5

前端从😳 到🚪 gRPC 框架

 2 years ago
source link: https://my.oschina.net/u/4066271/blog/5196757
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 语言官网,找到最新的版本下载安装即可: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/[email protected]
go install google.golang.org/grpc/cmd/[email protected] 
  • 安装运行时依赖

我们还需要安装运行时依赖,作用类似上面 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 框架进行开发或者更深的知识还需要诸君自己去摸索。

又是秃头的一天。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK