前端從? 到? gRPC 框架

張超傑發表於2021-08-22

RPC 是什麼?

RPC 英文全稱是 Remote Procedure Call 既遠端過程呼叫,維基百科中給的定義是一個計算機呼叫了一個函式,但這個函式並不在這臺計算機上,這種遠端呼叫方式程式設計師無需關注到底怎麼遠端呼叫,就像是本地執行一個函式一模一樣。

聽著很高大上,我們要實現一個求和的例子:

function sum(a, b) {
	return a + b
}

作為客戶端,實際是不知道 sum 的邏輯的,它只需要傳遞 ab 兩個引數給服務端,服務端返回結果即可。

這裡大家就會有一個疑問,為什麼我們要遠端調一個函式?

答案就是我們本地沒有呀,上面舉的是 sum 的純邏輯,但如果是客戶端有賬號和密碼,要獲取 使用者詳細資訊的資料呢,我們本地是沒有的,所以一定要遠端呼叫。

PRC 和 HTTP 協議的關係?

經過我們一解釋,相信大家都有些明白了,但又會產生一個新的疑問,這個過程怎麼和 http 的請求響應模型這麼像呢,兩者是什麼關係呢?
其實廣義的理解中,http 就是 rpc 的一種實現方式,rpc 更多像是一種思想,http 請求和響應是一種實現。

gPRC 是什麼?

剛剛說了 rpc 更多的是一種思想,而我們現在說的 gPRC 則是 PRC 的一種實現,也可以稱為一個框架,並且不止這一個框架,業界還有 thrift,但是目前微服務中用的比較廣泛的就是它,所以我們要學習的就是它。

gRPC 官網 的介紹是 A high performance, open source universal RPC framework。 一個高效能、開源的通用RPC框架。它有以下四個特點:

  • 定義簡單:它基於 Protocol Buffer 進行型別定義(就是有哪些函式、函式的引數型別、響應結果型別);
  • 跨語言和平臺:通過上述定義,我們可以一鍵生成 typescriptgoc#java 等程式碼 ? 。因為每種語言都是有函式的,函式也都有引數和返回值的,而 Protocol Buffer 是一種中間語言,那麼它就可以任意轉換(如果不好理解,你可以想一下 json,json 這種資料結構就是各個語言通用的概念,無論是前端的 json,還是 go 語言的 json 都可以按照統一的意思讀寫)。
  • 快速擴縮容。
  • 基於 HTTP/2 的雙向認證。

Protocol Buffer 是什麼?

VS Code 提供了 vscode-proto3 這個外掛用於 proto 的高亮

protocal buffer 你可以理解為一個語言,不過不用怕,其語法是十分的簡單,它的作用也很明確,就是用來定義函式、函式的引數、響應結果的,並且可以通過命令列轉為不同語言的函式實現。其基本語法為:

// user.proto

syntax = "proto3";

package user; // 包名稱

// 請求引數
message LoginRequest {
	string username = 1;
  string password = 2;
}

// 響應結果
message LoginResponse {
	string access_token = 1;
  int32 expires = 2;
}

// 使用者相關介面
service User {
	// 登入函式
	rpc login(LoginRequest) returns (LoginResponse);
}

為了方面理解,我將上面的定義翻譯為 typescript 定義:

namespace user {
  interface LoginRequest {
    username: string;
    password: string;
  }

  interface LoginResponse {
    access_token: string;
    expires: number;
  }

  interface User {
    login: (LoginRequest) => LoginResponse // ts 型別定義中,函式引數可以沒有名稱的。
  }
}

通過對比我們知道:

  • syntax = "proto3":這句話相當於用 proto3 版本的協議,現在統一的都是 3,每個 proto 檔案都這樣寫就對了
  • package:類似 namespace 作用域
  • message:相當於 ts 中的 interface
  • service:也是相當於 js 中的 interface
  • string、int32:分別是型別,因為 ts 中關於數的劃分沒那麼細,所以 int32 就被轉為了 number
  • User:相當於 ts 中的類或者物件
  • login:相當於 ts 中的方法
  • 數字 1、2:最令人迷惑的就是變數後的數字了,它實際是 grpc 通訊過程的關鍵,是用於把資料編碼和解碼的順序,類似於 json 物件轉為字串,再把字串轉為 json 物件中那些冒號和逗號分號的作用一樣,也就是序列化與反序列化的規則。

