

protobuf 为经络,gRPC为骨架
source link: https://zhuanlan.zhihu.com/p/162839054
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

protobuf 为经络,gRPC为骨架
自从沃斯老爷子给出著名的等式:
Algorithms + Data structure = Programs
以来,数据结构的重要性在任何软件项目中都是毋庸置疑的。但数据结构往往又是最难相处的:
- 数据结构容易变化:一开始的设计很难追得上产品需求的叠加或者变化
- 数据结构容易分散:一开始集中定义的结构在项目的运作过程中越来越分散,随手定义解决本地某个具体(一次性)问题的结构屡见不鲜
这两个问题往往让我们的项目越来越冗长,越来越难以维护。早先,人们为了解决数据在不断变化的过程中的向后兼容性,比如 v1 版本的数据通过网络传输给 v2 版本的服务器的兼容性,发明了 TLV,通过让数据的每个部分都自声明其类型(版本)和长度,来保证数据的可兼容性;后来,随着以 protobuf 为首的一系列工具的诞生,处理数据结构的变化变得不那么繁琐:程序员只要依照一定的要求,数据本身可以很方便地在多个版本的软件中兼容。
然而 protobuf 之类的工具让原本就比较分散的数据结构变得更加分散 — 现在网络层/协议层都用 protobuf 来定义数据结构,而业务层/应用层使用编程语言原生的支持来定义数据结构,项目一大,维护起来还是会很头疼。
我自己在不断实践中,尤其是在 rust 的实践中,逐渐摸索出来一套以 protobuf 为主的数据结构定义方式,即在一个项目开始(或者一个子服务),先集中精力用 protobuf 定义项目中所有可以被 protobuf 支持的简单类型定义的数据结构。且听我一一道来(以下是我做项目处理数据结构的顺序)。
一个系统,清晰可追踪的出错码是系统在演进过程中能够快速排错的关键之一(之二是日志)。所以我们从定义项目中的出错码入手。出错码用 enum
表述,如下:
enum ErrorCode {
// unknown error
UNKOWN = 0;
// not found
NOT_FOUND = 1;
// already exists
ALREADY_EXISTS = 2;
...
}
列表不必全,随用随更新。出错码可以用在 gRPC 的各种出错情况,这个自不必说。定义好出错码骨架之后我们可以为其实现 error::Error
— 这就是见证奇迹的地方:我们通过一套定义实现了内部和外部出错码的统一。
软件项目中,有大量的数据结构是枚举类型。比如产品的平台(web, ios, android),auth provider 的类型(wechat, google, facebook),用户的等级(unverified, beginner, advanced, core, admin)等等,都是枚举,可以用 enum
表述。这块也是随用随加。proto 文件可以都属于同一个 package,比如 common,但代码可以通过不同的文件拆分;而生成的代码,可以通过 re-export 将其放置于不同的命名空间下,方便管理。
在 rust 下,为了方便 prost 编译出的 protobuf 的枚举类型的各种应用,我会在 [build.rs](<http://build.rs>)
里加一些小的 hack,比如下面的代码:
/// supported platforms
#[derive(
serde::Serialize,
serde::Deserialize,
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
strum_macros::EnumIter,
strum_macros::EnumString,
strum_macros::Display,
::prost::Enumeration,
)]
#[repr(i32)]
pub enum Platform {
Ios = 0,
Android = 1,
Web = 2,
}
这里面,serde 和 strum 相关的宏都是 protobuf 在编译过程中被额外添上去的。通过这样的处理,同一套枚举可以被用在各种场景:数据库(整数),输入输出(字符串),grpc及内部逻辑(枚举)。
大部分的后端服务,都会用配置文件来允许使用者在不同的场景下使用服务。配置文件可以用 ini,json,yaml,toml 等,甚至是 dhall(有人用 dhall 么?)。我个人偏好 toml,兼具可读性和灵活性。对于配置文件的每个部分,我会定义这样的 proto:
// doorman service config
message DoormanConfig {
// gRPC port
uint32 grpc_port = 1;
// websocket port
uint32 wss_port = 2;
}
// facebook auth configuration
message FacebookConfig {
string base_url = 1;
string auth_url = 2;
string avatar_url = 3;
}
// guardian service config
message GuardianConfig {
// auth token secret
string token_secret = 1;
// auth db conn str
string conn_str = 2;
// auth token expiration
uint32 expiring_days = 3;
// facebook configuration
FacebookConfig facebook = 4;
}
还是略微加一点点 hack,在生成的 rust 代码中加上 serde 相关的宏(其它语言处理 protobuf 写的配置思路也类似),那么,在 proto 里定义的类型和下面的文件就可以相互转换,非常方便:
[doorman]
grpc_port = 8443
wss_port = 8480
[guardian]
token_secret = "c3VwZXJzZWNyZXQK"
conn_str = "postgres://postgres:postgres@localhost/aurora_test"
expiring_days = 14
[guardian.facebook]
base_url = "<http://localhost:1234>"
auth_url = "me?fields=id,first_name,last_name,email,gender,birthday&access_token="
avatar_url = "picture?type=square"
配置文件里面的配置项也可以是随用随填,不必一开始就完全定义好,有些内容,即便日后弃用( deprecate),也不用对序列化反序列化配置的逻辑做任何修改,非常方便。我个人觉得,项目一开始先定义好配置文件的样子是非常有必要的,尤其是对于单元测试。因为有了配置文件和内部数据结构的灵活转换,我们就可以这样子做单元测试:
let data = r#"
{
"email": "[email protected]",
"first_name": "Tyr",
"id": "2",
"last_name": "Chen"
}
"#;
let _m = mock_fb_success(data).create();
let fb = Facebook::new(&FacebookConfig::new_test());
let verified_token = fb.verify(MOCK_TOKEN).await?;
其中 FacebookConfig::new_test()
就是把测试期间使用的配置文件 test.toml 加载成 FacebookConfig
。
通过这种方式,一来配置文件的结构可以在开发功能的过程中逐渐完善;二来很多数据结构的初始化可以直接从配置文件里拿;三来测试不再需要很多乱七八糟的常量定义。而在 protobuf 里定义配置文件的结构还有一个额外的好处,就是配置可以通过网络直接以二进制的形式传递,省却了中间序列化和反序列化的过程(没错,性能这东西,省一点是一点)。我之前做的项目 gitrocks,git 的核心配置就是多个节点间来回同步的。
业务相关数据结构
在前面那些数据结构都定义完成后,接下来就可以定义业务相关的数据结构了。比如用户模块,用户的基本信息相关的结构,就可以用 protobuf 来定义:
// basic user information
message User {
// user id
string id = 1;
// user's email
string email = 2;
// user's first name
string first_name = 3;
// user's last name
string last_name = 4;
// user's gender - Unspecified if none
common.Gender gender = 5;
// user's birthday in UTC timestamp, 0 if none
int64 birthday = 6;
// user's avatar
string avatar = 7;
}
在 protobuf 里定义这样原本可能是在代码中定义的结构,好处有二:1) 集中 2) 便于演进。这两个好处上文中都已经阐述,这里就不多讲了。
用以上各种方法处理,一个项目大概有50%-70% 的数据结构都可以被集中在 proto 文件中定义;我们只需要合理地控制生成的代码的命名空间,就可以很方便地引用所有的结构。而那些不能用 protobuf 定义的数据结构多数是和外部依赖相关,比如数据库的连接句柄,网络的 socket 引用等等。就不必纠结了。
用 gRPC 串联一切
protobuf 并不需要和 gRPC 联合使用。在我的很多个人项目中,即便项目本身和 gRPC 无关,我也会用 protobuf 来定义上述所列的数据结构。不过,多数情况下,一个后端的服务总需要和外界打交道,兑现服务的承诺,而这个打交道的过程, gRPC 是非常完美的,承上启下的选择。
我喜欢 gRPC 的有两个原因:1) 服务的主要数据结构集中管理了,而服务对外的接口也能通过 gRPC 被集中管理起来,并且在需要的时候可以无缝升级 2) 它会强迫你用一个更好的结构来处理所面临的问题。
开发人员往往不太爱写文档,也不喜欢在项目的开始做深入的设计。protobuf 强迫你不得不先考虑好数据结构再写代码;而gRPC 强迫你不得不先定义好接口再写代码。而这两部分都完成并且得到充分的代码审查后,后续的实现再差也不会偏离大方向。
我们看这样一个例子:
service UserService {
// user signin
rpc Signin(RequestSigninOrRegister) returns (ResponseSigninOrRegister);
// user resets password
rpc ResetPassword(RequestResetPassword) returns (google.protobuf.Empty);
}
它定义了两个几乎每个后端服务可能都会处理的方法:signin 和 reset_password 。然而,gRPC 接口提供出来后,对 web 前端不太友好。我们知道,尽管现在已经是 http2 往 http3 发展的时代,浏览器的 javascript 还不能很好地处理http2,所以我们需要类似 grpc-web 的工具中转一下。此外,如果你提供面向公众的 API 服务,gRPC 可能不是一个很好的选择,一来它的用于调试的工具链还没有那么出色,二来处理强类型的数据总归比弱类型的数据麻烦,用户用两下,嫌麻烦就走了。大家还是希望有简单易用的 RESTful API,怎么破?
这个时候,protobuf 和 gRPC 作为一门单独的可扩展的语言的价值就体现出来:我们可以通过一些 annotation
,描述服务可以如何被编译成 REST ←→ gRPC 的 proxy,这样,我们仍旧只需要写一份代码,就可以同时支持 REST API 和 gRPC。以下是上述 gRPC 的扩展:
service UserService {
// user signin
rpc Signin(RequestSigninOrRegister) returns (ResponseSigninOrRegister) {
option (google.api.http) = {
post : "/api/v1/users/signin"
body : "*"
};
option (grpc.gateway.protoc_gen_swagger.options.openapiv2_operation) = {
summary : "User signin"
description : "Sign in a user by using an auth provider"
tags : "Users"
};
}
// user resets password
rpc ResetPassword(RequestResetPassword) returns (google.protobuf.Empty) {
option (google.api.http) = {
post : "/api/v1/users/reset_password"
body : "*"
};
option (grpc.gateway.protoc_gen_swagger.options.openapiv2_operation) = {
summary : "User reset password"
description : "Reset password for users. For first time setting "
"password, old password should be empty string"
tags : "Users"
security : {
security_requirement : {
key : "ApiKey";
value : {}
}
}
};
}
}
即便你没有看过类似的代码,你也不难理解,它标记了每一个 gRPC 方法对应的 HTTP method 和 URL,以及是否需要额外的 header(比如 reset_password 需要 Authorization header)。由此,通过对应的编译器,我们几乎不需付出额外代价(100 行左右无脑抄的 golang 代码)就可以生成一个 REST API 的 proxy,用户可以通过这套 REST API 和 gRPC 交互。更酷的是,因为 protobuf 和 gRPC 的强类型,我们还很容易自动生成 swagger 文档,并在 swagger 里完成浏览器 → proxy → gRPC 的服务调用:
由此,我们可以一套代码,两套接口为不同端提供服务。gRPC 在其中很好地串联起了一切。
构建编译器极限扩展
上文中用于编译产生 proxy 的工具是 grpc-gateway,这是一个值得仔细研究的工具。它给我们提供了很好地扩展 protobuf/gRPC,用代码生成代码的方向和蓝图。这也是 protobuf 这样的语言的魅力所在:它足够简单,可以很容易被解析,从而生成不同角度的工具。之前在 arcblock,我们就把 protobuf / gRPC 生成 GraphQL 的接口(没有 grpc-gateway 做得这么好),可以让客户端通过 GraphQL 来访问链上通过 gRPC 提供的接口。如果大家对如何构建编译器感兴趣,可以参考我之前写的文章 __。目前,很多语言都提供了 parser combinator 的支持,用来处理 protobuf/grpc 基本语法的解析不是什么大问题。
通过 gRPC,以及 gRPC gateway 的桥接,我们把外部世界和内部世界很好地连接起来。而 protobuf 定义的数据结构,则活跃在系统的各个部分,可上可下,可萝可御,居庙堂之高则忧其民,处江湖之远则忧其君。当我们的软件以「protobuf 为经络,gRPC为骨架」,再辅以 message bus 处理异步的事件,日志,及复杂的计算,那么,还怕天天乘风破浪的产品小姐姐的需求变更么?
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK