網站首頁 編程語言 正文
前言
Gradle自定義Task看起來非常簡單,通過tasks.register等API就可以輕松實現。但實際上為了寫出高效的,可緩存的,不拖慢編譯速度的task,還需要了解更多知識。
本文主要包括以下內容:
- 定義Task
- 查找Task
- 配置Task
- 將參數傳遞給Task構造函數
- Task添加依賴
- Task排序
- Task添加說明
- 跳過Task
- Task支持增量編譯
- Finalizer Task
定義Task
如上所說,自定義Task一般可以通過register API實現
tasks.register("hello") {
doLast {
println("hello")
}
}
tasks.register<Copy>("copy") {
from(file("srcDir"))
into(buildDir)
}
如果是kotlin或者kts中,也可以通過代理來實現
val hello by tasks.registering {
doLast {
println("hello")
}
}
val copy by tasks.registering(Copy::class) {
from(file("srcDir"))
into(buildDir)
}
register與create的區別
除了上面介紹的register,其實create也可以用于創建Task,那么它們有什么區別呢?
- 通過register創建時,只有在這個task被需要時才會真正創建與配置該Task(被需要是指在本次構建中需要執行該Task)
- 通過create創建時,則會立即創建與配置該Task
總得來說,通過register創建Task性能更好,更推薦使用
查找Task
我們有時需要查找Task,比如需要配置或者依賴某個Task,我們可以通過named方法來查找對應名字的task
tasks.register("hello")
tasks.register<Copy>("copy")
println(tasks.named("hello").get().name) // or just 'tasks.hello' if the task was added by a plugin
println(tasks.named<Copy>("copy").get().destinationDir)
也可以使用tasks.withType()方法來查找特定類型的Task
tasks.withType<Tar>().configureEach {
enabled = false
}
tasks.register("test") {
dependsOn(tasks.withType<Copy>())
}
除了上述方法,也可以通過tasks.getByPath()方法來查找task,不過這種方式破壞了configuration avoidance和project isolation,因此不被推薦使用
配置Task
在創建了Task之后,我們常常需要配置Task
我們可以在查找到Task之后進行配置
tasks.named<Copy>("myCopy") {
from("resources")
into("target")
include("**/*.txt", "**/*.xml", "**/*.properties")
}
我們還可以將Task引用存儲在變量中,并用于稍后在腳本中進一步配置任務。
val myCopy by tasks.existing(Copy::class) {
from("resources")
into("target")
}
myCopy {
include("**/*.txt", "**/*.xml", "**/*.properties")
}
我們也可以在定義Task時進行配置,這也是最常用的一種
tasks.register<Copy>("copy") {
from("resources")
into("target")
include("**/*.txt", "**/*.xml", "**/*.properties")
}
將參數傳遞給Task構造函數
除了在Task創建后配置參數,我們也可以將參數傳遞給Task的構建函數,為了實現這點,我們必須使用@Inject注解
abstract class CustomTask @Inject constructor(
private val message: String,
private val number: Int
) : DefaultTask()
然后,我們可以創建一個Task,在參數列表的末尾傳遞構造函數參數。
tasks.register<CustomTask>("myTask", "hello", 42)
需要注意的是,在任何情況下,作為構造函數參數傳遞的值都必須是非空的。如果您嘗試傳遞一個null值,Gradle 將拋出一個NullPointerException指示哪個運行時值是null.
Task添加依賴
有幾種方法可以定義Task的依賴關系,首先我們可以通過名稱定義依賴項
project("project-a") {
tasks.register("taskX") {
dependsOn(":project-b:taskY")
doLast {
println("taskX")
}
}
}
project("project-b") {
tasks.register("taskY") {
doLast {
println("taskY")
}
}
}
其次我們也可以通過Task對象定義依賴項
val taskX by tasks.registering {
doLast {
println("taskX")
}
}
val taskY by tasks.registering {
doLast {
println("taskY")
}
}
taskX {
dependsOn(taskY)
}
還有一些更高端的用法,我們可以用provider懶加載塊來定義依賴項,在evaluated階段,provider被傳遞給正在計算依賴的task
provider塊應返回單個對象Task或Task對象集合,然后將其視為任務的依賴項,如下所示:taskx添加了所有以lib開頭的對象
val taskX by tasks.registering {
doLast {
println("taskX")
}
}
// Using a Gradle Provider
taskX {
dependsOn(provider {
tasks.filter { task -> task.name.startsWith("lib") }
})
}
tasks.register("lib1") {
doLast {
println("lib1")
}
}tasks.register("lib2") {
doLast {
println("lib2")
}
}
tasks.register("notALib") {
doLast {
println("notALib")
}
}
Task排序
有時候,兩個task之間沒有依賴關系,但是對兩個task的執行順序卻有所要求
任務排序和任務依賴之間的主要區別在于,排序規則不會影響將執行哪些任務,只會影響它們的執行順序。
任務排序在許多場景中都很有用:
- 強制執行任務的順序:例如,build 永遠不會在clean 之前運行。
- 在構建的早期運行構建驗證:例如,在開始發布構建工作之前驗證我是否擁有正確的憑據。
- 通過在長時間驗證任務之前運行快速驗證任務來更快地獲得反饋:例如,單元測試應該在集成測試之前運行。
- 聚合特定類型的所有任務的結果的任務:例如測試報告任務組合所有已執行測試任務的輸出。
gradle提供了兩個可用的排序規則:mustRunAfter 和 shouldRunAfter
當您使用mustRunAfter排序規則時,您指定taskB必須始終在taskA之后運行,這表示為taskB.mustRunAfter(taskA)
而shouldRunAfter規則理加弱化,因為在兩種情況下這條規則會被忽略。一是使用這條規則會導致先后順序成環的情況,二是當并行執行task,并且任務的所有依賴關系都已經滿足時,那么無論它的shouldRunAfter依賴關系是否已經運行,這個任務都會運行。
因此您應該在排序有幫助但不是嚴格要求的情況下使用shouldRunAfter
示例如下:
val taskX by tasks.registering {
doLast {
println("taskX")
}
}
val taskY by tasks.registering {
doLast {
println("taskY")
}
}
// mustRunAfter
taskY {
mustRunAfter(taskX)
}
// shouldRunAfter
taskY {
shouldRunAfter(taskX)
}
需要注意的是,B.mustRunAfter(A)或B.shouldRunAfter(A)并不意味著任務之間存在任何執行依賴關系:
我們可以獨立執行A或者任務B。排序規則僅在兩個任務都計劃執行時才有效。
Task添加說明
您可以為Task添加說明。執行時gradle tasks時會顯示此說明。
tasks.register<Copy>("copy") {
description = "Copies the resource directory to the target directory."
from("resources")
into("target")
include("**/*.txt", "**/*.xml", "**/*.properties")
}
跳過Task
gradle提供了多種方式來跳過task的執行
使用onlyIf
你可以通過onlyIf為任務的執行添加條件,如果任務應該執行,則應該返回 true,如果應該跳過任務,則返回 false
val hello by tasks.registering {
doLast {
println("hello world")
}
}
hello {
onlyIf { !project.hasProperty("skipHello") }
}
Output of gradle hello -PskipHello
> gradle hello -PskipHello
> Task :hello SKIPPED?
如上所示,hello任務被跳過了
使用 StopExecutionException
如果跳過任務邏輯不能使用onlyIf實現,您可以使用StopExecutionException。如果某個Action拋出此異常,則跳過該Action的進一步執行以及該任務的任何后續Action的執行。構建繼續執行下一個任務。
val compile by tasks.registering {
doLast {
println("We are doing the compile.")
}
}
compile {
doFirst {
// Here you would put arbitrary conditions in real life.
if (true) {
throw StopExecutionException()
}
}
}
tasks.register("myTask") {
dependsOn(compile)
doLast {
println("I am not affected")
}
}
禁用與啟用Task
每個任務都有一個enabled的標志位,默認為true。將其設置為false可以阻止執行任何Task的執行。禁用的任務將被標記為 SKIPPED。
val disableMe by tasks.registering {
doLast {
println("This should not be printed if the task is disabled.")
}
}
disableMe {
enabled = false
}
Task超時
每個Task都有一個timeout屬性,可用于限制其執行時間。當一個任務達到它的超時時間時,它的任務執行線程被中斷。該任務將被標記為失敗。但是Finalizer Task任務仍將運行。
如果構建時使用了--continue參數,其他任務可以在它之后繼續運行。不響應中斷的task不能超時。Gradle 的所有內置task都會及時響應超時
Task支持增量編譯
任何構建工具的一個重要部分是避免重復工作。在編譯過程中,就是在編譯源文件后,除非發生了影響輸出的更改(例如源文件的修改或輸出文件的刪除),無需重新編譯它們。因為編譯可能會花費大量時間,因此在不需要時跳過該步驟可以節省大量時間。
Gradle 支持增量構建,當您運行構建時,有些Task被標記為UP-TO-DATE,這就是增量編譯生效了
那么Gradle增量編譯如何工作?自定義Task如何支持增量編譯?我們一起來看看
Task的輸入輸出
Task最基本的功能就是接受一些輸入,進行一系列運算后生成輸出。比如在編譯過程中,Java源文件是輸入,生成的classes文件是輸出。其他輸入可能包括諸如是否應包含調試信息之類的內容。
task輸入的一個重要特征是它會影響一個或多個輸出,從上圖中可以看出。根據源文件的內容和target jdk版本,會生成不同的字節碼。這使他們成為task輸入。
但是編譯期的一些其他屬性,比如編譯最大可用內存,由memoryMaximumSize屬性決定,memoryMaximumSize對生成的字節碼沒有影響。因此,memoryMaximumSize不是task輸入,它只是一個內部task屬性。
作為增量構建的一部分,Gradle 會檢查自上次構建以來是task的輸入或輸出有沒有發生變化。如果沒有,Gradle 可以認為task是最新的,因此跳過執行其action。需要注意的是,除非task至少有一個task輸出,否則增量構建將不起作用
總得來說:
您需要告訴 Gradle 哪些task屬性是輸入,哪些是輸出。
如果task屬性影響輸出,請務必將其注冊為輸入,否則該任務將被認為是最新的而不是最新的。
相反,如果屬性不影響輸出,則不要將其注冊為輸入,否則任務可能會在不需要時執行。
還要注意可能為完全相同的輸入生成不同輸出的非確定性task:不應將這些任務配置為增量構建,因為最新檢查將不起作用。
接下來讓我們看看如何將task屬性注冊為輸入和輸出。
自定義task類型
為了讓自定義task支持增量編譯,只需要以下兩個步驟
- 為每個task輸入和輸出創建類型化屬性(通過 getter 方法)
- 為每個屬性添加適當的注解
Gradle 支持四種主要的輸入和輸出類型:
- 簡單值
例如字符串和數字類型。更一般地說,任何一個實現了Serializable的類型。 - 文件系統類型
包括RegularFile,Directory和標準File類,也包括 Gradle 的FileCollection類型的派生類,以及任何可以被Project.file(java.lang.Object)和Project.files(java.lang.Object...)方法接收的參數 - 依賴解析結果
這包括包含Artifact元數據的ResolvedArtifactResult類型和包含依賴圖的ResolvedComponentResult類型。請注意,它們僅支持包裝在Provider中. - 包裝類型
不符合其他幾個類型但具有自己的輸入或輸出屬性的自定義類型。task的輸入或輸出包裝在這些自定義類型中。
接下來我們看個例子
假設您有一個task處理不同類型的模板,例如 FreeMarker、Velocity、Moustache 等。它獲取模板源文件并將它們與一些模型數據結合以生成不同結果。
此任務將具有三個輸入和一個輸出:
- 模板源文件
- 模型數據
- 模板引擎
- 輸出文件的寫入位置
在編寫自定義task類時,我們很容易通過注解將屬性注冊為輸入或輸出
public abstract class ProcessTemplates extends DefaultTask {
@Input
public abstract Property<TemplateEngineType> getTemplateEngine();
@InputFiles
public abstract ConfigurableFileCollection getSourceFiles();
@Nested
public abstract TemplateData getTemplateData();
@OutputDirectory
public abstract DirectoryProperty getOutputDir();
@TaskAction
public void processTemplates() {
// ...
}
}
public abstract class TemplateData {
@Input
public abstract Property<String> getName();
@Input
public abstract MapProperty<String, String> getVariables();
}
可以看出,我們定義了3個輸入,一個輸出
- templateEngine,表示使用什么模板引擎,我們傳入一個枚舉類型,枚舉類型都實現了Serializable,因此可作為輸入
- sourceFiles,表示源文件,我們傳入FileCollection作為輸入
- templateData,表示模型數據,自定義類型,在它的內部包裝了真正的輸入,通過@Nested注解表示
- outputDir,表示輸出目錄,表示單個目錄的屬性需要@OutputDirectory注解
當我們重復運行以上task之后,就可以看到以下輸出
> gradle processTemplates
> Task :processTemplates UP-TO-DATE
BUILD SUCCESSFUL in 0s
3 actionable tasks: 3 up-to-date
如上所示,task在執行過程中會判斷輸入輸出有沒有發生變化,由于task的輸入輸出都沒有發生變化,該task可以直接跳過,展示為up-to-date
除了上述幾種注解,還有其他常用注解如@Internal,@Optional,@Classpath等,具體可查看文檔:Incremental build property type annotations
聲明輸入輸出的好處
一旦你聲明了一個task的正式輸入和輸出,Gradle 就可以推斷出關于這些屬性的一些事情。例如,如果一個task的輸入設置為另一個task的輸出,這意味著第一個task依賴于第二個,gradle可以推斷出這一點并添加隱式依賴
推斷task依賴關系
想象一個歸檔task,會將processTemplates task的輸出歸檔。可以看到歸檔task顯然需要processTemplates首先運行,因此可能會添加顯式的dependsOn. 但是,如果您像這樣定義歸檔task:
tasks.register<Zip>("packageFiles") {
from(processTemplates.map {it.outputs })
}
Gradle 會自動使packageFiles依賴processTemplates。它可以這樣做是因為它知道 packageFiles 的輸入之一需要 processTemplates 任務的輸出。我們稱之為推斷的task依賴。
上面的例子也可以寫成
tasks.register<Zip>("packageFiles2") {
from(processTemplates)
}
這是因為from()方法可以接受task對象作為參數。然后在幕后,from()使用project.files()方法包裝參數,進而將task的正式輸出轉化為文件集合
輸入和輸出驗證
增量構建注解為 Gradle 提供了足夠的信息來對帶注解的屬性執行一些基本驗證。它會在task執行之前對每個屬性執行以下操作:
- @InputFile- 驗證屬性是否有值,并且路徑是否對應于存在的文件(不是目錄)。
- @InputDirectory- 與@InputFile相同,但路徑必須對應于目錄。
- @OutputDirectory- 驗證路徑是否是個目錄,如果該目錄尚不存在,則創建該目錄。
如果一個task在某個位置產生輸出,而另一個任務task將其作為輸入使用,則 Gradle 會檢查消費者任務是否依賴于生產者任務。當生產者和消費者任務同時執行時,構建就會失敗。
此類驗證提高了構建的穩健性,使您能夠快速識別與輸入和輸出相關的問題。
您偶爾會想要禁用某些驗證,特別是當輸入文件可能實際上不存在時。這就是 Gradle 提供@Optional注釋的原因:您使用它來告訴 Gradle 特定輸入是可選的,因此如果相應的文件或目錄不存在,則構建不應失敗。
并行task
定義task輸入和輸出的另一個好處是:當使用--parallel選項時,Gradle 可以使用此信息來決定如何運行task。
例如,Gradle 將在選擇下一個要運行的任務時檢查task的輸出,并避免并發執行寫入同一輸出目錄的任務。
同樣,當另一個task正在運行消耗或創建一些文件時,Gradle 將使用有關task銷毀哪些文件的信息(例如,由Destroys注釋)來避免運行刪除這些文件的task,反之亦然。
它還可以確定創建一組文件的task已經運行,并且使用這些文件的task尚未運行,并且將避免在這中間運行刪除這些文件的task。
總得來說,通過以這種方式提供task的輸入和輸出信息,Gradle 可以推斷task之間的創建/消費/銷毀關系,并可以確保task執行不會違反這些關系。
增量編譯原理解析
上面我們介紹了如何自定義一個支持增量編譯的task,那么它的原理是什么呢?
在第一次執行task之前,Gradle 會獲取輸入的指紋。該指紋包含輸入文件的路徑和每個文件內容的哈希值。Gradle 然后執行task。如果任務成功完成,Gradle 會獲取輸出的指紋。該指紋包含一組輸出文件和每個文件內容的哈希值。Gradle 會在下次執行task時保留兩個指紋。
之后每次執行task之前,Gradle 都會獲取輸入和輸出的新指紋。如果新指紋與之前的指紋相同,Gradle 會假定輸出是最新的并跳過該task。如果它們不相同,Gradle 將執行task。Gradle 會在下次執行task時保留兩個指紋。
如果文件的統計信息(即lastModified和size)沒有改變,Gradle 將重用上次運行的文件指紋。這意味著當文件的統計信息沒有更改時,Gradle 不會檢測到更改。
Gradle 還將task的代碼視為task輸入的一部分。當task、其操作或其依賴項在執行之間發生變化時,Gradle 會認為該task已過期。
Gradle 了解文件屬性(例如,包含 Java classpath 的屬性)是否是順序敏感的。在比較此類屬性的指紋時,即使文件順序發生變化也會導致task過時。
請注意,如果task指定了輸出目錄,則自上次執行以來添加到該目錄的任何文件都將被忽略,并且不會導致任務過期。這是因為不相關的任務可以共享一個輸出目錄而不會相互干擾。如果由于某種原因這不是您想要的行為,請考慮使用TaskOutputs.upToDateWhen(groovy.lang.Closure)
一些高端操作
上面介紹的內容涵蓋了您將遇到的大多數用例,但有些場景需要特殊處理
將@OutputDirectory鏈接到@InputFiles
當您想將一個task的輸出鏈接到另一個task的輸入時,類型通常匹配,例如,File可以將輸出屬性分配給File輸入。
不幸的是,當您希望將一個task的@OutputDirectory中的文件作為另一個task的@InputFiles屬性(類型FileCollection)的源時,這種方法就會失效。
例如,假設您想使用 Java 編譯task的輸出(通過destinationDir屬性)作為自定義task的輸入,該task檢測一組包含 Java 字節碼的文件。這個自定義task,我們稱之為Instrument,有一個使用@InputFiles注解的classFiles屬性。您最初可能會嘗試像這樣配置task:
tasks.register<Instrument>("badInstrumentClasses") {
classFiles.from(fileTree(tasks.compileJava.map { it.destinationDir }))
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}
這段代碼沒有明顯的問題,但是您如果實際運行的話可以看到compileJava并沒有執行。在這種情況下,您需要通過dependsOn在instrumentClasses和compileJava之間添加顯式依賴。因為使用fileTree()意味著 Gradle 無法推斷task依賴本身。
一種解決方案是使用TaskOutputs.files屬性,如以下示例所示:
tasks.register<Instrument>("instrumentClasses") {
classFiles.from(tasks.compileJava.map { it.outputs.files })
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}
或者,您可以使用project.files(),project.layout.files(),project.objects.fileCollection()來代替project.fileTree()
tasks.register<Instrument>("instrumentClasses2") {
classFiles.from(layout.files(tasks.compileJava))
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}
請記住files(),layout.files()和objects.fileCollection()可以將task作為參數,而fileTree()不能。
這種方法的缺點是源task的所有文件輸出都成為目標task的輸入文件。如果源task只有一個基于文件的輸出,就像JavaCompile一樣,那很好。但是如果你必須在多個輸出屬性中選擇一個,那么你需要明確告訴 Gradle 哪個task使用以下builtBy方法生成輸入文件:
tasks.register<Instrument>("instrumentClassesBuiltBy") {
classFiles.from(fileTree(tasks.compileJava.map { it.destinationDir }) {
builtBy(tasks.compileJava)
})
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
}
當然你也可以通過dependsOn添加明確的task依賴,但是上面的方法提供了更多的語義,解釋了為什么compileJava必須預先運行。
禁用up-to-date檢查
Gradle 會自動處理對輸出文件和目錄的up-to-date檢查,但如果task輸出完全是另一回事呢?也許它是對 Web 服務或數據庫表的更新。或者有時你有一個應該始終運行的task。
這就是doNotTrackState的作用,可以使用它來完全禁用task的up-to-date檢查,如下所示:
tasks.register<Instrument>("alwaysInstrumentClasses") {
classFiles.from(layout.files(tasks.compileJava))
destinationDir.set(file(layout.buildDirectory.dir("instrumented")))
doNotTrackState("Instrumentation needs to re-run every time")
}
如果你的自定義task需要始終運行,那么您也可以在任務類上使用注解@UntrackedTaskTask
提供自定義up-to-date檢查
Gradle 會自動處理對輸出文件和目錄的up-to-date檢查,但如果task輸出是對 Web 服務或數據庫表的更新。在這種情況下,Gradle 無法知道如何檢查task是否是up-to-date的。
這是就是TaskOutputs.upToDateWhen方法的作用,使用它我們就可以自定義up-to-date檢查的邏輯。例如,您可以從數據庫中讀取數據庫模式的版本號。或者,您可以檢查數據庫表中的特定記錄是否存在或已更改。
請注意,up-to-date檢查應該節省您的時間。不要添加比task的標準執行花費更多時間的檢查。事實上,如果一個task經常需要運行,因為它很少是up-to-date的,那么它可能根本不值得進行up-to-date檢查,如禁用up-to-date中所述。
一個常見的錯誤是使用upToDateWhen()而不是Task.onlyIf(). 如果您想根據與task輸入和輸出無關的某些條件跳過任務,那么您應該使用onlyIf(). 例如,如果您想在設置或未設置特定屬性時跳過task
Finalizer Task
我們常常使用dependsOn來在一個task之前做一些工作,但如果我們想要在task執行之后做一些操作,該怎么實現呢?
這里我們可以用到finalizedBy方法
val taskX by tasks.registering {
doLast {
println("taskX")
}
}
val taskY by tasks.registering {
doLast {
println("taskY")
}
}
taskX { finalizedBy(taskY) }
如上所示,taskY將在taskX之后執行,需要注意的是finalizedBy并不是依賴關系,就算taskX執行失敗,taskY也將正常執行
Finalizer task在構建創建的資源無論構建失敗還是成功都必須清理的情況下很有用,一個示例是在集成測試任務中啟動的 Web 容器,即使某些測試失敗,也應該始終關閉它。這樣看來finalizedBy類似java中的finally
要指定Finalizer task,請使用Task.finalizedBy(java.lang.Object...?)方法。此方法接受task實例、task名稱或Task.dependsOn(java.lang.Object...?)接受的任何其他輸入
總結
到這里這篇文章已經相當長了,gradle自定義task上手非常簡單,但實際上有非常多的細節,尤其是要支持增量編譯時。總得來說,為了寫出高效的,可緩存的,不拖慢編譯速度的task,還是有必要了解一下這些知識的
參考資料
docs.gradle.org/current/use…
原文鏈接:https://juejin.cn/post/7135065142768697380
相關推薦
- 2022-06-01 Python實現訪問者模式詳情_python
- 2022-03-14 @ConfigurationProperties獲取參數值
- 2022-07-13 rsync下行同步+inotify實時同步部署
- 2023-05-06 Go語言中Slice常見陷阱與避免方法詳解_Golang
- 2023-01-09 Spark處理trick總結分析_相關技巧
- 2022-05-09 pytorch中的hook機制register_forward_hook_python
- 2022-06-02 python?面向對象開發及基本特征_python
- 2024-01-28 springboot登錄認證JWT令牌
- 最近更新
-
- window11 系統安裝 yarn
- 超詳細win安裝深度學習環境2025年最新版(
- Linux 中運行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎操作-- 運算符,流程控制 Flo
- 1. Int 和Integer 的區別,Jav
- spring @retryable不生效的一種
- Spring Security之認證信息的處理
- Spring Security之認證過濾器
- Spring Security概述快速入門
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權
- redisson分布式鎖中waittime的設
- maven:解決release錯誤:Artif
- restTemplate使用總結
- Spring Security之安全異常處理
- MybatisPlus優雅實現加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務發現-Nac
- Spring Security之基于HttpR
- Redis 底層數據結構-簡單動態字符串(SD
- arthas操作spring被代理目標對象命令
- Spring中的單例模式應用詳解
- 聊聊消息隊列,發送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠程分支