Микросервисная архитектура на современном стеке Java-технологий +7



У нас были JDK 11, Kotlin, Spring 5 и Spring Boot 2, Gradle 5 с production-ready Kotlin DSL, JUnit 5, а ещё с полдюжины библиотек стека Spring Cloud для Service discovery, создания API gateway, клиентской балансировки, имплементации паттерна Circuit breaker, написания декларативных HTTP клиентов, распределённой трассировки и всего такого. Не то чтобы всё это было нужно для создания микросервисной архитектуры — only just for fun...

Вступление


В этой статье вы увидите пример микросервисной архитектуры на актуальных в Java-мире технологиях, основные из которых приведены ниже (указанные версии используются в проекте в момент публикации):
Тип технологии Название Версия
Платформа JDK 11.0.1
Язык программирования Kotlin 1.3.10
Фреймворк приложения Spring 5.0.9
Spring Boot 2.0.5
Система сборки Gradle 5.0
Gradle Kotlin DSL 1.0.4
Фреймворк для unit-тестирования JUnit 5.1.1
Spring Cloud
Единая точка доступа (API gateway) Spring Cloud Gateway Входит в Release train Finchley SR2 проекта Spring Cloud
Централизованное конфигурирование (Centralized configuration) Spring Cloud Config
Трассировка запросов (Distributed tracing) Spring Cloud Sleuth
Декларативный HTTP клиент (Declarative HTTP client) Spring Cloud OpenFeign
Обнаружение сервисов (Service discovery) Spring Cloud Netflix Eureka
Предохранитель (Circuit breaker) Spring Cloud Netflix Hystrix
Клиентская балансировка нагрузки (Client-side load balancing) Spring Cloud Netflix Ribbon

Проект состоит из 5-и микросервисов: 3-х инфраструктурных (Config server, Service discovery server, UI gateway) и примеров front-end’а (Items UI) и back-end’а (Items service):


Все они будут последовательно рассмотрены далее. В «боевом» проекте, очевидно, будет значительно больше микросервисов, реализующих какую-либо бизнес-функциональность. Их добавление в подобную архитектуру технически выполняется аналогично Items UI и Items service.

Disclaimer


В статье не рассматриваются инструменты для контейнеризации и оркестрации, т. к. в настоящее время они не используются в проекте.

Config server


Для создания централизованного хранилища конфигураций приложений был использован Spring Cloud Config. Конфиги могут быть прочитаны из различных источников, например, отдельного git-репозитория; в этом проекте для простоты и наглядности они находятся в ресурсах приложения:


При этом конфиг самого Config server (application.yml) выглядит так:

spring:
 profiles:
   active: native
 cloud:
   config:
     server:
       native:
         search-locations: classpath:/config

server:
 port: 8888

Использование порта 8888 позволяет клиентам Config server’а не указывать явно его порт в своих bootstrap.yml. При старте они загружают свой конфиг посредством выполнения GET-запроса к HTTP API Config server’а.

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

@SpringBootApplication
@EnableConfigServer
class ConfigServerApplication

fun main(args: Array<String>) {
   runApplication<ConfigServerApplication>(*args)
}

Классы приложения и main-методы в остальных микросервисах имеют аналогичный вид.

Service discovery server


Service discovery — это паттерн микросервисной архитектуры, позволяющий упростить взаимодействие между приложениями в условиях возможного изменения числа их инстансов и сетевого расположения. Ключевым компонентом при таком подходе является Service registry — база данных микросервисов, их инстансов и сетевых расположений (подробнее здесь).

В этом проекте Service discovery реализован на основе Netflix Eureka, представляющего собой Client-side service discovery: Eureka server выполняет функцию Service registry, а Eureka client перед выполнением запроса к какому-либо микросервису обращается к Eureka server за списком инстансов вызываемого приложения и самостоятельно осуществляет балансировку нагрузки (используя Netflix Ribbon). Netflix Eureka, как и некоторые другие компоненты стека Netflix OSS (например, Hystrix и Ribbon) интегрируется с Spring Boot приложениями с помощью Spring Cloud Netflix.

В конфиге Service discovery server, находящемся в его ресурсах (bootstrap.yml), указывается только название приложения и параметр, определяющий, что запуск микросервиса будет прерван, если невозможно подключиться к Config server:

spring:
 application:
   name: eureka-server
 cloud:
   config:
     fail-fast: true

Оставшаяся часть конфига приложения располагается в файле eureka-server.yml в ресурсах Config server:

