О Kodein

2019-04-21

Мы тут пишем Лямбды. Которые AWS Lambda. Пишем на Kotlin. Запускаем в JVM. В OpenJDK 1.8, который туда завезли.

Я, как человек, сильно покусанный всякими паттернами и Спрингом, конечно же пишу Лямбды в виде максимально независимых компонентов. И возникает вопрос, как эти компоненты связывать друг с другом. Речь не о передаче сообщений. Речь о том, где компоненты создать, и как им передать ссылки друг на друга.

Spring говорит нам использовать их IoC. Но тащить Spring в Lambda я сразу отказался. Из-за долгого времени инициализации. А время в AWS — деньги. Можно, конечно, попытаться это время заоптимизировать. Но зачем, если можно без Spring?

Лямбды — это не такие уж и большие штуки. Их задача: принять сообщение, распарсить, слепить с другими данными, и куда-нибудь передать или записать. Меньше десятка всяких компонентов. Недостаточно, чтобы тащить целый Spring. Да Spring и не даст ничего существенного.

Зато со Спрингом мы научились, что все зависимости нужно передавать в конструкторе. С именованными аргументами в Kotlin не проблема передавать достаточно много параметров. Спринг умеет создавать свои бины и внедрять зависимости именно как аргументы конструктора.

А как без Spring? У меня получилось, например, так.

Для начала нам нужны некоторые переменные окружения, которыми мы будем настраивать Лямбду.

class Environment(
    redisUri: URI? = null
) {

    val redisUri: URI by lazy {
        redisUri
            ?: URI(System.getenv("REDIS_URI")
            ?: throw ConfigurationException("Missing REDIS_URI environment variable"))
    }

    companion object {
        val DEFAULT = Environment()
    }

}

Затем нужно создать подключение к Redis. JedisPool — это стандартный пул коннекций редискового клиента Jedis. RedisOperations и RedisTemplate — это не Spring, это типа мои обёрточки над подмножеством операций в Redis.

object RedisConfig {

    private val poolInstance = SingletonHolder<JedisPool>()

    fun jedis(
        pool: JedisPool = jedisPool()
    ): RedisOperations {
        return RedisTemplate(pool.resource)
    }

    fun jedisPool(
        environment: Environment = Environment.DEFAULT
    ): JedisPool {
        return poolInstance.getInstance {
            val config = JedisPoolConfig()
            JedisPool(config, environment.redisUri)
        }
    }

}

SingletonHolder — это специальный класс, чтобы безопасно создавать и хранить синглетоны.

open class SingletonHolder<T> {

    @Volatile private var instance: T? = null

    fun getInstance(creator: () -> T): T {
        val i = instance
        if (i != null) {
            return i
        }

        return synchronized(this) {
            val i2 = instance
            if (i2 != null) {
                i2
            } else {
                val created = creator()
                instance = created
                created
            }
        }
    }

}

Далее у нас какой-нибудь репозиторий, который работает с данными в Redis.

class RedisRepository(
    private val redis: RedisOperations = RedisConfig.jedis()
): Repository {

    //...

}

Репозиторий нужно где-то создать. И в это где-то нужно передать наш экземпляр Jedis. Каждый раз новый, из пула.

object RepositoriesConfig {

    private val repositoryHolder = SingletonHolder<Repository>()

    fun repository(
        redis: RedisOperations = RedisConfig.jedis()
    ): Repository {
        return repositoryHolder.getInstance {
            RedisRepository(
                redis = redis
            )
        }
    }

}

А этот репозиторий используется в каком-то фильтре. Что-то тут мне надоело пробрасывать дефолтные значения, эти самые ленивые синглтоны.

class Filter(
    private val repository: Repository
    //...
) {

    //...

}

Ну и, наконец, фильтр используется в лямбдовом хэндлере. Таки нужно не забыть создать фильтр правильно, с правильным Jedis.

class Handler(
    private val filter: Filter = Filter(RepositoriesConfig.repository())
    //...
) : RequestStreamHandler {

    //...

}

То, что всё передаётся в конструкторе, позволяет легко писать тесты с моками.

class RedisRepositoryTest {

    private lateinit var redis: RedisOperations

    private lateinit var repository: Repository

    @Before
    fun setUp() {
        redis = mock()
        repository = RedisRepository(
            redis = redis
        )
    }

    //...

}

Но как написать некий интеграционный тест? Тут нужно создать настоящий пул коннекций, но по некоторому другому тестовому URI. И желательно этот URI передавать не через переменную окружения. Можно сделать так.

val environment = Environment(
    redisUri = URI("redis://localhost/")
)
val pool = RedisConfig.jedisPool(environment)
val jedis = RedisConfig.jedis(pool)
//...

В общем, не очень удобно в случае транзитивных зависимостей. Если нам нужно что-то поменять, нужно воссоздавать всю цепочку зависимостей. Не везде можно просто передать environment. Очень хочется сделать полноценный IoC, который бы запрятал всю эту муть с зависимостями, но и позволял всё легко переопределять.

Но IoC без Spring и на чистом Kotlin уже есть. И не один. Мне приглянулся Kodein.

Kodein logo

Осторожно, Kodein с пятой версии заметно изменился. Текущая версия уже 6.2.0. Старые туториалы на просторах интернетов для четвёртой версии будут немного неактуальны.

