前端到gRPC 框架

前端到gRPC 框架

Posted by SkioFox on December 10, 2023

RPC 是什么?

RPC 英文全称是 Remote Procedure Call 既远程过程调用,维基百科中给的定义是一个计算机调用了一个函数,但这个函数并不在这台计算机上,这种远程调用方式程序员无需关注到底怎么远程调用,就像是本地执行一个函数一模一样。

听着很高大上,我们要实现一个求和的例子:

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

作为客户端,实际是不知道 sum 的逻辑的,它只需要传递 ab 两个参数给服务端,服务端返回结果即可。

这里大家就会有一个疑问,为什么我们要远程调一个函数?

答案就是我们本地没有呀,上面举的是 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 启动客户端,发送请求。

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 语言官网,找到最新的版本下载安装即可: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 框架进行开发或者更深的知识还需要诸君自己去摸索。