從 proto 定義到 node 程式碼

動態載入版本

所謂動態載入版本是指在 nodejs 啟動時載入並處理 proto,然後根據 proto 定義進行資料的編解碼。

  • 建立目錄和檔案

gRPC 是客戶端和服務端交換資訊的框架,我們就建立兩個 js 檔案分為作為客戶端和服務端,客戶端傳送登入的請求,服務端響應,其目錄結構如下:

.
├── client.js # 客戶端
├── server.js # 服務端
├── user.proto # proto 定義
└── user_proto.js # 客戶端和服務端都要用到載入 proto 的公共程式碼
  • 安裝依賴
yarn add @grpc/grpc-js  # @grpc/grpc-js:是 gRPC node 的實現(不同語言有不同語言的實現)
yarn add @grpc/proto-loader # @grpc/proto-loader:用於載入 proto 
  • 編寫 user_proto.js

user_proto.js對於服務端和客戶端都很重要,客戶端可以知道自己要傳送的資料型別和引數,而服務端可以知道自己接受的引數、要響應的結果以及要實現的函式名稱。

// user_proto.js
// 載入 proto
const path = require('path')
const grpc = require('@grpc/grpc-js')
const protoLoader = require('@grpc/proto-loader')

const PROTO_PATH = path.join(__dirname, 'user.proto') // proto 路徑
const packageDefinition = protoLoader.loadSync(PROTO_PATH, { keepCase: true, longs: String, enums: String, defaults: true, oneofs: true })
const protoDescriptor = grpc.loadPackageDefinition(packageDefinition)

const user_proto = protoDescriptor. user

module.exports = user_proto
  • 編寫 server.js
// service.js
// 服務端
const grpc = require("@grpc/grpc-js"); // 引入 gprc 框架
const user_proto = require("./user_proto.js"); // 載入解析後的 proto

// User Service 實現
const userServiceImpl = {
  login: (call, callback) => {
    // call.request 是請求相關資訊
    const { request } = call;
    const { username, password } = request;

    // 第一個引數是錯誤資訊,第二個引數是響應相關資訊
    callback(null, {
      access_token: `username = ${username}; password = ${password}`,
      expires: "zhang",
    });
  },
};

// 和 http 一樣,都需要去監聽一個埠,等待別人連結
function main() {
  const server = new grpc.Server(); // 初始化 grpc 框架
  server.addService(user_proto.User.service, userServiceImpl); // 新增 service
 	// 開始監聽服務(固定寫法)
  server.bindAsync("0.0.0.0:8081", grpc.ServerCredentials.createInsecure(), () => {
      server.start();
      console.log("grpc server started");
    }
  );
}

main();

因為 proto 中我們只進行了定義,並沒有 login 的真正實現,所以我們需要再 server.js 中對 login 進行實現。我們可以 console.log(user_proto) 看到:

{
  LoginRequest: { 
  	// ... 
  },
  LoginResponse: {
  	// ...
  },
	User: [class ServiceClientImpl extends Client] {
    service: { login: [Object] }
  }
}

所以 server.addService 我們才能填寫 user_proto.User.service

  • 編寫 client.js
// client.js
const user_proto = require("./user_proto");
const grpc = require("@grpc/grpc-js");

// 使用 `user_proto.User` 建立一個 client,其目標伺服器地址是 `localhost:8081`
// 也就是我們剛剛 service.js 監聽的地址
const client = new user_proto.User(
  "localhost:8081",
  grpc.credentials.createInsecure()
);

// 發起登入請求
function login() {
  return new Promise((resolve, reject) => {
      // 約定的引數
      client.login(
        { username: 123, password: "abc123" },
        function (err, response) {
          if (err) {
            reject(err);
          } else {
            resolve(response);
          }
        }
      );
  })
}

async function main() {
  const res = await login();
  console.log(res)
}

main();
  • 啟動服務

node server.js 啟動服務端,讓其保持監聽,然後 node client.js 啟動客戶端,傳送請求。

image.png

我們看到已經有了響應結果。

  • 壞心眼

