Статьи

How-To: веб-приложение Spring Boot 2 с несколькими хранилищами Mongo и Kotlin

Прежде всего, отказ от ответственности: если вы пишете микросервис (что каждый сейчас делает правильно?) И хотите, чтобы он был идиоматическим , вы обычно не используете в нем несколько различных источников данных.

Изображение взято с Pixabay © https://pixabay.com/illustrations/software-binary-system-1-0-binary-557616/

Почему? Ну, по определению, микросервисы должны быть слабо связаны, чтобы они могли быть независимыми. Наличие нескольких микросервисов, записывающих в одну и ту же базу данных, действительно нарушает этот принцип, потому что это означает, что ваши данные могут быть изменены несколькими независимыми субъектами и, возможно, разными способами , что делает действительно трудным говорить о согласованности данных, а также, вы вряд ли можете сказать, что Сервисы независимы, так как имеют по крайней мере одну общую вещь, от которой они оба зависят: общие (и, возможно, испорченные) данные. Таким образом, существует шаблон проектирования, который называется « База данных на службу» и предназначен для решения этой проблемы путем применения одной службы к базе данных . А это значит, что каждый микросервис служит посредником между клиентами и его источником данных, и данные могут быть изменены только через интерфейс, который предоставляет этот сервис .

Однако один сервис на базу данных равен одной базе данных на сервис? Нет, это не так. Если вы думаете об этом, это не совсем то же самое.

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

Кроме того, возможно, вас не волнует, что ваши микросервисы вообще идиоматичны. Это тоже вариант. (Это будет на вашей совести, хотя.)

Итак, когда у нас будет несколько баз данных, к которым мы хотим получить доступ из одного и того же сервиса? Я могу придумать разные варианты:

  • Данные слишком велики, чтобы быть в одной базе данных;
  • Вы используете базы данных в качестве пространств имен, чтобы просто отделить разные части данных, которые принадлежат разным доменам или функциональным областям;
  • Вам нужен другой доступ к базам данных — возможно, одна из них критически важна, поэтому вы ставите ее за все уровни безопасности, а другая не так важна и не нуждается в такой защите;
  • Базы данных находятся в разных регионах, потому что они написаны людьми в разных местах, но их нужно читать из центрального места (или наоборот);
  • И все остальное, действительно, только что создало эту ситуацию, и вам просто нужно с этим жить.

Если ваше приложение является приложением Spring Boot и вы используете Mongo в качестве базы данных, самый простой способ — использовать репозитории Spring Data . Вы просто устанавливаете зависимость для начальных данных mongo (в качестве примера мы будем использовать проект Gradle).

1
2
3
4
5
6
7
8
9
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-mongodb")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

На самом деле, мы создаем этот пример проекта с помощью Spring Initializer , потому что это самый простой способ начать новый пример на основе Spring. Мы только что выбрали Kotlin и Gradle в настройках генератора и добавили Spring Web Starter и Spring Data MongoDB в качестве зависимостей. Давайте назовем проект мультимонго.

Когда мы создали проект и загрузили исходники, мы видим, что Spring по умолчанию создал файл application.properties . Я предпочитаю yaml , поэтому мы просто переименуем его в application.yml и покончим с этим.

Так. Как нам настроить доступ к нашей базе данных Монго по умолчанию, используя Spring Data? Нет ничего проще. Это то, что входит в application.yml .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
# possible MongoProperties
# spring.data.mongodb.authentication-database= # Authentication database name.
# spring.data.mongodb.database= # Database name.
# spring.data.mongodb.field-naming-strategy= # Fully qualified name of the FieldNamingStrategy to use.
# spring.data.mongodb.grid-fs-database= # GridFS database name.
# spring.data.mongodb.host= # Mongo server host. Cannot be set with URI.
# spring.data.mongodb.password= # Login password of the mongo server. Cannot be set with URI.
# spring.data.mongodb.port= # Mongo server port. Cannot be set with URI.
# spring.data.mongodb.repositories.type=auto # Type of Mongo repositories to enable.
# spring.data.mongodb.uri=mongodb://localhost/test # Mongo database URI. Cannot be set with host, port and credentials.
# spring.data.mongodb.username= # Login user of the mongo server. Cannot be set with URI.
 
spring:
  data:
    mongodb:
      uri: mongodb://localhost:27017
      database: multimongo-core

