RPC 微服务框架 Kitex 的入门实践
摘要
Kitex 是字节跳动开源的 Golang 微服务 RPC 框架,基于 Apache Thrift,具有高性能、强可扩展的特点,在字节内部已广泛使用。本篇是 Kitex 入门系列的第一篇,梳理相关的概念,并从案例上手实践。
Thrift
Thrift
是一个轻量级、跨语言的远程服务调用(Remove Procedure Call,RPC)框架,最初由 Facebook
开发,后面进入 Apache
开源项目。它通过自身的 IDL
中间语言(Interface Description Language,接口描述语言), 并借助代码生成引擎生成各种主流语言的 RPC
服务端 / 客户端模板代码。
Thrift 的一大优势就是可以跨语言代码生成,支持 C++, Java, Python, PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, JavaScript, Node.js 等语言。
架构
Thrift API 的客户端 - 服务端架构图如下所示:
- 顶层是根据 IDL 生成的客户端 / 服务端代码
- 协议层(TProtocol)定义了序列化和反序列化的协议,包含 TBinaryProtocol (二进制协议)、TCompactProtocol (压缩二进制协议)、TJSONProtocol(JSON 文本)、TSimpleJSONProtocol 等
- 传输层(TTransport)定义了网络传输的方式,基于 TCP/IP 协议,包含 TSocket(阻塞 Socket)、TSimpleFileTransport(文件传输)、TFramedTransport(帧传输)等
服务端模型
Thrift 支持以下几种服务端模型:
- TSimpleServer:简单的单线程服务模型,常用于测试;
- TThreadedServer:多线程服务模型,每个连接新建线程,使用标准阻塞 IO;
- TThreadPoolServer:多线程服务模型,使用线程池,使用标准的阻塞式 IO;
- TNonblockingServer:多线程服务模型,使用非阻塞式 IO(需使用 TFramedTransport 数据传输方式);
IDL
基础数据类型
- bool:布尔型(true or false);
- byte:8bit、有符号整型。
- i16、i32、i64:16/32/64 bit 有符号整型。
- double:64 位浮点数
- string:UTF-8 字符串
没有定义无符号整数类型,因为很多语言中没有原生的无符号整数类型。
特殊类型
- binary:字节序列
结构体
使用 struct 定义,一系列字段的集合,不支持继承。一个例子如下所示。结构体中每个字段需要用唯一的整型标识,用于序列化与反序列化,可以通过 required/optional
关键字定义字段是否必须,可以用 =
指定默认值。
1 | struct Work { |
Thrift 支持以下三种字段必要性标识,决定了序列化和反序列化的行为:
required
- 写:字段总是被写入,需要被赋值
- 读:字段总是被读取,输入流中必须包含,如果缺失则会抛出异常或者返回 error
- 默认值:总是被写入
- required 字段会限制版本的平滑过渡。如果新增 / 移除了 required 字段,都可能导致读取失败。
optional
- 写:只有被设置了才会被写入
- 读:可能出现在输入流中,也可以不存在
- 默认值:只有当设置了
isset
标识才会写入 - 很多语言的实现中,使用
isset
标识可选字段是否被赋值。只有设置了这个标识的字段才会被写入,反过来只有输入流中存在该字段才会设置isset
default(隐式默认)
- 写:理论上,总是被写入,除非特殊情况
- 读:与 optional 类似,可能出现在输入流中,也可以不存在
- 默认值:可能不会被写入
default 类似 required 和 optional 的混合,取决于具体的实现。例如,实现中可以不写入默认值,因为假设读取时会自动补充默认值;也可以写入默认值,没有限制不能这么做。
需要注意的是,未写入的默认值成为了 idl 版本的一部分,如果后续默认值出现了变更,接口就会发生变化。而如果默认值被写入了,即使 IDL 中的默认值改变,也不会影响序列化数据。
- 也就是说,不把默认值写入,读取侧只能依赖于 idl 中的默认值填充。写入了默认值,读取侧就可以只从数据中读取了。
容器
- map<type1,type2>:键值字典
- list<type>:有序列表
- set<type>:无序集合
理论上,容器的类型可以是任意合法的 Thrift 类型。但为了兼容性考虑,map 的 key 应当是基础类型。这是由于一些语言不允许复杂的键类型。此外,JSON 中也只支持基础类型的 key。
异常
异常在语法和功能上类似于结构体,只不过异常使用关键字 exception 而不是 struct 关键字声明。但它在语义上不同于结构体,当定义一个 RPC 服务时,开发者可能需要声明一个远程方法抛出一个异常。在 Golang 中,不使用异常机制,因此可以忽略。
1 | exception InvalidOperation { |
服务
服务类似于接口,是一系列抽象函数的集合。函数的声明与 C 中文法类似,返回类型在最前,然后是函数名、参数列表(每个参数需要整型 id 标识)。一个例子如下所示,函数可以使用 oneway
标识符表示 client 发出请求后不必等待结果,也就是异步调用。
1 | //“Twitter”与“{”之间需要有空格!!! |
service 可以通过 extends
关键字继承另一个 service。
枚举类型
使用 enum
关键字定义
1 | enum TweetType { |
令人难过的是,Golang 中没有原生的枚举类型。
常量
使用 const
关键字定义,复杂的类型和结构体可使用 JSON 形式表示。
1 | const i32 INT_CONST = 1234; // 分号是可选的 |
类型别名
与 C 类似,可以使用 typedef
关键字为类型声明别名
1 | typedef i32 MyInteger |
命名空间
namespace
关键字,可以为每种语言单独声明,用于隔离代码(例如 Go module、Java package 等),语法如下:
1 | namespace cpp com.example.project |
最佳实践
- struct 中任何新加的字段都不能是
required
,以防新旧版本不兼容出现的报错。 - 不要修改 struct 中已有字段的 id 值。
- 非必须的字段可以移除,但 id 值不要复用。更好的方式是使用
OBSOLETE_
前缀标识已被废弃。 - 可以更改默认值,但要牢记默认值不会序列化,接收方会使用自身的 idl 填充默认值,导致不一致。
Kitex
架构
Thrift 可以实现 idl 的定义与客户端 / 服务端代码生成,通过传输层和协议层完成 RPC 交互,但缺失 RPC 框架的高级特性,例如服务注册、发现、监控等。为此,字节结合内部的治理能力,开发了 Kitex
框架,架构如下所示:
可以看到,Kitex 以模块化的方式,支持服务注册 / 发现、负载均衡、熔断、限流、重试、监控、链路跟踪、日志、诊断等服务治理模块,大部分均已提供默认扩展,使用者可选择集成。
此外,Kitex 还支持不同的第三方包,例如使用了自研的高性能网络库 Netpoll,通过 epoll + 协程池,提高网络 IO 性能。KItex 也支持多种消息协议,包含 Thrift、Kitex Protobuf、gRPC 等,以及多种传输协议,包含 TTHeader、HTTP2 等。
快速上手
按照快速开始 | CloudWeGo 安装 Go 环境与 kitex 工具。
新建项目 kitex_started
,新建如下的 idl 文件 hello.thrift
:
1 | namespace go hello_world |
在 idl 中,定义了一个服务,两个函数。然后使用如下命令进行代码生成,-module
用于指定包名,需要与 go.mod
中的一致,-service
表示生成服务端代码,并指定了服务名,最后是 idl 的路径。
1 | kitex -module "kitex_started" -service a.b.c hello.thrift |
接着会发现,go.mod
包含以下内容,注意 replace 一行是由于 kitex 使用的是 0.13.0 版本的 thrift,高版本的不兼容。
1 | module kitex_started |
接着,运行 go mod tidy
安装依赖,就不会有红线报错了。我们得到了如下的项目结构:
1 | ❯ tree -r |
handler.go
服务的实现类,$HelloImpl
实现了 idl 中的 Hello
接口,不同的是,参数列表中增加了透传的 context,以及返回值中增加了 error。我们需要在这里实现业务逻辑。
1 | package main |
main.go
项目的入口,代码很简单,新建了一个 server 并开始运行。注意这里 import 的路径不同,虽然都被重命名为了 hello_world
1 | package main |
kitex_info.yaml
包含了服务名和版本信息。
1 | kitexinfo: |
kitex_gen
hello_world
包(idl 中的 namespace)下,有以下文件 / 目录:
hello.go
:定义了 idl 中的各种类型,例如HelloType,Request,Response
等,提供了Get/Set, Read/Write
方法k-consts.go
:没什么用k-hello.go
:各种类型的 FastRead/FastWrite 编解码实现,性能更好hello/
:Hello Service 相关代码client.go
:暴露NewClient
函数,新建实现了Hello
接口的 Client 对象hello.go
:暴露NewServiceInfo
函数,Service 信息,注意服务名是 idl 中的 Service,即Hello
server.go
:暴露NewServer
函数,用于启动服务invoker.go
:暴露NewInvoker
函数,没有找到用法
分析目录结构可以看出,当我们需要新建某个 Service 的 Client/Server 时,需要 import 对应的 Service 包,使用 NewClient/NewServer
函数;如果只是使用 idl 中定义的结构体,只需要 import 对应 namespace 的包即可。这也解释了上面 import 路径的不同。
运行
填好 handler 逻辑:
1 | // Echo implements the HelloImpl interface. |
在 client/main.go
编写客户端代码:
1 | // Copyright 2021 CloudWeGo Authors |
kitex server 默认监听端口为 8888,可以通过 WithServiceAddr
配置。接着,在项目根目录运行 go run .
命令,可以观察到如下输出,证明服务端已启动,监听 8888 端口:
1 | ❯ go run . |
在另一个 shell 中运行 go run client/main.go
,可以得到如下输出:
1 | ❯ go run client/main.go |
证实客户端请求成功到达服务端,并正确处理返回。
总结
本篇博客中,梳理了 Thrift 和 Kitex 的相关概念,通过一个简单的案例上手实践,分析项目结构并成功验证结果。作为框架的使用者,我们已经可以了解到基本用法,进一步深入原理是可选步骤。后面我会进一步分析请求的处理过程,来弄清楚易用性的背后,框架的底层运行机制是怎样的。