API 工程化分享

鄭子銘 發表於 2022-05-15

概要

本文是學習B站毛劍老師的《API 工程化分享》的學習筆記,分享了 gRPC 中的 Proto 管理方式,Proto 分倉原始碼方式,Proto 獨立同步方式,Proto git submodules 方式,Proto 專案佈局,Proto Errors,服務端和客戶端的 Proto Errors,Proto 文件等等

目錄

  • Proto IDL Management
  • IDL Project Layout
  • IDL Errors
  • IDL Docs

Proto IDL Management

  • Proto IDL
  • Proto 管理方式
  • Proto 分倉原始碼方式
  • Proto 獨立同步方式
  • Proto git submodules 方式

Proto IDL

gRPC 從協議緩衝區使用介面定義語言 (IDL)。協議緩衝區 IDL 是一種與平臺無關的自定義語言,具有開放規範。 開發人員會創作 .proto 檔案,用於描述服務及其輸入和輸出。 然後,這些 .proto 檔案可用於為客戶端和伺服器生成特定於語言或平臺的存根,使多個不同的平臺可進行通訊。 通過共享 .proto 檔案,團隊可生成程式碼來使用彼此的服務,而無需採用程式碼依賴項。

Proto 管理方式

煎魚的一篇文章:真是頭疼,Proto 程式碼到底放哪裡?

文章中經過多輪討論對 Proto 的儲存方式和對應帶來的優缺點,一共有如下幾種方案:

  • 程式碼倉庫
  • 獨立倉庫
  • 集中倉庫
  • 映象倉庫

映象倉庫

API 工程化分享

在我自己的微服務倉庫裡面,有一個 Proto 目錄,就是放我自己的 Proto,然後在我提交我的微服務程式碼到主幹或者某個分支的時候,它可能觸發一個 mirror 叫做自動同步,會映象到這個集中的倉庫,它會幫你複製過去,相當於說我不需要把我的原始碼的 Proto 開放給你,同時還會自動複製一份到集中的倉庫

在煎魚的文章裡面的集中倉庫還是分了倉庫的,B站大倉是一個統一的倉庫。為什麼呢?因為比方像谷歌雲它整個對外的 API 會在一個倉庫,不然你讓使用者怎麼找?到底要去哪個 GitHub 下去找?有這麼多 project 怎麼找?根本找不到,應該建統一的一個倉庫,一個專案就搞定了

我們最早衍生這個想法是因為無意中看到了 Google APIs 這個倉庫。大倉可以解決很多問題,包括高度程式碼共享,其實對於 API 檔案也是一樣的,集中在一個 Repo 裡面,很方便去檢索,去查閱,甚至看文件,都很方便

我們不像其他公司喜歡弄一個 UI 的後臺,我們喜歡 Git,它很方便做擴充套件,包括 CICD 的流程,包括 coding style 的 check,包括相容性的檢測,包括 code review 等等,你都可以基於 git 的擴充套件,gitlab 的擴充套件,GitHub 的一些 actions,做很多很多的工作

Proto 分倉原始碼方式

過去為了統一檢索和規範 API,我們內部建立了一個統一的 bapis 倉庫,整合所有對內對外 API。它只是一個申明檔案。

  • API 倉庫,方便跨部門協作;
  • 版本管理,基於 git 控制;
  • 規範化檢查,API lint;
  • API design review,變更 diff;
  • 許可權管理,目錄 OWNERS;

API 工程化分享

集中式倉庫最大的風險是什麼呢?是誰都可以更改

大倉的核心是放棄了讀許可權的管理,針對寫操作是有微觀管理的,就是你可以看到我的 API 宣告,但是你實際上呼叫不了,但是對於遷入 check in,提到主幹,你可以在不同層級加上 owner 檔案,它裡面會描述誰可以合併程式碼,或者誰負責 review,兩個角色,那就可以方便利用 gitlab 的 hook 功能,然後用 owner 檔案做一些細粒度的許可權管理,針對目錄級別的許可權管理