Теперь давайте представим очень простой и глупый случай для нашего разделения данных. Скажем, у нас есть core база данных, в которой хранятся продукты для нашего интернет-магазина. Тогда у нас есть данные о цене продуктов; эти данные не нуждаются в каких-либо ограничениях доступа, так как любой пользователь в сети может видеть цену, поэтому мы будем называть это external . Однако у нас также есть ценовая история, которую мы используем для аналитических целей. Это информация ограниченного доступа, поэтому мы говорим: «Хорошо, она идет в отдельную базу данных, которую мы будем защищать и назовем internal .

Очевидно, что для моего случая все они все еще на локальном хосте и не защищены, но потерпите меня, это всего лишь пример.

01
02
03
04
05
06
07
08
09
10
# Predefined spring data properties don't help us anymore.
# Therefore, we're creating our own configuration for the additional mongo instances.
 
additional-db:
  internal:
    uri: mongodb://localhost:27017
    database: multimongo-internal
  external:
    uri: mongodb://localhost:27017
    database: multimongo-external

Мы также создадим три разных каталога для хранения кода, связанного с доступом к данным: data.core , data.external и data.internal .

Наш Product.kt сохраняет сущность и репозиторий для продукта, ProductPrice.kt и ProductPriceHistory.kt представляют текущие цены на продукты и исторические цены. Сущности и репозитории довольно простые.

1
2
3
4
5
6
7
8
9
@Document
data class Product(
    @Id
    val id: String? = null,
    val sku: String,
    val name: String
)
 
interface ProductRepository : MongoRepository<Product, String>
1
2
3
4
5
6
7
8
9
@Document(collection = "productPrice")
data class ProductPrice(
    @Id
    val id: String? = null,
    val sku: String,
    val price: Double
)
 
interface ProductPriceRepository : MongoRepository<ProductPrice, String>
01
02
03
04
05
06
07
08
09
10
11
12
13
14
@Document(collection = "priceHistory")
data class PriceHistory(
    @Id
    val id: String? = null,
    val sku: String,
    val prices: MutableList<PriceEntry> = mutableListOf()
)
 
data class PriceEntry(
    val price: Double,
    val expired: Date? = null
)
 
interface PriceHistoryRepository : MongoRepository<PriceHistory, String>

Теперь давайте создадим конфигурацию для нашего монго по default .

1
2
3
4
5
6
7
8
9
@Configuration
@EnableMongoRepositories(basePackages = ["com.example.multimongo.data.core"])
@Import(value = [MongoAutoConfiguration::class])
class CoreMongoConfiguration {
    @Bean
    fun mongoTemplate(mongoDbFactory: MongoDbFactory): MongoTemplate {
        return MongoTemplate(mongoDbFactory)
    }
}

Мы используем класс MongoAutoConfiguration для создания экземпляра клиента Монго по умолчанию. Однако нам все еще нужен MongoTemplate компонент MongoTemplate который мы определяем явно.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Configuration
class ExtraMongoConfiguration {
 
    val uri: String? = null
    val host: String? = null
    val port: Int? = 0
    val database: String? = null
 
    /**
     * Method that creates MongoClient
     */
    private val mongoClient: MongoClient
        get() {
            if (uri != null && !uri.isNullOrEmpty()) {
                return MongoClient(MongoClientURI(uri!!))
            }
            return MongoClient(host!!, port!!)
        }
 
 
    /**
     * Factory method to create the MongoTemplate
     */
    protected fun mongoTemplate(): MongoTemplate {
        val factory = SimpleMongoDbFactory(mongoClient, database!!)
        return MongoTemplate(factory)
    }
}

И, наконец, мы создаем две конфигурации для хранения экземпляров шаблона Монго для наших external и internal баз данных.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@EnableMongoRepositories(
    basePackages = ["com.example.multimongo.data.external"],
    mongoTemplateRef = "externalMongoTemplate")
@Configuration
class ExternalDatabaseConfiguration : ExtraMongoConfiguration() {
    @Value("\${additional-db.external.uri:}")
    override val uri: String? = null
    @Value("\${additional-db.external.host:}")
    override val host: String? = null
    @Value("\${additional-db.external.port:0}")
    override val port: Int? = 0
    @Value("\${additional-db.external.database:}")
    override val database: String? = null
 
    @Bean("externalMongoTemplate")
    fun externalMongoTemplate(): MongoTemplate = mongoTemplate()
}
 
@EnableMongoRepositories(
    basePackages = ["com.example.multimongo.data.internal"],
    mongoTemplateRef = "internalMongoTemplate")
