跳轉到

Protocol

分散式系統中,很重要的一塊是「不同的服務間,彼此的溝通方式是什麼?」 比起撰寫完整又清楚的 API 文件,有沒有什麼好方法可以讓服務和服務之間同步 API?

以下討論皆假設:溝通是透過網路,且以 HTTP 協定為基礎。

OSI (Open Systems Interconnection)

  • 應用層(Application Layer)
  • 表達層(Presentation Layer)
  • 會議層(Session Layer)
  • 傳輸層(Transport Layer)
  • 網路層(Network Layer)
  • 資料連結層(Data Link Layer)
  • 實體層(Physical Layer)

OSI 七層的簡易說明,左邊代表其包裝資料後的單位名稱
OSI 七層的簡易說明,左邊代表其包裝資料後的單位名稱

HTTP

POST / HTTP/1.1
Host: www.example.com
Content-Type: application/json
Content-Length: 15

{"name":"evan.lu"}

空行後的下一行即為代表本次請求body,範例中的 body 是常見的 JSON 格式。

由此,可以想像 JSON 格式是在應用層之上的第八層

JSON

單純透過 JSON 傳遞有什麼缺點?

  1. 正確的資料格式應該要長什麼樣子?
  2. 使用者需要閱讀相關文件,有辦法讓機器自動處理嗎?

為了解決上述問題,就會有其他 protocol 需要被引入。

不過除了用其他協定,也有一些方式可以舒緩(降低)上述發生的問題,如:

上述僅是制定一些規範,讓使用者在閱讀相關 API 文件時,能較快進入狀況。

GraphQL

GraphQL 讓使用者在跟服務要取資料的時候能指定特定資料,這有幾個好處:

  • 可以拿到最準確的資料,減少網路傳輸
  • 把多種服務的資料在一次請求中要齊

這也讓 GraphQL 通常成為 facade services,也就是在眾多服務中的首個接觸點,並作為對外溝通的唯一渠道。

GraphQL 並不限定在要 HTTP 上執行,也能執行如 TCP 等協定之上。

雖然請求時送出的是類似 Query 的語法,但 Response 並無指定,只要能代表其階層式的結果就行,如 JSON

規範

type RecipeRoot {
    recipe(id: ID): Recipe
    pid: Int
}
type Recipe {
    id: ID!
    name: String!
    steps: String
    ingredients: [Ingredient]!
}
type Ingredient {
    id: ID!
    name: String!
    quantity: String
}

這份檔案是可以對外公開的,幫助使用者依此撰寫程式,類似上述提到的 OpenAPI。

請求

這時,我們可以依照上述的規範送出請求:

{
  pid
}
{
    "data": {
        "pid": 9372
    }
}
{
  recipe(id: 42) {
    name
    ingredients {
      name
      quantity
    }
  }
}
{
    "data": {
        "recipe": {
            "name": "Chicken Tikka Masala",
            "ingredients": [
                { "name": "Chicken", "quantity": "1 lb" },
                { "name": "Sauce", "quantity": "2 cups" }
            ]
        }
    }
}

Code Demo

下列則是以 Node.js 為基礎的範例:

// 僅展示請求的範例,這裡的 `kitchenSink` 是自定義名稱,方便 debug 用的
const query = `query kitchenSink ($id:ID) {
  recipe(id: $id) {
    id name
    ingredients {
      name quantity
    }
  }
  pid
}`;
const variables = { id: "42" };

return got(`http://${TARGET}/graphql`, {
    method: "POST",
    json: { query, variables },
});
import {
    GraphQLID,
    GraphQLInt,
    GraphQLObjectType,
    GraphQLSchema,
} from "graphql";

// 僅展示 RecipeRoot 的建置方式
const recipeRoot = new GraphQLObjectType({
    name: "RecipeRoot",
    fields: {
        pid: {
            type: GraphQLInt,
            resolve: resolvers.RecipeRoot.pid,
        },
        recipe: {
            type: recipeQuery,
            args: { id: { type: GraphQLID } },
            resolve: resolvers.RecipeRoot.recipe,
        },
    },
});
return new GraphQLSchema({ query: rootQuery });

