[Gradle翻译]如何在Gradle中使用复合构建作为buildSrc的替代?

1,191 阅读13分钟

本文由 简悦SimpRead 转码,原文地址 medium.com

Gradle buildSrc方法有一个主要缺陷--它在任何变化中都会使构建缓存失效。在本文中......

image.png

Gradle buildSrc的方法已经成为实现自定义插件、任务和指定常见配置(如依赖关系列表和版本)的标准,但它有一个重大缺陷--当构建缓存被改变时,它就会失效。另一方面,Gradle也提供了一种替代性的复合构建方法,缺乏这个缺陷。在这篇文章中,我描述了如何使用复合构建而不是buildSrc,以及从迁移中可以预期的挑战。

我对Gradle配置的经验

Gradle构建系统是和Android Studio一起引入Android开发的。Android Studio并没有一直被使用。如果你已经为Android开发了6年多的应用程序,你可能还记得带有Android插件的Eclipse。如今这个插件已经被废弃和不支持

Gradle和Groovy在当时绝对是个神奇的东西(实际上,它们现在仍然是),我只是从StackOverflow上复制粘贴了所有东西。因为应用程序很少有一个以上的模块,所以把所有东西都放在一个build.gradle文件中是绝对没问题的。

我开始使用外部库的模块,每当我想修复或采用它们里面的东西时。我只是把所有的魔法从应用程序的build.gradle复制粘贴到库的模块中。这很好,除了一切都需要重复,例如更新所有模块的依赖版本。后来我发现,你可以使用根build.gradle来定义模块的共同配置。

// projectRoot/build.gradle

public void configureAndroid(Project project) {
  project.android {
    compileSdkVersion 9
  }
}
// projectRoot/app/build.gradle

configureAndroid(this)

android {
  // Module specific configuration
}

这感觉好多了,但还是不完美。根build.gradle变得太大,维护起来很复杂。在模块化开始流行的时候,我们把我们的应用程序分成datacoredomainpresentation等模块,我发现了一个不同的方法:我们可以简单的把这些功能提取到单独的Gradle脚本中,然后应用它们。

// projectRoot/android.gradle

project.android {
  compileSdkVersion 9
}
// projectRoot/app/build.gradle

apply from: '../android.gradle'

这个方案缺乏自动完成的功能,你甚至无法修复它。在build.gradle方法中,至少你可以通过使用plugins { }块来修复缺失的自动完成,但这个块在除了build.gradle之外的所有其他脚本文件中是不可用的。

而现在,多年以后,很多开发者,包括我在内,都在使用buildSrc来管理常用配置。经过这么多年的痛苦,在我们的项目中使用buildSrc是一种幸福。你可以使用任何JVM语言,你有完整的自动完成和IDE支持,你甚至可以写测试:用JUnit或任何其他框架的单元测试,集成测试实际上是启动一个单独的Gradle实例,并提供测试环境。难道说,我们终于找到了Gradle配置的圣杯?

buildSrc的缺点

不幸的是,所有这些很酷的功能都有一个巨大的缺点。在buildSrc中的任何改变都会使构建缓存失效。在你使用远程构建缓存的情况下,它也会使其失效。虽然这对小项目来说不是什么问题,但对有上百个模块的大项目来说影响就很大了。另一方面,Gradle脚本文件的变化并不会使缓存失效,而只是使一些任务失效。

image.png

想象一下以下的可缓存任务链:compile(Java插件)->report(我们的自定义任务)。compile任务有一个JavaCompile的类型,我们从内置的Java插件中接收。report是我们的自定义任务,可以通过两种不同的方式定义:在buildSrcbuild.gradle内。现在我们要在report任务类中做任何影响字节码的改变。使用buildSrc方法,即使compile类、输入和输出没有改变,compile'和report'任务都会被再次执行。使用build.gradle方法,只有report任务会被再次执行。compile的输入、输出和字节码都没有被改变,所以结果可以从缓存中获取。Gradle无法验证report任务是否会产生相同的输出,这就是为什么它被再次启动而忽略了构建缓存。那么,我们怎样才能解决这个问题呢?我们绝对不想为了保持构建速度而回到过去,失去所有buildSrc的花哨、酷的功能。

复合构建

一般来说,复合构建是包括不同根项目的构建。如果你有一个简单的多模块项目,那么所有的子项目共享一个由根build.gradle定义的共同配置。但是有一种方法可以在不影响项目的情况下增加另一个项目,这就是使用一个自定义的配置,并将其隔离构建。用这种方式构建外部库,以及项目中完全独立的部分,可能会很有用,但它也可以用于Gradle插件。你可以从你的主项目中包含的构建中通过id引用插件。

