4-3: Nested DSL ​
In our journey through the DSL (Domain-specific language) landscape, we've encountered configurations that use a singular block, particularly within the slack test project. However, real-world applications often throw us into the depths of intricate, multi-level nested configurations, such as the Extension of the Android Gradle Plugin (AGP) shown below.
// depth = 0
android {
// depth = 1
defaultConfig {
// depth = 2
applicationId = "me.xx2bab.buildinaction.app"
}
buildTypes { // NamedDomainObjectContainer
named("debug") {
isMinifyEnabled = false
}
named("released") { ... }
}
...
}
In this exploration, we'll dive into various multifaceted scenarios for nesting DSLs. We'll start with the single object configurations, examine the mechanics of Kotlin Lambda and Groovy Closure transmissions, and delve into the enigma of the NamedDomainObjectContainer
.
You'll find the source code nestled in the slack project. Here's a quick breakdown:
- The Plugin project resides at slack/plugins/slack-nested-blocks;
- Tinker with the Plugin configuration at slack/plugin-config/src/main/kotlin/slack-nested-blocks-config.gradle.kts;
- The Android Application test project, a bearer of the Plugin, resides in slack/app. Remember to activate the appropriate Plugin in app.gradle.kts during testing.
4-3-1: Collection Object Configuration: NamedDomainObjectContainer ​
Though we've brushed over the NamedDomainObjectContainer
and the difference of buildTypes{...}
between two DSLs (Groovy and Gradle) in sections 2-3, it's time we really got our hands dirty. The essence of NamedDomainObjectContainer
is to create a collection within the DSL, tailored for effortless element operations:
- The elements within are identifiable by name. Manipulation—be it addition, removal, modification, or queries—is facilitated through a suite of APIs. Notably, the resulting set of queries is staying live.
- Internally, these elements maintain order and uniqueness, standing on the foundation of
SortedSet
.
Let's dissect this collection further with a piece of sample code.
The Name Convention ​
Leading the NamedDomainObjectContainer
parade is the Named
interface. This refers to the org.gradle.api.Named
interface, which mandates that collection elements should implement the getName()
method. The name retrieved through this method is used as the collection's identifier.
public interface Named {
String getName();
}
For instance, within the context of buildTypes
, identifiers like "debug" and "staging" represent the name
attribute of the buildType
.
buildTypes {
getByName("debug") {
isMinifyEnabled = false
}
register("staging") {
isMinifyEnabled = false
}
}
DSL Configuration ​
Let's extend our current perspective with a hypothetical scenario via the slack notification Plugin: broadcasting build notifications across multiple channels. Our goal is to engineer a DSL configuration, parallel in simplicity to buildTypes
, enabling users to easily specify channel parameters:
slackNotification2 {
...
channels {
register("androidTeam") {
token.set(slackToken)
channelId.set(slackChannelId)
}
register("mobileTeam") {
token.set(slackToken2)
channelId.set(slackChannelId2)
}
}
}
At a cursory glance at the configuration design, our next steps become clear: we must uncouple the token
and channelId
from the original SlackNotificationExtension
, placing them in a new class.
abstract class SlackNotificationExtension {
abstract val enabled: Property<Boolean>
abstract val message: Property<String>
abstract val channels: NamedDomainObjectContainer<SlackChannel>
}
interface SlackChannel: Named {
val token: Property<String>
val channelId: Property<String>
// If the `Named` interface doesn't meet your requirements,
// you could alternatively create a String property called "name",
// or deploy the getName() method as a substitute.
// val name: String
}
Upon initializing the SlackNotificationExtension
, it's delegated to Gradle as a managed type, properties such as channels
are getting initialized automatically. And a point to underscore: ensure that collection elements implement Named
interface or provide the name
property. This practice paves the way for name-based filtering and querying via various APIs:
// Direct return
slackExtension.channels.getByName("name")
// Procures its Provider type - `NamedDomainObjectProvider<T>`
slackExtension.channels.named("name")
Lastly, if you wish to manually create a NamedDomainObjectContainer
, you can use project.container(Class<T>)
or project.objects.domainObjectContainer(Class<T>)
. The difference between these two methods is elaborated in Section 4-5.
The DomainObjectCollection ​
Although NamedDomainObjectContainer<T>
is frequently utilized in practice,DomainObjectCollection<T>
serves as a top-level interface and should be taken into account. There are lots of sub-classes that we often engage with, notably:
TaskContainer
(accessible viaproject.tasks
) is a direct implementation ofNamedDomainObjectContainer<T>
.- Classes like
PluginContainer
(accessible throughproject.plugins
) andDependencySet
extend from theDomainObjectCollection<T>
directly.
The DomainObjectCollection<T>
is a direct child of java.util.Collection<T>
. Its essence is to provide enhancements over the standard collection methods in the realm of Gradle. This includes adding or removing elements with callback features and auto-updating result sets based on filtering criteria. With DomainObjectCollection
having many derived subclasses, including our much-referenced NamedDomainObjectContainer
, let's delve into some of the basic APIs that DomainObjectCollection
offers.
// As soon as an element is added,
// this callback will print its name.
slackExtension.channels.whenObjectAdded {
println(this.name)
}
// The matching(...) method yields a `NamedDomainObjectSet<T>`.
// This is a live collection that auto-updates based on the filter.
val androidSet = slackExtension.channels
.matching { it.name.contains("android", ignoreCase = false) }
// It's currently empty because the script hasn't evaluated,
// so no channels are added.
println("androidSet: " + androidSet.size)
// Adding a default channel for illustration.
slackExtension.channels.add(object : SlackChannel{
override val token: Property<String>
get() = project.objects
.property<String>()
.convention("token")
override val channelId: Property<String>
get() = project.objects
.property<String>()
.convention("id")
override fun getName(): String {
return "androidDefault"
}
})
// Now, the androidSet reflects its new size.
println("androidSet: " + androidSet.size)
This live data mechanism might remind you of the Provider
paradigm we examined earlier. Furthermore:
- If you need to filter collections based on subtypes, consider using
withType<S>()
(provided by thekotlin-dsl
Plugin). - The method
TaskCollection#whenTaskAdded(...)
introduced in a prior section is essentially built uponwhenObjectAdded(...)
. - There are a few Gradle extension classes that has similar design akin to
NamedDomainObjectContainer
, such asExtensionContainer
(accessible throughproject.extension
) which providescreate(...)
,getByName(...)
andgetByType(...)
.
The discussions in the earlier sections have largely deconstructed the core content of NamedDomainObjectContainer
. In conclusion, we've gathered the discussions on all interfaces and selected methods to present a core class diagram for NamedDomainObjectContainer
(Figure 4.3.1). It's advisable to revisit this topic while referencing the diagram in conjunction with the API:
4-3-2: Single Object Configuration: Action ​
The power of NamedDomainObjectContainer
enabled us to send messages across multiple channels. However, imagine a scenario where every channel broadcasts an identical message. The message
attribute of each SlackChannel
would be repetitive. What if, akin to the AGP's defaultConfig{}
, we could set default attributes, eliminating the need for such redundancy?
slackNotification2 {
enabled.set(true)
defaultConfig {
message.set("built successfully...")
}
channels {
...
}
}
interface DefaultConfig {
val message: Property<String>
}
Having introduced a fresh DefaultConfig
interface, the next logical step is to delve into how nested configurations of single child objects, like defaultConfig{}
, can be implemented.
Kotlin Lambdas and Trailing Lambdas ​
Kotlin has a great convention for lambda expressions: when a function's last parameter is another function, the lambda expression can be moved outside of the function call's parentheses. Consider the following:
fun defaultConfig(fileName: String, action: () -> Unit) {...}
slackNotification2 {
defaultConfig("fileA") {
...
}
}
If there's only one function parameter, it simplifies to:
fun defaultConfig(action: () -> Unit) {...}
slackNotification2 {
defaultConfig {
...
}
}
This seems close to our goal. However, the method accepts a lambda with no arguments, and we haven't yet configured it for a DefaultConfig
instance. By introducing a parameter into our lambda, the structure becomes ideal:
fun defaultConfig(action: (d: DefaultConfig) -> Unit) {...}
slackNotification2 {
defaultConfig { d ->
d.message.set("...")
}
}
We've now crafted a basic structure for the defaultConfig{}
configuration. Delving deeper, Kotlin offers a unique lambda type, A.(B) -> C
, known as "Function literals with a receiver". This means the lambda:
- Uses A as the receiver (
this
= A). - Accepts B as an input parameter and returns C.
For the sake of this discussion, if we exclude B and C (and reflect on the previous example), a succinct "type-safe builder" syntax, A.() -> Unit
, emerges. Let's incorporate this into our defaultConfig(...)
method:
val defaultConfig: DefaultConfig = ...
fun defaultConfig(action: DefaultConfig.() -> Unit) {
action.invoke(defaultConfig)
}
slackNotification2 {
defaultConfig {
message.set("...")
// It's equivalent to `this.message.set("...")`
}
}
So, we've used a Domain Specific Language (DSL) to configure the DefaultConfig
object, leveraging Kotlin's higher-order function capabilities.
Another similar approach is to use Kotlin's apply(...)
extension, and then a comparable outcome is achieved:
// `apply()` is designed to accept a type-safe builder.
public inline fun <T> T.apply(block: T.() -> Unit): T {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
block()
return this
}
fun defaultConfig(action: (d: DefaultConfig) -> Unit) {...}
slackNotification2 {
defaultConfig { d ->
d.apply {
message.set("...")
}
}
}
Groovy's Closure Mechanism ​
There's a notable limitation with the type-safe builder: it exclusively supports Kotlin Gradle DSL. Thus, the Gradle Groovy DSL works in a different way. Let's take Gradle's dependencies{}
API as an example:
// org.gradle.api.Project
void dependencies(Closure configureClosure);
// build.gradle
dependencies {
...
}
Groovy's Closure
seems less straightforward than Kotlin's type-safe builder, especially for newcomers. It's not immediately clear what object the "delegate" corresponds to just by inspecting the method's signature. One would need to delve into the source code or accompanying documentation to discover that the above Closure
eventually ties back to a DependencyHandler
object.
/**
* ...
* This method executes the given closure against the {@link DependencyHandler}
* for this project. The {@link DependencyHandler} is passed to
* the closure as the closure's delegate.
* ...
*/
Embracing Gradle's Action Interface ​
It's evident that Gradle acknowledged the limitations and intricacies of earlier approaches. Enter the org.gradle.api.Action
interface, a Java-based mechanism from Gradle, is designed to:
- Enable single object configuration in both DSL scripts.
- Support smooth modification of configurations across three languages.
Here's an example showcasing a refined SlackNotificationExtension
using this interface:
import org.gradle.api.Action
...
abstract class SlackNotificationExtension @Inject constructor(
objects: ObjectFactory
) {
val defaultConfig: DefaultConfig = objects.newInstance(
DefaultConfig::class.java)
fun defaultConfig(action: Action<DefaultConfig>) {
action.execute(defaultConfig)
}
}
// Gradle Kotlin DSL
slackNotification2 {
defaultConfig {
message.set("...")
}
}
// Gradle Groovy DSL
slackNotification2 {
defaultConfig {
message = "..."
}
}
Let's unravel the magic behind this with three key points:
Capabilities of the
Action
Interface: The secret sauce behindAction
is Gradle's runtime bytecode modification, making it adaptable to varying scripts. For instance, on the Gradle Groovy DSL, Gradle dynamically creates an overloaded function with the same name that takes a Groovy Closure. This necessitates Gradle managing the instantiation of related classes or properties, or explicitly employing utility classes likeObjectFactory
to initialize the corresponding objects.You might wonder why the default constructor has an
@Inject
annotation and what exactly is theObjectFactory
. The@Inject
annotation originates from the Java Dependency Injection standard (JSR-330). This annotation signifies constructors, class attributes, and methods that can be injected. For those familiar with dependency injection frameworks like Guice and Dagger, this should ring a bell. While they are implementations of this standard, they still have slight differences. Gradle, on the other hand, allows certain commonly-used services (utility classes) to be annotated with this label, indicating that they require injection by Gradle. These services include:ObjectFactory
ProjectLayout
ProviderFactory
WorkerExecutor
FileSystemOperations
ArchiveOperations
ExecOperations
ToolingModelBuilderRegistry
Throughout this book, we've used several of these services. For instance, we've used
ObjectFactory
both in the previous section and here. This utility class enables the creation of various Gradle Custom Types, including theNamedDomainObjectContainer
. Notably, itsnewInstance(...)
method can be used to explicitly create our customExtension
objects. By doing so, we achieve runtime optimizations synonymous with Gradle Managed Classes or Properties, which we first introduced in Section 2-4.Reasoning for the Public Property Declaration: Declaring
val defaultConfig: DefaultConfig
as public ensures simultaneous support for direct property access, without making it obligatory for users to use an additional block for configurations. (We can also see it in AGP configuration)KotlinslackNotification2.defaultConfig.message.set("...")
With the combination of the Action
interface and ObjectFactory
, you have a one-stop method to ensure compatibility across all languages in script DSLs and Plugins.
In the past, Gradle's Extensions often paired Groovy's Closure
with Java's Action
interface, especially when Groovy DSL was the only supported script. But with Gradle Kotlin DSL joining the party and the associated compatibility challenges, Action
emerged as the best practice. The official documentation echoes this sentiment, advocating the shift from the dual Closure
and Action
approach to predominantly using Action
only.
Nested Configurations with the Action API ​
Building on the above methods, crafting multi-tier nested DSL configurations is a breeze. To demonstrate, let's utilize the Action
approach:
interface Package {
val id: Property<String>
}
abstract class DefaultConfig @Inject constructor(
objectFactory: ObjectFactory
) {
abstract val message: Property<String>
val pkg: Package = objectFactory.newInstance(
Package::class.java)
fun pkg(action: Action<Package>) {
action.execute(pkg)
}
}
// depth = 0
slackNotification2 {
// depth = 1
defaultConfig {
message.set("...")
// depth = 2
pkg {
id.set("...")
}
}
...
}
This example showcases a three-tier nesting, apt for the slack notification configurations.
4-3-3: Passing Function: Kotlin Lambdas and Groovy Closures ​
Using Action
for configuring a single object is incredibly handy, eliminating the need for assistance from the APIs of Kotlin and Groovy. However, in reality, Kotlin Lambda and Groovy Closure serve additional purposes within Gradle scripts. Dive with me into a common scenario to see them in action:
Suppose you're integrating a third-party Plugin, while not all build types need this Plugin active. An instance would be when the Dex Packer plugin remains inactive during a local "debug" build.
A typical workaround involves scripting to fetch the input Task name, then deciding the Plugin's activation status:
slackNotification2 {
enabled.set(if(taskName.contains("debug") false else true))
}
Yet, when the rules become intricate, this method may fall short. Consider this: we've just rolled out the "send to multiple channels" capability in the plugin, so if we don't want the "mobileTeam" to be alerted during a "debug" build, the current Extension won't cut it.
An intuitive solution for a developer would be: Wouldn't directly passing a function into the Extension resolve the issue? Like so:
slackNotification2 {
...
selectChannelsByVariant { variant, channel ->
!(variant == "debug" && channel == "mobileTeam")
}
}
This snippet is a model of clarity and flexibility. Crafting this mechanism isn't rocket science either. You just define methods for the necessary Gradle script types upon Kotlin Lambda and Groovy Closure:
internal abstract val kotlinChannelsByVariantSelector: Property<
ChannelsByVariantSelector>
internal abstract val groovyChannelsByVariantSelector: Property<
Closure<Boolean>>
// For the Gradle Kotlin DSL
fun selectChannelsByVariant(selector: ChannelsByVariantSelector) {
kotlinChannelsByVariantSelector.set(selector)
}
// And for the Gradle Groovy DSL
fun selectChannelsByVariant(selector: Closure<Boolean>) {
groovyChannelsByVariantSelector.set(selector.dehydrate())
}
You'll observe we've enclosed it within Property
. When invoking the Task, it's common to merge selectors from both languages:
private fun isTheChannelRequiredByVariant(
variantName: String,
channelName: String
) = when {
kotlinChannelsByVariantSelector.isPresent -> {
kotlinChannelsByVariantSelector.get()
.invoke(variantName, channelName)
}
groovyChannelsByVariantSelector.isPresent -> {
groovyChannelsByVariantSelector.get()
.call(variantName, channelName)
}
else -> true
}
In conclusion, by combining the strengths of both Kotlin and Groovy, we've crafted a robust solution to our initial problem.
4-3-4: Hands-on Demo: Multi-Channel Notification, DefaultConfig, and Variant Filtering ​
Recall that through DSL nesting, we addressed three distinct requirements:
- Dispatching build notifications to various Slack channels: This was achieved using the
NamedDomainObjectContainer
which serves as a collection object configuration. - Configuring the association between Variants and specific channels, such as directing Debug builds only to "androidTeam" or routing Release builds to multiple channels: This was made possible by Kotlin Lambda and Groovy Closure.
- Permitting users to personalize the
DefaultConfig
configuration (a DSL nested at multiple levels): Implemented using theAction
interface.
Having discussed these components, we've successfully built the SlackNotificationExtension
. To wrap it up, we'll illustrate the Plugin configuration and Task logic:
abstract class SlackNotificationPlugin : Plugin<Project> {
...
override fun apply(project: Project) {
val slackExtension = project.extensions.create(
"slackNotification2",
SlackNotificationExtension::class.java
).apply {
enabled.convention(true)
defaultConfig.message.convention("")
channels.whenObjectAdded { // Default values setup
token.convention("")
channelId.convention("")
channelMsg.convention("")
}
}
...
project.plugins.withType<AppPlugin> {
...
androidExtension.onVariants { appVariant ->
...
// Registering the Task with all prepared parameters
val taskProvider = project.tasks.register(
"...",
SlackNotificationTask::class.java
) {
variantName.set(appVariant.name)
kotlinChannelsByVariantSelector.set(
slackExtension.kotlinChannelsByVariantSelector)
groovyChannelsByVariantSelector.set(
slackExtension.groovyChannelsByVariantSelector)
channels = slackExtension.channels
defaultMessage.set(slackExtension.defaultConfig.message)
builtArtifactsLoader.set(artifactsLoader)
apkFolder.set(apkDir)
notifyPayloadLog.set(logFile)
}
}
}
}
}
abstract class SlackNotificationTask : DefaultTask() {
@get:Input
abstract val variantName: Property<String>
@get:Optional
@get:Input
abstract val kotlinChannelsByVariantSelector: Property<
ChannelsByVariantSelector>
@get:Optional
@get:Input
abstract val groovyChannelsByVariantSelector: Property<
Closure<Boolean>>
@get:Input
var channels: NamedDomainObjectContainer<SlackChannel>? = null
@get:Input
abstract val defaultMessage: Property<String>
...
@TaskAction
fun notifyBuildCompletion() {
...
// Iterate all channels and send one by one
channels!!.forEach { slackChannel ->
// Pre-check before we post
if (!isTheChannelRequiredByVariant(
variantName.get(),
slackChannel.name
)) {
return@forEach
}
val customMsg = slackChannel.channelMsg.get().ifBlank {
defaultMessage.get()
} + "\n $baseContent"
val (code, responseBody) = postOnSlack(
slackChannel.token.get(),
slackChannel.channelId.get(),
customMsg
)
...
}
}
...
}
As for the result, if you execute the command ./gradlew clean :app:assembleAndNotifyDebug
, you'll observe a single log entry:
[Pg][Slack]: sending to androidTeam
In contrast, executing ./gradlew clean :app:assembleAndNotifyRelease
presents two log entries:
> [Pg][Slack]: sending to androidTeam
> [Pg][Slack]: sending to mobileTeam
4-3-5: In Summary ​
This section explains solutions to tangible challenges, simplifying Plugin configurations for users. For developers, this means bolstered confidence in Plugin creation, ensuring compatibility with both Groovy and Kotlin scripts.