拋棄jenkins,如何用node從零搭建自動(dòng)化部署管理平臺(tái)

一、背景

H5頁(yè)面由于其具有發(fā)布靈活、跨平臺(tái)、易于傳播等突出特點(diǎn),所以H5頁(yè)面是引流拉新、宣傳推廣的重要渠道和方式,備受各公司的青睞。

小編的日常工作就是做各種面向用戶的H5促銷活動(dòng)的開發(fā),在整個(gè)開發(fā)周期中,接合我司的一些情況,我總結(jié)了H5活動(dòng)頁(yè)面的以下特點(diǎn):

  1. 面向用戶,流量大;
  2. 各端展示方案不同,需要兼容各端(比如活動(dòng)規(guī)則、展示模塊,ios和android不一樣);
  3. 需求變更頻繁;
  4. 合作方較多(需要跟各個(gè)業(yè)務(wù)線合作聯(lián)調(diào));
  5. 排期緊張;

所以開發(fā)測(cè)試期間,部署效率就顯得特別重要了。

由于我司的CDN發(fā)布平臺(tái),需要手動(dòng)創(chuàng)建模板、粘貼代碼,部署效率比較低下;并且活動(dòng)頁(yè)面代碼分散,無(wú)法統(tǒng)一管理和實(shí)現(xiàn)工程化,所以決定實(shí)現(xiàn)一套自動(dòng)化部署系統(tǒng),目前已經(jīng)投入使用半年時(shí)間了,極大地提高了我們的工作效率。我稱這個(gè)自動(dòng)化部署系統(tǒng)為【H5 活動(dòng)管理平臺(tái)】。

二、H5 活動(dòng)管理平臺(tái)自動(dòng)化部署實(shí)現(xiàn)方案

介紹該平臺(tái)實(shí)現(xiàn)方案之前,先放張效果圖,好有一個(gè)直觀的認(rèn)識(shí)。

H5活動(dòng)管理平臺(tái)界面

該平臺(tái)實(shí)現(xiàn)主要依賴于本地開發(fā)工程、gitlab,三者之間通過(guò)通信交互,實(shí)現(xiàn)的自動(dòng)化部署。

image

最終達(dá)到的效果就是:當(dāng)本地開發(fā)分支merge到測(cè)試分支devTest或者master分支時(shí),該平臺(tái)會(huì)自動(dòng)拉取最新代碼,構(gòu)建目標(biāo)文件,然后將目標(biāo)文件部署到對(duì)應(yīng)的服務(wù)器目錄,另外提供了上下線、版本回滾、定時(shí)上下線等常用功能。

整體架構(gòu)流程圖:

H5活動(dòng)管理平臺(tái)架構(gòu)流程圖

下面對(duì)一些關(guān)鍵技術(shù)點(diǎn)進(jìn)行詳細(xì)介紹

1. 本地開發(fā)工程

我們的本地開發(fā)工程,是使用node + webpack + babel等相關(guān)技術(shù)搭建的多頁(yè)面開發(fā)工程,不同的活動(dòng)位于不同的目錄。因?yàn)橐鲎詣?dòng)化構(gòu)建部署處理,跟【H5活動(dòng)管理平臺(tái)】交互,所以有以下要點(diǎn)需要注意(可根據(jù)自己項(xiàng)目情況,自由調(diào)整方案)。

  1. 本地開發(fā)工程作為自動(dòng)化構(gòu)建部署的源頭,需要提供構(gòu)建命令行用于構(gòu)建測(cè)試文件和線上文件,便于后面shell命令調(diào)用。如在package.json中加入如下命令:
"scripts": {
    "local": "cross-env NODE_ENV=local node build.js", // 本地開發(fā)命令
    "build": "cross-env NODE_ENV=product node build.js", // 構(gòu)建上線文件
    "test": "cross-env NODE_ENV=test node build.js" // 構(gòu)建測(cè)試文件
}
  1. 提供構(gòu)建配置文件dev-config.js,用于過(guò)濾webpack構(gòu)建時(shí)的入口目錄,只構(gòu)建編譯當(dāng)前正在開發(fā)的活動(dòng)頁(yè)面,提高構(gòu)建速度。