最終你的同事不能隨便遷入,就是說把檔案的寫許可權,merge 許可權關閉掉,只允許通過 merge request 的評論區去回覆一些指令,比方說 lgtm(looks good to me),表示 review 通過,然後你可以回覆一個 approve,表示這個程式碼可以被成功 check in,這樣來做一些細粒度的許可權檢驗

怎麼遷入呢?我們的想法是在某一個微服務的 Proto 目錄下,把自己的 Proto 檔案管理起來,然後自動同步進去,就相當於要寫一個外掛,可以自動複製到 API 倉庫裡面去。做完這件事情之後,我們又分了 api.go,api.java,git submodule,就是把這些程式碼使用 Google protobuf,protoc 這個編譯工具生成客戶端的呼叫程式碼,然後推到另一個倉庫,也就是把所有客戶端呼叫程式碼推到一個原始碼倉庫裡面去

Proto 獨立同步方式

API 工程化分享

移動端採用自定義工具方式,在同步程式碼階段,自動更新最新的 proto 倉庫到 worksapce 中,之後依賴 bazel 進行構建整個倉庫

  • 業務程式碼中不依賴 target 產物,比如 objective-c 的 .h/.a 檔案,或者 Go 的 .go 檔案(鑽石依賴、proto 未更新問題)

原始碼依賴會引入很多問題

  • 依賴資訊丟失
  • proto 未更新
  • 鑽石依賴

依賴資訊丟失

在你的工程裡面依賴了其他服務,依賴資訊變成了原始碼依賴,你根本不知道依賴了哪個服務,以前是 protobuf 的依賴關係,現在變成了原始碼依賴,服務依賴資訊丟失了。未來我要去做一些全域性層面的程式碼盤點,比方說我要看這個服務被誰依賴了,你已經搞不清楚了,因為它變成了原始碼依賴

proto 未更新

如果我的 proto 檔案更新了,你如何保證這個人重新生成了 .h/.a 檔案,因為對它來說這個依賴資訊已經丟失,為什麼每次都要去做這個動作呢?它不會去生成 .h/.a 檔案

鑽石依賴

當我的 A 服務依賴 B 服務的時候,通過原始碼依賴,但是我的 A 服務還依賴 C 服務,C 服務是通過集中倉庫 bapis 去依賴的,同時 B 和 C 之間又有一個依賴關係,那麼這個時候就可能出現對於 C 程式碼來說可能會註冊兩次,protobuf 有一個約束就是說重名檔案加上包名是不允許重複的,否則啟動的時候就會 panic,有可能會出現鑽石依賴

  • A 依賴 B
  • A 依賴 C
  • A 和 B 是原始碼依賴
  • A 和 C 是 proto 依賴
  • B 和 C 之間又有依賴

那麼它的版本有可能是對不齊的,就是有風險的,這就是為什麼 google basic 構建工具把 proto 依賴的名字管理起來,它並沒有生成 .go 檔案再 checkin 到倉庫裡面,它不是原始碼依賴,它每一次都要編譯,每次都要生成 .go 檔案的原因,就是為了版本對齊

Proto git submodules 方式

經過多次討論,有幾個核心認知:

  • proto one source of truth,不使用映象方式同步,使用 git submodules 方式以倉庫中目錄形式來承載;
  • 本地構建工具 protoc 依賴 go module 下的相對路徑即可;
  • 基於分支建立新的 proto,submodules 切換分支生成 stub 程式碼,同理 client 使用聯調切換同一個分支;
  • 維護 Makefile,使用 protoc + go build 統一處理;
  • 宣告式依賴方式,指定 protoc 版本和 proto 檔案依賴(基於 BAZEL.BUILD 或者 Yaml 檔案)

proto one source of truth

如果只在一個倉庫裡面,如果只有一個副本,那麼這個副本就是唯一的真相併且是高度可信任的,那如果你是把這個 proto 檔案拷來拷去,最終就會變得源頭更新,拷貝的檔案沒辦法保證一定會更新

映象方式同步

