Building Domain Specific Languages on the JVM 

Kyle Boon

Who am I?

  • Lead Engineer @ Target on the Ole Warehousing Team
  • @kyleboon on twitter (dormant)
  • www.kyleboon.org 
  • kyle.f.boon@gmail.com
  • github.com/kyleboon

Easy to read
DISCOVERABLE
REVEALS INTENT

What is a DSL

  • Domain-specific language (noun): a computer programming language of limited expressiveness focused on a particular domain.

  • Read Martin Fowler's Book

Examples

  • Spock
  • JOOQ
  • build.gradle
  • regex

How Does Ole use Dsls

  • Abstract printer language
  • Building a Task Framework
  • Tap deploy scripts

Some Examples

String generateOversizePackLabelCommand(PackLabel packLabel) {
        print()
            .text("T-${packLabel.store}", 60, 40, EXTRA_LARGE)
            .text(packLabel.itemType.code, 500, 40, EXTRA_LARGE)

            .comment('Aisle List')
            .text(LOCATION_IN_STORE, 60, 180, TextSize.SMALL)
            .grid(packLabel.sortGroups.sort(), 60, 240)

            .comment('Shelf location')
            .rectangle(450, 250, 300, 100)
            .text(packLabel.packLocation.shelf.position, 470, 270, EXTRA_LARGE, true)
            .shelfShape(packLabel.packLocation.shelf, 560, 410)
        .build()
    }

            ^XA
            ^LH0,0
            ^FWN
            ^POI

            ^FO60,40^A0,80,80^FDT-3229^FS
            ^FO500,40^A0,80,80^FDNON-F^FS

            ^FXAisle List^FS
            ^FO60,180^A0,30,35^FDLocation in Store:^FS
            ^FO60,240^A0,45,40^FDA1^FS
            ^FO60,320^A0,45,40^FDA2^FS
            ^FO60,400^A0,45,40^FDA3^FS
            ^FO60,480^A0,45,40^FDA4^FS
            ^FO210,240^A0,45,40^FDA5^FS
            ^FO210,320^A0,45,40^FDA6^FS
            ^FO210,400^A0,45,40^FDA7^FS
            ^FO210,480^A0,45,40^FDA8^FS

            ^FXShelf location^FS
            ^FO450,250^GB300,100,100^FS
            ^FO470,270^A0,80,80^FR^FDBottom^FS
            ^FO560,410^GC80,80,B^FS

            ^XZ

Tasks

root(BIN_AUDIT, request.rootId)
    .requiresRole(Role.ADVANCED)
    .locationId(request.locationId)
    .entryPoint(request.binBarcode)
    .title(AuditMessages.BIN_AUDIT_TITLE)
    .sendsEvent(resultsBuilder)
    .subTask(
    scanTask()
        .description(SCAN_BIN)
        .successMessage(SCAN_BIN_SUCCESS)
        .validates(BarcodeHasValue, request.binBarcode))
    .subTask(
    repeats()
        .untilBarcodeScanned(
        scanTask()
            .successMessage(SCAN_BIN_SUCCESS)
            .validates(BarcodeHasValue, request.binBarcode))
        .subTask(
        scanTask()
            .description(SCAN_UPC_OR_BIN)
            .successMessage(SCAN_UPC_SUCCESS)
            .validates(MatchesRegex, /\d{7,17}/)
            .populatesEventField(resultsBuilder.scannedUpcs)))
    .subTask(
    scanTask()
        .validates(BarcodeHasValue, request.binBarcode)
        .description(CONFIRM_CLOSE)
        .successMessage(SCAN_BIN_SUCCESS))

Test

