Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐

趙鈺瑩發表於2018-08-08

一、背景

在日常開發中,我們經常會有釋出需求,而且還會遇到各種環境,比如:線上環境(Online),模擬環境(Staging),開發環境(Dev)等。最簡單的就是手動構建、上傳伺服器,但這種方式太過於繁瑣,使用持續整合可以完美地解決這個問題,推薦瞭解一下Jenkins。 Jenkins構建也有很多種方式,現在使用比較多的是自由風格的軟體專案(Jenkins構建的一種方式,會結合SCM和構建系統來構建你的專案,甚至可以構建軟體以外的系統)的方式。針對單個專案的簡單構建,這種方式已經足夠了,但是針對多個類似且又存在差異的專案,就難以滿足要求,否則就需要大量的job來支援,這就存在,一個小的變動,就需要修改很多個job的情況,難以維護。我們團隊之前就存在這樣的問題。

目前,我們團隊主要負責開發和維護多個Android專案,而且每個專案都需要構建,每個構建流程非常類似但又存在一定的差異。比如構建的流程大概如下:

  • 克隆程式碼;
  • 靜態程式碼檢查(可選);
  • 單元測試(可選);
  • 編譯打包APK或者熱補丁;
  • APK分析,獲取版本號(VersionCode),包的Hash值(apkhash)等;
  • 加固;
  • 上傳測試分發平臺;
  • 存檔(可選);
  • 觸發自動化測試(可選);
  • 通知負責人構建結果等。

整個流程大體上是相同的,但是又存在一些差異。比如有的構建可以沒有單元測試,有的構建不用觸發自動化測試,而且構建結果通知的負責人也不同。如果使用自由風格軟體專案的普通構建,每個專案都要建立一個job來處理流程(可能會呼叫其他job)。

這種處理方式原本也是可以的,但是必須考慮到,可能會有新的流程接入(比如二次簽名),構建流程也可能存在Bug等多種問題。無論哪種情況,一旦修改主構建流程,每個專案的job都需要修改和測試,就必然會浪費大量的時間。針對這種情況,我們使用了Pipeline的構建方式來解決。

當然,如果有專案整合了React Native,還需要構建JsBundle。在Native修改以後,JsBundle不一定會有更新,如果是構建Native的時候一起構建JsBundle,就會造成很多資源浪費。並且直接把JsBundle這類大檔案放在Native的Git倉庫裡,也不是特別合適。

本文是分享一種Pipeline的使用經驗,來解決這類問題。

二、Pipeline的介紹

Pipeline也就是構建流水線,對於程式設計師來說,最好的解釋是:使用程式碼來控制專案的構建、測試、部署等。使用它的好處有很多,包括但不限於:

  • 使用Pipeline可以非常靈活的控制整個構建過程;
  • 可以清楚的知道每個構建階段使用的時間,方便構建的優化;
  • 構建出錯,使用stageView可以快速定位出錯的階段;
  • 一個job可以搞定整個構建,方便管理和維護等。

Stage View

Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐

三、使用Pipeline構建

新建一個Pipeline專案,寫入Pipeline的構建指令碼,就像下面這樣:

Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐
對於單個專案來說,使用這樣的Pipeline來構建能夠滿足絕大部分需求,但是這樣做也有很多缺陷,包括:

  • 多個專案的Pipeline打包指令碼不能公用,導致一個專案寫一份指令碼,維護比較麻煩。一個變動,需要修改多個job的指令碼;
  • 多個人維護構建job的時候,可能會覆蓋彼此的程式碼;
  • 修改指令碼失敗以後,無法回滾到上個版本;
  • 無法進行構建指令碼的版本管理,老版本發修復版本需要構建,可能和現在用的job版本已經不一樣了,等等。

四、把Pipeline當程式碼寫

