还在用 Postman?Protobuf + Apifox + GitLab 给你 API 工程化极致体验

公众号名片 作者名片

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 的 错误 处理方式。

扫描二维码关注公众号,回复: 13783274 查看本文章

错误文件如下:

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 结尾。这样做的好处是:规范统一、避免有相同的 message 在生成 swagger 文档导入到 Apifox 时模型被覆盖。为了快速编写接口可以使用 GoLand 和 IDEA 自带代码模板,快速编写。

create_proto_gif

那么 proto 接口文件编写到这里已经结束了,整个思想借鉴了 kratos 的官方示例项目 beer-shop

编译发布 Protobuf 文件

因为编写的 proto 文件需要 CodeReview,而且每个开发人员本地编译环境可能不一致,所以编译这个流程统一放在 GitRunner 上,由 MergerRequest 合并后触发 GitRunner 在 Linux 上编译所有的 proto 文件。关于在 Linux 上 安装 Go 环境和相关的编译插件,就不在这里赘述了。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>\n>项目名称:$CI_PROJECT_NAME\n>提交日志:$CI_COMMIT_MESSAGE\n>流水线地址:[$CI_PIPELINE_URL]($CI_PIPELINE_URL)\"}}"
    - ./index.sh
复制代码

before_script 的内容就是配置文件权限和 git 的账号密码,after_script 输出编译结束的语句 build1only.refs 就是指定只在 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.git
复制代码

gen_app.sh 用于编译客户端接口。

#!/bin/bash

# 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 api/app -name *errors.proto) 穷举所有以 errors.proto 结尾的文件,client-errors_out 是下载了 kratos errors 的源码重新编译的命令,同 kratos errors 的用法。

枚举处理

$(find api/app -name *enums.proto) 穷举所有以 enums.proto 结尾的文件。

接口处理

