导读
本文会基于 grpc-web
封装出一个易于调用的方法,会像【gRPC】封装前端网络请求的核心思想 - TS版 中一样分封装、声明、调用三层。最终达到调用层使用完全一致,封装层统一处理格式化、异常等逻辑的效果。
由于只是 demo 阶段,可能有一些细节没有顾及到,但是相信整体思路和基本代码已经比较完整了,而且应该是能在公网查到的独一份儿。如果发现应用过程中有瑕疵,可以自行修改兼容。也欢迎留言,帮助笔者补齐逻辑。
PS:如果不清楚 gRPC 是什么,可先阅读 【gRPC】5 分钟入门扫盲。
目标
- 浏览器使用
gRPC
方式进行通信; - 异常、跨域、缓存等公共逻辑集中管理,不需要每次调用都写;
- 底层实现与调用代码完全解耦。即在更换底层框架(grpc-web、grpc-js,甚至 axios、fetch)时,完全不用修改使用层的调用代码;
- 调用时可以方便的使用 TS。
排坑
-
用
@vue/cli
生成的项目 typescript 的版本是4.1.6
,运行时会报错。但是用 VSCode 编码时用的是4.4.3
版本,没有提示任何错误。目前还没弄清楚最低到哪个版本可以运行,但是用4.4.3
肯定是没问题的。所以如果想用本文的实践,请保证项目中的 TS 版本符合要求; -
gRPC
的返回有两种格式,一种是普通的Promise
,可以用 then、catch 等方法;另一种是ReadableStream
格式,需要用.on('data', callback)
这种形式监听数据流变化,用法完全不一样。本文先只针对 Promise 格式的 Service 进行封装,因为比较常见,另一个以后再说; -
想要自己写个 demo 会比较麻烦。需要按照 grpc-web helloworld 的教程完成服务端、envoy 代理和客户端的运行。
- 需要先安装:
docker
、protoc
、protoc-gen-grpc-web
; - 然后按照教程成功启动服务端和 envoy 代理,注意可能需要修改代理配置文件中的端口号;
- 客户端由于要写 TS,所以要自己搭。笔者用的
@vue/cli
,遇到了 TS 版本问题,升级后勉强算是解决了; - 要把 proto 文件复制到客户端目录,并且运行 protoc 的命令行生成需要的 TS、JS 文件;
- 甚至还要适当修改 proto 文件,达到
2
中提到的两种格式分离的目的,以方便封装。
- 需要先安装:
说这些绝对不是想劝退大家,甚至强烈建议大家自己搭一个 demo,因为看似麻烦,实则这是从零熟悉 gRPC 最快的方法。说这些只是希望大家对准备工作有一个预期,大概会花掉你一整天的时间,也许你会碰到上面没提到的坑,碰到问题解决就好。
正文
准备
使用 grpc-web helloworld 提供的代码,服务端和代理按照里面的步骤做就可以了(请做好心理准备,这步可能没那么顺利),我们着重关注客户端的代码实现。另外,正如上文提到的,我们要对 proto
文件稍微做一下改动,把两种返回类型分开,如下:
helloworld.proto
// helloworld.proto
syntax = "proto3";
package helloworld;
service Greeter {
// unary call
rpc SayHello(HelloRequest) returns (HelloReply);
}
service Stream {
// server streaming call
rpc SayRepeatHello(RepeatHelloRequest) returns (stream HelloReply);
}
message HelloRequest {
string name = 1;
}
message RepeatHelloRequest {
string name = 1;
int32 count = 2;
}
message HelloReply {
string message = 1;
}
PS:因为暂时只会用到 sayHello
,所以服务端的代码和 proto 文件可以不用修改。
原始实现
我们先来看下不抽象,直接调用的代码。比较直观,为后续抽象做铺垫,要不跳跃性太大。
import { GreeterPromiseClient } from "./helloworld_grpc_web_pb.js";
const client = new GreeterPromiseClient("http://localhost:8080");
function sayHello(name: string) {
const request = new HelloRequest();
request.setName(name);
return client
.sayHello(request)
.then((res) => res.toObject())
.catch((err) => {
console.log(err);
});
}
sayHello('world').then(res => {
console.log(res.message); // 'Hello! world'
});
客户端
index.ts - 调用
与普通 RESTful 封装后的调用没有任何差别。
import { sayHello } from "./services";
sayHello({ name: "world" }).then((res) => {
console.log(res.message); // 'Hello! world' 且有友好的 TS 提示
});
services.ts - 声明
由于 gRPC
的特性,这部分代码不可避免的要依赖 proto
文件生成的 JS 和 TS。
import { grpcPromise } from "./request";
/* 需要依赖 proto 生成的类,不可避免 */
import { HelloRequest } from "./helloworld_pb.js";
export const sayHello = (data: HelloRequest.AsObject) => {
return grpcPromise({
method: "sayHello",
requestClass: HelloRequest,
data,
});
};
request.ts - 封装
重头戏来了,这地方的 TS 着实非常复杂,请各位观众做好心理准备。如果一时看不懂没关系,先拿着用用,用一用就懂了。之所以复杂主要有两个原因:
一是因为,先有根据 proto
文件生成的 .d.ts
文件,而这个文件是不能动的。所以不能像 RESTful 那样,ts 从零开始都是我们自己声明。这点我们没有绝对的控制权。
另外,为了追求极致,从声明层的使用可以看出,实际上有了 client
(全项目共用)和 method
,requestClass
和 data
的类型理论上都能被推断出才对。因为有了 method
就知道了方法,知道了方法就知道了入参,入参里有 request
,用 request
又能推出 requestClass
和 data
。事实上也的确如此,不过可以看出这个推导过程是比较复杂的。
综合以上两个因素,就造成了许多比较“根儿”的声明需要我们根据 .d.ts
里的声明反推,然后才能愉快的使用。从下面的代码也可以看出,甚至有 6 层嵌套的 TS 推断,还有两个不得不自己写的辅助方法。笔者已尽量将注释写的详细,毕竟这些注释大概率是写给几个月后的自己看的……
import { Metadata } from "grpc-web";
import { upperFirst, camelCase } from "lodash-es";
import { GreeterPromiseClient } from "./helloworld_grpc_web_pb.js";
export const client = new GreeterPromiseClient("http://localhost:8080");
/** 浏览器安装了 grpc-web 的插件时开启,可以更方便的看 grpc 请求,非必须。
* const enableDevTools = window.__GRPCWEB_DEVTOOLS__;
* if (typeof enableDevTools === "function") {
* enableDevTools([client]);
* }
*/
/**
* 辅助:用于没有 constructor 又可以 new 的 class。根据 new 之后的实例 T,反推其类型。
* eg:如果 const instance: InstanceType = new InstanceClass();
* 那么 typeof InstanceClass === ConcreteClass<InstanceType>;
*/
interface ConcreteClass<T> {
new (): T;
}
/**
* 辅助:根据 Promise 类型,反推其被输入的泛型。
* eg:如果 type A = Promise<B>;
* 那么 UnPromise<A> === B;
*/
type UnPromise<T extends Promise<unknown>> = T extends Promise<infer U>
? U
: never;
/**
* client 实例的所以方法名
*/
type ClientMethod = keyof GreeterPromiseClient;
/**
* 根据 client 实例中方法的(第一个)入参,反推 request 的类型。
*/
type RequestClass<M extends ClientMethod> = Parameters<
GreeterPromiseClient[M]
>[0];
/**
* requestClass 类型的反推逻辑:请结合 ConcreteClass 的解释理解;
* data 类型的反推逻辑:request 实例中 toObject 方法的返回值。
*/
interface GrpcPromiseParams<M extends ClientMethod> {
method: M;
requestClass: ConcreteClass<RequestClass<M>>;
data: Partial<ReturnType<RequestClass<M>["toObject"]>>;
metadata?: Metadata;
}
/**
* 因为最后要调用 toObject 方法,所以直接返回 ReturnType<<GreeterPromiseClient[M]>> 是不行的,
* 要先 UnPromise 一下然后拿到 toObject 的 ReturnType 后,再用 Promise 包裹一下。
*/
type GrpcPromiseReturn<M extends ClientMethod> = Promise<
ReturnType<UnPromise<ReturnType<GreeterPromiseClient[M]>>["toObject"]>
>;
export const grpcPromise = <M extends ClientMethod>(
params: GrpcPromiseParams<M>
) => {
const { requestClass, method, data, metadata = {} } = params;
const request = new requestClass();
Object.entries(data).forEach(([key, val]) => {
const setFunc =
request[`set${upperFirst(camelCase(key))}` as keyof RequestClass<M>];
if (typeof setFunc === "function") {
// Notice:此处如果不用 call,会导致传入 window 造成报错。
setFunc.call(request, val);
}
});
const result = client[method](request as any, metadata)
.then((res) => res.toObject())
.catch((err) => {
console.log(err);
// other code
}) as GrpcPromiseReturn<M>;
return result;
};
结语
说实话,写这部分代码的难度超出了原先的预估,好在目前来看效果还可以,达到了之前定的目标。不过还是有进一步优化的空间。比如
- 再进一步抽象,把
GreeterPromiseClient
的依赖也剔除,让其作为泛型输入; - 还有可以把异常处理、初始化配置等逻辑也抽离,让其作为参数传入;
- 再加入
ReadableStream
类型的声明等等。
这样做无疑会更完美,而且可以单独打包成一个 package 开源。但是这样做的成本会增加非常多,我们做工程的最重要的就是衡量性价比。目前阶段的研究成果已经可以比较好的在工程中应用了,所以就先抛出来给大家借鉴。后续进一步的抽象会视精力和时间再定,如果有同学有兴趣欢迎交流。
最后感慨一下,写完这部分,笔者觉得自己的 TS 水平有了显著的提高,比如终于理解了一些 infer
和 never
的意义。不得不感慨 TS 的博大精深,看似就那么几个简简单单的关键字,竟然能够有这么多神奇的变化,真的是大道至简,自己的路还长着呢。
做难事必有所得。越是喧闹,越是孤独;越是寂寞,越是丰富。 —— 金一南
宝剑锋从磨砺出,梅花香自苦寒来。 —— 《警世贤文》