Let’s Design a Builder

As an enterprise programmer, it’s hard to escape XML. XML shows up in configuration files and is also used as communication payloads. But no one goes home excited after manually editing XML documents. It’s much worse if XML is embedded as a string within code. Let’s build a DSL that can help programmers to easily create an XML document from data in memory.

Suppose we have a Map with programming languages as keys and authors as values:

 val​ languagesAndAuthors =
  mapOf(​"Java"​ to ​"Gosling"​, ​"Lisp"​ to ​"McCarthy"​, ​"Ruby"​ to ​"Matz"​)

Let’s design a DSL to help programmers translate that data into an XML format, like so:

 <languages>
  <language title=​'Java'​>
  <author>
  Gosling
  </author>
  </language>
  <language title=​'Lisp'​>
  <author>
  McCarthy
  </author>
  </language>
  <language title=​'Ruby'​>
  <author>
  Matz
  </author>
  </language>
 </languages>

Each of the languages in the Map appears as a <language></language> element, all embedded within the <languages></languages> root element. The name of the language is defined as an attribute title of the language element, and the author name appears as a text value within the <author></author> child element.

The main purpose of the DSL we design is to transform the data in a Map into an XML document. The syntax should be elegant and easy for the programmers using it.

We can take inspiration from the Groovy XML MarkupBuilder[3] to design the DSL but make the syntax fall in line within the confines of what’s permitted by the Kotlin language.

The users of our DSL may be able to transform the languagesAndAuthors into the XML representation we saw before, using a syntax like this:

 val​ xml = elem(​"languages"​) {
  languagesAndAuthors.forEach { (title, author) ->
  elem(​"language"​, ​"title"​ to title) {
  elem(​"author"​) {
  text(author)
  }
  }
  }
 }
 
 println(xml)

For this syntax to work, we need to design an elem() function that takes a String for a root element name and a lambda that will define the structure of the root’s children. We can embed the elem() function calls to create elements besides the root element. The elem() function, in addition to taking an XML element’s name, can optionally take the names and values of the XML element’s attributes. For example the argument "title" to key represents an attribute named title with a value of the variable key as the attribute’s value. Likewise, the text() function is used to define a text element in the XML document.

Take a few minutes to practice implementing this. Step away from this book, pour a cup of your favorite beverage, close the social media apps, and give it a shot. Once you’re done, continue further to compare the code you’ve created to the example that follows…

Welcome back—let’s walk through the steps needed to process the XML builder DSL code.

The outermost elem() function call appears like a top-level function, so we’ll design it as that. The function needs to take three arguments: the name of the element, a vararg of optional attributes, and a lambda for defining child elements. Let’s do that first:

 fun​ ​elem​(name: String, ​vararg​ attributes: Pair<String, String>,
  block: XmlNode.() -> Unit = {}): XmlNode =
  XmlNode(name, *attributes).apply { block() }

The elem() function returns an instance of a yet-to-be-written XmlNode class. Within the body of the function we create an instance of the XmlNode class, pass it the given name and the attributes, and execute the given lambda in the context of the XmlNode instance before the instance is returned.

Let’s shift our focus now to the XmlNode class, which represents an element in the XML document. Since XML is a hierarchical structure, an XmlNode instance may contain multiple XmlNode elements as its member, in addition to having attributes and text values.

At the minimum, the XmlNode class will need two functions, one for the nested elem() functions to define child elements and the other for the text() function to define a text node of the XML document. We’ll also need to override the toString() function to produce a String representation of an XML document. Let’s take a look at these functions along with a few properties that are necessary to store the details of an XML element node.

 class​ XmlNode(
 val​ elementName: String, ​vararg​ attributes: Pair<String, String>) {
 
 var​ textValue = ​""
 val​ children = mutableListOf<XmlNode>()
 val​ attributes = attributes
 
 fun​ ​elem​(name: String, ​vararg​ attributes: Pair<String, String>,
  block: XmlNode.() -> Unit = {}) =
  children.add(XmlNode(name, *attributes).apply { block() })
 
 fun​ ​text​(value: String) {
  textValue = value
  }
 
 override​ ​fun​ ​toString​() = xmlAsString()
 }

The constructor of the XmlNode class receives the element name and the list of attribute names and values as a vararg of Pair<String, String>.

The class uses the textValue property to store the value of a child text node. In general, an XML element may have multiple text nodes. Our design doesn’t support it, but it can be extended with a little effort.

The children property will hold the children XmlNode instances, and the attributes property refers to the given attributes for the element.

The elem() function of XmlNode is almost the same as the top-level elem() function. Both versions create an XMlNode instance using the given element name and attributes and invoke the given lambda in the context of the instance created. However, unlike the top-level elem() function that returned the created node, the elem() function of XmlNode adds the created node to the children property of the executing parent node.

The text() function merely assigns the given value to the textValue property, to represent the value of a text child node. Finally, the toString() function calls a yet-to-be-implemented xmlAsString() function.

Since XML is a hierarchical structure, we have to use proper indentations when creating a string representation of an XML document. That’s the responsibility of the xmlAsString() function.

 private​ ​fun​ ​xmlAsString​(indentation: String = ​""​): String =
  listOf(
  renderOpeningTag(indentation),
  renderTextNode(indentation),
  renderChildren(indentation),
  renderClosingTag(indentation))
  .filter(String::isNotEmpty)
  .joinToString(​" "​)
 
 private​ ​fun​ ​renderOpeningTag​(indentation: String): String {
 val​ attributeValues = attributes.map { (title, author) -> ​"$title='$author'"​ }
  .joinToString(​" "​, prefix = ​" "​)
 
 return​ ​"$indentation<${(elementName + attributeValues).trim()}>"
 }
 
 private​ ​fun​ ​renderTextNode​(indentation: String) =
 if​(textValue.isEmpty()) ​""​ ​else​ ​" $indentation$textValue"
 
 private​ ​fun​ ​renderChildren​(indentation: String) =
  children.map { node -> ​"${node.xmlAsString("​​$​indentation ​")}"​}
  .joinToString(​" "​)
 
 private​ ​fun​ ​renderClosingTag​(indentation: String) = ​"$indentation</$elementName>"

The xmlAsString() function takes indentation as its parameter, with an empty space as the default indentation. Using a set of small private functions, xmlAsString() creates a string representation of the XML element represented by the instance of XmlNode on which it’s called. It also takes care of including the attributes, any text values, and the children XML nodes as well. The function finally returns a string representation of the node.

You can try the XML Builder DSL with a few different Maps of key and values. You can also extend the design to support any variations you may like.

This example served as a practice to create DSLs. Next we’ll look at another aspect we’ve not touched on yet—we’ll run a DSL code snippet that resides in an external source.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.139.70.131