既然存在缺陷,我們就要找更好的方式,其實Jenkins提供了一個更優雅的管理Pipeline指令碼的方式,在配置專案Pipeline的時候,選擇Pipeline script from SCM,就像下面這樣:

Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐
這樣,Jenkins在啟動job的時候,首先會去倉庫裡面拉取指令碼,然後再執行這個指令碼。在指令碼里面,我們規定的構建方式和流程,就會按部就班地執行。構建的指令碼,可以實現多人維護,還可以Review,避免出錯。 以上就算搭建好了一個基礎,而針對多個專案時,還有一些事情要做,不可能完全一樣,以下是構建的結構圖:

Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐

如此以來,我們的構建資料來源分為三部分:job UI介面、倉庫的通用Pipeline指令碼、專案下的特殊配置,我們分別來看一下:

job UI介面(引數化構建)

在配置job的時候,選擇引數化構建過程,傳入專案倉庫地址、分支、構建通知人等等。還可以增加更多的引數 ,這些引數的特點是,可能需要經常修改,比如靈活選擇構建的程式碼分支。

Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐

專案配置

在專案工程裡面,放入針對這個專案的配置,一般是一個專案固定,不經常修改的引數,比如專案名字,如下圖:

Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐

注入構建資訊

QA提一個Bug,我們需要確定,這是哪次的構建,或者要知道commitId,從而方便進行定位。因此在構建時,可以把構建資訊注入到APK之中。

  1. 把屬性注入到gradle.properties
# 應用的後端環境
APP_ENV=Beta
# CI 打包的編號,方便確定測試的版本,不通過 CI 打包,預設是 0
CI_BUILD_NUMBER=0
# CI 打包的時間,方便確定測試的版本,不通過 CI 打包,預設是 0
CI_BUILD_TIMESTAMP=0
複製程式碼
  1. 在build.gradle裡設定buildConfigField
#使用的是gradle.properties裡面注入的值
buildConfigField "String", "APP_ENV", "\"${APP_ENV}\""
buildConfigField "String", "CI_BUILD_NUMBER", "\"${CI_BUILD_NUMBER}\""
buildConfigField "String", "CI_BUILD_TIMESTAMP", "\"${CI_BUILD_TIMESTAMP}\""
buildConfigField "String", "GIT_COMMIT_ID", "\"${getCommitId()}\""

//獲取當前Git commitId
String getCommitId() {
    try {
        def commitId = 'git rev-parse HEAD'.execute().text.trim()
        return commitId;
    } catch (Exception e) {
        e.printStackTrace();
    }
}
複製程式碼
  1. 顯示構建資訊 在App裡,找個合適的位置,比如開發者選項裡面,把剛才的資訊顯示出來。QA提Bug時,要求他們把這個資訊一起帶上
mCIIdtv.setText(String.format("CI 構建號:%s", BuildConfig.CI_BUILD_NUMBER));
mCITimetv.setText(String.format("CI 構建時間:%s", BuildConfig.CI_BUILD_TIMESTAMP));
mCommitIdtv.setText(String.format("Git CommitId:%s", BuildConfig.GIT_COMMIT_ID));
複製程式碼

倉庫的通用Pipeline指令碼

通用指令碼是抽象出來的構建過程,遇到和專案有關的都需要定義成變數,再從變數裡進行讀取,不要在通用指令碼里寫死。


node {
	try{
		stage('檢出程式碼'){//從git倉庫中檢出程式碼
	    	git branch: "${BRANCH}",credentialsId: 'xxxxx-xxxx-xxxx-xxxx-xxxxxxx', url: "${REPO_URL}"
	       	loadProjectConfig();
	  	}
	   	stage('編譯'){
	   		//這裡是構建,你可以呼叫job入參或者專案配置的引數,比如:
	   		echo "專案名字 ${APP_CHINESE_NAME}"
	   		//可以判斷
	   		if (Boolean.valueOf("${IS_USE_CODE_CHECK}")) {
	   			echo "需要靜態程式碼檢查"
	   		} else {
	   			echo "不需要靜態程式碼檢查"
	   		}

	   	}
	   	stage('存檔'){//這個演示的Android的專案,實際使用中,請根據自己的產物確定
	       	def apk = getShEchoResult ("find ./lineup/build/outputs/apk -name '*.apk'")
	       	def artifactsDir="artifacts"//存放產物的資料夾
	        sh "mkdir ${artifactsDir}"
	       	sh "mv ${apk} ${artifactsDir}"
	       	archiveArtifacts "${artifactsDir}/*"
	   	}
	   	stage('通知負責人'){
	   		emailext body: "構建專案:${BUILD_URL}\r\n構建完成", subject: '構建結果通知【成功】', to: "${EMAIL}"
	   	}
	} catch (e) {
		emailext body: "構建專案:${BUILD_URL}\r\n構建失敗,\r\n錯誤訊息:${e.toString()}", subject: '構建結果通知【失敗】', to: "${EMAIL}"
	} finally{
		// 清空工作空間
        cleanWs notFailBuild: true
	}
   	
   
}
 
