NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

从 Kratos 设计看 Go 微服务工程实践

  • 2021-07-16
  • 本文字数:6307 字

    阅读完需:约 21 分钟

从Kratos设计看Go微服务工程实践

导读


github.com/go-kratos/kratos(以下简称 Kratos)是一套轻量级 Go 微服务框架,致力于提供完整的微服务研发体验,整合相关框架及周边工具后,微服务治理相关部分可对整体业务开发周期无感,从而更加聚焦于业务交付。Kratos 在设计之初就考虑到了高可扩展性,组件化,工程化,规范化等。对每位开发者而言,整套 Kratos 框架也是不错的学习仓库,可以了解和参考微服务的技术积累和经验。


接下来我们从 Protobuf开放性规范依赖注入 这 4 个点了解一下 Kratos 在 Go 微服务工程领域的实践。

基于 Protocol Buffers(Protobuf)的生态

在 Kratos 中,API 定义、gRPC Service、HTTP Service、请求参数校验、错误定义、Swagger API json、应用服务模版等都是基于 Protobuf IDL 来构建的:



举一个简单的 helloworld.proto 例子:


syntax = "proto3";
package helloworld;
import "google/api/annotations.proto";import "protoc-gen-openapiv2/options/annotations.proto";import "validate/validate.proto";import "errors/errors.proto";
option go_package = "github.com/go-kratos/kratos/examples/helloworld/helloworld";
// The greeting service definition.service Greeter {// Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) { option (google.api.http) = {// 定义一个HTTP GET 接口,并且把 name 映射到 HelloRequestget: "/helloworld/{name}", };// 添加API接口描述(swagger api)option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {description: "这是SayHello接口"; }; }}
// The request message containing the user's name.message HelloRequest {// 增加name字段参数校验,字符数需在1到16之间 string name = 1 [(validate.rules).string = {min_len: 1, max_len: 16}];}
// The response message containing the greetingsmessage HelloReply { string message = 1;}
enum ErrorReason {// 设置缺省错误码 option (errors.default_code) = 500;// 为某个错误枚举单独设置错误码 USER_NOT_FOUND = 0 [(errors.code) = 404]; CONTENT_MISSING = 1 [(errors.code) = 400];;}
复制代码


以上是一个简单的 helloworld 服务定义的例子,这里我们定义了一个 Service 叫 Greeter,给 Greeter 添加了一个 SayHello 的接口,并根据 googleapis 规范给这个接口添加了 Restful 风格的 HTTP 接口定义,然后还利用 openapiv2 添加了接口的 Swagger API 描述,同时还给请求消息结构体 HelloRequest 中的 name 字段加上了参数校验,最后我们在文件的末尾还定义了这个服务可能返回的错误码。


这时我们在终端中执行:kratos proto client api/helloworld/ helloworld.proto 便可以生成以下文件:



由上,我们看到 Kraots 脚手架工具帮我们一键生成了上面提到的能力。从这个例子中,我们可以直观感受到使用使用 Protobuf 带来的开发效率的提升,除此之外 Kratos 还有以下优点:


  • 清晰:做到了定义即文档,定义即代码

  • 收敛,统一:将逻辑都收敛统一到一起,通过代码生成工具来保证 HTTP Service、grpc Service 等功能具有一致的行为

  • 跨语言:众所周知 Protobuf 是跨语言的,java、go、python、php、js、c 等等主流语言都支持