@Configuration
class InternalDatabaseConfiguration : ExtraMongoConfiguration() {
    @Value("\${additional-db.internal.uri:}")
    override val uri: String? = null
    @Value("\${additional-db.internal.host:}")
    override val host: String? = null
    @Value("\${additional-db.internal.port:0}")
    override val port: Int? = 0
    @Value("\${additional-db.internal.database:}")
    override val database: String? = null
 
    @Bean("internalMongoTemplate")
    fun internalMongoTemplate(): MongoTemplate = mongoTemplate()
}

Итак, теперь у нас есть три шаблонных компонента mongo, которые создаются mongoTemplate() , externalMongoTemplate() и internalMongoTemplate() в трех разных конфигурациях. Эти конфигурации сканируют разные каталоги и используют эти разные bean-компоненты монго через прямую ссылку в аннотации @EnableMongoRepositories — это означает, что они используют @EnableMongoRepositories . У весны с этим нет проблем; зависимости будут разрешены в правильном порядке.

Итак, как мы можем проверить, что все работает? Осталось сделать еще один шаг: нам нужно инициализировать некоторые данные, а затем получить их из базы данных.

Так как это всего лишь пример, мы создадим некоторые очень простые данные прямо при запуске приложения, просто чтобы убедиться, что оно есть. Мы будем использовать ApplicationListener для этого.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Component
class DataInitializer(
    val productRepo: ProductRepository,
    val priceRepo: ProductPriceRepository,
    val priceHistoryRepo: PriceHistoryRepository
) : ApplicationListener<ContextStartedEvent> {
 
    override fun onApplicationEvent(event: ContextStartedEvent) {
        // clean up
        productRepo.deleteAll()
        priceRepo.deleteAll()
        priceHistoryRepo.deleteAll()
 
        val p1 = productRepo.save(Product(sku = "123", name = "Toy Horse"))
        val p2 = productRepo.save(Product(sku = "456", name = "Real Horse"))
 
        val h1 = PriceHistory(sku = p1.sku)
        val h2 = PriceHistory(sku = p2.sku)
 
        for (i in 5 downTo 1) {
            if (i == 5) {
                // current price
                priceRepo.save(ProductPrice(sku = p1.sku, price = i.toDouble()))
                priceRepo.save(ProductPrice(sku = p2.sku, price = (i * 2).toDouble()))
 
                // current price history
                h1.prices.add(PriceEntry(price = i.toDouble()))
                h2.prices.add(PriceEntry(price = (i * 2).toDouble()))
            } else {
                // previous price
                val expiredDate = Date(ZonedDateTime.now()
                    .minusMonths(i.toLong())
                    .toInstant()
                    .toEpochMilli())
                h1.prices.add(PriceEntry(price = i.toDouble(), expired = expiredDate))
                h2.prices.add(PriceEntry(price = (i * 2).toDouble(), expired = expiredDate))
            }
        }
        priceHistoryRepo.saveAll(listOf(h1, h2))
    }
}

Как мы тогда проверяем, что данные были сохранены в базе данных? Поскольку это веб-приложение, мы представим данные в контроллере REST.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
@RestController
@RequestMapping("/api")
class ProductResource(
    val productRepo: ProductRepository,
    val priceRepo: ProductPriceRepository,
    val priceHistoryRepo: PriceHistoryRepository
) {
    @GetMapping("/product")
    fun getProducts(): List<Product> = productRepo.findAll()
 
    @GetMapping("/price")
    fun getPrices(): List<ProductPrice> = priceRepo.findAll()
 
    @GetMapping("/priceHistory")
    fun getPricesHistory(): List<PriceHistory> = priceHistoryRepo.findAll()
}

Контроллер REST просто использует наши репозитории для вызова метода findAll() . Мы ничего не делаем с преобразованиями данных, мы не разбираем страницы и не сортируем их, мы просто хотим увидеть, что что-то есть. Наконец, можно запустить приложение и посмотреть, что произойдет.

01
02
03
04
05
06
07
08
09
10
11
12
[
    {
        "id": "5d5e64d80a986d381a8af4ce",
        "name": "Toy Horse",
        "sku": "123"
    },
    {
        "id": "5d5e64d80a986d381a8af4cf",
        "name": "Real Horse",
        "sku": "456"
    }
]

Да, есть два продукта, которые мы создали! Мы видим, что Mongo присваивает им автоматически сгенерированные идентификаторы при сохранении — мы только определили имена и фиктивные коды SKU.

Мы также можем проверить данные по адресам http: // localhost: 8080 / api / price и http: // localhost: 8080 / api / priceHistory и убедиться, что да, на самом деле эти сущности действительно были созданы. Я не буду вставлять этот JSON сюда, так как он не очень актуален.

