KiteX 是字节跳动框架组研发的下一代高性能、强可扩展性的 Go RPC 框架。目前已经在 Github 开源。作为字节跳动内部原有的 RPC 框架 Kite 的升级版,虽然开源时间不是很长,但其实从性能和扩展性上来说都已经在字节上万微服务的场景下做了充分的验证。
近期花了一些时间简单看了看源码,被整体的框架设计和扩展性深深吸引。希望能够单独开一个系列,带大家一起熟悉Kitex的使用,通过源码了解其提供的能力,学习整体的设计。
官方文档:www.cloudwego.io/zh/docs/kit…
Github 仓库:github.com/cloudwego/k…
官方 example:github.com/cloudwego/k…
作为入门系列第一篇,今天我们先来看看如何上手。基于 kitex 0.2.1 版本。
go install github.com/cloudwego/kitex/tool/cmd/kitex@latest
go install github.com/cloudwego/thriftgo@latest
复制代码
这一步完成后,可以打开命令行运行 kitex --version
和 thriftgo --version
进行验证。
我在Github上创建了公开的仓库,和这个系列文章同步更新:github.com/ag9920/kstu… ,克隆到本地后开始我们今天的开发。
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.go
kitex 自动基于我们的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())
}
}
复制代码
让我们继续实现 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
}
复制代码
在main.go
所在目录下直接运行 go run .
即可,server 默认监听的端口为 8888,可通过参数配置修改。
2022/04/12 15:53:44.695872 server.go:77: [Info] KITEX: server listen at addr=[::]:8888
复制代码
我们在 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 调用。
在上一节示例代码中我们看到,客户端发起调用前要先引用 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的角度了解了大概的封装原理。
这个系列后续会继续了解Kitex的详细设计,包括remote,transport,middleware,endpoint 等,文中不足的地方欢迎大家comment,一起交流。