1. Introduction

Dependency Injection is a software development pattern where we separate object creation from the objects that are being created. We can use this to keep our main application code as clean as possible. This, in turn, makes it easier to work with and test.

In this tutorial, we’re going to explore the Injekt framework that brings Dependency Injection to Kotlin.

Note: The Injekt library is no longer actively developed, and the developers recommend using Kodein instead.

2. What Is Dependency Injection?

Dependency Injection is a common software development pattern used to make applications easier to maintain and construct. Using this pattern, we separate the construction of our application objects from the actual runtime behavior of them. This means that every part of our application stands alone, and does not directly depend on any other part. Instead, when we construct our objects, we can provide all of the dependencies that it needs.

Using dependency injection, we can easily test our code. Because we control the dependencies, we can provide different ones at the testing time. This allows the use of mock or stub objects so that our test code is in absolute control of everything outside the unit.

We can also trivially change implementations of some parts of the application without other parts needing to change. For example, can replace a JPA based DAO object with a MongoDB based one, and as long as it implements the same interface then nothing else needs to change. This is because the dependency that is being injected has changed, but the code that it is injected into doesn’t depend directly on it.

In Java development, the best-known Dependency Injection framework is Spring. However, when we use this, we bring in a lot of additional functionality that we often don’t need nor want. At its absolute core, Dependency Injection needs only be a setup where we construct our application objects separate to how we use them.

3. Maven Dependencies

Injekt is a standard Kotlin library and is available on Maven Central for inclusion in our project.

We can include this including the following dependency in our project:

<dependency>
    <groupId>uy.kohesive.injekt</groupId>
    <artifactId>injekt-core</artifactId>
    <version>1.16.1</version>
</dependency>

To make the code simpler, it is recommended to use star imports to bring Injekt into our application:

import uy.kohesive.injekt.*
import uy.kohesive.injekt.api.*

4. Simple Application Wiring

Once Injekt is available, we can start to use it to wire our classes together to build our application.

4.1. Starting Our Application

In the simplest case, Injekt provides a base class that we can use for our applications main class:

class SimpleApplication {
    companion object : InjektMain() {
        @JvmStatic fun main(args: Array<String>) {
            SimpleApplication().run()
        }

        override fun InjektRegistrar.registerInjectables() {
            addSingleton(Server())
        }
    }

    fun run() {
        val server = Injekt.get<Server>()
        server.start()
    }
}

We can define our beans in the registerInjectables method, and then the run method is the actual entry point of our application. In here we can access any of the beans that we’ve registered as needed.

4.2. Introducing Singleton Objects

As we’ve seen above, we can register Singleton objects with our application by using the addSingleton method. All this does is to create an object and put it into our dependency injection container for other objects to access.

This also means that we can’t reference other beans in the container when creating these, because the container doesn’t yet exist.

Alternatively, we can register a callback to construct the singleton only when it’s needed.

This gives us the ability to depend on other beans, and it also means that we don’t create them until we need them, making us more efficient:

class Server(private val config: Config) {
    private val LOG = LoggerFactory.getLogger(Server::class.java)
    fun start() {
        LOG.info("Starting server on ${config.port}")
    }
}
override fun InjektRegistrar.registerInjectables() {
    addSingleton(Config(port = 12345))
    addSingletonFactory { Server(Injekt.get()) }
}

Notice that we construct our Server bean using a callback method, and it’s provided with the required Config object directly from the Injekt container.

We don’t need to tell Injekt the types needed here because it can infer them based on the context – where it needs to return an object of type Config, so that’s what we get.

4.3. Introducing Factory Objects

On occasion, we want to have a new object created every time it’s used. For example, we might have an object that is a network client to another service, and every place using it should have its client injected in, with its network connection and everything.

We can achieve this using the addFactory method instead of addSingletonFactory.

The only difference here is that we will create a new instance on every injection, instead of caching it and reusing it:

class Client(private val config: Config) {
    private val LOG = LoggerFactory.getLogger(Client::class.java)
    fun start() {
        LOG.info("Opening connection to on ${config.host}:${config.port}")
    }
}
override fun InjektRegistrar.registerInjectables() {
    addSingleton(Config(host = "example.com", port = 12345))
    addFactory { Client(Injekt.get()) }
}

In this example, everywhere that we then inject a Client will get a brand new instance, but all of those instances will share the same Config object.

5. Accessing Objects

We can access objects built by the container in different ways, depending on what is most appropriate. Above we already saw that we could inject one object out of the container into another at construction time.

5.1. Direct Access from the Container