如果我们从buildSrc文件夹中提取我们的配置逻辑,那么包含的构建中的类就不会作为buildSrc的一部分受到威胁,Gradle也不会在每次改变时都使构建缓存失效。这是因为配置逻辑是作为外部依赖提供给主项目的(与其他插件相同,例如Android Gradle插件)。这意味着Gradle现在可以正确验证任务的输入和输出,并可以使用构建缓存。

需要补充的是,以下变化只影响build cache。构建缓存包含任务的序列化输出,键值由输入和classpath定义。当任务结果从缓存中取出时,你会看到任务的FROM-CACHE状态。Incremental builds不受这一变化的影响,无论如何任务都会被废止。在启动一个任务之前,Gradle可以检查任务的输入和输出是否在上次运行后被改变。如果没有,你会看到任务的UP-TO-DATE状态。

从buildSrc迁移到复合构建

现在,我将展示我们的Reaktive库向复合构建的迁移过程。这个项目是一个非常好的例子,原因如下。

事实上,我们有第一部分中描述的所有3种方法。在这里,我将展示如何处理它们中的每一个,并将它们转换为独立的插件。

复制

第一步很简单。让我们把我们的buildSrc文件夹复制到buildSrc2文件夹。如果你在buildSrc文件夹中没有使用插件,那么现在是一个开始的好时机。如果没有任何插件,你的新模块的类将不会被加载到构建脚本的classpath中。先不要删除你原来的 buildSrc。如果你这样做,你将无法同步项目。为了通知Gradle包含的新模块,我们在settings.gradle中添加以下内容。

// projectRoot/settings.gradle.kts

pluginManagement {
    repositories {
        google()
        jcenter()
        gradlePluginPortal()
    }
}

includeBuild("buildSrc2")

// include(":module")

添加第一行是为了方便我们的工作,并删除buildscript { repositories { } }块。通过使用includeBuild函数,我们告诉Gradle将buildSrc2文件夹内的项目视为包含式构建。这个项目将被按需构建。

插件迁移

那么,我们如何使用它呢?首先,让我们添加我们的插件定义。

// projectRoot/buildSrc2/build.gradle.kts

plugins {
    `kotlin-dsl`
    `java-gradle-plugin`
}

gradlePlugin {
    // Add fake plugin, if you don't have any
    plugins.register("class-loader-plugin") {
        id = "class-loader-plugin"
        implementationClass = "com.example.ClassLoaderPlugin"
    }
    // Or provide your implemented plugins
}

java-gradle-plugin将为插件创建相应的properties文件,这样你就可以删除它们。你可以阅读更多关于java-gradle-plugin的信息这里

如果你还没有任何插件,只是使用buildSrc进行依赖管理,你需要创建一个空的假插件,并将其应用到项目中,使类在构建脚本中可用。

class ClassLoaderPlugin: Plugin<Project> {
    override fun apply(target: Project) {
        // no-op
    }
}

object Deps {
    const val kotlinStdLib = "..."
}

一旦你把class-loader-plugin应用到项目中,Deps类就可用了。而自动完成将以同样的方式工作,和以前一样。

// projectRoot/app/build.gradle

plugins {
    id 'class-loader-plugin'
}

dependencies {
    implementation(Deps.kotlinStdLib)
}

常用函数迁移

build.gradle里面,我们有setupMultiplatformLibrary函数。

// projectRoot/build.gradle

void setupMultiplatformLibrary(Project project) {
    project.apply plugin: 'org.jetbrains.kotlin.multiplatform'
    project.kotlin {
        sourceSets {
            commonMain {
                dependencies {
                    implementation Deps.kotlin.stdlib.common
                }
            }

            commonTest {
                dependencies {
                    implementation Deps.kotlin.test.common
                    implementation Deps.kotlin.test.annotationsCommon
                }
            }
        }
    }
}

这个函数为所有模块定义了一些通用的配置。我们应用了Kotlin多平台插件并声明了一些基本的依赖关系。

为了将这个函数转换为Gradle插件,我们需要指定对Gradle插件的依赖,并创建我们的自定义插件,做同样的设置。

// projectRoot/buildSrc2/build.gradle.kts

dependencies {
    // Add dependency on Kotlin Gradle plugin to apply plugin and use its classes
    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72")
}
gradlePlugin {
    // Register our plugin
    plugins.register("mpp-configuration") {
        id = "mpp-configuration"
        implementationClass = "com.badoo.reaktive.configuration.MppConfigurationPlugin"
    }
}
// MppConfigurationPlugin.kt

class MppConfigurationPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        // Every plugin can register extensions like "kotlin" or "android"
        target.extensions.create("configuration", MppConfigurationExtension::class.java, target)
        // Invoke common function to apply the same configuration as before
        setupMultiplatformLibrary(target)
    }

    private fun setupMultiplatformLibrary(target: Project) {
        // project.apply plugin: 'org.jetbrains.kotlin.multiplatform'
        target.apply(plugin = "org.jetbrains.kotlin.multiplatform")
        // project.kotlin {
        target.extensions.configure(KotlinMultiplatformExtension::class.java) {
            sourceSets {
                maybeCreate("commonMain").dependencies { implementation(Deps.kotlin.stdlib.common) }
                maybeCreate("commonTest").dependencies {
                    implementation(Deps.kotlin.test.common)
                    implementation(Deps.kotlin.test.annotationsCommon)
                }
            }
        }
    }
}

此外,我们对setupAllTargetsWithDefaultSourceSets的设置进行了参数化,增加了isLinuxArm32HfpEnabled参数。Kotlin coroutines不支持linuxArm32Hfp目标,但我们支持。这就是为什么我们应该避免仅仅为coroutines互操作性库配置这个目标。要做到这一点,我们可以过滤掉project.name,但我们不想硬编码。相反,我们可以像其他插件一样用扩展的方法来实现它。

// MppConfigurationExtension.kt

open class MppConfigurationExtension @Inject constructor(
    private val project: Project
) {
    var isLinuxArm32HfpEnabled: Boolean = false
        private set

    // We will call this function from build.gradle if we need to enable arm32 compilation
    fun enableLinuxArm32Hfp() {
        if (isLinuxArm32HfpEnabled) return
        project.plugins.findPlugin(MppConfigurationPlugin::class.java)?.setupLinuxArm32HfpTarget(project)
        isLinuxArm32HfpEnabled = true
    }
}
// MppConfigurationPlugin.kt

class MppConfigurationPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        target.extensions.create("configuration", MppConfigurationExtension::class.java, target)
        ...
    }

    fun setupLinuxArm32HfpTarget(project: Project) {
        if (!Target.shouldDefineTarget(project, Target.LINUX)) return
        project.kotlin {
            linuxArm32Hfp()
            sourceSets {
                maybeCreate("linuxArm32HfpMain").dependsOn(getByName("linuxCommonMain"))
                maybeCreate("linuxArm32HfpTest").dependsOn(getByName("linuxCommonTest"))
            }
        }
    }
}

不幸的是,我们不能在这里使用选择退出系统(disableLinuxArm32Hfp()),因为Kotlin Gradle插件不处理目标删除,只处理目标添加。然而,我们可以在mpp-configuration插件及其configuration扩展的帮助下应用配置。

// projectRoot/reaktive/build.gradle

plugins {
    id 'mpp-configuration'
}

// Optionaly
configuration {
    enableLinuxArm32Hfp()
}

而且自动完成的工作也是预期的。

image.png

外部脚本文件迁移

我们使用二进制兼容验证器插件,我在下面的文章中介绍过。它的配置在binrary-compatibility.gradle中定义,并应用于根build.gradle。基本上,它所做的就是应用插件和配置忽略的模块。


// projectRoot/binary-compatibility.gradle

if (Target.shouldDefineTarget(target, Target.ALL_LINUX_HOSTED)) {
    apply plugin: kotlinx.validation.BinaryCompatibilityValidatorPlugin

    apiValidation {
        ignoredProjects += [
                'benchmarks',
                'jmh',
                'sample-mpp-module',
                'sample-android-app',
                'sample-js-browser-app',
                'sample-linuxx64-app',
                'sample-ios-app',
                'sample-macos-app'
        ]
    }
}

而我们可以简单地使用前面描述的方法将下面的构建脚本转换成一个插件。

// projectRoot/buildSrc2/build.gradle.kts

dependencies {
    // Add a dependency on Binary Compatibility plugin to apply plugin and use its classes
    implementation("org.jetbrains.kotlinx:binary-compatibility-validator:0.2.3")
}
gradlePlugin {
    // Register our own plugin
    plugins.register("binary-compatibility-configuration") {
        id = "binary-compatibility-configuration"
        implementationClass = "com.badoo.reaktive.compatibility.BinaryCompatibilityConfigurationPlugin"
    }
}
class BinaryCompatibilityConfigurationPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        if (Target.shouldDefineTarget(target, Target.ALL_LINUX_HOSTED)) {
            target.apply(plugin = "binary-compatibility-validator")
            target.extensions.configure(ApiValidationExtension::class) {
                ignoredProjects.addAll(
                    listOf(
                        "benchmarks",
                        "jmh",
                        "sample-mpp-module",
                        "sample-android-app",
                        "sample-js-browser-app",
                        "sample-linuxx64-app",
                        "sample-ios-app",
                        "sample-macos-app"
                    )
                )
            }
        }
    }
}

接下来,我们可以在根build.gradle内应用我们的新插件。

