在多项目构建中,一个常见的模式是一个项目使用另一个项目的工件。

一般来说,Java 生态系统中最简单的使用形式是,当 A 依赖于 B 时,A 将依赖于项目 B 生成的 jar

考虑因素和可能的解决方案

声明跨项目依赖关系的一个常见的反模式是

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

这种发布模型是不安全的,可能导致不可重现且难以并行化的构建。

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

您可以在生产者端定义一个配置,作为生产者和消费者之间工件交换的途径。

consumer/build.gradle
dependencies {
    instrumentedClasspath(project(path: ":producer", configuration: 'instrumentedJars'))
}
consumer/build.gradle.kts
dependencies {
    instrumentedClasspath(project(mapOf(
        "path" to ":producer",
        "configuration" to "instrumentedJars")))
}

然而,消费者必须显式地告知它依赖于哪个配置,这是不推荐的。如果您计划发布具有此依赖关系的组件,则很可能导致元数据损坏。

本节解释了如何通过使用变体定义项目之间的“交换”来正确创建跨项目边界

工件的变体感知共享

Gradle 的 变体模型 允许消费者使用属性指定需求,而生产者也使用属性提供适当的传出变体。

例如,像 project(":myLib") 这样的单个依赖声明可以根据架构选择 myLibarm64i386 版本。

为了实现这一点,必须在消费者和生产者配置上都定义属性。

当配置具有属性时,它们参与变体感知解析。这意味着每当使用任何依赖声明(例如 project(":myLib"))时,它们都会成为解析的候选对象。

生产者配置上的属性必须与同一项目提供的其他变体一致。引入不一致或模棱两可的属性可能导致解析失败。

在实践中,您定义的属性通常取决于生态系统(例如,Java、C++),因为特定于生态系统的插件通常应用不同的属性约定。

考虑一个 Java Library 项目的示例。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

这告诉我们 runtimeElements 配置包含 5 个属性

  1. org.gradle.category 指示此变体表示一个

  2. org.gradle.dependency.bundling 指定依赖是外部 jar(而不是重新打包在 jar 内部)。

  3. org.gradle.jvm.version 表示支持的最低 Java 版本,即 Java 11。

  4. org.gradle.libraryelements 显示此变体包含 jar 中通常找到的所有元素(类和资源)。

  5. org.gradle.usage 将变体定义为 Java 运行时,适用于编译和运行时。

为了确保在执行测试时使用 instrumentedJars 变体代替 runtimeElements,我们必须将类似的属性附加到这个新变体。

此配置的关键属性是 org.gradle.libraryelements,因为它描述了变体包含的内容。我们可以相应地设置 instrumentedJars 变体

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'))
        }
    }
}
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"))
    }
}

这确保了 instrumentedJars 变体被正确识别为包含类似于 jar 的元素,从而使其能够被适当地选择。

选择正确的属性是此过程中最具挑战性的部分,因为它们定义了变体的语义。在引入新属性之前,始终考虑现有属性是否已经传达了所需的语义。如果不存在合适的属性,您可以创建一个新的属性。但是,请注意——添加新属性可能会在变体选择期间引入歧义。在许多情况下,添加属性需要将其一致地应用于所有现有变体。

我们为运行时引入了一个新的变体,它提供 instrumented 类而不是普通类。因此,消费者现在面临两个运行时变体之间的选择

  1. runtimeElements - 由 java-library 插件提供的默认运行时变体。

  2. instrumentedJars - 我们添加的自定义变体。

如果我们希望将 instrumented 类包含在测试运行时类路径中,我们现在可以将消费者端的依赖关系声明为常规项目依赖关系

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

如果我们在这里停止,Gradle 仍然会解析 runtimeElements 变体,而不是 instrumentedJars 变体。

发生这种情况是因为 testRuntimeClasspath 配置请求将 libraryelements 属性设置为 jar 的变体,而我们的 instrumented-jars 值不匹配。

为了解决这个问题,我们需要更新请求的属性,以专门针对 instrumented jars

consumer/build.gradle
configurations {
    testRuntimeClasspath {
        attributes {
            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements, 'instrumented-jar'))
        }
    }
}
consumer/build.gradle.kts
configurations {
    testRuntimeClasspath {
        attributes {
            attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class.java, "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 报告的补充。

通过分别在关系的消费者端和生产者端运行这两个报告,您可以准确地看到在依赖关系解析期间哪些属性参与匹配,并更好地预测配置被解析时的结果。

在这一点上,我们指定测试运行时类路径应解析具有instrumented 类的变体。

但是,存在一个问题:某些依赖项(如 JUnit)不提供 instrumented 类。如果我们在这里停止,Gradle 将失败,并声明不存在 JUnit 的兼容变体。

发生这种情况是因为我们没有告诉 Gradle,当 instrumented 变体不可用时,可以回退到常规 jar。为了解决这个问题,我们需要定义一个兼容性规则

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
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
dependencies {
    attributesSchema {
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE) {
            compatibilityRules.add(InstrumentedJarsRule)
        }
    }
}
consumer/build.gradle.kts
dependencies {
    attributesSchema {
        attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE) {
            compatibilityRules.add(InstrumentedJarsRule::class.java)
        }
    }
}

就是这样!现在我们有了

  • 添加了一个提供 instrumented jars 的变体。

  • 指定此变体是运行时的替代品。

  • 定义消费者仅在测试运行时需要此变体。

Gradle 提供了一个强大的机制,用于根据偏好和兼容性选择正确的变体。有关更多详细信息,请查看文档的 变体感知插件部分

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

对于本地消费者,这通常不是问题,因为所有项目都共享相同的模式。但是,如果您需要将这个新变体发布到外部仓库,外部消费者也必须将相同的规则添加到他们的构建中才能使其工作。

对于生态系统插件(例如,Kotlin 插件),这通常不是问题,因为如果不应用插件,则无法使用。但是,如果您添加自定义值或属性,则会变得有问题。

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