還在用 Postman?Protobuf + Apifox + GitLab 給你 API 工程化極致體驗

百瓶技術發表於2022-04-11

公眾號名片
作者名片

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 標識。如圖:

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: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 命令觸發轉換,只需要在專案中放置一個配置檔案即可,配置檔案內容如下:

url_config

具體欄位含義:

language:目標語言
indexUrl:swagger 文件 Url
output:輸出路徑,相對於當前配置檔案
apis:需要轉換的介面

使用 bb-model 命令輸出如下

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/

更多精彩請關注我們的公眾號「百瓶技術」,有不定期福利呦!

相關文章