Однако как мы можем быть уверены, что данные действительно были сохранены (и прочитаны) из разных баз данных? Для этого мы можем просто использовать любое клиентское приложение mongo, которое позволяет нам подключаться к локальному экземпляру mongo (я использую официальный инструмент от mongo — MongoDB Compass ).

Давайте проверим содержание в базе данных, которая содержит наши текущие цены.

Мы также можем использовать интеграционный тест для проверки данных вместо того, чтобы делать это вручную, если мы хотим сделать все правильно (на самом деле не все — нам нужно использовать встроенную базу данных Mongo для тестов, но мы пропустим эту часть здесь чтобы не сделать учебник слишком сложным). Для этой цели мы будем использовать MockMvc из библиотеки Spring spring-test .

<

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@RunWith(SpringRunner::class)
@SpringBootTest
class MultimongoApplicationTests {
    @Autowired
    private val productRepo: ProductRepository? = null
    @Autowired
    private val priceRepo: ProductPriceRepository? = null
    @Autowired
    private val priceHistoryRepo: PriceHistoryRepository? = null
 
    @Autowired
    private val initializer: DataInitializer? = null
    @Autowired
    private val context: ApplicationContext? = null
 
    private var mvc: MockMvc? = null
 
    @Before
    fun setUp() {
        val resource = ProductResource(
            productRepo!!,
            priceRepo!!,
            priceHistoryRepo!!
        )
        this.mvc = MockMvcBuilders
            .standaloneSetup(resource)
            .build()
        initializer!!.onApplicationEvent(ContextStartedEvent(context!!))
    }
 
    @Test
    fun productsCreated() {
        mvc!!.perform(get(“/api/product”))
            .andExpect(status().isOk)
            .andDo {
                println(it.response.contentAsString)
            }
            .andExpect(jsonPath(“$.[*].sku”).isArray)
            .andExpect(jsonPath(“$.[*].sku”)
                .value(hasItems(“123”, “456”)))
    }
 
    @Test
    fun pricesCreated() {
        mvc!!.perform(get(“/api/price”))
            .andExpect(status().isOk)
            .andDo {
                println(it.response.contentAsString)
            }
            .andExpect(jsonPath(“$.[*].sku”).isArray)
            .andExpect(jsonPath(“$.[*].sku”)
                .value(hasItems(“123”, “456”)))
            .andExpect(jsonPath(“$.[0].price”)
                .value(5.0))
            .andExpect(jsonPath(“$.[1].price”)
                .value(10.0))
    }
 
    @Test
    fun pricesHistoryCreated() {
        mvc!!.perform(get(“/api/priceHistory”))
            .andExpect(status().isOk)
            .andDo {
                println(it.response.contentAsString)
            }
            .andExpect(jsonPath(“$.[*].sku”).isArray)
            .andExpect(jsonPath(“$.[*].sku”)
                .value(hasItems(“123”, “456”)))
            .andExpect(jsonPath(“$.[0].prices.[*].price”)
                .value(hasItems(5.0, 4.0, 3.0, 2.0, 1.0)))
            .andExpect(jsonPath(“$.[1].prices.[*].price”)
                .value(hasItems(10.0, 8.0, 6.0, 4.0, 2.0)))
    }
}

Вы можете найти полный рабочий пример здесь, в моем репозитории github. Надеюсь, это помогло вам решить проблему использования нескольких экземпляров Монго в одном веб-приложении Spring Boot! Это не такая сложная проблема, но тоже не совсем тривиальная.

Когда я просматривал другие примеры в Интернете, я также прочитал эту статью (под названием « Азбука боголюбова», «Конфигурация данных Spring: множественные базы данных Mongo »), и она была довольно хорошей и полной. Тем не менее, он не совсем подходил моему случаю, потому что он полностью перекрывал автоматическую конфигурацию монго. Я, с другой стороны, хотел сохранить его для моей базы данных по умолчанию, но не для других. Но подход в этой статье основан на том же принципе использования разных шаблонов монго для сканирования разных репозиториев .

Просто в конфигурации по умолчанию вы можете легко избавиться от лишних классов, когда что-то, например, изменится, и все ваши данные снова попадут в одну и ту же базу данных.

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

Эта статья также опубликована на Medium здесь .

Опубликовано на Java Code Geeks с разрешения Марины Чернявской, партнера нашей программы JCG . См. Оригинальную статью здесь: How-To: веб-приложение Spring Boot 2 с несколькими хранилищами Mongo и Kotlin.

Мнения, высказанные участниками Java Code Geeks, являются их собственными.