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
2
3
4
5
6
struct Work {
1: i32 num1 = 0
2: required i32 num2
3: string op
4: optional string comment
}

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
2
3
4
exception InvalidOperation {
1: i32 what,
2: string why
}

服务

服务类似于接口,是一系列抽象函数的集合。函数的声明与 C 中文法类似,返回类型在最前,然后是函数名、参数列表(每个参数需要整型 id 标识)。一个例子如下所示,函数可以使用 oneway 标识符表示 client 发出请求后不必等待结果,也就是异步调用。

1
2
3
4
5
6
7
8
9
10
11
//“Twitter”与“{”之间需要有空格!!!
service Twitter {
// 方法定义方式类似于C语言中的方式,它有一个返回值,一系列参数和可选的异常
// 列表. 注意,参数列表和异常列表定义方式与结构体中域定义方式一致.
void ping() // 函数定义可以使用逗号或者分号标识结束
bool postTweet(1:Tweet tweet) // 参数可以是基本类型或者结构体,参数是只读的(const),不可以作为返回值!!!
TweetSearchResult searchTweets(1:string query) // 返回值可以是基本类型或者结构体
// ”oneway”标识符表示client发出请求后不必等待回复(非阻塞)直接进行下面的操作,
// ”oneway”方法的返回值必须是void
oneway void zip() // 返回值可以是void
}

service 可以通过 extends 关键字继承另一个 service。

枚举类型

使用 enum 关键字定义

1
2
3
4
5
6
enum TweetType {
TWEET, // 编译器默认从1开始赋值
RETWEET = 2, // 可以赋予某个常量某个整数
DM = 0xa, //允许常量是十六进制整数
REPLY // 末尾没有逗号
}

令人难过的是,Golang 中没有原生的枚举类型。

常量

使用 const 关键字定义,复杂的类型和结构体可使用 JSON 形式表示。

1
2
const i32 INT_CONST = 1234;    // 分号是可选的
const map<string,string> MAP_CONST = {"hello": "world", "goodnight": "moon"}

类型别名

与 C 类似,可以使用 typedef 关键字为类型声明别名

1
typedef i32 MyInteger 

命名空间

namespace 关键字,可以为每种语言单独声明,用于隔离代码(例如 Go module、Java package 等),语法如下:

1
2
namespace cpp com.example.project 
namespace java com.example.project

最佳实践

  • struct 中任何新加的字段都不能是 required,以防新旧版本不兼容出现的报错。
  • 不要修改 struct 中已有字段的 id 值。
  • 非必须的字段可以移除,但 id 值不要复用。更好的方式是使用 OBSOLETE_前缀标识已被废弃。
  • 可以更改默认值,但要牢记默认值不会序列化,接收方会使用自身的 idl 填充默认值,导致不一致。

Kitex

架构

Thrift 可以实现 idl 的定义与客户端 / 服务端代码生成,通过传输层和协议层完成 RPC 交互,但缺失 RPC 框架的高级特性,例如服务注册、发现、监控等。为此,字节结合内部的治理能力,开发了 Kitex框架,架构如下所示:

可以看到,Kitex 以模块化的方式,支持服务注册 / 发现、负载均衡、熔断、限流、重试、监控、链路跟踪、日志、诊断等服务治理模块,大部分均已提供默认扩展,使用者可选择集成。

此外,Kitex 还支持不同的第三方包,例如使用了自研的高性能网络库 Netpoll,通过 epoll + 协程池,提高网络 IO 性能。KItex 也支持多种消息协议,包含 ThriftKitex ProtobufgRPC 等,以及多种传输协议,包含 TTHeaderHTTP2 等。

快速上手

按照快速开始 | CloudWeGo 安装 Go 环境与 kitex 工具。

新建项目 kitex_started,新建如下的 idl 文件 hello.thrift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
namespace go hello_world

enum HelloType {
Morning = 1,
Noon = 2,
Afternoon = 3,
}

struct Request {
1: required i64 id,
2: required string message,
3: optional HelloType type,
}

struct Response {
1: required string message,
2: optional string error,
3: optional list<i64> ids,
}

struct AddRequest {
1: required i64 first,
2: required i64 second,
}

struct AddResponse {
1: required i64 sum,
}

service Hello {
Response echo(1: Request req),
AddResponse add(1: AddRequest req),
}

在 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
2
3
4
5
6
7
8
9
10
module kitex_started

go 1.21

replace github.com/apache/thrift => github.com/apache/thrift v0.13.0

require (
github.com/apache/thrift v0.13.0
github.com/cloudwego/kitex v0.8.0
)

