Kotlin DSL is now being used for creating gradle build files, for defining routes in Spring Webflux, for creating html templates using kotlinx.html library.
Here I am going to demonstrate creating a kotlin based DSL to represent a Cloud Foundry Application Manifest content.
A sample manifest looks like this when represented as a yaml file:
applications: - name: myapp memory: 512M instances: 1 path: target/someapp.jar routes: - somehost.com - antother.com/path envs: ENV_NAME1: VALUE1 ENV_NAME2: VALUE2
And here is the kind of DSL I am aiming for:
cf { name = "myapp" memory = 512(M) instances = 1 path = "target/someapp.jar" routes { +"somehost.com" +"another.com/path" } envs { env["ENV_NAME1"] = "VALUE1" env["ENV_NAME2"] = "VALUE2" } }
Getting the basic structure
Let me start with a simpler structure that looks like this:
cf { name = "myapp" instances = 1 path = "target/someapp.jar" }
and want this kind of a DSL to map to a structure which looks like this:
data class CfManifest( var name: String = "", var instances: Int? = 0, var path: String? = null )
It would translate to a Kotlin function which takes a Lambda expression:
fun cf(init: CfManifest.() -> Unit) { ... }
The parameter which looks like this:
() -> Unitis fairly self-explanatory, a lambda expression which does not take any parameters and does not return anything.
The part that took a while to seep into my mind is this modified lambda expression, referred to as a Lambda expression with receiver:
CfManifest.() -> Unit
It does two things the way I have understood it:
1. It defines in the scope of the wrapped function an extension function for the receiver type - in my case the
CfManifest
class2.
this
within the lambda expression now refers to the receiver function.Given this, the
cf
function translates to :fun cf(init: CfManifest.() -> Unit): CfManifest { val manifest = CfManifest() manifest.init() return manifest }
which can be succinctly expressed as:
fun cf(init: CfManifest.() -> Unit) = CfManifest().apply(init)
so now when I call:
cf { name = "myapp" instances = 1 path = "target/someapp.jar" }
It translates to:
CFManifest().apply { this.name = "myapp" this.instances = 1 this.path = "target/someapp.jar" }
More DSL
Expanding on the basic structure:cf { name = "myapp" memory = 512(M) instances = 1 path = "target/someapp.jar" routes { +"somehost.com" +"another.com/path" } envs { env["ENV_NAME1"] = "VALUE1" env["ENV_NAME2"] = "VALUE2" } }
The routes and the envs in turn become methods on the
CfManifest
class and look like this:data class CfManifest( var name: String = "", var path: String? = null, var memory: MEM? = null, ... var routes: ROUTES? = null, var envs: ENVS = ENVS() ) { fun envs(block: ENVS.() -> Unit) { this.envs = ENVS().apply(block) } ... fun routes(block: ROUTES.() -> Unit) { this.routes = ROUTES().apply(block) } } data class ENVS( var env: MutableMap<String, String> = mutableMapOf() ) data class ROUTES( private val routes: MutableList<String> = mutableListOf() ) { operator fun String.unaryPlus() { routes.add(this) } }
See how the
routes
method takes in a Lambda expression with a receiver type of ROUTES
, this allows me to define an expression like this:cf { ... routes { +"somehost.com" +"another.com/path" } ... }
Another trick here is way a route is being added is using :
+"somehost.com"
which is enabled using a Kotlin convention which translates specific method names to operators, here the unaryPlus method. The cool thing for me is that this operator is visible only in the scope of ROUTES instance!
Another feature of the DSL making use of Kotlin features is the way a memory is specified, there are two parts to it - a number and the modifier, 2G, 500M etc.
This is being specified in a slightly modified way via the DSL as 2(G) and 500(M).
The way it is implemented is using another Kotlin convention where if a class has an
invoke
method then instances can call it the following way:class ClassWithInvoke() { operator fun invoke(n: Int): String = "" + n } val c = ClassWithInvoke() c(10)
So implementing
invoke
method as an extension function on Int
in the scope of the CFManifest
class allows this kind of a DSL:data class CfManifest( var name: String = "", ... ) { ... operator fun Int.invoke(m: MemModifier): MEM = MEM(this, m) }
This is pure experimentation on my part, I am both new to Kotlin as well as Kotlin DSL's so very likely there are a lot of things that can be improved in this implementation, any feedback and suggestions are welcome. You can play with this sample code at my github repo here
No comments:
Post a Comment