Nest.js任务队列实现远程代码扫描
2021-10-11
之前开发的代码质量检测工具存在一个问题:在大部分时候开发业务代码已经够费神了,开发者并不会主动使用这个工具给代码打分,而且万一检查出来很多问题还得改,这不是没事找事吗?但是你们不使用我怎么完成我的OKR呢?为了能够让工具的使用更加无感知,结合 CICD 是一种比较好的做法,每次提交合并请求就自动检查,有问题这个请求就不能被合入,再添以邮件通知告警等功能,则可对项目实现一次配置,持续监察。
问题在于该工具的使用条件需要满足 Node 版本大于 12,调研后发现许多产品线的 Runner 中打包项目的 Node 版本并不满足此要求,因此远程扫描这个功能便应运而生。
远程扫描功能整个过程是:
- cli 将本地的代码发送至服务端进行扫描
- 服务端扫描代码(等价于在本地扫描代码,只是执行环境在服务端),返回扫描结果
很显然如果代码量较大,则压缩传输和服务端扫描都需要花费一定的时间,但使用者关注的只是报告结果,这个结果过会儿去报告平台上去查阅即可,因此扫描的这部分时间并不值得等待。所以服务端的接口在获取到上传文件后便可向客户端返回响应告知其上传成功即可,扫描的工作在后台执行。像这种场景,任务队列便是解决这种类型问题的好办法。
在 Nest.js Queues 中了解到 bull,它基于 Redis 实现任务队列。下面是 Nest.js 中使用 bull 实现任务队列的一个例子。
先定义任务队列管理器:
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
@Processor('bcodeScan')
export class ScanCodeProcessor {
// 某个任务队列
@Process('startRemoteScan')
async startScan (job: Job) {
// 获取该任务处理相关的数据
const { dataExample } = job.data;
// do something to handle task...
// 这里返回值会保存在 Redis 该记录的 returnValue 中,因此可以通过某个标识去获取任务结果
return xxxx;
}
}
在业务中使用该任务处理器:
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
export class RemoteScanService {
// 注入任务管理器
constructor(@InjectQueue('bcodeScan') private readonly scanQueue: Queue) {}
async handleUploadSource(fileData: Buffer, bodyParams: UploadSourceParams) {
this.scanQueue.add(
'startRemoteScan', // 将任务添加到哪个队列
{
dataExample: 'test' // 数据
},
{
jobId: +new Date(), // 自定义任务id
},
);
}
}
在 Modules 中注册队列:
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
@Module({
// ...
imports: [
BullModule.registerQueue({
name: 'bcodeScan',
redis: {
host: 'localhost',
port: 6379,
},
}),
]
})
比较简单的就实现了一个任务队列。回到问题本身,这里如何实现远程扫描?
我的解决方案:
- 当待扫描源码上传且将其解压到指定目录后,在待扫描源码根目录下生成
bcodeExcute.js
文件,它的内容则是将开发者在本地执行的命令行转为 Javascript 代码 - 通过子进程的方式执行
bcodeExcute.js
来扫描
// 服务端处理上传源码的逻辑
async handleUploadSource(fileData: Buffer, bodyParams: UploadSourceParams) {
// 解压源码文件,获取目标目录
const targetDir = depressFile(fileData);
// 在待扫描源码根目录下生成 bcodeExcute.js 文件
genScanScode(targetDir);
// 任务队列新增任务
this.scanQueue.add(
'startRemoteScan', // 将任务添加到哪个队列
{
targetDir // 待扫描源码目录
},
{
jobId: +new Date(), // 自定义任务id
},
);
}
我们知道开发的命令行工具的原理是解析外部输入命令,调用功能函数来执行操作,因此此处只需要调用 bcode 暴露出来的 run 方法,即可将命令行转为同等功能的 Javascript 代码来执行。生成的 bcodeExcute.js
内容如下:
// 生成的bcodeExcute.js
const genScanScode = (targetDir: string, scanDir: string, config: string) => {
const code = `
const run = require('bcode');
process.on('message', async ({params}) => {
const scanMark = await run(
JSON.parse(params),
true,
);
process.send(scanMark);
});
`;
fs.writeFileSync(path.resolve(targetDir, 'bcodeExcute.js'), code);
};
接下来在该任务队列的处理逻辑里,实现父子进程通信执行脚本文件。
import * as treeKill from 'tree-kill';
import * as Redis from 'ioredis';
const redisClient = new Redis({
db: 0,
});
@Process('startRemoteScan')
async startScan (job: Job) {
// 获取待扫描源码目录
const { targetDir, params } = job.data;
// 执行远程扫描
// fork 的方式创建子进程
const p = fork(forkPath, {
cwd: targetDir, // 指定该脚本 process.cwd 到源码目录
});
// 发送子进程所需要的数据
p.send({
scanDir,
params,
});
p.on('exit', (code, singal) => {
// 子进程退出
});
// 子进程出错
p.on('error', (error) => {
// 将错误信息记录在该任务redis数据内
redisClient.hmset(`bull:bcodeScan:${job.id}`, {
errorInfo: error.message,
});
// 退出子进程
treeKill(p.pid);
});
// 子进程消息
p.on('message', async (scanMark: string) => {
// 成功,将结果记录在该记录内
redisClient.hmset(`bull:bcodeScan:${job.id}`, {
returnvalue: scanMark,
});
// ... 做一些扫描成功后的事情,如邮件通知
treeKill(p.pid);
});
}