Тип технологии | Название | Версия |
---|---|---|
Платформа | 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 |
application.yml
) выглядит так:spring:
profiles:
active: native
cloud:
config:
server:
native:
search-locations: classpath:/config
server:
port: 8888
bootstrap.yml
. При старте они загружают свой конфиг посредством выполнения GET-запроса к HTTP API Config server’а.@SpringBootApplication
@EnableConfigServer
class ConfigServerApplication
fun main(args: Array<String>) {
runApplication<ConfigServerApplication>(*args)
}
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
register-with-eureka
(указано для наглядности, т. к. оно же используется по умолчанию) говорит о том, что само приложение, как и другие микросервисы, будет зарегистрировано в Eureka server. Параметр fetch-registry
определяет, будет ли Eureka client получать данные из Service registry.http://localhost:8761/
:@Bean
fun itemsRouter(handler: ItemHandler) = router {
path("/items").nest {
GET("/", handler::getAll)
POST("/", handler::add)
GET("/{id}", handler::getOne)
PUT("/{id}", handler::update)
}
}
fun getAll(request: ServerRequest) = ServerResponse.ok()
.contentType(APPLICATION_JSON_UTF8)
.body(fromObject(itemRepository.findAll()))
spring-cloud-starter-netflix-eureka-client
. После регистрации приложение с определённой периодичностью посылает в Eureka server хартбиты, и в случае, если за некоторый период времени процент принятых Eureka server’ом хартбитов относительно максимально возможного значения окажется ниже некоторого порога, приложение будет удалено из Service registry.@PostConstruct
private fun addMetadata() = aim.registerAppMetadata(mapOf("description" to "Some description"))
localhost:8761/eureka/apps/items-service
через Postman:@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\"}"
}
}
}
}
RestTemplate
@Bean
@LoadBalanced
fun restTemplate() = RestTemplate()
fun requestWithRestTemplate(id: Long): String =
restTemplate.getForEntity("http://items-service/items/$id", String::class.java).body ?: "No result"
WebClient
(способ специфичен для фреймворка WebFlux)@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
:itemsServiceFeignClient.getItem(1)
FallbackFactory
, в котором нужно обработать ошибку и вернуть ответ по умолчанию (или пробросить исключение дальше). В случае, если некоторое число последовательных вызовов завершатся ошибкой, Предохранитель разомкнёт цепь (подробнее о Circuit breaker здесь и здесь), давая время на восстановление упавшему микросервису.@EnableFeignClients
:@SpringBootApplication
@EnableFeignClients(clients = [ItemsServiceFeignClient::class])
class ItemsUiApplication
feign:
hystrix:
enabled: true
http://localhost:8081/hystrix-fallback
. Feign-клиент попытается выполнить запрос по несуществующему в Items service пути, что приведёт к возвращению респонса:{"error" : "Some error"}
server:
port: 443
ssl:
key-store: classpath:keystore.p12
key-store-password: qwerty
key-alias: test_key
key-store-type: PKCS12
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()
.anyExchange()
) — только аутентифицированным. При попытке входа на URL, требующий аутентификации, будет выполнено перенаправление на страницу логина (https://localhost/login
):@Bean
fun routes() = router {
GET("/login") { ServerResponse.ok().contentType(MediaType.TEXT_HTML).render("login") }
}
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).@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")
}
}
http://localhost:8761
), второй нужен для загрузки ресурсов этой страницы.https://localhost/actuator/gateway/routes
.@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())
}
}
https://localhost/items-ui/greeting
, справедливо предполагая, что в Items UI обработка этих заголовков уже реализована:spring-cloud-starter-sleuth
.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"
gradlew clean build
или ./gradlew clean build
.К сожалению, не доступен сервер mySQL