在多项目构建中,一个常见模式是,一个项目使用另一个项目的工件。通常,Java 生态系统中最简单的使用形式是,当 A 依赖于 B 时,A 将依赖于项目 B 生成的 jar。如本章前面所述,这由 A 依赖于 B 的一个变体来建模,其中变体是根据 A 的需求选择的。对于编译,我们需要 B 的 API 依赖项,由 apiElements 变体提供。对于运行时,我们需要 B 的运行时依赖项,由 runtimeElements 变体提供。

但是,如果您需要一个与主工件不同的工件怎么办?例如,Gradle 提供了对依赖于另一个项目的测试夹具的内置支持,但有时您需要依赖的工件并没有作为变体公开。

为了能够在项目之间安全共享并实现最大性能(并行),此类工件必须通过传出配置公开。

不要直接引用其他项目任务

声明跨项目依赖项的常见反模式是

dependencies {
   // this is unsafe!
   implementation project(":other").tasks.someOtherJar
}

这种发布模型是不安全的,会导致构建不可重现且难以并行化。本节介绍如何通过使用变体来定义项目之间的“交换”,从而正确创建跨项目边界。

在项目之间共享构件有两种互补的选项。 简化版本 仅适用于您需要共享的构件是简单的构件,并且不依赖于消费者。 简单解决方案也仅限于该构件未发布到存储库的情况。 这也意味着消费者不会发布对该构件的依赖关系。 在消费者在不同上下文中(例如,不同的目标平台)解析为不同的构件或需要发布的情况下,您需要使用 高级版本

项目之间简单共享构件

首先,生产者需要声明一个将暴露给消费者的配置。 如 配置章节 中所述,这对应于一个可消费配置

假设消费者需要来自生产者的带仪器的类,但该构件不是主要的构件。 生产者可以通过创建一个将“承载”该构件的配置来暴露其带仪器的类

producer/build.gradle.kts
val instrumentedJars by configurations.creating {
    isCanBeConsumed = true
    isCanBeResolved = false
    // If you want this configuration to share the same dependencies, otherwise omit this line
    extendsFrom(configurations["implementation"], configurations["runtimeOnly"])
}
producer/build.gradle
configurations {
    instrumentedJars {
        canBeConsumed = true
        canBeResolved = false
        // If you want this configuration to share the same dependencies, otherwise omit this line
        extendsFrom implementation, runtimeOnly
    }
}

此配置是可消费的,这意味着它是为消费者准备的“交换”。 我们现在将向此配置添加构件,消费者在使用它时会获得这些构件

producer/build.gradle.kts
artifacts {
    add("instrumentedJars", instrumentedJar)
}
producer/build.gradle
artifacts {
    instrumentedJars(instrumentedJar)
}

这里我们附加的“构件”是一个任务,它实际上会生成一个 Jar。 这样做,Gradle 可以自动跟踪此任务的依赖关系并在需要时构建它们。 这是因为 Jar 任务扩展了 AbstractArchiveTask。 如果不是这种情况,您将需要显式声明构件的生成方式。

producer/build.gradle.kts
artifacts {
    add("instrumentedJars", someTask.outputFile) {
        builtBy(someTask)
    }
}
producer/build.gradle
artifacts {
    instrumentedJars(someTask.outputFile) {
        builtBy(someTask)
    }
}

现在消费者需要依赖此配置才能获得正确的构件

consumer/build.gradle.kts
dependencies {
    instrumentedClasspath(project(mapOf(
        "path" to ":producer",
        "configuration" to "instrumentedJars")))
}
consumer/build.gradle
dependencies {
    instrumentedClasspath(project(path: ":producer", configuration: 'instrumentedJars'))
}
不建议声明对显式目标配置的依赖关系。 如果您计划发布具有此依赖关系的组件,这可能会导致元数据损坏。 如果您需要将组件发布到远程存储库,请遵循 变体感知交叉发布文档 中的说明。

在这种情况下,我们将依赖关系添加到instrumentedClasspath 配置中,这是一个消费者特定配置。 在 Gradle 术语中,这被称为 可解析配置,其定义方式如下

consumer/build.gradle.kts
val instrumentedClasspath by configurations.creating {
    isCanBeConsumed = false
}
consumer/build.gradle
configurations {
    instrumentedClasspath {
        canBeConsumed = false
    }
}

项目之间变体感知共享构件

简单共享解决方案 中,我们在生产者端定义了一个配置,作为生产者和消费者之间交换工件的媒介。但是,消费者必须明确指定它依赖的配置,这是我们在变体感知解析中想要避免的。事实上,我们也 解释过,消费者可以使用属性来表达需求,生产者也应该使用属性提供相应的输出变体。这允许更智能的选择,因为使用单个依赖声明,没有任何显式目标配置,消费者可以解析不同的内容。典型的例子是,使用单个依赖声明 project(":myLib"),我们会根据架构选择 myLibarm64i386 版本。

