6. GDK 拓展
本专题主要讨论 Groovy 如何在 “枯燥” 的 JDK 基础之上增加 “甜度” —— 首先,我们最好了解一下统一访问原则,第二,简单了解 GDK 在 JDK 之上做的拓展;第三,基于 SPI 来手动实现对 JDK 的拓展,以此来增加 Groovy 开发效率。
6.1 统一访问原则
回到不久前的话题,我们现在知道通过访问 execute().text
属性就能够直接取出某个进程的流信息:
// 访问这个 Process 的 text 属性
println "...".execute().text
复制代码
然而,笔者在试图了解 .text
属性的内部细节时却发现:text
根本就不是一个属性,它实际上被链接到一处 .getText()
方法。
// 来自反编译软件。
public static String getText(File file) throws IOException {
return IOGroovyMethods.getText(newReader(file));
}
复制代码
Groovy 提供这样的语法糖,一切以 get[Name]
为命名的方法,外部都可以 .[Name]
的形式像访问属性一样去调用它。就像这样:
class Apple{
def getWeight(){
return 1000
}
}
// Apple 没有 weight 属性。
new Apple().weight
复制代码
其实,这处设计灵感源自于统一访问原则 ( Uniform Access Principle ) 。设计这个原则的原因是:供给方和需求方看待产品的角度是不同的。在程序交付中,数据供给方为了提供可靠的数据,往往需要经过一系列 ( 甚至很复杂的 ) 处理过程,但这个过程对于需求方是完全透明的。这也是为什么编程语言要引入函数 —— 除了代码复用以外,另一个目的就是隐藏掉那些调用者 ( 即数据需求方 ) 毫不关心,或者不应触碰的内部处理细节。
访问一致原则更进一步:数据的需求方或许压根没有必要关注数据是通过 访问字段 还是通过 函数计算 获得的。需求方仅需要记住一件事情,那就是在需要的时候访问某一个变量名就可以了,就像我们之前直接从 .text
那里获取进程信息流那样。
这个设计原则的一个反例是:Java 的 List
接口的 Size()
方法 ( 可怜的 Java ,为了烘托小弟 Groovy 的 “妙”,几乎在笔者整个 Groovy 专题中充当着 “反派” )。这相当于告诉用户:”想要获取序列的长度,那就必须通过方法调用的方式获得”。或许我们早已习惯如此,并撇嘴说:”统一访问原则也没什么大不了的嘛”。但是,Groovy 坚信,一些善意的谎言或许可以让你我的生活更加美好。
有关于统一访问原则的概念在笔者的 Scala 学习文章中也曾出现过:Scala 之:函数式编程之始 (juejin.cn)
6.2 GDK 的拓展方法
Groovy 的存在不仅为 JVM 带来了动态语言的优势,它还增强了存在已久的 JDK 的性能,用户得以使用 Groovy 来享受更轻量级,更优雅的 Java API。Groovy 对 JDK 的拓展被称之为 Groovy JDK 开发包,即我们为使用 Groovy 而安装的 GDK。
文章不会事无巨细地介绍 SDK 的所有拓展,这里仅介绍一些简单的例子来体验通过闭包 + GDK 来提高开发效率的快感。尤其是下文介绍的 with()
和间接访问方式,它们在未来的动态编程中会很有用。
6.2.1 上下文绑定:with 方法
在方法闭包中曾提到过这个方法:给某个对象 “挂载” 一个闭包,闭包内的所有方法调用将优先路由到对象的实例方法,这个实例充当该闭包的上下文。
class Context{
def func1(){
println "func1 of ${this.getClass()}@${this.hashCode()}"
}
def func2(){
println "func2 of ${this.getClass()}@${this.hashCode()}"
}
}
// 这两个方法不会被优先调用,通过 this 能够体现出来。
def func1(){println "func1 of ${this.getClass()}@${this.hashCode()}"}
def func2(){println "func2 of ${this.getClass()}@${this.hashCode()}"}
new Context().with {
func1()
func2()
}
复制代码
再次强调,它和设置闭包 delegate
的方式存在不同,两者路由方法的优先顺序是相反的。
6.2.2 间接访问属性和方法
假定一个学生类 Student
有两种信息 id
和 name
。当我们明确用户要访问姓名字段时,仅需要调用 .name
就可以了。但如果事先不知道用户请求访问哪个字段,因为这些内容可能来自网络另一方提交的表单,我们不可能根据用户所有的可能输入去硬编码一个个分支出来。一个解决方案是使用反射,那既然如此,就先引入个 java.lang.Reflect.*
全家桶再说吧。
好在 Groovy 天生就建立在 Java 的反射机制之上,它让事情变得简单了:使用 []
操作符可以直接动态地访问用户的某一个属性。这本质上是 Groovy 为对象绑定了 getAt()
方法,然后通过操作符重载来实现的。
class Student_ {
int id
String name
def info(){println "student ${id} : ${name}"}
}
def s = new Student_(id:1,name:"Wang Fang")
// 这个 key 可能来自于外部输入。
def key = 'id'
println(s[key])
复制代码
同样地,既然 Groovy 支持在屏蔽一大串反射调用的前提下提供间接属性访问,那么间接方法访问也差不多。 Groovy 中,任何一个对象都支持通过 invokeMethod()
来动态调用方法:
student.invokeMethod('info',null)
复制代码
想想我们之前是怎么做的:首先获取元对象 Class
,然后调用 getMethod
方法访问 Method
实例,最后在该实例上调用 invoke
方法,当然,还要抛出一大批不知如何处理的异常。
6.2.3 对 Thread 的拓展
在 Groovy 里通过 Thread.start
就能够启动一个线程,然后直接在闭包内部告诉每一个线程它的任务是什么。
// 两个线程是否能并行执行,取决于机器是否有多个逻辑核心,以及两个线程是否存在数据竞态。
// 注,start 只是表明该线程进入就绪状态,实际运行的时机取决于 JVM 的调度。
Thread.start {
def name = Thread.currentThread().name
for (;;){
println("thread:${name} : is running....")
Object.sleep(1000)
}
}
Thread.start {
def name = Thread.currentThread().name
for (;;){
println("thread:${name} : is running....")
Object.sleep(1000)
}
}
复制代码
Thread.startDaemon
能够开启守护线程,相当于后台线程。当程序所有的其它非守护线程执行 ( 包括主线程 ) 完毕之后,守护线程会自动退出。
Thread.start {
def name = Thread.currentThread().name
println("thread:${name} : is running....")
Object.sleep(3000)
println("thread:${name} has done.")
}
Thread.start {
def name = Thread.currentThread().name
println("thread:${name} : is running....")
Object.sleep(4000)
println("thread:${name} has done.")
}
// 在上面两个线程执行完毕后,该守护线程自动退出。
Thread.startDaemon {
def name = Thread.currentThread().name
for (;;)
{
println "thread:${name} is waiting... ${Thread.activeCount()} is alive."
Object.sleep(500)
}
}
// 主线程在开启这三个线程之后就相当于退出了。
复制代码
既然提到了线程,这里再顺便介绍一下 Groovy 为 Object
绑定的 sleep()
方法。该方法可以让执行到此处的线程陷入睡眠状态 —— 说得更准确一些,应该是昏睡 ( soundSleep ) 状态。当其它线程通过 interrupt()
打断它的睡眠时,在默认情况下 InterurptedException
会被压制下来,这是和 Thread.sleep
不同的一点。
import static java.lang.System.currentTimeMillis as now
// 实际运行时间 ~ 2000 ms,说明中断没有起效果。
def t = Thread.start {
def l1 = now()
new Object().sleep(2000)
println now() - l1
}
// 主线程在休眠一秒后尝试中断 t 的运行。
new Object().sleep(1000)
t.interrupt()
复制代码
如果想要该线程允许被中断,那么就要在调用 sleep
方法之后再补充一个闭包,它表明捕获到中断异常之后该做如何处理,如果打算继续忽略中断,那么闭包最终返回一个 false
( 或者返回值是 void
) ,否则返回一个 true
。
def t = Thread.start {
def l1 = now()
new Object().sleep(2000){
// 这里的 it 指代捕获到了 InterruptedException 异常。
println "谁叫醒了我???${it}"
true
}
println now() - l1
}
new Object().sleep(1000)
t.interrupt()
println "打断睡眠 ..."
复制代码
6.2.4 对 java.io 的拓展
Groovy 向 java.io
类添加了许多实用的拓展方法 ( 在之前我们已经体验过一些方法了,比如 withWriter
,withStream
… 等 )。而对于一些轻量级的 IO 需求,Groovy 支持我们直接在 File
当中进行 ( Groovy 是如何在不修改 JDK 的前提下拓展方法的?下文的 “手动拓展 JDK 方法” 或许能提供思路 )。
比如说:在一个 File
实例中调用 eachFile()
查看当前路径下的所有文件,或者是通过 eachDir()
查看当前路径下的所有文件夹。所有的操作以闭包的方式传入,下面的代码演示了如何在 Groovy 中利用递归和 eachFile()
方法遍历文件夹下的所有内容。
def seek(File file, int layout = 0) {
if (file.file) {
println "\t" * layout + file.name
} else if (file.directory) {
println "\t" * layout + file.name
file.eachFile { seek(it, layout + 1) }
}
}
复制代码
又或者是,我们想把某个文件的文本内容一次性加载到程序中 ( text
内部设有 8M 的缓存空间,结合 StringBuilder,虽然字符串常量仅支持 65535 的长度,但是一个字符串对象可以占用 2G 的空间 )。
// 统一访问原则 .text => .getText() 方法
prtinln new File(/C:\Users\i\Desktop\武林秘籍.txt/).text
复制代码
当然,也可以选择逐行处理文本内容。
new File(/C:\Users\i\Desktop\武林秘籍.txt/).eachLine {
// 在这里可插入对每一行的任何处理。
println it
}
复制代码
如果想要过滤文本内容,可以借助 fitlerLine
方法,结合正则表达式对内容进行筛选:
println new File("C:\\Users\\liJunhu\\Desktop\\群起指令.txt").filterLine {
// 过滤掉 # 开头表示的注释行,注意取反
!(it =~ /^#/)
}
复制代码
对于写入文件,Groovy 几乎是将语法简练到了极致:
// 这里支持 append 追加,或者 write 复写。
// 在写二进制文件时,绝大部分情况是复写。
new File("C:\\Users\\liJunhu\\Desktop\\aa.txt").append("""
Hello Groovy, this is the 5th day of learning groovy.
I wouldn't miss Java anymore.s
""","UTF-8"
)
// 等同于 append 方法。
new File("C:\\Users\\liJunhu\\Desktop\\aa.txt") << "..."
复制代码
即使我们想遵循 Java 传统的模式 ( 比如希望手动调节缓冲流的大小 ),withXXXX
方法也能给我们提供极大的方便,我们只需在闭包里编写逻辑,至于一些IOException
或是 flush()
,close()
等云云,Groovy 会基于环绕方法模式 ( Execute Around Method ) 把这些琐事全部解决。
new BufferedOutputStream(new FileOutputStream(new File("...")),4096).withStream {
// 这里的 it 就是指代 BufferedOutputStream。
it.write(...)
}
复制代码
6.3 手动拓展 JDK 类方法
或许有一天,我们会觉得 java.lang.String
的功能有些欠缺,希望能够根据项目需求给它绑定一些定制化的静态或实例方法。只是很可惜,String
被标记为了 final
类,这表明 JDK 不希望我们对 String
动手动脚。
我们或许想要强制实现一个没有任何继承关系的装饰器,这意味着一旦需要调用 String
的原生方法,要么拆箱,比如 new StringHandler("long long code").getBind().toUpperCase()
;要么就干脆在此之前手动地把所有原生的 String
方法全部包裹一遍 ( 代价太大,令人难以接受 )。
public class MainTest {
public static void main(String[] args) throws IOException {
// 我们需要一种无缝植入的方式。
System.out.println(StringHandler.bracket("hello"));
new StringHandler("looooog loooog code").writeTo(new File("C:\\Uses\\liJunhu\\Desktop\\aa.txt"),"UTF-8");
}
}
class StringHandler {
private String bind;
/**
* 下面密密麻麻的代码只想表达一件事:将字符串直接写到一个文本内,将这个过程封装成一个方法。
* @param target 传入目标的文件,必须满足 file 类型,否则断言失败。
* @param charset 传入字符集的字符串表达。
*/
public void writeTo(File target,String charset) throws IOException {
assert target.isFile();
try(FileOutputStream fos = new FileOutputStream(target,true)){
// 这里没有用 FileWriter,因为笔者认为调节它的 charset 有点费劲。
OutputStreamWriter osw = new OutputStreamWriter(fos,charset);
BufferedWriter bw = new BufferedWriter(osw);
bw.newLine();
bw.append(bind);
bw.flush();
}catch (IOException ioe){
ioe.printStackTrace();
}
}
/**
* 为字符串添加一对括号。
* @param self 静态方法,需要从外部传入字符串。
* @return 返回处理后的字符串。
*/
public static String bracket(String self){
return "(" + self + ")";
}
// 出于阅读体验,这里省略了 GET,SET,以及构造器方法。
}
复制代码
尽管笔者尽力说服自己 “StringHandler
就是包装的 String
“,但此刻是多么希望 Java 能够引入 Scala 的隐式类特性啊 …… 难道就没有将拓展方法无缝植入到 String
的途径了吗?Groovy 提供了一种解决之道。简单来说,这就像 “冰箱装大象一样”,只需要三步:
- 编写容器类承载
String
的拓展实例方法以及静态方法。这些容器可以是通过任意一个 JVM 语言编写的,包括 Groovy,Java 。 - 把这些容器打包成一个
jar
。 - 想办法让 Groovy 识别到该
jar
包的拓展内容 ( 通过 SPI 实现,见 java-spi编程实践(Service Provider Interface+maven)。
上文提到了两个方法,一个是将字符串写入到文本的 writeTo
实例方法,另一个则是为字符串添加括号的 bracket
静态方法。这里选择使用 Groovy 类去实现,并且将静态方法和实例方法分别存储到两个容器类当中。
package com.i.extension
import groovy.transform.CompileStatic
@CompileStatic
class StringExtension {
// "HELLO WORLD".writeTo(new File("..."))
// 这里的 self 指调用此实例方法的那个 String 。
// 在调用时,writeTo 只接收 file, charset 两个参数。
static String writeTo(String self,File file,String charset){
assert file.file
file.withWriterAppend(charset){
it.writeLine(self)
}
}
}
//------ 两个类文件 -----------//
package com.i.extension
import groovy.transform.CompileStatic
@CompileStatic
class StringStaticExtension {
// String.brackt("hello") -> "(hello)"
// 这里的 selfType 表示此方法会和 String 类型绑定。
// 在调用时,bracket 只接收 target 参数。
static String bracket(String selfType,String target){
return "(${target})"
}
}
复制代码
注意,无论我们要为目标绑定实例方法还是静态方法,它们在容器内必须全部声明为静态方法。其次,如果这个方法要被绑定为目标的实例方法,那么它接收的第一个参数代表了目标自身 ( 相当于 this
),反之,第一个参数用于绑定静态类型。
也就是说,每个方法的第一个参数都有特殊的含义,它们将来并不会直接出现在方法的参数列表内 ( 详情见注解 )。
接下来将这些容器装入到一个 jar
包。但是! Groovy 不会平白无故就将这个 jar
实别为 String ( 或者是其它 JDK 类 ) 的拓展工具。在此之前,我们需要利用 SPI ( 服务发现接口,用于打破传统的双亲委派模型,JDBC 就是基于它实现的 ) 接口,在 META-INF/services
下注册拓展服务。这样,当这个拓展包将来被引入到其它项目内时,Groovy 就会知道我们对 String
做了拓展。
注,如果是 Maven 项目,META-INF/services
应该被放置在 resoucre
文件夹下。进入到 META-INF/services
下创建 org.codehaus.groovy.runtime.ExtensionModule
文件,留下以下信息:
moduleName=GString_extension
moduleVersion=1.0
extensionClasses=com.i.extension.StringExtension
staticExtensionClasses=com.i.extension.StringStaticExtension
复制代码
moduleName
是我们为这个拓展模块命名的逻辑名称,moduleVersion
用于声明该模块的版本 ( 这两项可自定义 )。extensionClasses
指明了我们的实例方法拓展容器的全限定名,如果有多个项,那么就使用逗号分割;staticExtensionClasses
则指明了我们的静态方法拓展容器的全限定名。
现在,可以借助 IDE 或者是 Maven 插件将整个项目导出为一个 jar
包了。在其它 Groovy 项目中,只要将刚才这个 jar
包导入到依赖,然后就能使用由我们自己为 String
类拓展的方法了。
// 一段 "由我们实现" 的 String 方法诞生了。
// 所有的方法调用好像真的来自于 String 类型。
"hello".writeTo(new File(/C:\Users\i\Desktop\greet.txt/),"UTF-8")
// (hello)
println String.bracket("hello")
复制代码
不仅如此,IDE 现在完全能够基于我们绑定的方法给出代码提示。到现在为止,之前关于 “GDK 如何在不违背 OCP 原则的前提下拓展 JDK” 的疑惑,我们应该有些许的眉目了 ……