  • 拥抱开源生态:比如 Kratos 复用了 google.http.api、protoc-gen-openapiv2、protoc-gen-validate 等等一些犀利的 Protobuf 周边生态工具或规范,这比起自己造一个 IDL 的轮子要容易维护得多,同时老的使用这些轮子的 gRPC 项目迁移成本也更低

开放性

一个基础框架在设计的时候就要考虑未来的可扩展性,那 Kratos 是怎么做的呢?

1. Server Transport

我们先看下服务协议层的代码:



上面是 Kratos RPC 服务协议层的接口定义,这里我们可以看到如果想要给 Kratos 新增一个新的服务协议,只要实现 Start()、Stop()、Endpoint()这几个方法即可。这样的设计解耦了应用和服务协议层的实现,使得扩展服务协议更加方便。



从上图中我们可以看到 App 层无需关心底层服务协议的实现,只是一个容器管理好应用配置、服务生命周期、加载顺序即可。

2. Log

我们再看一个 Kratos 日志模块的设计:



这里 Kratos 定义了一个日志输出接口 Logger,它的设计的非常简单 - 只用了一个方法、两个输入、一个输出。我们知道一个包暴露的接口越少,越容易维护,同时对使用和实现方的心智负担更小,扩展日志实现会变得更容易。但问题来了,这个接口从功能上来讲似乎只能输出日志 level 和固定的 kv paris,如何能支持更高级的功能?比如输出 caller stack、实时 timestamp、 context traceID ?这里我们定义了一个回调接口 Valuer:



这个 Valuer 可以被当作 key/value pairs 中的 value 被 Append 到日志里,并被实时调用。


我们看一下如何给日志加时间戳的 Valuer 实现:



使用时只要在原始的 logger 上再 append 一个固定的 key 和一个动态的 valuer 即可:



这里的 With 是一个 Helper function,里面 new 了一个新的 logger(也实现了 Logger 接口),并将 key\value pairs 暂存在新的 logger 里,等到 Log 方法被调用时再通过断言.(Valuer)的方式获取值并输出给底层原始的 logger。


所以我们可以看到仅仅通过两个简单的接口+一个 Helper function 的组合我们就实现了日志的大多数功能,这样大大提高了可扩展性。实际上还有日志过滤、多日志源输出等功能也是通过组合使用这两接口来实现,这里待下次分享再展开细讲。

3. Tracing

最后我们来看下 Kratos 的 Tracing 组件,这里 Kratos 采用的是 CNCF 项目 OpenTelemetry。


OpenTelemetry 在设计之初就考虑到了组件化和高可扩展性,其实现了 OpenTracing 和 W3C Trace Context 的规范,可以无缝对接 zipkin、jaeger 等主流开源 tracing 系统,并且可以自定义 Propagator 和 TraceProvider。通过 otel.SetTracerProvider()我们可以轻易得替换 Span 的落地协议和格式,从而兼容老系统中的 trace 采集 agent;通过 otel.SetTextMapPropagtor()我们可以替换 Span 在 RPC 中的 Encoding 协议,从而可以和老系统中的服务互相调用时也能兼容。

工程流程

我们知道在工程实践的时候,强规范和约束往往比自由和更多的选择更有优势,那么在 Go 工程规范这块我这里主要介绍三块:

1. 面向包的设计规范

Go 是一个面向包名设计的语言,Package 在 Go 程序中主要起到功能隔离的作用,标准库就是很好的设计范例。Kratos 也是可以按包进行组织代码结构,这里我们抽取 Kratos 根目录下主要几个 Package 包来看下:


/cmd:可以通过 go install 一键安装生成工具,使用户更加方便地使用框架。


/api:Kratos 框架本身的暴露的接口定义


/errors:统一的业务错误封装,方便返回错误码和业务原因。


/config:支持多数据源方式,进行配置合并铺平,通过 Atomic 方式支热更配置。


/internal :存放对外不可见或者不稳定的接口。


/transport:服务协议层(HTTP/gRPC)的抽象封装,可以方便获取对应的接口信息。


/middleware:中间件抽象接口,主要跟 transport 和 service 之间的桥梁适配器。


/third_party:第三方外部的依赖


可以看到 Kratos 的包命名清晰简短,按功能进行划分,每个包具有唯一的职责。


在设计包时我们还需要考虑到以下几点:


  • 包的设计必须以使用者为中心,直观且易于使用,包的命名必须旨在描述它提供的内容,如果包的名称不能立即暗示这一点,则它可能包含一组零散的功能。

  • 包的目的是为特定问题域而提供的,为了有目的,包必须提供,而不是包含。包不能成为不同问题域的聚合地,随着时间的推移,它将影响项目的简洁和重构、适应、扩展和分离的能力。

