Kitex 泛化调用案例:基于 API 网关的支付开放平台

作者:王伟超(baiyutang)

Kitex框架介绍

Kitex 是 CloudWeGo 开源的第一个微服务框架,是一个支持多协议的 Golang RPC 框架,从网络库、序列化库到框架的实现基本完全自研

🚩点击 字节跳动自研高性能微服务框架 Kitex 的演进之旅 了解更多

泛化调用

泛化调用是不需要依赖生成代码即可对 RPC 服务发起调用的一种特性。通常用于不需要生成代码的中台服务,场景如流量中转、API 网关等。

调用方式

Kitex 泛化调用目前仅支持 Thrift 协议,调用方式如下:

  1. 二进制泛化调用
  2. HTTP 映射泛化调用
  3. Map 映射泛化调用
  4. JSON 映射泛化调用

其中 HTTP 映射泛化对 IDL 编写规范有专门文章介绍《Thrift-HTTP 映射的 IDL 规范》,里面详细介绍了泛化调用解析 Thrift IDL 文件整体规范、约定和已支持的注解。

IDLProvider

HTTP/Map/JSON 映射的泛化调用虽然不需要生成代码,但需要使用者提供 IDL,来定义入参位置和映射关系。

目前 Kitex 有两种 IDLProvider 实现,使用者可以选择指定 IDL 路径,也可以选择传入 IDL 内容。当然也可以根据需求自行扩展 generci.DescriptorProvider。如果有 IDL 管理平台,最好与平台打通,可以及时更新 IDL。

文档

更多内容可参考官网文档《泛化调用》章节。

支付开放平台

支付开放平台通常是开放给服务商或商户提供收款记账等能力的服务入口,常见于支付宝、微信、银联等第三方或第四方支付渠道商,特别是前几年发展起来的聚合支付方向。

该演示项目规划要点如下:

  1. 对外暴露的是 HTTP 接口,可以用 Hertz 来做网关入口,根据 HTTP 请求使用 Kitex 泛化调用对请求分发到具体的 RPC 服务;
  2. 需要加签、验签,可以演示 Hertz 自定义 middleware;
  3. 业务服务通常有商户、支付、对账、安全等模块,业务边界清晰,为了演示仅做支付服务;
  4. 关注工程化,如 ORM、分包、代码分层、错误的统一定义及优雅处理等;

架构

整体架构

工程目录

.
├── Makefile
├── README.md
├── cmd
│   └── payment
│       ├── main.go
│       ├── wire.go
│       └── wire_gen.go
├── configs
│   └── sql
│       └── payment.sql
├── docker-compose.yaml
├── docs
│   └── open-payment-platform.png
├── go.mod
├── go.sum
├── hertz-gateway
│   ├── README.md
│   ├── biz
│   │   ├── errors
│   │   │   └── errors.go
│   │   ├── handler
│   │   │   └── gateway.go
│   │   ├── middleware
│   │   │   └── gateway_auth.go
│   │   ├── router
│   │   │   └── register.go
│   │   └── types
│   │       └── response.go
│   ├── main.go
│   ├── router.go
│   └── router_gen.go
├── idl
│   ├── common.thrift
│   └── payment.thrift
├── internal
│   ├── README.md
│   └── payment
├── kitex_gen
└── pkg
    └── auth
        └── auth.go

实现过程

项目根目录是 open-payment-platform,后续的目录讨论都从这里开始。

Payment Server

在实现一个支付服务时,要考虑几个要点:

  • IDL 存放位置;
  • main.go 入口文件存放位置;
  • 服务逻辑如何组织代码;
  • ORM 及数据仓储层怎么编写;
  • 逻辑代码依赖管理怎么做;

IDL

IDL 文件一般来说有两种组织方法:

第一种是分散的定义在各自服务内,这种对于少量微服务可接受,各自服务各自定义并维护,这可能是通常做法。不过对于服务数量特别多,几十个几百个的情况下,显然在沟通成本和可复用性上会有明显缺点;

