【認識 Gradle】(6)Java 專案與 Build Script 客製化
- gradle-series
前二篇是針對 Gradle Script 識讀的概念進行較細節的介紹。Gradle 的生態圈是以 Java 語言為主的專案,例如 Java 應用程式、Java Web 專案或 Android 專案較為常見。
許多的 Open Source 專案也由 Maven 轉移至 Gradle。由學習使用 Gradle 輔助 Java 專案的開發是個很好上手的起點,使用 Gradle 建置 Java 專案可參考官手冊:
相信有在 follow 的讀者發現,我們給予的材料都是依官方手冊為主的。除了已購入 Gradle 書籍的讀者之外,我想官方手冊已經有充足的資訊讓 Gradle 使用者解決日常開發的需要。而此系列教學的目標在於多解說一些概念,讓讀者能讀懂官方手冊。這些概念是散落於手冊許多部分的,或是非 Gradle 專屬的領域的(像是 Groovy 的用法)。
觀念的傳達試著讓讀者將這些資訊黏起來,讓它們串在一起。我們希望學習者面對新的使用需求或問題時,能優先運用這些觀念與手冊的資訊找到解法,使用搜尋引擎碰運氣才是最後的手段。
編:上述提的 2 個官方手冊參考連結是 2013 年的 1.8 版。現今 (此文於 2021 年重新上架) 的內部實作有所不同,特別是 Java Plugin 部分已被改寫替換。
Java 專案
讀者可參考在 Gradle 起手式 使用的範例。它是一個經典的 Hello World 專案。基本結構、原始碼、設定檔與 build.gradle 回顧。對於有已使用 Maven 的讀者來說,應該習慣了程式碼間的部局方式。像是原始碼應放在 src/main/java
而相關的設定檔要放在 src/main/resources
。對於測試程式與測試程式用到的設定檔也是相同的慣例 (convention),這是 Gradle 許多 plugin 的實作都承襲 Maven 的 Convention Over Configuration 哲學:
/* 引用 java plugin 獲得編譯 java 專案相關的 task $ */
apply plugin: 'java'
/* 引用 application plugin 獲得執行 java 專案相關的 task $ */
apply plugin:'application'
/* 執行 application plugin 用到的參數 $ */
mainClassName = "tw.com.codedata.HelloWorld"
/* 設定 maven repository server $ */
repositories {
mavenCentral()
}
/* 宣告專案的相依函式庫 $ */
dependencies {
compile group: 'commons-logging', name: 'commons-logging', version: '1.1.1'
compile group: 'log4j', name: 'log4j', version: '1.2.16'
}
這簡單的範例是個起點,但它離實際運用的仍有距離。一個實際在運作的專案來,可不是程式碼可以編成一個 jar 檔就行。還要依不同的目標產生不同的編譯產出。例如:依部署環境不同使用不同的設定檔,像是在測試環境使用的 DB Server 位置、相關設定檔內容或是快取存放位置都有修改的必要。這些事過去你可能是用 Ant 執行或手工修改設定檔。這樣的工作應該依賴編譯工具,並加上檢核的步驟避免人為錯誤。
/* 引用 java plugin 獲得編譯 java 專案相關的 task $ */
apply plugin: 'java'
我們再度回到起點,使用 apply 委派 Project 物件將 Java Plugin 加進這個專案。這回課程的主要目標是講述日常開發的事物,我們不會過度深入 Gradle 內部實作的細節,所以讀者只要知道 apply plugin 會改變現有 Build Script 的『內涵』:引進新的變數與新的 task。
SourceSets
新的變數最廣為人知的為 sourceSets,這也是多數想要客製化 Java 專案的學習者最先認識的變數。在 Gradle 的手冊中將這類別變數稱為 Convention Properties,透過 Gradle 的機制將它由 Plugin 內部曝露在 Build Script 可以取用的範圍,所以變更它即能影響 Plugin 的行為。sourceSets 是其中一個變數,我們可以用它來改變 Project Layout。
qty:gradleLab qrtt1$ tree .
.
├── build.gradle
└── src
├── main
│ ├── java
│ └── resources
└── test
├── java
└── resources
以上面的例子來看,它是典型的 Java 專案的 Project Layout,這慣例如同 Maven 使用的 Project Layout 一致。不過有許多理由,我們不想要用這樣的 Project Layout,例如舊的專案,特別是已經透過版本系統管理的舊專案,不是因為『需求變更』而變更路徑的情況使得版本記錄不連貫,這使得一些原本在路一個路徑下的記錄轉移到不同的路徑,使得在語意上屬於同個檔案的歷史有著二代的記錄。
當然還有其他同樣理直氣壯的理由,例如:Gradle 只是作為輔助的編譯工具,同時還並存著 Ant 或 Maven 讓其他尚未接納 Gradle 作為編譯工具的同事使用。不管什麼理由,你需要改變它成這個樣子:
qty:gradleLab qrtt1$ tree
.
├── build.gradle
├── config
│ └── log4j.properties
└── src
└── tw
└── com
└── codedata
└── HelloWorld.java
src 即為原先 src/main/java
的位置,config 即為原先 src/main/resources
的位置,由於其他沒有交待的原因,沒有規劃 src/test 相關路徑的位置。它的 Build Script 會改寫成這樣:
apply plugin: 'java'
repositories {
mavenCentral()
}
dependencies {
compile group: 'commons-logging', name: 'commons-logging', version: '1.1.1'
compile group: 'log4j', name: 'log4j', version: '1.2.16'
}
sourceSets {
main {
java {
srcDir 'src'
}
resources {
srcDir 'config'
}
}
}
修改好後,編譯居然也能動了,實在很神奇。回想一下前幾回提過的觀念,它只是在呼叫 delegate 方法。我們稍為改寫一下 Build Script:
println sourceSets.class
sourceSets {
println main.class
main {
println java.class
java {
srcDir 'src'
}
println resources.class
resources {
srcDir 'config'
}
}
}
執行 gradle 會印出:
class org.gradle.api.internal.tasks.DefaultSourceSetContainer_Decorated
class org.gradle.api.internal.tasks.DefaultSourceSet_Decorated
class org.gradle.api.internal.file.DefaultSourceDirectorySet
class org.gradle.api.internal.file.DefaultSourceDirectorySet
所以,我們可以知道:
- sourceSets 是 SourceSetContainer
- main 是 SourceSet
- java 與 resources 是 SourceDirectorySet
查詢 Javadoc 可以明顯地發現,srcDir 是 SourceDirectorySet 的方法,它的功能是增加一個新的路徑。實際上的結果是,原先的路徑依然存在,只是剛好沒有放檔案在那些路徑上,所以只會編譯目前新增路徑的原始碼:
另一個 sourceSets 常用的功能是排除某些檔案,不要讓它被編譯或被複製。在 SourceSet Javadoc 上有這樣的例子:
apply plugin: 'java'
sourceSets {
main {
java {
exclude 'some/unwanted/package/**'
}
}
}
這個排除的設定由『源頭』就被封鎖,它間接影響到產生 IDE 設定的 plugin。以 eclipse plugin 來說,它會在 classpath 內排除它。對於 Java 原始碼來說,會看到它標示成不同的 Icon,如果是一般的設定檔就比較看不出來。舉個例來說,如果將所有 .properties 為副檔名的檔案在 sourceSets 內排除:
sourceSets {
main {
java {
srcDir 'src'
exclude '**/*.properties'
}
resources {
srcDir 'config'
}
}
}
產生出來的 .classpath 會包含:
<classpathentry excluding="**/*.properties" kind="src" path="src"/>
而這類的設定檔本身沒有特別的圖示標明它是否為『作用中』的狀態,比較不容易察覺吃不到設定檔產生的錯誤。所以,在 sourceSets 內使用排除檔案或目錄的功能要特別小心。若只是針對輸出的 jar 檔要排除檔案,可以在其他的流程處理。
Java Tasks
當你的專案引用 java plugin 後,它就會增加上述的 task。這圖是取自 Chapter 23. The Java Plugin。Java Plugin 建構出如圖中所示的 Task 相依關係,當你試著呼叫 build task 時,它的顯示如下:
qty:gradleLab qrtt1$ gradle build
:compileJava
:processResources
:classes
:jar
:assemble
:compileTestJava UP-TO-DATE
:processTestResources UP-TO-DATE
:testClasses UP-TO-DATE
:test UP-TO-DATE
:check UP-TO-DATE
:build
BUILD SUCCESSFUL
執行 gradle build
時,它依序列出 task 被執行的順序,比對圖示由 build 的項目往回推算,預期要執行的 task 都正確執行。不過,我們並不是為了檢查手冊有沒有寫錯才來看它的。它每一個 task 都會有一個目的要達成,而編譯工作就是一系列地將檔案進行轉換成另一組檔案。
每一個 task 都有方法修改它預設的行為,以 compileJava
來說,它是負責將在 main sourceSet
的 Java 原始碼編譯,輸出至該 sourceSet 定義的 output 目錄下的 task。compileJava
它就如同你呼叫 javac 的功能,我們可以有個直覺的反應,那麼 javac 能調整的選項 compileJava
task 也應該要有。像是常見的原始碼要相容於哪一個 Java 版本,而目的碼要相容於哪一個 Java 版本,或是常見的原始碼的文字編碼設定要強制設為 UTF-8 不用環境變數抓到的其他值。
要客製化既有 task 的途徑有二種,一種是利用 task 的 configure closure,一種是利用 Plugin 提供的 Convention Properites。
在 【認識 Gradle】(5)Gradle Task 觀念導讀 時,我們已經介紹過 configure closure,它用法很直覺就是替 task 寫個 closure 罷了,像在前一段提到的除排特定檔案,我們可以選擇在打包 jar 時進行:
jar {
exclude '**/log4j.properties'
}
編譯出的檔案內就不會出現 log4j.properties
:
qty:gradleLab qrtt1$ unzip -l build/libs/gradleLab.jar
Archive: build/libs/gradleLab.jar
Length Date Time Name
-------- ---- ---- ----
0 12-21-13 13:10 META-INF/
25 12-21-13 13:10 META-INF/MANIFEST.MF
0 12-20-13 18:00 tw/
0 12-20-13 18:00 tw/com/
0 12-20-13 18:00 tw/com/codedata/
750 12-20-13 18:00 tw/com/codedata/HelloWorld.class
-------- -------
775 6 files
在 library 內排除特定設定檔是相當常見的功能,我們會希望 library 使用者明確指定他應該準備的設定檔。這種細節可以避免忘了放檔案,程式能動但動的不如期望的情況發生。
另一種的客製化就是透過 Plugin 事先準備好的 Convention Properties 來做,例如在 Java 專案內設定編譯選項:
sourceCompatibility=1.4
targetCompatibility=1.5
指定原始碼、目的碼版本,在一些舊專案特別有用,像是一些舊專案把 enum 拿來當變數名或方法名稱,在 1.5 就行不通了,若沒有改寫的計劃,這種情況就得靠編譯參數來處理。還有 Java 7 出來後的統一成特定版本的 bytecode 也是一個用途。常用的當然還有編碼設定,像是把 compileJava
的對原始碼的編碼處理改成 UTF-8:
compileJava.options.encoding='UTF-8'
除了 compileJava
外,還有 compileTestJava
也同樣能設定,加上 javadoc 也可以設成需要的編碼。這可以透過 groovy 的 spread operator 寫成比較騷包的型式:
[compileJava, compileTestJava, javadoc]*.options*.encoding = 'UTF-8'
若你的 Build Script 更複雜,有更多組的 sourceSet 的 Java Compiler 需要綁定,直接寫 task 名稱去設定就可能有所遺漏。這樣可以改成以 task 型別的方法去修改參數,任何 task 有繼承自 Compile 的 task 就修改它的 options 設定為 UTF-8:
tasks.withType(Compile) {
options.encoding='UTF-8'
}
Packaging
日常開發的活動,不只單純把 code 寫出來,將專案打包成可以發佈的型式為一個重要的環節。實際的專案它會有許多組的設定檔,以下目錄節構是模擬出來的一種情境:
qty:gradleLab qrtt1$ tree
.
├── build.gradle
├── config
│ └── log4j.properties
├── scripts
│ └── run.sh
├── profile_dev
│ └── log4j.properties
├── profile_production
│ └── log4j.properties
├── scripts
└── src
└── tw
└── com
└── codedata
└── HelloWorld.java
新的目錄結構,多出三個目錄:
- scripts:不分環境會用到的檔案。
- profile_dev:給開發環境用的設定檔。
- profile_production:給正式環境使用的設定檔。
現在有個目標是適當地打包編譯後結果,有幾個可能的打包型式。透過指定 profileDir 參數選擇打包時要包含的檔案:
gradle pack -PprofileDir=profile_dev
gradle pack -PprofileDir=profile_dev
或是設定成不需要指定參數的,改由支援不同的 task 來打包:
gradle pack_dev
gradle pack_production
也可以設計成一個 task 自動依 profile 目錄打包出對應的版本:
gradle pack
上述的型式都是可行的方案,不過實際用起來越簡單越好。因為你馬上會發現,身為作者的自己常被詢問該加什麼參數。至於打包的型式,常見的是 ZIP 檔,如果是 Web 專案那就會是 WAR 檔(其實還是 ZIP 啊),或者輸出一個目錄方便透過 rsync
或 s3sync
之類的檔案工具,僅同步有變更的檔案。明白『需求』後,以自動偵測 profile
目錄來進行打包為目標,增加 pack task
。
在實作上我們可以分別實作 packdev task 與 packproduction task,再用一個 pack 建立相依關係:
task pack(dependsOn: [pack_dev, pack_production])
Gradle 是建立在 DSL 之上的編譯工具,那就能適時地發揮它作為 Programming Language 的威力,我們這回試著寫成 官方文件 Build Script Basics 內提過的 Dynamic tasks:
file(".").eachDir { profile ->
/* 掃瞄目前的目錄,找出名稱含 profile_ 的目錄 */
if (profile.name =~ /profile_/) {
/* 取出 profile_ 後的字作為 task 識別名稱 */
def key = profile.name - "profile_"
/* 建立新的 tack 命名為 pack_ 開頭的 task 名稱 */
task "pack_${key}"(dependsOn:clean, group:'Package', type:Zip) {
/* 指定 ZIP 檔的主檔名 */
baseName = project.name + "_" + key
/* 指定要存入的 ZIP Entries */
into (project.name + '/libs') {
/* 包含被設進 compile 分類的 dependencies */
from configurations.compile
/* 包含 jar task 產生的檔案 */
from jar.outputs.files
}
into (project.name) {
/* 包含 scripts 目錄下的檔案 */
from file("./scripts").listFiles()
/* 包含目前 profile 路徑的檔案 */
from profile.listFiles()
}
}
}
}
/* 建立一個 pack task 相依於其他 pack_ 開頭命名的 task */
task pack(dependsOn: tasks.matching { it.name =~ /pack_/} )
透過動態的方式產生 task,對未來新增環境時的擴充相當容易,在程式有良好實作參數化的情況下,只要加新的 profile_ 目錄就行了,較少有機會需要再改變 Build Script 的設計。
內容回顧
這次的內容提到運用 Gradle 開發 Java 專案常見的客製化情境,現實的情況可能會更加複雜,但掌握專案 Build Script 客製化的幾個管道,就能得心應手:
- 運用既有的 Convention Properties 改變 task 行為
- 運用 configure closure 改變 task 行為
- 實作自己的 task 增加額外的功能
上述三者為簡單、快速的客製化途徑。聰明的讀者可能立馬想到『還有實作 plugin 吧!』,這確實是個途徑之一,不過本質上也是把上述三個基本方式以 plugin 的型式實作,而 plugin 實作得考慮更多的問題,後續會再有獨立的篇幅討論。