server:
 port: 8761

eureka:
 client:
   register-with-eureka: true
   fetch-registry: false

Eureka server использует порт 8761, что позволяет всем Eureka client’ам не указывать его, используя значение по умолчанию. Значение параметра register-with-eureka (указано для наглядности, т. к. оно же используется по умолчанию) говорит о том, что само приложение, как и другие микросервисы, будет зарегистрировано в Eureka server. Параметр fetch-registry определяет, будет ли Eureka client получать данные из Service registry.

Список зарегистрированных приложений и другая информация доступны по http://localhost:8761/:


Альтернативными вариантами для реализации Service discovery являются Consul, Zookeeper и другие.

Items service


Это приложение представляет собой пример back-end с REST API, реализованным с использованием появившегося в Spring 5 фреймворка WebFlux (документация здесь), а точнее Kotlin DSL для него:

@Bean
fun itemsRouter(handler: ItemHandler) = router {
   path("/items").nest {
       GET("/", handler::getAll)
       POST("/", handler::add)
       GET("/{id}", handler::getOne)
       PUT("/{id}", handler::update)
   }
}

Обработка принятых HTTP запросов делегируется бину класса ItemHandler. Например, метод для получения списка объектов некоторой сущности выглядит так:

fun getAll(request: ServerRequest) = ServerResponse.ok()
       .contentType(APPLICATION_JSON_UTF8)
       .body(fromObject(itemRepository.findAll()))

Приложение становится клиентом Eureka server, т. е. регистрируется и получает данные из Service registry, за счёт наличия зависимости spring-cloud-starter-netflix-eureka-client. После регистрации приложение с определённой периодичностью посылает в Eureka server хартбиты, и в случае, если за некоторый период времени процент принятых Eureka server’ом хартбитов относительно максимально возможного значения окажется ниже некоторого порога, приложение будет удалено из Service registry.

Рассмотрим один из способов отправки дополнительных метаданных на Eureka server:

@PostConstruct
private fun addMetadata() = aim.registerAppMetadata(mapOf("description" to "Some description"))

Убедимся в получении Eureka server этих данных, зайдя на localhost:8761/eureka/apps/items-service через Postman:



Items UI


Этот микросервис, помимо того, что демонстрирует взаимодействие с UI gateway (будет показано в следующем разделе), выполняет функцию front-end для Items service, с REST API которого может взаимодействовать несколькими способами:

  1. Клиент к REST API, написанный с помощью OpenFeign:

    @FeignClient("items-service", fallbackFactory = ItemsServiceFeignClient.ItemsServiceFeignClientFallbackFactory::class)
    interface ItemsServiceFeignClient {
    
       @GetMapping("/items/{id}")
       fun getItem(@PathVariable("id") id: Long): String
    
       @GetMapping("/not-existing-path")
       fun testHystrixFallback(): String
    
       @Component
       class ItemsServiceFeignClientFallbackFactory : FallbackFactory<ItemsServiceFeignClient> {
    
           private val log = LoggerFactory.getLogger(this::class.java)
    
           override fun create(cause: Throwable) = object : ItemsServiceFeignClient {
    
               override fun getItem(id: Long): String {
                   log.error("Cannot get item with id=$id")
                   throw ItemsUiException(cause)
               }
    
               override fun testHystrixFallback(): String {
                   log.error("This is expected error")
                   return "{\"error\" : \"Some error\"}"
               }
           }
       }
    }
  2. Бин класса RestTemplate
    В java-конфиге создаётся бин:

    @Bean
    @LoadBalanced
    fun restTemplate() = RestTemplate()

    И используется таким образом:

    fun requestWithRestTemplate(id: Long): String =
           restTemplate.getForEntity("http://items-service/items/$id", String::class.java).body ?: "No result"
  3. Бин класса WebClient (способ специфичен для фреймворка WebFlux)
    В java-конфиге создаётся бин:

    @Bean
    fun webClient(loadBalancerClient: LoadBalancerClient) = WebClient.builder()
           .filter(LoadBalancerExchangeFilterFunction(loadBalancerClient))
           .build()

    И используется таким образом:

    fun requestWithWebClient(id: Long): Mono<String> =
           webClient.get().uri("http://items-service/items/$id").retrieve().bodyToMono(String::class.java)

В том, что все три способа возвращают одинаковый результат, можно убедиться, зайдя на http://localhost:8081/example:


Я предпочитаю вариант с использованием OpenFeign, т. к. он даёт возможность разработать контракт на взаимодействие с вызываемым микросервисом, обязанности по имплементации которого берёт на себя Spring. Объект, реализующий этот контракт, инжектируется и используется, как обычный бин:

itemsServiceFeignClient.getItem(1)

Если запрос по каким-либо причинам завершится ошибкой, то будет вызван соответствующий метод класса, реализующего интерфейс FallbackFactory, в котором нужно обработать ошибку и вернуть ответ по умолчанию (или пробросить исключение дальше). В случае, если некоторое число последовательных вызовов завершатся ошибкой, Предохранитель разомкнёт цепь (подробнее о Circuit breaker здесь и здесь), давая время на восстановление упавшему микросервису.

Для работы Feign-клиента требуется аннотировать класс приложения @EnableFeignClients:

@SpringBootApplication
@EnableFeignClients(clients = [ItemsServiceFeignClient::class])
class ItemsUiApplication

Для работы Hystrix fallback в Feign-клиенте в конфиг приложения нужно внести:

feign:
 hystrix:
   enabled: true

Чтобы протестировать работу Hystrix fallback в Feign-клиенте, достаточно зайти на http://localhost:8081/hystrix-fallback. Feign-клиент попытается выполнить запрос по несуществующему в Items service пути, что приведёт к возвращению респонса:

{"error" : "Some error"}

UI gateway


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

В этом проекте для большей наглядности реализован UI gateway, то есть единая точка входа для различных UI; очевидно, что API gateway реализуется аналогично. Микросервис реализован на основе фреймворка Spring Cloud Gateway. Альтернативным вариантом является Netflix Zuul, входящий в Netflix OSS и интегрированный с Spring Boot с помощью Spring Cloud Netflix.
UI gateway работает на 443 порту, используя сгенерированный SSL-сертификат (находится в проекте). SSL и HTTPS сконфигурированы следующим образом:

server:
 port: 443
 ssl:
   key-store: classpath:keystore.p12
   key-store-password: qwerty
   key-alias: test_key
   key-store-type: PKCS12

Логины и пароли пользователей хранятся в Map-based имплементации специфичного для WebFlux интерфейса ReactiveUserDetailsService:

@Bean
fun reactiveUserDetailsService(): ReactiveUserDetailsService {
   val user = User.withDefaultPasswordEncoder()
           .username("john_doe").password("qwerty").roles("USER")
           .build()
   val admin = User.withDefaultPasswordEncoder()
           .username("admin").password("admin").roles("ADMIN")
           .build()
   return MapReactiveUserDetailsService(user, admin)
}

Параметры безопасности настроены таким образом:

@Bean
fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain = http
       .formLogin().loginPage("/login")
       .and()
       .authorizeExchange()
       .pathMatchers("/login").permitAll()
       .pathMatchers("/static/**").permitAll()
       .pathMatchers("/favicon.ico").permitAll()
       .pathMatchers("/webjars/**").permitAll()
       .pathMatchers("/actuator/**").permitAll()
       .anyExchange().authenticated()
       .and()
       .csrf().disable()
       .build()

