RPC 是什么?
RPC 英文全称是 Remote Procedure Call 既远程过程调用,维基百科中给的定义是一个计算机调用了一个函数,但这个函数并不在这台计算机上,这种远程调用方式程序员无需关注到底怎么远程调用,就像是本地执行一个函数一模一样。
听着很高大上,我们要实现一个求和的例子:
function sum(a, b) {
return a + b
}
作为客户端,实际是不知道 sum 的逻辑的,它只需要传递 a
和 b
两个参数给服务端,服务端返回结果即可。
这里大家就会有一个疑问,为什么我们要远程调一个函数?
答案就是我们本地没有呀,上面举的是 sum
的纯逻辑,但如果是客户端有账号和密码,要获取 用户详细信息的数据呢,我们本地是没有的,所以一定要远程调用。
RPC 和 HTTP 协议的关系?
经过我们一解释,相信大家都有些明白了,但又会产生一个新的疑问,这个过程怎么和 http 的请求响应模型这么像呢,两者是什么关系呢? 其实广义的理解中,http 就是 rpc 的一种实现方式,rpc 更多像是一种思想,http 请求和响应是一种实现。
gRPC 是什么?
刚刚说了 rpc 更多的是一种思想,而我们现在说的 gRPC 则是 RPC 的一种实现,也可以称为一个框架,并且不止这一个框架,业界还有 thrift
,但是目前微服务中用的比较广泛的就是它,所以我们要学习的就是它。
gRPC 官网 的介绍是 A high performance, open source universal RPC framework。
一个高性能、开源的通用RPC框架。它有以下四个特点:
- 服务定义简单
- 跨语言和平台
- 快速扩缩容
- 基于 HTTP/2 的双向认证
解读:
- 服务定义简单:基于
Protocol Buffers
定义服务(后面会讲解) - 跨语言和平台:通过 proto 定义可以生成各个语言的代码 🐂
本文也主要围绕着两点,通过示例进行阐述。
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 语言官网,找到最新的版本下载安装即可: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 框架进行开发或者更深的知识还需要诸君自己去摸索。