// 獲取 shell 命令輸出內容
def getShEchoResult(cmd) {
    def getShEchoResultCmd = "ECHO_RESULT=`${cmd}`\necho \${ECHO_RESULT}"
    return sh (
        script: getShEchoResultCmd,
        returnStdout: true
    ).trim()
}

//載入專案裡面的配置檔案
def loadProjectConfig(){
    def jenkinsConfigFile="./jenkins.groovy"
    if (fileExists("${jenkinsConfigFile}")) {
        load "${jenkinsConfigFile}"
        echo "找到打包引數檔案${jenkinsConfigFile},載入成功"
    } else {
        echo "${jenkinsConfigFile}不存在,請在專案${jenkinsConfigFile}裡面配置打包引數"
        sh "exit 1"
    }
}

複製程式碼

輕輕的點兩下Build with Parameters -> 開始構建,然後等幾分鐘的時間,就能夠收到郵件。

Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐

五、其他構建結構

以上,僅僅是針對我們當前遇到問題的一種不錯的解決方案,可能並不完全適用於所有場景,但是可以根據上面的結構進行調整,比如:

  • 根據stage拆分出不同的Pipeline指令碼,這樣方便CI的維護,一個或者幾個人維護構建中的一個stage;
  • 把構建過程中的stage做成普通的自由風格的軟體專案的job,把它們作為基礎服務,在Pipeline中呼叫這些基礎服務等。

六、當遇上React Native

當專案引入了React Native以後,因為技術棧的原因,React Native的頁面是由前端團隊開發,但容器和原生元件是Android團隊維護,構建流程也發生了一些變化。

方案對比

方案 說明 缺點 優點
手動拷貝 等JsBundle構建好了,再手動把構建完成的產物,拷貝到Native工程裡面 1. 每次手動操作,比較麻煩,效率低,容易出錯
2. 涉及到跨端合作,每次要去前端團隊主動拿JsBundle
3. Git不適合管理大檔案和二進位制檔案
簡單粗暴
使用submodule儲存構建好的JsBundle 直接把JsBundle放在Native倉庫的一個submodule裡面,由前端團隊主動更新,每次更新Native的時候,直接就拿到了最新的JsBundle 1. 簡單無開發成本
2. 不方便單獨控制JsBundle的版本
3. Git不適合管理大檔案和二進位制檔案
前端團隊可以主動更新JsBundle
使用submodule管理JsBundle的原始碼 直接把JsBundle的原始碼放在Native倉庫的一個submodule裡面,由前端團隊開發更新,每次構建Native的時候,先構構建JsBundle 1. 不方便單獨控制JsBundle的版本
2. 即使JsBundle無更新,也需要構建,構建速度慢,浪費資源
方便靈活
分開構建,產物存檔 JsBundle和Native分開構建,構建完了的JsBundle分版本存檔,Native構建的時候,直接去下載構建好了的JsBundle版本 1. 通過配置管理JsBundle,解放Git
2. 方便Jenkins構建的時候,動態配置需要的JsBundle版本
1. 需要花費時間建立流程
2. 需要開發Gradle的JsBundle下載外掛

