是否有不少人跟我同样有这样的一个烦恼,天天有写不完的需求、改不完的BUG,天天撸着重复、繁琐的业务代码,担忧着本身的技术成长。html
其实换个角度,咱们所学的全部前端技术都是服务于业务的,那咱们为何不想办法使用前端技术为业务作点东西?这样既能解决业务的困扰,也能让本身摆脱天天只能写重复繁琐代码的困扰。前端
本文主要为笔者针对当前团队内的一些业务问题,实现的一个自动化部署平台的技术方案。vue
原文地址node
去年年初,因为团队里没有前端,恰好我是被招过来的第一个,也是惟一一个FE,因而我接手了一个一直由后端维护的JSSDK项目,其实也说不上项目,接手的时候它只是一个2000多行代码的胖脚本,没有任何工程化痕迹。git
这个JSSDK,主要做用是在后端了为业务方分配appKey以后,前端将appKey写死在JSSDK中,上传到CDN后,为业务方提供数据采集服务的脚本。github
有的同窗可能有疑问,为何不像一些正常的SDK同样,appKey是以参数的形式传入到JSSDK中,这样就能够统一全部业务方使用同一个JSSDK,而不须要为每一个业务业务方都提供一个JSSDK。其实我刚开始也是这么想的,因而我向个人leader提出了个人这个想法,被拒绝了,拒绝缘由以下:sql
因为个人leader如今主要是负责产品推广,常常和业务方打交道,可能他更能站在业务方的角度来考虑问题。因此,个人leader选择牺牲项目的维护成原本下降SDK的接入成本和规避风险,能够理解。vuex
那既然咱们改变不了现状,那就只能适应现状。shell
那么针对原来没有任何工程化状况的胖脚本,每次新增一个业务方,我须要作的事情以下:数据库
整个过程都须要手动进行,相对来讲很是繁琐,而且一不当心就会填错,每次都须要对脚本和接入文档进行检查。
针对以上状况,获得咱们须要解决的问题:
介绍方案以前,先上一张平台截图,以便先有一个直观的认识:
SDK自动化部署平台主要实现了JSSDK的编译,发布测试(在线预览),上传CDN功能。
服务端技术栈包括:
客户端技术栈就不介绍了,Vue全家桶 + vue-property-decorator + vuex-class。
项目搭建参考:Vue+Express+Mysql 全栈初体验
自动化部署平台主要依赖于 GIT + 本地环境 + 私有NPM源 + MYSQL,各环节之间进行通讯交互,完成自动化部署。
主要达到的效果:本地环境拉取git仓库代码后,进行需求开发,完成后发布一个带Rollup的SDK编译器包到私有NPM仓库,自动化部署平台在工程目录安装指定版本的SDK,而且备份到本地,在SDK编译时,选择特定版本的Rollup的SDK编译器,并传参(如appKey,appId等)到编译器中进行编译,同时自动生成JSSDK接入文档等后打包成带描述文件的Release包,在上传到CDN时,将描述文件的对应的信息写入MYSQL中进行保存。
因为JSSDK本来只是一个脚本,咱们必须实现项目的工程化,从而完成版本管理,方便快速版本切换进行发布,回滚,进而快速止损。
首先,咱们须要将项目工程化,使用Rollup
进行模块管理,而且在发包NPM包的时候,输入为各类参数(如appKey)输出为一个Rollup Complier
的函数,而后使用rollup-plugin-replace在编译时候替换代码中具体的参数。
lib/build.js
,JSSDK中发包的入口文件,提供给SDK编译时使用
import * as rollup from 'rollup';
const replace = require('rollup-plugin-replace');
const path = require('path');
const pkgPath = path.join(__dirname, '..', 'package.json');
const pkg = require(pkgPath);
const proConfig = require('./proConfig');
function getRollupConfig(replaceParams) {
const config = proConfig;
// 注入系统变量
const replacePlugin = replace({
'__JS_SDK_VERSION__': JSON.stringify(pkg.version),
'__SUPPLY_ID__': JSON.stringify(replaceParams.supplyId || '7102'),
'__APP_KEY__': JSON.stringify(replaceParams.appKey)
});
return {
input: config.input,
output: config.output,
plugins: [
...config.plugins,
replacePlugin
]
};
};
module.exports = async function (params) {
const config = getRollupConfig({
supplyId: params.supplyId || '7102',
appKey: params.appKey
});
const {
input,
plugins
} = config;
const bundle = await rollup.rollup({
input,
plugins
});
const compiler = {
async write(file) {
await bundle.write({
file,
format: 'iife',
sourcemap: false,
strict: false
});
}
};
return compiler;
};
复制代码
在自动化部署平台中,使用shelljs
安装JSSDK包:
import {route, POST} from 'awilix-express';
import {Api} from '../framework/Api';
import * as shell from 'shell';
import * as path from 'path';
@route('/supply')
export default class SupplyAPI extends Api {
// some code
@route('/installSdkVersion')
@POST()
async installSdkVersion(req, res) {
const {version} = req.body;
const pkg = `@baidu/xxx-js-sdk@${version}`;
const registry = 'http://registry.npm.baidu-int.com';
shell.exec(`npm i ${pkg} --registry=${registry}`, (code, stdout, stderr) => {
if (code !== 0) {
console.error(stderr);
res.failPrint('npm install fail');
return;
}
// sdk包备份路径
const sdkBackupPath = this.sdkBackupPath;
const sdkPath = path.resolve(sdkBackupPath, version);
shell.mkdir('-p', sdkPath).then((code, stdout, stderr) => {
if (code !== 0) {
console.error(stderr);
res.failPrint(`mkdir \`${sdkPath}\` error.`);
return;
}
const modulePath = path.resolve(process.cwd(), 'node_modules', '@baidu', 'xxx-js-sdk');
// 拷贝安装后的文件,方便后续使用
shell.cp('-rf', modulePath + '/.', sdkPath).then((code, stdout, stderr) => {
if (code !== 0) {
console.error(stderr);
res.failPrint(`backup sdk error.`);
return;
}
res.successPrint(`${pkg} install success.`);
});
})
});
}
}
复制代码
Release包就是咱们在上传到CDN以前须要准备的压缩包。所以,打包JSSDK以后,咱们须要生成的文件有,接入文档、JSSDK DEMO预览页面、JSSDK编译结果、描述文件。
首先,打包函数以下:
import {Service} from '../framework';
import * as fs from 'fs';
import path from 'path';
import _ from 'lodash';
export default class SupplyService extends Service {
async generateFile(supplyId, sdkVersion) {
// 数据库查询对应的业务方的CDN文件名
const [sdkInfoErr, sdkInfo] = await this.supplyDao.getSupplyInfo(supplyId);
if (sdkInfoErr) {
return this.fail('服务器错误', null, sdkInfoErr);
}
const {appKey, cdnFilename, name} = sdkInfo;
// 须要替换的数据
const data = {
name,
supplyId,
appKey,
'sdk_url': `https://***.com/sdk/${cdnFilename}`
};
try {
// 编译JSSDK
const sdkResult = await this.buildSdk(supplyId, appKey, sdkVersion);
// 生成接入文档
const docResult = await this.generateDocs(data);
// 生成预览DEMO html文件
const demoHtmlResult = await this.generateDemoHtml(data, 'sdk-demo.html', `JSSDK-接入页面-${data.name}.html`);
// 生成release包描述文件
const sdkInfoFileResult = await this.writeSdkVersionFile(supplyId, appKey, sdkVersion);
const success = docResult && demoHtmlResult && sdkInfoFileResult && sdkResult;
if (success) {
// release目标目录
const dir = path.join(this.releasePath, supplyId + '');
const fileName = `${supplyId}-${sdkVersion}.zip`;
const zipFileName = path.join(dir, fileName);
// 压缩全部结果文件
const zipResult = await this.zipDirFile(dir, zipFileName);
if (!zipResult) {
return this.fail('打包失败');
}
// 返回压缩包提供下载
return this.success('打包成功', {
url: `/${supplyId}/${fileName}`
});
} else {
return this.fail('打包失败');
}
} catch (e) {
return this.fail('打包失败', null, e);
}
}
}
复制代码
JSSDK的编译很简单,只须要加载对应版本的JSSDK的编译函数,而后将对应的参数传入编译函数获得一个Rollup Compiler,而后将 Compiler 结果写入Release路径便可。
export default class SupplyService extends Service {
async buildSdk(supplyId, appKey, sdkVersion) {
try {
const sdkBackupPath = this.sdkBackupPath;
// 加载对应版本的备份的JSSDK包的Rollup编译函数
const compileSdk = require(path.resolve(sdkBackupPath, sdkVersion, 'lib', 'build.js'));
const bundle = await compileSdk({
supplyId,
appKey: Number(sdkInfo.appKey)
});
const releasePath = path.resolve(this.releasePath, supplyId, `${supplyId}-sdk.js`);
// Rollup Compiler 编译结果至release目录
await bundle.write(releasePath);
return true;
} catch (e) {
console.error(e);
return false;
}
}
}
复制代码
原理很简单,使用JSZip
,打开接入文档模板,而后使用Docxtemplater
替换模板里的特殊字符,而后从新生成DOC文件:
import Docxtemplater from 'docxtemplater';
import JSZip from 'JSZip';
export default class SupplyService extends Service {
async generateDocs(data) {
return new Promise(async (resolve, reject) => {
if (data) {
// 读取接入文档,替换appKey,cdn路径
const supplyId = data.supplyId;
const docsFileName = 'sdk-doc.docx';
const supplyFilesPath = path.resolve(process.cwd(), 'src/server/files');
const content = fs.readFileSync(path.resolve(supplyFilesPath, docsFileName), 'binary');
const zip = new JSZip(content);
const doc = new Docxtemplater();
// 替换`[[`前缀和`]]`后缀的内容
doc.loadZip(zip).setOptions({delimiters: {start: '[[', end: ']]'}});
doc.setData(data);
try {
doc.render();
} catch (error) {
console.error(error);
reject(error);
}
// 生成DOC的buffer
const buf = doc.getZip().generate({type: 'nodebuffer'});
const releasePath = path.resolve(this.releasePath, supplyId);
// 建立目标目录
shell.mkdir(releasePath).then((code, stdout, stderr) => {
if (code !== 0 ) {
resolve(false);
return;
}
// 将替换后的结果写入release路径
fs.writeFileSync(path.resolve(releasePath, `JSSDK-文档-${data.name}.docx`), buf);
resolve(true);
}).catch(e => {
console.error(e);
resolve(false);
});
}
});
}
}
复制代码
与接入文档生成原理相似,打开一个DEMO模板HTML文件,替换内部字符,从新生成文件:
export default class SupplyService extends Service {
generateDemoHtml(data, file, toFile) {
return new Promise((resolve, reject) => {
const supplyId = data.supplyId;
// 须要替换的数据
const replaceData = data;
// 打开文件
const content = fs.readFileSync(path.resolve(supplyFilesPath, file), 'utf-8');
// 字符串替换`{{`前缀和`}}`后缀的内容
const replaceContent = content.replace(/{{(.*)}}/g, (match, key) => {
return replaceData[key] || match;
});
const releasePath = path.resolve(this.releasePath, supplyId);
// 写入文件
fs.writeFile(path.resolve(releasePath, toFile), replaceContent, err => {
if (err) {
console.error(err);
resolve(false);
} else {
resolve(true);
}
});
});
}
}
复制代码
将当前打包的一些参数存在一个文件中的,一并打包到Release包中,做用很简单,用来描述当前打包的一些参数,方便上线CDN的时候记录当前上线的是哪一个SDK版本等
export default class SupplyService extends Service {
async writeSdkVersionFile(supplyId, appKey, sdkVersion) {
return new Promise(resolve => {
const writePath = path.resolve(this.releasePath, supplyId, 'version.json');
// Release描述数据
const data = {version: sdkVersion, appKey, supplyId};
try {
// 写入release目录
fs.writeFileSync(writePath, JSON.stringify(data));
resolve(true);
} catch (e) {
console.error(e);
resolve(false);
}
});
}
}
复制代码
将以前生成的JSSDK编译结果、接入文档、预览DEMO页面文件,描述文件使用archive
打包起来:
export default class SupplyService extends Service {
zipDirFile(dir, to) {
return new Promise(async (resolve, reject) => {
const output = fs.createWriteStream(to);
const archive = archiver('zip');
archive.on('error', err => reject(err));
archive.pipe(output);
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.resolve(dir, file);
const info = fs.statSync(filePath);
if (!info.isDirectory()) {
archive.append(fs.createReadStream(filePath), {
'name': file
});
}
});
archive.finalize();
resolve(true);
});
}
}
复制代码
大部分上传到CDN都为像CDN源站push文件,而正好咱们运维在个人自动化部署平台的机器上挂载了NFS,即我只须要本地将JSSDK文件拷贝到共享目录,就实现了CDN文件上传。
export default class SupplyService extends Service {
async cp2CDN(supplyId, fileName) {
// 读取描述文件
const sdkInfoPath = path.resolve(this.releasePath, '' + supplyId, 'version.json');
if (!fs.existsSync(sdkInfoPath)) {
return this.fail('Release描述文件丢失,请从新打包');
}
const sdkInfo = JSON.parse(fs.readFileSync(sdkInfoPath, 'utf-8'));
sdkInfo.cdnFilename = fileName;
// 将文件拷贝至文件共享目录
const result = await this.cpFile(supplyId, fileName, false);
// 上传成功
if (result) {
// 将Release包描述文件的数据同步到MYSQL
const [sdkInfoErr] = await this.supplyDao.update(sdkInfo, {where: {supplyId}});
if (sdkInfoErr) {
return this.fail('JSSDK信息记录失败,请重试', null, jssdkInfoResult);
}
return this.success('上传成功', {url})
}
return this.fail('上传失败');
}
}
复制代码
项目效益仍是很明显,从本质上解决了咱们须要解决的问题:
节省了人工上传粘贴代码的时间,大大地提升了工做效率。
这个项目仍是19年前半年我的花业余时间完成的工具项目,后来获得了Leader的重视,将工具正式升级为平台,集成了不少业务相关的配置在平台,我19年的前半年KPI就这么来的,哈~~~
或者这一套思路对每一个业务都比较适用
其实每一个项目中的痛点都通常都是XX的性能低下、XX很是低效,仍是比较容易发现的,这个时候只须要主动的寻找方案并推动实现就OK了。
前端技术离不开业务,技术永远服务于业务,离开了业务的技术,那是彻底没有落脚点的技术,彻底没有意义的技术。因此,除了写写页面,利用前端页面实现工具化、自动化,从而推动到平台化也是一个不错的落脚点选择。