Live Demo

http://localhost:4000/graphql

gRPC

像是 REST 或 GraphQL 都是建立在資料之上,而透過 CRUD 的方式去執行行為,這裡就可以注意到其限制:

大量的名詞,而僅有少量的動詞

舉例: 若有一個 API endpoint 是用來建立發票,今欲新增一附帶條件:是否同時寄送信箱通知。 有什麼樣的方式?

  • 再建立一個 endpoint 專門做這件事: 過多 API,難管理和理解
  • 在該 endpoint 新增變數:need_send_email: 讓該 endpoint 越來越複雜

Remote Procedure Call 就是來解決此事的!

gRPC 為 Google 建立的 RPC 標準

gRPC 預設即非使用 JSON 格式進行資訊的傳遞,而是以 Protocol Buffers(ProtoBufs)的方式進行傳遞。

有幾個條件:

  • 所有格式皆須預先設定好,副檔名為 .proto,且需要讓 client 擁有。
  • 各值需給定順序,且之後不建議修改。
  • 數字有多型別:int32int64floatdouble 等等。

這些條件有幾個好處:

  • 效能、體積的最優化,binary serialize/deserialize
{"id":42} v.s. 42
  • 向後相容
v1 需要 arg1 arg2
v2 需要 arg1 arg2 arg3
若 client 僅拿到 v1 的 proto,程式上會自動忽略 arg2 後的參數

Code Demo

  • gRPC proto
syntax = "proto3";
package recipe;
service RecipeService {
  rpc GetRecipe(RecipeRequest) returns (Recipe) {}
  rpc GetMetaData(Empty) returns (Meta) {}
}
message Recipe {
  int32 id = 1;
  string name = 2;
  string steps = 3;
  repeated Ingredient ingredients = 4;
}
message Ingredient {
  int32 id = 1;
  string name = 2;
  string quantity = 3;
}
message RecipeRequest {
  int32 id = 1;
}
message Meta {
  int32 pid = 2;
}
message Empty {}
import { loadPackageDefinition, Server } from "@grpc/grpc-js";
import { loadSync } from "@grpc/proto-loader";

// 讀取 proto 檔
const def = loadSync(__dirname + "/grpc.proto");
const proto = loadPackageDefinition(def);

// 建立處理邏輯
// handlers = ...;
const server = new Server();
server.addService(proto.recipe.RecipeService.service, handlers);

// 建立對外連線
// credentials = ...; for https
const cb = () => server.start();
server.bindAsync(`${HOST}:${PORT}`, credentials, cb);

// 建立 handlers
const handlers = {
    GetMetaData: (_call, cb) => {
        cb(null, {
            // error = null
            pid: process.pid,
        });
    },
    GetRecipe: (call, cb) => ({}), // if (call.request.id === 42)
};
import { loadPackageDefinition } from "@grpc/grpc-js";
import { loadSync } from "@grpc/proto-loader";

// 讀取 proto 檔
const def = loadSync(__dirname + "/grpc.proto");
const proto = loadPackageDefinition(def);

// credentials = ...; for https
const client = new proto.recipe.RecipeService(TARGET, credentials);

client.getMetaData({}, cb);
client.getRecipe({ id: 42 }, cb);

Live Demo

http://localhost:3001


Alternatives

除了 gRPC 還有什麼類似的東西?

ProtoBufs

MessagePack

雖然同為 binary representation of hierarchical object data,但

  • 有 field
  • 不需要額外檔案(如 .proto)去描述

gRPC

  • Apache Thrift
  • JSON RPC

關於 gRPC 推薦的文章:

結論

  1. JSON 你需要一個 http client 來呼叫眾多 API Endpoint,訊息格式也需要有額外的 Schema 定義
  2. GraphQL 你還是需要一個 http client,但是這次只需要對應一個端點,而且可以自己組織查詢內容
  3. gRPC 你連 http client 都不用,套件會幫你產出這些呼叫的程式邏輯,而你只需要像寫一般 function 一樣呼叫即可