RPC 是什麼?
RPC 英文全稱是 Remote Procedure Call 既遠端過程呼叫,維基百科中給的定義是一個計算機呼叫了一個函式,但這個函式並不在這臺計算機上,這種遠端呼叫方式程式設計師無需關注到底怎麼遠端呼叫,就像是本地執行一個函式一模一樣。
聽著很高大上,我們要實現一個求和的例子:
function sum(a, b) {
return a + b
}
作為客戶端,實際是不知道 sum 的邏輯的,它只需要傳遞 a
和 b
兩個引數給服務端,服務端返回結果即可。
這裡大家就會有一個疑問,為什麼我們要遠端調一個函式?
答案就是我們本地沒有呀,上面舉的是 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
進行型別定義(就是有哪些函式、函式的引數型別、響應結果型別); - 跨語言和平臺:通過上述定義,我們可以一鍵生成
typescript
、go
、c#
、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
啟動客戶端,傳送請求。
我們看到已經有了響應結果。
- 壞心眼
我們使個壞心眼,如果傳送的資料格式不是 proto 中定義的型別的會怎麼樣?
答案是會被強制型別轉換為 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.js
和user_grpc_pb.js
兩個檔案:
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
定義擴充套件各種編解碼方法,也就是對 LoginRequest
和 LoginResponse
做處理。
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,下載所在平臺的檔案,然後進行安裝,安裝完記得把其加入到設定環境變數裡,確保可以全域性使用。
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();
型別提示很完美 ?
- 編寫 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();
當我們輸入錯型別時,ts 就會進行強制檢驗。
- 啟動服務
我們使用 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-tools
和 grpc_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)
}
}
為什麼是 gRPC 而非 HTTP?
現在微服務架構大多數使用的是 gRPC 進行服務間通訊,那麼為什麼不再使用我們前端熟悉的 http 呢?
有人說高效率,gRPC 是 tcp
協議、二進位制傳輸,效率高,效率高缺失沒錯,但它相對於 http 並不會有明顯的差距,一方面 http 中 json 編解碼效率和佔用空間數並不會比編解成二進位制差多少,其次,tcp 和 http 在內網環境下,帶來的效能我個人感覺也不會差多少(PS:gRPC 官網也並未強調它相對於 HTTP 的高效率)。
其實官網核心突出的就在於它的語言無關性,通過 protobuf 這種中間形式,可以轉換為各種語言的程式碼,確保了程式碼的一致性,而非 http 那樣對著 swagger 或者其他的文件平臺去對介面。
結束語
本篇只是一個入門,至於 gRPC 如何結合 node 框架進行開發或者更深的知識還需要諸君自己去摸索。
又是禿頭的一天。