Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

In Kotlin, how can you limit the choices in a fluent Builder for different forks of settings

Tags:

kotlin

In Kotlin, I am writing a builder and want a series of steps that are obvious and must be completed. With a fluent builder I can present all steps but not really set the order they must occur, nor can I change which ones are available based on the previous step. So:

serverBuilder().withHost("localhost")
         .withPort(8080)
         .withContext("/something")
         .build()

is fine, but then adding options like SSL certs:

serverBuilder().withHost("localhost")
         .withSsl()
         .withKeystore("mystore.kstore")
         .withContext("/secured")
         .build()

Now nothing prevents the non-ssl version from having the withKeystore and other options. There should be an error when calling this SSL method without first turning on withSsl():

serverBuilder().withHost("localhost")
         .withPort(8080)
         .withContext("/something")
         .withKeystore("mystore.kstore")   <------ SHOULD BE ERROR!
         .build()

And it might be more complicated with more forks in the road where I only want some objects present and not others.

How do I limit which functions are available at each fork in the builder logic? Is this impossible for a builder, and instead should be a DSL?

Note: this question is intentionally written and answered by the author (Self-Answered Questions), so that the idiomatic answers to commonly asked Kotlin topics are present in SO.

like image 814
Jayson Minard Avatar asked Sep 17 '16 01:09

Jayson Minard


1 Answers

You need to think of your builder as more of a DSL with a series of classes instead of just one class; even if sticking with the builder pattern. The context of the grammar changes which builder class is currently active.

Let's start with a simple option that forks the builder class only when the user selects between HTTP (default) and HTTPS, keeping the builder feel:

A quick extension function that we'll use to make fluent methods prettier:

fun <T: Any> T.fluently(func: ()->Unit): T {
    return this.apply { func() }
}

Now the main code:

// our main builder class
class HttpServerBuilder internal constructor () {
    private var host: String = "localhost"
    private var port: Int? = null
    private var context: String = "/"

    fun withHost(host: String) = fluently { this.host = host }
    fun withPort(port: Int) = fluently { this.port = port }
    fun withContext(context: String) = fluently { this.context = context }

    // !!! transition to another internal builder class !!!
    fun withSsl(): HttpsServerBuilder = HttpsServerBuilder()

    fun build(): Server = Server(host, port ?: 80, context, false, null, null)

    // our context shift builder class when configuring HTTPS server
    inner class HttpsServerBuilder internal constructor () {
        private var keyStore: String? = null
        private var keyStorePassword: String? = null

        fun withKeystore(keystore: String) = fluently { this.keyStore = keyStore }
        fun withKeystorePassword(password: String) = fluently { this.keyStorePassword = password }

        // manually delegate to the outer class for withPort and withContext
        fun withPort(port: Int) = fluently { [email protected] = port }
        fun withContext(context: String) = fluently { [email protected] = context }

        // different validation for HTTPS server than HTTP
        fun build(): Server {
            return Server(host, port ?: 443, context, true,
                    keyStore ?: throw IllegalArgumentException("keyStore must be present for SSL"),
                    keyStorePassword ?: throw IllegalArgumentException("KeyStore password is required for SSL"))
        }
    }
}

And a helper function to start off a builder to match your code in the question above:

fun serverBuilder(): HttpServerBuilder {
    return HttpServerBuilder()
}

In this model we use an inner class that can continue to operate on some values of the builder and optionally carry its own unique values and unique validation of the final build(). The builder transitions the user's context to this inner class on the withSsl() call.

Therefore the user is limited to only the options allowed at each "fork in the road". Calling withKeystore() before withSsl() is no longer allowed. You have the error you desire.

An issue here is that you must manually delegate from the inner class back to the outer class any settings that you want to continue to work. If this was a large number, this could be annoying. Instead you could make common settings into an interface, and use class delegation to delegate from the nested class to the outer class.

So here is the builder refactored to use a common interface:

private interface HttpServerBuilderCommon {
    var host: String
    var port: Int?
    var context: String

    fun withHost(host: String): HttpServerBuilderCommon
    fun withPort(port: Int): HttpServerBuilderCommon
    fun withContext(context: String): HttpServerBuilderCommon

    fun build(): Server
}

With the nested class delegating via this interface to the outer:

class HttpServerBuilder internal constructor (): HttpServerBuilderCommon {
    override var host: String = "localhost"
    override var port: Int? = null
    override var context: String = "/"

    override fun withHost(host: String) = fluently { this.host = host }
    override fun withPort(port: Int) = fluently { this.port = port }
    override fun withContext(context: String) = fluently { this.context = context }

    // transition context to HTTPS builder
    fun withSsl(): HttpsServerBuilder = HttpsServerBuilder(this)

    override fun build(): Server = Server(host, port ?: 80, context, false, null, null)

    // nested instead of inner class that delegates to outer any common settings
    class HttpsServerBuilder internal constructor (delegate: HttpServerBuilder): HttpServerBuilderCommon by delegate {
        private var keyStore: String? = null
        private var keyStorePassword: String? = null

        fun withKeystore(keystore: String) = fluently { this.keyStore = keyStore }
        fun withKeystorePassword(password: String) = fluently { this.keyStorePassword = password }

        override fun build(): Server {
            return Server(host, port ?: 443, context, true,
                    keyStore ?: throw IllegalArgumentException("keyStore must be present for SSL"),
                    keyStorePassword ?: throw IllegalArgumentException("KeyStore password is required for SSL"))
        }
    }
}

We end up with the same net effect. If you have additional forks you can continue to open the interface for inheritance and add settings for each level in a new descendant for each level.

Although the first example may be smaller due to a small number of settings it could be the opposite when there is a much greater number of settings and we had more forks in the road that were building up more and more settings, then the interface + delegation model may not save a lot of code but it will reduce the chance that you forget a specific method to delegate or have a different method signature than expected.

It is a subjective difference between the two models.

About using DSL style builder instead:

If you used a DSL model instead, for example:

Server {
    host = "localhost" 
    port = 80  
    context = "/secured"
    ssl {
        keystore = "mystore.kstore"
        password = "p@ssw0rd!"
    }
}

You have the advantage that you don't have to worry about delegating settings or the order of method calls because within a DSL you tend to enter and exit the scope of a partial builder and therefore already have some context shifting. The issue here is that because you are using implied receivers for each part of the DSL, the scope can bleed from an outer object to an inner object. This would be possible:

Server {
    host = "localhost" 
    port = 80  
    context = "/secured"
    ssl {
        keystore = "mystore.kstore"
        password = "p@ssw0rd!"
        ssl {
            keystore = "mystore.kstore"
            password = "p@ssw0rd!"
            ssl {
                keystore = "mystore.kstore"
                password = "p@ssw0rd!"
                port = 443
                host = "0.0.0.0"
            }
        }
    }
}

So you cannot prevent some of HTTP properties from bleeding into the HTTPS scope. This is intended to be fixed in KT-11551, see here for more details: Kotlin - Restrict extension method scope

like image 119
2 revs, 2 users 100% Avatar answered Jan 03 '23 12:01

2 revs, 2 users 100%