手记

一键修复依赖搞定

图片由Maxim Hopman 拍摄,图片来自Unsplash网站

如果你正在维护一个JVM或Android项目,你很可能听说过依赖分析 Gradle 插件(DAGP)。它拥有超过1800个星标关注,被世界上一些最大的 Gradle 项目之一所使用,同时也被Gradle 本身使用。它填补了 Gradle 生态系统中的一个重大空白:没有它,我不知道还有什么其他方法来消除未使用的依赖项并准确声明所有实际使用的依赖项。换句话说,使用此插件后,你的依赖声明将精确地反映构建项目所需的依赖项,不多不少。

这听起来可能不重要,但对于大规模的工业项目来说,一个健康的依赖关系图就像是软件工程界的超级武器,它可以防止错误,让调试更加轻松,无论是构建时还是运行时,保持构建速度,让构建产物更小。如果提高开发者生产力的工作是软件工程界的公共卫士,那么一个健康的依赖关系图就像是运作良好的下水道系统。直到它出了问题,你才会发现它的重要性,那时候你就会发现到处都是乱七八糟。

问题在于,如果你的工具只告诉你所有的问题但不帮你解决,你可能会遇到一个非常(烦人的)问题。我在最近一篇关于代码风格格式化器的吐槽文章中提到这是重要的考虑因素。从v1.11.0版本开始,DAGP才有了一个可以解决问题的fixDependencies任务,这个任务会根据问题报告直接重写构建脚本。即使在此之前,在v0.46.0版本中,插件已经为高级用户提供了注册“后处理任务”的功能,以允许他们以任何方式使用“构建健康”报告。例如,Foundry(原名为Slack Gradle插件)有一个叫“[依赖rake]”的功能,这个功能在fixDependencies出现之前就已经存在,并且启发了fixDependencies的功能。

fixDependencies 并不总是能很好地工作。一方面,分析中可能存在错误,导致当你“解决”所有问题后,构建可能会出错。(DAGP 正在积极开发中,所以如果你遇到这种问题,请报告问题!)在这种情况下,可能需要专家来判断问题出在哪里,并想办法解决,或者你可以手动调整并尝试解决。

另外,构建脚本重写器依赖这种简化的语法来解析和重写Gradle Groovy和Kotlin DSL构建脚本。如果脚本过于复杂,这种语法可能会导致失败。这个问题很快就会通过引入基于KotlinEditor语法的Gradle Kotlin DSL解析器得到解决,该解析器支持Kotlin语言的所有功能。(目前,Gradle Groovy DSL脚本将继续使用旧的简化语法。)

最近我们修复了很多错误,以(1)提高分析的正确性,(2)使重写过程更健壮,能够应对各种常见表达。DAGP现在对版本目录访问器的支持更好了(但还不支持实验项目访问器)。

随着这些实际和计划中的改进,想象一下自动化跨数百个包含数百万行代码的代码库的大规模依赖修复,使得这一切都能“顺利进行”变得可能。具体情况如下:

  1. 超过500个仓库。
  2. 每个仓库都有自己的版本目录。
  3. 大多数版本目录条目使用相同的名称,但命名空间中存在一些偶然的重复(多个键指向相同的依赖项坐标)。
  4. 超过2000个Gradle模块。
  5. 近1500万行Kotlin和Java代码分布在超过10万个文件中,以及超过3000个构建脚本中超过15万行的“Gradle”代码。虽然最后一点与前三点相比不那么重要,但它有助于说明我所指的“工业规模”。

此外,我们想要编写的构建代码应该遵循 Gradle 的最佳实践:它应该尽可能地缓存,应该与配置缓存一起工作,并且为了额外加分项,不应违反隔离项目的约定(这也对最大性能有好处)。最终目标是让开发人员和构建维护人员能够运行一个任务,并使其(1)修复所有依赖声明,这可能需要在构建脚本中添加新的声明;(2)所有构建脚本声明尽可能都应该有一个版本条目;(3)并且所有版本条目都应该来自相同的全局命名空间,以便整个由500多个存储库组成的系统完全一致。这一最后部分是一个重要的要求,因为我们正在将这些仓库迁移到一个单一的单一大仓库,因为其他原因。

这里是有一个任务,他们现在可以开始了,记录如下所示:

gradle :解决所有依赖

全屏,退出全屏

(注意:我们使用 gradle 而不是 ./gradlew,因为我们用 hermit 按仓库管理 gradle。)

那我们怎么弄呢?

预处理阶段

第一步是创建全局版本目录命名空间的创建。我们并未尝试实际创建单一的published global version catalog,因为在完成我们的megarepo迁移之前,一个重要的约定是每个仓库维护自己的依赖关系(及其版本)。因此,我们收集了版本目录名称与依赖标识符(去掉版本号后的依赖坐标)的完整映射,并利用现有的大规模变更工具消除了所有重复,将最终的全局集合(1:1映射)填充到我们已广泛使用的约定插件中。

概念模型