前端團隊開發頁面,構建後生成JsBundle,Android團隊拿到前端構建的JsBundle,一起打包生成最終的產物。 在我們開發過程中,JsBundle修改以後,不一定需要修改Native,Native構建的時候,也不一定每次都需要重新構建JsBundle。並且這兩個部分由兩個團隊負責,各自獨立發版,構建的時候也應該獨立構建,不應該融合到一起。

綜合對比,我們選擇了使用分開構建的方式來實現。

分開構建

因為需要分開發布版本,所以JsBundle的構建和Native的構建要分開,使用兩個不同的job來完成,這樣也方便兩個團隊自行操作,避免相互影響。 JsBundle的構建,也可以參考上文提到的Pipeline的構建方式來做,這裡不再贅述。 在獨立構建以後,怎麼才能組合到一起呢?我們是這樣思考的:JsBundle構建以後,分版本的儲存在一個地方,供Native在構建時下載需要版本的JsBundle,大致的流程如下:

Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐

這個流程有兩個核心,一個是構建的JsBundle歸檔儲存,一個是在Native構建時去下載。

JsBundle歸檔儲存

方案 缺點 優點
直接存檔在Jenkins上面 1. JsBundle不能彙總瀏覽
2. Jenkins很多人可能要下載,命名帶有版本號,時間,分支等,命名不統一,不方便構建下載地址
3. 下載Jenkins上面的產物需要登陸授權,比較麻煩
1. 實現簡單,一句程式碼就搞定,成本低
自己構建一個儲存服務 1. 工程大,開發成本高
2. 維護起來麻煩
可擴充套件,靈活性高
MSS
(美團儲存服務)
1. 儲存空間大
2. 可靠性高,配合CDN下載速度快
3. 維護成本低, 價格便宜

這裡我們選擇了MSS。 上傳檔案到MSS,可以使用s3cmd,但畢竟不是每個Slave上面都有安裝,通用性不強。為了保證穩定可靠,這裡基於MSS的SDK寫個小工具即可,比較簡單,幾行程式碼就可以搞定。

private static String TenantId = "mss_TenantId==";
private static AmazonS3 s3Client;

public static void main(String[] args) throws IOException {
	if (args == null || args.length != 3) {
		System.out.println("請依次輸入:inputFile、bucketName、objectName");
		return;
	}
	s3Client = AmazonS3ClientProvider.CreateAmazonS3Conn();
	uploadObject(args[0], args[1], args[2]);
}

public static void uploadObject(String inputFile, String bucketName, String objectName) {
	try {
		File file = new File(inputFile);
		if (!file.exists()) {
			System.out.println("檔案不存在:" + file.getPath());
			return;
		}
		s3Client.putObject(new PutObjectRequest(bucketName, objectName, file));
		System.out.printf("上傳%s到MSS成功: %s/v1/%s/%s/%se", inputFile, AmazonS3ClientProvider.url, TenantId, bucketName, objectName);
	} catch (AmazonServiceException ase) {
		System.out.println("Caught an AmazonServiceException, which " +
				"means your request made it " +
				"to Amazon S3, but was rejected with an error response" +
				" for some reason.");
		System.out.println("Error Message:    " + ase.getMessage());
		System.out.println("HTTP Status Code: " + ase.getStatusCode());
		System.out.println("AWS Error Code:   " + ase.getErrorCode());
		System.out.println("Error Type:       " + ase.getErrorType());
		System.out.println("Request ID:       " + ase.getRequestId());
	} catch (AmazonClientException ace) {
		System.out.println("Caught an AmazonClientException, which " +
				"means the client encountered " +
				"an internal error while trying to " +
				"communicate with S3, " +
				"such as not being able to access the network.");
		System.out.println("Error Message: " + ace.getMessage());
	}
}
複製程式碼

我們直接在Pipeline裡構建完成後,呼叫這個工具就可以了。 當然,JsBundle也分型別,在除錯的時候可能隨時需要更新,這些JsBundle不需要永久儲存,一段時間後就可以刪除了。在刪除時,可以參考MSS生命週期管理。所以,我們在構建JsBundle的job裡,新增一個引數來區分。

