【gRPC】Web 请求的 TS 封装 - 完美版

导读

【gRPC】Web 请求的 TS 封装 (下文简称《封装》)一文提供了一版对 gRPC 的封装,虽然已经可以在实践中使用了,但是还存在两处处不完美的地方。一是请求的某个参数为复杂类型(object)时,还是需要在 services 声明层使用 new RequestClass() 这种方式处理入参;二是在 services 层必须要传入 requestClass,理论上应该是可以省略的。

本文致力于解决以上两个问题,达到 【gRPC】封装前端网络请求的核心思想 - TS版 中要求的声明层极简的效果。但是实现方法要利用 AST 处理工具对 protoc 生成的代码进行二次处理,所以具有一定的技术复杂度。不过也算是学习 AST 工具的好契机,不过为了保证文章的聚焦,AST 处理将会另起一文。

背景

如导读中说的,《封装》方案中存在两处不完美的地方:复杂类型入参的处理必须传入 requestClass。这样说还是有些抽象,我们直接上代码:

假设《封装》中的 helloworld.proto 文件新增了如下内容:

// 新增 Student 类型
message Student {
  string name = 1;
  int32 age = 2;
}

// 修改 HelloRequest 参数,加入 Student
message HelloRequest {
  string name = 1;
  Student student_info = 2;
}
复制代码

HelloRequest 多了一个 student_info 的参数,是一个复杂类型(对象)。这时,services 层的代码就会变成:

import { grpcPromise, client } from "./request";
import { HelloRequest, Student } from "./helloworld_pb.js";

export const sayHello = (data: HelloRequest.AsObject) => {
  /* 处理 Student start */
  const { studentInfo, ...rest } = data;
  const { name = "", age = 0 } = data.studentInfo ?? {};
  const student = new Student();
  student.setName(name);
  student.setAge(age);
  /* 处理 Student end */
  return grpcPromise({
    method: "sayHello",
    requestClass: HelloRequest,
    data: { ...rest, studentInfo: student },
  });
};
复制代码

有一大堆的处理 Student 的代码,这非常的不优雅,不够完美。这就是上文提到的复杂类型入参的处理问题。

另外,此处还必须引入 HelloRequest,并且作为参数传入 grpcPromise,这样也很不优雅。理论上传入了 sayHello 就能够得到其入参类 HelloRequest 的信息才对。这就是上文提到的第二个问题:必须传入 requestClass

想要解决这两个问题,必须对 protoc 生成的 JS/TS 文件进行二次加工,需要用到 AST 的处理工具。有一定的复杂度,具体实现的方详见笔者的另一篇文章 GoGoCode - 像用 Jquery 一样方便地处理 AST。为保证思路的连贯,本文假设已经解决了上述问题,聚焦展现完美版的封装代码应该是什么样。

假设

以下假设也是《GoGoCode》一文需要实现的目标:

  1. 参数实例(Message)上有 getXXXClass 方法能够获取到 XXX 类,如上例中调用 getStudentInfoClass() 能够返回 Student 类;
  2. 服务实例(Service)上有 getXXXParamsClass 方法能够获取到 XXX 方法入参的类,如上例中调用 getSayHelloParamsClass() 能够返回 HelloRequest 类。

正文

如果满足以上假设,services.ts 将会满足《思想》一文中的要求:

import { grpcPromise } from "./request";
import { HelloRequest } from "./helloworld_pb.js";

// 不需要处理 Student; 不需要传入 HelloRequest;
// 此处的 HelloRequest.AsObject 只作为 ts 使用
export const sayHello = (data: HelloRequest.AsObject) =>
  grpcPromise({ method: "sayHello", data });
复制代码

很优雅,完美。接下来就是重头戏,如何修改 request.ts 的代码。

复杂类型入参的处理

因为调用 getStudentInfoClass 方法就能够获取到 Student 类,所以上文中把 JSON 处理成 Student Instance 的代码就可以抽象到 request.ts 中了。另外,可以很自然的想到,这是一个典型的递归场景,所以关键点就在实现递归函数,直接上代码:

const transJson2ClassInstance = <C>(
  className: ConcreteClass<C>,
  data: Record<string, any>
) => {
  const result = new className(); // 抽象成了参数
  Object.entries(data).forEach(([key, val]) => {
    const method = upperFirst(camelCase(key));
    const setMethod = `set${method}` as keyof C;
    const setFunc = result[setMethod];
    if (typeof setFunc === "function") {
      if (typeof val === "object" && !Array.isArray(val)) {
        // 调用 getXXXClass 方法获取到 XXX 类
        const subClassName = result[`get${method}Class` as keyof C]();
        setFunc.call(result, transJson2ClassInstance(subClassName, val));
      } else {
        setFunc.call(result, val);
      }
    }
  });
  return result;
};
复制代码

必须传入 requestClass

接下来就是根据 sayHello 获取到 HelloRequest 类,并作为 transJson2ClassInstance 的初始参数传入。有了上文的铺垫,这里就比较好理解了。直接调用 getSayHelloParamsClass 方法即可:

interface GrpcPromiseParams<M extends ClientMethod> {
  method: M;
  // requestClass: ConcreteClass<RequestClass<M>>; 不需要了
  data: Partial<ReturnType<RequestClass<M>["toObject"]>>;
  metadata?: Metadata;
}

export const grpcPromise = <M extends ClientMethod>(
  params: GrpcPromiseParams<M>
) => {
  const { method, data, metadata = {} } = params;
  // 调用 getXXXParamsClass 获取到 XXX 入参的类
  const getClassMethod = `get${upperFirst(method)}ParamsClass`;
  const requestClass = client[getClassMethod]() as ConcreteClass<
    RequestClass<M>
  >;
  const request = transJson2ClassInstance(requestClass, data);

  const result = client[method](request as any, metadata)
    .then((res) => res.toObject())
    .catch((err) => console.log(err)) as GrpcPromiseReturn<M>;
  return result;
};
复制代码

主要逻辑就是调用 getXXXParamsClasstransJson2ClassInstance 实现 request 的生成。到这就已经达到一个比较完美的状态了,起码声明层调用层已经很简洁好用了。

PS:这部分由于会有拼接字符串的逻辑,这种弱逻辑较难处理 TS,所以适当的 @ts-ignore 一下吧,好在这一层的 ignore 不会对使用层造成影响。

结语

本文的灵感是受到同事的启发,他为了能够达到这个效果,去研究了 protoc 的源码,并且也达成了上述的效果。但是这样我们就只能用私人定制版的 protoc 了。不过他的研究给了笔者灵感,既然改源码有诸多限制,那么用脚本对生成物进行二次加工总可以了吧,于是就有了这篇文章。

最近给团队提出了愿景:极致,创新,无边界。我对其的理解是:

极致是创新的前提,创新是极致的结果;无边界可以在更大范围内寻找最优解,是创新的催化剂。

用这个例子来说,我的同事首先追求极致,才想到要去修改 protoc 源码。作为前端去修改 C 的代码,敢想敢干,不设边界。最终的成果目前来看可以说是业内的独一份,肯定算得上是创新了。而且也切切实实的为团队带来了收益,试想下,如果团队中都是这样的好同志,团队的战斗力将是何等强悍。

在激烈竞争中,我们往往会发现,取胜的系统在最大化或者最小化一个或几个变量上走到近乎荒谬的极端。——《穷查理宝典》

おすすめ

転載: juejin.im/post/7034051175284473870