Automating API Development with Protobuf, GitLab CI, and Kratos
This article explains how to engineer APIs by automating protobuf file management, code generation, CI/CD pipelines, error handling, validation, and documentation generation using tools such as GitLab, Kratos, Apifox, and quicktype, ultimately improving development efficiency across backend and client teams.
API 工程化是什么
API 工程化是通过一系列工具的组合,将 API 的编写、构建、发布、测试、更新、管理等流程,进行自动化、规范化。降低各端在 API 层面的沟通成本,降低管理和更新 API 的成本,提高各端的开发效率。
百瓶技术 API 工程化的效果
后端开发人员编写好 Protobuf 文件后提交到 GitLab,在 GitLab 发起 MergeRequest。GitLab 会发邮件给 MergeRequest 合并人员,合并人员收到邮件提醒后,在 GitLab 上进行 CodeReview 后合并 MergeRequest。工作群会收到 API 构建消息。开发人员在 Apifox 上点击立即导入按钮,Apifox 上的接口文档便会更新。客户端人员在自己的项目中配置新接口地址,便会构建新的请求模型。
百瓶技术 API 工程化的流程
编写和管理 Protobuf 接口文件
Protobuf 基本的环境搭建和使用就不在这里赘述了。
如煎鱼老师总结的 真是头疼,Proto 代码到底放哪里?,可能每个公司对 proto 文件的管理方法是不一样的,本文采用的是集中仓库的管理方式。如下图:
Kratos 的毛剑老师也对 API 工程化 有过一次分享,对煎鱼老师的这篇文章进行了一些 解读,本人听过后受益匪浅。
本文的项目结构如下图:
本项目基础是一个 Go 的项目,在 api 包分为 app 客户端接口和 backstage 管理后台的接口。从 app 下的 user 目录中可以看到,在 user 域中有个 v1 的包用来做接口版本区分,有一个 user_enums.proto 文件用来放 user 域共用的枚举。枚举文件如下:
syntax = "proto3";
package app.user;
option go_package = "gitlab.bb.local/bb/proto-api-client/api/app/user;user";
// Type 用户类型
enum Type {
// 0 值
INVALID = 0;
// 普通用户
NORMAL = 1;
// VIP 用户
VIP = 2;
}有一个 user_errors.proto 文件存放 user 域共用的错误。这里的错误处理使用的是 kratos 的 错误 处理方式。
syntax = "proto3";
package app.user;
import "errors/errors.proto";
option go_package = "gitlab.bb.local/bb/proto-api-client/api/app/user;user";
option java_multiple_files = true;
enum UserErrorReason {
option (pkg.errors.default_code) = 500;
// 未知错误
UNKNOWN_ERROR = 0;
// 资源不存在
NOT_EXIST = 1[(pkg.errors.code) = 404];
}pkg 中 errors 包放的是编译错误文件用公用模型,model 包放的是业务无关的数据模型,如 page、address 等。transport 包存放的是 Grpc code 转 http code 的代码,在错误处理中用到。validate 包存放的是接口参数校验用的文件,如下:
type validator interface {
Validate() error
}
// Interceptor 参数拦截器
var Interceptor = func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
if r, ok := req.(validator); ok {
if err := r.Validate(); err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
}
return handler(ctx, req)
}third_party 存放的是编写编译 proto 文件时需要用的第三方的 proto 文件,其他的文件在后续的流程使用中再进行讲解。
核心的接口文件编写如下:
syntax = "proto3";
package app.user.v1;
option go_package = "api/app/user/v1;v1";
import "google/api/annotations.proto";
import "validate/validate.proto";
import "app/user/user_enums.proto";
// 用户
service User {
// 添加用户
rpc AddUser(AddUserRequest) returns (AddUserReply) {
option (google.api.http) = {
post: "/userGlue/v1/user/addUser"
body:"*"
};
}
// 获取用户
rpc GetUser(GetUserRequest) returns (GetUserReply) {
option (google.api.http) = {
get: "/userGlue/1/user/getUser"
};
}
}
message AddUserRequest {
// User 用户
message User {
// 用户名
string name = 1[(validate.rules).string = {min_len:1,max_len:10}];
// 用户头像
string avatar = 2;
}
// 用户基本信息
User user = 1;
// 用户类型
Type type = 2;
}
message AddUserReply {
// 用户 id
string user_id = 1;
// 创建时间
int64 create_time = 2;
}
message GetUserRequest {
// 用户 id
string user_id = 1[(validate.rules).string = {min_len:1,max_len:8}];
}
message GetUserReply {
// 用户名
string name = 1;
// 用户头像
string avatar = 2;
// 用户类型
Type type = 3;
}从上面的代码可以看到一个业务域中的接口和对应的 message 都定义在同一个文件中。接口用到的请求 message 以方法名 + Request 结尾,返回 message 以方法名 + Reply 结尾。这样做的好处是规范统一、避免在生成 swagger 文档导入 Apifox 时模型被覆盖。可以使用 GoLand 和 IDEA 自带代码模板快速编写。
编译发布 Protobuf 文件
因为编写的 proto 文件需要 CodeReview,而且每个开发人员本地编译环境可能不一致,所以编译流程统一放在 GitRunner 上,由 MergeRequest 合并后触发 GitRunner 在 Linux 上编译所有的 proto 文件。GitRunner 配置文件如下:
before_script:
- echo "Before script section"
- whoami
- sudo chmod +x ./*
- sudo chmod +x ./shell/*
- sudo chmod +x ./pkg/*
- sudo chmod +x ./third_party/*
- sudo chmod +x ./api/app/*
- sudo chmod +x ./api/backstage/*
- git config --global user.name "${GITLAB_USER_NAME}"
- git config --global user.email "${GITLAB_USER_EMAIL}"
after_script:
- echo "end"
build1:
stage: build
only:
refs:
- master
script:
- ./index.sh
- ./gen_app.sh
- ./gen_backstage.sh
- ./format_json.sh
- ./git.sh
- curl 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx' -H 'Content-Type:application/json' -d "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":\"构建结果:<font color=\"info\">成功</font>
>项目名称:$CI_PROJECT_NAME
>提交日志:$CI_COMMIT_MESSAGE
>流水线地址:[$CI_PIPELINE_URL]($CI_PIPELINE_URL)\"}}"
- ./index.sh其中 before_script 配置文件权限和 git 账号,after_script 输出编译结束的语句,build1 只在 master 分支触发,script 为核心执行流程。
index.sh 用于将 GitLab 的代码 copy 到 GitRunner 所在的服务器:
cd ..
echo "当前目录 `pwd`"
rm -rf ./proto-api-client
git clone http://xx:[email protected]/xx/proto-api-client.gitgen_app.sh 用于编译客户端接口:
# errors
API_PROTO_ERRORS_FILES=$( find api/app -name *errors.proto)
protoc --proto_path=. \
--proto_path=pkg \
--proto_path=third_party \
--go_out=paths=source_relative:. \
--client-errors_out=paths=source_relative:. \
$API_PROTO_ERRORS_FILES
# enums
API_PROTO_ENUMS_FILES=$( find api/app -name *enums.proto)
protoc --proto_path=. \
--proto_path=third_party \
--go_out=paths=source_relative:. \
$API_PROTO_ENUMS_FILES
# api
API_PROTO_API_FILES=$( find api/app/*/v* -name *.proto)
protoc --proto_path=. \
--proto_path=api \
--proto_path=pkg \
--proto_path=third_party \
--go_out=paths=source_relative:. \
--new-http_out=paths=source_relative,plugins=http:. \
--new-grpc_out=paths=source_relative,plugins=grpc:. \
--new-validate_out=paths=source_relative,lang=go:. \
--openapiv2_out . \
--openapiv2_opt allow_merge=true,merge_file_name=app \
--openapiv2_opt logtostderr=true \
$API_PROTO_API_FILES错误处理、枚举处理、接口处理分别通过 find 命令遍历对应的 proto 文件并使用相应的 protoc 插件编译。
format_json.sh 用于把 openapiv2 生成的 swagger 文档中 int64 类型显示为 string 的字段加上 int64 标识,脚本如下:
#!/bin/bash
node ./format.jsformat.js 读取 swagger.json,遍历对象并在有 format 字段时修改 title 或 description,或添加 description 标记原始类型。
const fs = require('fs');
const path = require('path');
const jsonFileUrl = path.join(__dirname, 'app.swagger.json');
function deepFormat(obj) {
if (typeof obj == 'object') {
const keys = Object.keys(obj);
const hasFormat = keys.includes('format');
const hasTitle = keys.includes('title');
const hasDescription = keys.includes('description');
const hasName = keys.includes('name');
const hasType = keys.includes('type');
if (hasFormat && hasTitle) { obj.title = `${obj.title} (${obj.format})`; return; }
if (hasFormat && hasDescription) { obj.description = `${obj.description} (${obj.format})`; return; }
if (hasFormat && hasName && !hasDescription) { obj.description = `原类型为 (${obj.format})`; return; }
if (hasFormat && hasType && !hasName && !hasDescription) { obj.description = `原类型为 (${obj.format})`; return; }
for (const key of keys) {
const value = obj[key];
if (typeof value == 'object') deepFormat(value);
}
return;
}
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const value = obj[i];
if (typeof value == 'object') deepFormat(value);
}
}
}
async function main() {
const jsonOriginString = fs.readFileSync(jsonFileUrl, 'utf8');
const jsonOrigin = JSON.parse(jsonOriginString);
deepFormat(jsonOrigin);
fs.writeFileSync(jsonFileUrl, JSON.stringify(jsonOrigin, null, 2));
}
main();git.sh 用于提交编译后的代码并使用 -o ci.skip 防止循环触发:
#!/bin/bash
# 获取最后一次提交记录
result=$(git log -1 --online)
git status
git add .
git commit -m "${result} 编译 pb 和生成 openapiv2 文档"
git push -o ci.skip http://xx:[email protected]/xx/proto-api-client.git HEAD:master构建成功后通过企业微信 webhook 发送构建结果。
Apifox 更新接口
Apifox 导入数据支持使用在线的数据源,但 GitLab 的数据源需要鉴权,Apifox 目前不支持鉴权。折中方案是将编译后的代码 clone 到 GitRunner,通过 nginx 映射出一个无需鉴权的 URL,填入 Apifox。
客户端更新请求模型
大多数语言在使用 JSON 时需要对应的数据模型,Apifox 的模型生成不够简便。基于 Node.js 开发了一个使用 quicktype 的工具来批量生成模型。
quicktype 接收 JSON Schema 并生成目标语言的模型代码,核心函数如下:
/**
* @description: 单个 Model 转换
*/
async function convertSingleModel(language, messageName, jsonSchemaString, option) {
const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore());
await schemaInput.addSource({ name: messageName, schema: jsonSchemaString });
const inputData = new InputData();
inputData.addInput(schemaInput);
const { lines } = await quicktype({ inputData, lang: language, rendererOptions: option });
return lines.join('
');
}模型写入文件的函数会根据语言特性处理路径和蛇形命名:
function outputSingleModel(modelInfo, outputDir) {
const { name, type, region, suffix, snake } = modelInfo;
let filePath = join(region, type, `${name}.${suffix}`);
if (snake) filePath = snakeNamedConvert(filePath);
filePath = join(outputDir, filePath);
const outputDirPath = dirname(filePath);
try { mkdirSync(outputDirPath, { recursive: true }); } catch (error) { errorLog(`创建目录失败:${outputDirPath}`); }
let { content } = modelInfo;
if (hooks[modelInfo.language]?.after) content = hooks[modelInfo.language].after(content);
try { writeFileSync(filePath, content); } catch (error) { errorLog(`写入文件失败:${filePath}`); }
successLog(`${filePath} 转换成功`);
}配置文件示例(url_config)指定目标语言、swagger 文档 URL、输出路径以及需要转换的接口列表,使用 bb-model 命令即可批量生成模型,显著提升客户端开发效率。
小结
到这里整个 API 工程化一期的流程已经全部完成。后续将加入 proto 文件 lint 检查的支持,接口编译文件将以 tag 形式发布,并加入对 Java 语言的支持。
参考资料
[1] protobuf: https://github.com/protocolbuffers/protobuf
[2] beer-shop: https://github.com/go-kratos/beer-shop
[3] kratos-errors: https://go-kratos.dev/docs/component/errors/
[4] openapiv2: https://github.com/grpc-ecosystem/grpc-gateway/tree/master/protoc-gen-openapiv2
[5] validate: https://github.com/envoyproxy/protoc-gen-validate
[6] apifox: https://www.apifox.cn/
[7] quicktype-core: https://www.npmjs.com/package/quicktype-core
[8] gitrunner: https://docs.gitlab.com/runner/
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
BaiPing Technology
Official account of the BaiPing app technology team. Dedicated to enhancing human productivity through technology. | DRINK FOR FUN!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
