一. 需求背景
首先, 说明下我们的项目在前端与后端是使用WebSocket进行通讯, 使用ProtoBuf协议进行数据转化, 可以使整体包结构缩小, 在庞大数据量传输的情况下可以带来性能上的大幅度提升, 有兴趣的小伙伴可以深入了解下, 这里就不多说明了
关于protobuf的使用, 要求前后端必须同时都有对应的消息体文件(message package), 在前端数据传输给后台的过程中, 需要通过对应的消息体文件进行转码, 接受数据的过程中, 又得用对应的消息体对数据进行解码, 这一来一回的过程, 导致前端这边需要做很多重复性的工作(ctrl+c, ctrl+v后台的消息体文件到前端, 然后加对应的转码, 解码配置等等balabala......)
在刚开始的迭代过程中, 针对一个功能后台给出的接口不会很多, 所以我们前端这边开发也不会有什么感觉, 后台发给前端, 直接就Copy拿来用; 直到最近的一次迭代, 后台一次性给出了17个接口要我ctrl c ctrl v 17次还要配置那么多东西还不能漏 What the fxxk?!!
害, 稳食艰难, 生活还是要继续, 代码还得继续敲, 可我不能继续这样子搞, 这样子搞也太傻x了吧
我看着配置文件, 然后看了看消息体, 再看看今日X条, 再看看小破站, 再看回代码
叮! I had an idea!!!
我懂了! 干脆搞个自动化代码生成脚步出来, 这样我就可以躺着把活干了, 我TM直呼妙啊
二. 正文
OK, 以下开始进入正文环节
首先, 我要做的是对于历史代码结构的分析
先看看消息体大搞长什么样子吧
// @filename a.proto
syntax = "proto3"
package Test;
message CTest {
string market = 1; // 这是一个备注测试1
string stkcode = 2; // 这是一个备注测试2
double price = 3; // 这是一个备注测试3
int32 qty = 4; // 这是一个备注测试4
repeated Test.DTest account = 5; // 这是一个备注测试5
...
}
message DTest {
string id = 1; // 这是一个内嵌备注测试1
double hold_amt = 2; // 这是一个内嵌备注测试2
...
}
...
然后是配置文件
// @file a.js
const str = `
message CTest {
string market = 1;
...
}
message DTest {
string id = 1;
...
}
`
module.exports = {
type: 'CTest',
str,
}
// @filename parse.ts
const a = require('./a');
...
class Parser {
...
private method({xx}) {
...
return balabalabala;
}
private PROTO_MAPPING = {
[APICONF.XXX]: this.method(a),
[APICONF.YYY]: this.method(b),
[APICONF.ZZZ]: this.method(c),
...
}
...
}
OK, 观察完上面几个文件后, 我发现这些代码结构都是一样的, 只不过是内容不一样而已, 基于这点入手, 我可以根据 a.proto 去生成这堆文件, 并且由于我们项目使用Typescript进行编码, 而 proto 消息体本身就跟接口定义长得很像, 那我还可以顺便把接口定义文件给生成了;
叮叮!!搓多麻跌, 既然接口定义文件都生成了, 那么 API 请求文件我也可以生成了; 既然API请求可以自动生成了, 那么接口文档我也可以自动生成了; 既然接口文档可以自动生成了, 那么我也可以...嘿嘿嘿
哈哈哈, 思维扩散有点厉害了, 我们还是先回到主题吧
三. How to do?
-
首先, 观察 a.js 文件, 我们可以得知一个接口需要使用到的是特定的消息头; 即, APICONF.XXX需要使用CTest进行转码,OK,那有什么方法可以拿到CTest呢, 这里我们可以采取特殊注释的方法, 在特定消息体上添加注释, 然后再用正则去匹配,就可以很方便很快速的找到各个接口啦 (tips: 接口在我们项目中又被称为功能,每个功能都有自己的功能号,不同功能可能使用相同的功能号)
-
添加注释, 这里强烈推荐VSCode支持的自定义代码片段拓展功能,生成代码不要太爽; 然后看下大概的注释长什么样子(这里注释由后端维护)
// @filename a.proto
...
======= ∨∨∨ 特殊注释 ∨∨∨ =======
/**
* @funcid 12345
* @desc 这是一个接口介绍
*/
======= ∧∧∧ 特殊注释 ∧∧∧ =======
message CTest {
string market = 1; // 这是一个备注测试1
string stkcode = 2; // 这是一个备注测试2
double price = 3; // 这是一个备注测试3
int32 qty = 4; // 这是一个备注测试4
repeated Test.DTest account = 5; // 这是一个备注测试5
...
}
message DTest {
string id = 1; // 这是一个内嵌备注测试1
double hold_amt = 2; // 这是一个内嵌备注测试2
...
}
...
-
规划具体实现流程图
-
开始撸码 首先, 看下大致的执行流程
// @filename index.js
...
// 首先, 创建目录
createDir(['./proto', './type', './conf', './request']);
...
// 获取所有proto文件的路径
getAllFilePath()
// 这一步可以忽略,这里的作用是给自定义忽略的import添加weak标识
.then(async list => {
await batchChangeWeakImport(list);
return list;
})
// 开始批量读取
.then(list => {
return batchReadFile(list);
})
// 获取到所有功能号缓存,功能号使用的消息体缓存, 所有消息体的缓存, 然后执行操作
.then(async data => {
const { funCache, funcMsgCache, allMsgCache } = data;
const list = Object.values(funCache);
// 遍历功能号列表
for (let i = 0; i < list.length; i++) {
const main = list[i];
const mainMsg = getMessage(main.name, funcMsgCache);
const msgChildList = recursionCache(mainMsg, funcMsgCache);
if (!mainMsg) {
console.log(`没有找到消息头: ${main}!`);
} else {
...
// 如果读到消息体,开始写proto.js, type.ts, request.ts
await writeProtoFile(mainMsg, msgChildList);
await writeTypeFile(mainMsg, msgChildList, allMsgCache);
await writeRequestFile(mainMsg)
}
}
// 写 type/index.ts 做统一导出
await writeTypeIndexFile(list);
// 写配置文件 conf.ts
await writeConfFile(Object.values(funCache));
return data;
})
.then(() => {
console.log('任务完成');
})
.then(() => {
// 获取prettier执行路径
const prettierPath = path.resolve(__dirname, '../../../node_modules/prettier/bin-prettier.js');
// 使用exec执行
childProgress.exec(`node ${prettierPath} --write ${This is the proto filePath}`, (err, _code) => {
if (err) {
console.log(err);
return;
}
console.log('格式化成功');
});
});
具体细节函数的实现
// 批量文件读取函数
async function batchReadFile(list) {
let funCache = Object.create({});
let funcMsgCache = Object.create({});
let allMsgCache = Object.create({});
await list.reduce(async (last, next) => {
const result = await last;
if (result) {
funCache = Object.assign(funCache, result.funcMsgCache);
funcMsgCache = Object.assign(funcMsgCache, result.nested);
allMsgCache = Object.assign(allMsgCache, result.allMsgCache);
}
return await readSignFile(next);
}, readSignFile(list[0]));
return {
funcMsgCache,
funCache,
allMsgCache,
};
}
// 单个文件读取函数
function readSignFile(filePath) {
return new Promise(resolve => {
fs.readFile(filePath, (__err, originCode) => {
const code = iconv.decode(originCode, 'utf-8');
// 使用正则, 匹配到整个文件所有功能号
// GET_FUNC_MESSAGE_REG = /@funcid\s+(?<funcid>.+)??[\r|\n][\s|\S]+?@desc\s+(?<desc>.+)??[\r|\n][\s|\S]+?message\s+(?<name>(\w+))[\s|\S]+?{/g;message\s+(?<name>(\w+))[\s|\S]+?{/g;
let funcMsg = GET_FUNC_MESSAGE_REG.exec(code);
// GET_ALL_MESSAGE_REG = /message (?<name>(\w+))[\s|\S]+?{(?<content>[\s|\S]+?)}/g;
let allMsg = GET_ALL_MESSAGE_REG.exec(code);
while (funcMsg !== null) {
...
funcMsg = GET_FUNC_MESSAGE_REG.exec(code);
}
while (allMsg !== null) {
...
allMsg = GET_ALL_MESSAGE_REG.exec(code);
}
// 之前第一版全用的是正则匹配,但是发现proto消息体不仅仅只是一个文件,它里面可以引入很多模块
// 导致其他文件的消息体读取不到, 比较麻烦
// 这里使用了第三方 protobufjs 库进行读取, 可以很方便地为我们解决这个问题
root.load(filePath, { keepCase: true }, (err, bufCode) => {
...
if (bufCode) {
resolve({
nested: bufCode.nested, // 嵌套消息体
funcMsgCache,
allMsgCache,
});
} else {
resolve();
}
});
});
});
}
Write的函数, 其他Write函数基本上都大同小异, 这里就不贴出来了
function writeProtoFile(main, childlist) {
return new Promise(async resolve => {
const fileName = `./proto/${main.name}.js`;
const filePath = path.resolve(__dirname, fileName);
await write(filePath, baseProtoModule(main, childlist));
resolve();
});
}
模板文件
// @filename commonModule.js
const { NEED_CHANGE_NUMBER } = require('../resource');
// 这里需要将int32, int62, double, long等等JavaScript不存在的类型转化成number
function isNeedChangeNumber(str) {
if (NEED_CHANGE_NUMBER.includes(str)) return 'number';
return str;
}
// 基础的proto模板
function baseProtoModule(main, childList) {
const result = writeContent(main, childList, 'message', false);
return `
const str = \`
${result}
\`;
module.exports = {
type: '${main.name}',
str,
}
`;
}
// 基础的type模板
function baseTypeModule(main, childList, allMsgCache) {
return `
/** 功能号 ${main.use || main.funcid} */
${writeContent(main, childList, 'interface', allMsgCache, true)}
`;
}
// 返回消息体
function writeContent(main, childList, type, allMsgCache = {}, needExport = false) {
...
return `
${messageContent(main, type, allMsgCache, parentExportStr ? 'export default' : '')}
${childStr}
`;
}
// 获取消息体
function messageContent(message, type, allMsgCache, prefix = '') {
...
const mainContent = (fields, split) => `
${prefix} ${type} ${message.name} {
${createMessageContent(fields, split, allMsgCache)}
}
`;
// 可能存在内嵌枚举类型
const enumContent = (message, split) => `
${prefix} enum ${message.name} {
${createEnumContent(message, split)}
}
`;
if (message.fieldsArray) {
return mainContent(message.fieldsArray, splitComman);
} else {
// 如果没有field, 则说明可能是特殊类型, 例如枚举Enum
return enumContent(message, splitComman);
}
}
// 创建消息体内容
function createMessageContent(list, split, allMsgCache = {}) {
let str = ``;
for (let i = 0; i < list.length; i++) {
const item = list[i];
const type = item.type.indexOf('.') >= 0 ? item.type.split('.')[1] : item.type;
if (split == ',') {
// 获取消息体content, 以获取注释
const msgContent = allMsgCache[item.parent.name];
let note = '';
if (msgContent) {
const reg = new RegExp(`${item.name}[\\s|\\S]+?;(?<note>[\\s|\\S]*?\\n)`);
const getNote = reg.exec(msgContent);
note = `/** ${getNote && getNote.groups.note.replace(/\/\/|\n|\s/g, '')} */`;
}
str += `
${note}
${item.name}: ${item.rule === 'repeated' ? `Array<${type}>` : isNeedChangeNumber(type)}; \n
`;
} else if (split == ';') {
str += `${item.rule || ''} ${type} ${item.name} = ${item.id}${split} \n`;
}
}
return str;
}
...
// @filename confModule.js
function baseConfModule(apiList) {
let str = '';
let importStr = '';
let hadRequire = {};
for (let i = 0; i < apiList.length; i++) {
const apiItem = apiList[i];
// 由于有些消息体可能是Test.EMsg这样的命名,在之前解析文件的时候已经将realName提取出来,
// 并注入到apiList中
if (!hadRequire[apiItem.realName]) {
hadRequire[apiItem.realName] = true;
// 自动引入
importStr += `const ${apiItem.realName.toLocaleLowerCase()} = require('../proto/${apiItem.realName}.js'); \n`;
}
// 这里生成配置项, 同parse.ts中的PROTO_MAPPING结构一致
// 在后续parse.ts中,直接引入conf export出的内容
// 就可以做到零影响注入了
str += `[FUNAPI.${apiItem.name.toLocaleUpperCase()}]: method(${apiItem.realName.toLocaleLowerCase()}), \n`;
}
return `
import FUNAPI from './FUNAPI';
// 提取了parse.ts中的方法, 放在公共的文件,方便复用
const method = require("../../protocol-method");
${importStr}
function method(xxx) {
return protobuf(xxx);
}
export default {
${str}
}
`;
}
- 结果展示
在package.json的script中添加 protoAutoTask: "node ${xxx/index.js}",
然后执行npm run protoAutoTask命令如下图
让我们再看下具体生成的文件
proto消息体定义文件部分截取.js
type接口类型定义部分截取.ts
FunConf配置部分截取.ts
api例子截取.ts
doc例子截取生成
铛铛铛铛! 搞定了! 这就是自动化生成代码工具的大概流程了
四. 结语
说一说为什么要写这个东西吧, 毕业这几年来, 一直就没有仔细地写过总结, 导致很多东西总是不知不觉地遗忘
这次是我自毕业以来的第一篇文章, 也是2021年的第一篇文章, 希望借此机会, 以今年为分水岭, 培养写作的能力, 学会总结, 学会分享, 跟大🔥 萌共同进步!
这是萌新的第一篇文章,希望大佬们多给点建议!
接下来有可能会继续写的主题包括:
- 基于Jenkins的前后端代码定时同步任务
- Webpack 懒加载源码解读 原理与实现
- Electron的热更新实现
- 基于Git Hooks的代码提交流程规范
- 编写自定义Eslint规则, 定制化你的业务需求
- ......