  • 高便携性,尽量减少依赖其他代码库,一个包与其它包依赖越少,一个包的可重用性就越高。

  • 不能成为单点依赖,当包被单一的依赖点时,就像一个公共包(common),会给项目带来很高的耦合性。

2. 配置

首先,我们来看下常见的基础框架是怎么初始化配置的:



这是 Go 标准库 HTTP Server 配置初始化的例子,但是这样做会有如下几个问题:


  • &http.Server{}由于是一个取址引用,里面的参数可能会被外部运行时修改,这种运行时修改带来的危害是不可把控的。

  • 无法区分 nil 和 0 值,当里面的参数值为 0 的时候,不知道是用户未设置还是就是被设置成了 0。

  • 难以分辨必传和选传参数,只能通过文档说明来隐式约定,没有强约束力。


那么 Kraots 是怎么解决这些问题的呢?答案就是 Functional Options 。我们看下 transport/http/client.go 的代码:



Client.go 中定义了一个回调函数 ClientOption,该函数接受一个定义了一个存放实际配置的未导出结构体 clientOptions 的指针,然后我们在 NewClient 的时候,使用可变参数进行传递,然后再初始化函数内部通过 for 循环调用修改相关的配置。


这么做有这么几个好处:


  • 由于 clientOptions 结构体是未导出的,那么就不存在被外部修改的可能。

  • 可以区分 0 值和未设置,首先我们在 new clientOptions 时会设置默认参数,那么如果外部没有传递相应的 Option 就不会修改这个默认参数。

  • 必选参数显示定义,可选值则通过 Go 可变参数进行传递,很好的区分必传和选传。

3. Error 规范

Kratos 为微服务提供了统一的 Error 模型:



  • Code 用作外部展示和初步判断,服务端无需定义大量全局唯一的 XXX_NOT_FOUND,而是使用一个标准 Code.NOT_FOUND 错误代码并告诉客户端找不到某个资源。错误空间变小降低了文档的复杂性,在客户端库中提供了更好的惯用映射,并降低了客户端的逻辑复杂性。同时这种标准的大类 Code 的存在也对外部的观测系统更友好,比如可以通过分析 Nginx Access Log 中的 HTTP StatusCode 来做服务端监控和告警。

  • Reason 是具体的错误原因,可以用来更详细的错误判定。每个微服务都会定义自己 Reason,那么要保持全局唯一就需要加上领域前缀,比如 User_XXX。

  • Message 错误信息可以帮助用户轻松快捷地理解和解决 API 错误

  • Metadata 中则可以存放一些标准的错误详情,比如 retryInfo、error stack 等

  • 这种强制规范,避免了开发人员直接透传 Go 的 error 从而导致一些敏感信息泄露。


接下来我们看下 Error 结构体还实现了哪些接口:



  • 实现了 GRPCStatus () *status.Status 接口,这样就实现了从 http status code 到 grpc status code 的转换,这样 Kratos Error 可以被 gRPC 直接转成 google.rpc.Status 传递出去。