@Unroll
void "#steps.index Scanning #steps.name (#steps.barcode) expects next screen '#steps.description'"() {
    when:
    process(steps)

    then:
    assertResult(steps)

    where:
    steps << [
        successScan('binBarcode', binBarcode, REPEATS_UNTIL, BIN_AUDIT_TITLE, SCAN_UPC_OR_BIN, [], SCAN_BIN_SUCCESS, []),
        successScan('first upc', upc1, REPEATS_UNTIL, BIN_AUDIT_TITLE, SCAN_UPC_OR_BIN, [], SCAN_UPC_SUCCESS, []),
        successScan('second upc', upc2, REPEATS_UNTIL, BIN_AUDIT_TITLE, SCAN_UPC_OR_BIN, [], SCAN_UPC_SUCCESS, []),
        successScan('other bin', BAD_BIN, REPEATS_UNTIL, BIN_AUDIT_TITLE, SCAN_UPC_OR_BIN, [], INVALID_BARCODE, [BAD_BIN]),
        successScan('non upc', 'BAD_BAD_BAD_BAD_BAD_BARCODE', REPEATS_UNTIL, BIN_AUDIT_TITLE, SCAN_UPC_OR_BIN, [], INVALID_BARCODE, ['BAD_BAD_BAD_BAD_BAD_BARCODE']),
        successScan('upc', 'BAD_UPC', REPEATS_UNTIL, BIN_AUDIT_TITLE, SCAN_UPC_OR_BIN, [], INVALID_BARCODE, ['BAD_UPC']),
        successScan('third upc', upc1, REPEATS_UNTIL, BIN_AUDIT_TITLE, SCAN_UPC_OR_BIN, [], SCAN_UPC_SUCCESS, []),
        successScan('binBarcode', binBarcode, BIN_AUDIT, BIN_AUDIT_TITLE, CONFIRM_CLOSE, [], SCAN_BIN_SUCCESS, []),
        completionScan('binBarcode', binBarcode, BIN_AUDIT, BIN_AUDIT_TITLE, null, [], SCAN_BIN_SUCCESS, [])
            .event(new BinAuditResults(upcsScanned: ['UPC1': 2, 'UPC2': 1], binBarcode: binBarcode, auditType: BIN_AUDIT, locationId: '3844', auditedByLanId: nextTask.completedBy))
    ]
}

Goals

  • Rapid development
  • Keep all business logic server side
  • Abstract persistence, http, kafka messaging away

Alternative computation model

  • DSL defines a semantic model of a task
  • Declarative rather than imperative 

Tap Deployment

---
deployment:
  port: 5053
  cpuMilliCores: 4000
  memoryMi: 3072
  image:
    name: task-manager
ole_shared_configmaps:
  - common.endpoints.yml
  - common.kafka-ole.yml
ole_shared_secrets:
  - kafka.secret.yml

IMplementation Strategies

  • Expression builders
  • Method chaining 
  • Nested closures / functions

Useful groovy Features

  • @Builder Support
  • Operator overloading
  • Extension methods
  • ASTs

Builders Support in Groovy

import groovy.transform.builder.Builder

@Builder
class User {
    String username
    String password
    List<Role> roles

    static enum Role {
        NORMAL, ADMIN
    }
}

User.builder()
        .username("kyle")
        .password("password1")
        .build()
import groovy.transform.builder.Builder

@Builder(prefix = 'assign', buildMethodName = 'create', excludes = ['password'])
class User {
    String username
    String password
    List<Role> roles

    static enum Role {
        NORMAL, ADMIN
    }
}

User.builder()
        .assignUsername("kyle")
        .assignRoles([User.Role.ADMIN])
        .create()
import groovy.transform.builder.Builder

@Builder()
class User {
    String username
    String password
    List<Role> roles = [Role.ADMIN]

    static enum Role {
        NORMAL, ADMIN
    }
}

assert User.builder().build().roles
import groovy.transform.builder.Builder
import groovy.transform.builder.ExternalStrategy

class User {
    String username
    String password
    List<Role> roles

    static enum Role {
        NORMAL, ADMIN
    }
}