Gradle框架通常将Project作为最重要的参考点。一个Project实例支持所有的build.gradle[.kts]脚本,例如,大多数插件都实现了Plugin<Project>接口。安全且高效的_高质量_构建代码尊重这个概念边界,并将每个项目(也称为“模块”)视为一个独立的单元。

如果 Task 有明确定义的输入和输出(即注解为 @Input<X>@Output<X>),那么也可以将项目视为有输入和输出。通常来说,一个项目的输入是其源代码(通常位于项目根目录的 src/ 目录下),以及其依赖项。项目的输出是它生成的构建产物,比如。对于 Java 项目,主要的构建产物是 jar 文件(供外部使用),或者类文件(供多项目构建中的其他项目使用)。

考虑到这一点,我们可以决定如果两个项目需要互相通信,那么它们应该通过它们明确的输入和输出来进行。我们通过定义依赖关系来描述项目之间的连接(A -> B 表示 A 依赖于 B,因此 BA 的输入),并且我们可以为这种连接添加特定的类型,这样我们就可以告诉 Gradle A 关心 B 的哪些输出。默认情况下是 主要输出(通常是类文件,用于类路径目的),但它也可以是 任何可以写入磁盘的数据。它可以是 B 的一些元数据。它也可以两者兼顾!(您可以为相同的两个项目声明多个依赖关系,每个边表示不同的变体。)在具体例子中,这会更清晰。

实现方式: :fixAllDependencies(修复所有依赖项)

剩下的部分将主要讨论实现,但会保持在一个相对高的层次细节。一些代码将以伪代码形式呈现。我的目标是通过概念性的方式展示整个流程,使得一个有足够动力的读者可以在他们的工作流中实现类似的内容,或者更可能地,只是学习如何用 Gradle 做一些酷炫的事情。

这里是一个用Excalidraw画的简化任务流程图草稿:

注意,每个项目都是独立的。明确定义的 Gradle 构建通过尊重项目边界,来最大化并发。

第一步:全局命名空间

正如 预处理 部分提到的,我们需要全局范围内的命名空间。我们希望所有的依赖声明都引用版本目录中的条目,即 libs.amazingMagic,而不是 "com.amazing:magic:1.0"。由于 DAGP 已经支持 版本目录引用在其分析中,如果你的版本目录中已经有 amazingMagic = "com.amazing:magic:1.0" 条目,那么这将会直接生效。然而,如果没有,DAGP 将默认采用“原始字符串”声明。如果我们需要的话,我们还可以告诉 DAGP 其他它默认无法识别的映射:

    // 根构建脚本文件
    dependencyAnalysis {
      structure {
        structure部分 {
          map.putAll(
            "com.amazing:magic" to "libs.amazingMagic",
            // 更多条目,例如
          )
        }
      }
    }

点击全屏模式 点击退出全屏

dependencyAnalysis.structure.map 是一个 MapProperty<String, String> 类型的属性。你可以在构建脚本中直接修改它,或通过插件间接修改它。请注意,该声明的“原始字符串”形式不包含版本信息,这很重要,因为你在声明中指定的版本可能与 Gradle 最终解析出的版本不一致。

更新版本清单:第一部分

通过 步骤 1,DAGP 将通过内置的 fixDependencies 任务重写构建脚本以匹配你所需的模式,但你的下一次构建将会失败,因为你将有依赖项引用类似 libs.amazingMagic 这样的库,而这些库实际上并未在你的版本目录中定义。因此,你需要更新版本目录以确保包含所有这些新的依赖项。这将是一个逐步完成的过程。

首先,我们需要计算可能缺失的条目。我们编写一个新的任务 ComputeNewVersionCatalogEntriesTask,并让它继承自 DAGP 自身提供的 AbstractPostProcessingTask。这暴露了一个函数 projectAdvice(),该函数允许子类访问 DAGP 发送到控制台的“项目建议”,但形式上便于计算机处理和分析。我们将使用该输出,从中过滤出“添加建议”,然后将这些值写入磁盘。我们只关注 添加建议,因为这是唯一可能表示不在版本目录中的依赖关系的类型。

    // 在一个自定义任务中的操作
    // 生成并写入新的条目到文件
    val newEntries = projectAdvice()
      .dependencyAdvice
      .filter { it.isAnyAdd() }
      .filter { it.coordinates 是 ModuleCoordinates }
      .map { it.coordinates.gav() }
      .toSortedSet()

    // 将新条目写入输出文件,每个条目占一行
    outputFile.writeText(newEntries.joinToString(separator = "\n"))

全屏显示 退出全屏

在处理 Gradle 任务的输出时,最好总是对输出进行排序以确保稳定性,并以启用远程构建缓存。

接下来我们告诉DAGP关于这个任务的后处理(以便它这样就能访问projectAdvice())。

    // 子项目的构建脚本示例
    computeNewVersionCatalogEntries = tasks.register(...) // 计算新版本目录条目

    dependencyAnalysis {
      registerPostProcessingTask(computeNewVersionCatalogEntries) // 注册后处理任务
    }

点击进入全屏 点击退出全屏