  • 实现了标准库 errors 包的 Is (error) bool 接口,这样使用者可以直接调用 errors.Is()来比较两个 erorr 中的 reason 是否相等,避免了使用==来直接判断 error 是否相等这种错误姿势。

依赖注入

依赖注入 (Dependency Injection)可以理解为一种代码的构造模式,按照这样的方式来写,能够让你的代码更加容易维护,一般在 Java 的项目中见到的比较多。


依赖注入初看起来比较违反直觉,那么为什么 Go 也需要依赖注入?假设我们要实现一个用户访问计数的功能。我们先看看不使用依赖注入的项目代码:


type Service struct {    redisCli *redis.Client}
func (s *Service) AddUserCount(ctx context.Context) { //do some business logic s.redisCli.Incr(ctx, "user_count")}
func NewService(cfg *redis.Options) *Service { return &Service{redisCli: redis.NewClient(cfg)}}
复制代码


这种方式比较常见,在项目刚开始或者规模小的时候没什么问题,但我们如果考虑下面这些因素:


  • Redis 是基础组件,往往会在项目的很多地方被依赖,那么如果哪天我们想整体修改 redis sdk 的甚至想把 redis 整体替换成 mysql 时,需要在每个被用到的地方都进行修改,耗时耗力还容易出错。

  • 很难对 App 这个类写单元测试,因为我们需要创建一个真实的 redis.Client。


使用依赖注入改造后的 Service:


type DataSource interface{    Incr(context.Context, string)}
type Service struct { dataSource DataSource}
func (s *Service) AddUserCount(ctx context.Context) { //do some business logic s.dataSource.Incr(ctx, "user_count")}
func NewService(ds DataSource) *Service { return &Service{dataSource: ds}}
复制代码


上面代码中我们把*redis.Client 实体替换成了一个 DataSource 接口,同时不控制 dataSource 的创建和销毁,把 dataSource 生命周期控制权交给了上层来处理,以上操作有三个主要原因:


  • 因为 Service 层已不再关心 dataSource 的创建和销毁,这样当我们需要修改 dataSource 实现的时候,只要在上层统一修改即可,无需在各个被依赖的地方一一修改。

  • 因为依赖的是一个接口,我们写单元测试的时候只要传递一个 mock 后的 Datasource 实现即可 。

  • 这里 dataSource 这个基础组件不再被会到处创建,可以做到复用一个单例节省资源开销。


Go 的依赖注入框架有两类,一类是通过反射在运行时进行依赖注入,典型代表是 uber 开源的 dig,另外一类是通过 generate 进行代码生成,典型代表是 Google 开源的 wire。使用 dig 功能会强大一些,但是缺点就是错误只能在运行时才能发现,这样如果不小心的话可能会导致一些隐藏的 bug 出现。使用 wire 的缺点就是功能限制多一些,但是好处就是编译的时候就可以发现问题,并且生成的代码其实和我们自己手写相关代码差不太多,更符合直觉,心智负担更小。所以 Kratos 更加推荐 wire,Kratos 的默认项目模板中 kratos-layout 也正是使用了 google/wire 进行依赖注入。


我们来看下 wire 使用方式:


我们首先要定义一个 ProviderSet,这个 Set 会返回构建依赖关系所需的组件 Provider。如下所示,Provider 往往是一些简单的工厂函数,这些函数不会太复杂:


type RedisSource struct {    redisCli *redis.Client}
// RedisSource实现了Datasource的Incr接口func (ds *RedisSource) Incr(ctx context.Context, key string) { ds.redisCli.Incr(ctx, key)}
// 构建实现了DataSource接口的Providerfunc NewRedisSource(db *redis.Client) *RedisSource { return &RedisSource{redisCli: db}}
// 构建*redis.Client的Providerfunc NewRedis(cfg *redis.Options) *redis.Client { return redis.NewClient(cfg)}// 这是一个Provider的集合,告诉wire这个包提供了哪些Providervar ProviderSet = wire.NewSet(NewRedis, NewRedisSource)
复制代码


接着我们要在应用启动处新建一个 wire.go 文件并定义 Injector,Injctor 会分析依赖关系并将 Provider 串联起来构建出最终的 Service:


// +build wireinject
func initService(cfg *redis.Options) *service.Service { panic(wire.Build( redisSource.ProviderSet,//使用 wire.Bind 将 Struct 和接口进行绑定了,表示这个结构体实现了这个接口,wire.Bind(new(data.DataSource), new(*redisSource.RedisSource)), service.NewService), )}
复制代码


最后执行 wire .后自动生成的代码如下:


//go:generate go run github.com/google/wire/cmd/wire//+build !wireinject
func initService(cfg *redis.Options) *service.Service { client := redis2.NewRedis(cfg) redisSource := redis2.NewRedisSource(client) serviceService := service.NewService(redisSource) return serviceService}
复制代码


由此我们可以看到只要定义好组件初始化的 Provider 函数,还有把这些 Provider 组装在一起的 Injector 就可以直接生成初始化链路代码了,上手还是相对简单的,生成的代码所见即所得,容易 Debug。


综上可见,Kratos 是一款凝结了开源社区力量以及 Go 同学们大量微服务工程实践后诞生的一款微服务框架。现在腾讯云微服务治理治理平台(微服务平台 TSF)也已支持 Kratos 框架,给 Kratos 赋予了更多企业级服务治理能力、提供多维度服务,如:应用生命周期托管、一键上云、私有化部署、多语言发布。


作者介绍


曹国梁:6 年 Go 微服务研发经历,腾讯云高级研发工程师,Kratos Maintainer,gRPC-go contributor


本文转载自公众号腾讯云中间件(ID:gh_6ea1bc2dd5fd)。


原文链接


从Kratos设计看Go微服务工程实践

2021-07-16 15:308479

评论

发布
暂无评论
发现更多内容

SRv6网络典型部署场景

穿过生命散发芬芳

8月月更 SRv6

Kubernetes故障排查eBPF

CTO技术共享

开源 ebpf 签约计划第三季 8月月更

百家号打击挂载恶意导流链接行为,必须严厉打击恶意挂链灰产

石头IT视角

Kubernetes 调度器优化

CTO技术共享

开源 Kubernetes 集群 签约计划第三季

Kubernetes证书过期怎么玩

CTO技术共享

开源 签约计划第三季 8月月更

Kubernetes 实现灰度和蓝绿发布

CTO技术共享

开源 灰度发布 蓝绿发布 签约计划第三季 8月月更

Kubernetes Cilium展示

CTO技术共享

开源 cilium Kubernetes 集群 签约计划第三季

关于在谷歌浏览器,vue-video-player 实现断点续播,currentTime不生效问题。

泉城老铁

Go-Excelize API源码阅读(一)——NewFile()

Regan Yue

Go 开源 源码刨析 8月月更

电动汽车充电站的部署优化策略

乌龟哥哥

8月月更

如何克服紧张

踏雪痕

头脑风暴:零钱兑换

HelloWorld杰少

8月月更

钝感力与自我和解

Amazing_eve

#开源

数据库日增20万条数据,用读写分离和分库分表加持破它

知识浅谈

8月月更

Service Mesh迁移原则

阿泽🧸

Service Mesh 8月月更

文本词频统计的利器 Trie树

Five

c 算法题 8月月更

【ELT.ZIP】OpenHarmony啃论文俱乐部——学术科研方法论沉淀辑

ELT.ZIP

方法论 OpenHarmony ELT.ZIP 啃论文

一起学习集合框架之 TreeSet

宇宙之一粟

Java 8月月更

Kubernetes网络模型

CTO技术共享

开源 Kubernetes 集群 签约计划第三季 8月月更

Kubernetes构建Redis 集群

CTO技术共享

redis 开源 签约计划第三季 8月月更

Android 应用安全机制实现方案探究

No Silver Bullet

android 签约计划第三季 8月月更 安全机制

关于 SAP UI5 floating footer 显示与否的单步调试以及使用 SAP UI5 的收益

Jerry Wang

前端开发 SAP SAP UI5 ui5 8月月更

Kubernetes信息安全

CTO技术共享

开源 信息安全 Kubernetes 集群 签约计划第三季 8月月更

Python 教程之输入输出(5)—— input() 函数中的漏洞 – Python 2.x

海拥(haiyong.site)

Python 8月月更

gulp 的常用 API

Jason199

js gulp 8月月更

系统管理-Linux重定向与管道

Albert Edison

Linux centos 运维 服务器 8月月更

开源一夏 | jQuery 密码验证和深入理解JSONP【前端jQuery框架】

恒山其若陋兮

开源 8月月更

Angular 为什么要引入 injection token 的概念

Jerry Wang

前端开发 angular web开发 依赖注入 8月月更

SRE运维解密-服务质量目标:SLI,SLO,SLA

董哥的黑板报

微服务 运维 云原生 SRE Google

C++ 中的四种智能指针

桑榆

c++ 8月月更

Kubernetes内存泄露怎么玩

CTO技术共享

开源 内存泄漏 签约计划第三季 8月月更

从Kratos设计看Go微服务工程实践_开源_腾讯云中间件_InfoQ精选文章