我們使個壞心眼,如果傳送的資料格式不是 proto 中定義的型別的會怎麼樣?
image.png
答案是會被強制型別轉換為 proto 中定義的型別,比如我們在 server.js 中將 expires 欄位的返回值改為了 zhang 那麼他會被轉為數字 0,而客戶端傳送過去的 123 也被轉為了字串型別。

靜態編譯版本

動態載入是執行時載入 proto,而靜態編譯則是提前將 proto 檔案編譯成 JS 檔案,我們只需要載入 js 檔案就行了,省去了編譯 proto 的時間,也是在工作中更常見的一種方式。

  • 新建專案

我們新建一個專案,這次資料夾內只有四個檔案,分別為:

.
├── gen # 資料夾,用於存放生成的程式碼
├── client.js # 客戶端程式碼
├── server.js # 服務端程式碼
└── user.proto # proto 檔案,記得將內容拷貝過來
  • 安裝依賴
yarn global add grpc-tools # 用於從 proto -> js 檔案的工具
yarn add google-protobuf @grpc/grpc-js # 執行時的依賴
  • 生成 js 程式碼
grpc_tools_node_protoc \
--js_out=import_style=commonjs,binary:./gen/ \
--grpc_out=grpc_js:./gen/ user.proto

我們看到已經生成了 user_pb.jsuser_grpc_pb.js 兩個檔案:

image.png

  • grpc_tools_node_protoc:是安裝 grpc-tools 後生成的命令列工具
  • --js_out=import_style=commonjs,binary:./gen/:是生成 user_pb.js 的命令
  • --grpc_out=grpc_js:./gen/:是生成 user_grpc_pb.js 的命令。

pb 是 protobuf 的簡寫

如果你去仔細檢視兩者的內容你就會發現:

user_pb.js:主要是對 proto 中的 message 定義擴充套件各種編解碼方法,也就是對 LoginRequestLoginResponse 做處理。

user_grpc_pb.js:則是對 proto 中的 service 進行各種方法定義。

  • 編寫 server.js
const grpc = require("@grpc/grpc-js");

const services = require("./gen/user_grpc_pb");
const messages = require("./gen/user_pb");

const userServiceImpl = {
  login: (call, callback) => {
    const { request } = call;

    // 使用 request 裡的方法獲取請求的引數
    const username = request.getUsername();
    const password = request.getPassword();

    // 使用 message 設定響應結果
    const response = new messages.LoginResponse();
    response.setAccessToken(`username = ${username}; password = ${password}`);
    response.setExpires(7200);

    callback(null, response);
  },
};

function main() {
  const server = new grpc.Server();

  // 使用 services.UserService 新增服務
  server.addService(services.UserService, userServiceImpl);
  server.bindAsync(
    "0.0.0.0:8081",
    grpc.ServerCredentials.createInsecure(),
    () => {
      server.start();
      console.log("grpc server started");
    }
  );
}

main();

我們發現和動態版的區別就是 addService 時直接使用了匯出的 UserService 定義,然後再實現 login 時,我們能使用各種封裝的方法來處理請求和響應引數。

  • 編寫 client.js
// client.js

const grpc = require("@grpc/grpc-js");

const services = require("./gen/user_grpc_pb");
const messages = require("./gen/user_pb");

// 使用 services 初始化 Client
const client = new services.UserClient(
  "localhost:8081",
  grpc.credentials.createInsecure()
);

// 發起 login 請求
function login() {
  return new Promise((resolve, reject) => {
    // 使用 message 初始化引數
    const request = new messages.LoginRequest();
    request.setUsername("zhang");
    request.setPassword("123456");

    client.login(request, function (err, response) {
      if (err) {
        reject(err);
      } else {
        resolve(response.toObject());
      }
    });
  });
}

async function main() {
  const res = await login()
  console.log(res)
}

main();

從上面的註釋可以看出,我們直接從生成的 JS 檔案中載入內容,並且它提供了很多封裝的方法,讓我們傳參更加可控。

從 JS 到 TS

從上面我們也看出了,對於引數型別的限制,更多是強制型別轉換,在書寫階段並不能發現,這就很不科學了,不過,我們就需要通過 proto 生成 ts 型別定義來解決這個問題。

網上關於從 proto 到生成 ts 的方案有很多,我們選擇了使用 protoc + grpc_tools_node_protoc_ts + grpc-tools

  • 新建專案