为此,我们将为消费者和生产者添加属性。

重要的是要理解,一旦配置具有属性,它们就会参与变体感知解析,这意味着它们是每次使用 project(":myLib") 之类的任何符号时考虑的候选者。换句话说,在生产者上设置的属性必须与同一项目上产生的其他变体一致。它们尤其不能为现有选择引入歧义。

在实践中,这意味着您创建的配置上使用的属性集可能取决于所使用的生态系统(Java、C++ 等),因为这些生态系统的相关插件通常使用不同的属性。

让我们增强我们之前的示例,它恰好是一个 Java 库项目。Java 库向其消费者公开几个变体,apiElementsruntimeElements。现在,我们添加了第三个,instrumentedJars

因此,我们需要了解新变体的用途,以便在其上设置正确的属性。让我们看看我们在生产者上的 runtimeElements 配置上找到的属性

gradle outgoingVariants --variant runtimeElements
Attributes
    - org.gradle.category            = library
    - org.gradle.dependency.bundling = external
    - org.gradle.jvm.version         = 11
    - org.gradle.libraryelements     = jar
    - org.gradle.usage               = java-runtime

它告诉我们,Java 库插件会生成具有 5 个属性的变体

  • org.gradle.category 告诉我们这个变体代表一个

  • org.gradle.dependency.bundling 告诉我们这个变体的依赖项以 jar 的形式找到(例如,它们不会被重新打包到 jar 中)

  • org.gradle.jvm.version 告诉我们这个库支持的最低 Java 版本是 Java 11

  • org.gradle.libraryelements 表示该变体包含 jar 文件中所有元素(类和资源)。

  • org.gradle.usage 表示该变体是 Java 运行时,因此适用于 Java 编译器,也适用于运行时。

因此,如果我们希望在执行测试时使用已插桩的类来代替该变体,我们需要将类似的属性附加到我们的变体。实际上,我们关心的属性是 org.gradle.libraryelements,它解释了变体包含的内容,因此我们可以这样设置变体。

producer/build.gradle.kts
val instrumentedJars by configurations.creating {
    isCanBeConsumed = true
    isCanBeResolved = false
    attributes {
        attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY))
        attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME))
        attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling.EXTERNAL))
        attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, JavaVersion.current().majorVersion.toInt())
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named("instrumented-jar"))
    }
}
producer/build.gradle
configurations {
    instrumentedJars {
        canBeConsumed = true
        canBeResolved = false
        attributes {
            attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.LIBRARY))
            attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage, Usage.JAVA_RUNTIME))
            attribute(Bundling.BUNDLING_ATTRIBUTE, objects.named(Bundling, Bundling.EXTERNAL))
            attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, JavaVersion.current().majorVersion.toInteger())
            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, 'instrumented-jar'))
        }
    }
}

选择要设置的正确属性是此过程中最困难的事情,因为它们承载着变体的语义。因此,在添加新属性之前,您应该始终问问自己是否没有承载您所需语义的属性。如果没有,那么您可以添加一个新属性。添加新属性时,您还必须小心,因为这可能会在选择过程中产生歧义。通常,添加属性意味着将其添加到所有现有变体中。

我们在这里所做的是添加了一个新的变体,它可以在运行时使用,但包含已插桩的类而不是普通类。但是,现在这意味着对于运行时,使用者必须在两个变体之间进行选择。

  • runtimeElementsjava-library 插件提供的常规变体。

  • instrumentedJars,我们创建的变体。

特别是,假设我们希望在测试运行时类路径上使用已插桩的类。现在,在使用者端,我们可以将我们的依赖项声明为常规的项目依赖项。

consumer/build.gradle.kts
dependencies {
    testImplementation("junit:junit:4.13")
    testImplementation(project(":producer"))
}
consumer/build.gradle
dependencies {
    testImplementation 'junit:junit:4.13'
    testImplementation project(':producer')
}

如果我们到此为止,Gradle 仍然会选择 runtimeElements 变体来代替我们的 instrumentedJars 变体。这是因为 testRuntimeClasspath 配置要求一个 libraryelements 属性为 jar 的配置,而我们新的 instrumented-jars不兼容

因此,我们需要更改请求的属性,以便现在查找已插桩的 jar 文件。

consumer/build.gradle.kts
configurations {
    testRuntimeClasspath {
        attributes {
            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class.java, "instrumented-jar"))
        }
    }
}
consumer/build.gradle
configurations {
    testRuntimeClasspath {
        attributes {
            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, 'instrumented-jar'))
        }
    }
}

我们可以查看另一个消费者端的报告,以查看将请求每个依赖项的哪些属性。

gradle resolvableConfigurations --configuration testRuntimeClasspath
Attributes
    - org.gradle.category            = library
    - org.gradle.dependency.bundling = external
    - org.gradle.jvm.version         = 11
    - org.gradle.libraryelements     = instrumented-jar
    - org.gradle.usage               = java-runtime

resolvableConfigurations 报告是 outgoingVariants 报告的补充。通过分别在关系的消费者和生产者端运行这两个报告,您可以准确地了解依赖项解析期间匹配涉及的属性,并更好地预测配置解析时的结果。

现在,我们说,无论何时要解析测试运行时类路径,我们都在寻找已插桩的类。但是,存在一个问题:在我们的依赖项列表中,我们有 JUnit,它显然没有被插桩。因此,如果我们在这里停止,Gradle 将会失败,并解释说没有提供已插桩类的 JUnit 变体。这是因为我们没有解释,如果不可用已插桩版本,则可以使用常规的 jar。为此,我们需要编写一个兼容性规则

示例 9. 兼容性规则
consumer/build.gradle.kts
abstract class InstrumentedJarsRule: AttributeCompatibilityRule<LibraryElements> {

    override fun execute(details: CompatibilityCheckDetails<LibraryElements>) = details.run {
        if (consumerValue?.name == "instrumented-jar" && producerValue?.name == "jar") {
            compatible()
        }
    }
}
consumer/build.gradle
abstract class InstrumentedJarsRule implements AttributeCompatibilityRule<LibraryElements> {

    @Override
    void execute(CompatibilityCheckDetails<LibraryElements> details) {
        if (details.consumerValue.name == 'instrumented-jar' && details.producerValue.name == 'jar') {
            details.compatible()
        }
    }
}

我们需要在属性模式上声明它

consumer/build.gradle.kts
dependencies {
    attributesSchema {
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE) {
            compatibilityRules.add(InstrumentedJarsRule::class.java)
        }
    }
}
consumer/build.gradle
dependencies {
    attributesSchema {
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE) {
            compatibilityRules.add(InstrumentedJarsRule)
        }
    }
}

就是这样!现在我们有

  • 添加了一个提供已插桩 jar 的变体

  • 解释了此变体是运行时的替代品

  • 解释了消费者仅在测试运行时需要此变体

因此,Gradle 提供了一种强大的机制,可以根据偏好和兼容性选择正确的变体。更多详细信息可以在文档的变体感知插件部分中找到。

通过向现有属性添加值(如我们所做的那样),或通过定义新属性,我们正在扩展模型。这意味着所有消费者都必须了解此扩展模型。

对于本地消费者,这通常不是问题,因为所有项目都理解并共享相同的模式,但是,如果您必须将此新变体发布到外部存储库,则意味着外部消费者必须在他们的构建中添加相同的规则才能通过。对于生态系统插件(例如:Kotlin 插件),这通常不是问题,因为在任何情况下,如果不应用插件,则无法使用它,但如果您添加自定义值或属性,则会出现问题。

因此,避免发布仅供内部使用的自定义变体

面向不同平台

库通常会面向不同的平台。在 Java 生态系统中,我们经常看到同一个库的不同工件,它们通过不同的分类器来区分。一个典型的例子是 Guava,它发布为以下形式:

  • guava-jre 用于 JDK 8 及更高版本

  • guava-android 用于 JDK 7

这种方法的问题在于,分类器没有与之相关的语义。特别是依赖关系解析引擎无法根据消费者的要求自动确定要使用哪个版本。例如,最好表达你对 Guava 的依赖关系,并让引擎根据兼容性在 jreandroid 之间进行选择。

Gradle 为此提供了一个改进的模型,它没有分类器的缺点:属性。

特别是在 Java 生态系统中,Gradle 提供了一个内置属性,库作者可以使用它来表达与 Java 生态系统的兼容性:org.gradle.jvm.version。此属性表示消费者为了正常工作必须具有的最低版本

当你应用 javajava-library 插件时,Gradle 会自动将此属性与传出的变体关联起来。这意味着所有使用 Gradle 发布的库都会自动告知它们使用的目标平台。

默认情况下,org.gradle.jvm.version 设置为源集主编译任务的 release 属性(或作为回退到 targetCompatibility 值)的值。

虽然此属性会自动设置,但 Gradle 默认情况下不会让你为不同的 JVM 构建项目。如果你需要这样做,则需要按照 关于变体感知匹配的说明创建额外的变体。

Gradle 的未来版本将提供自动为不同 Java 平台构建的方法。