速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

从 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:308891

评论

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

开源交流丨一站式大数据平台运维管家ChengYing安装原理剖析

袋鼠云数栈

走向云原生数据库 - 使用 Babelfish 加速迁移 SQL Server 的代码实践

亚马逊云科技 (Amazon Web Services)

数据库 云原生

MobLink Android端业务场景简单说明

MobTech袤博科技

android 开发者

赋能企业敏捷开发的低代码平台

力软低代码开发平台

传统BI需要一次新的「革命」

ToB行业头条

【联通】数据编排技术在联通的应用

Alluxio

中国联通 Alluxio 大数据 开源 数据编排 9月月更

英特尔将推出第四代至强可扩展服务器,为高性能计算、人工智能和网络提供全方位加速服务

科技之家

软件测试 | 测试开发 | 测试人生 | 00后拿下了名企大厂 offer,这个后浪学习之路全公开

测吧(北京)科技有限公司

软件测试 测试

软件测试 | 测试开发 | Hybird app开发入门之Native和H5页面交互原理

测吧(北京)科技有限公司

软件测试

入驻快讯|欢迎 SelectDB 正式入驻 InfoQ 写作社区!

SelectDB

数据库 大数据 OLAP Doris 企业号九月金秋榜

软件测试 | 测试开发 | 使用Fastmonkey进行iosMonkey测试初探

测吧(北京)科技有限公司

测试 软件测试和开发

软件测试 | 测试开发 | Redis Zset Score精度问题

测吧(北京)科技有限公司

redis 软件测试 测试

软件测试 | 测试开发 | 接口测试实战 | Android 高版本无法抓取 HTTPS,怎么办?

测吧(北京)科技有限公司

https 测试 自动化测试

ShareSDK Android端渠道下载统计配置说明

MobTech袤博科技

android sdk

终于有人把不同标签的加工内容与落库讲明白了丨DTVision分析洞察篇

袋鼠云数栈

阿里巴巴数字商业知识图谱的构建及应用

阿里技术

人工智能 机器学习 知识图谱

软件测试 | 测试开发 | MySQL锁机制总结

测吧(北京)科技有限公司

MySQL 测试

软件测试 | 测试开发 | 高性能高维向量的KNN搜索方案

测吧(北京)科技有限公司

软件测试 测试

阿里P8手写Spring Cloud Alibaba实战学习手册,架构师养成必备!

了不起的程序猿

Java spring SpringCloud java程序员 java编程

软件测试 | 测试开发 | 因服务器时间不同步引起的异常

测吧(北京)科技有限公司

软件测试 测试

2021年中国人工智能软件及服务市场规模超千亿,认知智能增速显著

易观分析

人工智能

抖音二面:计算机网络-应用层

Java快了!

计算机网络

直播预告 | PolarDB 开源人才培初级考试备考辅导公开课

阿里云数据库开源

数据库 阿里云 开源 人才培养 polarDB

普适性强的ERP/MES系统为什么难选?4种挑选方案教你避坑

优秀

MES系统 mes ERP系统

软件测试 | 测试开发 | Uiautomator项目搭建与实现原理

测吧(北京)科技有限公司

软件测试 测试

软件测试 | 测试开发 | Python数据驱动测试 unittest+ddt

测吧(北京)科技有限公司

Python 软件测试

云堡垒机和信创堡垒机主要区别讲解

行云管家

云计算 信创 堡垒机 云堡垒机

详谈 MySQL 8.0 原子 DDL 原理

RadonDB

MySQL 数据库

雪上加霜,运维部门裁员后,中了勒索病毒……

嘉为蓝鲸

运维 故障 病毒 变更

软件测试 | 测试开发 | Android 10 来袭

测吧(北京)科技有限公司

android Android开发

软件测试 | 测试开发 | Linux下的Nginx内存泄露定位

测吧(北京)科技有限公司

nginx Liunx 测试开发

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