mkdir grpc_demo_ts && cd grpc_demo_ts # 建立專案目錄

yarn global add typescript ts-node @types/node # 安裝 ts 和 ts-node

tsc --init # 初始化 ts
  • 安裝 proto 工具
yarn global add grpc-tools grpc_tools_node_protoc_ts # 安裝 proto 工具到全域性
  • 安裝執行時依賴
yarn add google-protobuf @grpc/grpc-js # 執行時依賴
  • 建立檔案
mkdir gen # 建立存放輸出檔案的目錄
touch client.ts server.ts user.proto # 建立檔案
# 記得把 user.proto 的內容拷貝過去
  • 安裝 protoc

然後我們需要安裝 protoc 這個工具,首先進入 protobuf 的 github,進入 release,下載所在平臺的檔案,然後進行安裝,安裝完記得把其加入到設定環境變數裡,確保可以全域性使用。

image.png

mac 可以通過 brew install protobuf 進行安裝,安裝後全域性就會有 protoc 命令

  • 生成 js 檔案和 ts 型別定義
# 生成 user_pb.js 和 user_grpc_pb.js
grpc_tools_node_protoc \
--js_out=import_style=commonjs,binary:./gen \
--grpc_out=grpc_js:./gen \
--plugin=protoc-gen-grpc=`which grpc_tools_node_protoc_plugin` \
./user.proto

# 生成 d.ts 定義
protoc \
--plugin=protoc-gen-ts=`which protoc-gen-ts` \
--ts_out=grpc_js:./gen \
./user.proto
  • 編寫 server.ts
// server.ts

import * as grpc from "@grpc/grpc-js";
import { IUserServer, UserService } from "./gen/user_grpc_pb";
import messages from "./gen/user_pb";

// User Service 的實現
const userServiceImpl: IUserServer = {
  // 實現登入介面
  login(call, callback) {
    const { request } = call;
    const username = request.getUsername();
    const password = request.getPassword();

    const response = new messages.LoginResponse();
    response.setAccessToken(`username = ${username}; password = ${password}`);
    response.setExpires(7200);
    callback(null, response);
  }
}

function main() {
  const server = new grpc.Server();
  
  // UserService 是定義,UserImpl 是實現
  server.addService(UserService, userServiceImpl);
  server.bindAsync(
    "0.0.0.0:8081",
    grpc.ServerCredentials.createInsecure(),
    () => {
      server.start();
      console.log("grpc server started");
    }
  );
}

main();

image.png

型別提示很完美 ?

  • 編寫 client.ts
// client.ts

import * as grpc from "@grpc/grpc-js";
import { UserClient } from "./gen/user_grpc_pb";
import messages from "./gen/user_pb";

const client = new UserClient(
  "localhost:8081",
  grpc.credentials.createInsecure()
);

// 發起登入請求
const login = () => {
  return new Promise((resolve, reject) => {
    const request = new messages.LoginRequest();
    request.setUsername('zhang');
    request.setPassword("123456");

    client.login(request, function (err, response) {
      if (err) {
        reject(err);
      } else {
        resolve(response.toObject());
      }
    });
  })
}

async function main() {
  const data = await login()
  console.log(data)
}

main();

image.png
當我們輸入錯型別時,ts 就會進行強制檢驗。

  • 啟動服務

image.png

我們使用 ts-node 啟動兩者,發現效果一起正常。

從 Node 到 Go

上面的介紹中,client 和 server 都是用 js/ts 來寫的,但實際工作中更多的是 node 作為客戶端去聚合調其他語言寫的介面,也就是通常說的 BFF 層,我們以 go 語言為例。

  • 改造原 ts 專案

我們將上面的 ts 專案改造為 client 和 server 兩個目錄,client 是 ts 專案作為客戶端,server 是 go 專案,作為服務端,同時我們把原來的 server.ts 刪除,把 user.proto 放到最外面,兩者共用。

.
├── client # 客戶端資料夾,其內容同 ts 章節,只是刪除了 server.ts 相關內容
│   ├── client.ts
│   ├── gen
│   │   ├── user_grpc_pb.d.ts
│   │   ├── user_grpc_pb.js
│   │   ├── user_pb.d.ts
│   │   └── user_pb.js
│   ├── package.json
│   ├── tsconfig.json
│   └── yarn.lock
├── server # 服務端檔案
└── user.proto # proto 檔案
  • 安裝 Go