//dev-config.js
module.exports = {
    devPages: ['test']   //  當(dāng)前自己正在開發(fā)頁(yè)面目錄,不寫時(shí)會(huì)編譯所有活動(dòng)頁(yè)面
}
  1. 提供活動(dòng)頁(yè)面目錄信息配置config.json,該配置信息用于【H5活動(dòng)管理平臺(tái)】的展示,也就是效果圖中的信息源。
// config.json
{
  "pages": [
        {
            "folder": "lion",
            "desc": "前端名獅",
            "author": "訣九",
            "user": "juejiu"
        },
        {
            "folder": "test",
            "desc": "活動(dòng)測(cè)試頁(yè)面",
            "author": "訣九",
            "user": "juejiu"
        }
    ]
}
    
  1. 構(gòu)建生成的 JSHTML 文件,存放在 dist 目錄下的對(duì)應(yīng)活動(dòng)目錄中。構(gòu)建生成的目錄結(jié)構(gòu)如下:
|--dist
   |-- lion
       |-- lion_app.js
       |-- index.html
   |--test
       |-- test_app.js
       |-- index.html

  1. 提測(cè)時(shí),將開發(fā)分支merge到devTest分支,上線時(shí),將開發(fā)分支merge到master分支。
工程目錄結(jié)構(gòu)

2. gitlab服務(wù)器

Gitlab作為企業(yè)代碼版本管理工具,提供了Webhook的功能配置,Webhook顧名思義,其實(shí)就是一鉤子。當(dāng)我們?cè)?code>Gitlab上做出某些特定操作時(shí),可以觸發(fā)鉤子,去進(jìn)行一些我們事先設(shè)定好的腳本,以達(dá)到某些特定功能(例如--前端項(xiàng)目自動(dòng)發(fā)布)。

實(shí)際上可以把它理解為回調(diào),或者委托,或者事件通知,歸根揭底它就是一個(gè)消息通知機(jī)制。當(dāng)gitlab觸發(fā)某個(gè)事件時(shí),它會(huì)向你的所配置的http服務(wù)發(fā)送Post請(qǐng)求

注意:

  1. URL處填寫的是【H5活動(dòng)管理平臺(tái)】部署的服務(wù)器IP;
  2. IP后面跟的merge是該平臺(tái)提供的一個(gè)接口,用于觸發(fā)鉤子后,gitlab服務(wù)器向這個(gè)接口發(fā)送Post請(qǐng)求;
  3. Secret Token處填寫的是一個(gè)token,主要用于merge接口請(qǐng)求做安全校驗(yàn),可以隨便設(shè)置。

具體配置如下圖:

webhooks 配置

我們項(xiàng)目是設(shè)置的merge鉤子,下面只貼一下Merge request events請(qǐng)求傳遞的數(shù)據(jù)信息:

Request header:

X-Gitlab-Event: Merge Request Hook

Request body:

{
  "object_kind": "merge_request",
  "user": {
    "name": "Administrator",
    "username": "root",
    "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
  },
  "object_attributes": {
    "id": 99,
    "target_branch": "master",
    "source_branch": "ms-viewport",
    "source_project_id": 14,
    "author_id": 51,
    "assignee_id": 6,
    "title": "MS-Viewport",
    "created_at": "2013-12-03T17:23:34Z",
    "updated_at": "2013-12-03T17:23:34Z",
    "st_commits": null,
    "st_diffs": null,
    "milestone_id": null,
    "state": "opened",
    "merge_status": "unchecked",
    "target_project_id": 14,
    "iid": 1,
    "description": "",
    "source":{
      "name":"Awesome Project",
      "description":"Aut reprehenderit ut est.",
      "web_url":"http://example.com/awesome_space/awesome_project",
      "avatar_url":null,
      "git_ssh_url":"git@example.com:awesome_space/awesome_project.git",
      "git_http_url":"http://example.com/awesome_space/awesome_project.git",
      "namespace":"Awesome Space",
      "visibility_level":20,
      "path_with_namespace":"awesome_space/awesome_project",
      "default_branch":"master",
      "homepage":"http://example.com/awesome_space/awesome_project",
      "url":"http://example.com/awesome_space/awesome_project.git",
      "ssh_url":"git@example.com:awesome_space/awesome_project.git",
      "http_url":"http://example.com/awesome_space/awesome_project.git"
    },
    "target": {
      "name":"Awesome Project",
      "description":"Aut reprehenderit ut est.",
      "web_url":"http://example.com/awesome_space/awesome_project",
      "avatar_url":null,
      "git_ssh_url":"git@example.com:awesome_space/awesome_project.git",
      "git_http_url":"http://example.com/awesome_space/awesome_project.git",
      "namespace":"Awesome Space",
      "visibility_level":20,
      "path_with_namespace":"awesome_space/awesome_project",
      "default_branch":"master",
      "homepage":"http://example.com/awesome_space/awesome_project",
      "url":"http://example.com/awesome_space/awesome_project.git",
      "ssh_url":"git@example.com:awesome_space/awesome_project.git",
      "http_url":"http://example.com/awesome_space/awesome_project.git"
    },
    "last_commit": {
      "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
      "message": "fixed readme",
      "timestamp": "2012-01-03T23:36:29+02:00",
      "url": "http://example.com/awesome_space/awesome_project/commits/da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
      "author": {
        "name": "GitLab dev user",
        "email": "gitlabdev@dv6700.(none)"
      }
    },
    "work_in_progress": false,
    "url": "http://example.com/diaspora/merge_requests/1",
    "action": "open",
    "assignee": {
      "name": "User1",
      "username": "user1",
      "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon"
    }
  }
}

3. H5 活動(dòng)管理平臺(tái)

當(dāng)開發(fā)者merge代碼到GitLab服務(wù)器,會(huì)觸發(fā)merge事件,GitLab會(huì)發(fā)送一個(gè)POST請(qǐng)求連帶數(shù)據(jù)(數(shù)據(jù)格式)給webhooks指定的URL,該平臺(tái)接收到URL請(qǐng)求后,就涉及如下關(guān)鍵技術(shù)點(diǎn):

1. 根據(jù)post請(qǐng)求頭信息和和body數(shù)據(jù),我們能得到如下信息:

merge的目標(biāo)分支: req.body.object_attributes.target_branch;

安全校驗(yàn)token:req.headers['x-gitlab-token'];

gitlab工程倉(cāng)庫(kù)地址:req.body.project.git_ssh_url

觸發(fā)的鉤子行為類型:req.body.object_attributes.action

// gitlab觸發(fā)merge請(qǐng)求
router.post('/merge', function (req, res, next) {
    let git_ssh_url = req.body.project.git_ssh_url;
    let name = req.body.project.name;
    // 上線merge分支master
    if (req.headers['x-gitlab-token'] == 'mergeRequest' && req.body.object_attributes.target_branch == 'master' && req.body.object_attributes.action == 'merge') {
        if (config[name] && config[name].git_ssh_url == git_ssh_url) {
            mergeTaskQueue.addTask(function () {
                getCode.init(git_ssh_url, name, 'master').then(function (data) {
                    console.log(data);
                    mergeTaskQueue.run();
                }).catch(function (error) {
                    console.log(error);
                    mergeTaskQueue.run();
                })
            }.bind(null, git_ssh_url, name));
        }
        res.end('receive request');
        // 測(cè)試merge分支dev
    } else if (req.headers['x-gitlab-token'] == 'mergeRequest' && req.body.object_attributes.target_branch == config[name].testEnv.targetBranch && req.body.object_attributes.action == 'merge') {
        if (config[name] && config[name].git_ssh_url == git_ssh_url) {
            mergeTaskQueue.addTask(function () {
                getCode.init(git_ssh_url, name, req.body.object_attributes.target_branch).then(function (data) {
                    console.log(data);
                    mergeTaskQueue.run();
                }).catch(function (error) {
                    console.log(error);
                    mergeTaskQueue.run();
                })
            }.bind(null, git_ssh_url, name));
        }
        res.end('receive request');
    } else {
        return res.end('receive request');
    }
})

2. 執(zhí)行腳本

腳本這塊沒有使用shell腳本,而是使用了node版本的shell.js庫(kù),這個(gè)庫(kù)可以讓我們控制執(zhí)行邏輯,更友好的處理錯(cuò)誤信息,幫助平臺(tái)有更友好的信息展示。

拉取最新代碼進(jìn)行構(gòu)建出目標(biāo)文件,大致邏輯如下圖:

目標(biāo)代碼
function init(git_ssh_url, projectName, targetBranch) {
    deferred = Q.defer();
    if (!git_ssh_url || !projectName) {
        return deferred.reject('項(xiàng)目地址或者項(xiàng)目名稱為空');
    }
    repository = git_ssh_url;
    repositoryName = projectName;
    clonePath = path.join(__dirname, '../projects/' + projectName);

    shell.exec('exit 0');
    if (shell.test('-e', clonePath)) {
        shell.cd(clonePath);
        let currentBranch = shell.exec('git symbolic-ref --short -q HEAD', {async: false, silent: true}).stdout;
        if(currentBranch != targetBranch) {
            let outInfo = shell.exec('git branch', {async: false, silent: true}).stdout;
            let gitcmd = outInfo.indexOf(targetBranch) >= 0 ? ('git checkout ' + targetBranch) : ('git checkout -b ' + targetBranch + ' origin/' + targetBranch);
            shell.exec('git pull && ' + gitcmd, {async: false, silent: true});
        }
        shell.exec('git pull', {async: false, silent: true}, function (code, stdout, stderr) {
            if (code != 0) {
                console.log(stderr);
                return deferred.reject('git pull error');
            }
            console.log(stdout);
            console.log('git pull run success');
            return buildTest(projectName, targetBranch);
        })
    } else {
        if (!fs.existsSync(projects_path)) {
            fs.mkdirSync(projects_path);
        }
        shell.cd(projects_path);
        shell.exec('git clone ' + repository, function (code, stdout, stderr) {
            if (code != 0) {
                console.log(stderr);
                return deferred.reject('git clone error');
            }
            console.log('git clone success');
            shell.cd(clonePath);
            let outInfo = shell.exec('git branch', {async: false, silent: true}).stdout;
            let gitcmd = outInfo.indexOf(targetBranch) >= 0 ? ('git checkout ' + targetBranch) : ('git checkout -b ' + targetBranch + ' origin/' + targetBranch);
            shell.exec(gitcmd, {async: false, silent: true});
            return buildTest(projectName, targetBranch);
        })
    }
    return deferred.promise;
}

// 構(gòu)建項(xiàng)目
function buildTest(projectName, targetBranch) {
    shell.cd(clonePath);
    shell.exec('npm config set registry https://registry.npm.taobao.org && npm install', {async: true, silent: true}, function (code, stdout, stderr) {
        if (code != 0) {
            console.log(stderr);
            return deferred.reject('npm install error');
        }
        console.log('npm install success');
        shell.rm('-rf', path.join(clonePath, 'dist'));
        let testCommand = config[repositoryName].commands.test || 'npm run test'; //構(gòu)建測(cè)試文件命令行
        shell.exec(testCommand, {async: true, silent: true}, function (code, stdout, stderr) {
            if (code != 0) {
                console.log(stderr);
                return deferred.reject('npm run test fail');
            }
            console.log('npm run test success');
            copyPage(repositoryName, 'test'); // copy到測(cè)試目錄
            if(targetBranch != 'master') {
                shell.exec('exit 0');
                deferred.resolve('build success and finish');
                return; // 提測(cè)時(shí)只構(gòu)建測(cè)試文件
            }
            // 構(gòu)建最終上線文件
            shell.rm('-rf', path.join(clonePath, 'dist'));
            let buildCommand = config[repositoryName].commands.build || 'npm run build'; //構(gòu)建預(yù)上線文件命令行
            shell.exec(buildCommand, {async: true, silent: true}, function (code, stdout, stderr) {
                if (code != 0) {
                    console.log(stderr);
                    return deferred.reject('npm run build fail');
                }
                console.log('npm run build success');
                copyPage(repositoryName, 'online'); //copy到上線正式目錄
                
                // 每次合并master構(gòu)建后,都切換到測(cè)試分支,便于平臺(tái)讀取config.json信息(測(cè)試分支是最新的)
                shell.exec('git checkout ' + config[projectName].testEnv.targetBranch, {async: false, silent: false}); 
                shell.exec('exit 0');
                deferred.resolve('build success and finish');
            })
        })
    })
}

