一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情。
KiteX 是字节跳动框架组研发的下一代高性能、强可扩展性的 Go RPC 框架。目前已经在 Github 开源。有兴趣的小伙伴可以看看 字节跳动 Go RPC 框架 KiteX 性能优化实践。作为字节跳动内部原有的 RPC 框架 Kite 的升级版,虽然开源时间不是很长,但其实从性能和扩展性上来说都已经在字节上万微服务的场景下做了充分的验证。
近期花了一些时间简单看了看源码,被整体的框架设计和扩展性深深吸引。希望能够单独开一个系列,带大家一起熟悉Kitex的使用,通过源码了解其提供的能力,学习整体的设计。
Github 仓库:github.com/cloudwego/k…
官方 example:github.com/cloudwego/k…
作为入门系列第一篇,今天我们先来看看如何上手。基于 kitex 0.2.1 版本。
准备工作
- 先准备好本地的 Golang 环境,设置好 GOPATH,建议使用较新的 Go 版本。
- 安装 kitex 和 thriftgo 两个命令行工具
go install github.com/cloudwego/kitex/tool/cmd/kitex@latest
go install github.com/cloudwego/thriftgo@latest
这一步完成后,可以打开命令行运行 kitex --version 和 thriftgo --version 进行验证。
Server
创建本地仓库
我在Github上创建了公开的仓库,和这个系列文章同步更新:github.com/ag9920/kstu… ,克隆到本地后开始我们今天的开发。
创建IDL文件
Kitex 典型的使用场景就是基于 thrift 定义的 IDL 来定义服务接口,实现客户端和服务端的通信。
这里我们新建一个demo的服务定义: kstudy.thrift
namespace go api
struct Request {
1: string message1
2: string message2
}
struct Response {
1: string message
}
service KStudy {
Response Concat(1: Request req)
}
可以看到,我们定义了一个KStudy服务,包含一个Concat接口,语义是将 Request 中的 message1 和 message2 两个字符串拼接起来,通过 Response 返回。
使用命令行工具生成代码
kitex 工具提供了很多选项扩展,可以通过 --help 来了解。
kitex --help
Version v0.2.0
Usage: kitex [flags] IDL
flags:
-I value
Add an IDL search path for includes.
-combine-service
Combine services in root thrift file.
-copy-idl
Copy each IDL file to the output path.
-invoker
Generate invoker side codes when service name is specified.
-module string
Specify the Go module name to generate go.mod.
-no-fast-api
Generate codes without injecting fast method.
-protobuf value
Specify arguments for the protobuf compiler.
-service string
Specify the service name to generate server side codes.
-thrift value
Specify arguments for the thrift compiler.
-thrift-plugin value
Specify thrift plugin arguments for the thrift compiler.
-type string
Specify the type of IDL: 'thrift' or 'protobuf'. (default "thrift")
-use string
Specify the kitex_gen package to import when generate server side codes.
-v
-verbose
Turn on verbose mode.
-version
Show the version of kitex
作为入门,我们只需要最基础的基于thrift生成代码,所以只需要在项目目录运行
kitex -service kstudy kstudy.thrift
就会发现目录下多了一些文件,我们简要说明一下:
kitex_gen这个目录下包含了 kitex 为 client 和 server 生成的代码;handler.gokitex 自动基于我们的IDL定义,生成了一个server接口的空实现:
package main
import (
"context"
"github.com/ag9920/kstudy/kitex_gen/api"
)
// KStudyImpl implements the last service interface defined in the IDL.
type KStudyImpl struct{}
// Concat implements the KStudyImpl interface.
func (s *KStudyImpl) Concat(ctx context.Context, req *api.Request) (resp *api.Response, err error) {
// TODO: Your code here...
return
}
main.go启动 server,当前应用的入口。
package main
import (
api "github.com/ag9920/kstudy/kitex_gen/api/kstudy"
"log"
)
func main() {
svr := api.NewServer(new(KStudyImpl))
err := svr.Run()
if err != nil {
log.Println(err.Error())
}
}
补充server实现
让我们继续实现 Concat 的逻辑,由于流程非常简单,我们直接在 handler.go 中修改,直接拼接两个参数返回。
// Concat implements the KStudyImpl interface.
func (s *KStudyImpl) Concat(ctx context.Context, req *api.Request) (resp *api.Response, err error) {
resp = api.NewResponse()
resp.Message = req.GetMessage1() + req.GetMessage2()
return
}
启动server
在main.go所在目录下直接运行 go run . 即可,server 默认监听的端口为 8888,可通过参数配置修改。
2022/04/12 15:53:44.695872 server.go:77: [Info] KITEX: server listen at addr=[::]:8888
Client
我们在 kstudy 下新建client文件夹,用于实现客户端相关逻辑。新增 main.go 作为客户端的启动入口。由于我们目前只是单机的 server 和 client 交互,没有复杂的服务发现逻辑,需要通过添加client.Option来指定调用的 host 和 port。
package main
import (
"context"
"log"
"github.com/ag9920/kstudy/kitex_gen/api"
"github.com/ag9920/kstudy/kitex_gen/api/kstudy"
"github.com/cloudwego/kitex/client"
)
func main() {
client, err := kstudy.NewClient("kstudy", client.WithHostPorts("0.0.0.0:8888"))
if err != nil {
log.Fatal(err)
}
req := &api.Request{
Message1: "message1",
Message2: "message2",
}
resp, err := client.Concat(context.Background(), req)
if err != nil {
log.Fatal(err)
}
log.Println(resp)
}
在server启动的前提下,运行 client/main.go 的代码,会输出以下结果:
2022/04/12 16:16:02 Response({Message:message1message2})
这样就完成了一次 client => server 调用。
探究Client实现
在上一节示例代码中我们看到,客户端发起调用前要先引用 kitex_gen 中生成的代码 kstudy.NewClient,传入目标服务的名称(在demo中是"kstudy"),以及希望配置的 calloption。
进入 kitex_gen/api/kstudy/client.go 可以看到,kitex 底层依赖的Client本质是一个 interface 类型,与IDL中的定义完全匹配:
// Client is designed to provide IDL-compatible methods with call-option parameter for kitex framework.
type Client interface {
Concat(ctx context.Context, req *api.Request, callOptions ...callopt.Option) (r *api.Response, err error)
}
// NewClient creates a client for the service defined in IDL.
func NewClient(destService string, opts ...client.Option) (Client, error) {
var options []client.Option
options = append(options, client.WithDestService(destService))
options = append(options, opts...)
kc, err := client.NewClient(serviceInfo(), options...)
if err != nil {
return nil, err
}
return &kKStudyClient{
kClient: newServiceClient(kc),
}, nil
}
type kKStudyClient struct {
*kClient
}
NewClient 返回了一个默认实现的Client,底层其实是 kKStudyClient 这个结构体。
这样的设计模式也很有借鉴意义,面向接口编程,而不是强耦合具体的实现,方便扩展和mock。如果确实有特殊的业务场景,Client 默认的实现可以被替换。
下面我们深入一步,看看 kKStudyClient 是怎么构建出来的。
核心的逻辑在于 kc, err := client.NewClient(serviceInfo(), options...) 这里。注意,此处的 client.NewClient 调用的是 "github.com/cloudwego/kitex/client" 下的方法。签名如下:
func NewClient(svcInfo *serviceinfo.ServiceInfo, opts ...Option) (Client, error)
kitex 利用代码生成工具来完成最后的一层封装,使得业务调用的 Client 是基于自身服务定义的接口。底层的能力其实还是依赖 kitex/client 下的抽象 Client 接口。
type kKStudyClient struct {
*kClient
}
type kClient struct {
c client.Client
}
此处的 kKStudyClient 是本地代码生成的业务客户端,内部嵌套的 *kClient 内置了一个kitex/client 下的 Client 实现,这里才是核心能力的抽象。
下面我们具体来看看 kitex/client 包下面定义的 Client 的实现。
// Client is the core interface abstraction of kitex client.
// It is designed for generated codes and should not be used directly.
// Parameter method specifies the method of a RPC call.
// Request is a packing of request parameters in the actual method defined in IDL, consist of zero, one
// or multiple arguments. So is response to the actual result type.
// Response may be nil to address oneway calls.
type Client interface {
Call(ctx context.Context, method string, request, response interface{}) error
}
可以看到,只定义了一个 Call 方法,接收 method, request, response 三个参数,返回远程调用的 error。
类似的,对应到Client接口,我们也能找到框架的默认实现:
type kClient struct {
svcInfo *serviceinfo.ServiceInfo
mws []endpoint.Middleware
eps endpoint.Endpoint
sEps endpoint.Endpoint
opt *client.Options
inited bool
closed bool
}
func (kc *kClient) init() error {
// 初始化参数,传输协议,context,构造调用链,应用option等
...
}
// Call implements the Client interface .
func (kc *kClient) Call(ctx context.Context, method string, request, response interface{}) error {
// 详细实现后续说明,此处只是表明 kClient 实现了 Client 接口。
...
}
kitex/client 包下的 NewClient 函数,可以简单理解为填充了此次远程调用所必须的服务信息,以及调用选项(通过 Option控制)。执行了 kClient 的 init() 方法后,返回 kClient 对象,作为Client接口的实现。
// NewClient creates a kitex.Client with the given ServiceInfo, it is from generated code.
func NewClient(svcInfo *serviceinfo.ServiceInfo, opts ...Option) (Client, error) {
if svcInfo == nil {
return nil, errors.New("NewClient: no service info")
}
kc := &kClient{
svcInfo: svcInfo,
opt: client.NewOptions(opts),
}
if err := kc.init(); err != nil {
return nil, err
}
// like os.File, if kc is garbage-collected, but Close is not called, call Close.
runtime.SetFinalizer(kc, func(c *kClient) {
c.Close()
})
return kc, nil
}
下面我们来看 kClient 是如何实现 Call() 方法的。
// Call implements the Client interface .
func (kc *kClient) Call(ctx context.Context, method string, request, response interface{}) error {
if !kc.inited {
panic("client not initialized")
}
if kc.closed {
panic("client is already closed")
}
if ctx == nil {
panic("ctx is nil")
}
var ri rpcinfo.RPCInfo
ctx, ri = kc.initRPCInfo(ctx, method)
ctx = kc.opt.TracerCtl.DoStart(ctx, ri)
if kc.opt.RetryContainer == nil {
err := kc.eps(ctx, request, response)
kc.opt.TracerCtl.DoFinish(ctx, ri, err)
if err == nil {
rpcinfo.PutRPCInfo(ri)
}
return err
} else {
callTimes := 0
var prevRI rpcinfo.RPCInfo
recycleRI, err := kc.opt.RetryContainer.WithRetryIfNeeded(ctx, func(ctx context.Context, r retry.Retryer) (rpcinfo.RPCInfo, error) {
callTimes++
retryCtx := ctx
cRI := ri
if callTimes > 1 {
retryCtx, cRI = kc.initRPCInfo(ctx, method)
retryCtx = metainfo.WithPersistentValue(retryCtx, retry.TransitKey, strconv.Itoa(callTimes-1))
if prevRI == nil {
prevRI = ri
}
r.Prepare(retryCtx, prevRI, cRI)
prevRI = cRI
}
err := kc.eps(retryCtx, request, response)
return cRI, err
}, ri, request)
kc.opt.TracerCtl.DoFinish(ctx, ri, err)
if recycleRI {
// why need check recycleRI to decide if recycle RPCInfo?
// 1. no retry, rpc timeout happen will cause panic when response return
// 2. retry success, will cause panic when first call return
// 3. backup request may cause panic, cannot recycle first RPCInfo
// RPCInfo will be recycled after rpc is finished,
// holding RPCInfo in a new goroutine is forbidden.
rpcinfo.PutRPCInfo(ri)
}
return err
}
}
为了方便理解基本流程,除去 RetryContainer 的高阶用法,以及相关的参数校验,trace 逻辑,我们来看 Call() 的简化版,核心就是调用 kc.eps 并返回 error。
func (kc *kClient) Call(ctx context.Context, method string, request, response interface{}) error {
var ri rpcinfo.RPCInfo
ctx, ri = kc.initRPCInfo(ctx, method)
err := kc.eps(ctx, request, response)
if err == nil {
rpcinfo.PutRPCInfo(ri)
}
return err
}
eps 在 kClient 中的类型为 endpoint.Endpoint,代表了一个发起远程调用的方法。
// Endpoint represent one method for calling from remote.
type Endpoint func(ctx context.Context, req, resp interface{}) (err error)
那么 eps 是在哪里被赋值的呢?我们回过头来看 kClient 的 init() 方法:
func (kc *kClient) init() error {
initTransportProtocol(kc.svcInfo, kc.opt.Configs)
ctx := fillContext(kc.opt)
if nCtx, err := kc.proxyInit(ctx); err != nil {
return err
} else {
ctx = nCtx
}
if err := kc.checkOptions(); err != nil {
return err
}
if err := kc.initCircuitBreaker(); err != nil {
return err
}
if err := kc.initRetryer(); err != nil {
return err
}
kc.initMiddlewares(ctx)
kc.inited = true
if kc.opt.DebugService != nil {
kc.opt.DebugService.RegisterProbeFunc(diagnosis.DestServiceKey, diagnosis.WrapAsProbeFunc(kc.opt.Svr.ServiceName))
kc.opt.DebugService.RegisterProbeFunc(diagnosis.OptionsKey, diagnosis.WrapAsProbeFunc(kc.opt.DebugInfo))
kc.opt.DebugService.RegisterProbeFunc(diagnosis.ChangeEventsKey, kc.opt.Events.Dump)
kc.opt.DebugService.RegisterProbeFunc(diagnosis.ServiceInfoKey, diagnosis.WrapAsProbeFunc(kc.svcInfo))
}
kc.richRemoteOption(ctx)
return kc.buildInvokeChain()
}
在 init() 的最后一步,kClient 会构造自己的调用链,这是我们关注的核心
func (kc *kClient) buildInvokeChain() error {
innerHandlerEp, err := kc.invokeHandleEndpoint()
if err != nil {
return err
}
kc.eps = endpoint.Chain(kc.mws...)(innerHandlerEp)
innerStreamingEp, err := kc.invokeStreamingEndpoint()
if err != nil {
return err
}
kc.sEps = endpoint.Chain(kc.mws...)(innerStreamingEp)
return nil
}
eps 和 sEps 两个 endpoint.Endpoint 都是在这里被赋值。
// Middleware deal with input Endpoint and output Endpoint.
type Middleware func(Endpoint) Endpoint
// Chain connect middlewares into one middleware.
func Chain(mws ...Middleware) Middleware {
return func(next Endpoint) Endpoint {
for i := len(mws) - 1; i >= 0; i-- {
next = mws[i](next)
}
return next
}
}
从前面 endpoint.Chain() 的实现可以看出,Chain 的本质在于将一组 middleware 串联起来。一个中间件就是一个输入是EndPoint,输出也是EndPoint的函数,支持嵌套,这样保证了对应用的透明性。
更多 middlware 相关的逻辑我们会在后续的文章中详细了解,目前看,核心发起调用的逻辑在 kc.invokeHandleEndpoint 中。remote 相关的部分还有很多复杂的封装,这里这里我们直接看简化版:
func (kc *kClient) invokeHandleEndpoint() (endpoint.Endpoint, error) {
transPipl, err := newCliTransHandler(kc.opt.RemoteOpt)
if err != nil {
return nil, err
}
return func(ctx context.Context, req, resp interface{}) (err error) {
// 获取rpc调用基本信息,构造client
ri := rpcinfo.GetRPCInfo(ctx)
cli, err := remotecli.NewClient(ctx, ri, transPipl, kc.opt.RemoteOpt)
if err != nil {
return
}
// 发送消息
var sendMsg remote.Message
sendMsg = remote.NewMessage(req, kc.svcInfo, ri, remote.Call, remote.Client)
sendMsg.SetProtocolInfo(remote.NewProtocolInfo(ri.Config().TransportProtocol(), kc.svcInfo.PayloadCodec))
if err = cli.Send(ctx, ri, sendMsg); err != nil {
return
}
// 处理返回的消息
var recvMsg remote.Message
recvMsg = remote.NewMessage(resp, kc.opt.RemoteOpt.SvcInfo, ri, remote.Reply, remote.Client)
err = cli.Recv(ctx, ri, recvMsg)
return err
}, nil
}
好了,今天只是尝个鲜,我们先分析到这里,不展开过多细节。今天我们一起基于 thrift 实现了最基础的 Kitex server 以及 client,并从client的角度了解了大概的封装原理。开篇的demo源码可以在 github.com/ag9920/kstu… 查看。
这个系列后续会继续了解Kitex的详细设计,包括remote,transport,middleware,endpoint 等,文中不足的地方欢迎大家comment,一起交流。