@Builder(builderStrategy = ExternalStrategy, forClass = User)
class UserBuilder {
    UserBuilder() {
        roles = [User.Role.ADMIN]
    }
}

new UserBuilder().build().roles
import groovy.transform.builder.Builder
import groovy.transform.builder.ExternalStrategy

class User {
    String username
    String password
    List<Role> roles

    static enum Role {
        NORMAL, ADMIN
    }
}

@Builder(builderStrategy = ExternalStrategy, forClass = User)
class UserBuilder {
    UserBuilder admin() {
        roles = [User.Role.ADMIN]
        return this
    }
}

new UserBuilder().admin().build().roles == [User.Role.ADMIN]
import groovy.transform.builder.Builder
import groovy.transform.builder.ExternalStrategy

import java.time.Duration
import java.time.LocalDateTime

@Builder
class User {
    String username
    String password
    List<Role> roles
    LocalDateTime passwordExpires

    static enum Role {
        NORMAL, ADMIN
    }
}

// still need to add the descriptors file
class MonthsExtension {
    static LocalDateTime monthsFromNow(Integer self) {
        LocalDateTime.now().plusMonths(self)
    }
}

User.builder().passwordExpires(3.monthsFromNow())
import groovy.transform.builder.Builder
import groovy.transform.builder.ExternalStrategy

class User {
    String username
    String password
    List<Role> roles

    static enum Role {
        NORMAL, ADMIN
    }
}

@Builder(builderStrategy = ExternalStrategy, forClass = User)
class UserBuilder {
    UserBuilder() {
        roles = []
    }

    UserBuilder leftShift(final User.Role role) {
        roles << role
        this
    }
}

UserBuilder builder = new UserBuilder()
builder << User.Role.ADMIN
assert builder.build().roles == [User.Role.ADMIN]

Ratpack Example

RatpackServer.start({ def s -> s
        .serverConfig(c -> c.baseDir(BaseDir.find()))
        .registry(Guice.registry({ def b -> b.module(MyModule)) })
        .handlers({ def chain -> chain
            .path("foo", { def ctx -> ctx.render("from the foo handler") }) 
            .path("bar", { def ctx -> ctx.render("from the bar handler") }) 
            .prefix("nested", { def nested -> 
              nested.path("more", { def ctx -> 
                ctx.render("from the nested/more handler")
              })
            })
            .prefix("static", { nested -> nested.fileSystem("public", Chain::files) })
        })
    })

Using the groovy dsl

ratpack {
        serverConfig {
            baseDir(BaseDir.find()
        }

	bindings {
	    module MyModule
	}

	handlers {
	    path "foo" { render "from the foo handler" }
            path "bar" { render "from the bar handler" }
            prefix "nested" {
                path "more" {
                    render "from the nested/more handler"
                }
            }

	    fileSystem "public", { f -> f.files() }
	}

}

Nested functions and closures

  • Same underlying API, very different feel
  • Groovy offers supports with Closures, @DelagatesTo,  Command Query

Command CHains

These are functionally equivalent in groovy.

a(b).c(d)
a b c d
import groovy.transform.builder.Builder
import groovy.transform.builder.ExternalStrategy
import java.util.function.Supplier

@Builder
class User {
    String username
    String password
}

def build(Supplier supplier) {
    supplier.get().build()
}

def user(String username) {
    User.builder().username(username)
}

build { user "kyle" password "password1" }

@Delegatesto

import groovy.transform.builder.Builder
import groovy.transform.builder.ExternalStrategy

class User {
    String username
    String password
}

@Builder(builderStrategy = ExternalStrategy, forClass = User)
class UserBuilder { }

def build(@DelegatesTo(strategy=Closure.DELEGATE_ONLY, value=UserBuilder) Closure cl) {
    def user = new UserBuilder()
    def code = cl.rehydrate(user, this, this)
    code.resolveStrategy = Closure.DELEGATE_ONLY
    code().build()
}

build { username "kyle" password "password1" }
Made with Slides.com