//根據TYPE,上傳到不同的bucket裡面
def bucket = "rn-bundle-prod"
if ("${TYPE}" == "dev") {
	bucket = "rn-bundle-dev" //有生命週期管理,一段時間後自動刪除
}
echo "開始JsBundle上傳到MSS"
//jar地址需要替換成你自己的
sh "curl -s -S -L  http://s3plus.sankuai.com/v1/mss_xxxxx==/rn-bundle-prod/rn.bundle.upload-0.0.1.jar -o upload.jar"
sh "java -jar upload.jar ${archiveZip} ${bucket} ${PROJECT}/${targetZip}"
echo "上傳JsBundle到MSS:${archiveZip}"
複製程式碼

Native構建時JsBundle的下載

為了實現構建時能夠自動下載,我們寫了一個Gradle的外掛。 首先要在build.gradle裡面配置外掛依賴:

classpath 'com.zjiecode:rn-bundle-gradle-plugin:0.0.1'
複製程式碼

在需要的Module應用外掛:

apply plugin: 'mt-rn-bundle-download'
複製程式碼

在build.gradle裡面配置JsBundle的資訊:

RNDownloadConfig {
    //遠端檔案目錄,因為有多種型別,所以這裡可以填多個。
    paths = [
            'http://msstest-corp.sankuai.com/v1/mss_xxxx==/rn-bundle-dev/xxx/',
            'http://msstest-corp.sankuai.com/v1/mss_xxxx==/rn-bundle-prod/xxx/'
    ]
    version  = "1"//版本號,這裡使用的是打包JsBundle的BUILD_NUMBER
    fileName = 'xxxx.android.bundle-%s.zip' //遠端檔案的檔名,%s會用上面的version來填充
    outFile  = 'xxxx/src/main/assets/JsBundle/xxxx.android.bundle.zip' // 下載後的儲存路徑,相對於專案根目錄
}
複製程式碼

外掛會在package的task前面,插入一個下載的task,task讀取上面的配置資訊,在打包階段檢查是否已經存在這個版本的JsBundle。如果不存在,就會去歸檔的JsBundle裡,下載我們需要的JsBundle。 當然,這裡的version可以使用上文介紹的注入構建資訊的方式,通過job引數的方式進行注入。這樣在Jenkins構建Native時,就可以動態地填寫需要JsBundle的版本了。 這個Gradle外掛,我們已經放到到了github倉庫,你可以基於此修改,當然,也歡迎PR。 github.com/zjiecode/rn…

六、總結

我們把一個構建分成了好幾個部分,帶來的好處如下:

  • 核心構建過程,只需要維護一份,減輕維護工作;
  • 方便多個人維護構建CI,避免Pipeline程式碼被覆蓋;
  • 方便構建job的版本管理,比如要修復某個已經發布的版本,可以很方便切換到釋出版本時候用的Pipeline指令碼版本;
  • 每個專案,配置也比較靈活,如果專案配置不夠靈活,可以嘗試定義更多的變數;
  • 構建過程視覺化,方便針對性優化和錯誤定位等。

當然,Pipeline也存在一些弊端,比如:

  • 語法不夠友好,但好在Jenkins提供了一個比較強大的幫助工具(Pipeline Syntax);
  • 程式碼測試繁瑣,沒有本地執行環境,每次測試都需要提交執行一個job,等等。

當專案整合了React Native時,配合Pipeline,我們可以把JsBundle的構建產物上傳到MSS歸檔。在構建Native的時候 ,可以動態地下載。

七、作者

張傑,美團點評高階Android工程師,2017年加入餐飲平臺成都研發中心,主要負責餐飲平臺B端應用開發。 王浩,美團點評高階Android工程師,2017年加入餐飲平臺成都研發中心,主要負責餐飲平臺B端應用開發。

八、招聘廣告

本文作者來自美團成都研發中心(是的,我們在成都建研發中心啦)。我們在成都有眾多後端、前端和測試的崗位正在招人,歡迎大家投遞簡歷:songyanwei@meituan.com。

Jenkins的Pipeline指令碼在美團餐飲SaaS中的實踐

相關文章