一种通过 proto 生成 http 客户端请求的方法
Why Protobuf
Protobuf 是 Google 开源的一种跨语言跨平台的序列化协议, 被广泛用于服务间通讯和磁盘上的数据归档存储. 比如常见的 gRPC 框架默认的服务间通讯就是 Protobuf 协议.
Protobuf 的好处是它支持多种语言, 比如 Go, Dart, Java, Kotlin 以及其他各种语言....
不同语言不同框架的开发人员, 可以使用同一套标准来描述及调用接口, 而不用关心具体的代码细节, 如此大大提高了协作效率.
在使用 Protobuf 协作的团队中, 研发流程将会变成如下形式:
可以看到当研发协作的源头被收敛到统一的入口里, 那么团队协作天然的就会变得统一且流畅, 当有接口变动时, 改动逻辑也是同样的闭环, 这就使得接口改动有可能成为一个原子化改动. (如果是 Monorepo 的话, 可以很轻松的做到这一点, 改动成本也会变得更低)
从团队协作的角度看, 将原本人员间接口对接的复杂度转移到了编写 .proto
文件上, 尽管复杂度不会消失只会转移, 但是人员协作带来的复杂度相比于 .proto
编写的复杂度是不可控的.
Why Not Swagger
当我们要生成接口类型定义的时候, 一般的选择是 Swagger , 它有很好的 RESTful API 支持, 同时也便于阅读和测试, 有良好的社区支持, 比如基于 Swagger 的接口测试.
缺点是研发人员通常需要额外的注解来生成文档, 同时从 Swgger 的设计目的来看更多的是为了 API 文档来设计. 这个目的就让其有几个天然的缺点:
- 允许 Swagger 文档和实际接口不一致, Swagger 注解不参与运行时, 那么研发人员忘记更新接口文档是很难发现的;
- 接口修改无法被感知, 这个更多是在研发初期, 接口改动频繁. 而 Web 端很难从很长的文档上发现接口的改动;
这两个缺点的补全都依赖研发来补全和提升, 在日常协作中往往会造成实际上的问题.
而 Protobuf 则可以完美的解决这两个问题:
- 因为
.proto
文件才是源头, 如果改了接口而不改实现, 那么代码无法被编译; - 同样对
.proto
文件的编辑会被同步到各个端侧代码, 代码编译时即可发现问题.
Note本段内容主要探讨了使用 Protobuf 及对应的工具来解决团队协作的问题, 这本质上是 Protobuf 的设计理念带来的好处.
How
首先我们的客户端代码是运行在浏览器环境, 那么 HTTP Api 肯定是更常见的选择, 尽管现在有 grpc-web 这种能运行在浏览器的 grpc 运行时, 但终究多了一套运行时, 不如 XHR/Fetch Json 这类直观.
因为我们希望能获得接口约定与字段提示, 那么 TypeScript 相比于 JavaScript 显然是更好的选择.
那么我们的问题就变成了, 如何 根据 .proto
生成 TypeScript HttpClient .
找了一圈发现我们能有的工具有如下:
- ts-proto -- 根据
.proto
生成 TypeScript - protobuf-ts -- 根据
.proto
生成 TypeScript - protobuf.js -- 用 js 解析
.proto
AST - ts-morph -- 通常用来操作 TypeScript 的 AST
关于 ts-proto 和 protobuf-js 的差异可以看这个讨论
很显然没有能直接符合我们预期的工具, 前面两个更多是为了 RPC 客户端设计.
那么我们将问题分成 2 步:
- 生成 TS 的类型约定;
- 生成对应的 HTTP Client的代码;
Only Types
从只生成类型这个角度看, ts-proto
明显更符合我们的预期, 它支持很多 Type Only
的选项来帮助更简洁的生成类型.
onlyTypes=true
, 将会自动只生成类型, 并 excludelong
和protobufjs/minimal
的依赖. 等价于outputJsonMethods=false,outputEncodeMethods=false,outputClientImpl=false,nestJs=false
参考 ts-proto 的文档, 我们有如下指令
protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --proto_path=$PROTO_PATH --proto_path=./node_modules/google-proto-files --ts_proto_out=$DIST_PATH --ts_proto_opt=useSnakeTypeName=false,outputIndex=true,outputClientImpl=false,lowerCaseServiceMethods=true,outputServices=default,onlyTypes=true,outputJsonMethods=false,outputEncodeMethods=false,useExactTypes=true,useOptionals=messages,esModuleInterop=true $PROTO_PATH/apis/**/**/**.proto
我们拆解一下对应的配置:
- 我们需要传入要解析的 proto_path, 这里包含了依赖路径, 比如我们使用 Google 的 Proto 依赖, 那么就需要添加
google-proto-files
. - 最终的 ts 输出目录:
DIST_PATH
useSnakeTypeName=false
,Box_Element_Image_Alignment
=>BoxElementImageAlignment
outputIndex=true
, 将会输出index.ts
, 并通过 namespaces 集中导出, 形如export * from './api/book
lowerCaseServiceMethods=true
, 得到的 Service 将会是service.findFoo
而不是service.FindFoo
useOptionals=messages
, proto3 中 Message 的字段始终是可选的esModuleInterop=true
, 这个和tsconfig
保持一致即可, 不赘述outputServices=default
我们需要保留 Service 字段, 来判断有哪些接口, 否则的话, 输出代码中是没有接口约定的, 只有实体类型的约定 (Proto 中的 Message ).
如果你的 .proto
文件是:
service Example {
rpc Foo(FooRequest) returns (FooResponse) {
option (google.api.http) = {get: "/example/foo"};
}
}
message FooRequest {
string foo = 1;
}
message FooResponse {
string bar = 1;
}
那么你会得到, service method 首字母小写, 每个 interface 字段都可选的类型描述:
export interface FooRequest {
foo?: string | undefined;
}
export interface FooResponse {
bar?: string | undefined;
}
export interface Example {
foo(request: FooRequest): Promise<FooResponse>;
}
此时我们的第一个目标就完成了, So Easy.
Generate Method
这里有一个问题, 就是原来 service
中声明的 google.api.http
没有了, 我们无法拿到 HTTP Method 和 Path 字段.
事实上 ts-proto
的文档中有提到
We also don't support clients for
google.api.http
-based Google Cloud APIs, see #948 if you'd like to submit a PR.
感兴趣的同学可以 Follow 这个 PR :
不过哪怕支持了 google.api.http
, 依然不能满足我的需求, 我需要一个更加定制化的接口类型, 来兼容原有类型, 同时满足业务需求的约定. 因此我选择在上面 Only Types 的基础上改造.
Generate Api With Path
接下来我们需要借助 ts-proto
和 protobuf.js
来生成我们需要的 Api 类型, 此处的代码仅供参考.
import { Project, VariableDeclarationKind } from 'ts-morph';
import protobuf from 'protobufjs';
// 判断数组是否有效
function isValidArray(val) {
return val && Array.isArray(val) && val.length > 0;
}
// 首字母大写
function getFirstUpperCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function generateApi() {
// 构建 morph 对应的 project, 可以按需传入 tsconfig.json
const project = new Project({});
const sourceFiles = project.addSourceFilesAtPaths(`src/**/*.ts`);
// 因为包含文件的异步写操作, 故此为 Promise.all 以确保状态完成
return Promise.all([
// 根据 google.api.http 拼写对应方法
...sourceFiles.map((sourceFile) => {
const filePath = sourceFile.getFilePath();
// 获取所有的 Interface 包含 Service 和 Message
const interfaceDeclarations = sourceFile.getInterfaces();
interfaceDeclarations.forEach((interfaceDeclaration) => {
if(!interfaceDeclaration) {
return;
}
const methods = interfaceDeclaration.getMethods();
// 只处理 Service, 不包含 methods 的会被过滤掉
if(!isValidArray(methods)) {
return;
}
// 获取 service 名
const serviceName = interfaceDeclaration.getName();
// 找到对应的 proto 文件
const protoFilePath = filePath.replace(tsDir, protoDir).replace('.ts', '.proto');
if(existsSync(protoFilePath)) {
const content = readFileSync(protoFilePath, 'utf-8');
// 解析 proto 的 AST
const result = protobuf.parse(content);
const packageName = result.package.split('.')[1];
const protoServiceMethods = result.root.lookupService(`${result.package}.${serviceName}`).methods;
if(!isValidArray(Object.keys(protoServiceMethods))) {
return;
}
methods.forEach((method) => {
const methodName = method.getName();
const comments = method.getLeadingCommentRanges();
// 获取注释
const comment = comments?.[0]?.getText().replace(/(\/\*\*|\*\/|\s+?\*|\/)/g, '') || ' ';
// ts 中的方法名是小写字母开头, proto 中是大写字母开头
const protoMethodName = getFirstUpperCase(methodName);
const protoServiceMethod = protoServiceMethods[protoMethodName];
// 解析 options
const protoServiceMethodOptions = protoServiceMethod?.parsedOptions;
if (protoServiceMethodOptions) {
// 获取 api 相关的信息
const httpOption = protoServiceMethodOptions.find((option) => option['(google.api.http)'])?.['(google.api.http)'];
if(!httpOption) {
return;
}
const httpMethod = Object.keys(httpOption)[0];
const httpPath = httpOption[httpMethod];
let data = null;
let query = null;
if (httpMethod === 'get') {
// 当 get 请求时, 参数用 query 传递
query = protoServiceMethod.requestType;
} else {
// 当 post 请求时, 参数与 body 传递
data = protoServiceMethod.requestType;
}
const methodName = `${httpMethod.toLowerCase()}${serviceName}${protoMethodName}`;
if(data) {
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
isExported: true,
declarations: [{
name: methodName,
initializer: `(query : {}, data: ${data}, config: {} = {})=> ({
resp: ${protoServiceMethod.responseType},
path: '${httpPath}',
method: '${httpMethod.toUpperCase()}',
query: query,
body: data,
config
}) as const`,
}],
}).addJsDoc(comment);
}
if(query) {
sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
isExported: true,
declarations: [{
name: methodName,
initializer: `(query : ${query}, config: {} = {})=> ({
resp: ${protoServiceMethod.responseType},
path: '${httpPath}',
method: '${httpMethod.toUpperCase()}',
query: query,
config
}) as const`,
}],
}).addJsDoc(comment);
}
}
});
}
});
sourceFile.formatText();
// 保存 ts 文件, 这是一个异步方法, 所以我们使用了 promise.all
return sourceFile.save();
}),
])
}
此时我们会得到形如下的接口类型约定:
export const getExampleFoo = (query: FooRequest, params: {} = {}) => ({
resp: FooResponse,
path: '/example/foo',
method: 'GET',
query: query,
params
}) as const;
如此看起来是不是就很贴近 Swagger 的生成代码了, 将方法名写在函数开头, 同时有了 Path 等入参, 这个方法可以方便的用于 axois
等请求库, 因为笔者是自己封装的, 因此没有针对 axois 做特定的 properties mapping.
至此已经当成我们最初的预期, 通过解析 .proto
文件生成 HttpClientApi
的类型约定.
Function Name With Namespace
聪明的朋友很快就发现了, 当上面的类型名在不同服务间其实很容易重复, 这导致前端在写代码时对同一个类型的 import
可能会有很多提示, 这一点就是 ServiceApi 和 WebApi 的不同.
ServiceApi 很明确自己要调用的服务是什么, 需要的方法是什么, 更面向数据库, 天然就具备嵌套的特性. 所以服务端代码一般是 BookService.getDetailByName
这种代码.
而 WebApi 的接口更面向用户更面向业务, 一个接口往往涉及到多个 Service, 而返回参数往往需要来自多个实体类, 这就导致如果我们需要一个铺平的 index
的时非常容易出现命名冲突.
很容易的我们就能想到, 如果我将服务名也附带在 Interface
上就可以很好的解决这个问题, 比如 ExampleFooRequest
和 ExampleFooResponse
. ServiceName 往往直接对应到表, 不太容易冲突, 而单个服务内的方法也不允许出现冲突.
所以我们可以有代码
Promise.all(project.addSourceFilesAtPaths(`src/**/**/*.ts`).map((sourceFile) => {
const pathArr = sourceFile.getFilePath().replace(src, '').split('/');
// 只处理最后一层
if(pathArr.length <= 3) {
return;
}
const interfaceDeclarations = [...sourceFile.getInterfaces(), ...sourceFile.getEnums()];
interfaceDeclarations.forEach((interfaceDeclaration) => {
if(!interfaceDeclaration) {
return;
}
const oldInterfaceName = interfaceDeclaration.getName();
if(!oldInterfaceName) {
return;
}
const newServiceName = getFirstUpperCase(sourceFile.getBaseName()).replace('.ts', '');
const newInterfaceName = `${newServiceName}${getFirstUpperCase(oldInterfaceName)}`;
interfaceDeclaration.rename(newInterfaceName);
});
sourceFile.formatText();
return sourceFile.save();
}))
完成这一步之后, 我们就可以得到类似如下的代码:
// example.ts
export interface ExampleFooRequest {
foo?: string | undefined;
}
export interface ExampleFooResponse {
bar?: string | undefined;
}
// 有需要可以根据是否存在 method 来决定是否跳过 service
export interface ExampleExample {
foo(request: FooRequest): Promise<FooResponse>;
}
// 这个是变量, 不会被 getInterfaces 获取到
export const getExampleFoo = (query: ExampleFooRequest, params: {} = {}) => ({
resp: ExampleFooResponse,
path: '/example/foo',
method: 'GET',
query: query,
params
}) as const;
Export Declaration
如果我们感觉每次要从多层的文件中 import
我们需要的方法很麻烦, 我们可以考虑将 export 具名导出在根目录的 index.ts
, 以此方便使用.
const indexFilePaths = glob.sync(`${DIST_PATH}/*.ts`);
// 优先处理更底层的, 即长度更长的, 这样最后处理的就是 index.ts
indexFilePaths.sort((a, b) => {
const aLength = a.replace(DIST_PATH, '').split('.').length;
const bLength = b.replace(DIST_PATH, '').split('.').length;
return bLength - aLength;
});
Promise.all(indexFiles.map((sourceFile) => {
const exportDeclarations = sourceFile.getExportDeclarations();
const moduleExportsMap = new Map();
exportDeclarations.forEach(exportDeclaration => {
// 检查是否是 export * from 声明
if (exportDeclaration.isNamespaceExport()) {
const moduleSpecifier = exportDeclaration.getModuleSpecifierValue();
const exportedDeclarations = project.getSourceFileOrThrow(join(DIST_PATH, moduleSpecifier) + '.ts').getExportedDeclarations();
// 替换为具体的导出
if (!moduleExportsMap.has(moduleSpecifier)) {
moduleExportsMap.set(moduleSpecifier, []);
}
for (const [name] of exportedDeclarations) {
moduleExportsMap.get(moduleSpecifier).push(name);
}
exportDeclaration.remove();
}
});
moduleExportsMap.forEach((exports, moduleSpecifier) => {
if (exports.length > 0) {
sourceFile.addExportDeclaration({
namedExports: exports,
moduleSpecifier: moduleSpecifier,
});
}
});
sourceFile.formatText();
return sourceFile.save();
}))
如此一来我们就可以直接通过 index.ts
导出具有唯一性的方法名和变量名了.