實際上維護了本地微服務的目錄裡面有一個 protobuf 的定義,映象同步到集中的倉庫裡面,實際上是有兩個副本的

使用 git submodules 方式以倉庫中目錄形式來承載

git submodules 介紹

子模組允許您將 Git 儲存庫保留為另一個 Git 儲存庫的子目錄。這使您可以將另一個儲存庫克隆到您的專案中並保持您的提交分開。

API 工程化分享

圖中 gateway 這個目錄就是以本地目錄的形式,但是它是通過 git submodules 方式給承載進來的

如果公司內程式碼都在一起,api 的定義都在一起,那麼大倉絕對是最優解,其次才是 git submodules,這也是 Google 的建議

我們傾向於最終 proto 的管理是集中在一個倉庫裡面,並且只有一份,不會做任何的 copy,通過 submodules 引入到自己的微服務裡面,也就是說你的微服務裡面都會通過 submodules 把集中 API 的 git 拷貝到本地專案裡面,但是它是通過 submodeles 的方式來承載的,然後你再通過一系列 shell 的工具讓你的整個編譯過程變得更簡單

IDL Project Layout

Proto Project Layout

在統一倉庫中管理 proto,以倉庫為名

根目錄:

  • 目錄結構和 package 對齊;
  • 複雜業務的功能目錄區分;
  • 公共業務功能:api、rpc、type;

API 工程化分享

目錄結構和 package 對齊

我們看一下 googleapis 大量的 api 是如何管理的?

第一個就是在 googleapis 這個專案的 github 裡面,它的第一級目錄叫 google,就是公司名稱,第二個目錄是它的業務域,業務的名稱

目錄結構和 protobuf 的包名是完全對齊的,方便檢索

複雜業務的功能目錄區分

v9 目錄下分為公共、列舉、錯誤、資源、服務等等

公共業務功能:api、rpc、type

在 googleapis 的根目錄下還有類似 api、rpc、type 等公共業務功能

IDL Errors

  • Proto Errors
  • Proto Errors:Server
  • Proto Errors:Client

Proto Errors

  • 使用一小組標準錯誤配合大量資源
  • 錯誤傳播

用簡單的協議無關錯誤模型,這使我們能夠在不同的 API,API 協議(如 gRPC 或 HTTP)以及錯誤上下文(例如,非同步,批處理或工作流錯誤)中獲得一致的體驗。

API 工程化分享

API 工程化分享

使用一小組標準錯誤配合大量資源

伺服器沒有定義不同型別的“找不到”錯誤,而是使用一個標準 google.rpc.Code.NOT_FOUND 錯誤程式碼並告訴客戶端找不到哪個特定資源。狀態空間變小降低了文件的複雜性,在客戶端庫中提供了更好的慣用對映,並降低了客戶端的邏輯複雜性,同時不限制是否包含可操作資訊。

我們以前自己的業務程式碼關於404,關於某種資源找不到的錯誤碼,定義了上百上千個,請問為什麼大家在設計 HTTP restful 或者 grpc 介面的時候不用人家標準的狀態碼呢?人家有標準的404,或者 not found 的狀態碼,用狀態碼去對映一下通用的錯誤資訊不好嗎?你不可能呼叫一個介面,返回幾十種具體的錯誤碼,你根本對於呼叫者來說是無法使用的。當我的介面返回超過3個自定義的錯誤碼,你就是面向錯誤程式設計了,你不斷根據錯誤碼做不同的處理,非常難搞,而且你每一個介面都要去定義

這裡的核心思路就是使用標準的 HTTP 狀態碼,比方說500是內部錯誤,503是閘道器錯誤,504是超時,404是找不到,401是引數錯誤,這些都是通用的,非常標準的一些狀態碼,或者叫錯誤碼,先用它們,因為不是所有的錯誤都需要我們叫業務上 hint,進一步處理,也就是說我調你的服務報錯了,我大概率是啥都不做的,因為我無法糾正服務端產生的一個錯誤,除非它是帶一些業務邏輯需要我做一些跳轉或者做一些特殊的邏輯,這種不應該特別多,我覺得兩個三個已經非常多了