We can call Injekt.get from anywhere in our code at any time, and it will do the same thing. This means that we can also call it from our live application at any time to access objects from the container.

This is especially useful for Factory Objects, where we would get a new instance every time at runtime instead of being injected the same one at construction time:

class Notifier {
    fun sendMessage(msg: String) {
        val client: Client = Injekt.get()
        client.use {
            client.send(msg)
        }
    }
}

This also means that we aren’t restricted to using classes for our code. We can access objects from the container inside of top-level functions as well.

5.2. Use as a Default Parameter

Kotlin allows us to specify default values for parameters. We can also use Injekt here so that a value is obtained from the container if an alternative value is not provided.

This can be especially useful for writing unit tests, where the same object can be used both in the live application – obtaining dependencies automatically from the container, or from unit tests – where we can provide an alternative for testing purposes:

class Client(private val config: Config = Injekt.get()) {
    ...
}

We can use this equally well for constructor parameters and method parameters, and for both classes and top-level functions.

5.3. Using Delegates

Injekt provides us with some delegates that we can use to access container objects automatically as class fields.

The injectValue delegate will get an object from the container immediately on class construction, whereas the injectLazy delegate will get an object from the container only when it’s first used:

class Notifier {
    private val client: Client by injectLazy()
}

6. Advanced Object Construction

So far, everything that we have done we can achieve without using Injekt, albeit not as cleanly as when using Injekt.

There are more advanced construction tools that we have available to us though, allowing techniques that are harder to manage on our own.

6.1. Per Thread Objects

Once we start accessing objects from the container directly in our code, we start to run the risk of object contention. We can solve this by getting a new instance every time – created using addFactory – but this can get expensive.

Alternatively, Injekt can create a new instance for every thread that calls it, but then cache the instance for that thread.

This avoids the risk of contention – each thread can only be doing one thing at a time – but also reduces the number of objects that we need to create:

override fun InjektRegistrar.registerInjectables() {
    addPerThreadFactory { Client(Injekt.get()) }
}

We can now obtain a Client object at any time, and it will always be the same one for the current thread, but never the same as in any other thread.

6.2. Keyed Objects

We need to be careful not to get carried away with the per-thread allocation of objects. This is fine if there is a fixed number of threads, but if threads are being created and often destroyed then our collection of objects can grow needlessly.

Additionally, sometimes we need to have access to different instances of the same class at the same time, to use for different reasons. We still want to be able to access the same instance for the same reason.

Injekt gives us the ability to access objects in a keyed collection, where the caller that requests the object provides the key.

This means that any time we use the same key, we will get the same object. The factory method also has access to this key in case it’s needed for modifying functionality in some way:

override fun InjektRegistrar.registerInjectables() {
    addPerKeyFactory { provider: String ->
        OAuthClientDetails(
            clientId = System.getProperty("oauth.provider.${provider}.clientId"),
            clientSecret = System.getProperty("oauth.provider.${provider}.clientSecret")
        )
    }
}

We can now obtain the OAuth Client Details for a named provider – e.g. “google” or “twitter”. The object returned is correctly populated based on the system properties set on the application.

7. Modular Application Construction

So far we’ve only been building our container in a single place. This works but will get unwieldy over time.

Injekt gives us the ability to do better than this but splitting our configuration up into modules. This lets us have smaller, more targeted areas of configuration. It also allows us to have configuration included inside libraries for which they apply.

For example, we might have a dependency that represents a Twitter bot. This could include an Injekt module so that anyone else using it can plug it directly in.

Modules are Kotlin objects that extend the InjektModule base class, and that implement the registerInjectables() method.

We’ve already done this with the InjektMain class that we used earlier. That is a direct subclass of InjektModule and works the same way:

object TwitterBotModule : InjektModule {
    override fun InjektRegistrar.registerInjectables() {
        addSingletonFactory { TwitterConfig(clientId = "someClientId", clientSecret = "someClientSecret") }
        addSingletonFactory { config = TwitterBot(Injekt.get()) }
    }
}

Once we have a module, we can include it anywhere else in our container using the importModule method:

override fun InjektRegistrar.registerInjectables() {
    importModule(TwitterBotModule)
}

At this point, all objects defined in this module are available exactly as if they were defined directly here.

8. Conclusion

In this article, we’ve given an introduction to Dependency Injection in Kotlin, and how the Injekt library makes this simple to achieve.

There is a lot more than we can achieve using Injekt than shown here. Hopefully, this should get you started on the journey to simple Dependency Injection.

And, as always, check out the examples of all this functionality over on GitHub.


« 上一篇: Kotlin排序指南