这段时间在学习构建工具Gradle,因为Gradle的构建脚本要使用到groovy语言编写,因此就顺便学习了一下groovy。一开始听说gradle脚本其实就是groovy语言的脚本,我是十分震惊的,因为再怎么看都感觉build.gradle都只是像一个配置文件,根本想不到是一段代码。学习了gradle里面的各种特殊的省略写法和闭包的概念之后,才恍然大悟,原来.gradle脚本就是各种省略完了的groovy脚本。

定义成DSL的好处时,让就算没学过Groovy语法的非专业用户也能非常方便直观地,使用接近自然语言的魔法语法来编写代码组织功能。因为听起来真的很cool,我就也尝试自己写了一套用于绘制简单的线段图形的DSL。这个想法来源于《程序员修炼之道》这本书,书中在介绍领域语言的时候引入了一个类似的练习。

目标

使用Groovy定义一套DSL,让下面的脚本可以绘制出直线并展示出来。

pad(500, 500) {
    color "black"
    line {
        start(100, 200) end(300, 300)
        start(100, 30) end(0, 0)
    }
    color "red"
    line {
        start(100, 200) end(100, 300)
        start(500, 500) end(60, 20)
    }
}

pad {
    size {
        length = 1000
        width = 1000
    }
    color "red"
    line {
        start(100, 200) end(300, 300)
    }
}

进行一下简单说明:

  1. pad表示一个新建一个画板,花括号中表示这个画板上的内容
  2. 画板的大小既可以在建立的时候通过圆括号指定,也可以在内容中用size来指定
  3. color表示更换颜色,表示之后的绘图将使用新的颜色。默认黑色
  4. start end表示一条线段开始和结束的坐标,放在line后的块可以定义line

Step0 打桩

直接运行脚本必定报错,首先想办法让脚本不报错。这里打桩的方法是建立一个脚本,用来写自己的DSL实现,然后在现在的类中通过静态引入的方式,将dsl脚本引入。

// SimpleTest.groovy
import static top.claws.PaintingDsl.*

pad(500, 500) {
    color "black"
    //......
}

然后在PaintingDsl中,先定义静态的pad函数。

class PaintingDsl {
    static def pad(def length, def width, Closure closure) {
        // TODO
    }
    
    static def pad(Closure closure) {
        // TODO
    }
}

这里牵扯到Groovy中的省略:

  • 如果一个函数最后一个参数是闭包,这个闭包在调用的时候可以写在外面。
  • 如果一个函数调用有且只有一个参数,则函数调用的括号可以省略。

这样,pad {...}pad(500, 500) {...} 类似的写法就都不会报错了。

Step1 绘制第一条直线

先不管pad中的内容是怎么样的,想办法可以用java的库来绘制展示第一条直线试试。

package top.claws

import javax.swing.JFrame
import javax.swing.JPanel
import java.awt.Color
import java.awt.Graphics

class PaintingDsl {
    def length
    def width

    PaintingDsl(length, width) {
        this.length = length
        this.width = width
    }

    static def pad(def length, def width, Closure closure) {
        def dsl = new PaintingDsl(length, width)
        dsl.show()
    }

    static def pad(Closure closure) {
        pad(500, 500, closure)
    }

    def show() {
        def panel = new JPanel() {
            @Override
            protected void paintComponent(Graphics g) {
                super.paintComponent(g)
                g.setColor(Color.RED)
                g.drawLine(0, 0, 100, 100)
            }
        }

        def frame = new JFrame()
        frame.contentPane = panel
        frame.setSize(length, width)
        frame.setLocationRelativeTo(null)
        frame.defaultCloseOperation = JFrame.EXIT_ON_CLOSE
        frame.visible = true
    }
}

将DSL定义更改成上面这样,就可以绘制第一条红色线段了。

Step2 设定size

给pad设置size应该实现两种方式,第一种就是新建的时候传入大小参数,第二种是在pad的内容中设定。第一种在刚才的部分已经实现了,着重来看第二个:

pad {
    size {
        length = 500
        width = 500
    }
}

一旦出现大括号,就要当做闭包类型的参数,传递给前面的函数。于是这里应该有一个名为size的函数,接收一个闭包参数。之后有一种做法是,使用一个字典成员变量,将闭包中的变量复制全部记录到字典中。

同时要在pad函数中,将closure的delegate指向刚实例化的PaintingDsl对象,让size能够调用到PaintingDsl的成员函数。