// projectRoot/build.gradle

plugins {
    id 'binary-compatibility-configuration'
}

依赖性

使用这种新方法,我们需要在主项目和包含的构建中进行依赖性管理。在包含的构建中,我们使用不同的插件作为依赖,以确保对相关类的访问,如项目扩展、任务等。为此,我们将创建额外的内含构建,以便管理依赖关系。

// rootProject/dependencies/build.gradle.kts

plugins {
    `kotlin-dsl`
    `java-gradle-plugin`
}

// We set up group and version to make this project available as dependency artifact
group = "com.badoo.reaktive.dependencies"
version = "SNAPSHOT"

repositories {
    jcenter()
}

gradlePlugin {
    // Create fake plugin to load classes into build.gradle
    plugins.register("dependencies") {
        id = "dependencies"
        implementationClass = "com.badoo.reaktive.dependencies.DependenciesPlugin"
    }
}

现在我们可以为所有的外部依赖创建一个Deps类。你可以检查实现这里。使用includeBuild("dependencies")settings.gradle中添加一个新模块。现在,dependencies插件和Deps类可以在任何项目中使用。

// projectRoot/buildSrc2/build.gradle.kts

import com.badoo.reaktive.dependencies.Deps

plugins {
    `kotlin-dsl`
    `java-gradle-plugin`
    id("dependencies")
}

dependencies {
    // Finally we can use Deps here
    implementation(Deps.kotlin.plugin)
    // implementation(implementation("com.badoo.reaktive.dependencies:dependencies:SNAPSHOT"))
}

// Almost same as 'implementation("com.badoo.reaktive.dependencies:dependencies:SNAPSHOT")', but will make autocomplete work
kotlin.sourceSets.getByName("main").kotlin.srcDir("../dependencies/src/main/kotlin")

在实施这种方法时,我发现如果我在另一个包含的构建中使用包含的构建作为依赖,IDEA不会在编辑器中解析它的类(尽管它的编译仍然没有错误)。为了解决这个问题,我在 "buildSrc2 "的编译中手动包含了 "Deps "类,这样,无论 "buildSrc2 "上的任何插件被应用,它都可以使用。这是一个相当肮脏和不稳定的黑客行为,但我希望在未来的版本中的修复将使这种错误行为成为不必要的。一旦修复,使用常规的implementation("com.badoo.reaktive.dependencies:dependencies:SNAPSHOT")符号就可以了。

dependencies插件可以在主项目模块中使用,方法与上面所述相同。

缺点

使用复合构建而不是buildSrc的唯一区别是没有相应插件的类的可用性。

真正的区别出现在我们使用plugins { }块而不是apply plugin: 'id'或直接调用配置函数。使用plugins块的主要优点是在Groovy脚本中自动完成。你将可以访问与特定插件和扩展相关的类。但你不能完全确定,在你应用插件的那一刻,扩展已经完全配置好了。例如,假设你有以下的设置。

apply plugin: 'android-library'

android {
    compileSdkVersion 30
}

apply plugin: 'custom-plugin'
class CustomPlugin: Plugin<Project> {
    override fun apply(target: Project) {
        target.logger.warn(
            target.extentions.getByType(BaseExtension::class)
                .compileSdkVersion.toString()
        )
    }
}

自定义插件在配置阶段只会在控制台中打印compileSdkVersion。在这个实现中,它将是30。现在让我们把它迁移到使用plugins { }块。

plugins {
    id 'android-library'
    id 'custom-plugin'
}

android {
    compileSdkVersion 30
}

在这种情况下,你会在控制台输出中看到null,因为custom-plugin是在android配置之前应用的。有几种方法可以解决这个问题。

  1. 使用apply plugin: 'custom-plugin'或静态函数来设置构建。如果难以迁移,继续使用也是可以的。你将失去的唯一东西是Groovy脚本中的自动完成支持。另外,记住你需要应用一个假的插件来加载相应的类。

  2. 在插件内使用project.afterEvaluate { }块。但要小心:如果你过分滥用它,你的afterEvaluate块将开始依赖其他afterEvaluate块和它们的执行顺序。

  3. 尝试通过使用lazy APItasks和其他机制来转换插件内的逻辑。这需要对Gradle API有很好的了解,但这样你就可以创建可重复使用的独立插件。

复合构建可以作为buildSrc的替代品,以避免Gradle缓存的失效。使用建议的方法进行迁移既直接又无痛苦。包含的构建可以使用来自其他包含的构建的插件,并相互依赖。为了在你的Groovy脚本中看到自动完成的全部功能,如果你愿意,可以使用plugins { }块。当你没有插件时,只需创建一个空的假插件并应用它来加载同一模块的类。


www.deepl.com 翻译