3. 動(dòng)態(tài)擴(kuò)展項(xiàng)目

通過(guò)修改項(xiàng)目配置文件,接入不同的項(xiàng)目,配置信息有每個(gè)項(xiàng)目要上傳的CDN路徑、構(gòu)建命令、項(xiàng)目目錄展示信息文件路徑(config.json),如下圖:

// 接入該平臺(tái)的項(xiàng)目列表
module.exports = {
    'h5-activity-cms': {
        git_ssh_url: 'git@example.com:awesome_space/awesome_project.git',
        desc: '前端名獅項(xiàng)目',
        tabContent: '前端名獅', //頁(yè)面中tab展示文字
        onlineParam: { //上傳cdn的參數(shù),根據(jù)自己項(xiàng)目設(shè)置
            html: {
                domain: '',
                path: ''
            },
            js: {
                domain: '',
                path: ''
            }
        },
        commands: { //構(gòu)建腳本命令行
            test: 'npm run test',
            build: 'npm run build'
        },
       
        configFile: 'config.json', // 活動(dòng)頁(yè)面列表信息
    }
}

4. 隊(duì)列處理

構(gòu)建目標(biāo)文件的過(guò)程中,很多生成文件、壓縮、copy的異步操作,不同的merge請(qǐng)求,有可能操作的是同一個(gè)文件,所以需要對(duì)merge請(qǐng)求做隊(duì)列處理。

class TaskQueue {
    constructor() {
        this.list = [];
        this.isRunning = false;
    }
    addTask(task) {
        this.list.push(task);
        if(this.isRunning) {
            return;
        }
        this.start();
    }
    shift() {
        return this.list.length > 0 ? this.list.shift() : null;
    }
    run() {
        let task = this.shift();
        if(!task) {
            this.isRunning = false;
            return;
        }
        task();
    }
    start() {
        this.isRunning = true;
        this.run();
    }
}
module.exports = TaskQueue;

5. CDN 發(fā)布

這個(gè)需要后端同學(xué)提供一個(gè)服務(wù)接口,用于推送文件到CDN上或者服務(wù)器上。我們這邊是借助于一個(gè)服務(wù)端接口,我們通過(guò)node上傳到他們的服務(wù)器,接口方會(huì)定時(shí)推送文件到CDN,具體每個(gè)人的情況處理吧哈。


三、總結(jié)

該平臺(tái)使用node實(shí)現(xiàn)了一個(gè)微型的、類似jenkins功能的部署管理平臺(tái),具有如下突出的優(yōu)點(diǎn):

  1. 該平臺(tái)打通了本地開發(fā)環(huán)境和測(cè)試環(huán)境部署,實(shí)現(xiàn)了測(cè)試部署自動(dòng)化,節(jié)省了人工上傳粘貼代碼的時(shí)間,大大地提高了工作效率;

  2. 基于項(xiàng)目工程劃分的類別,便于開發(fā)者高效率的查找頁(yè)面;

  3. 支持動(dòng)態(tài)擴(kuò)展,可以通過(guò)添加配置文件,接入其他gitlab項(xiàng)目;

  4. 可以根據(jù)需要定制化平臺(tái)操作頁(yè)面,比使用jenkins更靈活,更輕便;


掃一掃 關(guān)注我的公眾號(hào)【前端名獅】,更多精彩內(nèi)容陪伴你!

【前端名獅】
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容