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