$(find api/app/*/v* -name *.proto) 穷举所有接口文件,new-http_outnew-grpc_out 是为支持公司自研框架编译的命令。

参数校验

new-validate_out 是因为 validate 这个参数校验插件在 linux 环境编译的时候和枚举有冲突(笔者还没解决),所以下载源码重新编译了命令。编译结果如下:

编译结果

openapiv2_out 使用的是 openapiv2 插件,allow_merge=true,merge_file_name=app 参数合并所有的接口文件为一个名字 app.swagger.json 的接口文档。logtostderr=true 参数为开启日志,该命令会到一个 app.swagger.json 的文件,这个文件可以导入到 Apifox 中使用。Apifox 真的是一个神器,大大简化接口相关的工作,对于 Apifox 的使用这里不在赘述,请看 官网。编译文档如下:

编译结果

format_json.sh 因为 openapiv2 插件会把 int64 类型的数据在接口文档上显示为 string 类型,为了方便 前端同学区分接口文档中的 string 类型是不是由 int64 类型转的,所以编写了一个 js 文件用来对生成的 swagger.json 文档进行修改,修改后的文档会在由 int64 转成的 string 类型的字段描述中添加 int64 标识。如图:

convert_int64

脚本如下:

#!/bin/bash

node ./format.js
复制代码

用 node 来执行修改编译出的 swagger.json 文档的 js 代码。

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 (let i = 0; i < keys.length; i++) {
      const key = keys[i];
      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 参数用于在此次提交中不再触发 GitRunner 避免循环触发。

#!/bin/bash

# 获取最后一次 提交记录
result=$(git log -1 --online)

# git
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
复制代码

curl https://qyapi.weixin.qq.com/cgi-bin/webhook/send... 用于构建成功后给工作群发送构建结果。这里使用的是企业微信。具体怎么使用这里不再赘述。效果如下:

通知结果

index.sh 再次 clone 编译后的代码到 GitRunner 服务器。

Apifox 更新接口

Apifox 导入数据支持使用在线的数据源,因为在使用 GitLab 的数据源 url 的时候需要鉴权,而 Apifox 目前不支持鉴权,所以想了一个折中的方案,在提交编译后的代码后,将代码再 clone 到 GitRunner,通过 nginx 映射出一个不需要鉴权的数据源 url。将 不需要鉴权的 url 填入 Apifox。

构建结果

客户端更新请求模型

众所周知,除 JavaScript 外的大多数语言在使用 JSON 时需要对应的数据模型,虽然 Apifox 提供了生成数据模型的功能,但是不够简便,接口有改动需要手动生成并且替换到项目内,开发体验并不是很好。

针对以上的痛点,基于 Node.js 开发了一个使用简单,功能强大的工具。

数据模型生成

首先要解决的问题是数据模型怎么生成,经过调研,发现已经有很多优秀的轮子走在前面,可以开箱即用,此处感慨开源的力量是无限的。
最后选择了 quicktype, 开发者提供了在线工具,而将使用它的核心依赖包 quicktype-core 来开发自己的工具。

quicktype 可以接收一个 JSON Schema 格式的 Model 描述字符串,根据目标语言的设置,转换为模型字符串数组,拼装后输出到指定文件内。

调用方法如下:

/**
 * @description: 单个 Model 转换
 * @param {string} language 目标语言
 * @param {string} messageName Model 名称
 * @param {string} jsonSchemaString Model JSON Schema 字符串
 * @param {LanguageOptions} option 目标语言的附加设置
 * @return {string} 转换后的 Model 内容
 */
async function convertSingleModel(
  language: string,
  messageName: string,
  jsonSchemaString: string,
  option: LanguageOptions
): Promise<string> {
  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('\n');
}

...

/**
 * @description: 单个转换后的 Model 写入文件
 * @param {ModelInfo} modelInfo 转换后的 Model 信息
 * @param {string} outputDir 输出目录
 * @return {*}
 */
function outputSingleModel(modelInfo: ModelInfo, outputDir: string): void {
  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 {
    fs.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} 转换成功`);
}
复制代码

要注意的是,当输入的对象中有嵌套对象的时候,转换器会在传入的 JSON Schema 中的 definitions 字段寻找对应的引用,所以需要传入完整的 definitions,或者提前对对象递归查找会引用到的对象提取出来重新拼装 JSON Schema。

提效

上面完成了对一个 Model 的转换和输出,这样还做不到提效,如果可以做到批量转换想要的接口的 Model,岂不美哉?

为了满足上面的目标,工具以 npm 包形式提供,全局安装可以使用 bb-model 命令触发转换,只需要在项目中放置一个配置文件即可,配置文件内容如下:

url_config

具体字段含义:

language:目标语言
indexUrl:swagger 文档 Url
output:输出路径,相对于当前配置文件
apis:需要转换的接口

使用 bb-model 命令输出如下

model

这个方案的配置文件可以随着项目一起由版本控制工具管理,利于多成员协作,后续集成到 CI/CD 中也很简单。

Model 转换是百瓶 API 工程化一期的最后一块拼图,大大提升了客户端同学的开发效率。

小结

到这里整个 API 工程化一期的流程已经全部完成。后续将加入 proto 文件 lint 检查的支持,接口编译文件将会以 tag 的形式发布,加入对 java 语言的支持。

参考资料

[1] protobuf: github.com/protocolbuf…

[2] beer-shop: github.com/go-kratos/b…

[3] kratos-errors: go-kratos.dev/docs/compon…

[4] openapiv2: github.com/grpc-ecosys…

[5] validate: github.com/envoyproxy/…

[6] apifox: www.apifox.cn/

[7] quicktype-core: www.npmjs.com/package/qui…

[8] gitrunner: docs.gitlab.com/runner/

更多精彩请关注我们的公众号「百瓶技术」,有不定期福利呦!

猜你喜欢

转载自juejin.im/post/7085231962767491086