文章目录
- 背景
- monorepo
- 多项目调试/打包
- 公共静态资源服务
- 公共模型拷贝入项目的public文件夹
- 总结
背景
Modelground中的项目,基本都依赖Mediapipe模型,因此,有很强的需要对Mediapipe进行封装,其余项目都调用这个封装库。从架构上,这种结构的项目很容易联想到Monorepo,即多项目管理。现代包管理器对monorepo形式的仓库已有较好的支持,例如yarn、lerna等。Modelground采用的是其中的一种:pnpm
。
架构示意图如下:
monorepo
首先全局安装pnpm
npm i pnpm -g
项目初始化
pnpm init
创建pnpm-workspace.yaml,定义包目录
packages:
- 'packages/**'
创建packages文件夹,添加第项目A和项目B
mkdir packages
cd packages
pnpm create vite A
pnpm create vite B
此时,packages中会出现名称为A和B的两个项目文件夹。
如果项目B要依赖A:
pnpm add A --filter B
此时,B项目的packages.json如下:
{
"dependencies": {
"A": "workspace:^",
},
}
这样,B打包时,A包不需要发布成npm包,B就可以将A一同打包进dist。
同理,需要给B项目添加某个依赖包C,也是如下代码:
pnpm add C --filter B
最后,统一安装整个项目的包
pnpm i
多项目调试/打包
Modelground中的项目有依赖关系,例如B依赖A,有时候我们会同时修改A和B项目的代码,如果每次都要手动启动两个项目,步骤过于繁琐,因此强需求一套自动化代码去调试某个项目前,自动开启其依赖项目。
我们首先需要一个终端的命令行选项,根目录安装inquirer
pnpm add inquirer --D -i
其次,在js文件中更方便地执行shell,需要安装execa
pnpm add execa --D -i
命令行写法:
inquirer
.prompt([
{
type: "list",
message: `选择要启动的项目:`,
name: "mono", // 存储答案的字段
default: 'home', // 默认启动项
choices: ['home', 'fitness-count', 'ml-video', 'shooter-game', 'generate-ai'], // 想启动的项目名列表
}
]).then({mono: prd} => {
console.log(prd)
// 选择启动的项目名,例如"home"
})
启动项目的代码:
const projectServer = execa('pnpm', ['--F', prd, 'run', 'dev'], { stdio: 'pipe' }); // 等价 $pnpm --F prd run dev
projectServer.stdout.on('data', (data) => { console.log(data) }); // 监听运行输出
projectServer.stderr.on('data', (data) => { console.error(data) }); // 监听报错输出
如何在项目A启动完成后,再启动B?
一种解法是在A项目启动后,监听stdout中的输出信息,如果出现"built in",就启动B,代码如下:
let hasRun = false;
const A = execa('pnpm', ['--F', 'A', 'run', 'dev'], { stdio: 'pipe' });
A.stdout.on('data', (data) => {
console.log(data)
// A运行起来后,再运行当前启动项目,仅运行一次
if (data.includes('built in') && !hasRun) {
hasRun = true;
const B = execa('pnpm', ['--F', 'B', 'run', 'dev'], { stdio: 'pipe' });
B.stdout.on('data', (data) => { console.log(data) });
B.stderr.on('data', (data) => { console.error(data) });
}
});
modelServer.stderr.on('data', (data) => { console.error(data) });
如果想区分不同项目的输出信息,可以安装一个chalk,可以用调整输出文字的颜色:
// 当前项目用绿色加粗
function projectTitle() {
return chalk.green.bold('当前项目服务:');
}
// 公共静态文件用黄色加粗
function publicTitle() {
return chalk.yellow.bold('公共模型服务:');
}
// 模型依赖用蓝色加粗
function modelTitle() {
return chalk.blue.bold('mediapipe模型服务:');
}
// stdOut用白色
function stdOut(data) {
return chalk.white(data);
}
// stdErr用红色
function stdErr(data) {
return chalk.red(data);
}
// 改造上述代码
A.stdout.on('data', (data) => { console.log(projectTitle(), stdOut(data)) });
A.stderr.on('data', (data) => { console.log(projectTitle(), stdErr(data)) });
效果图:
同理,也可以实现多项目打包代码同步远程仓库,这里就不赘述。
公共静态资源服务
Mediapipe模型所需的预训练模型体积相对较大,由于内部采用fetch方法去请求预训练模型,因此没法放在公共依赖包中,只能放在项目的public下。但是如果每个项目都去存放一些模型,往往有重复问题,因此强需求一个公共静态资源服务,将所有的预训练模型提取到一个公共目录下。
在packages下创建一个public-assets文件夹,将公共模型都放入该文件夹下。
创建一个server.js,写一个简单的node文件服务。
// 引入http模块
const http = require('http');
// 引入fs模块
const fs = require('fs');
// 引入path模块
const path = require('path');
// 创建HTTP服务器
const server = http.createServer((req, res) => {
// 构建请求的文件路径
const filePath = path.join(__dirname, '/', req.url === '/' ? 'index.html' : req.url);
// 检查文件是否存在
fs.exists(filePath, (exist) => {
if (!exist) {
// 如果文件不存在,返回404
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('404 Not Found');
return;
}
// 读取文件内容
fs.readFile(filePath, (err, content) => {
if (err) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end('500 Internal Server Error');
} else {
// 设置响应头
const extname = path.extname(filePath);
let contentType = 'text/plain';
if (extname === '.task') {
contentType = 'application/octet-stream'
} else if (extname === '.wasm') {
contentType = 'application/wasm';
} else if (extname === '.tflite') {
contentType = 'application/octet-stream';
}
res.writeHead(200, {
'Content-Type': contentType,
"access-control-allow-origin": "*", // 解决不同端口跨域问题
});
res.end(content, 'utf-8');
}
});
});
});
// 设置监听端口
const port = 5180;
server.listen(port, () => {
console.log(`Server running at http://localhost:${port}/`);
});
通过 node server.js 就可以开启该文件服务。
我们在每个项目中新建环境变量文件.env.development
VITE_MODEL_PATH=http://localhost:5180/
在实际请求模型时,通过vite的环境变量就可以取到该变量import.meta.env.VITE_MODEL_PATH
同理,在正式环境时,模型的请求地址就变成了项目的路由,如果是根路由,设定.env.production
VITE_MODEL_PATH=/
这样,不同环境下就能正常获取模型文件。
公共模型拷贝入项目的public文件夹
简单说,就是利用shell的cp命令,拷贝文件,直接上build代码:
import inquirer from "inquirer";
import { execaCommand } from "execa";
// 各项目所需的模型文件
const copyConfig = {
'shooter-game': {
'packages/public-assets/wasm/vision_wasm_internal.js': 'packages/shooter-game/dist/wasm',
'packages/public-assets/wasm/vision_wasm_internal.wasm': 'packages/shooter-game/dist/wasm',
'packages/public-assets/models/ObjectDetection/rim_ball_model_v1.tflite': 'packages/shooter-game/dist/models/ObjectDetection'
},
'ml-video': {
"packages/public-assets/models/ObjectDetection/rim_ball_model_v1.tflite": 'packages/ml-video/dist/models/ObjectDetection',
"packages/public-assets/models/ObjectDetection/efficientdet_lite0.tflite": 'packages/ml-video/dist/models/ObjectDetection',
"packages/public-assets/models/ObjectDetection/efficientdet_lite2.tflite": 'packages/ml-video/dist/models/ObjectDetection',
"packages/public-assets/models/PoseLandMarker/pose_landmarker_full.task": 'packages/ml-video/dist/models/PoseLandMarker',
"packages/public-assets/models/PoseLandMarker/pose_landmarker_lite.task": 'packages/ml-video/dist/models/PoseLandMarker',
"packages/public-assets/models/HandLandMarker/hand_landmarker.task": 'packages/ml-video/dist/models/HandLandMarker',
"packages/public-assets/models/FaceLandMarker/face_landmarker.task": 'packages/ml-video/dist/models/FaceLandMarker',
"packages/public-assets/wasm/vision_wasm_internal.js": 'packages/ml-video/dist/wasm',
"packages/public-assets/wasm/vision_wasm_internal.wasm": 'packages/ml-video/dist/wasm'
}
}
async function run() {
try {
const { mono: prd } = await inquirer
.prompt([
{
type: "list",
message: `选择要构建的项目:`,
name: "mono", // 存储答案的字段
default: 'home', // 默认启动项
choices: ['home', 'fitness-count', 'mediapipe-model-core', 'ml-video', 'shooter-game', 'generate-ai'],
}
]);
// 先打包,有了dist文件夹再拷贝文件
let result = await execaCommand(`pnpm --filter ${prd} run build`, { stdio: "inherit" });
const copy = copyConfig[prd];
if (!copy) return;
const pa = Object.entries(copy);
for (let i = 0; i < pa.length; i++) {
const from = pa[i][0]; // 待拷贝的文件
const to = pa[i][1]; // 目标文件夹
await execaCommand(`mkdir -p ${to}`); // 如果没有该文件夹,先新建
await execaCommand(`cp ${from} ${to}`); // 执行拷贝
}
} catch (err) {
console.error(err);
}
}
run();
总结
这套架构是我在开发Modelground过程中,逐渐摸索出来的比较成熟的架构。很多坑都是过程中发现并解决,并不是一开始就能考虑到的。
总结而言,依赖monorepo多项目管理模式,实现项目依赖,并行开发。通过流水线模式,简化项目启动流程。通过公共模型服务,减少冗余静态文件复制动作,在打包时统一拷贝。
以上,就是Modelground的工程化架构设计内容,极大减少了本人开发耗时,可以将精力集中在构思创意上。
欢迎访问Modelground体验已有模型https://tryiscool.space
如果本文对你有帮助,希望能得到你的三连+订阅Modelground专栏
,鼓励我持续产出,谢谢!