О 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 с пятой версии заметно изменился. Текущая версия уже 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.