第二种是集中的管理,不管是集中在一个文件夹下,或者是一个仓库中,集中化的管理对大量微服务下更方便一些。

特别地,在这个案例中,因为泛化调用要遍历 IDL 文件,所以把 IDL 文件统一放在 idl 目录下。

工程化

核心要点

工程化的思考,这里分享两个核心要点:

  1. 独立于框架,需要的时候框架也是可被方便的替换;
  2. 独立于数据库,仓储层被依赖的是抽象接口,用到 ORM 等数据操作是具体实现;

核心逻辑应该保持在红线圈内,包括 Use Cases 和 Entities,和框架强相关的都封装了 cmd/payment/main.go 文件,而依赖管理也在 cmd/payment/wire.go 中。

工程目录

/internal/payment目录下的代码目录大致如下:

.
├── Makefile
├── entity
│   └── order.go
├── infrastructure
│   ├── ent
│   │   ├── client.go
│   │   ├── schema
│   │   │   └── order.go
│   │   └── ...
│   └── repository
│       └── order_sql.go
└── usecase
    ├── interface.go
    └── service.go

Hertz-gateway

用 Kitex 实现一个服务是容易的,但是那只是辅助,该 biz-demo 的核心是支付网关,即在 hertz 中如何转发 http 请求到 RPC 服务。

泛化调用的最简单实现

在原有 kitex-examples/generic 仓库中有最简单的示例代码,以此为基础进行展开。

解析 IDL

// 该方法为解析 IDL 文件到内存,作为描述服务提供者
provider := generic.NewThriftFileProvider("xx-svc.thrift")

构建泛化策略

// 将第一步解析到的 IDL 内容,根据场景需要构建 HTTP 的泛化策略
g, err := generic.HTTPThriftGeneric(provider)
if err != nil {
   hlog.Fatal(err)
}

生成泛化调用客户端

// 特别地,svcName 是解析 IDL 时获知的服务名,这里生成的客户端也应该与 svcName 是一对一的
cli, err := genericclient.NewClient(
   svcName,
   g,
   client.WithResolver(nacosResolver),
   client.WithTransportProtocol(transport.TTHeader),
   client.WithMetaHandler(transmeta.ClientTTHeaderHandler),
)
if err != nil {
   hlog.Fatal(err)
}

具体实现

网关参数

{
    "sign":"xxx", // 必填,签名
    "sign_type":"RSA", // 必填,加签方法
    "nonce_str":"J84FJIUH93NFSUH894NJOF", // 必填,随机字符串
    "merchant_id":"xxxx", // 必填,用于签名验证
    "method":"svc-function-name", // 必填,RPC 调用的具体方法
    "biz_params":"{'key':'value'}" // 必填,RPC 业务参数
}

路由规则

将上述的三步构建泛化调用客户端的代码放在了 Hertz 启动服务注册路由时的实现,服务的路由规则是

/gateway/:svc,即构建 gateway 的路由组,使用参数路由知道要泛化调用 RPC 服务的具体服务名。

暂时无法在飞书文档外展示此内容

这部分实现可参看 route.go 文件中 registerGateway

// 定义路由组,并指定签名验证的中间件
group := r.Group("/gateway").Use(middleware.GatewayAuth()...)

// 遍历 IDL
idlPath := "./idl/"
c, err := os.ReadDir(idlPath)
if err != nil {
    hlog.Fatalf("new thrift file provider failed: %v", err)
}

// 指定服务发现组件
nacosResolver, err := resolver.NewDefaultNacosResolver()
if err != nil {
        hlog.Fatalf("err:%v", err)
}

// 根据 IDL 分别构建泛化调用客户端
for _, entry := range c {
    // ...
    provider, err := generic.NewThriftFileProvider(entry.Name(), idlPath)
    if err != nil {
        hlog.Fatalf("new thrift file provider failed: %v", err)
        break
    }
    g, err := generic.HTTPThriftGeneric(provider)
    if err != nil {
        hlog.Fatal(err)
    }
    cli, err := genericclient.NewClient(
        svcName,
        g,
        client.WithResolver(nacosResolver),
        client.WithTransportProtocol(transport.TTHeader),
        client.WithMetaHandler(transmeta.ClientTTHeaderHandler),
    )
    if err != nil {
        hlog.Fatal(err)
    }
    
    // 保存映射关系
    handler.SvcMap.Store(svcName, cli)
}

