【認識 Gradle】(6)Java 專案與 Build Script 客製化

- gradle-series

前二篇是針對 Gradle Script 識讀的概念進行較細節的介紹。Gradle 的生態圈是以 Java 語言為主的專案,例如 Java 應用程式、Java Web 專案或 Android 專案較為常見。

許多的 Open Source 專案也由 Maven 轉移至 Gradle。由學習使用 Gradle 輔助 Java 專案的開發是個很好上手的起點,使用 Gradle 建置 Java 專案可參考官手冊:

  1. Chapter 7. Java Quickstart
  2. Chapter 23. The Java Plugin

相信有在 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

所以,我們可以知道:

  1. sourceSets 是 SourceSetContainer
  2. main 是 SourceSet
  3. 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

新的目錄結構,多出三個目錄:

  1. scripts:不分環境會用到的檔案。
  2. profile_dev:給開發環境用的設定檔。
  3. 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 啊),或者輸出一個目錄方便透過 rsyncs3sync 之類的檔案工具,僅同步有變更的檔案。明白『需求』後,以自動偵測 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 客製化的幾個管道,就能得心應手:

  1. 運用既有的 Convention Properties 改變 task 行為
  2. 運用 configure closure 改變 task 行為
  3. 實作自己的 task 增加額外的功能

上述三者為簡單、快速的客製化途徑。聰明的讀者可能立馬想到『還有實作 plugin 吧!』,這確實是個途徑之一,不過本質上也是把上述三個基本方式以 plugin 的型式實作,而 plugin 實作得考慮更多的問題,後續會再有獨立的篇幅討論。