一、背景
H5頁(yè)面由于其具有發(fā)布靈活、跨平臺(tái)、易于傳播等突出特點(diǎn),所以H5頁(yè)面是引流拉新、宣傳推廣的重要渠道和方式,備受各公司的青睞。
小編的日常工作就是做各種面向用戶的H5促銷活動(dòng)的開發(fā),在整個(gè)開發(fā)周期中,接合我司的一些情況,我總結(jié)了H5活動(dòng)頁(yè)面的以下特點(diǎn):
- 面向用戶,流量大;
- 各端展示方案不同,需要兼容各端(比如活動(dòng)規(guī)則、展示模塊,ios和android不一樣);
- 需求變更頻繁;
- 合作方較多(需要跟各個(gè)業(yè)務(wù)線合作聯(lián)調(diào));
- 排期緊張;
所以開發(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í)。
該平臺(tái)實(shí)現(xiàn)主要依賴于本地開發(fā)工程、gitlab,三者之間通過(guò)通信交互,實(shí)現(xiàn)的自動(dòng)化部署。
最終達(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)流程圖:
下面對(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)整方案)。
- 本地開發(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è)試文件
}
- 提供構(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è)面
}
- 提供活動(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"
}
]
}
- 構(gòu)建生成的
JS和HTML文件,存放在dist目錄下的對(duì)應(yīng)活動(dòng)目錄中。構(gòu)建生成的目錄結(jié)構(gòu)如下:
|--dist
|-- lion
|-- lion_app.js
|-- index.html
|--test
|-- test_app.js
|-- index.html
- 提測(cè)時(shí),將開發(fā)分支merge到devTest分支,上線時(shí),將開發(fā)分支merge到master分支。
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)求。
注意:
- URL處填寫的是【H5活動(dòng)管理平臺(tái)】部署的服務(wù)器IP;
- IP后面跟的
merge是該平臺(tái)提供的一個(gè)接口,用于觸發(fā)鉤子后,gitlab服務(wù)器向這個(gè)接口發(fā)送Post請(qǐng)求; -
Secret Token處填寫的是一個(gè)token,主要用于merge接口請(qǐng)求做安全校驗(yàn),可以隨便設(shè)置。
具體配置如下圖:
我們項(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)文件,大致邏輯如下圖:
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):
該平臺(tái)打通了本地開發(fā)環(huán)境和測(cè)試環(huán)境部署,實(shí)現(xiàn)了測(cè)試部署自動(dòng)化,節(jié)省了人工上傳粘貼代碼的時(shí)間,大大地提高了工作效率;
基于項(xiàng)目工程劃分的類別,便于開發(fā)者高效率的查找頁(yè)面;
支持動(dòng)態(tài)擴(kuò)展,可以通過(guò)添加配置文件,接入其他gitlab項(xiàng)目;
可以根據(jù)需要定制化平臺(tái)操作頁(yè)面,比使用jenkins更靈活,更輕便;
掃一掃 關(guān)注我的公眾號(hào)【前端名獅】,更多精彩內(nèi)容陪伴你!