所以說你會發現大部分去呼叫別人介面的時候,你只需要用一個通用的標準的狀態碼去對映,它會大大降低客戶端的邏輯複雜性,同時也不限制說你包含一些可操作的 hint 的一些資訊,也就是說你可以包含一些指示你接下來要去怎麼做的一些資訊,就是它不衝突

錯誤傳播

如果您的 API 服務依賴於其他服務,則不應盲目地將這些服務的錯誤傳播到您的客戶端。

舉個例子,你現在要跟移動端說我有一個介面,那麼這個介面會返回哪些錯誤碼,你始終講不清楚,你為什麼講不清楚呢?因為我們整個微服務的呼叫鏈是 A 調 B,B 調 C,C 調 D,D 的錯誤碼會一層層透傳到 A,那麼 A 的錯誤碼可能會是 ABCD 錯誤碼的並集,你覺得你能描述出來它返回了哪些錯誤碼嗎?根本描述不出來

所以對於一個服務之間的依賴關係不應該盲目地將下游服務產生的這些錯誤碼無腦透傳到客戶端,並且曾經跟海外很多公司,像 Uber,Twitter,Netflix,跟他們很多的華人的朋友交流,他們都不建議大家用這種全域性的錯誤碼,比方 A 部門用 01 開頭,B 部門用 02 開頭,類似這樣的方式去搞所謂的君子契約,或者叫鬆散的沒有約束的脆弱的這種約定

在翻譯錯誤時,我們建議執行以下操作:

  • 隱藏實現詳細資訊和機密資訊
  • 調整負責該錯誤的一方。例如,從另一個服務接收 INVALID_ARGUMENT 錯誤的伺服器應該將 INTERNAL 傳播給它自己的呼叫者。

比如你返回的錯誤碼是4,代表商品已下架,我對這個錯誤很感興趣,但是錯誤碼4 在我的專案裡面已經被用了,我就把它翻譯為我還沒使用的錯誤碼6,這樣每次翻譯的時候就可以對上一層你的呼叫者,你就可以交代清楚你會返回錯誤碼,因為都是你定義的,而且是你翻譯的,你感興趣的才翻譯,你不感興趣的通通返回 500 錯誤,就是內部錯誤,或者說 unknown,就是未知錯誤,這樣你每個 API 都能講清楚自己會返回哪些錯誤碼

API 工程化分享

在 grpc 傳輸過程中,它會要求你要實現一個 grpc states 的一個介面的方法,所以在 Kraots 的 v2 這個工程裡面,我們先用前面定義的 message Error 這個錯誤模型,在傳輸到 grpc 的過程中會轉換成 grpc 的 error_details.proto 檔案裡面的 ErrorInfo,那麼在傳輸到 client 的時候,就是呼叫者請求服務,service 再返回給 client 的時候再把它轉換回來

也就是說兩個服務使用一個框架就能夠對齊,因為你是基於 message Error 這樣的錯誤模型,這樣在跨語言的時候同理,經過 ErrorInfo 使用同樣的模型,這樣就解決了跨語言的問題,通過模型的一致性

API 工程化分享

Proto Errors:Server

API 工程化分享

errors.proto 定義了 Business Domain Error 原型,使用最基礎的 Protobuf Enum,將生成的原始碼放在 biz 大目錄下,例如 biz/errors

  • biz 目錄中核心維護 Domain,可以直接依賴 errors enum 型別定義;
  • data 依賴並實現了 biz 的 Reporisty/ACL,也可以直接使用 errors enum 型別定義;
  • TODO:Kratos errors 需要支援 cause 儲存,支援 Unwrap();

在某一個微服務工程裡面,errors.proto 檔案實際上是放在 API 的目錄定義,之前講的 API 目錄定義實際上是你的服務裡面的 API 目錄,剛剛講了一個 submodules,現在你可以理解為這個 API 目錄是另外一個倉庫的 submodules,最終你是把這些資訊提交到那個 submodules,然後通過 reference 這個 submodules 獲取到最新的版本,其實你可以把它打成一個本地目錄,就是說我的定義宣告是在這個地方

