乐趣区

关于gitlab:还在用-PostmanProtobuf-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 的 谬误 解决形式。

谬误文件如下:

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]](assets/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:xxx!@gitlab.xx.xx/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 标识。如图:

脚本如下:

#!/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:xx!@gitlab.xx.xx/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 命令触发转换,只须要在我的项目中搁置一个配置文件即可,配置文件内容如下:

具体字段含意:

language:目标语言
indexUrl:swagger 文档 Url
output:输入门路,绝对于以后配置文件
apis:须要转换的接口

应用 bb-model 命令输入如下

这个计划的配置文件能够随着我的项目一起由版本控制工具治理,利于多成员合作,后续集成到 CI/CD 中也很简略。

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

小结

到这里整个 API 工程化一期的流程曾经全副实现。后续将退出 proto 文件 lint 查看的反对,接口编译文件将会以 tag 的模式公布,退出对 java 语言的反对。

参考资料

[1] protobuf: https://github.com/protocolbu…

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

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

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

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

[6] apifox: https://www.apifox.cn/

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

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

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

退出移动版