项目背景
上文的代码,是我基于egg.js做的一个傻瓜版发布版本工具。机制如下:根据前端请求的参数(一个项目列表,每个项目有一些指令),然后后端根据不同指令和项目id去跑对应的逻辑。
- 1.创建临时目录
- 2.拉取指定仓库最新代码
- 3.自动安装依赖
- 4.npm run build
- 5.ftp/git同步代码到生产环境。
界面以及核心代码
下面是核心代码。通过长链接做项目发布的整个流程前后端交互方式.代码随便瞎写的,凑合看。
egg.js + bootstrap样式库 + socket.io.js +axios。
后端业务核心代码
'use strict';
const Controller = require('egg').Controller;
const build = require('@root/config/build');
const fs = require('fs');
const shell = require('shelljs');
const FtpService = require('@root/lib/ftp');
const utils = require('@root/lib/utils');
const Log = require('@root/lib/log');
const logCtx = Log.getInstance();
class ProcessController extends Controller {
constructor(props) {
super(props);
this.releasePath = '';
this.id = '';
this.tempDirName = '';
this.buildConfig = build.config;
this.logFilePath = '';
}
log({
msg, id, toast = true, showLine = true } = {
}) {
if (typeof arguments[0] === 'string')msg = arguments[0];
if (!id)id = this.id;
if (!id) return;
const {
ctx } = this;
ctx.socket.emit('log', ctx.helper.parseMsg('process-log', {
id, msg, toast, showLine }));
}
recordFileLog({
msg }) {
Log.recordFileLog({
filePath: this.logFilePath, msg });
}
async index() {
const {
ctx } = this;
const {
action, id } = ctx.args[0];
// 重要
this.id = id;
const taskId = '';
const projectInfoData = await this.service.project.getProjectById(id);
const projectInfo = utils.objTranslate(projectInfoData);
if (action === 'release') {
try {
const startTime = new Date().getTime();
await ctx.helper.sleep(100);
// 先说流程已经启动
this.log('流程已启动......');
// 1.创建虚拟目录
const tempDirPath = this.buildConfig.tempDirPath;
const nowTime = new Date().getTime();
const tempDirName = `${
projectInfo.repository_name}_${
nowTime}_${
id}`;
const tempPath = `${
tempDirPath}/${
tempDirName}`;
this.releasePath = tempPath;
this.tempDirName = tempDirName;
this.logFilePath = `${
this.releasePath}/${
projectInfo.repository_name}_${
this.tempDirName}_${
this.id}_log.txt`;
this.log({
msg: `打包路径:${
tempPath}`, toast: false });
fs.mkdirSync(`${
tempPath}`, {
recursive: true });
this.log('目录创建成功...' + tempPath);
shell.cd(tempPath);
// 2.拉取代码
const {
repository_url } = projectInfo;
const {
name, pwd } = this.buildConfig.gitlab;
this.log({
msg: `从仓库${
repository_url}拉取代码`, showLine: false });
const url = repository_url.replace('http://', '');
await ctx.helper.sleep(100);
// 只能这样嵌套了嘛。。
await new Promise((resolve, reject) => {
shell.exec(`git clone http://${
name}:${
pwd}@${
url} .`, {
}, (code, stdout, stderr) => {
const logFileRowContent = JSON.stringify({
code, stdout, stderr });
this.recordFileLog(logFileRowContent);
// update project version form package.json
// release/60a708c766f1a146b79d475d_1621560699290/package.json
const packageJsonObj = require(`@root/${
tempPath}/package.json`);
if (packageJsonObj && packageJsonObj.hasOwnProperty('version')) {
const currentVersion = packageJsonObj.version;
projectInfo.version = currentVersion;
}
resolve(true);
});
});
this.log('代码拉取成功...');
this.log('开始安装依赖');
await ctx.helper.sleep(100);
// 3.安装依赖
// shell.exec('yarn');
await new Promise((resolve, reject) => {
shell.exec('yarn', {
}, (code, stdout, stderr) => {
const logFileRowContent = JSON.stringify({
code, stdout, stderr });
this.recordFileLog(logFileRowContent);
resolve(true);
});
});
this.log('依赖安装成功...');
// 4.打包
this.log('开始打包');
await ctx.helper.sleep(100);
// shell.exec('npm run build');
await new Promise((resolve, reject) => {
shell.exec('npm run build', {
}, (code, stdout, stderr) => {
const logFileRowContent = JSON.stringify({
code, stdout, stderr });
this.recordFileLog(logFileRowContent);
resolve(true);
});
});
this.log('打包成功...');
// 5.上传
const {
ftp_host, ftp_name, ftp_pwd, ftp_port } = projectInfo;
if (ftp_host && ftp_name && ftp_pwd) {
this.log('开始上传');
// 这里的dist最好是可以配置,因为不同工程打包出来不一样。比如hbuilder打包出来的就不是unpackage/dist...
await FtpService.up({
host: ftp_host, user: ftp_name, password: ftp_pwd, port: ftp_port, localRoot: `${
tempPath}/dist` }).then(res => {
this.log('上传成功...');
}).catch(err => {
this.log('上传失败:' + err.message);
});
}
await ctx.helper.sleep(100);
// 6.压缩备份
// 更新数据
const newProjectInfo = {
...projectInfo, build_count: Number(projectInfo.build_count) + 1 };
await ctx.service.project.save(newProjectInfo);
ctx.socket.emit('process', ctx.helper.parseMsg('process-success', {
id }));
const endTime = new Date().getTime();
this.recordFileLog({
msg: '编译成功,耗时' + (endTime - startTime) / 1000 + '秒' });
this.log('任务完成');
} catch (e) {
this.log('编译失败啦');
this.recordFileLog({
msg: '编译失败' + e.message });
const newProjectInfo = {
...projectInfo, err_count: Number(projectInfo.err_count) + 1 };
await ctx.service.project.save(newProjectInfo);
ctx.socket.emit('process', ctx.helper.parseMsg('process-fail', {
id }));
}
}
}
}
module.exports = ProcessController;
前端核心代码
{% extends "layout.html" %}
{% block right %}
<div class="action-head m-b-15">
<a href="/project/form" type="button" class="btn btn-primary btn-sm">添加项目</a>
</div>
<div class="container-box">
<table class="table fz-14">
<thead>
<tr>
<th>序号</th>
<th>名字</th>
<th>描述</th>
<th>仓库地址</th>
<th>版本</th>
<th>编译成功次数</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for project in projectList %}
<tr>
<th data-pid="{
{project._id}}">{
{loop.index}}</th>
<td>{
{project.title}}</td>
<td>{
{project.description}}</td>
<td><a target="_blank" href="{
{project.repository_url}}">{
{project.repository_url}}</a></td>
<td>{
{project.version}}</td>
<td><span id="build-count-{
{project._id}}">{
{project.build_count}}</span></td>
<td>
<div id="status-{
{project._id}}">空闲</div>
</td>
<td style="white-space: nowrap;">
<button onclick="projectRelease(this)" id="release-btn-{
{project._id}}" data-id="{
{project._id}}"
type="button" class="btn btn-primary btn-sm">
<i class="bi bi bi-cloud-upload m-r-4"></i>
一键发布
</button>
<button onclick="projectBuild(this)" data-id="{
{project._id}}" type="button" class="btn btn-success btn-sm">
编译
一键发布
</button>
<a href="/project/form?id={
{project.id}}" class="btn btn-info btn-sm m-l-10"><i
class="bi bi bi-gear m-r-4"></i>编辑配置</a>
<button type="button" class="btn btn-danger btn-sm m-l-10"><i class="bi bi-back m-r-4"></i>回滚版本</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block script %}
<script>
function addNotify({
msg, title = '提示', time = false }) {
if (!document.getElementById('notifyBox')) {
$('#wrap')
.append(`<div id="notifyBox" style="position: fixed; top: 10px; right: 10px;z-index: 66"></div>`);
}
// <div id="notifyBox" style="position: fixed; top: 0; right: 0;z-index: 66"></div>
if (!time) {
time = dayjs()
.format('YYYY-MM-DD HH:mm:ss');
}
const name = new Date().getTime();
const tmplStr = `<div id="${
name}" class="toast" role="alert" aria-live="assertive" aria-atomic="true" data-delay="4000">
<div class="toast-header">
<i class="bi bi-info-circle m-r-4"></i>
<strong class="mr-auto">${
title}</strong>
<small class="text-muted">${
time}</small>
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="toast-body">
${
msg}
</div>
</div>`;
$('#notifyBox')
.append(tmplStr);
$(`#${
name}`)
.toast('show');
$(`#${
name}`)
.on('hidden.bs.toast', function() {
$(`#${
name}`)
.remove();
});
}
var socketReady = false
$(function(){
// if(!socketReady){
// socketCtx.open()
// socketReady = true
// }
})
//别超时
const socketCtx = io('/',{
autoConnect:false});
function ping() {
console.log('ping')
socketCtx.emit('ping', {
time: new Date().getTime() });
socketCtx.emit('chat', '123456789');
}
socketCtx.on('connect', () => {
console.log('connect!',socketCtx.id);
//不需要搞心跳
// ping();
// //心跳
// setInterval(ping, 3* 1000);
//socketCtx.emit('process', '123456789');
});
socketCtx.on("disconnect", () => {
console.log('socket disconnect'); // undefined
});
socketCtx.on("connect_error", () => {
console.log('connect_error emit')
// socketCtx.connect();
});
socketCtx.on('log', res => {
console.log('res from server:', JSON.stringify(res));
const {
action, payload } = res.data;
const {
id: projectId } = payload;
const $currentProjectStatusEl = $(`#status-${
projectId}`);
const $currentProjectEl = $(`#release-btn-${
projectId}`);
switch (action) {
case 'process-log':
payload.toast && addNotify({
msg: payload.msg });
payload.showLine && $currentProjectStatusEl.html(`${
payload.msg}`);
break;
default:
break;
}
})
socketCtx.on('res', res => {
if (typeof res === 'string') {
console.log('res from server:', res);
return;
}
console.log('res from server:', JSON.stringify(res));
});
socketCtx.on('process', res => {
if (typeof res === 'string') {
console.log('res from server:', res);
return;
}
console.log('res from server:', JSON.stringify(res));
const {
action, payload } = res.data;
const {
id: projectId } = payload;
const $currentProjectStatusEl = $(`#status-${
projectId}`);
const $currentProjectEl = $(`#release-btn-${
projectId}`);
switch (action) {
case 'process-fail':
// $currentProjectEl.html('一键发布')
$currentProjectStatusEl.html(`<span class="color-red">发布失败</span>`);
$currentProjectEl.attr('disabled', false);
break;
case 'process-success':
$currentProjectStatusEl.html(`<span class="color-green">发布成功</span>`);
// $currentProjectEl.html('一键发布')
$currentProjectEl.attr('disabled', false);
$(`#build-count-${
projectId}`).html(Number($(`#build-count-${
projectId}`).html())+1)
break;
default:
break;
}
});
async function projectBuild(el){
const projectId = $(el).data('id');
axios.post('/project/build',{
projectId}).then(()=>{
}).cache((err)=>{
console.log(err)
})
}
async function projectRelease(el) {
if(!socketReady){
socketCtx.open()
socketReady = true
}
const projectId = $(el)
.data('id');
//alert(projectId)
$(el)
.attr('disabled', true);
$(`#status-${
projectId}`)
.html('启动中...');
const $currentProjectStatusEl = $(`#status-${
projectId}`);
$currentProjectStatusEl.prepend(`<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true" id="${
projectId}_loading"></span>`);
socketCtx.emit('process', {
id: projectId, action: 'release' });
// await axios.post('/project/release',{id:projectId}).then(res=>{
// console.log(res)
// }).catch(err=>{
// console.log(err)
// }).finally(()=>{
//
// })
}
</script>
{% endblock %}
错误截图
vue-cli的filenameHashing默认是true的,所以打包后的js应该是hash的
手动打包是正常的
用了我的傻瓜工具
用这个代码,复现问题是必现的,会导致奇怪的问题。
'use strict';
const shell = require('shelljs');
shell.cd('release/vocen-engine-admin_1621566342725_60a72099ff8dfb0af9029188');
shell.exec('yarn');
shell.exec('npm run build');
问题分析
个人想法
1.第一层 首先编译后文件名不符合预期,就是webpack这里的配置加载有问题,一般自己手写就是这样
webpack.config.js
const path = require('path');
module.exports = {
//就是output
output: {
filename: 'my-first-webpack.bundle.js',
chunkFilename: (pathData) => {
return pathData.chunk.name === 'main' ? '[name].js' : '[name]/[name].js';
}
},
};
2.第二层
而之前手撸webpack的过程中,一般都会在指令中或者配置文件去约定mode是production还是development。代码如下。
//package.json
"scripts": {
"serve": "webpack-dev-server --mode development --progress --open",
"build": "webpack --env.production --mode production --progress"
}
然后想到有可能是shelljs和真实的cmd差别,有可能就是他被设置成了development
开始捣鼓代码
然后就开始找filename和chunkFilename相关而配置.
不过一开始没有方向,只能从头开始把vue-cli代码看了一下,居然看了好久。虽然看高手代码很开心,但是还是有硬性的目标要弄完。在这个过程中,基本看了一下vue-cli的打包过程(dev的没有过多关注,只看了vue-cli-service build相关的逻辑)。
在读代码的过程中遗漏了一个重要代码,导致耽误了两个小时。
//node_modules/@vue/cli-service/lib/Service.js,
//概179行左右,自己找
const builtInPlugins = [
'./commands/serve',//我认真看了这个,下意识认为服务相关的都在这里了
'./commands/build',
'./commands/inspect',
'./commands/help',
//我把后面的配置初始化这几个文件忽视了,失误太大了
// config plugins are order sensitive
'./config/base',
'./config/css',
'./config/prod',
'./config/app'
].map(idToPlugin)
因为看错了文件,导致在commands/service上花费太多事件。去了解指令的注册、以及调用过程(这里面包含了好多次2个对照组的多次运行,然后控制台打印生成的webpack配置文件对比,花费不少时间)。
在没有溯源到output.filename相关配置时,我尝试用了vscode在node_modules目录中搜索关键字filename,结果vscode欺骗了我。。。没有结果。
然后又是继续愚公移山一样去走完该指令的所有过程,走完之后发现配置还是不一样,但是无法定位关键步骤在哪里。。。
这个时候想到是不是vscode忽略了node_modules中的代码,没有参与检索(我之前用webstrom等,搜索结果会忽略node_modules中的包,只展示业务代码的搜索结果),尝试着用了一下webstrom打开node_modules目录(不打开项目目录,直接打开node_modules作为根目录),一搜索filename,我发现好多符合结果的。。。一下子就觉得之前白干了,微软坑我。
然后开始快速浏览结果,浏览到一个app.js中有符合的结果,再一看,这个文件居然是在vue-cli包里面,福至心灵了。。。基本就确定尤大写的东西,还是可以层级很清晰而且是高度可以配置的,没必要去深究到webpack包那一层。
然后打开这文件好好看,看到这里基本就知道了。。。
const isProd = process.env.NODE_ENV === 'production'
const isLegacyBundle = process.env.VUE_CLI_MODERN_MODE && !process.env.VUE_CLI_MODERN_BUILD
const outputDir = api.resolve(options.outputDir)
const getAssetPath = require('../util/getAssetPath')
const outputFilename = getAssetPath(
options,
`js/[name]${
isLegacyBundle ? `-legacy` : ``}${
isProd && options.filenameHashing ? '.[contenthash:8]' : ''}.js`
)
console.log('outputFilename is',outputFilename,isProd)
webpackConfig
.output
.filename(outputFilename)
.chunkFilename(outputFilename)
在控制台一打,确实如此。
const isProd = process.env.NODE_ENV === 'production'
console.log('isProd is',isProd)
傻瓜工具下运行 shell.exce(‘npm run build’)
outputFilename is false
手动运行npm run build
outputFilename is true
分析为什么我绕了弯路
没有认真看代码。
node_modules/@vue/cli-service/lib/Service.js
在使用shelljs跑npm run build等指令(也就是vue-cli-service build时),Servie逻辑跑的时候时序很重要。
module.exports = class Service {
constructor (context, {
plugins, pkg, inlineOptions, useBuiltIn } = {
}) {
//在这个resolvePlugins方法里面,配置了要引入的一堆东西。
//关键里面的config/app.js
this.plugins = this.resolvePlugins(plugins, useBuiltIn)
}
resolvePlugins (inlinePlugins, useBuiltIn) {
const idToPlugin = id => ({
id: id.replace(/^.\//, 'built-in:'),
apply: require(id)
})
let plugins
const builtInPlugins = [
'./commands/serve',
'./commands/build',
'./commands/inspect',
'./commands/help',
// config plugins are order sensitive
'./config/base',
'./config/css',
'./config/prod',
//这里面有用到process.env.NODE_ENV这个变量,来区分是dev还是prod.
'./config/app'
].map(idToPlugin)
}
}
// node_modules/@vue/cli-service/lib/Service.js loadEnv方法 ,大概96行。
//我看到过如下代码。。
loadEnv (mode) {
//省略一些代码。。。
// by default, NODE_ENV and BABEL_ENV are set to "development" unless mode
// is production or test. However the value in .env files will take higher
// priority.
if (mode) {
// always set NODE_ENV during tests
// as that is necessary for tests to not be affected by each other
const shouldForceDefaultEnv = (
process.env.VUE_CLI_TEST &&
!process.env.VUE_CLI_TEST_TESTING_ENV
)
const defaultNodeEnv = (mode === 'production' || mode === 'test')
? mode
: 'development'
if (shouldForceDefaultEnv || process.env.NODE_ENV == null) {
process.env.NODE_ENV = defaultNodeEnv
}
if (shouldForceDefaultEnv || process.env.BABEL_ENV == null) {
process.env.BABEL_ENV = defaultNodeEnv
}
}
//省略一些代码。。。
}
原因记录
@vue\cli-service\lib\config\app.js
const isProd = process.env.NODE_ENV === 'production'
/*
do something
*/
const outputFilename = getAssetPath(
options,
`js/[name]${
isLegacyBundle ? `-legacy` : ``}${
isProd && options.filenameHashing ? '.[contenthash:8]' : ''}.js`
)
具体位置在22行左右(版本不一致,自己找下)
就是这个错误
shelljs和cmd运行有什么差别呢??
又去看了shelljs的包介绍。
英文看不太懂,然后看源码
//node_modules/shelljs/src/exec.js 29行
opts = common.extend({
silent: common.config.silent,
cwd: _pwd().toString(),
env: process.env,
maxBuffer: DEFAULT_MAXBUFFER_SIZE,
encoding: 'utf8',
}, opts);
解决办法
shell.exec('npm run build', {
env: Object.assign({
}, process.env, {
NODE_ENV: 'production' }) });
经验总结
仔细看文档,多看源码,善用工具。
转载:https://blog.csdn.net/Function_JX_/article/details/117126723