为忙碌的工程师开发 Gradle 插件

852 阅读2分钟

测试工具

如果插件依赖于 Android Gradle Plugin (AGP)(或任何第三方生态系统插件),应该强烈考虑将其声明为 compileOnly。

你不知道你的用户会做什么,所以你应该假设他们会做任何事情。想象一下,如果一个插件可以默默地改变你的构建所针对的 Gradle 版本。它(几乎)那么糟糕!

虽然我认为上述解释足以证明 compileOnly 建议的合理性,但我也可以用实际问题证明它的合理性。

这是探索我们的问题空间的规范的第一次迭代:

class AndroidSpec extends Specification {

  @AutoCleanup
  AbstractProject project

  def "gets the expected version of AGP on the classpath (#gradleVersion AGP #agpVersion)"() {
    given: 'An Android Gradle project'
    project = new AndroidProject(agpVersion)

    when: 'We check the version of AGP on the classpath'
    def result = Builder.build(
      gradleVersion,
      project, 
      'lib:which', '-e', 'android'
    )

    // The output will contain a line like this:
    // jar for 'android': file:/path/to/gradle-all-the-way-down/plugin/build/tmp/functionalTest/work/.gradle-test-kit/caches/jars-9/f19c6db5e8f27caa4113e88608762369/gradle-4.2.2.jar
    then: 'It matches what the project provides, not what the plugin compiles against'
    def androidJar = result.output.split('\n').find {
      it.startsWith("jar for 'android'")
    }
    assertThat(androidJar).endsWith("gradle-${agpVersion}.jar")

    where:
    [gradleVersion, agpVersion] << gradleAgpCombinations()
  }
}

让我们先从高层次理解流程:

我们创建了一个用于测试的 Android 项目。
我们使用选项 -e android 运行一个名为 which 的任务。
我们断言我们通过步骤 2 找到的 jar 具有正确的版本信息。
我们针对 Gradle 和 AGP 版本的矩阵运行整个事情,因为我们是彻底的。

这是从 IDE 运行时该规范的样子:

image.png

$ ./gradlew plugin:functionalTest --tests AndroidSpec
> Task :plugin:functionalTest
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.3.3 AGP 4.2.2)(mutual.aid.AndroidSpec)
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.4.1 AGP 4.2.2)(mutual.aid.AndroidSpec)
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.3.3 AGP 7.1.2)(mutual.aid.AndroidSpec)
Running test: Test gets the expected version of AGP on the classpath (Gradle 7.4.1 AGP 7.1.2)(mutual.aid.AndroidSpec)

这就是Spock的力量。为参数化测试生成数据管道真的很容易。

数据驱动测试

由于数据驱动方面对于理解我们正在探索的概念非常重要,我想展示它的实现:

inal class Combinations {
  static List<List> gradleAgpCombinations(
    List<Object>... others = []
  ) {
    return [
      gradleVersions(), agpVersions(), *others
    ].combinations()
  }

  static List<GradleVersion> gradleVersions() {
    return [
      GradleVersion.version('7.3.3'),
      GradleVersion.version('7.4.1')
    ]
  }

  static List<String> agpVersions() {
    return ['4.2.2', '7.1.2']
  }
}

哪个 AGP?

为了以编程方式检查我们构建的运行时类路径中的 AGP 版本,我编写了一个小帮助脚本来注册我们的规范调用的任务。这是定义的方式:

// which.gradle
tasks.register('which', WhichTask)

@UntrackedTask(because = 'Not worth tracking')
abstract class WhichTask extends DefaultTask {

  WhichTask() {
    group = 'Help'
    description = 'Print path to jar providing extension, or list of all available extensions and their types'
  }

  @Optional
  @Option(option = 'e', description = 'Which extension?')
  @Input
  abstract String ext

  @TaskAction def action() {
    if (ext) printLocation()
    else printExtensions()
  }

  private void printLocation() {
    def jar = project.extensions.findByName(ext)
      ?.class
      ?.protectionDomain
      ?.codeSource
      ?.location

    if (jar) {
      logger.quiet("jar for '$ext': $jar")
    } else {
      logger.quiet("No extension named '$ext' registered on project.")
    }
  }

  private void printExtensions() {
    logger.quiet('Available extensions:')
    project.extensions.extensionsSchema.elements.sort { it.name }.each {
      // fullyQualifiedName since Gradle 7.4
      logger.quiet("* ${it.name}, ${it.publicType.fullyQualifiedName}")
    }
  }
}

此任务可以以两种模式运行:

./gradlew which -e <some-extension>
./gradlew which