這個 errors.proto 檔案其實就列舉了各種錯誤碼,或者叫錯誤的字串,我們其實更建議大家用字串,更靈活,因為一個數字沒有寫文件前你根本不知道它是幹啥的,如果我用字串的話,我可以 user_not_found 告訴你是使用者找不到,但是我告訴你它是3548,你根本不知道它是什麼含義,如果我沒寫文件的話

所以我們建議使用 Protobuf Enum 來定義錯誤的內容資訊,定義是在這個地方,但是生成的程式碼,按照 DDD 的戰術設計,屬於 Domain,因為業務設計是屬於領域的一個東西,Domain 裡面 exception 它最終的原始碼會在哪?會在 biz 的大目錄下,biz 是 business 的縮寫,就是在業務的目錄下,舉個例子,你可以放在 biz 的 errors 目錄下

有了這個認知之後我們會做三個事情

首先你的 biz 目錄維護的是領域邏輯,你的領域邏輯可以直接依賴 biz.errors 這個目錄,因為你會拋一些業務錯誤出去

第二,我們的 data 有點像 DDD 的 infrastructure,就是所謂的基礎設施,它依賴並實現了 biz 的 repository 和 acl,repository 就是我們所謂的倉庫,acl 是防腐層

因為我們之前講過它的整個依賴倒置的玩法,就是讓我們的 data 去依賴 biz,最終讓我們的 biz 零依賴,它不依賴任何人,也不依賴基礎設施,它把 repository 和 acl 的介面定義放在 biz 自己目錄下,然後讓 data 依賴並實現它

也就是說最終我這個 data 目錄也可以依賴 biz 的 errors,我可能通過查 mysql,結果這個東西查不到,會返回一個 sql no rows,但肯定不會返回這個錯誤,那我就可以用依賴 biz 的這個 errors number,比如說 user_not_found,我把它包一個 error 丟擲去,所以它可以依賴 biz 的 errors

目前 Kratos 還不支援根因儲存,根因儲存是什麼呢?剛剛說了你可能是 mysql 報了一個內部的錯誤,這個內部錯誤你實際上在最上層的傳輸框架,就是 HTTP 和 grpc 的 middleware 裡面,你可能會把日誌打出來,就要把堆疊資訊打出來,那麼根因儲存就是告訴你最底層發生的錯誤是什麼

不支援 Unwrap 就是不支援遞迴找根因,如果支援根因以後呢,就可以讓 Kratos errors 這個 package 可以把根因傳進去,這樣子既能搞定我們 go 的 wrap errors,同時又支援我們的狀態碼和 reason,大類錯誤和小類錯誤,大類錯誤就是狀態碼,小類錯誤就是我剛剛說的用 enum 定義的具體資訊,比方說這個商品被下架,這種就不太好去對映一個具體的錯誤碼,你可能是返回一個500,再帶上一個 reason,可能是這樣的一個做法

Proto Errors:Client

從 Client 消費端只能看到 api.proto 和 error.proto 檔案,相應的生成的程式碼,就是呼叫測的 api 以及 errors enum 定義

  • 使用 Kratos errors.As() 拿到具體型別,然後通過 Reason 欄位進行判定;
  • 使用 Kratos errors.Reason() helper 方法(內部依賴 errors.As)快速判定;

拿到這兩個檔案之後你可以生成相應程式碼,然後呼叫 api

API 工程化分享

舉個例子,圖中的程式碼是呼叫服務端 grpc 的某一個方法,那麼我可能返回一個錯誤,我們可以用 Kratos 提供的一個 Reason 的 short car,一個快捷的方法,然後把 error 傳進去,實際上在內部他會呼叫標準庫的 error.As,把它強制轉換成 Kratos 的 errors 型別,然後拿到裡面的 Reason 的欄位,然後再跟這個列舉值判定,這樣你就可以判定它是不是具體的一個業務錯誤

