Groovy DSL Builders #1: The Concept
The builder pattern is used to create complex objects with constituent parts that must be created in the same order or using a specific algorithm. An external class controls the construction algorithm. — The Gang of Four
Groovy is a language where domain-specific languages (DSL) are the first class citizens. There is a whole page in the documentation dedicated to writing DSL:
Builder pattern has its special place among the other DSL approaches because of an ability to delegate closures to different objects which allows creating compact and easily readable code-as-data. Here is the example of one of the built-in builder JsonBuilder
from the official documentation above:
StringWriter writer = new StringWriter()
StreamingJsonBuilder builder = new StreamingJsonBuilder(writer)
builder.records {
car {
name 'HSV Maloo'
make 'Holden'
year 2006
country 'Australia'
record {
type 'speed'
description 'production pickup truck with speed of 271kph'
}
}
}
String json = JsonOutput.prettyPrint(writer.toString())
There are many other builders present directly it the Groovy codebase which could be used as inspiration:
- MarkupBuilder can build XML or HTML content
- NodeBuilder can help you build any node-like structure
- ObjectGraphBuilder for creating interconnected objects
- FileTreeBuilder to create nested directories structure
In this series, we are not going to use any of these as they are providing poor developer experience. I would like you to show how to step-by-step implement your own builder which will benefit from excellent IDE support as well as Groovy’s static compilation.
As an example, I’ve chosen to create DSL for YUML.me diagrams which help to create simple UML diagram online and which data model is relatively simple.
If we want to describe the Order’s example with plain old Groovy features we can just use language features such as map constructor:
Diagram diagram = new Diagram()diagram.notes.add(new Note(
text: 'You can stick notes on diagrams too!',
color: 'skyblue'
))Type customer = new Type(name: 'Customer')
Type order = new Type(name: 'Order')
Type lineItem = new Type(name: 'LineItem')
Type deliveryMethod = new Type(name: 'DeliveryMethod')
Type product = new Type(name: 'Product')
Type category = new Type(name: 'Category')
Type nationalDeliveryMethod = new Type(name: 'National')
Type internationalDeliveryMethod = new Type(name: 'International')diagram.types.add(customer)
diagram.types.add(order)
diagram.types.add(lineItem)
diagram.types.add(deliveryMethod)
diagram.types.add(product)
diagram.types.add(category)
diagram.types.add(nationalDeliveryMethod)
diagram.types.add(internationalDeliveryMethod)diagram.relationships.add(new Relationship(
source: customer,
sourceCardinality: '1',
destinationTitle: 'orders',
destination: order,
destinationCardinality: '0..*',
type: RelationshipType.AGGREGATION
))diagram.relationships.add(new Relationship(
source: order,
sourceCardinality: '*',
destination: lineItem,
destinationCardinality: '*',
type: RelationshipType.COMPOSITION
))diagram.relationships.add(new Relationship(
source: order,
destination: deliveryMethod,
destinationCardinality: '1'
))diagram.relationships.add(new Relationship(
source: order,
sourceCardinality: '*',
destination: product,
destinationCardinality: '*'
))diagram.relationships.add(new Relationship(
source: category,
destination: product,
bidirectional: true
))diagram.relationships.add(new Relationship(
source: nationalDeliveryMethod,
destination: deliveryMethod,
type: RelationshipType.INHERITANCE
))diagram.relationships.add(new Relationship(
source: internationalDeliveryMethod,
destination: deliveryMethod,
type: RelationshipType.INHERITANCE
))
You can see that it takes many lines of code to write just a very simple diagram. In particular, we need to take care of creating new objects and adding them into particular collections.
Diagram
class and its companion in this example are a just plain old Groovy object which uses toString
method to build the diagram:
@CompileStatic
@EqualsAndHashCode
class Diagram {
Collection<Note> notes = new LinkedHashSet<>()
Collection<Type> types = new LinkedHashSet<>()
Collection<Relationship> relationships = new LinkedHashSet<>()
@Override
String toString() {
assert types
StringWriter stringWriter = new StringWriter()
PrintWriter printWriter = new PrintWriter(stringWriter)
for (Note note in notes) {
stringWriter.println(note)
}
Collection<Type> orphanTypes = new LinkedHashSet<>(types)
for (Relationship relationship in relationships) {
orphanTypes.remove(relationship.source)
orphanTypes.remove(relationship.destination)
printWriter.println(relationship)
}
for (Type type in orphanTypes) {
printWriter.println(type)
}
return stringWriter.toString()
}
}
The produced YUML format is on the other hand very compact:
[note: You can stick notes on diagrams too!{bg:skyblue}]
[Customer]<>1-orders 0..*>[Order]
[Order]++*-*>[LineItem]
[Order]-1>[DeliveryMethod]
[Order]*-*>[Product]
[Category]<->[Product]
[DeliveryMethod]^[National]
[DeliveryMethod]^[International]
This will result in a very familiar graph to any YUML user:
The code is available on GitHub under 01-simple
tag:
git clone https://github.com/musketyr/yuml-dsl-builder.git
cd yuml-dsl-builder
git checkout 01-simple
In the next post in this series The Essence: The closures’ basics we will simplify the code using Closures.
Contents
- The Concept: The core concept of builders
- The Essence: The closures' basics
- The Aid: Using the annotations for static compilation
- The Disguise: Hiding the implementation of the builder API
- The Desiccation: Keeping the code DRY
- The Expectations: The importance of handling closures' owner properly
- The Extension: Designing your builder DSL for extendability
- The Resignation: Rewriting the Groovy DSL builder into Java
- The Navigation: Using the annotations for named parameters
- The Conclusion: The checklist for Groovy DSL builders' authors