// 绑定处理函数
group.POST("/:svc", handler.Gateway)

发起泛化调用

路由匹配成功之后,走到绑定的 handler.Gateway 处理函数即是发起泛化调用的关键点。

首先根据 handler.SvcMap,获取泛化调用客户端 genericclient.Client,然后根据路由参数 :svcPOST 参数 biz_paramsmethod 拼凑相关参数,进行泛化调用。这里的逻辑可以参看 hertz-gateway/biz/handler/gateway.go

svcName := c.Param("svc")
cli, ok := SvcMap.Load(svcName)
if !ok {
   c.JSON(http.StatusOK, errors.New(common.Err_BadRequest))
   return
}

// ...

req, err := http.NewRequest(http.MethodPost, "", bytes.NewBuffer([]byte(params.BizParams)))
if err != nil {
   hlog.Warnf("new http request failed: %v", err)
   c.JSON(http.StatusOK, errors.New(common.Err_RequestServerFail))
   return
}
// 这里要留意 IDL 相关注解
req.URL.Path = fmt.Sprintf("/%s/%s", svcName, params.Method)

customReq, err := generic.FromHTTPRequest(req)
if err != nil {
   hlog.Errorf("convert request failed: %v", err)
   c.JSON(http.StatusOK, errors.New(common.Err_ServerHandleFail))
   return
}

// 发起调用
resp, err := cli.GenericCall(ctx, "", customReq)
respMap := make(map[string]interface{})
if err != nil {
// 错误处理
}
realResp, ok := resp.(*generic.HTTPResponse)
if !ok {
   c.JSON(http.StatusOK, errors.New(common.Err_ServerHandleFail))
   return
}
// 正常响应
c.JSON(http.StatusOK, realResp.Body)

场景补充

为了更好的演示支付网关,这里做了签名验证和返回参数加签的代码。

签名

首先在路由组注册时,给 /gateway 路由组注册了一个 GatewayAuth 的中间件

func registerGateway(r *server.Hertz) {
   group := r.Group("/gateway").Use(middleware.GatewayAuth()...)
}

type AuthParam struct {
   Sign       string `form:"sign,required" json:"sign"`
   SignType   string `form:"sign_type,required" json:"sign_type"`
   MerchantId string `form:"merchant_id,required" json:"merchant_id"`
   NonceStr   string `form:"nonce_str,required" json:"nonce_str"`
}

func GatewayAuth() []app.HandlerFunc {
   return []app.HandlerFunc{func(ctx context.Context, c *app.RequestContext) {
      var authParam AuthParam

// TODO 签名相关的 key 或私钥应该根据商户号正确获取,这里仅做展示,没有做商户相关逻辑
      key := "123"
      p, err := auth.NewSignProvider(authParam.SignType, key)
      if err != nil {
         hlog.Error(err)
         c.JSON(http.StatusOK, errors.New(common.Err_Unauthorized))
         c.Abort()
         return
      }
      // 验签关键点
      if !p.Verify(authParam.Sign, authParam) {
         hlog.Error(err)
         c.JSON(http.StatusOK, errors.New(common.Err_Unauthorized))
         c.Abort()
         return
      }

      c.Next(ctx)

      // 响应之后加签回去
      data := make(utils.H)
      if err = json.Unmarshal(c.Response.Body(), &data); err != nil {
         dataJson, _ := json.Marshal(errors.New(common.Err_RequestServerFail))
         c.Response.SetBody(dataJson)
         return
      }
      data[types.ResponseNonceStr] = authParam.NonceStr
      data[types.ResponseSignType] = authParam.SignType
      data[types.ResponseSign] = p.Sign(data)
      dataJson, _ := json.Marshal(data)
      c.Response.SetBody(dataJson)
   }}
}

