【gRPC】Web 请求的 TS 封装

2,783 阅读7分钟

导读

本文会基于 grpc-web 封装出一个易于调用的方法,会像【gRPC】封装前端网络请求的核心思想 - TS版 中一样分封装、声明、调用三层。最终达到调用层使用完全一致封装层统一处理格式化、异常等逻辑的效果。

由于只是 demo 阶段,可能有一些细节没有顾及到,但是相信整体思路和基本代码已经比较完整了,而且应该是能在公网查到的独一份儿。如果发现应用过程中有瑕疵,可以自行修改兼容。也欢迎留言,帮助笔者补齐逻辑。

PS:如果不清楚 gRPC 是什么,可先阅读 【gRPC】5 分钟入门扫盲

目标

  1. 浏览器使用 gRPC 方式进行通信;
  2. 异常、跨域、缓存等公共逻辑集中管理,不需要每次调用都写;
  3. 底层实现与调用代码完全解耦。即在更换底层框架(grpc-web、grpc-js,甚至 axios、fetch)时,完全不用修改使用层的调用代码;
  4. 调用时可以方便的使用 TS。

排坑

  1. @vue/cli 生成的项目 typescript 的版本是 4.1.6,运行时会报错。但是用 VSCode 编码时用的是 4.4.3 版本,没有提示任何错误。目前还没弄清楚最低到哪个版本可以运行,但是用 4.4.3 肯定是没问题的。所以如果想用本文的实践,请保证项目中的 TS 版本符合要求

  2. gRPC 的返回有两种格式,一种是普通的 Promise,可以用 then、catch 等方法;另一种是 ReadableStream 格式,需要用 .on('data', callback) 这种形式监听数据流变化,用法完全不一样。本文先只针对 Promise 格式的 Service 进行封装,因为比较常见,另一个以后再说;

  3. 想要自己写个 demo 会比较麻烦。需要按照 grpc-web helloworld 的教程完成服务端envoy 代理客户端的运行。

    • 需要先安装:dockerprotocprotoc-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(全项目共用)和 methodrequestClassdata 的类型理论上都能被推断出才对。因为有了 method 就知道了方法,知道了方法就知道了入参,入参里有 request,用 request 又能推出 requestClassdata。事实上也的确如此,不过可以看出这个推导过程是比较复杂的。

综合以上两个因素,就造成了许多比较“根儿”的声明需要我们根据 .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 水平有了显著的提高,比如终于理解了一些 infernever 的意义。不得不感慨 TS 的博大精深,看似就那么几个简简单单的关键字,竟然能够有这么多神奇的变化,真的是大道至简,自己的路还长着呢。

做难事必有所得。越是喧闹,越是孤独;越是寂寞,越是丰富。 —— 金一南

宝剑锋从磨砺出,梅花香自苦寒来。 —— 《警世贤文》