О Spring

2017-12-09

Spring — это весна. Spring — это пружина. Spring — это родник. Springfield — это городок, где живут Симпсоны. Плюс ещё стопицот одноимённых городков в Соединённом Королевстве, Австралии и Соединённых Штатах.

А ещё есть Spring Framework. Фреймворк, который знают все явисты. Возникший когда-то как легковесная альтернатива Ынтырпрайзным ЯваБобам (EJB).

Spring Logo

Помню, как лет десять назад мы перелезали на Спринг с таких, ныне экзотичных вещей, как Apache Struts, или даже просто месива из сервлетов и JSP. Тогда надо было писать большущие простыни XMLя с описанием всех бинов. Уже тогда надо было подглядывать в исходники Спринга, чтобы понять, как туда воткнуть что-то нестандартное с точки зрения разработчиков фреймворка.

Сейчас в Спринге есть аннотации. И конфигурацию можно описывать Ява кодом. Или вообще не описывать, а просто помечать нужные классы как @Component.

Сейчас есть Spring Boot. Вообще крайне странная штуковина.

С одной стороны, он сильно упрощает начальное создание Спринг приложения. Это — POM артефакты, содержащие целые группы спринговых и внешних зависимостей, с тщательно подобранными (хочется в это верить) версиями, гарантированно работающими друг с другом. Это — автоматические настройщики, которые создают бины, исходя из набора свойств в application.yml или application.properties, и сканируют пакеты в поисках классов и методов помеченных хитрыми аннотациями, чтобы и их настроить. Это — плугины к Maven и Gradle, которые умеют собирать красивые суперджары, с внедрённым Jetty или Netty для веб приложений.

С другой стороны, Spring Boot весьма усложняет попытки уйти в сторону и сделать что-то непредусмотренное. Притащить другую версию библиотеки почти наверняка не выйдет, потому что у артефакта Spring Boot уже есть своя версия. Причём какая это версия, простого способа узнать нет, ибо там очень много транзитивных зависимостей. Чтобы воткнуть нестандартную конфигурацию, банально несколько подключений к разным MongoDB, придётся отключать автоконфигураторы. Какие именно отключать, придётся находить методом научного тыка, ибо нет простого списка этих конфигураторов с описанием того, за что они отвечают и что делают.

Spring Boot Logo

В тех случаях, когда наш любимый, акторный, но пока не очень человеколюбивый фреймворк использовать нельзя, мы берём Spring, Spring Boot и Kotlin. И нормально.

Точка входа в приложение, Application.kt, выглядит забавно.

@SpringBootApplication
@EnableAutoConfiguration(exclude=arrayOf(MongoAutoConfiguration::class, MongoDataAutoConfiguration::class))
@EnableScheduling
@EnableCaching
open class Application

fun main(args: Array<String>) {
    SpringApplication.run(Application::class.java, *args)
}

main() тут получается в классе ApplicationKt. Но Спрингу и его Буту нужен ещё один класс, открытый, ибо станет бином. И вот этот класс и становится @SpringBootApplication.

Современный Спринг делает страшные вещи с вашими классами. То, что будет бином, т.е. любые классы, помеченные @SpringBootApplication, @Configuration, @Component, @Repository, @Service, @Controller, а также любые методы, помеченные @Bean, в Котлине должны быть open. Потому что вы (почти) никогда не получите на выходе (в IOC) именно ваш класс. Вы получите его наследника. Куда будут аккуратно засунуты все объявленные @Autowired и @Value.

Любой класс, куда вы навешаете эти аннотации, станет частью Спринга. Вам понадобятся спринговые зависимости, чтобы эти аннотации объявить. И если этот класс случайно окажется в пакете, где его найдёт автоконфигурация, он сразу окажется в IOC. А если не получится найти все нужные @Autowired и @Value, то приложение рухнет на старте.

Поэтому мы так не делаем. Мы стараемся делать сервисы и компоненты, которые вообще не зависят от Спринга. Все другие сервисы и компоненты, а также конфигурационные значения, которые нужны для работы этого сервиса, передаются в конструкторе. Как это и положено с нормальными объектами. Куча аргументов конструктора в Котлине — не проблема. Мы просто используем именованные аргументы.

Сами внешние сервисы и компоненты в аргументах конструктора представлены интерфейсами. Интерфейс легко замокать. И можно и нужно протестировать этот компонент как положено, юнит тестами. А ещё типы бинов удобнее представлять интерфейсами, тогда можно тихо и незаметно подменить реализацию. Кстати, Спринг вполне корректно различает генерик интерфейсы, с разными типами переменных типа.

class EntityInsertService(
    private val name: String = "",
    private val mongoOps: MongoOperations,
    private val collectionName: String,
    private val executor: Executor
) : IInsertService<Entity> {

    override fun insert(data: Entity) {
        //...
    }

    override fun flush() {
        //...
    }

}

Такие компоненты без аннотаций легко выносятся в (почти) независимые от Спринга библиотеки и переиспользуются между разными приложениями.

А в самом приложении они уже подключаются через объявление конфигурации.

@Configuration
open class InsertServiceConfiguration {

    @Value("\${insert.concurrency:2}")
    private var concurrency: Int = 2

    @Value("\${insert.collection:data}")
    private lateinit var collection: String

