前端賦能業務 - Node實現自動化部署平臺

前言

是否有不少人跟我同樣有這樣的一個煩惱,天天有寫不完的需求、改不完的BUG,天天擼着重複、繁瑣的業務代碼,擔憂着本身的技術成長。html

其實換個角度,咱們所學的全部前端技術都是服務於業務的,那咱們爲何不想辦法使用前端技術爲業務作點東西?這樣既能解決業務的困擾,也能讓本身擺脫天天只能寫重複繁瑣代碼的困擾。前端

本文主要爲筆者針對當前團隊內的一些業務問題,實現的一個自動化部署平臺的技術方案。vue

原文地址node

背景

去年年初,因爲團隊裏沒有前端,恰好我是被招過來的第一個,也是惟一一個FE,因而我接手了一個一直由後端維護的JSSDK項目,其實也說不上項目,接手的時候它只是一個2000多行代碼的胖腳本,沒有任何工程化痕跡。git

業務需求

這個JSSDK,主要做用是在後端了爲業務方分配appKey以後,前端將appKey寫死在JSSDK中,上傳到CDN後,爲業務方提供數據採集服務的腳本。github

有的同窗可能有疑問,爲何不像一些正常的SDK同樣,appKey是以參數的形式傳入到JSSDK中,這樣就能夠統一全部業務方使用同一個JSSDK,而不須要爲每一個業務業務方都提供一個JSSDK。其實我剛開始也是這麼想的,因而我向個人leader提出了個人這個想法,被拒絕了,拒絕緣由以下:sql

  • appKey若是以參數形式傳入,對業務方的接入成本有所增長,會出現appKey填錯的問題。
  • 業務方接入JSSDK以後,但願每次JSSDK版本迭代對業務方來講是無感知的(也就是版本迭代是覆蓋式發佈),若是全部業務方使用同一個JSSDK,每次JSSDK的版本迭代,一次發版會一次性對全部業務方都有影響,會增長風險。

因爲個人leader如今主要是負責產品推廣,常常和業務方打交道,可能他更能站在業務方的角度來考慮問題。因此,個人leader選擇犧牲項目的維護成原本下降SDK的接入成本和規避風險,能夠理解。vuex

那既然咱們改變不了現狀,那就只能適應現狀。shell

項目痛點

那麼針對原來沒有任何工程化狀況的胖腳本,每次新增一個業務方,我須要作的事情以下:數據庫

  1. 打開一個胖腳本和JSSDK接入文檔,拷貝一份新的。
  2. 找後端要分配好的appKey,找對對應的appKey那一行代碼手動修改。
  3. 手動混淆修改無缺的腳本並上傳到CDN。
  4. 修改JSSDK接入文檔中CDN的地址,保存後發送給業務方。

整個過程都須要手動進行,相對來講很是繁瑣,而且一不當心就會填錯,每次都須要對腳本和接入文檔進行檢查。

針對以上狀況,獲得咱們須要解決的問題:

  • 怎樣針對一個新的業務方快速輸出一份新的JSSDK和接入文檔?
  • 怎樣快速對新的JSSDK進行混淆並上傳到CDN。

自動化方案

介紹方案以前,先上一張平臺截圖,以便先有一個直觀的認識:

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包

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就這麼來的,哈~~~

總結

或者這一套思路對每一個業務都比較適用

  1. 瞭解業務的背景
  2. 發現業務的痛點
  3. 尋找解決方案並主動推動實現
  4. 解決問題

其實每一個項目中的痛點都通常都是XX的性能低下、XX很是低效,仍是比較容易發現的,這個時候只須要主動的尋找方案並推動實現就OK了。

前端技術離不開業務,技術永遠服務於業務,離開了業務的技術,那是徹底沒有落腳點的技術,徹底沒有意義的技術。因此,除了寫寫頁面,利用前端頁面實現工具化、自動化,從而推動到平臺化也是一個不錯的落腳點選擇。

相關文章
相關標籤/搜索