package top.claws

import javax.swing.JFrame
import javax.swing.JPanel
import java.awt.Color
import java.awt.Graphics

class PaintingDsl {
    def sizeMap = [:]

    PaintingDsl(length, width) {
        sizeMap.length = length
        sizeMap.width = width
    }

    static def pad(def length, def width, Closure closure) {
        def dsl = new PaintingDsl(length, width)
        closure.delegate = dsl
        closure.resolveStrategy = Closure.DELEGATE_ONLY
        dsl.show()
    }

    static def pad(Closure closure) {
        pad(500, 500, closure)
    }

    def size(Closure closure) {
        // 等同于设置delegate到sizeMap并执行闭包
        sizeMap.with closure
    }

    def show() {
        def panel = new JPanel() {
            @Override
            protected void paintComponent(Graphics g) {
                super.paintComponent(g)
                g.setColor(Color.RED)
                g.drawLine(0, 0, 100, 100)
            }
        }
        
        // ...
    }
    
    // 下面这两个方法用来占位 避免报错
    def line(Closure closure) {
        // TODO
    }
    
    def color(def color) {
        // TODO
    }
}

运行之前的测试脚本,发现其中一个窗口确实变大了。

Step3 添加线条

可以使用一个容器记录所有要添加的线条,然后在pad内容结束后,绘图时,统一添加到panel中。

对于line块的处理,也要使用闭包,而line块中的内容将被作为新闭包的delegate的方法被实现出来。因此这里需要增加一个新的闭包以及新的LineDsl类。同时添加一个Line类方便线段管理。

为了简洁,下面只记录增加的部分。

class PaintingDsl {
    def lineList = []
    
    def line(Closure closure) {
        def dsl = new LineDsl(this)
        closure.delegate = dsl
        closure.resolveStrategy = Closure.DELEGATE_ONLY
        closure()
    }
    
    // 更改一下绘图逻辑,绘制列表中的所有线段
    def show() {
        def panel = new JPanel() {
            @Override
            protected void paintComponent(Graphics g) {
                super.paintComponent(g)
                g.setColor(Color.RED)
                for (def line in lineList) {
                    g.drawLine(line.xFrom, line.yFrom, line.xTo, line.yTo)
                }
            }
        }
        // ...
    }
}

class LineDsl {
    def paintingDsl
    def lineBuilding
    
    LineDsl(def paintingDsl) {
        this.paintingDsl = paintingDsl
    }
    
    def start(x, y) {
        lineBuilding = new Line()
        lineBuilding.xFrom = x
        lineBuilding.yFrom = y
        this
    }
    
    def end(x, y) {
        lineBuilding.xTo = x
        lineBuilding.yTo = y
        paintingDsl.lineList.add(lineBuilding)
        lineBuilding = null
    }
}

class Line {
    def xFrom
    def yFrom
    def xTo
    def yTo
    def color
}

Step4 处理颜色

只需要有一个成员变量记录当前在用的颜色就可以了。由于传进来的是一个字符串,可以使用一个static的字典记录颜色名称和对应颜色对象的映射关系。

之后再稍微修改LineDsl中end的逻辑,在线段结束的时候标上颜色。然后更改一下绘图的逻辑,遍历线段时对颜色进行重新设置即可。

class PaintingDsl {
    def curColor = colorMap.black

    static colorMap = [
            red  : new Color(255, 175, 175),
            black: new Color(0, 0, 0),
            white: new Color(255, 255, 255)
    ]
    
    def color(String color) {
        curColor = colorMap[color]
    }
    
    def show() {
        def panel = new JPanel() {
            @Override
            protected void paintComponent(Graphics g) {
                super.paintComponent(g)
                for (def line in lineList) {
                    g.setColor(line.color)
                    g.drawLine(line.xFrom, line.yFrom, line.xTo, line.yTo)
                }
            }
        }
        // ...
    }
}

class LineDsl {
    def end(x, y) {
        lineBuilding.xTo = x
        lineBuilding.yTo = y
        lineBuilding.color = paintingDsl.curColor // 给线段记录颜色
        paintingDsl.lineList.add(lineBuilding)
        lineBuilding = null
    }
}

成果展示

完成了所有代码的编写之后,之前的脚本中的需求就全部达成了,效果如下图: image-20230506010132842

这个小实践中涉及的代码:https://gist.github.com/jingjiecb/e3c049ea6e463d9f06a45e879ea0878a