trpc.group/trpc-go/trpc-go@v1.0.3/docs/developer_guide/develop_plugins/database_zh_CN.md (about) 1 [English](database.md) | 中文 2 3 # 怎么开发一个 database 类型的插件 4 5 在日常的开发过程中,大家经常会访问 MySQL、Redis、Kafka 等存储进行数据库的读写。直接使用开源的 SDK 虽然可以满足访问数据库的需求,但是用户需要自己负责路由寻址、监控上报、配置的开发。 6 7 考虑到 tRPC-Go 提供了多种多样的路由寻址、监控上报、配置管理的插件,我们可以封装一下开源的 SDK,复用 tRPC-Go 插件的能力,减少重复代码。tRPC-Go 提供了部分开源 SDK 的封装,可以直接复用 tRPC-Go 的路由寻址、监控上报功能,代码仓库:https://github.com/trpc-ecosystem/go-database。 8 9 ## 生产者 10 11 ### 定义 `Client` 请求入口 12 13 生产者执行具体的指令生产消息,例如希望发起 MySQL `Exec`,我们在外层定义一个 `MysqlCli` 结构体提供 `Exec` 方法,为了复用 tRPC-Go 的生态插件,我们需要进入 tRPC-Go 的 Client,在 Client 层会调用各种拦截器,其中路由寻址和监控上报插件都是使用拦截器实现的,所以,在 Exec 方法的最后,调用 `Client.Invoke` 进入 Client 层执行拦截器: 14 15 ```golang 16 type MysqlCli struct { 17 serviceName string 18 client client.Client 19 } 20 21 func (c *MysqlCli) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { 22 // 构造请求和响应 23 mreq := &Request{ 24 Exec: query, 25 Args: args, 26 } 27 mrsp := &Response{} 28 29 ctx, msg := codec.WithCloneMessage(ctx) 30 defer codec.PutBackMessage(msg) 31 msg.WithClientRPCName(fmt.Sprintf("/%s/Exec", c.serviceName)) 32 msg.WithCalleeServiceName(c.serviceName) 33 msg.WithSerializationType(codec.SerializationTypeUnsupported) 34 msg.WithCompressType(codec.CompressTypeNoop) 35 36 // 将请求和响应放到 context 中 37 msg.WithClientReqHead(mreq) 38 msg.WithClientRspHead(mrsp) 39 40 // 进入 Client 层,通过 WithProtocol 指定后续使用 mysql 对应的 transport 41 err := c.client.Invoke(ctx, mreq, mrsp, client.WithProtocol("mysql")) 42 if err != nil { 43 return nil, err 44 } 45 46 return mrsp.Result, nil 47 } 48 ``` 49 50 ### 实现 `transport.ClientTransport` 接口 51 52 `transport.ClientTransport` 接口有 `RoundTrip` 方法,是 tRPC-Go 框架 `Client` 层结束后,发起网络的请求的起点,database 插件的目标并不是实现数据库的 SDK,而是对已有的数据库 SDK 做一层 tRPC-Go 外壳的封装,使其拥有 tRPC-Go 客户端一样的寻址和监控能力。 53 54 所以我们在 `RoundTrip` 方法里,需要调用数据库的 SDK,发起具体的请求,例如希望发起一次 MySQL exec: 55 56 ```golang 57 func init() { 58 // 注册 mysql transport 59 transport.RegisterClientTransport("mysql", &MysqlTransport{}) 60 } 61 62 type MysqlTransport struct {} 63 64 func (t *MysqlTransport) RoundTrip(ctx context.Context, req []byte, opts ...transport.RoundTripOption) ([]byte, error) { 65 // 从 context 取出请求和响应 66 msg := codec.Message(ctx) 67 req := msg.ClientReqHead().(*Request) 68 rsp := msg.ClientRspHead().(*Response) 69 70 opt := &transport.RoundTripOptions{} 71 for _, o := range opts { 72 o(opt) 73 } 74 75 // 保证同一个 address 只会 Open 一次 76 db, err := sql.Open(ct.DriverName, opt.Address) 77 rsp = db.ExecContext(ctx, req.Exec, req.Args...) 78 } 79 ``` 80 81 ### 更好的实现方法,使用 Hook 82 83 虽然定义请求入口和 transport 层可以实现对数据库请求的拦截,实现路由寻址和监控上报。但是开源库提供的接口各种各样,为了让我们的 database 提供的接口更加完善,和开源 SDK 保持一致,那需要我们对每个接口都定义一个请求入口。但是随着接口的增加,代码会显得啰嗦,维护也会更加困难。 84 85 我们可以使用开源 SDK 提供的 Hook 功能,实现拦截器的注入,因为几乎所有的路由寻址,监控上报插件都是拦截器,只要能执行拦截器,那路由寻址和监控上报功能就实现了,不需要进入 Client 层。而且使用 Hook 的方式可以尽可能的使用开源 SDK 原始的请求入口,不需要额外定义。但并不是所有开源的 SDK 都提供了 Hook 的功能,我们以 goredis 为例,看看如何利用 Hook 来执行拦截器。 86 87 [go-redis](github.com/redis/go-redis/v9) 的 UniversalClient 提供了 AddHook(hook) 的方法,能够在创建连接和发起请求的前后执行自定义逻辑。 88 89 ```golang 90 type Hook interface { 91 DialHook(next DialHook) DialHook 92 ProcessHook(next ProcessHook) ProcessHook 93 ProcessPipelineHook(next ProcessPipelineHook) ProcessPipelineHook 94 } 95 ``` 96 97 例如我们希望在创建连接的时候,进行路由寻址: 98 99 ```golang 100 type hook struct { 101 filters *joinfilters.Filters // 拦截器 102 selector selector.Selector // 路由寻址选择器 103 } 104 105 func (h *hook) DialHook(next redis.DialHook) redis.DialHook { 106 return func(ctx context.Context, network, addr string) (net.Conn, error) { 107 start := time.Now() 108 node, err := h.selector.Select(h.options.Service) 109 if err != nil { 110 return nil, err 111 } 112 conn, err := next(ctx, network, node.Address) 113 return conn, err 114 } 115 } 116 ``` 117 118 例如我们希望在发起请求的前后执行拦截器: 119 120 ```golang 121 func (h *hook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook { return func(ctx context.Context, cmds []redis.Cmder) error { 122 ctx, redisMsg := WithMessage(ctx) 123 call := func(context.Context) (string, error) { 124 nextErr := next(ctx, cmds) 125 rspBody := pipelineRspBody(cmds) 126 return rspBody, nextErr 127 } 128 req := &invokeReq{ 129 cmds: cmds, 130 rpcName: "pipeline", 131 call: call, 132 } 133 req.calleeMethod, req.reqBody = pipelineReqBody(cmds) 134 trpcErr := h.filters.Invoke(ctx, rReq, rRsp, func(ctx context.Context, _, _ interface{}) error { 135 rRsp.Cmd, callErr = req.call(ctx) 136 return TRPCErr(callErr) 137 }, opts...) 138 } 139 } 140 ``` 141 142 完整代码见:https://github.com/trpc-ecosystem/go-database/blob/main/goredis/client.go 143 144 ## 消费者 145 146 ### 定义桩代码 147 148 消费者服务通常是回调函数,类似于普通 tRPC-Go 服务端,通过桩代码将用户处理逻辑注册到框架中。例如 Kafka 消费服务,底层 Kafka SDK 使用 [sarama](https://github.com/IBM/sarama) 我们定义业务处理接口签名如下: 149 150 ```golang 151 type KafkaConsumer interface { 152 Handle(ctx context.Context, msg *sarama.ConsumerMessage) error 153 } 154 ``` 155 156 定义桩代码,提供用户处理逻辑注册方法: 157 158 ```golang 159 func RegisterKafkaConsumerService(s server.Service, svr KafkaConsumer) { 160 _ = s.Register(&KafkaConsumerServiceDesc, svr) 161 } 162 163 var KafkaConsumerServiceDesc = server.ServiceDesc{ 164 ServiceName: "trpc.kafka.consumer.service", 165 HandlerType: ((*KafkaConsumer)(nil)), 166 Methods: []server.Method{{ 167 Name: "/trpc.kafka.consumer.service/handle", 168 Func: KafkaConsumerHandle, 169 }}, 170 } 171 ``` 172 173 为了能让 tRPC-Go 框架正确执行业务处理逻辑,我们需要实现 `server.Method.Func`,从 context 中拿到 `sarama.ConsumerMessage` 交给业务处理函数: 174 175 ```golang 176 func(svr interface{}, ctx context.Context, f FilterFunc) (rspbody interface{}, err error) 177 178 func KafkaConsumerHandle(svr interface{}, ctx context.Context, f server.FilterFunc) (interface{}, error) { 179 filters, err := f(nil) 180 if err != nil { 181 return nil, err 182 } 183 handleFunc := func(ctx context.Context, _ interface{}, _ interface{}) error { 184 msg := codec.Message(ctx) 185 m, ok := msg.ServerReqHead().(*sarama.ConsumerMessage) 186 if !ok { 187 return errs.NewFrameError(errs.RetServerDecodeFail, "kafka consumer handler: message type invalid") 188 } 189 return svr.(KafkaConsumer).Handle(ctx, m) 190 } 191 if err := filters.Handle(ctx, nil, nil, handleFunc); err != nil { 192 return nil, err 193 } 194 return nil, nil 195 } 196 ``` 197 198 ### 实现 `transport.ServerTransport` 199 200 `transport.ServerTransport` 的 `ListenAndServe` 方法是网络请求的入口,在这个函数里,我们需要调于 `sarama` 的消费接口,获取到消息后给到用户: 201 202 完整代码:https://github.com/trpc-ecosystem/go-database/blob/main/kafka/server_transport.go