导读
本文是写【gRPC】系列文章孵化出来的,想要实现 grpc-web
的完美封装,需要对 protoc 生成的 JS/TS 文件进行二次加工。自然想到用 AST(抽象语法树)的处理工具,在同事的推荐下,试用了一下 gogocode
,的确很方便。
背景
具体背景详见【gRPC】Web 请求的 TS 封装 - 完美版,由于上下文较多,所以本文可以做到即使不爬前文也能看懂 gogocode 怎么用,各位看官可以安心阅读。
GoGoCode 官网的介绍:
GoGoCode 是一个基于 AST 的 JavaScript/Typescript/HTML 代码转换工具,但相较于同类,它提供了更符合直觉的 API:一套类 JQuery 的 API 用来查找和处理 AST、一套和正则表达式接近的语法用来匹配和替换代码
另有与 babel
和 jscodeshift
的对比,可见它们是同一类东西。本文会比较偏实践,直接提出问题,并展示 gogocode 如何解决,不会有太多理论层面的论述。
目标
没有阅读上下文的同学可以简单理解本文需要作如下操作:
- 修改下列 JS 代码,增加一个函数;
// 原内容
proto.helloworld.HelloRequest.prototype.getStudentInfo = function() {
return /** @type{?proto.helloworld.Student} */ (
jspb.Message.getWrapperField(this, proto.helloworld.Student, 2));
};
// 待增加内容
proto.helloworld.HelloRequest.prototype.getStudentInfoClass = function() {
return proto.helloworld.Student;
}
复制代码
- 修改下列 TS 代码,为 Class 增加一个方法。
export class HelloRequest extends jspb.Message {
// 原内容
getStudentInfo(): Student | undefined;
// 待增加内容
getStudentInfoClass(): typeof Student;
}
复制代码
正文
处理 helloworld_pb.js 代码
分析
注:没有阅读上下文的同学可以忽略分析过程,直接看分析结论。
阅读以下 protoc 生成的 helloworld_pb.js
代码
/**
* optional Student student_info = 2;
* @return {?proto.helloworld.Student}
*/
proto.helloworld.HelloRequest.prototype.getStudentInfo = function() {
return /** @type{?proto.helloworld.Student} */ (
jspb.Message.getWrapperField(this, proto.helloworld.Student, 2));
};
/**
* @param {?proto.helloworld.Student|undefined} value
* @return {!proto.helloworld.HelloRequest} returns this
*/
proto.helloworld.HelloRequest.prototype.setStudentInfo = function(value) {
return jspb.Message.setWrapperField(this, 2, value);
};
复制代码
我们可以发现:
- 所有复杂类型的 get 方法,都会调用
getWrapperField
,set 也一样; getWrapperField
的参数中有我们需要的Student
类,就是上文中的proto.helloworld.Student
;
有了以上两个前提,理论上我们就可以用 AST
工具做以下操作,来达到最终目的:
- 获取到所有
getWrapperField
调用,并取出其第二个参数A
; - 同时获取到外层的函数名称
getXXX
,上例中为getStudentInfo
; - 生成
getXXXClass
函数返回A
,上例为
proto.helloworld.HelloRequest.prototype.getStudentInfoClass = function() {
return proto.helloworld.Student
};
复制代码
实现
直接上核心代码:
const $ = require("gogocode");
const classMethodList = []; // 收集被命中的 method,为后续 ts 处理提供筛选范围
const newCode = $(code).find(
`$_$0 = function() {
return jspb.Message.getWrapperField(this, $_$1, $_$2);
};`
)
.each((item) => {
const getMethod = item.match[0][0].value;
const paramsClass = item.match[1][0].value;
classMethodList.push(getMethod);
item.after(`
${getMethod}Class = function() {
return ${paramsClass};
}
`);
})
.root()
.generate();
复制代码
简要说明一下:
code
即为 protoc 生成的helloworld_pb.js
的代码;- gogocode 的代码风格很像 jQuery,可读性比较强,
find
、each
、after
等语法,即使没用过 jQuery,大概也能猜到是什么意思; - 代码中的
$_$x
是正则占位符,下面的item.match[x][x].value
就是在读取它们的值。
处理后生成的 newCode 代码片段为:
proto.helloworld.HelloRequest.prototype.getStudentInfo = function() {
return /** @type{?proto.helloworld.Student} */ (
jspb.Message.getWrapperField(this, proto.helloworld.Student, 2));
};
proto.helloworld.HelloRequest.prototype.getStudentInfoClass = function() {
return proto.helloworld.Student;
}
复制代码
- 新生成的代码(
after
方法里的内容)可以根据自己的需求定义,理论上不用非得是一个函数,直接定义一个属性反而更简单,本文为了易读性使用了函数。
处理 helloworld_pb.d.ts 文件
分析
阅读以下 protoc 生成的 helloworld_pb.d.ts
代码
export class HelloRequest extends jspb.Message {
// other code
getStudentInfo(): Student | undefined;
setStudentInfo(value?: Student): HelloRequest;
hasStudentInfo(): boolean;
clearStudentInfo(): HelloRequest;
// other code
}
复制代码
我们希望插入一条
getStudentInfoClass(): typeof Student;
复制代码
利用 gogocode 提取出 getStudentInfo
和 Student
后拼出需要的代码就行了。但是,别看修改量不大,想实现也不太容易,需要注意几点:
- 理论上这部分修改的代码需要依赖修改 js 文件时,遍历的
getMethod
信息进行筛选,所以需要用一个classMethodList
数组来收集被命中的方法名,详见上文的代码备注; typeof
这个 ts 的关键字目前gogocode
没有提供方便的生成 AST 的方法(也可能是用的不熟),所以不得不引入@babel/types
来生成需要的 AST 对象;- 基于上一点,生成代码有两种思路:一是 clone 一份
getStudentInfo
的 AST,修改其属性;二是直接用@babel/types
来生成。前者的易读性更好,但是代码量较多;后者代码量较少,但是需要具备一定的 babel 知识,各位视情况而定。不过即使是第一种方法,也需要稍微用一下@babel/types
来解决 typeof 的问题。
实现
const $ = require("gogocode");
const t = require("@babel/types");
const tsNewCode = $(tsCode)
.find("$_$1(): $_$2 | undefined")
.each((item) => {
const callee = item.match[1][0].value; // getStudentInfo
const returnNode = item.match[2][0]; // returnType AST
if (returnNode.type === "TSTypeReference" && classMethodList.includes(callee)) {
// 方法一:clone item
const newNode = item.clone();
// t.tsTypeQuery 就是生成 typeof AST 的方法
newNode.attr({
"[0].nodePath.node.key.name": `${callee}Class`,
"[0].nodePath.node.returnType.typeAnnotation": t.tsTypeQuery(
returnNode.typeName // Student 的 AST
),
});
item.after(newNode);
/* 方法二: @babel/types
item.after(
t.tsDeclareMethod(
null,
t.identifier(`${callee}Class`),
null,
[],
t.tsTypeAnnotation(t.tsTypeQuery(returnNode.typeName))
)
);
*/
}
})
.root()
.generate();
复制代码
最终生成的代码片段:
// other code
getStudentInfo(): Student | undefined;
getStudentInfoClass(): typeof Student;
// other code
复制代码
处理 helloworld_grpc_web_pb.js
在《完美版》中还提到,需要对 sayHello
所在的文件进行处理,让其能够返回 sayHello
等 Method 的入参类。思路与方法与上文大同小异,就不赘述了。
值得一提的是本来它内部是有一个对象能够获取到这个信息的,但是因为有 bug,没有办法获取到。见如下代码:
const methodDescriptor_Greeter_SayHello = new grpc.web.MethodDescriptor(
'/helloworld.Greeter/SayHello',
grpc.web.MethodType.UNARY,
proto.helloworld.HelloRequest,
proto.helloworld.HelloReply,
/**
* @param {!proto.helloworld.HelloRequest} request
* @return {!Uint8Array}
*/
function(request) {
return request.serializeBinary();
},
proto.helloworld.HelloReply.deserializeBinary
);
复制代码
实际上调用 methodDescriptor_Greeter_XXX.getRequestMessageCtor()
就能够获取到 HelloRequest
了,但是打包后的代码中,该对象并没有这个方法,所以根本调不到。
这个 bug 是同事发现的(没错,就是《完美版》中提到的那个同事),而且据说已经合了相关 PR,估计不久后会发布新版本吧。如果新版本自带了这个能力,我们也就不用再加工它了。
结语
本文的内容都是基于最简单的 demo,所以在用在实践当中时有可能会有需要调整的地方。所以各位重要的还是理解思想,知道这个思路可行即可,具体问题具体分析。
不过 gogocode
的确是一个好用的操作 AST 的工具,而且它还提供了 Vue2 转 Vue3 的功能,正好符合团队诉求,后续如果还有什么好用的工具会尽量分享出来。另外,简单了解下 Babel
尤其是 @babel/types
的用法其实性价比挺高的。
谨记,你是在寻找最好的答案,而不是你自己能得出的最好答案。——Ray Dalio