第一个将打印提供扩展名的 jar 的路径(例如“android”),而第二个将打印给定模块可用的所有扩展名,以及它们的完全限定类型.

测试场景

首先,我在插件的构建脚本中添加了一个标志,让我可以更改它的构建方式,以便我可以通过自动化测试来探索这种行为。这不是我在一般情况下会推荐的东西——它只是用于驱动以下探索性场景的断言。

// plugin/build.gradle
// -Dimpl (for 'implementation')
boolean impl = providers.systemProperty('impl').orNull != null

dependencies {
  if (impl) {
    implementation 'com.android.tools.build:gradle:7.2.0-beta04'
  } else {
    compileOnly 'com.android.tools.build:gradle:7.2.0-beta04'
  }
}

默认情况下,我们使用 compileOnly,但如果您在构建期间传递 -Dimpl,我们将使用 implementation。我们还必须更新我们的测试配置,因为我们需要测试 JVM 中可用的标志(它是从主 JVM 派生的,默认情况下不会获取其所有系统属性)。

// plugin/build.gradle
testTask.configure {
  ...
  systemProperty('impl', impl)
  ...
}

迭代 1:用户是否在根 buildscript 块中声明 AGP 是否重要?

@Requires({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for implementation (#gradleVersion AGP #agpVersion useBuildScript=#useBuildScript)"() {
  given: 'An Android Gradle project'
  project = new AndroidProject(agpVersion,useBuildScript)

  when: 'We check the version of AGP on the classpath'
  def result = Builder.build(
    gradleVersion,
    project,
    'lib:which', '-e', 'android'
  )

  then: 'Result depends'
  def androidJar = result.output.split('\n').find {
    it.startsWith("jar for 'android'") 
  }
  def expected = useBuildScript
    ? "gradle-${agpVersion}.jar"
    : 'gradle-7.2.0-beta04.jar'
  assertThat(androidJar).endsWith(expected)

  where: '2^3=8 combinations'
  [gradleVersion, agpVersion, useBuildScript] << gradleAgpCombinations([true, false])
}

实际上,这些场景映射到以下两个 Gradle 构建脚本:

// settings.gradle -- for BOTH versions of the build script
pluginManagement {
  repositories {
    gradlePluginPortal()
    google()
    mavenCentral()
  }
  plugins {
    // Centralized version declarations. These do not directly 
    // impact the classpath. Rather, this simply lets you have
    // a single place to declare all plugin versions.
    id 'com.android.library' version '7.1.2'
  }
}

// build.gradle 1
// useBuildScript = false
plugins {
  id 'com.android.library' apply false
}

// build.gradle 2
// useBuildScript = true
buildscript {
  repositories {
    google()
    mavenCentral()
  }
  dependencies {
    classpath "com.android.tools.build:gradle:$agpVersion"
  }
}

image.png

从我们的规范通过的事实来看,我们可以自信地回答标题中的问题:是的,结果取决于用户是否在根项目的 buildscript 块中声明了 AGP。对于不熟悉 Android 的用户,请注意这是自古以来的标准做法,并且仅随着最新模板开始更改。

当然,这已经是有问题的了:我们已经创建了一个插件已经成功地将我们的构建升级到 AGP 的 beta 版本的场景。

迭代 2:如果我们关闭一些 TestKit 魔法会发生什么?

TestKit 中有一点魔力,可以将您的插件置于构建类路径中,这样您就不必这样做了。它对于简单的场景非常有用,但我发现它缺乏工业规模的用例。要测试这些场景,首先我们必须更新我们的构建脚本:

// plugin/build.gradle
plugins {
  ...
  id 'maven-publish'
}

group = 'mutual.aid'
version = '1.0'

// Some specs rely on the plugin as an external artifact
// This task is added to the build by the maven-publish plugin
def publishToMavenLocal = tasks.named('publishToMavenLocal')

testTask.configure {
  ...
  dependsOn(publishToMavenLocal)
  ...
}

现在,每当我们运行 functionalTest 任务时,它都会首先将我们的插件发布到本地 maven (~/.m2/repositories)。

现在对 Builder 进行更改,让我们改变这种行为:

private fun runner(
  gradleVersion: GradleVersion,
  projectDir: Path,
  withPluginClasspath: Boolean,
  vararg args: String
): GradleRunner = GradleRunner.create().apply {
  ...
  if (withPluginClasspath) {
    withPluginClasspath()
  }
  ...
}
@Requires({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for implementation (#gradleVersion AGP #agpVersion useBuildScript=#useBuildScript useMavenLocal=#useMavenLocal)"() {
  given: 'An Android Gradle project'
  project = new AndroidProject(
    agpVersion,
    useBuildScript,
    useMavenLocal
  )

  when: 'We check the version of AGP on the classpath'
  def result = Builder.build(
    gradleVersion,
    project,
    // !useMavenLocal => withPluginClasspath
    !useMavenLocal,
    'lib:which', '-e', 'android'
  )

  then: 'Result depends'
  def androidJar = result.output.split('\n').find {
    it.startsWith("jar for 'android'")
  }
  def expected
  if (useBuildScript || useMavenLocal) {
    expected = "gradle-${agpVersion}.jar"
  } else {
    expected = 'gradle-7.2.0-beta04.jar'
  }

  // Our assertion is growing more complicated
  assertThat(androidJar).endsWith(expected)

  where: '2^4=16 combinations'
  [gradleVersion, agpVersion, useBuildScript, useMavenLocal] << gradleAgpCombinations(
    // useBuildScript
    [true, false],
    // useMavenLocal
    [true, false],
  )
}

实际上,这些场景映射到以下四个 Gradle 构建脚本:

// settings.gradle now varies
pluginManagement {
  repositories {
    if (useMavenLocal) mavenLocal()
    gradlePluginPortal()
    google()
    mavenCentral()
  }
}

// build.gradle 1
// useBuildScript = true
// useMavenLocal = false
buildscript {
  repositories {
    google()
    mavenCentral()
  }
  dependencies {
    classpath "com.android.tools.build:gradle:$agpVersion"
  }
}

// build.gradle 2
// useBuildScript = true
// useMavenLocal = true
buildscript {
  repositories {
    mavenLocal()
    google()
    mavenCentral()
  }
  dependencies {
    classpath "com.android.tools.build:gradle:$agpVersion"
  }
}

// build.gradle 3
// useBuildScript = false
// useMavenLocal = true
plugins {
  id 'com.android.library' apply false
}

// build.gradle 4 (identical to 3, but recall settings.gradle
// varies)
// useBuildScript = false
// useMavenLocal = false
plugins {
  id 'com.android.library' apply false
}

image.png

由于我们的规范已经通过,我们知道是的,TestKit 类路径魔法会影响我们构建的结果。由于 TestKit 在实际构建中没有发挥作用,因此我更喜欢不使用 withPluginClasspath() 方法,而是始终依赖于将我的插件发布到本地 maven,因为它更接近于真实构建。

迭代 3:我们是否为被测插件使用 buildscript 是否重要?

@Requires({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for implementation (#gradleVersion AGP #agpVersion useBuildScriptForAgp=#useBuildScriptForAgp useBuildScriptForPlugin=#useBuildScriptForPlugin useMavenLocal=#useMavenLocal)"() {
  given: 'An Android Gradle project'
  project = new AndroidProject(
    agpVersion,
    useBuildScriptForAgp,
    useBuildScriptForPlugin,
    useMavenLocal
  )

  when: 'We check the version of AGP on the classpath'
  def result = Builder.build(
    gradleVersion,
    project,
    !useMavenLocal,
    'lib:which', '-e', 'android'
  )

  then: 'Result depends'
  def androidJar = result.output.split('\n').find {
    it.startsWith("jar for 'android'")
  }
  def expected
  if (useBuildScriptForAgp && useBuildScriptForPlugin && useMavenLocal) {
    // our 'implementation' dependency has greater priority
    expected = 'gradle-7.2.0-beta04.jar'
  } else if (useBuildScriptForAgp || useMavenLocal) {
    // the project's requirements have greater priority
    expected = "gradle-${agpVersion}.jar"
  } else {
    // our 'implementation' dependency has greater priority
    expected = 'gradle-7.2.0-beta04.jar'
  }

  // Note that the assertion is... complicated.
  assertThat(androidJar).endsWith(expected)

  where: 'There is a truly atrocious combinatorial explosion'
  [gradleVersion, agpVersion, useBuildScriptForAgp, useBuildScriptForPlugin, useMavenLocal] <<
    gradleAgpCombinations(
      // useBuildScriptForAgp
      [true, false],
      // useBuildScriptForPlugin
      [true, false],
      // useMavenLocal
      [true, false],
    )
}

这一次,我不会分享所有的个体变化,而是将 if/else 逻辑留在原处,以便您可以发挥您的想象力。请注意,以下合并了一些伪代码以提高可读性。

// settings.gradle
pluginManagement {
  repositories {
    if (useMavenLocal) mavenLocal()
    gradlePluginPortal()
    google()
    mavenCentral()
  }
  plugins {
    id 'com.android.library' version '7.1.2'
    id 'mutual.aid.meaning-of-life' version '1.0'
  }
}

// build.gradle
if (useBuildScriptForAgp) {
  buildscript {
    repositories {
      if (useMavenLocal) mavenLocal()
      google()
      mavenCentral()
    }
    dependencies {
      classpath 'com.android.tools.build:gradle:4.2.2'
      if (useBuildScriptForPlugin) classpath 
  'mutual.aid.meaning-of-life:mutual.aid.meaning-of-life.gradle.plugin:1.0' 
    }
  }
} else {
  plugins {
    id 'com.android.library' apply false
  }
}

image.png

现在你将在实际构建中最终得到的 AGP 版本现在很难预测,除非你是专家并且了解类加载器关系是如何工作的,以及这些关系如何相互作用(或不交互)与依赖解析引擎。

迭代 4:我们可以(而且必须)做得更好!或者,零假设。

乍一看,它可能看起来很复杂,但请注意断言总是相同的。也就是说,无论我们传递什么样的标志组合(即,无论我们的用户可能决定做什么),我们总是以用户指定的 AGP 版本结束。这就是 compileOnly 的强大之处。

@IgnoreIf({ PreconditionContext it -> it.sys.impl == 'true' })
def "gets the expected version of AGP on the classpath for compileOnly (#gradleVersion AGP #agpVersion useBuildScriptForAgp=#useBuildScriptForAgp useBuildScriptForPlugin=#useBuildScriptForPlugin useMavenLocal=#useMavenLocal)"() {
  given: 'An Android Gradle project'
  project = new AndroidProject(
    agpVersion,
    useBuildScriptForAgp,
    useBuildScriptForPlugin,
    useMavenLocal
  )

  when: 'We check the version of AGP on the classpath'
  def result = Builder.build(gradleVersion, project, 'lib:which', '-e', 'android')

  then: 'It matches what the project provides, not the plugin, always'
  def androidJar = result.output.split('\n').find {
    it.startsWith("jar for 'android'")
  }
  // Note that the assertion is always the same
  assertThat(androidJar).endsWith("gradle-${agpVersion}.jar")

  where:
  [gradleVersion, agpVersion, useBuildScriptForAgp, useBuildScriptForPlugin, useMavenLocal] <<
    gradleAgpCombinations(
      // useBuildScriptForAgp
      [true, false],
      // useBuildScriptForPlugin
      [true, false],
      // useMavenLocal
      [true, false],
    )
}

image.png

除非调试类加载器和依赖解析交互是你的日常工作,否则我强烈建议你保持简单,走老路,并使用 compileOnly。

即使你做了正确的事情并使用 compileOnly,你依赖的一些插件可能仍然使用实现并将 AGP 带入你的运行时类路径,无论你是否想要它.在将我的大型构建迁移到约定插件的过程的早期,我遇到了这个问题,并发现自己处于两个 AGP 版本最终在运行时可用的场景中! 😱 为了防止将来发生这种情况,我将以下任务添加到我所有的约定插件项目中,并在 CI 中运行它。

下面是任务实现:

@UntrackedTask(because = "Not worth tracking")
abstract class NoAgpAtRuntime : DefaultTask() {

  @get:Internal
  lateinit var artifacts: ArtifactCollection

  @PathSensitive(PathSensitivity.RELATIVE)
  @InputFiles
  fun getResolvedArtifactResult(): FileCollection {
    return artifacts.artifactFiles
  }

  @TaskAction fun action() {
    val android = artifacts.artifacts
      .map { it.id.componentIdentifier.displayName }
      .filter { it.startsWith("com.android.tools") }
      .toSortedSet()

    if (android.isNotEmpty()) {
      val msg = buildString {
        appendLine("AGP must not be on the runtime classpath. The following AGP libs were discovered:")
        android.forEach { a ->
          appendLine("- $a")
        }
        appendLine(
          "The most likely culprit is `implementation 'com.android.tools.build:gradle'` in your dependencies block"
        )
      }
      throw GradleException(msg)
    }
  }
}

这是它的注册方式:

// register the task
def runtimeClasspath = project.configurations.findByName('runtimeClasspath')
if (runtimeClasspath) {
  def noAgpAtRuntime = tasks.register('noAgpAtRuntime', NoAgpAtRuntime) {
    artifacts = runtimeClasspath.incoming.artifacts
  }

  tasks.named('check').configure {
    dependsOn noAgpAtRuntime
  }
}

当检查失败时,解决方案是找到正在使用 AGP 实现的第三方插件,并将其声明为 compileOnly。