9. To build a DSL

DSLS, or Domain Sepcific Language, are usually created to solve a particular problem: SQL statements, for example, are designed to solve the interaction between programmers and databases. DSLS are clever, expressive, and have two characteristics: context-driven and very smooth (of course, a smooth DSL is often complex to design).

DSLS come in two types: external DSLS or internal DSLS. External DSLS are zero-based DSLS with independent facilities for lexical analysis, parsing techniques, interpretation, compilation, code generation, and so on. Developing an external DSL is akin to implementing a completely new language from scratch, with unique syntax and semantics. The build tool make, the parser generation tool YACC, and the lexical analysis tool LEX are common external DSLS.

An internal DSL is relatively inexpensive because it is not built in isolation from the host language. But because of this, the functionality and syntax of an internal DSL are limited by the host language itself. It is interesting to map the syntax of the internal DSL to the underlying logic of the host language and make the internal DSL more expressive in some way than the host language.

In general, it is easier to implement internal DSLS in dynamic languages that offer good metaprogramming capabilities and flexible syntax, such as Ruby, Python, and so on. I’ve also seen how Scala creates internal DSLS in the form of parser combinators, and while Scala is a static language for the JVM, its own abstract expressiveness (pattern matching, implicit classes, type variants) and almost completely free operator overloads are amazing. In contrast, designing internal DSLS in Scala is much more difficult.

Taking your time designing an internal DSL is one of Groovy’s core Features, identified on The Apache Groovy website: The Apache Groovy Programming Language (Groovy-lang.org). Creating an internal DSL requires not only some design effort, but also a lot of clever tricks. For example, this chapter takes advantage of Groovy’s features:

  1. Dynamic loading, splicing, and the flexibility of executing Groovy scripts. ( Groovy as Script )
  2. Inject methods into classes at run time using categories or ExpandoMetaClass.
  3. Delegate and using closureswithMethod provides the Context Context.
  4. Operator overloading.
  5. When a method is called, the parentheses are checked(a)The simplified.

9.1 Command Link Feature

We noticed a long time ago that you can omit the parentheses when calling a method in Groovy. Such as:

println("hello,Groovy")
println "hello,Groovy"
Copy the code

This flexible approach in turn leads to Groovy’s command linking features.

def move(String dir){
    print "move $dir "
    this
}

def turn(String dir){
    print "turn $dir"
    this
}


def jump(String speed,String dir){
    print "jump ${dir} ${speed}"
    this
}

//move("forward").turn("right").turn("right").move("back")
move "forward" turn "right" turn "right" move "back" / / 1

//jump("fast","forward").move("back").move("forward")
jump "fast"."forward" move "back" move "forward"      / / 2
Copy the code

The first statement call does not have a comma. Groovy will first assume that we called a move(“forward”) method, which returns the object instance itself this that also supports calling move, turn, jump, and so on. It then further calls its turn(“right”) method. And so on, a coherent command link emerges.

The second statement calls with an extra comma because the jump method takes two arguments, speed and dir, representing the jump direction.

9.2 Create context using closure delegates

Is there a close package delegate, or a review of the with method for how to gracefully program FP with Groovy closures? (juejin. Cn).

Design contexts are also a feature of DSLS. For example: “Venti latte with two extra shots! . It’s Starbucks DSL, and even though we don’t mention coffee at all, the waitress will still serve us a venti latte — but not at Michelle Ice City. Each DSL is attached to its own context, or context-driven.

Here is a code to order Pizza:

class PizzaShop {
    def setSize(String size){}
    def setAddress(String addr){}
    def setPayment(String cardId){}
}

def pizzaShop = new PizzaShop()
pizzaShop.setSize("large")
pizzaShop.setAddress("XXX street")
pizzaShop.setPayment("WeChat")
Copy the code

The pizzaShop reference is called repeatedly due to the lack of context. In Groovy, methods like this can be combed out using with:

pizzaShop.with {
    setSize "large"
    setAddress "XXX street"
    setPayment "WeChat"
}
Copy the code

The instance pizaaShop acts as a context here, making the code style even more compact.