第二種寫法你可以拿到原始的我們 Kratos 的 Error 模型,就是以下這個模型

API 工程化分享

new 出來之後用標準庫的 errors.As 轉換出來,轉換出來之後再用 switch 獲取它裡面的 reason 欄位,然後可以寫一些業務邏輯

這樣你的 client 程式碼跨語言,跨傳輸,跨協議,無論是 grpc,http,同樣是用一樣的方式去解決

IDL Docs

  • Proto Docs

Proto Docs

基於 openapi 外掛 + IDL Protobuf 註釋(IDL 即定義,IDL 即程式碼,IDL 即文件),最終可以在 Makefile 中使用 make api 生成 openapi.yaml,可以在 gitlab/vscode 外掛直接檢視

  • API Metadata 元資訊用於微服務治理、除錯、測試等;

因為我們可以在 IDL 檔案上面寫上大量的註釋,那麼當講到這個地方,你就明白了 IDL 有什麼樣的好處?

IDL 檔案它既定義,同時又是程式碼,也就是說你既做了宣告,然後使用 protoc 可以去生成程式碼,並且是跨語言的程式碼,同時 IDL 本身既文件,也就是說它才真正滿足了 one source of truth,就是唯一的事實標準

最終你可以在 Makefile 中定義一個 api 指令,然後生成一個 openapi.yaml,以前是 swagger json,現在叫 openapi,用 yaml 宣告

API 工程化分享

生成 yaml 檔案以後,現在 gitlab 直接支援 openapi.yaml 檔案,所以你可以直接開啟 gitlab 去點開它,就能看到這樣炫酷的 UI,然後 VSCode 也有一個外掛,你可以直接去檢視

還有一個很關鍵的點,我們現在的 IDL 既是定義,又是程式碼,又是文件,其實 IDL 還有一個核心作用,這個定義表示它是一個元資訊,是一個後設資料,最終這個 API 的 mate data 元資訊它可以用於大量的微服務治理

因為你要治理的時候你比方說對每個服務的某個介面進行路由,進行熔斷進行限流,這些元資訊是哪來的?我們知道以前 dubbo 2.x,3.x 之前都是把這些元資訊註冊到註冊中心的,導致整個資料中心的儲存爆炸,那麼元資訊在哪?

我們想一想為什麼 protobuf 是定義一個檔案,然後序列化之後它比 json 要小?因為它不是自描述的,它的定義和序列化是分開的,就是原始的 payload 是沒有任何的定義資訊的,所以它可以高度的compressed,可被壓縮,或者說叫更緊湊

所以說同樣的道理,IDL 的定義和它的元資訊,和生成程式碼是分開的話,意味著你只要有 one source of truth 這份唯一的 pb 檔案,基於這個 pb 檔案,你就有辦法把它做成一個 api 的 metadata 的服務,你就可以用於做微服務的治理

你可以選一個服務,然後看它有些什麼介面,然後你可以通過一個管控面去做熔斷、限流等功能,然後你還可以基於這個元資訊去除錯,你做個炫酷的 UI 可以讓它有一些引數,甚至你可以寫一些擴充套件,比方說這個欄位叫 etc,建議它是什麼樣的值,那麼你在渲染 UI 的時候可以把預設值填進去,那你就很方便做一些除錯,甚至包含測試,你基於這個 api 去生成大量的 test case

參考

API 工程化分享
https://www.bilibili.com/video/BV17m4y1f7qc/

介面定義語言
https://docs.microsoft.com/zh-cn/dotnet/architecture/grpc-for-wcf-developers/interface-definition-language

真是頭疼,Proto 程式碼到底放哪裡?
https://mp.weixin.qq.com/s/cBXZjg_R8MLFDJyFtpjVVQ

git submodules
https://git-scm.com/book/en/v2/Git-Tools-Submodules

kratos
https://github.com/go-kratos/kratos

error_details.proto
https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto#L112

pkg/errors
https://github.com/pkg/errors

Modifying gRPC Services over Time

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

如有任何疑問,請與我聯絡 ([email protected]) 。