API Design
gRPC
gRPC是什麼可以用官網的一句話來概括 "A high-performance, open-source universal RPC framework"
- 多語言:語言中立,支持多種語言。
- 輕量級、高性能:序列化支持 PB(Protocol Buffer)和 JSON,PB 是一種語言無關的高性能序列化框架。
- 可插拔
- IDL(Interface description language):基於文件定義服務,通過 proto3 工俱生成指定語言的數據結構、服務端接口以及客戶端 Stub。
- 設計理念
- 移動端:基於標準的 HTTP2 設計,支持雙向流、消息頭壓縮、單 TCP 的多路復用、服務端推送等特性,這些特性使得 gRPC 在移動端設備上更加省電和節省網絡流量。

- 服務而非對象、消息而非引用:促進微服務的系統間粗粒度消息交互設計理念。
- 負載無關的:不同的服務需要使用不同的消息類型和編碼,例如 protocol buffers、JSON、XML和Thrift。
- 流: Streaming API。
- 阻塞式和非阻塞式:支持異步和同步處理在客戶端和服務端間交互的消息序列。
- 元數據交換(metadata):常見的橫切關注點,如認證或跟踪,依賴數據交換。
- 標準化狀態碼:客戶端通常以有限的方式響應 API 調用返回的錯誤。
不要過早關注性能問題,先標準化。
語法
syntax = "proto3";
package rpc_package;
// define a service
service HelloWorldService {
// define the interface and data type
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
// define the data type of request
message HelloRequest {
string name = 1;
}
// define the data type of response
message HelloReply {
string message = 1;
}
定義 Message Type
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
- field:在 message 中每一行都是一個 field,並包含有名稱(name)和型別(type)
- field type:每個欄位的第一個值是用來定義該欄位的型別
- field number:每個欄位的最後有一個數字,這是數字將是唯一值(unique number),用來在 message binary format 中辨認欄位用
field number
可以看到每一個欄位都含有一個獨特的數字(unique number),這個數字是用來在 message binary format 中辨認欄位用的,一旦這個 message type 正在使用中就不應該再改變它,因此撰寫時並不一定要照著數字的順序,只要確保它是唯一個。由於數字 1~15 會使用 1 個 byte,數字 16~2047 會使用 2 個 byte,因此一般來說,會把最常用到的欄位使用 1~15 來編碼,但也要記得預留 1~15 的空間,因為未來可能會新增其他常用到的欄位。
數字可以從 1 一直到 536,870,911(2^29 -1),但你不能使用 19000~19999 這個區間,因是這個區間是給 Protocol Buffer 實作用的
被保留的欄位(reserved fields)
在建立好 proto 檔後,未來更新 message 時若是直接移除(註解)掉某個欄位時,後續維護的開發者在不知情的情況下,可能會用到曾經被使用過的 field number,由於 gRPC 是透過 field number 來辨別欄位,因此重複的 field number 將會導致嚴重的問題。
為了避免這個問題,是把曾經使用過的 field numbers 註記保留下來,未來 protocol buffer 在 compiled 時如果有使用到這些 reserved fields 時就會提出警告。
message Foo {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
}
Source from : https://pjchender.dev/internet/note-protocol-buffer/ 範例 : https://github.com/kimi0230/practise-gRPC-go
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
helloworld/helloworld.proto
API Project
- https://github.com/googleapis/googleapis
- https://github.com/envoyproxy/data-plane-api
- https://github.com/istio/api
為了統一檢索和規範 API,我們內部建立了一個統一的 bapis 倉庫,整合所有對內對外 API。
- API 倉庫,方便跨部門協作。
- 版本管理,基於 git 控制。
- 規範化檢查,API lint。
- API design review,變更 diff。
- 權限管理,目錄 OWNERS。

API Project Layout
項目中定義 proto, 以 api 為包名根目錄:

再統一倉庫中管理 proto, 以倉庫為包名根目錄:


API Compatibility
向後兼容(非破壞性)的修改
- 給 API 服務定義添加 API 接口
- 從協議的角度來看,這始終是安全的。
- 給請求消息(request)添加字段
- 只要客戶端在新版和舊版中對該字段的處理不保持一致,添加請求字段就是兼容的。
- 給響應消息(response)添加字段
- 在不改變其他響應字段的行為的前提下,非資源(例如,ListBooksResponse)的響應消息可以擴展而不必破壞客戶端的兼容性。即使會引入冗餘,先前在響應中填充的任何字段應繼續使用相同的語義填充。
向後不兼容(破壞性)的修改
- 刪除或重命名服務,字段,方法或枚舉值
- 從根本上說,如果客戶端代碼可以引用某些東西,那麼刪除或重命名它都是不兼容的變化,這時必須修改major 版本號。
- 修改字段的類型
- 即使新類型是傳輸格式兼容的,這也可能會導致客戶端庫生成的代碼發生變化,因此必須增加major版本號。對於編譯型靜態語言來說,會容易引入編譯錯誤。
- 修改現有請求的可見行為
- 客戶端通常依賴於 API 行為和語義,即使這樣的行為沒有被明確支持或記錄。因此,在大多數情況下,修改 API 數據的行為或語義將被消費者視為是破壞性的。如果行為沒有加密隱藏,您應該假設用戶已經發現它,並將依賴於它。
- 給資源消息添加 讀取/寫入字段
API Naming Conventions
包名為應用的標識(APP_ID),用於生成 gRPC 請求路徑,或者 proto 之間進行引用 Message。 件中聲明的包名稱應該與產品和服務名稱保持一致。 帶有版本的 API 的軟件包名稱必須以此版本結尾。
- my.package.v1,為 API 目錄,定義service相關接口,用於提供業務使用。
// RequestURL: /<package_name>.<version>.<service_name>/{method}
package <package_name>.<version>;
| API 名稱 | 範例 |
|---|---|
| 產品名稱 | Google Calendar API |
| 服務名稱 | calendar.gooleapis.com |
| 軟件包名稱 | google.calender.v3 |
| 接口名稱 | google.calendar.v3.CalendarService |
| 來源目錄 | //google/calendar/v3 |
| API 名稱 | calendar |
https://github.com/googleapis/googleapis/blob/master/google/example/library/v1/library.proto
package_name, 應用的標識(APP_ID) : google.example.library 也同於目錄結構 /google/example/library
version : v1
service_name : LibraryService
method : CreateShelf
RequestURL: /
gRPC建議一定要定義 request 對象 不用使用內建的 message Empty {} , 方便以後擴長
syntax = "proto3";
package google.example.library.v1;
// ...
service LibraryService {
option (google.api.default_host) = "library-example.googleapis.com";
// Creates a shelf, and returns the new Shelf.
rpc CreateShelf(CreateShelfRequest) returns (Shelf) {
option (google.api.http) = {
post: "/v1/shelves"
body: "shelf"
};
option (google.api.method_signature) = "shelf";
}
// Gets a shelf. Returns NOT_FOUND if the shelf does not exist.
rpc GetShelf(GetShelfRequest) returns (Shelf) {
option (google.api.http) = {
get: "/v1/{name=shelves/*}"
};
option (google.api.method_signature) = "name";
}
// Lists shelves. The order is unspecified but deterministic. Newly created
// shelves will not necessarily be added to the end of this list.
rpc ListShelves(ListShelvesRequest) returns (ListShelvesResponse) {
option (google.api.http) = {
get: "/v1/shelves"
};
}
//...
}
// ...
API Primitive Fields
gRPC 默認使用 Protobuf v3 格式, 因為去除了 required 和 optional 關鍵字, 默認全部都是 optional 字段。如果沒有賦值的字段, 默認會基礎類型字段的默認值,比如 0 或者 ""。
// proto2
message Account {
required string name = 1;
// 預設值 -0.0
optional double profit_rate = 2 [default=-1.0];
}
// proto3
message Account {
string name = 1;
// 預設值 0.0
double profit_rate = 2;
}
- Protobuf v3 中,建議使用:https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/wrappers.proto
- Warpper 類型的字段,即包裝一個 message,使用時變為指針。
// Wrapper message for `double`. // // The JSON representation for `DoubleValue` is JSON number. message DoubleValue { // The double value. double value = 1; }
import "google/protobuf/wrappers.proto";
message Account {
string name = 1;
// 預設值 0.0
google.protobuf.DoubleValue profit_rate = 2;
}
Protobuf 作為強 schema 的描述文件,也可以方便擴展,是不是用於配置文件定義也可? 可以
validation :
- https://github.com/grpc-ecosystem/go-grpc-middleware
- https://pandaychen.github.io/2020/06/13/KRATOS-WARDEN-GRPC-VALIDATOR/
API Errors
使用一小組標準錯誤配合大量資源
例如,服務器沒有定義不同類型的"找不到"錯誤,而是使用一個標準 google.rpc.Code.NOT_FOUND 錯誤代碼並告訴客戶端找不到哪個特定資源。 狀態空間變小降低了文檔的複雜性,在客戶端庫中提供了更好的慣用映射, 並降低了客戶端的邏輯複雜性,同時不限制是否包含可操作信息(/google/rpc/error_details)。
錯誤傳播
如果您的 API 服務依賴於其他服務,則不應盲目地將這些服務的錯誤傳播到您的客戶端。 在翻譯錯誤時,我們建議執行以下操作:
- 隱藏實現詳細信息和機密信息。
- 調整負責該錯誤的一方。例如,從另一個服務接收 INVALID_ARGUMENT 錯誤的服務器應該將 INTERNAL 傳播給它自己的調用者
| HTTP | RPC | 錯誤消息範例 |
|---|---|---|
| 400 | INVALID_ARGUMENT | 請求字段xy.Z是xxx,預期為 lyy, Z221 內的一個。 |
| 400 | FAILED_PRECONDITION | 資源 xxx 是非空目錄,因此無法刪除。 |
| 400 | OUT_OF_RANGE | 參數"age”超出範圍[0,125]。 |
| 401 | UNAUTHENTICATED | 身份驗證憑據無效 |
| 403 | PERMISSION_DENIED | 使用權限"xxx"處理資源"yyy'被拒絕。 |
| 404 | NOT FOUND | 找不到資源"xxx”。 |
| 409 | ABORTED | 無法鎖定資源"xxx"。 |
| 409 | ALREADY_EXISTS | 資源"xxx'已經存在。 |
| 429 | RESOURCE_EXHAUSTED | 超出配額限制"xxx〞 |
| 499 | CANCELLED | 請求被客戶端取消 |
| 500 | DATA_LOSS | 請參閱註釋, |
| 500 | UNKNOWN | 請參閱註釋。 |
| 500 | INTERNAL | 請參閱註釋。 |
| 501 | NOT_IMPLEMENTED | 方法"xxx"未實現. |
| 503 | UNAVAILABLE | 請參閱註釋, |
| 504 | DEADLINE EXCEEDED | 請參閱備註。 |
Sample
// dao
func BatchGetGirl() ([]Girl, error) {
rows, err := db.Query("SELECT * FROM girls WHERE love = 10")
if err != nil {
// ...
}
err := rows.Err()
if err != nil {
// sql.ErrNoRows
// bussines code
// stack stace
return errors.Wrapf(code.ErrNoFound, fmt.Sprintf("query: %s failed(%v)", sql, err))
}
return []Girl{/**/}, nil
}
// biz
func Usecase() error {
// Is errors 1.13 Unwrap => root cause error,
// bussiness code
// sql or mongodb or hbase
if errors.Is(code.ErrNotFound, err){
}
}
全局錯誤碼
全局錯誤碼,是鬆散、易被破壞契約的, 基於我們上述討論的,在每個服務傳播錯誤的時候,做一次翻譯, 這樣保證每個服務 + 錯誤枚舉,應該是唯一的, 而且在 proto 定義中是可以寫出來文檔的。
https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto
message ErrorInfo {
// The reason of the error. This is a constant value that identifies the
// proximate cause of the error. Error reasons are unique within a particular
// domain of errors. This should be at most 63 characters and match
// /[A-Z0-9_]+/.
string reason = 1;
// The logical grouping to which the "reason" belongs. The error domain
// is typically the registered service name of the tool or product that
// generates the error. Example: "pubsub.googleapis.com". If the error is
// generated by some common infrastructure, the error domain must be a
// globally unique value that identifies the infrastructure. For Google API
// infrastructure, the error domain is "googleapis.com".
string domain = 2;
// Additional structured details about this error.
//
// Keys should match /[a-zA-Z0-9-_]/ and be limited to 64 characters in
// length. When identifying the current value of an exceeded limit, the units
// should be contained in the key, not the value. For example, rather than
// {"instanceLimit": "100/request"}, should be returned as,
// {"instanceLimitPerRequest": "100"}, if the client exceeds the number of
// instances that can be created in a single (batch) request.
map<string, string> metadata = 3;
}
https://github.com/googleapis/googleapis/blob/master/google/rpc/status.proto
// The `Status` type defines a logical error model that is suitable for
// different programming environments, including REST APIs and RPC APIs. It is
// used by [gRPC](https://github.com/grpc). Each `Status` message contains
// three pieces of data: error code, error message, and error details.
//
// You can find out more about this error model and how to work with it in the
// [API Design Guide](https://cloud.google.com/apis/design/errors).
message Status {
// The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code].
int32 code = 1;
// A developer-facing error message, which should be in English. Any
// user-facing error message should be localized and sent in the
// [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client.
string message = 2;
// A list of messages that carry the error details. There is a common set of
// message types for APIs to use.
repeated google.protobuf.Any details = 3;
}
將FieldMask 添加到請求消息中
API 設計者可以將field_mask 字段添加到請求消息中,而不是創建一次性的"包含"字段:
import "google/protobuf/field_mask.proto";
message GetProductionRequest {
string production_id = 1;
google.protobuf.FieldMask field_mask = 2;
}
消費者可以為他們希望在響應中收到的字段設置路徑。如果消費者只對標題和格式感興趣,他們可以設置帶有"title"和"format"路徑的FieldMask:
FieldMask fieldMask = FieldMask.newBuilder()
.addPaths("title")
.addPaths("format")
.build();
GetProductionRequest request = GetProductionRequest.newBuilder()
.setProductionId(LA_CASA_DE_PAPEL_PRODUCTION_ID)
.setFieldMask(fieldMask)
.build();
如果消費者只需要最後一個更新日程表的人的標題和電子郵件,他們可以設置不同的字段掩碼:
FieldMask fieldMask = FieldMask.newBuilder()
.addPaths("title")
.addPaths("schedule.last_updated_by.email")
.build();
GetProductionRequest request = GetProductionRequest.newBuilder()
.setProductionId(LA_CASA_DE_PAPEL_PRODUCTION_ID)
.setFieldMask(fieldMask)
.build();