Приведённый конфиг определяет, что часть web-ресурсов (например, статика) доступна всем пользователям, включая не прошедших аутентификацию, а всё остальное (.anyExchange()) — только аутентифицированным. При попытке входа на URL, требующий аутентификации, будет выполнено перенаправление на страницу логина (https://localhost/login):


Эта страница использует средства фреймворка Bootstrap, подключаемого к проекту с помощью Webjars, который даёт возможность управлять client-side библиотеками как обычными зависимостями. Для формирования HTML-страниц используется Thymeleaf. Доступ к странице логина конфигурируется с помощью WebFlux:

@Bean
fun routes() = router {
   GET("/login") { ServerResponse.ok().contentType(MediaType.TEXT_HTML).render("login") }
}

Маршрутизация средствами Spring Cloud Gateway может быть настроена в YAML- или java-конфиге. Роуты к микросервисам либо прописываются вручную, либо создаются автоматически на основе данных, полученных из Service registry. При достаточно большом количестве UI, к которым требуется осуществлять маршрутизацию, удобнее будет воспользоваться интеграцией с Service registry:

spring:
 cloud:
   gateway:
     discovery:
       locator:
         enabled: true
         lower-case-service-id: true
         include-expression: serviceId.endsWith('-UI')
         url-expression: "'lb:http://'+serviceId"

Значение параметра include-expression указывает, что роуты будут созданы только для микросервисов, названия которых оканчиваются на -UI, а значение параметра url-expression — что они доступны по HTTP протоколу, в отличие от самого UI gateway, работающего по HTTPS, и при обращении к ним будет использоваться клиентская балансировка нагрузки (реализуемая с помощью Netflix Ribbon).

Рассмотрим пример создания роутов в java-конфиге вручную (без интеграции с Service registry):

@Bean
fun routeLocator(builder: RouteLocatorBuilder) = builder.routes {
   route("eureka-gui") {
       path("/eureka")
       filters {
           rewritePath("/eureka", "/")
       }
       uri("lb:http://eureka-server")
   }
   route("eureka-internals") {
       path("/eureka/**")
       uri("lb:http://eureka-server")
   }
}

Первый роут осуществляет маршрутизацию на ранее показанную домашнюю страницу Eureka server (http://localhost:8761), второй нужен для загрузки ресурсов этой страницы.

Все созданные приложением роуты доступны по https://localhost/actuator/gateway/routes.

В нижележащих микросервисах может возникнуть необходимость получить доступ к логину и/или ролям пользователя, прошедшего аутентификацию в UI gateway. Для этого я создал фильтр, добавляющий в запрос соответствующие заголовки:

@Component
class AddCredentialsGlobalFilter : GlobalFilter {

   private val loggedInUserHeader = "logged-in-user"
   private val loggedInUserRolesHeader = "logged-in-user-roles"

   override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain) = exchange.getPrincipal<Principal>()
           .flatMap {
               val request = exchange.request.mutate()
                       .header(loggedInUserHeader, it.name)
                       .header(loggedInUserRolesHeader, (it as Authentication).authorities?.joinToString(";") ?: "")
                       .build()
               chain.filter(exchange.mutate().request(request).build())
           }
}

Теперь обратимся к Items UI, используя UI gateway — https://localhost/items-ui/greeting, справедливо предполагая, что в Items UI обработка этих заголовков уже реализована:


Spring Cloud Sleuth — это решение для трассировки запросов в распределённой системе. В заголовки запроса, проходящего через несколько микросервисов, добавляются Trace Id (сквозной идентификатор) и Span Id (идентификатор unit of work) (для более лёгкого восприятия я упростил схему; здесь более детальное объяснение):


Этот функционал подключается простым добавлением зависимости spring-cloud-starter-sleuth.

Указав соответствующие настройки логирования, в консоли соответствующих микросервисов можно будет увидеть примерно следующее (после названия микросервиса выводятся Trace Id и Span Id):

DEBUG [ui-gateway,009b085bfab5d0f2,009b085bfab5d0f2,false] o.s.c.g.h.RoutePredicateHandlerMapping   : Route matched: CompositeDiscoveryClient_ITEMS-UI
DEBUG [items-ui,009b085bfab5d0f2,947bff0ce8d184f4,false] o.s.w.r.function.server.RouterFunctions  : Predicate "(GET && /example)" matches against "GET /example"
DEBUG [items-service,009b085bfab5d0f2,dd3fa674cd994b01,false] o.s.w.r.function.server.RouterFunctions  : Predicate "(GET && /{id})" matches against "GET /1"

Для графического представления распределённой трассировки можно воспользоваться, например, Zipkin, который будет выполнять функцию сервера, агрегирующего информацию о HTTP-запросах из других микросервисов (подробнее здесь).

Сборка


В зависимости от ОС выполняется gradlew clean build или ./gradlew clean build.

Учитывая возможность использования Gradle wrapper, нет необходимости в наличии установленного локально Gradle.

Сборка и последующий запуск успешно проходят на JDK 11.0.1. До этого проект работал на JDK 10, поэтому допускаю, что на этой версии проблем со сборкой и запуском не возникнет. По поводу более ранних версий JDK данных у меня нет. Кроме того, нужно учитывать, что используемый Gradle 5 требует как минимум JDK 8.

Запуск


Рекомендую стартовать приложения в порядке их описания в этой статье. Если вы используете Intellij IDEA с включённым Run Dashboard, то должно получиться примерно следующее:


Заключение


В статье был рассмотрен пример микросервисной архитектуры на актуальном в Java-мире стеке технологий, её основные компоненты и некоторые фичи. Надеюсь, для кого-то материал окажется полезным. Благодарю за внимание!

Ссылки





К сожалению, не доступен сервер mySQL