了解 Groovy 对 JDK 的拓展

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 有两种信息 idname。当我们明确用户要访问姓名字段时,仅需要调用 .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 类添加了许多实用的拓展方法 ( 在之前我们已经体验过一些方法了,比如 withWriterwithStream … 等 )。而对于一些轻量级的 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 提供了一种解决之道。简单来说,这就像 “冰箱装大象一样”,只需要三步:

  1. 编写容器类承载 String 的拓展实例方法以及静态方法。这些容器可以是通过任意一个 JVM 语言编写的,包括 Groovy,Java 。
  2. 把这些容器打包成一个 jar
  3. 想办法让 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” 的疑惑,我们应该有些许的眉目了 ……

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享