For another example, the user doesn’t want to actively create an instance of PizzaShop (since creating an instance might require a lot of extra configuration, assuming we follow the “convention over configuration” rule), just to get a pizza. If you want to create an implicit context object, try using Groovy closures’ delegate capabilities:

// The disadvantage is that IntelliJ cannot give code hints for dynamic delegate closures when writing code.
getPizza {
    setSize "large"
    setAddress "XXX street"
    setPayment "WeChat"
}

def getPizza(Closure closure){
    def pizzaShop = new PizzaShop()
    closure.delegate = pizzaShop
    closure.run()
}
Copy the code

9.3 Clever use of Groovy script aggregation and method interception

Taking advantage of Groovy’s DSL capabilities, you can also format configuration files yourself, as shown in a step-by-step example. Each configuration line requires two items: configuration name and value. We can write it like this:

// Think of it as a configuration -> size = "large" and so on.
size "large"  	
payment "WeChat"
address "XXXStreet"
Copy the code

Each line of configuration items can be seen in Groovy as calling the k(v) method (for example, size “large” in a configuration item is equivalent to calling size(“large”)). To avoid errors, we might want to implement and configure the same name in advance:

// The config does not have the def keyword, indicating that it is a global variable within the script.
config = [:]
def size(String size){
	config["size"] = size
}
// Similar to payment,address...
Copy the code

If you have a large number of configuration items, it can take a while to fill out these methods. In fact, this is not necessary for Groovy at all. Reviewing the MOP from the previous chapters, we just need to compose these namesake methods in the methodMissing method, as shown in the code block below. Also, define an acceptOrder “context” that iterates through the contents of the config item and prints it to the console:

config = [:]

def methodMissing(String name,args){
	// Interceptor method name (which represents the configuration name), stored as k.
	// Intercepting parameter values (which represent configuration items and can be an entire array or multiple parameters, depending on how you design them), stored as v.
	config[name] = args
}

// act as an implicit context.
def acceptConfig(Closure closure){

    // Thus, the "method call" in the "configuration file" leads to the current script's methodMissing() method.
    closure.delegate = this

    // Only by calling the closure can the script read the configuration using the methodMissing method.
    closure()

    println "Load configuration :--------------------------"
    config.each {
        k,v ->
            println("config[$k] = $v")}}Copy the code

Inside the current script, the use of configuration items looks something like this:

acceptConfig {
    // This part of the configuration can be separated into another pizzashopdsl. DSL file.
    size "large"
    addr "XXX street"
    payment "WeChat"
}
Copy the code

We do not want to hardcode the content of the configuration inside the source file. Therefore, separate the contents of the configuration items into another pizzashopdsl.dsl text file, and then separate the methodMissing, acceptConfig, and config properties into another loadConfig.groovy script file.

That way, if you want to read and use the configuration in an external script file, you read the two text files, then splice them together into a full Groovy script and execute it.

String config = new File("config.dsl").text
String loadConfig = new File("LoadConfig.groovy").text

def script =
""" ${loadConfig} acceptConfig { ${config} } """
// Execute the spliced script
new GroovyShell().evaluate(script)
Copy the code

Incidentally, if a Groovy script uses a GString expression internally as a string, such as ${k}, you need to escape it to \${k}. Otherwise, Groovy will look for k in the current script, which is not semantically correct.

Also, the loadconfig.groovy in the current example is essentially text. So it’s theoretically possible to store it in TXT, or any other text format, and there’s no serious rule about that. Note that there is no package declaration for the code block used for patchwork.

9.4 Workarounds for the empty parenthesis method

In the example below, Groovy has designed a simple counter based on the DSL, where the Clear method for clearing zeros and the outLine method for output to the console require no parameters.

count = (int)0

def Add(int i){
    count += i
    this
}

def Sub(int i){
    count -= i
    this
}

def clear(){
    count = 0
    this
}

def outLine(){
    println count
    this
}

Add 1 Sub 10 clear() Add 5 outLine()
Copy the code

Empty parentheses () stand out in a coherent DSL. If you remove them, Groovy will assume that we are accessing the Clear, outLine properties (which obviously we are not) and report an error. As mentioned earlier, using Groovy’s universal access principle, you can remove () with only a few changes:

count = (int)0