项目优化

错误处理

在网关和 RPC 服务都要演示错误处理,可能数量比较多。为了规范实现,把错误定义收拢到 IDL 公共协议中去,根据生成的代码返回特定的错误,便于判断和管理。

错误定义

idl 目录中新增了 common.thrift 文件,把错误码都枚举出来,并约定不同的服务或地方使用不同的错误码段。

namespace go common

enum Err
{
   // gateway 10001- 19999
   BadRequest            = 10001,
   Unauthorized          = 10002,
   ServerNotFound        = 10003,
   ServerMethodNotFound  = 10004,
   RequestServerFail     = 10005,
   ServerHandleFail      = 10006,
   ResponseUnableParse   = 10007,

   // payment 20001- 29999
   DuplicateOutOrderNo = 20001,
   
   // other 30001- 93999
   Errxxx = 30001,
}

Hertz-gateway

在网关处的错误进行了简单的封装,方便使用:

type Err struct {
   ErrCode int64  `json:"err_code"`
   ErrMsg  string `json:"err_msg"`
}

// New Error, the error_code must be defined in IDL.
func New(errCode common.Err) Err {
   return Err{
      ErrCode: int64(errCode),
      ErrMsg:  errCode.String(),
   }
}

func (e Err) Error() string {
   return e.ErrMsg
}

用法如:

import (
    "github.com/cloudwego/biz-demo/open-payment-platform/hertz-gateway/biz/errors"
    "github.com/cloudwego/biz-demo/open-payment-platform/kitex_gen/common"
)

c.JSON(http.StatusOK, errors.New(common.Err_RequestServerFail))

Payment Server

RPC 服务使用 Kitex 业务异常 的特性支持,只需要在泛化调用客户端和 RPC 服务端制定好相关配置即可。

具体用法如:


import (
   "github.com/cloudwego/biz-demo/open-payment-platform/kitex_gen/common"
)
// 这里类型转换较为繁琐,亦可考虑如何简化优化封装
// 比如一个思路是如果想业务异常也想不依赖某个框架用法,如何做
return nil, kerrors.NewBizStatusError(int32(common.Err_DuplicateOutOrderNo), common.Err_DuplicateOutOrderNo.String())

错误处理后续

以上实现了统一管理,实际开发可能要考虑如果 err_msg 先自定义或改成中文,如何优化。如果纯英文也没关系,严格来讲 HTTP 出去之后,前端拿到错误码也应该针对错误码适当优化文案。

演示环境

为了方便项目演示,项目所依赖的注册中心、数据库等都使用了容器,可参看 docker-compose.yaml

version: "3.1"
services:
  mysql:
    image: "mysql"
    volumes:
      - ./configs/sql:/docker-entrypoint-initdb.d
    environment:
      MYSQL_ROOT_PASSWORD: root
    ports:
      - "3306:3306"
    restart: always
  nacos:
    image: "nacos/nacos-server:2.0.3"
    ports:
      - "8848:8848"
      - "9848:9848"
    environment:
      MODE: standalone

并且把常用命令放了 Makefile 里,可以很方便的准备环境、运行网关、启动支付服务、清理环境等。

详细的用法和演示效果都可以参看 README.md 文件。

遗留问题

  1. 该项目没有演示配置相关的使用,所以注册中心和数据库配置仅是硬编码;
  2. 签名处理如何获取商户私钥或 key ,需要实际业务考虑;
  3. 错误处理可继续优化;
  4. 泛化调用注解示例较为简单,可根据实际入参和映射关系进行灵活配置;
  5. 整洁架构在业务膨胀之后是否会遇到新的问题;

总结

以上讲述了利用 CloudWego Kitex 和 Hertz 两大开源项目进行的支付开放平台的业务演示,并着重实践了 Kitex 泛化调用的能力。该项目相对来说更为完整,比较重视最佳实践的参考,其中如整洁架构、依赖注入、project-layout 等。力求简洁、清晰得向大家展示效果。希望给大家能在业务落地带来更多灵感,有不好的做法也欢迎批评指正。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容