我們進入 Go 語言官網,找到最新的版本下載安裝即可:https://golang.google.cn/dl/

  • 設定 go 代理

和 npm 一樣,go 語言拉包,也需要設定映象拉包才能更快。

go env -w GOPROXY=https://goproxy.cn,direct
  • 初始化 go 專案

類似 yarn init -y 的作用。

cd server # 進入 server 目錄
go mod init grpc_go_demo # 初始化包
mkdir -p gen/user # 用於存放後面生成的程式碼
  • 安裝 protoc 的 go 語言外掛

用於生成 go 語言的程式碼,作用與 grpc-toolsgrpc_tools_node_protoc_ts 相同。

go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1 
  • 安裝執行時依賴

我們還需要安裝執行時依賴,作用類似上面 node 的 google-protobuf@grpc/grpc-js

go get -u github.com/golang/protobuf/proto
go get -u google.golang.org/grpc
  • 修改 user.proto
syntax = "proto3";

option go_package = "grpc_go_demo/gen/user"; // 增加這一句

package user;

message LoginRequest {
	string username = 1;
  string password = 2;
}

message LoginResponse {
	string access_token = 1;
  int32 expires = 2;
}

service User {
	rpc login(LoginRequest) returns (LoginResponse);
}
  • 生成 go 程式碼
// 要在 server 目錄哦

protoc --go_out=./gen/user -I=../ --go_opt=paths=source_relative \
    --go-grpc_out=./gen/user -I=../ --go-grpc_opt=paths=source_relative \
    ../user.proto
  • 安裝 VS Code 外掛並新建立開啟專案

當你點選去檢視生成出來的 user.pb.go 或者 user_grpc.pb.go 時,你會發現 vscode 讓你裝外掛,裝就完事了,然後你可能會發現 go 包報找不到的錯誤,不要慌,我們以 server 為專案根路徑重新開啟專案即可。

  • 建立 main.go 書寫服務端程式碼
// server/main.go

package main

import (
	"context"
	"fmt"
	pb "grpc_go_demo/gen/user"
	"log"
	"net"

	"google.golang.org/grpc"
)

// 宣告一個物件
type userServerImpl struct {
	pb.UnimplementedUserServer
}

// 物件有一個 Login 方法
func (s *userServerImpl) Login(ctx context.Context, in *pb.LoginRequest) (*pb.LoginResponse, error) {
	// 返回響應結果
    return &pb.LoginResponse{
		AccessToken: fmt.Sprintf("go: username = %v, password = %v", in.GetUsername(), in.GetPassword()),
		Expires: 7200,
	}, nil
}


// 監聽服務並將 server 物件註冊到 gRPC 伺服器上
func main() {
    // 建立 tcp 服務
	lis, _ := net.Listen("tcp", ":8081")
	
    // 建立 grpc 服務
	server := grpc.NewServer()
    
    // 將 UserServer 註冊到 server
	pb.RegisterUserServer(server, &userServerImpl{})
    
	log.Printf("server listening at %v", lis.Addr())

	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

image.png

為什麼是 gRPC 而非 HTTP?

現在微服務架構大多數使用的是 gRPC 進行服務間通訊,那麼為什麼不再使用我們前端熟悉的 http 呢?

有人說高效率,gRPC 是 tcp 協議、二進位制傳輸,效率高,效率高缺失沒錯,但它相對於 http 並不會有明顯的差距,一方面 http 中 json 編解碼效率和佔用空間數並不會比編解成二進位制差多少,其次,tcp 和 http 在內網環境下,帶來的效能我個人感覺也不會差多少(PS:gRPC 官網也並未強調它相對於 HTTP 的高效率)。

其實官網核心突出的就在於它的語言無關性,通過 protobuf 這種中間形式,可以轉換為各種語言的程式碼,確保了程式碼的一致性,而非 http 那樣對著 swagger 或者其他的文件平臺去對介面。

結束語

本篇只是一個入門,至於 gRPC 如何結合 node 框架進行開發或者更深的知識還需要諸君自己去摸索。

又是禿頭的一天。

相關文章