前端賦能業務:Node實現自動化部署平臺
前言
是否有很多人跟我一樣有這樣的一個煩惱,每天有寫不完的需求、改不完的BUG,每天擼著重復、繁瑣的業務代碼,擔心著自己的技術成長。
其實換個角度,我們所學的所有前端技術都是服務于業務的,那我們為什么不想辦法使用前端技術為業務做點東西?這樣既能解決業務的困擾,也能讓自己擺脫每天只能寫重復繁瑣代碼的困擾。
本文主要為筆者針對當前團隊內的一些業務問題,實現的一個自動化部署平臺的技術方案。
背景
去年年初,由于團隊里沒有前端,剛好我是被招過來的第一個,也是唯一一個FE,于是我接手了一個一直由后端維護的JSSDK項目,其實也說不上項目,接手的時候它只是一個2000多行代碼的胖腳本,沒有任何工程化痕跡。
業務需求
這個JSSDK,主要作用是在后端了為業務方分配appKey之后,前端將appKey寫死在JSSDK中,上傳到CDN后,為業務方提供數據采集服務的腳本。
有的同學可能有疑問,為什么不像一些正常的SDK一樣,appKey是以參數的形式傳入到JSSDK中,這樣就可以統一所有業務方使用同一個JSSDK,而不需要為每個業務業務方都提供一個JSSDK。其實我剛開始也是這么想的,于是我向我的leader提出了我的這個想法,被拒絕了,拒絕原因如下:
- appKey如果以參數形式傳入,對業務方的接入成本有所增加,會出現appKey填錯的問題。
- 業務方接入JSSDK之后,希望每次JSSDK版本迭代對業務方來說是無感知的(也就是版本迭代是覆蓋式發布),如果所有業務方使用同一個JSSDK,每次JSSDK的版本迭代,一次發版會一次性對所有業務方都有影響,會增加風險。
由于我的leader現在主要是負責產品推廣,經常和業務方打交道,可能他更能站在業務方的角度來考慮問題。所以,我的leader選擇犧牲項目的維護成本來降低SDK的接入成本和規避風險,可以理解。
那既然我們改變不了現狀,那就只能適應現狀。
項目痛點
那么針對原來沒有任何工程化情況的胖腳本,每次新增一個業務方,我需要做的事情如下:
- 打開一個胖腳本和JSSDK接入文檔,拷貝一份新的。
- 找后端要分配好的appKey,找對對應的appKey那一行代碼手動修改。
- 手動混淆修改完好的腳本并上傳到CDN。
- 修改JSSDK接入文檔中CDN的地址,保存后發送給業務方。
整個過程都需要手動進行,相對來說非常繁瑣,并且一不小心就會填錯,每次都需要對腳本和接入文檔進行檢查。
針對以上情況,得到我們需要解決的問題:
- 怎樣針對一個新的業務方快速輸出一份新的JSSDK和接入文檔?
- 怎樣快速對新的JSSDK進行混淆并上傳到CDN。
自動化方案
介紹方案之前,先上一張平臺截圖,以便先有一個直觀的認識:
SDK自動化部署平臺主要實現了JSSDK的編譯,發布測試(在線預覽),上傳CDN功能。
服務端技術棧包括:
- 框架 Express
- 熱更新 nodemon
- 依賴注入 awilix
- 數據持久化 sequelize
- 部署 pm2
客戶端技術棧就不介紹了,Vue全家桶 + vue-property-decorator + vuex-class。
項目搭建參考:
Vue+Express+Mysql 全棧初體驗
https://juejin.im/post/5ce96694f265da1bc5523f69
自動化部署平臺主要依賴于 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 replacereplacePlugin = 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包
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的編譯很簡單,只需要加載對應版本的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頁面
與接入文檔生成原理類似,打開一個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包描述文件
將當前打包的一些參數存在一個文件中的,一并打包到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都為像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('上傳失敗');
- }
- }
項目成效
項目效益還是很明顯,從本質上解決了我們需要解決的問題:
- 完成了項目的工程化,自動化生成JSSDK和接入文檔。
- 編譯過程中自動化進行混淆,并實現了一鍵上傳至CDN。
節省了人工上傳粘貼代碼的時間,大大地提高了工作效率。
這個項目還是19年前半年個人花業余時間完成的工具項目,后來得到了Leader的重視,將工具正式升級為平臺,集成了很多業務相關的配置在平臺,我19年的前半年KPI就這么來的,哈~~~
總結
或者這一套思路對每個業務都比較適用
- 了解業務的背景
- 發現業務的痛點
- 尋找解決方案并主動推進實現
- 解決問題
其實每個項目中的痛點都一般都是XX的性能低下、XX非常低效,還是比較容易發現的,這個時候只需要主動的尋找方案并推進實現就OK了。
前端技術離不開業務,技術永遠服務于業務,離開了業務的技術,那是完全沒有落腳點的技術,完全沒有意義的技術。所以,除了寫寫頁面,利用前端頁面實現工具化、自動化,從而推進到平臺化也是一個不錯的落腳點選擇。