А ещё Kodein собирается развернуться, стать полноценным фреймворком или даже NoSQL базой данных. Но меня сейчас интересует часть, называемая Kodein-DI.

Всё крутится вокруг экземпляра класса Kodein, который тоже надо где-то создать. Тот же самый способ в виде поля object ничем не хуже.

object Config {

    val kodein = Kodein {

        constant(tag = "redisUri") with (
            URI(System.getenv("REDIS_URI")
            ?: throw ConfigurationException("Missing REDIS_URI environment variable"))
        )

        bind<RedisOperations>() with singleton {
            RedisTemplate(
                jedis = instance()
            )
        }

        bind<Jedis>() with provider {
            val pool: JedisPool = instance()
            pool.resource
        }

        bind<JedisPool>() with singleton {
            val config = JedisPoolConfig()
            JedisPool(config, instance<URI>(tag = "redisUri"))
        }

        bind<Filter>() with singleton {
            Filter(
                repository = instance()
            )
        }

        bind<Repository>() with singleton {
            RedisRepository(
                redis = instance()
            )
        }

    }

}

Это вся та же конфигурация, только через Kodein. constant() задаёт некоторое «константное» значение, которое инициализируется при инициализации нашего IoC, то есть при загрузке класса Config. bind() задаёт некий биндинг, то есть навешивает стратегию получения объекта указанного типа. singleton создаёт синглтон. Через instance() можно получить экземпляр объекта из IoC, в том числе и в стратегиях получения других объектов. provider вызывает функцию стратегии при каждом получении экземпляра, в данном случае забирается соединение (ресурс) из пула.

По умолчанию Kodein резолвит значения по типу. Но если нужно, можно указать tag. В данном случае тег указан у константы. Для констант теги особенно нужны, так как они все обычно одного типа, чаще String.

Kodein теперь придётся таскать повсюду. А так как все зависимости мы внедряем в конструктор, стоит его поставить первым параметром конструктора. А остальные параметры по дефолту выдирать из IoC.

Конструкторы становятся такими:

class Handler(
    kodein: Kodein = Config.kodein,
    private val filter: Filter = kodein.direct.instance()
    //...
) : RequestStreamHandler {

    //...

}

Тут проявляется отличие от четвёртой версии Kodein. Нужно явно написать kodein.direct.instance(), чтобы сразу получить объект из IoC. Просто последние версии Kodein заточены на инициализацию свойств. Предполагается писать вот так:

class Handler(
    kodein: Kodein = Config.kodein
) : RequestStreamHandler {

    private val filter: Filter by kodein.instance()

    //...

}

kodein.instance() возвращает не значение, а делегат для получения свойства. Но мы же хотим всё инициализировать через конструктор, поэтому делегаты не пригодятся.

Инициализация всего через конструктор по-прежнему позволяет писать простые тесты. Если настоящий полноценный Kodein, который по умолчанию, мешается, его можно заменить на что-нибудь пустое.

val kodein = Kodein {}
val redis = mock()
val repository = RedisRepository(
    kodein = kodein,
    redis = redis
)

Конечно же, без проблем для каждого теста можно создавать свой собственный Kodein, с минимальным набором зависимостей и моками.

С интеграционными тестами можно переопределять биндинги из продакшенового IoC:

val kodein = Kodein {
    extend(Config.kodein, allowOverride = true)
    constant(tag = "redisUri", overrides = true) with URI("redis://localhost/")
}

Но в данном случае это не работает, потому что в продакшине "redisUri" инициализируется из переменной окружения. А если переменная не задана, инициализация падает. А в Kodein реализовано почти полноценное наследование, можно заполучить и объект из родительского IoC.

Решение — модули. Это будет не наследование, а композиция (хотя официальная документация Kodein весьма вольно подходит к использованию этих терминов). Если мы определим те объекты, что нам нужны для тестов, в отдельном модуле, то мы сможем этот модуль использовать и для продакшена, и для теста, определяя URI там и там таким, как нам нужно.

Вот модуль. URI ещё не определён.

object RedisConfig {

    val kodein = Kodein.Module(name = "Redis") {

         bind<RedisOperations>() with singleton {
             //...
         }

         bind<Jedis>() with provider {
             //...
         }

         bind<JedisPool>() with singleton {
             val config = JedisPoolConfig()
             JedisPool(config, instance<URI>(tag = "redisUri"))
         }

    }

}

А вот мы используем модуль для теста. Здесь уже определяем тестовый URI.

val kodein = Kodein {
    constant(tag = "redisUri") with URI("redis://localhost/")
    import(RedisConfig.kodein)
}

Ещё раз, Kodein.Module — это не живой IoC. Он не инициализируется и не создаётся, пока не будет включён в полноценный Kodein. А вот Kodein — это живой IoC. И если его расширять, нужно разбираться в отношениях с предками.

Kodein мне понравился. Интенсивно использует плюшки Kotlin со всякими этими DSL, выводом типов, reified inline функциями. Вроде как generic вкус Kodein даже умеет не терять типы у generic.

В Kodein отсутствует чудовищный рефлекшен и прокси объекты, как в Spring, или тонна кодогенерации, как в Dagger. Всё просто, изящно, и на чистом Kotlin.