前端賦能業(yè)務(wù) - Node實現(xiàn)自動化部署平臺
作者:yacan8
https://github.com/yacan8/blog/issues/27
前言
是否有很多人跟我一樣有這樣的一個煩惱,每天有寫不完的需求、改不完的BUG,每天擼著重復(fù)、繁瑣的業(yè)務(wù)代碼,擔(dān)心著自己的技術(shù)成長。
其實換個角度,我們所學(xué)的所有前端技術(shù)都是服務(wù)于業(yè)務(wù)的,那我們?yōu)槭裁床幌朕k法使用前端技術(shù)為業(yè)務(wù)做點東西?這樣既能解決業(yè)務(wù)的困擾,也能讓自己擺脫每天只能寫重復(fù)繁瑣代碼的困擾。
本文主要為筆者針對當(dāng)前團隊內(nèi)的一些業(yè)務(wù)問題,實現(xiàn)的一個自動化部署平臺的技術(shù)方案。
背景
去年年初,由于團隊里沒有前端,剛好我是被招過來的第一個,也是唯一一個FE,于是我接手了一個一直由后端維護的JSSDK項目,其實也說不上項目,接手的時候它只是一個2000多行代碼的胖腳本,沒有任何工程化痕跡。
業(yè)務(wù)需求
這個JSSDK,主要作用是在后端了為業(yè)務(wù)方分配appKey之后,前端將appKey寫死在JSSDK中,上傳到CDN后,為業(yè)務(wù)方提供數(shù)據(jù)采集服務(wù)的腳本。
有的同學(xué)可能有疑問,為什么不像一些正常的SDK一樣,appKey是以參數(shù)的形式傳入到JSSDK中,這樣就可以統(tǒng)一所有業(yè)務(wù)方使用同一個JSSDK,而不需要為每個業(yè)務(wù)業(yè)務(wù)方都提供一個JSSDK。其實我剛開始也是這么想的,于是我向我的leader提出了我的這個想法,被拒絕了,拒絕原因如下:
appKey如果以參數(shù)形式傳入,對業(yè)務(wù)方的接入成本有所增加,會出現(xiàn)appKey填錯的問題。 業(yè)務(wù)方接入JSSDK之后,希望每次JSSDK版本迭代對業(yè)務(wù)方來說是無感知的(也就是版本迭代是覆蓋式發(fā)布),如果所有業(yè)務(wù)方使用同一個JSSDK,每次JSSDK的版本迭代,一次發(fā)版會一次性對所有業(yè)務(wù)方都有影響,會增加風(fēng)險。
由于我的leader現(xiàn)在主要是負責(zé)產(chǎn)品推廣,經(jīng)常和業(yè)務(wù)方打交道,可能他更能站在業(yè)務(wù)方的角度來考慮問題。所以,我的leader選擇犧牲項目的維護成本來降低SDK的接入成本和規(guī)避風(fēng)險,可以理解。
那既然我們改變不了現(xiàn)狀,那就只能適應(yīng)現(xiàn)狀。
項目痛點
那么針對原來沒有任何工程化情況的胖腳本,每次新增一個業(yè)務(wù)方,我需要做的事情如下:
打開一個胖腳本和JSSDK接入文檔,拷貝一份新的。 找后端要分配好的appKey,找對對應(yīng)的appKey那一行代碼手動修改。 手動混淆修改完好的腳本并上傳到CDN。 修改JSSDK接入文檔中CDN的地址,保存后發(fā)送給業(yè)務(wù)方。
整個過程都需要手動進行,相對來說非常繁瑣,并且一不小心就會填錯,每次都需要對腳本和接入文檔進行檢查。
針對以上情況,得到我們需要解決的問題:
怎樣針對一個新的業(yè)務(wù)方快速輸出一份新的JSSDK和接入文檔? 怎樣快速對新的JSSDK進行混淆并上傳到CDN。
自動化方案
介紹方案之前,先上一張平臺截圖,以便先有一個直觀的認識:

SDK自動化部署平臺主要實現(xiàn)了JSSDK的編譯,發(fā)布測試(在線預(yù)覽),上傳CDN功能。
服務(wù)端技術(shù)棧包括:
框架 Express 熱更新 nodemon 依賴注入 awilix 數(shù)據(jù)持久化 sequelize 部署 pm2
客戶端技術(shù)棧就不介紹了,Vue全家桶 + vue-property-decorator + vuex-class。
項目搭建參考:
Vue+Express+Mysql 全棧初體驗
https://juejin.im/post/5ce96694f265da1bc5523f69
自動化部署平臺主要依賴于 GIT + 本地環(huán)境 + 私有NPM源 + MYSQL,各環(huán)節(jié)之間進行通信交互,完成自動化部署。

主要達到的效果:本地環(huán)境拉取git倉庫代碼后,進行需求開發(fā),完成后發(fā)布一個帶Rollup的SDK編譯器包到私有NPM倉庫,自動化部署平臺在工程目錄安裝指定版本的SDK,并且備份到本地,在SDK編譯時,選擇特定版本的Rollup的SDK編譯器,并傳參(如appKey,appId等)到編譯器中進行編譯,同時自動生成JSSDK接入文檔等后打包成帶描述文件的Release包,在上傳到CDN時,將描述文件的對應(yīng)的信息寫入MYSQL中進行保存。
版本管理
由于JSSDK原本只是一個腳本,我們必須實現(xiàn)項目的工程化,從而完成版本管理,方便快速版本切換進行發(fā)布,回滾,進而快速止損。
首先,我們需要將項目工程化,使用Rollup進行模塊管理,并且在發(fā)包NPM包的時候,輸入為各種參數(shù)(如appKey)輸出為一個Rollup Complier的函數(shù),然后使用rollup-plugin-replace在編譯時候替換代碼中具體的參數(shù)。
lib/build.js,JSSDK中發(fā)包的入口文件,提供給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;
????//?注入系統(tǒng)變量
????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');
????????????????//?拷貝安裝后的文件,方便后續(xù)使用
????????????????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包
Release包就是我們在上傳到CDN之前需要準備的壓縮包。因此,打包JSSDK之后,我們需要生成的文件有,接入文檔、JSSDK DEMO預(yù)覽頁面、JSSDK編譯結(jié)果、描述文件。
首先,打包函數(shù)如下:
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)?{
????????//?數(shù)據(jù)庫查詢對應(yīng)的業(yè)務(wù)方的CDN文件名
????????const?[sdkInfoErr,?sdkInfo]?=?await?this.supplyDao.getSupplyInfo(supplyId);
????????if?(sdkInfoErr)?{
????????????return?this.fail('服務(wù)器錯誤',?null,?sdkInfoErr);
????????}
????????const?{appKey,?cdnFilename,?name}?=?sdkInfo;
????????//?需要替換的數(shù)據(jù)
????????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);
????????????//?生成預(yù)覽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);
????????????????//?壓縮所有結(jié)果文件
????????????????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的編譯很簡單,只需要加載對應(yīng)版本的JSSDK的編譯函數(shù),然后將對應(yīng)的參數(shù)傳入編譯函數(shù)得到一個Rollup Compiler,然后將 Compiler 結(jié)果寫入Release路徑即可。
export?default?class?SupplyService?extends?Service?{
????async?buildSdk(supplyId,?appKey,?sdkVersion)?{
????????try?{
????????????const?sdkBackupPath?=?this.sdkBackupPath;
????????????//?加載對應(yīng)版本的備份的JSSDK包的Rollup編譯函數(shù)
????????????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?編譯結(jié)果至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();
????????????????//?替換`[[`前綴和`]]`后綴的內(nèi)容
????????????????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);
????????????????//?創(chuàng)建目標目錄
????????????????shell.mkdir(releasePath).then((code,?stdout,?stderr)?=>?{
????????????????????if?(code?!==?0?)?{
????????????????????????resolve(false);
????????????????????????return;
????????????????????}
????????????????????//?將替換后的結(jié)果寫入release路徑
????????????????????fs.writeFileSync(path.resolve(releasePath,?`JSSDK-文檔-${data.name}.docx`),?buf);
????????????????????resolve(true);
????????????????}).catch(e?=>?{
????????????????????console.error(e);
????????????????????resolve(false);
????????????????});
????????????}
????????});
????}
}
生成預(yù)覽DEMO頁面
與接入文檔生成原理類似,打開一個DEMO模板HTML文件,替換內(nèi)部字符,重新生成文件:
export?default?class?SupplyService?extends?Service?{
????generateDemoHtml(data,?file,?toFile)?{
????????return?new?Promise((resolve,?reject)?=>?{
????????????const?supplyId?=?data.supplyId;
????????????//?需要替換的數(shù)據(jù)
????????????const?replaceData?=?data;
????????????//?打開文件
????????????const?content?=?fs.readFileSync(path.resolve(supplyFilesPath,?file),?'utf-8');
????????????//?字符串替換`{{`前綴和`}}`后綴的內(nèi)容
????????????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包描述文件
將當(dāng)前打包的一些參數(shù)存在一個文件中的,一并打包到Release包中,作用很簡單,用來描述當(dāng)前打包的一些參數(shù),方便上線CDN的時候記錄當(dāng)前上線的是哪個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描述數(shù)據(jù)
????????????const?data?=?{version:?sdkVersion,?appKey,?supplyId};
????????????try?{
????????????????//?寫入release目錄
????????????????fs.writeFileSync(writePath,?JSON.stringify(data));
????????????????resolve(true);
????????????}?catch?(e)?{
????????????????console.error(e);
????????????????resolve(false);
????????????}
????????});
????}
}
打包所有文件結(jié)果
將之前生成的JSSDK編譯結(jié)果、接入文檔、預(yù)覽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都為像CDN源站push文件,而正好我們運維在我的自動化部署平臺的機器上掛載了NFS,即我只需要本地將JSSDK文件拷貝到共享目錄,就實現(xiàn)了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包描述文件的數(shù)據(jù)同步到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('上傳失敗');
????}
}
項目成效
項目效益還是很明顯,從本質(zhì)上解決了我們需要解決的問題:
完成了項目的工程化,自動化生成JSSDK和接入文檔。 編譯過程中自動化進行混淆,并實現(xiàn)了一鍵上傳至CDN。
節(jié)省了人工上傳粘貼代碼的時間,大大地提高了工作效率。
這個項目還是19年前半年個人花業(yè)余時間完成的工具項目,后來得到了Leader的重視,將工具正式升級為平臺,集成了很多業(yè)務(wù)相關(guān)的配置在平臺,我19年的前半年KPI就這么來的,哈~~~
總結(jié)
或者這一套思路對每個業(yè)務(wù)都比較適用
了解業(yè)務(wù)的背景 發(fā)現(xiàn)業(yè)務(wù)的痛點 尋找解決方案并主動推進實現(xiàn) 解決問題
其實每個項目中的痛點都一般都是XX的性能低下、XX非常低效,還是比較容易發(fā)現(xiàn)的,這個時候只需要主動的尋找方案并推進實現(xiàn)就OK了。
前端技術(shù)離不開業(yè)務(wù),技術(shù)永遠服務(wù)于業(yè)務(wù),離開了業(yè)務(wù)的技術(shù),那是完全沒有落腳點的技術(shù),完全沒有意義的技術(shù)。所以,除了寫寫頁面,利用前端頁面實現(xiàn)工具化、自動化,從而推進到平臺化也是一個不錯的落腳點選擇。
??愛心三連擊
1.看到這里了就點個在看支持下吧,你的「點贊,在看」是我創(chuàng)作的動力。
2.關(guān)注公眾號
程序員成長指北,回復(fù)「1」加入Node進階交流群!「在這里有好多 Node 開發(fā)者,會討論 Node 知識,互相學(xué)習(xí)」!3.也可添加微信【ikoala520】,一起成長。
“在看轉(zhuǎn)發(fā)”是最大的支持