def Add(int i){
    count += i
    this
}

def Sub(int i){
    count -= i
    this
}

// There is no point in returning this because there is no subsequent connection to any other operations.
def getClear(){
    count = 0
}

def getOutline(){
    println count
}

/ / - 9
Add 1 Sub 10 outline

/ / 3
Add 12 outline

clear
outline
Copy the code

The downside of this is that after “executing” the clear or outLine methods, you must start on a new line to continue with the Add or Sub operation. This counter is much like a Java Stream, with Add and Sub being intermediate operations (or conversion operations) and outline and clear representing termination operations (or termination operations). However, the Java flow closes after a termination operation, but our counter does not.

All intermediate operations should be pure functions, that is, return results that relate only to the external input parameters, and only interact with the external through the return values, with no internal side effects. Terminating is the opposite: only through internal side effects and external interactions (typically output to the console, since this is equivalent to doing one IO), without external data interaction with input parameters and return values.

If a method satisfies both of these characteristics, it is prone to confusion; On the other hand, if a method has no parameters, no return values, and no side effects, it honestly doesn’t make any sense. At this point, it doesn’t seem like a “bad thing” to end each DSL statement with a terminating operation (depending on your opinion).

9.5 Building other forms of DSL

We want the program to recognize a statement like this:

5 days ago at 10:30
// Even if you don't understand Groovy's syntax, you should be able to figure out what this code means.
// 5.days.ago.at(10:30)
Copy the code

If you print this expression, the program correctly prints the previous (or later) date five days ago, and sets the time to 10:30 a.m. Obviously, we need to do some method injection on top of the existing Integer type, so here are two ways to do it:

9.6 Implementing DSL by classification

The first is implemented using the classification described earlier (used in the previous section to code enhance a class in a finite domain, similar to Scala’s implicit classes) :

class DateUtil {

    // days (); // days ();
    static def getDays(Integer self){ self }

    // ago, returns the date x days ago
    static Calendar getAgo(Integer self){
        def date =Calendar.instance
        date.add(Calendar.DAY_OF_MONTH,-self)
        date
    }

    // after, returns the date in x days.
    static Calendar getAfter(Integer self){
        def date = Calendar.instance
        date.add(Calendar.DAY_OF_MONTH,self)
        date
    }

    // at, you can set the specific time.
    static Date at(Calendar self,Map<Integer,Integer> time){
        assert time.size() == 1
        def timeEntry = time.find {true}
        self.set(Calendar.HOUR_OF_DAY,(Integer)timeEntry.key)
        self.set(Calendar.MINUTE,(Integer)timeEntry.value)
        self.set(Calendar.SECOND,0)
        self.time
    }
}

use(DateUtil){
    println 5.days.ago.at(10:30)
    println 10.days.after.at(13:30)
    println 10.days.after.time
}
Copy the code

After calling 10.days.ago, the program returns a singleton of Calendar type. So we’ll inject the AT method into the Calendar type (rather than the Integer type) and have it return a Date type. The AT method is intentionally designed to receive a Map in order to allow the user to express the time in the natural form of HH:mm.

9.7 Implement DSLS through method injection

This code block says roughly the same thing, except that the ExpandoMetaClass injection works globally.

Integer.metaClass {
    
    getDays = {
        -> delegate
    }

    getAgo = {
        -> delegate
        def date = Calendar.instance
        date.add(Calendar.DAY_OF_MONTH,(Integer)delegate)
        return date
    }
}

Calendar.metaClass.at = {
    Map<Integer,Integer> time ->
        assert time.size() == 1
        def timeEntry = time.find {true}
        def t = ((Calendar)delegate)
        t.set(Calendar.HOUR_OF_DAY,(int)timeEntry.key)
        t.set(Calendar.MINUTE,(int)timeEntry.value)
        t.set(Calendar.SECOND,0)
        t.time
}

println 5.days.ago.at(14:30)
Copy the code

Now we know how easy it is to create DSLS inside Groovy. Dynamic features and optional types help a lot in creating a smooth interface; Closure delegates help create context; Classification and ExpandoMetaClass injections and calls to methods are also used in this example.

With that in mind, I’ll update the design of how to implement DSLS using Scala in the future.