QCon全球软件开发大会9折优惠倒计时,了解详情 了解详情
写点什么

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

2021 年 7 月 16 日

从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 年 7 月 16 日 15:302049

评论

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

架构师训练营第四周

Melo

WPF中的Data Binding调试指南

大白技术控

.net 微软 WPF

Docker基础修炼2--Docker镜像原理及常用命令

黑马腾云

Docker Linux 容器 运维 镜像

区块链系列教程之:比特币中的挖矿

程序那些事

比特币 区块链 挖矿

2020年6月26日 查询性能优化

瑞克与莫迪

MySQL InnoDB 存储引擎 - 锁

Arthur

从0开始设计Flutter独立APP | 第一篇: 数据库与状态管理

渔子长

flutter 前端 跨平台

ARTS week3

姜海天

二叉树深度优先遍历

封不羁

Java 算法 二叉树

抖音、腾讯、阿里、美团春招服务端开发岗位硬核面试(完结)

aoho

面试 后端 阿里

Why Spring ???

猴哥一一 cium

Java spring 源码 Spring Boot 框架设计

​外包公司干了不到3个月,我离职了...(防坑指南)

程序员生活志

程序员 外包 工作经历

面试官:我们来聊下锁吧

java金融

Java 乐观锁 悲观锁

架构师训练营第三周学习总结

lwy

极客大学架构师训练营 系统架构 第7课 听课总结

John(易筋)

极客时间 系统架构 高并发 极客大学 极客大学架构师训练营

架构师训练营第四周-总结

无心水

极客大学架构师训练营

近两年流行面试题:Spring循环依赖问题

Java小咖秀

spring 面试 ioc

[译]都0202年了,你还觉得go-scheduler很难理解吗?

卓丁

Go golang scheduler GPM goroutines

架构师训练营第三周命题作业

lwy

极客大学架构师训练营

架构训练营第四周 - 作业

无心水

极客大学架构师训练营

架构师第4周

上山砍柴

极客大学架构师训练营

Oracle SQL调优系列之看懂执行计划explain

Nicky.Ma

sql

区块链的应用为什么这么难?出路在哪?

CECBC区块链专委会

比特币 区块链技术 Token 联盟共识

测试阶段发现缺陷多怎么办?

洪永潮

ARTS WEEK4

紫枫

ARTS 打卡计划

极客大学架构师训练营 框架开发 模式与重构 JUnit、Spring、Hive核心源码解析 第6课

John(易筋)

spring 极客时间 极客大学 极客大学架构师训练营 JUnit

基于阿里云服务网格(ASM)的GRPC服务部署实践

韩陆

Kubernetes gRPC Service Mesh

【总结】企业级案例驱动 打造高可用、高并发、多IDC部署业务中台微服务架构

魔曦

极客大学架构师训练营

过早优化是万恶之源

非著名程序员

程序员 程序人生 提升认知

新手村:Redis基础补充知识

多选参数

数据库 redis 数据库设计 redis6.0.0

创业一定要学投资

Neco.W

创业 投资

数据库运维技术发展与展望

数据库运维技术发展与展望

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