一文通读 Groovy 元对象协议 MOP

1,884 阅读23分钟

7. Groovy 元对象协议

编程中我们总是会听到 "元" ( Meta- ) 信息,或者 "元" 数据的一些说法,它们的通俗解释就是:描述信息的信息,或者是描述数据的数据。拿一张相片举例子,除了相片自身携带的图像信息以外,有关于它的印刷时间,拍摄时间,标签等都可以属于 "元信息"。在本篇文章中,信息本身就是 POJO / POGO 对象,而它的描述信息则是元方法 MetaMethod 和元属性 MetaProperty,后文统称两者为元信息。

由此引申,元编程 ( Metaprogramming ),就是 "用于编写程序的程序",简单来说,通过操纵,创建各种元信息来达到动态编程,或者是自动化编程的手段。一个例子就是:Grails/GORM 利用元编程的能力为数据查询动态合成类和方法。元编程依赖一种约定 —— 那就是元对象协议 Meta Object Protocol,简称 MOP。Groovy 的 MOP 本身很简单 —— 仅仅用一个 groovy.lang.MetaObjectProtocol 接口规范了如何获取元信息,设置元信息。因此我们的关注点也不在 MOP 本身,而是围绕 MOP 的各种应用:方法拦截,方法合成,方法注入

在 Java 当中,有关一个类的所有信息都已经在编译期间尘埃落定。我们常说 "Java 的动态反射" 也仅限于 "动态获取已有的静态信息",而不能在运行时修改它的类型,或者赋予它全新的行为。但如果一个类是在运行期间基于各种元信息构建 ( 拼凑 ) 出来的呢?Groovy 的确提供了这么一个类 —— Expando。

本章首先介绍 MOP 最基本的内容,对于 POJO 或者 POGO,应该如何获取它们的元信息,然后再介绍基于这些元信息的实用操作。对于方法拦截,方法注入,方法合成等内容本章围绕两个重要的方法进行:invokeMethodmethodMissing

7.1 POGO & POJO,MetaClass

POJO ( Plain Old Java Object ),简单 Java 对象,代表没有额外类继承 ( 只继承于 java.lang.Object ),没有额外接口实现,也没有被其它框架侵入的 Java 对象。从广义上来讲,它指不受任何特定 Java 对象模型,约定,框架约束的 Java 对象;而正因为它不和任何详细的业务功能绑定,从狭义上来看,POJO 又可以代表那些只用于承载数据,仅设置了 settergetter 方法的 Java 对象,因此 POJO 有时又会和 Entity,DTO 等概念混淆,本文的 POJO 指广义的概念。

同理,在 Groovy 中,POGO 代表了普通的 Groovy 对象,它既是 java.lang.Object 的子类,又天然地实现了 groovy.lang.GroovyObject 接口;而 POJO 在这里可以是任何其它 JVM 语言实现的。下面是 groovy.lang.GroovyObject 接口的内容:

// 来自 IDEA 的反编译内容。
public interface GroovyObject {
    @Internal
    default Object invokeMethod(String name, Object args) {
        return this.getMetaClass().invokeMethod(this, name, args);
    }

    @Internal
    default Object getProperty(String propertyName) {
        return this.getMetaClass().getProperty(this, propertyName);
    }

    @Internal
    default void setProperty(String propertyName, Object newValue) {
        this.getMetaClass().setProperty(this, propertyName, newValue);
    }
    
    MetaClass getMetaClass();
    void setMetaClass(MetaClass var1);
}

从方法名称来看,它们都和 元类型 MetaClass 紧密相关,事实也是如此。幸运的是,不论一个对象属于 POGO 还是 POJO,我们总通过 .metaClass 简单地提取出它们的元类型:

// 该对象是一个 Java 实现
def pojo = new POJO(10)

// 该对象是一个 Groovy 实现
def pogo = new POGO()

// 任何在 Groovy Script 中使用的类都可以使用 .metaClass ,这而与它的源 JVM 语言无关。
// 结合了之前 "统一访问原则" 的设计思想。
pogo.metaClass
pojo.metaClass

实际上,两者的 metaClass 的获取途径并不相同:对于 POJO,Groovy 维护用于获取 MetaClass 的一个 MetaClassRegistry。而对于 POGO ,其 .metaClass 指向接口中的 setMetaClass 方法,这个语法糖在前文介绍 "统一访问原则" 时提到过。

7.2 基于 MetaClass 获取元信息

无论是 POJO 还是 POGO,一旦获取了其 MetaClass,我们就能以类似 "Java 反射" 的形式在运行时获取这个类一切的元方法 MetaMethod 和元属性 MetaProperty ( 所以说访问修饰符在 Groovy 中 "形同虚设" )。

元方法 ( Meta Method ),包含了实例方法和静态方法,因此对应的获取方法叫 getMetaMethodgetStaticMetaMethod。如果要获取重载的方法列表,那么对应的方法则是 getMetaMethodsgetStaticMetaMethods 。获取的元方法可以通过 invoke 方法调用。下面是一个简单的例子:

def pojo = new POJO(10)
def methodName = "yawn"
def staticMethodName = "greet"

// 获取实例方法 (元方法) 的一个 "句柄"。
def method = pojo.metaClass.getMetaMethod(methodName)

// 通过 invoke 方法绑定一个实例并调用。
// 如果调用的方法是空括号方法,那么可以只传进一个实例。
method.invoke(pojo)

// 如果获取的元方法需要参数列表,则需要以可变参数的形式提供这些参数类型 Class, 或者是对应类型的任意 Object。
// 这里存在方法重载,如果只传入 null, 那么 Groovy 会疑惑第二个参数是 Class[] 还是 Object[] 因而报错。
// null as Class[] 是避免歧义的做法。
def staticMethod =  pojo.metaClass.getStaticMetaMethod(staticMethodName,null as Class[])
staticMethod.invoke(pojo)

// 下面演示了获取有参数列表的 "静态" 元方法的方式。
def staticMethodWithParam = pojo.metaClass.getStaticMetaMethod(staticMethodName,String.class)
// 静态方法不需要绑定特定对象。
staticMethodWithParam.invoke(null,"Tom")

元属性 ( Meta Property ),包含了实例属性和静态属性,对应的获取方法叫 getMetaPropertygetStaticMetaProperty。假定我们只是想对某些元信息的 "存在性" 做检查,那么可以使用 responseTo ( 检查元方法,在之前我们曾用于对 "鸭子类型" 进行检查 ) 或者是 hasProperty ( 检查元属性 ) 。

def pojo = new POJO(10)

def methodName = "SayHi"
def propertyName = "property"

println( "the pojo has the method ${methodName} ? ${pojo.respondsTo(methodName)?"yes":"no"}")
println( "the pojo has the method ${propertyName} ? ${pojo.hasProperty(propertyName)?"yes":"no"}")

7.4 动态访问对象

我们之前已经介绍了一个动态访问对象属性的语法糖 —— 通过重载的 getAt 方法。这个属性完全可能是对象已有的,或者是我们通过操纵元属性添加的。

def pojo = new POJO(10)
println pojo["property"]

下面介绍通过另一种方式:

println pojo."property"

或者是通过 GString 引用的方式指代属性名 ( 后文会经常使用这个操作方法 ):

def propertyName = "property"
println pojo."${propertyName}"

同理,如果要动态调用方法,除了 invokeMethod() 之外,还可以写成:

def methodName = "SayHi"
// 等价于 pojo.SayHi()
println pojo."${methodName}"()

此外,如果要查看 POGO 的所有属性 ( 包括 class 信息 ),可以通过 .properties ( .getProperties() 方法 ) 获取 Map 形式,或者是通过 .metaPropertyValues 获取 List 形式。这两个方法不是元对象协议的一部分,对 POJO 使用只能访问到 class 信息。

// 获取一个 POGO 对象的所有属性。
pogo.properties
// 由于返回值是 Map,因此可以像 k-v 对那样 "取值"。
pogo.properties["property"]

// 或者拿到 for 循环里遍历:
def name,value
(name,value) = ["",""]
def str = /property name {${-> name}},value is {${-> value}}/
pogo.properties.each {
    entry ->
        name = entry.key
        value = entry.value
        println str
}

// 获取链表形式
// getProperties() 方法本质上就是通过这个方法获取的,只不过额外包装了放入 Map 的过程。
pogo.metaPropertyValues.each {println "[${-> it.value}:${it.name}]"}

7.5 动态创建类

Groovy 提供了 Expando 来允许程序在运行时动态塑造出一个类。我们可以在构造器内部传入一个 Map 来任意指派它内部的属性,也可以通过赋值的形式随时为它注入全新的属性或者方法。

def namelessObj = new Expando(name:"jdbcConfiguration")

namelessObj.Ip = "192.168.2.140"
namelessObj.Port = "3306"
namelessObj.Db = "mysql"

// Expando 不支持统一访问原则,因此不能隐式地转换成 "uri" 属性。
namelessObj.getUri= {
    return "${delegate.Db}://${delegate.Ip}:${delegate.Port}"
}


// 支持对这个合成类的动态属性调用
println namelessObj["Ip"]

// 等价于以下访问方式
println namelessObj.Ip

// 支持对这个合成类的动态方法调用
def eval = "getUri"
println namelessObj."${eval}"()

// 等价于以下调用方式
// 访问不存在的属性返回 null
// 调用不存在的方法会被引导至 methodMissing 方法
println namelessObj.getUri()

它是轻量级的,且十分灵活。比如,为单元测试创建一个模拟对象 ( Mock ) 时,这个特性会大放光彩。

8. 基于 MOP 方法拦截

对于学习过 Spring 的读者而言,方法拦截一定不陌生。Groovy 实现 AOP 不需要借助任何的框架来实现。本文所提到的方法拦截,形式上非常接近于 Spring 的环绕通知 ( Around Advice ) 。在这个环绕通知内部,我们将自由地支配前向通知,后向通知,以及在何时去调用原方法等等。

这一大章介绍方法拦截的两个思路,两者的区别在于是我们是否有权限修改这个类定义。如果我们是这个类的作者,那么可以通过硬编码 GroovyInterceptable 接口实现,否则,我们就需要借助元类型 MetaClass 注入特定方法的形式在外部进行方法拦截。

8.0 闭包属性可能会代替方法调用

Groovy 有这样的语法现象:如果找不到一个实例的方法,那么就去寻找有无同名的闭包属性可以调用:

class POGO {
    // POGO 的一个名为 greet 的方法,优先被调用
    def greet(){
        println "greet by method"
    }
    // POGO 的一个名为 greet 的闭包属性,由于同名方法已经存在,因此该闭包不会被优先调用
    // 结合统一访问原则,以下写法会优先调用闭包: new POGO().greet, new POGO().getGreet().call()
    def greet = {
        println "greet by closure"
    }
    // 在同名方法不存在的情况下,Groovy 尝试去调用该闭包的 .call() 方法
    def hi = {
        println "hi by closure"
    }
    // 当发现方法不存在时的重载版 methodMiss() 方法
    def methodMissing(String name, def args) {
        println "a method $name doesn't exist."
        return null
    }
}

下面是一段测试结果:

// 当方法和闭包属性同名时,这种写法优先选择前者。
// greet by method
pogo.greet()

// 注意,这种写法总是获取闭包属性。
pogo.greet

// 没有可用方法时选择同名的闭包属性并调用 call 方法,或者说使用这个闭包属性 "顶替" 不存在的同名方法。
// hi by closure
pogo.hi()

// 调用确实不存在的方法或闭包属性,会被引导至 methodMissing 方法。
// 如果自身没有实现 methodMissing 方法,那么就会抛出异常。
// a method echo doesn't exist.
pogo.echo()

注意,greet()greet 在这里是不一样的:

  1. greet() 表示方法调用,在同名方法不存在时,这也有可能指向一个名为 greet 的闭包调用。
  2. greet 表示访问属性,它在这里表示返回那个名为 greet 的闭包。但严格来说,它也有可能指向 getGreet() 方法。

8.1 GroovyInterceptable 接口实现

现在我们了解了,一个对象的方法如果要被成功调用,要么它的确有这个方法定义,要么它具备同名闭包的属性。而如果一个 POGO 实现了 GroovyInterceptable 接口,那么这个规则会被改写 —— 所有的方法调用会被其自身的 invokeMethod() 方法拦截,甚至这个方法或者闭包属性可以完全不存在。我们可以在这个方法内部加入统一的前向通知和后向通知,来达到切面编程的目的。

下面是一个 Car 的定义,这辆车有一些行为:启动 run 和仪表盘 speed 。假定我们想要在调用这两个方法之前和之后加上统一的处理方法,此时就可以使用 GroovyInterceptable 接口配合 invokeMethod() 方法对这些调用拦截。

class Car implements GroovyInterceptable {
    
	// 此处设计是为了避免 Car 自身对 println 方法进行拦截。
    def static outline = { any -> System.out.println(any) }

    // 调用这些方法都会被拦截。
    def run() {outline "running!"}
    def speed(double kmh){"${kmh} km / h"}

    // 核心方法。
    def invokeMethod(String name, args) {
        // 前向通知
        outline "pre:the method ${name} needs check."

        // 通过 metaClass 调用原本的方法
        def result = this.metaClass.getMetaMethod(name, args as Object[]).invoke(this, args as Object[])

        // 后向通知
        outline "after:done "
        if (!result.is(null)){
            // 有结果则打印
            outline "and the result is ${result}"
        }
    }
}

def car = new Car()
car.run()
car.speed(109)

// 运行结果
// pre:the method run needs check.
// running!
// after:done

// pre:the method speed needs check.
// after:done 
// and the result is 109.0 km / h

注意,这个代码块特意没有使用原生的 println 方法进行控制台输出。原因是:println 方法是 Groovy 为 Object 注入的方法,这个方法也会被 POGO 自身的 invokeMethod 方法拦截,但我们并不希望这么做。System.out.println()PrintStream 的一个 static 方法,它不会受此影响,且笔者又不想处处留下一长串的 sout,因此用一个命名更简短的闭包取而代之。

对这个套路比较熟悉的读者会明白:这是对代理模式的一个实践。被代理 ( 或称被 "拦截" ) 的方法名和方法参数都可以通过 nameargs 获取,我们利用这两条信息在 .metaClass 中找到原来的方法并调用。这里同样是为了避免 Groovy 混淆重载的 getMetaMethod 方法,因此特意通过 as 关键字限定了类型。

如果调用的方法是不存在的,那么 invokeMethod 也会对此进行拦截。所有的前向通知都能够正常执行,直到通过调用 getMetaMethod 获取元方法这一步为止。对于不存在的方法,我们只能获取到一个空指针,对空指针调用 invoke 方法会抛出空指针异常。

8.2 MetaClass 注入的实现

GroovyIntercptable 接口通过硬编码到类定义的方式实现方法拦截。但是如果我们并不是这个类的设计者,或者无权对原来的类定义做出修改时,这一招就行不通了。如果无法从内部定义类的方法拦截,那就想想如何在外部实现它 —— 比如说通过 .metaClass 获取元类型,然后对它注入 invokeMethod 方法。

// 还是刚才的 Car,只不过现在没有办法修改了。
final class Car  {
    def static outline = { any -> System.out.println(any) }
    def run() { outline "running!"}
    def speed(double kmh) {"${kmh} km / h"}
}

// 打印方法注入之前的 metaClass 类型
println Car.metaClass.getClass().name

// IDEA 的 Groovy 插件不会对外部注入的方法进行代码提示,甚至可能还会说 can not resovle symbol ...
// 这是插件的问题,程序能够正常运行。
Car.metaClass.invokeMethod = {
    String name,args->
        def outline = {any -> System.out.println(any)}
        outline "before running, the method ${name} needs check."
        def result = Car.metaClass.getMetaMethod(name,args as Object[]).invoke(delegate,args as Object[])
        outline "after running....${result.is(null)?"":"and the result is ${result}"}"
}

def car = new Car()
car.run()
car.speed(109)

// 打印方法注入之后的 metaClass 类型。
println Car.metaClass.getClass().name

我们通过为 Car.metaClass.invokeMethod 赋值闭包的方式,实现了在 Car 的元信息内注入方法。不止是 invokeMethod 方法,在元类型内注入任何其它的方法也是相似的套路 ( 见后文 ) 。只不过 invokeMethod 更特殊一些:后续对该类实例的所有方法调用全部会率先引导至 invokeMethod 方法。

在闭包内部,使用 delegate 来指向调用的那个对象,而非 this。如果要使用到对象的一些内部属性,比如说 name,需要借助 Groovy 的动态访问语法来完成:delegate["name"]

和前者的 GroovyInterceptable 接口实现相比,它的一大优势是:Groovy 也支持提取 POJO 的 metaClass ,因此这个方法也能用于拓展 POJO。

如果拦截到的方法确实不存在,在直接抛出空指针异常之前,我们更希望通过 methodMissing 做一些善后工作。下一节我们就会介绍如何利用 methodMissing 方法做一些更体面的处理 —— 比如根据用户传入的方法名来推测用户的需求,然后给出合适的结果,而不是像现在一样给出一行枯燥的代码提示。值得注意的是,在 invokeMethod 内部,我们需要借助 invokeMissingMethod 来间接调用该对象的 methodMissing 方法,否则 methodMissing 方法也会意外地被 invokeMethod 拦截。

这个 methodMissing 方法本身也是通过外部注入来实现的。

// 在其它 Groovy 脚本外部注入 `invokeMethod`
Car.metaClass.invokeMethod = {
    String name,args->
        def outline = {any -> System.out.println(any)}
        outline "before running, the method ${name} needs check."

        def job = Car.metaClass.getMetaMethod(name,args as Object[])
        def result = null

        // 相当于是调用了 delegate.methodMiss(name,args)
        if(job == null) Car.metaClass.invokeMissingMethod(delegate,name,args as Object[])
        else {
            result = job.invoke(delegate,args as Object[])
        }
        outline "after running....${result.is(null)?"":"and the result is ${result}"}"
}

// 同理,如果在内部无法定义,就在外部通过 metaClass 注入 methodMissing 方法。
Car.metaClass.methodMissing = {
     String name,args->
        System.err.println("this object has no method ${name}, and the args ${args} are unused.")
}

这里还有一处小细节,通过打印 .metaClass.getClass().name 能够观察到,对元类型进行方法注入会改变 MetaClass 自身的类型:在最开始,它属于 org.codehaus.groovy.runtime.HandleMetaClass 类。而一旦有注入的动作,它就会变更为 groovy.lang.ExpandoMetaClass 类型 ( 后文简称为 EMC )。这是一个历史遗留问题:EMC 目前并不是 Groovy MetaClass 的唯一实现,但我们目前的各种操作事实上都是通过它进行的。

8.3 方法合成

方法合成和方法拦截的区别在于:后者拦截所有 ( 包括不存在的 ) 的调用,从而实现 AOP 切面编程。而前者只专注于拦截运行期间不存在的方法调用,并希望能够根据传入的方法名,参数等语义推测用户行为,并返回合理的处理结果,从而让用户感觉 "什么调用都好像是动态生成的一样"。

Groovy 提供的 methodMissing 对方法合成非常有用。比如 Grail / GORM 可以在调用时为领域对象合成出 findByFirstNamefindByFirstNameAndLast 这样的查找方法。合成的方法只有在调用时才会作为独立的方法存在,因此无法通过通常的反射手段找到它们。与此同时,合成的方法也可以缓存下来方便以后复用,来减少对 methodMissing 方法的频繁调用。Grails 的创建者 Graeme Rocher 称之为 "拦截,缓存,调用" 模式。

这是一个 Groovy 方法合成的一个简单例子。假定有一个 Person 类,而他有一些爱好,这些爱好存在一个 plays 字段中,并且有可能会后续的运行中继续添加。现在要求对于 plays 内的每一个元素都编写出一个 playXXX 方法。

最基本的方式,就是在代码内进行硬编码。一旦客户后续在 plays 列表了添加,或者是修改了某个字段,我们就会被迫地回到源代码中重做修改并编译。显然,在 Groovy 中绝对不会这么做。

class Person{
    def plays = ["Football","Basketball","TableTennis"]
    String name
    def playFootBall(){
        // 实际生产中,这可能是一个数据库查询。
        println("$name is playing FootBall")
    }
    def playBasketball(){println("$name is playing Basketball")}
    // 相似的套路 ...
}

def john = new Person(name: "john")
john.playBasketball()
john.playFootBall()

我们在此引入 methodMissing 方法,用方法合成的思路解决问题。该方法不像 invokeMethod 那样对所有的方法进行拦截,仅在目标方法不存在的场合才会被 Groovy 调用。

下面的代码块是这样做的:根据用户提供的方法名解析出他可能期望获得的字段,然后程序查询这个字段是否在 plays 列表内,如果有,返回结果。否则,抛出 MissingMethodException ( 或者抛出其它合理的异常也可以 ) 。

class Person {
    private def sout = { any -> System.out.println(any)}
    def plays = ["Football","Basketball","TableTennis"]
    def name
    def run(){sout "running..."}
    def methodMissing(String name,args){
        def key = name.split("play")[1]
        if(!plays.contains(key)) throw new MissingMethodException(name,this.class,args as Object[])
        sout "${this.name} is playing ${key}"
    }
}

def john = new Person(name: "john")

// 存在的方法可以正常调用,不存在的方法会在 methodMissing 临时合成。
john.playBasketball()
john.playFootball()
john.run()

8.3.1 方法合成的记忆性

与此同时,我们希望这种合成是具备 "记忆性" 的 —— 当第一次调用新合成的方法时,将它注入到 MetaClass 内部。

def methodMissing(String name,args){
    println "//--------first call for method $name-------//"
    def key = name.split("play")[1]
    if(!plays.contains(key)) throw new MissingMethodException(name,this.class,args as Object[])
    
    // 创建一个闭包
    def impl = {
        -> println "playing ${name.split("play")[1]}"
    }
    
    // 将闭包注入到当前对象的 MetaClass.
    Person self = this
    self.metaClass."$name" = impl
    impl()
}

这种缓存手段在简单的例子中甚至比不缓存还要更慢一些。但这种缓存手段很重要,只是在这个案例中,我们的 methodMissing 逻辑相比之下实在是太简单了。在大型项目中, methodMissing 需要处理各种方法合成请求,逻辑也会变得复杂,耗时明显会变长。此时,缓存手段的优势才会体现出来。

一旦目标是一个 POJO,或者我们没有对它的修改权限,这种手段就行不通了。此时如果要实现方法合成,我们需要将 methodMissing 方法注入到元类型 MetaClass 内部,之前已经演示过了。

john.metaClass.methodMissing = {
    String name, args ->
        println "first call of method $name..."
        def key = name.split("play")[1]
        Person self = delegate
        if (!self.plays.contains(key)) throw new MissingMethodException(name, Person.class, args as Object[])
        def impl = { -> println "${self.name} is playing $key" }
        self.metaClass."$name" = impl
        impl()
}

9. 方法注入

"要是 java.lang.String 原生支持一个 encrypt 方法那就太好了。" 面向对象编程本身就是为了拓展,但是能够拓展到什么程度,又往往受到编程语言的限制。如果我们能够基于实际需求为类拓展方法,那么写出一段富有表现力的代码就会十分轻松。

由于 MOP 协议的存在,Groovy 实现这些非常容易 ( 即便不通过 MOP 协议,我们也可以通过 SPI 接口进行方法拓展 )。在之前的章节中,我们了解了如何基于 .metaClass 提取元属性,元方法。本章开始,我们要逐渐了解如何利用 Groovy 为我们暴露的元信息并向内部注入一些基于现实需要的行为。

9.1 使用分类注入方法

Groovy 提供了可控的,或者说仅在局部范围内生效的方法注入,分类 ( category ) 就是一种。形式上,它的用法比较接近 Scala 的隐式类 —— 在一个上下文环境中隐式地通过代理对象实现方法增强。在 Groovy 中,分类的代码增强效果仅仅在一段 use 作用域和执行线程有效。当线程退出这段代码块之后,一切都会恢复原样。

下面我们使用分类方法注入的形式重新实现在 "拓展 JDK" 章节中曾介绍的那个例子:为 String 拓展 writeTo 方法,以便我们直接将代码内的字符串输出到外部的文本文件。

类似地,我们需要令行准备一个拓展类,暂且为它起名 StringExtension,然后将 writeTo 方法补充到这里。有两点需要注意:首先,这个增强方法仍然需要使用 static 关键字修饰;其次,该方法的第一个形参用于表明拓展的类型,它代表了调用此方法的那个对象,但是不会在调用时体现出来。

class StringExtension {
    // 调用时,相当于 "xxxxx".writeTo(filename,charset)。
    // self 代表了调用的那个字符串。
    def static writeTo(String self,String filename,String charset){
        new File(filename).withWriterAppend(charset){
            it.writeLine(self)
        }
    }
}
// 创建了上下文
use(StringExtension){
    // 注意,仅在 use 语句块内有效
    "this is mop method...".writeTo("C:\\Users\\i\\Desktop\\武林秘籍.txt","UTF-8")
}

之后,在任意一处代码块内创建一个 use 块:第一个形参传入刚才设定好的增强类型 ( 不是传实例 ),然后我们就可以在后面的闭包块内享受到 StringExtensionString 拓展的增强方法 ( 这个闭包后附的语法参见笔者 Groovy 的第一篇入门教程 )。

对于分类的声明,还存在一种更简洁直观的 @Category 注解的写法:在注解标记被代理 ( 被增强 ) 的类型,然后内部无需再使用 static 声明增强方法,也不需要在形参列表中额外预留一个参数。同时,该增强类内部的 this 将不再指代自身,而是指代那个目标实例。

@Category(String)
class StringExtension {
    def writeTo(String filename,String charset){
        new File(filename).withWriterAppend(charset){           
            // this 指代被修饰的 String,而不是 StringExtension
            it.writeLine(this)
        }
    }
}

// 创建了上下文
use(StringExtension){
    // 注意,仅在 use 语句块内有效
    "this is mop method...".writeTo("C:\\Users\\i\\Desktop\\武林秘籍.txt","UTF-8")
}

9.1.1 分类接受多个增强类

use 是一个重载的方法:它的第一个参数不仅可以接受单个增强类,还能受由多个增强类组成的数组 List<Class> 。从写法上,Groovy 支持以逗号的形式将这些类名传进 () 小括号内。

如果传入了多个增强类,那么就可能存在一个问题:方法重复。在多个增强类依次排列的情况下,Groovy 优先选择最后一个传进来的增强类。比如:

@Category(String)
class StringExtension_01 {
    def shout() {
        println("from StringExtension_01")
    }
}

@Category(String)
class StringExtension_02 {
    def shout() {
        println("from StringExtension_02")
    }
}

use(StringExtension_01, StringExtension_02) {
    // from StringExtension_02
    "string".shout()
}

Groovy 在很多地方都遵守这样的原则,后文为了方便解释,笔者在这里暂且将这种原则称之为 "最远原则" ( 最后传来的优先级最高 ) 。

9.1.2 分类可以嵌套

同样的,use 语句块内部也允许定义新的 use 块。如果此时发生了方法冲突,Groovy 的处理方式是:优先选择最内部层次的增强类方法。比如:

use(StringExtension_02){
    // 对于不同层次的增强类而言,当发生方法重复时,Groovy 优先选择最内部的方法。
    use(StringExtension_01){
        // from StringExtension_01
        "string".shout()
    }
}

结合上一条考虑:一个增强类所处的 use 嵌套层次越深,且在同层次中定义的顺序越靠后,那么当发生方法冲突时,Groovy 选择它的优先级就越高。

9.2 使用 ExpandoMetaClass 注入方法

想要创建领域特定语言 ( DSL ),我们需要向不同的类,甚至类的层次结构当中注入任意的方法,包括注入实例方法,静态方法,甚至是操作构造器。前文已经暗示了 —— 通过调取一个类的 metaClass ,我们能注入的可不止 invokeMethodmethodMissing 方法,理论上,只要想象力到位,我们想注入什么方法都可以。

下面是一个演示通过 EMC 实现方法注入的 case:向 Integer 类的 MetaClass 注入一个方法,该方法能够打印出 "基于这个天数之后的日期"。

// 标准的拓展格式:
// 类.metaClass.方法名 = 一个闭包 {...}
// 方法名自拟,需要的参数写在闭包内部。
Integer.metaClass.daysFromNow = {
    ->
    def now = Calendar.instance
    // delegate 指向那个被注入的 Integer
    now.add(Calendar.DAY_OF_MONTH,delegate as int)
    now.time
}


// 打印 5 天之后的时间: Sun May 16 17:18:46 CST 2021
println 5.daysFromNow()

此外,daysFromNow() 是一个内部无副作用,且外部不接受参数,纯粹用于返回一个值的纯函数。在 Scala 中,满足这种特性的方法都会被 "统一访问原则" 伪装成一个属性,Groovy 也能实现。因此,在这里不妨将 daysFromNow 方法改名为 getDaysFromNow 来去掉访问时那个多余的小括号。

// getDaysFromNow 方法被 Groovy 包装成了 DaysFromNow 属性。
// 当然,用户方可以选择任意一种方式来获取结果。
Integer.metaClass.getDaysFromNow = {
    // 这个方法不需要外部传递参数。
    ->
    def now = Calendar.instance
    // delegate 指向那个被注入的 Integer 实例
    now.add(Calendar.DAY_OF_MONTH,delegate as int)
    now.time
}

// 这种套路在 DSL 语言中经常用到。
println 5.daysFromNow

这也是 Groovy 不谈 "注入属性" 的原因:这只需要注入对应的 getXXX 方法就可以解决了,调用这段代码的用户不会发现 ( 也不会感兴趣 ) 这个属性是由方法 "伪装" 成的。

相比于分类 ( category ) 的拓展方式,这种方法注入会全局范围内生效,任意一个 Integer 实例都可以调用这个 DaysFromNow "属性"。但是,我们认为在经过适当的数值转换,精度舍入的处理之后,像 Long,或者 Double 这类数值应当也能支持 getDaysFromNow 方法。那么想要给多个类同时注入一个方法该怎么做呢?

第一种做法是先声明一个闭包,然后再将它注入到多个 MetaClass 内:

def getDaysFromNow = {
    ->
    def now = Calendar.instance
    //
    now.add(Calendar.DAY_OF_MONTH,delegate as int)
    now.time
}

def injectName = "getDaysFromNow"
Integer.metaClass."$injectName" = getDaysFromNow
Long.metaClass."$injectName" = getDaysFromNow
Double.metaClass."$injectName" = getDaysFromNow

// 打印 5 天之后的时间: Sun May 16 17:18:46 CST 2021
println 5.daysFromNow
println 10.00d.daysFromNow

这种做法有点啰嗦。而 IntegerLongDouble 都有一个共同父类,那就是 Number 类。那么只需要向这个父类注入实例方法,那么所有的子类就都会 "通过继承关系" 自动获得。

Number.metaClass.getDaysFromNow = {
    ->
    def now = Calendar.instance
    //
    now.add(Calendar.DAY_OF_MONTH,delegate as int)
    now.time
}

// 打印 5 天之后的时间: Sun May 16 17:18:46 CST 2021
println 5.daysFromNow
println 10.00d.daysFromNow

9.2.1 注入静态方法

在了解注入实例方法之后,注入静态方法几乎没有什么难度,区别是多了一步 'static' ( 需要加引号 ) 的选择操作。

Number.metaClass.'static'.getDaysFromNow = {
    Number number ->
    def now = Calendar.instance
    now.add(Calendar.DAY_OF_MONTH,number as int)
    now.time
}

println Integer.daysFromNow(10)
println Number.daysFromNow(10)

9.2.2 注入构造器

对构造器的注入分为两种:第一种是新增构造器,第二种是对已有的构造器进行覆盖。

对于第一种情况,需要进入到 Clazz.metaClass.constructor 域内部,然后借助 << 运算符进行注入。如果此运算符被用于覆盖类原有的构造器,那么 Groovy 会报错。

Integer.metaClass.constructor << {
    Calendar c -> new Integer(c.get(Calendar.DAY_OF_YEAR))
}
// 根据日期返回 "今天是今年的第几天",为 Integer 类型。
def day = new Integer(Calendar.instance)
println day

对于第二种情况,我们使用 = 而非 << 操作符,这里不再做演示。需要注意的是,Groovy 并非是真的覆盖了原来的构造器,通过 Java 的反射也可以重新调用它。

9.2.3 使用 EMC DSL 批量注入方法

如果仅打算为一个类补充一两个方法,那么 Clazz.metaClass.anyMethod = {...} 的写法尚且可以接受。但如果一个类挂载了一系列实例方法,静态方法乃至构造器方法,此时将这些方法集中到大代码块内统一管理是更明智的做法。

Groovy 提供了 EMC DSL 来使我们能够像写 yml 文件一样,让类的注入方法呈现出清晰的树状层次:

Integer.metaClass {
    // 注入实例方法
    // 可以通过 .daysFromNow 获取
    getDaysFromNow = {
        ->
        Calendar calendar = Calendar.instance
        calendar.add(Calendar.DAY_OF_MONTH,delegate as int)
        calendar.time
    }

    'static' {
        getDaysFromNow = {
            Integer integer ->
            Calendar calendar = Calendar.instance
            calendar.add(Calendar.DAY_OF_MONTH,integer as int)
            calendar.time
        }
    }

    constructor << {
        Calendar c -> new Integer(c.get(Calendar.DAY_OF_YEAR))
    }
}

// 调用注入的静态方法
println Integer.getDaysFromNow(10)

// 调用注入的实例方法
println 10.daysFromNow

println(new Integer(Calendar.instance))

9.2.4 向实例注入方法

如果不想将方法注入到整个类内部,那也可以仅提取某一个对象的 MetaClass 进行方法注入。下面的案例创建了两个对象 p1p2 ,其中,我们只为 p1 的元类型注入了 sing 方法,因此只有 p1 会 "唱歌"。

class Person{
    String name
    def talk(String s){println "${name}:[$s]"}
}


def p1 = new Person(name:"Wang Fang")
def p2 = new Person(name:"Lao Wang")

p1.metaClass {
    sing = {
        // 如果
        String s -> "${delegate["name"]}:${s}~~~ oh~~"
    }
}

println p1.sing("oh baby baby")

// p2 的 EMC 没有绑定,因此这里会抛出 MissingMethodException 异常
println p2.sing("are you ok?")

9.3 利用 MOP 实现动态委托

如果委托是静态的,那它就可以在编译期间确定,因此也就无需在运行时更改,关于动态委托的实现可以参考之前文章提到过的 @Delegate 注解 ( 这是编译时元编程技术 )。

现在有三个角色:老板 Boss ( 被委托人 ),普通职员 Worker 和分析师 analyst ( 委托人 )。后两者有各自的分工,但是所有的方法调用通过 Boss 进行。考虑到委托人的角色以及数量都可能动态变化,因此这里不使用静态的 @Delegate 注解。

class Boss{}
class Employee{
    def work(){
        println "working now"
    }
}

class Analyst{
    def analize(){
        println "solving this problem..."
    }
}

首先,我们将代表动态委托功能的 delegateTo 方法注入给 Object ( 或者只注入给 Boss 类也可以 ),从形式上,只要将任意多个类型传入到方法当中,就可以建立该对象和这些类的委托关系。

具体的实现方式清晰明了:根据接受的 Class[] 创建出委托类的实例数组 ins,然后利用方法合成的套路,向被委托实例的 delegate 注入重写的 methodMissing 方法进行方法合成 ( 在这里应该是方法委托 )。

一旦调用了该对象不存在的方法,Groovy 就会试图从 methodMissing 那里返回一个结果。在 methodMissing 内部,我们通过轮询 ins 数组的方式寻找出第一个对该方法名做出响应的委托实例 ( 这里可通过 findresponseTo 方法实现 ),然后再通过 invokeMethod() 间接调用被委托的方法。

如果愿意的话,也可以向之前的套路那样,每调用一个新的委托方法,就将它注入到原先的被委托类的 MetaClass 中。下一次调用相同的方法时,Groovy 就没有必要重新去 ins 数组那里做轮询了。

Object.metaClass.delegateTo = {
    Class... clazzs ->
        def ins = clazzs.collect { it.getDeclaredConstructor().newInstance() }
        delegate.metaClass.methodMissing = {
            String name, args ->
                // 找到能响应该方法的实例,找到第一个就返回。
                def proxy = ins.find {
                    it.respondsTo(name,args)
                }

                // 可选项,如果乐意的话,也可以向实例的 MetaClass 内部缓存。
                delegate.metaClass."${name}" = { Object[] vargs ->
                    proxy.invokeMethod(name,vargs)
                }

                // 调用该实例的方法。
                proxy.invokeMethod(name,args)
        }
}

现在,我们能够在 Groovy 代码内以简洁地形式声明一个动态调用。

def boss = new Boss()
boss.delegateTo(Employee,Analyst)

boss.work()
boss.analize()

由于查找代理实例的过程中依赖 find 方法进行,因此这无形间规定了优先级顺序:如果多个类都能完成某个委托,那么先传入的类,其方法调用的优先级最高。这是一个综合的练习,因为它除了还利用了以下知识点:

  1. 通过调用一个对象的 responseTo 来检查它是否能对某一个方法做出响应。
  2. 通过调用一个对象的 invokeMethod 方法来动态调用方法。
  3. 通过注入 methodMissing 来实现方法合成 ( 在这个案例中,方法合成达成了方法委托的效果 )。

9.4 总结 Groovy 调用方法的策略

在调用一个方法时,Groovy 会根据这个对象是 POJO 还是 POGO ,是否使用了 MetaClass 注入等因素而采取不同的策略。整个 MOP 专题基本围绕两个主要的方法进行:invokeMethodmethodMissing

9.4.1 If POJO

对于 POJO 而言,优先级顺序:

  1. 通过 MetaClass 注入的 invokeMethod 方法 ( 拦截所有调用 )。
  2. 通过 MetaClass 注入的普通方法。
  3. 原有方法。
  4. 通过 MetaClass 注入的 methodMissing 方法。
  5. 抛出 MissingMethodException

注意 2,3 两项, 通过 MetaClass 注入的同名方法相比原本方法而言优先级更高。

9.4.2 If POGO

对于没有实现 GroovyInterceptor 标记接口的 POGO 而言,方法调用优先级顺序:

  1. 通过 MetaClass 注入的 invokeMethod 方法。 ( 拦截所有调用 )
  2. 通过 MetaClass 注入的普通方法。
  3. 原有方法 / 同名的闭包属性。
  4. 通过 MetaClass 注入的 methodMissing 方法。
  5. 内部定义的 methodMissing 方法。
  6. 内部定义的 invokeMethod 方法。
  7. 抛出 MissingMethodException

事实上,我们不可能在一个类内部同时用上所有项。为了让思路更清晰一些,笔者从需求角度进行了划分:

a. 当需要在外部进行方法拦截时,只需要额外定义第 1 项 ( 不要和内部定义的第 6 项搞混 )。

b. 当需要动态拓展方法时,只需要额外定义第 2 项。

c. 当需要在外部实现方法合成时,只需要额外定义第 4 项。

d. 当需要在内部实现方法合成时,只需要额外定义第 5 项。

如果 POGO 在内部实现了 GroovyInterceptor 接口,所有调用将只会引导至内部定义的 invokeMethod 方法。

9.5 使用 Mixin 混合注入方法

首先来举一个例子:动物 Animal 衍化成了两个类别:卵生动物的代表飞鸟 Bird 与哺乳动物的代表走兽 Beast

abstract class Animal{
    // 动物都会叫 ...
    abstract def roar()
}

class Beast extends Animal{
    def roar(){println "beast yawn..."}
    // 走兽会捕食 ...
    def predate(){println "predate ..."}
}

class Bird extends Animal{
    def roar(){println "bird twitter..."}
    // 飞鸟会飞...
    def fly(){ println "hovering in the sky"
}

现在有一个特例蝙蝠 Bat:它自身虽然属于哺乳动物,但是却又具备飞鸟独有的 fly 方法,它无论单继承哪一边都好像缺点什么。但是,如果两者都继承也会出现问题:它是选择像飞鸟一样鸣叫呢?还是选择像走兽一样吼叫呢?

这是多重继承所带来的菱形继承问题。好在 Java 单继承的,假使在开发中遇到了类似的问题,最直接的思路就是仅保留公共方法到父类,将其它的方法放到接口去,这样就可以通过 "单继承 + 组合模式" 来规避菱形继承问题。

然而,Groovy 提供了 "通过混合类型 ( Mixin ) 来实现多重继承" 的方案。混合的方式有两种 —— 编译期混合和运行期混合。

9.5.1 编译期混入 Mixin

如果要编译期混合类型,那么就要用到 @Mixin 注解。这个注解使用起来非常简单:将想要混入的类 ( 的数组 ) 传递到注解的 value 值就可以了。

@Mixin([Beast,Bird])
class Bat extends Animal{
    @Override
    def roar() {
        println "I'm BATMAN, I'm rich."
    }
}

def b = new Bat()

// 自身的 roar 方法被压制了,调用的是 Bird 的方法。
b.roar()

// 混入 Bird 类获得的 fly 方法。
b.fly()

// 混入 Beast 类获得的 predate 方法。
b.predate()

这里还有一处细节要交代:当我们调用 roar 方法时,Bat 对象并没有给出预期中的结果。其原因可以类比笔者在前文提到的 "最远原则":在混入多个类的情况下,当发生方法冲突时,Groovy 优先选择数组内最后一个满足的类。

笔者的 Groovy 版本为 3.0.8。在当前版本当中,编译期混入其实已经不推荐再使用 @Mixin 注解了,Groovy 建议使用更简单的关键字 trait 来代替。

9.5.2 运行期混入

如果选择在运行期间混入,那么可以先提取出一个类 ( 对象 ) 的元类型 MetaClass ,然后通过 mixin 方法按照 "最远原则" 依次插入混入的类型。同样地,运行时混入避免了对类的源代码进行修改。

// 没有对类定义进行修改 
abstract class Animal{
    abstract def roar()
}

class Beast extends Animal{
    def roar(){println "beast yawn..."}
    def predate(){println "prey ..."}
}

class Bird extends Animal{
    def roar(){println "bird twitter..."}
    def fly(){ println "hovering in the sky"}
}

class Bat extends Animal{
    @Override
    def roar() {
        println "I'm BATMAN, I'm rich."
    }
}
//----------------------------

def bat = new Bat()
bat.metaClass.mixin(Bird,Beast)
// 根据 "最远原则",优先调用 Beast 的方法。
bat.roar()
bat.fly()
bat.predate()

9.6 编译期 Mixin 的替代方案:Trait

Groovy 或许也觉得 Mixin 和 Scala 混入特质的思路大体相同,因此在 2.3.x 版本之后,Groovy 也开始采用 trait 关键字,用以代替具备侵入性的 @Mixin 注解。

特质 Trait 使得 Groovy 类像实现多接口一样去继承多个 ( 抽象 ) 类。当出现菱形继承问题时,Groovy 会按照 "最远原则" 去处理。但和之前 @Mixin 注解不一样之处在:如果这个类本身有重写实现,那么就相当于消除了二义性, Groovy 就不会再考虑任何父特质的方法了

特质本身可以同时定义抽象方法和实例方法 ( 按 Scala 的说法就是 "富特质" ),或者只有其一。由于它的存在,Groovy 的 interface 接口没有再引入 JDK 8 的 "默认方法" ( default 保留字在 Groovy 中无法用于接口 )。特质可以继承特质,但是特质无法继承类,或是实现接口。类可以实现 implements 特质,但是不能继承 extends 特质。

trait Animal{
    abstract def roar()
}

trait Beast extends Animal{
    def roar(){println "beast yawn..."}
    def predate(){println "prey ..."}
}
trait Bird extends Animal{
    def roar(){println "bird twitter..."}
    def fly(){ println "hovering in the sky"}
}

// 由于 Bat 自身拥有此方法,因此 Groovy 不会再考虑 "最远原则"。
class Bat implements Beast,Bird{
    @Override
    def roar() {
        println "I'm BATMAN, I'm rich."
    }
}

def bat = new Bat()
// BAT MAN
bat.roar()
bat.fly()
bat.predate()