最后还要记录我们新任务的结果,作为这个项目的成果!

    val publisher = interProjectPublisher(
      project,
      MyArtifacts.Kind.VERSION_CATALOG_ENTRIES
    )
    publisher.publish(
      computeNewVersionCatalogEntries.flatMap { 
        it.newVersionCatalogEntries 
      }
    )

定义了一个发布者publisher,该发布者用于发布新版本目录条目。

点击进入全屏模式,点击退出全屏模式

interProjectPublisher 和相关代码很大程度上借鉴了 DAGP 的 artifacts 包,因为我写了这两个部分。简单来说,这部分代码告诉 Gradle 项目中的 次要工件 是什么。希望 Gradle 提供一个更好的 API 来处理这些,可惜没有。

更新版本清单,第二步

在根项目里,我们需要为每个子项目声明对它们的依赖,并调整声明以表示我们想要 VERSION_CATALOG_ENTRIES 工件。

    // 根项目
    val resolver = interProjectResolver(
      project,
      MyArtifacts.Kind.VERSION_CATALOG_ENTRIES
    )

    // 是的,这可以是正确的,但你只能访问每个项目的不可变属性。
    // 这设置了从根项目到每个“真正”的子项目的依赖关系,其中“真正”的意思是过滤掉没有代码的中间目录
    allprojects.forEach { p ->
      // 实现留给读者完成
      if (isRealProject(p)) {
        dependencies.add(
          resolver.declarable.name, 
          // p.path 是一个不可变属性,所以我们没问题
          dependencies.project(mapOf("path" to p.path))
        )
      }
    }

    val fixVersionCatalog = tasks.register(
      "fixVersionCatalog", 
      UpdateVersionCatalogTask::class.java
    ) { t ->
        t.newEntries.setFrom(resolver.internal)
        t.globalNamespace.putAll(...)
        t.versionCatalog.set(layout.projectDirectory.file("gradle/libs.versions.toml"))
      }

全屏 退出全屏

根项目是正确注册此任务的位置,因为版本目录文件通常位于根目录下的 gradle/libs.versions.toml

有了这样的设置,用户现在可以运行gradle :fixVersionCatalog,它会首先运行 <每个模块>*:projectHealth,接着运行 <每个模块>*:computeNewVersionCatalogEntries,最后执行:fixVersionCatalog,因为这些都是我们所定义并连接起来的必要步骤。

这将更新版本信息,使其包含解决这些需求中的所有可能的 libs.<foo> 依赖声明,以解决整个构建过程中所有潜在的依赖声明。

步骤 4:解决所有依赖项声明的问题

这一步使用了DAGP的fixDependencies任务,其实就是把所有东西打包得整整齐齐。

我们希望在根目录注册一个单一的任务。这将是一个生命周期任务,调用它会触发 :fixVersionCatalog 以及其他所有模块的 *:fixDependencies 任务。

    // 根项目
    val fixDependencies = mutableListOf<String>()

    allprojects.forEach { p ->
      if (isRealProject(p)) {
        // ...保持原样...

        // 避免使用 `p.tasks.findByName()` 这样的方式,
        // 这会违反独立项目的隔离性以及惰性任务配置。
        fixDependencies.add("${p.path}:fixDependencies")    
      }
    }

    tasks.register("fixAllDependencies") { t ->
      t.dependsOn(fixVersionCatalog) // 注释:fixVersionCatalog 需要额外定义或解释
      t.dependsOn(fixDependencies)
    }

全屏 退出

就这样完了。

(可选)步骤五:排序依赖模块

如果你完成了以上所有步骤,你应该会有一个成功的构建,这时依赖图会尽可能地简化。🎉 但是你的依赖块可能会显得非常杂乱无章,这使得它们难以进行视觉上的扫描。DAGP 并不试图维持声明的排序,因为这和它的核心功能无关,不同的团队可能有不同的排序偏好。这也是为什么我编写并发布了 Gradle 依赖排序器 的 CLI 和插件,它采用了我认为合理的默认排序方式。如果你在你的构建中使用这个工具(我们通过我们的约定插件将其应用到所有的构建中),你可以在后面运行 :fixAllDependencies 任务:

gradle 排序依赖项

全屏,退出全屏

而且这通常能顺利运行。此插件已经在使用KotlinEditor中的增强Kotlin语法,所以它不会对Gradle Kotlin DSL构建脚本造成问题。

现在我们真的搞定了。

参考文献

1 目前支持的编程语言:Groovy、Java、Kotlin 和 Scala。

这也是我认为保持脚本语言简单和声明式很重要的原因之一。

3 使用cloc工具进行测量。

在我看来,Gradle 最大的问题在于其 API 并没有强制实施这个概念边界。

5 这段话是为了讨论而过于简单化。

6 不过,除了就自动化测试和写博客,就没有什么了。
(Note: The comma after "不过" was not included as it depends on the preference of the writing style, but the translation style aims to reflect the original text's style and fluency.)

For better adherence to the original punctuation and readability:
6 不过,除了就自动化测试和写博客之外,就没有什么了。

0人推荐
随时随地看视频
慕课网APP