    @Autowired
    private lateinit var mongo: MongoOperations

    @Bean
    open fun insertService(): IInsertService<Entity> {
        val executor = Executors.newWorkStealingPool(concurrency)
        return EntityInsertService(
            name = "entityInsert",
            mongoOps = mongo,
            collectionName = collection,
            executor = executor
        )
    }

}

Kotlin Logo

Вот эти вот insert.concurrency — это проперти приложения. Как известно, их можно объявлять в application.properties. Но мы, конечно же, предпочитаем более сложновложенный application.yml. Из этих пропертей тоже можно конструировать бины, списки и даже мапы.

Вот вам нестандартная конфигурация с кучей Монг:

mongodb:
  connections:
    source:
      host: 172.31.22.180
      port: 27017
      database: project
      serverSelectionTimeout: 10000     # https://scalegrid.io/blog/understanding-mongodb-client-timeout-options/
      connectTimeout: 5000
      socketTimeout: 1000
    target:
      host: localhost
      port: 27017
      database: test
      serverSelectionTimeout: 10000
      connectTimeout: 5000
      socketTimeout: 1000
      writeConcern: acknowledged        # http://mongodb.github.io/mongo-java-driver/3

Можно, конечно, наваять @Configuration класс, куда внедрить все эти значения через @Value. Но мне понадобилось иметь произвольное количество таких подключений. Их все можно прочитать в мапу с помощью @ConfigurationProperties.

data class MongoConnectionProperties(
    var host: String = "localhost",
    var port: Int = 27017,
    var database: String = "test",
    var serverSelectionTimeout: Int = 0,
    var connectTimeout: Int = 5000,
    var socketTimeout: Int = 1000,
    var writeConcern: String = "acknowledged",
    var connectionsPerHost: Int = 10
) {
    val writeConcernValue: WriteConcern
        get() = WriteConcern.valueOf(writeConcern.toUpperCase())
}

data class MongoConnections(
    val connections: MutableMap<String, MongoConnectionProperties> = mutableMapOf()
) {
    operator fun get(name: String): MongoConnectionProperties
        = connections[name] ?: throw NoSuchElementException("No MongoDB connection mongodb.connections.$name")
}

@Configuration
open class MongoConnectionsConfiguration {

    @Bean
    @ConfigurationProperties(prefix="mongodb")
    open fun allMongoProperties(): MongoConnections {
        return MongoConnections()
    }

}

Эта мапа должна быть мутабельной, а объекты должны быть настоящими ява бинами, с полным набором сеттеров, чтобы Спринг смог эту мапу заполнить. Поэтому тут в котлиновых датаклассах сплошные var и дефолтные значения. Для итогового набора свойств тоже нужно придумывать свой класс, нельзя просто вернуть коллекцию, потому что списки и мапы в качестве типа бина обрабатываются Спрингом по-особому: он пытается туда впихнуть все бины из контекста, подходящие по типу, а нам это совсем не нужно.

Из бина описания можно сделать настоящий объект MongoTemplate, который уже можно использовать.

@Configuration
open class MongoConnectionsConfiguration {

    @Bean
    @Scope("prototype")
    open fun mongoOperations(name: String): MongoOperations {
        val properties = allMongoProperties()[name]
        val options = MongoClientOptions.builder()
            .writeConcern(properties.writeConcernValue)
            .serverSelectionTimeout(properties.serverSelectionTimeout)
            .connectTimeout(properties.connectTimeout)
            .socketTimeout(properties.socketTimeout)
            .connectionsPerHost(properties.connectionsPerHost)
            .build()
        val dbFactory = SimpleMongoDbFactory(
            MongoClient(ServerAddress(properties.host, properties.port), options),
            properties.database)
        return MongoTemplate(dbFactory)
    }

}

Правда здесь у нас получается бин, который прототип, да ещё принимающий строку параметром. Получить такие бины из ApplicationContext труда не составляет. А вот @Autowired или @Inject для них уже не работают.

Поэтому приходится, если нужно, все эти прототипы создавать явно, и засовывать в синглетон коллекцию. Опять своего отдельного типа. Славься Котлин дата классами.

data class MongoOperationsMap(
    val mongos: Map<String, MongoOperations>
)

@Configuration
open class MongoConnectionsConfiguration {

    @Bean
    open fun allMongoOperations() : MongoOperationsMap {
        val map: MutableMap<String, MongoOperations> = mutableMapOf()
        for ((name, _) in allMongoProperties().connections) {
            map.put(name, mongoOperations(name))
        }
        return MongoOperationsMap(map)
    }

}

Ну а дальше этот наш дата класс можно автовайрить куда угодно.

Springfield

Иногда приходится бороться со Спрингом вообще, и со Спринг Бутом в частности. И хочется выкинуть этот Спринг нафиг. Со всеми его странными и несовместимыми обёртками вокруг обычного Монго драйвера.

Но как представишь, сколько вопросов возникнет без Спринга: Где создавать компоненты? Как их связывать друг с другом? Как автоматически и многопоточно подключаться к какой-нибудь очереди? Какой шедулер взять? Какой кэш взять и как его прикрутить?

И решаешь: пусть Спринг остаётся. Всё же он берёт на себя громадную кучу инфраструктурных проблем. А иногда с ним пободаться даже полезно.