接着,运行 go mod tidy 安装依赖,就不会有红线报错了。我们得到了如下的项目结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
❯ tree -r                                                                                                          
.
├── script
│   └── bootstrap.sh
├── main.go
├── kitex_info.yaml
├── kitex_gen
│   └── hello_world
│   ├── k-hello.go
│   ├── k-consts.go
│   ├── hello.go
│   └── hello
│   ├── server.go
│   ├── invoker.go
│   ├── hello.go
│   └── client.go
├── idl
│   └── hello.thrift
├── handler.go
├── go.sum
├── go.mod
└── build.sh

handler.go

服务的实现类,$HelloImpl 实现了 idl 中的 Hello 接口,不同的是,参数列表中增加了透传的 context,以及返回值中增加了 error。我们需要在这里实现业务逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"context"
hello_world "kitex_started/kitex_gen/hello_world"
)

// HelloImpl implements the last service interface defined in the IDL.
type HelloImpl struct{}

// Echo implements the HelloImpl interface.
func (s *HelloImpl) Echo(ctx context.Context, req *hello_world.Request) (resp *hello_world.Response, err error) {
// TODO: Your code here...
return
}

// Add implements the HelloImpl interface.
func (s *HelloImpl) Add(ctx context.Context, req *hello_world.AddRequest) (resp *hello_world.AddResponse, err error) {
// TODO: Your code here...
return
}

main.go

项目的入口,代码很简单,新建了一个 server 并开始运行。注意这里 import 的路径不同,虽然都被重命名为了 hello_world

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
hello_world "kitex_started/kitex_gen/hello_world/hello"
"log"
)

func main() {
svr := hello_world.NewServer(new(HelloImpl))

err := svr.Run()

if err != nil {
log.Println(err.Error())
}
}

kitex_info.yaml

包含了服务名和版本信息。

1
2
3
4
kitexinfo:
ServiceName: 'a.b.c'
ToolVersion: 'v0.8.0'

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
2
3
4
5
6
7
8
9
10
11
12
13
// Echo implements the HelloImpl interface.
func (s *HelloImpl) Echo(ctx context.Context, req *hello_world.Request) (resp *hello_world.Response, err error) {
// TODO: Your code here...
resp = &hello_world.Response{Message: fmt.Sprintf("Receive: %s", req.Message)}
return
}

// Add implements the HelloImpl interface.
func (s *HelloImpl) Add(ctx context.Context, req *hello_world.AddRequest) (resp *hello_world.AddResponse, err error) {
// TODO: Your code here...
resp = &hello_world.AddResponse{Sum: req.First + req.Second}
return
}

client/main.go 编写客户端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Copyright 2021 CloudWeGo Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

package main

import (
"context"
"kitex_started/kitex_gen/hello_world"
"kitex_started/kitex_gen/hello_world/hello"
"log"
"time"

"github.com/cloudwego/kitex/client"
)

func main() {
client, err := hello.NewClient("Hello", client.WithHostPorts("0.0.0.0:8888"))
if err != nil {
log.Fatal(err)
}
for {
req := &hello_world.Request{
Id: 1,
Message: "my request",
}
resp, err := client.Echo(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println(resp)
time.Sleep(time.Second)
addReq := &hello_world.AddRequest{First: 512, Second: 512}
addResp, err := client.Add(context.Background(), addReq)
if err != nil {
log.Fatal(err)
}
log.Println(addResp)
time.Sleep(time.Second)
}
}

kitex server 默认监听端口为 8888,可以通过 WithServiceAddr 配置。接着,在项目根目录运行 go run . 命令,可以观察到如下输出,证明服务端已启动,监听 8888 端口:

1
2
❯ go run .
2024/01/17 20:02:44.944884 server.go:83: [Info] KITEX: server listen at addr=[::]:8888

在另一个 shell 中运行 go run client/main.go,可以得到如下输出:

1
2
3
❯ go run client/main.go                                                                                             
2024/01/17 20:02:51 Response({Message:Receive: my request Error:<nil> Ids:[]})
2024/01/17 20:02:52 AddResponse({Sum:1024})

证实客户端请求成功到达服务端,并正确处理返回。

总结

本篇博客中,梳理了 Thrift 和 Kitex 的相关概念,通过一个简单的案例上手实践,分析项目结构并成功验证结果。作为框架的使用者,我们已经可以了解到基本用法,进一步深入原理是可选步骤。后面我会进一步分析请求的处理过程,来弄清楚易用性的背后,框架的底层运行机制是怎样的。

参考