15、自定义插件开发(二):插件扩展(Extension)、DSL配置、延迟配置(Lazy Configuration)

上一章我们搭好了插件的骨架,写了个能跑起来的Hello World。但说实话,那种硬编码的插件没什么实际用处。真正的插件,得让用户能配置、能定制。这就是今天要聊的核心——插件扩展(Extension)和DSL配置。

我个人习惯把Extension看作是插件的「对外接口」。你想想看,用户凭什么用你的插件?不就是因为你能让他通过几行简洁的DSL,就把复杂的事情搞定吗?

一、Extension:插件的「配置面板」

先从一个最简单的场景说起。假设我们要写一个代码生成插件,用户需要指定生成的文件名、包名、还有是否覆盖已存在的文件。这些配置项,就是Extension的职责。

// build.gradle.kts
plugins {
    id("com.example.codegen")
}

codegen {
    className = "UserEntity"
    packageName = "com.example.entity"
    overwrite = true
}

这段DSL看起来很自然对吧?但背后是怎么实现的呢?

首先,我们需要定义一个Extension类。它本质上就是一个普通的Kotlin类,用open修饰,属性用var声明:

// src/main/kotlin/com/example/CodegenExtension.kt
open class CodegenExtension {
    var className: String = "DefaultClass"
    var packageName: String = "com.example"
    var overwrite: Boolean = false
}

然后在插件类里注册它:

// src/main/kotlin/com/example/CodegenPlugin.kt
class CodegenPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val extension = project.extensions.create(
            "codegen",          // DSL中的名字
            CodegenExtension::class.java
        )
        // 后续可以用extension读取配置
    }
}

就这么简单?嗯,基本框架确实就这么简单。但我在项目中遇到过一个问题——Extension的创建时机。如果你在apply方法里直接读取extension的属性,那时候用户还没配置呢,拿到的全是默认值。

⚠️ 注意:Extension的配置发生在build.gradle执行阶段,而apply方法在插件加载时就执行了。两者有先后顺序,千万别在apply里直接读取用户配置。

二、DSL配置:让Extension「活」起来

刚才的Extension只能处理简单的key-value配置。但现实中的需求往往更复杂。比如,用户可能需要配置多个生成目标:

codegen {
    targets {
        register("entity") {
            className = "UserEntity"
            packageName = "com.example.entity"
        }
        register("vo") {
            className = "UserVO"
            packageName = "com.example.vo"
        }
    }
}

这种嵌套的DSL结构,就需要用到NamedDomainObjectContainer了。说白了,它是一个容器,专门管理一组有名字的对象。

open class CodegenTarget(val name: String) {
    var className: String = ""
    var packageName: String = ""
}

open class CodegenExtension {
    val targets: NamedDomainObjectContainer<CodegenTarget> =
        project.objects.domainObjectContainer(CodegenTarget::class.java)
}

这里有个坑——CodegenTarget的构造函数必须接受一个String类型的name参数。这是NamedDomainObjectContainer的硬性要求,因为每个对象都需要一个唯一标识。

💡 小技巧:如果你想让DSL更流畅,可以给Extension加一个targets方法,返回一个Action闭包。这样用户就能用targets { ... }的写法了。

三、延迟配置(Lazy Configuration):告别「先来后到」的烦恼

好,现在Extension能用了,DSL也漂亮了。但还有一个问题——配置顺序。你想想看,如果Task需要用到Extension的配置,但Task的配置发生在Extension之前,怎么办?

Gradle团队早就想到了这个问题。从Gradle 4.9开始,他们引入了延迟配置(Lazy Configuration)机制。核心思想就是:不直接存值,而是存一个「值的提供者」

open class CodegenExtension {
    val className: Property<String> = objects.property(String::class.java)
        .convention("DefaultClass")  // 设置默认值

    val overwrite: Property<Boolean> = objects.property(Boolean::class.java)
        .convention(false)
}

看到区别了吗?不再是普通的String和Boolean,而是Property<String>和Property<Boolean>。用户配置时:

codegen {
    className.set("UserEntity")
    overwrite.set(true)
}

Task读取时:

class CodegenTask : DefaultTask() {
    @Input
    val className: Property<String> = objects.property(String::class.java)

    @TaskAction
    fun generate() {
        // 在任务执行时才真正取值
        val name = className.get()
        logger.lifecycle("Generating $name...")
    }
}

这样做的好处是什么?配置和执行的时机完全解耦了。不管用户什么时候配置,Task只在自己执行时才去取值。我曾经在一个多模块项目里遇到过因为配置顺序导致的诡异bug,排查了两天才发现是Extension和Task的初始化顺序问题。换成Lazy Configuration后,问题迎刃而解。

四、核心知识体系

说了这么多,我画了一张图来总结整个插件扩展的架构。你看完应该能一目了然:

插件扩展核心架构 Plugin apply(project) Extension project.extensions.create() DSL配置 build.gradle.kts Lazy Configuration Property<T> / Provider<T> Task @Input / @TaskAction 关键特性 • 配置与执行解耦 • 支持默认值(convention) • 避免NPE • 支持链式调用 • 增量构建友好 • 线程安全

五、实战:完整的插件扩展示例

光说不练假把式。我们来写一个完整的例子,把上面讲的东西串起来。

// 1. 定义Extension
open class CodegenExtension(private val objects: ObjectFactory) {
    val className: Property<String> = objects.property(String::class.java)
        .convention("DefaultEntity")
    
    val packageName: Property<String> = objects.property(String::class.java)
        .convention("com.example")
    
    val overwrite: Property<Boolean> = objects.property(Boolean::class.java)
        .convention(false)
}

// 2. 定义Task
abstract class CodegenTask : DefaultTask() {
    @get:Input
    abstract val className: Property<String>
    
    @get:Input
    abstract val packageName: Property<String>
    
    @get:Input
    abstract val overwrite: Property<Boolean>
    
    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty
    
    @TaskAction
    fun generate() {
        val name = className.get()
        val pkg = packageName.get()
        val dir = outputDir.get().asFile
        
        logger.lifecycle("Generating $name in package $pkg")
        // 实际生成代码的逻辑...
    }
}

// 3. 注册插件
class CodegenPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val extension = project.extensions.create(
            "codegen",
            CodegenExtension::class.java,
            project.objects
        )
        
        project.tasks.register("generateCode", CodegenTask::class.java) { task ->
            task.className.set(extension.className)
            task.packageName.set(extension.packageName)
            task.overwrite.set(extension.overwrite)
            task.outputDir.set(
                project.layout.buildDirectory.dir("generated/codegen")
            )
        }
    }
}

🔑 关键点:

  • Task的属性用abstract修饰,Gradle会自动实现
  • @get:Input而不是@Input,这是Kotlin的写法
  • Task和Extension之间通过Property连接,而不是直接传值
  • 这样配置,即使用户在Task注册之后才配置Extension,也能正确读取

六、避坑指南

最后,分享几个我踩过的坑:

  • Property的get()方法只能在Task执行时调用。我曾经在配置阶段调用了get(),结果拿到的全是默认值。记住,get()是「消费」操作,只能在执行阶段用。
  • 不要混用普通属性和Property。如果一个Extension里既有普通String又有Property<String>,用户会搞不清楚该用set()还是直接赋值。保持一致性很重要。
  • NamedDomainObjectContainer的注册顺序。如果你在配置阶段遍历container,可能有些对象还没注册完。用all {}回调可以解决这个问题。

嗯,关于插件扩展的内容,今天就聊到这里。这些模式我在好几个生产级插件里都用过,稳定可靠。你只要掌握了Extension、DSL配置、Lazy Configuration这三板斧,大部分插件需求都能轻松应对。