共计 8239 个字符,预计需要花费 21 分钟才能阅读完成。
作者:王伟超(baiyutang)
Kitex 框架介绍
Kitex 是 CloudWeGo 开源的第一个微服务框架,是一个 反对多协定的 Golang RPC 框架 ,从网络库、序列化库到框架的实现 根本齐全自研。
🚩点击 字节跳动自研高性能微服务框架 Kitex 的演进之旅 理解更多
泛化调用
泛化调用是不须要依赖生成代码即可对 RPC 服务发动调用的一种个性。通常用于不须要生成代码的中台服务,场景如流量直达、API 网关等。
调用形式
Kitex 泛化调用目前仅反对 Thrift 协定,调用形式如下:
- 二进制泛化调用
- HTTP 映射泛化调用
- Map 映射泛化调用
- JSON 映射泛化调用
其中 HTTP 映射泛化对 IDL 编写标准有专门文章介绍《Thrift-HTTP 映射的 IDL 标准》,外面具体介绍了泛化调用解析 Thrift IDL 文件整体标准、约定和已反对的注解。
IDLProvider
HTTP/Map/JSON 映射的泛化调用尽管不须要生成代码,但须要使用者提供 IDL,来定义入参地位和映射关系。
目前 Kitex 有两种 IDLProvider 实现,使用者能够抉择指定 IDL 门路,也能够抉择传入 IDL 内容。当然也能够依据需要自行扩大 generci.DescriptorProvider
。如果有 IDL 治理平台,最好与平台买通,能够及时更新 IDL。
文档
更多内容可参考官网文档《泛化调用》章节。
领取开放平台
领取开放平台通常是凋谢给服务商或商户提供收款记账等能力的服务入口,常见于支付宝、微信、银联等第三方或第四方领取渠道商,特地是前几年倒退起来的聚合领取方向。
该演示我的项目布局要点如下:
- 对外裸露的是 HTTP 接口,能够用 Hertz 来做网关入口,依据 HTTP 申请应用 Kitex 泛化调用对申请散发到具体的 RPC 服务;
- 须要加签、验签,能够演示 Hertz 自定义 middleware;
- 业务服务通常有商户、领取、对账、平安等模块,业务边界清晰,为了演示仅做领取服务;
- 关注工程化,如 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
目录下。
工程化
外围要点
工程化的思考,这里分享两个外围要点:
- 独立于框架,须要的时候框架也是可被不便的替换;
- 独立于数据库,仓储层被依赖的是形象接口,用到 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
,而后依据路由参数 :svc
和 POST
参数 biz_params
、method
拼凑相干参数,进行泛化调用。这里的逻辑能够参看 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 文件。
遗留问题
- 该我的项目没有演示配置相干的应用,所以注册核心和数据库配置仅是硬编码;
- 签名解决如何获取商户私钥或 key,须要理论业务思考;
- 错误处理可持续优化;
- 泛化调用注解示例较为简单,可依据理论入参和映射关系进行灵便配置;
- 整洁架构在业务收缩之后是否会遇到新的问题;
总结
以上讲述了利用 CloudWego Kitex 和 Hertz 两大开源我的项目进行的领取开放平台的业务演示,并着重实际了 Kitex 泛化调用的能力。该我的项目相对来说更为残缺,比拟器重最佳实际的参考,其中如整洁架构、依赖注入、project-layout 等。力求简洁、清晰得向大家展现成果。心愿给大家能在业务落地带来更多灵感,有不好的做法也欢送批评指正。