基于ProtoBuf协议消息体的自动化代码生成工具

2,337 阅读6分钟

一. 需求背景

首先, 说明下我们的项目在前端与后端是使用WebSocket进行通讯, 使用ProtoBuf协议进行数据转化, 可以使整体包结构缩小, 在庞大数据量传输的情况下可以带来性能上的大幅度提升, 有兴趣的小伙伴可以深入了解下, 这里就不多说明了

关于protobuf的使用, 要求前后端必须同时都有对应的消息体文件(message package), 在前端数据传输给后台的过程中, 需要通过对应的消息体文件进行转码, 接受数据的过程中, 又得用对应的消息体对数据进行解码, 这一来一回的过程, 导致前端这边需要做很多重复性的工作(ctrl+c, ctrl+v后台的消息体文件到前端, 然后加对应的转码, 解码配置等等balabala......)

在刚开始的迭代过程中, 针对一个功能后台给出的接口不会很多, 所以我们前端这边开发也不会有什么感觉, 后台发给前端, 直接就Copy拿来用; 直到最近的一次迭代, 后台一次性给出了17个接口要我ctrl c ctrl v 17次还要配置那么多东西还不能漏 What the fxxk?!!

去尼玛的工作, 老子不干了.jpg

害, 稳食艰难, 生活还是要继续, 代码还得继续敲, 可我不能继续这样子搞, 这样子搞也太傻x了吧

肚子好饿, 早知道不做前端.jpg

我看着配置文件, 然后看了看消息体, 再看看今日X条, 再看看小破站, 再看回代码

叮! I had an idea!!!

犀利的眼神.jpg

我懂了! 干脆搞个自动化代码生成脚步出来, 这样我就可以躺着把活干了, 我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 消息体本身就跟接口定义长得很像, 那我还可以顺便把接口定义文件给生成了;

灵光一现.jpg

叮叮!!搓多麻跌, 既然接口定义文件都生成了, 那么 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命令如下图 结果展示1.jpg 结果展示2.jpg

让我们再看下具体生成的文件

proto消息体定义文件部分截取.js

a.js

type接口类型定义部分截取.ts

type.ts

FunConf配置部分截取.ts

FunConf.ts

api例子截取.ts

api例子截取.ts

doc例子截取生成

铛铛铛铛! 搞定了! 这就是自动化生成代码工具的大概流程了

棒,不愧是我.jpg

四. 结语

说一说为什么要写这个东西吧, 毕业这几年来, 一直就没有仔细地写过总结, 导致很多东西总是不知不觉地遗忘

我TM直接痴呆.jpg

这次是我自毕业以来的第一篇文章, 也是2021年的第一篇文章, 希望借此机会, 以今年为分水岭, 培养写作的能力, 学会总结, 学会分享, 跟大🔥 萌共同进步!

这是萌新的第一篇文章,希望大佬们多给点建议!

接下来有可能会继续写的主题包括: