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,应该如何获取它们的元信息,然后再介绍基于这些元信息的实用操作。对于方法拦截,方法注入,方法合成等内容本章围绕两个重要的方法进行:invokeMethod 和 methodMissing。
7.1 POGO & POJO,MetaClass
POJO ( Plain Old Java Object ),简单 Java 对象,代表没有额外类继承 ( 只继承于 java.lang.Object ),没有额外接口实现,也没有被其它框架侵入的 Java 对象。从广义上来讲,它指不受任何特定 Java 对象模型,约定,框架约束的 Java 对象;而正因为它不和任何详细的业务功能绑定,从狭义上来看,POJO 又可以代表那些只用于承载数据,仅设置了 setter 和 getter 方法的 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 ),包含了实例方法和静态方法,因此对应的获取方法叫 getMetaMethod ,getStaticMetaMethod。如果要获取重载的方法列表,那么对应的方法则是 getMetaMethods 和 getStaticMetaMethods 。获取的元方法可以通过 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 ),包含了实例属性和静态属性,对应的获取方法叫 getMetaProperty,getStaticMetaProperty。假定我们只是想对某些元信息的 "存在性" 做检查,那么可以使用 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 在这里是不一样的:
greet()表示方法调用,在同名方法不存在时,这也有可能指向一个名为greet的闭包调用。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,因此用一个命名更简短的闭包取而代之。
对这个套路比较熟悉的读者会明白:这是对代理模式的一个实践。被代理 ( 或称被 "拦截" ) 的方法名和方法参数都可以通过 name 和 args 获取,我们利用这两条信息在 .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 可以在调用时为领域对象合成出 findByFirstName 和 findByFirstNameAndLast 这样的查找方法。合成的方法只有在调用时才会作为独立的方法存在,因此无法通过通常的反射手段找到它们。与此同时,合成的方法也可以缓存下来方便以后复用,来减少对 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 块:第一个形参传入刚才设定好的增强类型 ( 不是传实例 ),然后我们就可以在后面的闭包块内享受到 StringExtension 为 String 拓展的增强方法 ( 这个闭包后附的语法参见笔者 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 ,我们能注入的可不止 invokeMethod 和 methodMissing 方法,理论上,只要想象力到位,我们想注入什么方法都可以。
下面是一个演示通过 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
这种做法有点啰嗦。而 Integer,Long 和 Double 都有一个共同父类,那就是 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 进行方法注入。下面的案例创建了两个对象 p1 和 p2 ,其中,我们只为 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 数组的方式寻找出第一个对该方法名做出响应的委托实例 ( 这里可通过 find 和 responseTo 方法实现 ),然后再通过 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 方法进行,因此这无形间规定了优先级顺序:如果多个类都能完成某个委托,那么先传入的类,其方法调用的优先级最高。这是一个综合的练习,因为它除了还利用了以下知识点:
- 通过调用一个对象的
responseTo来检查它是否能对某一个方法做出响应。 - 通过调用一个对象的
invokeMethod方法来动态调用方法。 - 通过注入
methodMissing来实现方法合成 ( 在这个案例中,方法合成达成了方法委托的效果 )。
9.4 总结 Groovy 调用方法的策略
在调用一个方法时,Groovy 会根据这个对象是 POJO 还是 POGO ,是否使用了 MetaClass 注入等因素而采取不同的策略。整个 MOP 专题基本围绕两个主要的方法进行:invokeMethod 和 methodMissing。
9.4.1 If POJO
对于 POJO 而言,优先级顺序:
- 通过
MetaClass注入的invokeMethod方法 ( 拦截所有调用 )。 - 通过
MetaClass注入的普通方法。 - 原有方法。
- 通过
MetaClass注入的methodMissing方法。 - 抛出
MissingMethodException。
注意 2,3 两项, 通过 MetaClass 注入的同名方法相比原本方法而言优先级更高。
9.4.2 If POGO
对于没有实现 GroovyInterceptor 标记接口的 POGO 而言,方法调用优先级顺序:
- 通过
MetaClass注入的invokeMethod方法。 ( 拦截所有调用 ) - 通过
MetaClass注入的普通方法。 - 原有方法 / 同名的闭包属性。
- 通过
MetaClass注入的methodMissing方法。 - 内部定义的
methodMissing方法。 - 内部定义的
invokeMethod方法。 - 抛出
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()