Starter to work with Spring Cloud
- Tutorial
Hello!
In this article, I will demonstrate the basic components for creating Reactive RESTful mixservices using Spring WebFlux, Spring Security, Spring Cloud Netflix Eureka (Service Discovery), Hystrix (Circuit Breaker), Ribbon (Client Side Load Balancer), External Configuration (via git repository) , Spring Cloud Sleuth, Spring Cloud Gateway, Spring Boot Reactive MongoDB. As well as Spring Boot Admin and Zipkin for monitoring.
This review was made after studying the books of Spring Microservices in Action and the Hands-On Spring 5 Security for Reactive Applications.
In this article, we will create an elementary application with three queries: get a list of games, get a list of players, create a game from player id, a request to check for rollback (Hystrix fallback) in case of a long wait for a response. And the implementation of authentication via JWT token based on the book Hands-On Spring 5 Security for Reactive Applications.
I will not describe how each application is created in the IDE, since this article is intended for an experienced user.
Project structure
The project consists of two modules. The module spring-servers
can be easily copied from project to project. There are almost no code and configurations. The module tictactoe-services
contains modules and microservices of our application. Immediately, I’ll note that by adding modules to services auth-module
and domain-module
I’m breaking one of the principles of the microservice architecture about microservice autonomy. But at the stage of development of these modules, I believe that this is the most optimal solution.
Gradle configuration
I like it when the entire Gradle configuration is in one file, so I configured the entire project in one build.gradle
.
buildscript {
ext {
springBootVersion = '2.1.1.RELEASE'
gradleDockerVersion = '0.20.1'
}
repositories {
mavenCentral()
maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
classpath("gradle.plugin.com.palantir.gradle.docker:gradle-docker:${gradleDockerVersion}")
}
}
allprojects {
group = 'com.tictactoe'
apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'
apply plugin: 'com.palantir.docker'
apply plugin: 'com.palantir.docker-run'
apply plugin: 'com.palantir.docker-compose'
}
docker.name = 'com.tictactoe'
bootJar.enabled = false
sourceCompatibility = 11
repositories {
mavenCentral()
maven { url "https://repo.spring.io/milestone" }
}
subprojects {
ext['springCloudVersion'] = 'Greenwich.M3'
sourceSets.configureEach { sourceSet ->
tasks.named(sourceSet.compileJavaTaskName, {
options.annotationProcessorGeneratedSourcesDirectory = file("$buildDir/generated/sources/annotationProcessor/java/${sourceSet.name}")
})
}
repositories {
mavenCentral()
maven { url "https://repo.spring.io/milestone" }
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
compile fileTree(include: ['*.jar'], dir: 'libs')compileOnly('org.projectlombok:lombok')annotationProcessor('org.projectlombok:lombok')
}
}
project(':spring-servers'){
bootJar.enabled = false
task cleanAll {
dependsOn subprojects*.tasks*.findByName('clean')
}
task buildAll {
dependsOn subprojects*.tasks*.findByName('build')
}
dockerCompose {
template 'docker-compose.spring-servers.template.yml'
dockerComposeFile 'docker-compose.spring-servers.yml'
}
}
project(':tictactoe-services') {
bootJar.enabled = false
task cleanAll {
dependsOn subprojects*.tasks*.findByName('clean')
}
task buildAll {
dependsOn subprojects*.tasks*.findByName('build')
}
}
// Tictactoe Modules
project(':tictactoe-services:domain-module') {
bootJar.enabled = false
jar {
enabled = true
group 'com.tictactoe'
baseName = 'domain-module'
version = '1.0'
}
dependencies {
implementation('org.springframework.boot:spring-boot-starter-security')
implementation('org.springframework.boot:spring-boot-starter-data-mongodb-reactive')
implementation('org.springframework.boot:spring-boot-starter-validation')
implementation('com.fasterxml.jackson.core:jackson-annotations:2.9.3')
implementation 'com.intellij:annotations:+@jar'
compileOnly('org.projectlombok:lombok')
testCompile group: 'junit', name: 'junit', version: '4.12'
}
}
project(':tictactoe-services:auth-module') {
bootJar.enabled = false
jar {
enabled = true
baseName = 'auth-module'
version = '1.0'
}
dependencies {
implementation project(':tictactoe-services:domain-module')implementation('org.springframework.boot:spring-boot-starter-webflux')implementation('org.springframework.boot:spring-boot-starter-data-mongodb-reactive')implementation('org.springframework.boot:spring-boot-starter-security')implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon')implementation('org.springframework.security:spring-security-oauth2-core')implementation('org.springframework.security:spring-security-oauth2-jose')
implementation 'com.intellij:annotations:+@jar'
testImplementation('org.springframework.boot:spring-boot-starter-test')testImplementation('io.projectreactor:reactor-test')testImplementation('org.springframework.security:spring-security-test')
}
}
project(':tictactoe-services:user-service'){
bootJar {
launchScript()
baseName = 'user-service'
version = '0.1.0'
}
dependencies {
implementation project(':tictactoe-services:domain-module')
implementation project(':tictactoe-services:auth-module')
}
}
project(':tictactoe-services:game-service'){
bootJar {
launchScript()
baseName = 'game-service'
version = '0.1.0'
}
dependencies {
implementation project(':tictactoe-services:domain-module')
implementation project(':tictactoe-services:auth-module')
}
}
project(':tictactoe-services:webapi-service'){
bootJar {
launchScript()
baseName = 'webapi-service'
version = '0.1.0'
}
dependencies {
implementation project(':tictactoe-services:domain-module')
implementation project(':tictactoe-services:auth-module')
}
}
// Spring Serversproject(':spring-servers:discovery-server'){
bootJar {
launchScript()
baseName = 'discovery-server'
version = '0.1.0'
}
dependencies {
implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-server')
implementation('org.springframework.boot:spring-boot-starter-security')
compile('javax.xml.bind:jaxb-api:2.3.0')
compile('javax.activation:activation:1.1')
compile('org.glassfish.jaxb:jaxb-runtime:2.3.0')
testImplementation('org.springframework.boot:spring-boot-starter-test')
}
}
project(':spring-servers:config-server') {
bootJar {
launchScript()
baseName = 'config-server'
version = '0.1.0'
}
dependencies {
implementation('org.springframework.boot:spring-boot-starter-security')
implementation('org.springframework.cloud:spring-cloud-config-server')
implementation('org.springframework.cloud:spring-cloud-starter-config')
implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client')
testImplementation('org.springframework.boot:spring-boot-starter-test')
}
}
project(':spring-servers:gateway-server') {
bootJar {
launchScript()
baseName = 'gateway-server'
version = '0.1.0'
}
dependencies {
implementation('org.springframework.boot:spring-boot-starter-webflux')
implementation('org.springframework.boot:spring-boot-starter-actuator')
implementation('org.springframework.cloud:spring-cloud-starter-gateway')
implementation('org.springframework.cloud:spring-cloud-starter-config')
implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon')
implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client')
testImplementation('org.springframework.boot:spring-boot-starter-test')
}
}
project(':spring-servers:admin-server') {
ext['springBootAdminVersion'] = '2.1.1'
bootJar {
launchScript()
baseName = 'admin-server'
version = '0.1.0'
}
dependencies {
implementation('org.springframework.boot:spring-boot-starter-web')
implementation('org.springframework.boot:spring-boot-starter-security')
implementation('de.codecentric:spring-boot-admin-starter-server')
implementation('org.springframework.cloud:spring-cloud-starter-config')
implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client')
testImplementation('org.springframework.boot:spring-boot-starter-test')
testImplementation('org.springframework.security:spring-security-test')
}
dependencyManagement {
imports {
mavenBom "de.codecentric:spring-boot-admin-dependencies:${springBootAdminVersion}"
}
}
}
subprojects { subproject ->
if (file("${subproject.projectDir}/docker/Dockerfile").exists()) {
docker {
// workingbit - replace with your dockerhub's username
name "workingbit/${subproject.group}.${subproject.bootJar.baseName}"
tags 'latest'dockerfile file("${subproject.projectDir}/docker/Dockerfile")
files tasks.bootJar.archivePath, 'docker/run.sh'
buildArgs "JAR_FILE": "${subproject.bootJar.baseName}-${subproject.bootJar.version}.jar",
"RUN_SH": "run.sh"
}
} else {
docker.name = 'noop'
}
if (subproject.name.endsWith('service')) {
dependencies {
implementation('org.springframework.boot:spring-boot-starter-actuator')
implementation('org.springframework.boot:spring-boot-starter-webflux')
implementation('org.springframework.boot:spring-boot-starter-data-mongodb-reactive')
implementation('org.springframework.boot:spring-boot-starter-security')
implementation('org.springframework.security:spring-security-oauth2-core')
implementation('org.springframework.security:spring-security-oauth2-jose')
implementation('org.springframework.cloud:spring-cloud-starter-config')
implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client')
implementation('org.springframework.cloud:spring-cloud-starter-netflix-hystrix')
implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon')
implementation('org.springframework.cloud:spring-cloud-starter-sleuth')
implementation('org.springframework.cloud:spring-cloud-starter-zipkin')
implementation('org.springframework.security:spring-security-rsa')
implementation('com.intellij:annotations:+@jar')
implementation('org.apache.commons:commons-lang3:3.8.1')
runtimeOnly('org.springframework.boot:spring-boot-devtools')
testImplementation('org.springframework.boot:spring-boot-starter-test')
testImplementation('de.flapdoodle.embed:de.flapdoodle.embed.mongo')
testImplementation('io.projectreactor:reactor-test')
}
}
}
The use of a common configuration file allows us to place dependencies common to microservices, in this case, services with the name ending in “service” in one place. BUT, this again violates the principle of autonomy of microservices. In addition to general dependencies, you can add tasks to subprojects. I added a plugin task gradle.plugin.com.palantir.gradle.docker:gradle-docker
to work with Docker
.
Auth-module
Now, consider the JWT authentication module. A description of the package of auth
this module can be found in the book on reactive authentication, which I have indicated above.
And, on the package config
dwell in more detail.
The class of “complex” properties ApplicationClientsProperties.java
@Data
@Component
@ConfigurationProperties("appclients")
public class ApplicationClientsProperties {
private List<ApplicationClient> clients = new ArrayList<>();
@Data
public static class ApplicationClient {
private String username;
private String password;
private String[] roles;
}
}
This class contains “complex” properties for the inMemory database configuration.
Configuration class of module AuthModuleConfig.java
@Data
@Configuration
@PropertySource("classpath:moduleConfig.yml")
public class AuthModuleConfig {
@Value("${tokenExpirationMinutes:60}")
private Integer tokenExpirationMinutes;
@Value("${tokenIssuer:workingbit-example.com}")
private String tokenIssuer;
@Value("${tokenSecret:secret}") // length minimum 256 bites
private String tokenSecret;
}
In the resource file, you must specify these variables. In my configuration, the token fades after 10 hours.
MicroserviceServiceJwtAuthWebFilter.java filter matchers configuration class
public class MicroserviceServiceJwtAuthWebFilter extends JwtAuthWebFilter {
private final String[] matchersStrings;
public MicroserviceServiceJwtAuthWebFilter(JwtService jwtService, String[] matchersStrings) {
super(jwtService);
this.matchersStrings = matchersStrings;
}
@Override
protected ServerWebExchangeMatcher getAuthMatcher() {
List<ServerWebExchangeMatcher> matchers = Arrays.stream(this.matchersStrings)
.map(PathPatternParserServerWebExchangeMatcher::new)
.collect(Collectors.toList());
return ServerWebExchangeMatchers.matchers(new OrServerWebExchangeMatcher(matchers));
}
}
With this design, at the construction, the service for working with JWT and the list of paths that this filter will process are transferred.
Configuration class Reactive Spring Boot Security MicroserviceSpringSecurityWebFluxConfig.java
@ConditionalOnProperty(value = "microservice", havingValue = "true")
@EnableReactiveMethodSecurity
@PropertySource(value = "classpath:/application.properties")
public class MicroserviceSpringSecurityWebFluxConfig {
@Value("${whiteListedAuthUrls}")
private String[] whiteListedAuthUrls;
@Value("${jwtTokenMatchUrls}")
private String[] jwtTokenMatchUrls;
/**
* Bean which configures whiteListed and JWT filter urls
* Also it configures authentication for Actuator. Actuator takes configured AuthenticationManager automatically
* which uses MapReactiveUserDetailsService to configure inMemory users
*/
@Bean
public SecurityWebFilterChain springSecurityFilterChain(
ServerHttpSecurity http, JwtService jwtService
) {
MicroserviceServiceJwtAuthWebFilter userServiceJwtAuthWebFilter
= new MicroserviceServiceJwtAuthWebFilter(jwtService, jwtTokenMatchUrls);
http.csrf().disable();
http
.authorizeExchange()
.pathMatchers(whiteListedAuthUrls)
.permitAll()
.and()
.authorizeExchange()
.pathMatchers("/actuator/**").hasRole("SYSTEM")
.and()
.httpBasic()
.and()
.addFilterAt(userServiceJwtAuthWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);
return http.build();
}
}
There are three interesting annotations here.
@ConditionalOnProperty(value = "microservice", havingValue = "true")
Annotation that enables this module depending on the microservice variable in the configuration file that is specified in the annotation. This is necessary in order to disable the general token check in some modules. In this application, this is a service webapi-service
that has its own implementation of the bean SecurityWebFilterChain
.
@PropertySource(value = "classpath:/application.properties")
This annotation also allows you to take properties from the main service to which this module is imported. In other words, variables
@Value("${whiteListedAuthUrls}")
private String[] whiteListedAuthUrls;
@Value("${jwtTokenMatchUrls}")
private String[] jwtTokenMatchUrls;
Take your values from the configuration of the microservice of the child.
And, the summary, which allows you to hang the annotation security type @PreAuthorize(“hasRole(‘MY_ROLE’)”)
@EnableReactiveMethodSecurity
And in this module, a bin is created SecurityWebFilterChain
that configures the access to the actuator, which are allowed by url and url on which the JWT token is checked. It should be noted that access to the JWT token filter must be open.
SpringWebFluxConfig.java configuration
In this configuration, bins are created MapReactiveUserDetailsService
to configure the actuator and other system users in memory.
@Bean
@Primary
public MapReactiveUserDetailsService userDetailsRepositoryInMemory() {
List<UserDetails> users = applicationClients.getClients()
.stream()
.map(applicationClient ->
User.builder()
.username(applicationClient.getUsername())
.password(passwordEncoder().encode(applicationClient.getPassword()))
.roles(applicationClient.getRoles()).build())
.collect(toList());
return new MapReactiveUserDetailsService(users);
}
The bean ReactiveUserDetailsService
that is needed to stitch our user's repository with Spring Security
.
@Bean
public ReactiveUserDetailsService userDetailsRepository(UserRepository users) {
return (email) -> users.findByEmail(email).cast(UserDetails.class);
}
Creation bean WebClient
- client to perform reactive requests.
@Bean
public WebClient loadBalancedWebClientBuilder(JwtService jwtService) {
return WebClient.builder()
.filter(lbFunction)
.filter(authorizationFilter(jwtService))
.build();
}
private ExchangeFilterFunction authorizationFilter(JwtService jwtService) {
return ExchangeFilterFunction
.ofRequestProcessor(clientRequest ->
ReactiveSecurityContextHolder.getContext()
.map(securityContext ->
ClientRequest.from(clientRequest)
.header(HttpHeaders.AUTHORIZATION,
jwtService.getHttpAuthHeaderValue(securityContext.getAuthentication()))
.build()));
}
Two filters are added during creation. LoadBalancer
and a filter that takes an ReactiveSecurityContext
instance from the context Authentication
and creates a token from it in order for it to be authenticated by the filter of the target server and accordingly authorized.
And for the convenience of working with the MongoDB type ObjectId
and dates, I added an objectMapper creation bin:
@Bean
@Primary
ObjectMapper objectMapper() {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.serializerByType(ObjectId.class, new ToStringSerializer());
builder.deserializerByType(ObjectId.class, new JsonDeserializer() {
@Override
public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
Map oid = p.readValueAs(Map.class);
return new ObjectId(
(Integer) oid.get("timestamp"),
(Integer) oid.get("machineIdentifier"),
((Integer) oid.get("processIdentifier")).shortValue(),
(Integer) oid.get("counter"));
}
});
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return builder.build();
}
Microservice game-service
Microservice game-service has the following structure:
As you can see there is only one ApplicationConfig configuration file.
ApplicationConfig.java Configurator
@Data
@Configuration
@EnableReactiveMongoRepositories("com.tictactoe.gameservice.repository")
@Import({ApplicationClientsProperties.class, SpringWebFluxConfig.class, MicroserviceSpringSecurityWebFluxConfig.class})
public class ApplicationConfig {
@Value("${userserviceUrl}")
private String userServiceUrl;
}
It contains a variable with the address of the service user-service
and there are two interesting annotations:
@EnableReactiveMongoRepositories("com.tictactoe.gameservice.repository")
This annotation is necessary in order to specify the MongoDB repository to the configurator.
@Import({ApplicationClientsProperties.class, SpringWebFluxConfig.class, MicroserviceSpringSecurityWebFluxConfig.class})
This annotation imports configurations from a module auth-module
.
GameService.java service
This service has only the following interesting code:
@HystrixCommand
public Flux<Game> getAllGames() {
return gameRepository.findAll();
}
@HystrixCommand(fallbackMethod = "buildFallbackAllGames",
threadPoolKey = "licenseByOrgThreadPool",
threadPoolProperties =
{@HystrixProperty(name = "coreSize", value = "30"),
@HystrixProperty(name = "maxQueueSize", value = "10")},
commandProperties = {
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "75"),
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "7000"),
@HystrixProperty(name = "metrics.rollingStats.timeInMilliseconds", value = "15000"),
@HystrixProperty(name = "metrics.rollingStats.numBuckets", value = "5")}
)
public Flux<Game> getAllGamesLong() {
// logger.debug("LicenseService.getLicensesByOrg Correlation id: {}", UserContextHolder.getContext().getCorrelationId());
randomlyRunLong();
return gameRepository.findAll();
}
This method randomly throws an exception, and Hystrix, in accordance with the annotation, returns the result of the following method:
private Flux<Game> buildFallbackAllGames() {
User fakeUserBlack = new User("fakeUserBlack", "password", Collections.emptyList());
User fakeUserWhite = new User("fakeUserBlack", "password", Collections.emptyList());
Game game = new Game(fakeUserBlack, fakeUserWhite);
List<Game> games = List.of(game);
return Flux.fromIterable(games);
}
As mentioned in the book mentioned above, if something broke, then let's better show the cached or alternative data than nothing.
Microservice webapi-service
This is a kind of middleware between Gateway and internal microservices that are not visible from the outside. The purpose of this service is to get a sample from other services and form the answer to the user on its basis.
We will begin consideration with a configuration.
SpringSecurityWebFluxConfig.java configuration
@Configuration
@EnableReactiveMethodSecurity
public class SpringSecurityWebFluxConfig {
private static final String AUTH_TOKEN_PATH = "/auth/token";
@Value("${whiteListedAuthUrls}")
private String[] whiteListedAuthUrls;
@Value("${jwtTokenMatchUrls}")
private String[] jwtTokenMatchUrls;
@Bean
@Primary
public SecurityWebFilterChain systemSecurityFilterChain(
ServerHttpSecurity http, JwtService jwtService,
@Qualifier("userDetailsRepository") ReactiveUserDetailsService userDetailsService
) {
Here we create an authentication manager for services userDetailsService
, which we defined earlier in the module auth-module
.
UserDetailsRepositoryReactiveAuthenticationManager authenticationManager
= new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
And we create a filter with this manager, and also add the Authentication instance converter to get the user data encoded in x-www-form-urlencoded
.
AuthenticationWebFilter tokenWebFilter = new AuthenticationWebFilter(authenticationManager);
tokenWebFilter.setServerAuthenticationConverter(exchange ->
Mono.justOrEmpty(exchange)
.filter(ex -> AUTH_TOKEN_PATH.equalsIgnoreCase(ex.getRequest().getPath().value()))
.flatMap(ServerWebExchange::getFormData)
.filter(formData -> !formData.isEmpty())
.map((formData) -> {
String email = formData.getFirst("email");
String password = formData.getFirst("password");
return new UsernamePasswordAuthenticationToken(email, password);
})
);
Add a handler for successful authorization, the essence of which is to put the JWT token in the Authentication
request header generated from the request, so that you can only authenticate by valid guest token.
tokenWebFilter.setAuthenticationSuccessHandler(new JwtAuthSuccessHandler(jwtService));
MicroserviceServiceJwtAuthWebFilter webApiJwtServiceWebFilter = new MicroserviceServiceJwtAuthWebFilter(jwtService, jwtTokenMatchUrls);
http.csrf().disable();
http
.authorizeExchange()
We allow addresses from the white list. As I wrote earlier, the addresses that will be processed by the JWT filter should also be opened
.pathMatchers(whiteListedAuthUrls)
.permitAll()
.and()
.authorizeExchange()
We protect the actuator and some addresses with basic authentication
.pathMatchers("/actuator/**").hasRole("SYSTEM")
.pathMatchers(HttpMethod.GET, "/url-protected/**").hasRole("GUEST")
.pathMatchers(HttpMethod.POST, "/url-protected/**").hasRole("USER")
.and()
.httpBasic()
.and()
.authorizeExchange()
Mandatory authentication for access to the token
.pathMatchers(AUTH_TOKEN_PATH).authenticated()
.and()
Add filters. To authenticate and verify the JWT token.
.addFilterAt(webApiJwtServiceWebFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.addFilterAt(tokenWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);
return http.build();
}
And as I wrote above, this service disables the JWT token check common for other services, indicating the value of the variable micoservice=false
in the file application.properites
.
Token issuance, registration and authorization controller AuthController.java
I will not describe this controller, since it is very specific.
WebApiService.java service
This service is called in controller WebApiMethodProtectedController.jav
a and has an interesting annotation:
@PreAuthorize("hasRole('GUEST')")
public Flux<User> getAllUsers() {
}
This annotation only allows access to authorized users with the guest role.
How to test
Create an environment:
Get a token
Update the TOKEN variable with the received token.
Register a new user
After registration, you will receive a user token. It expires in 10 hours. When it expires you need to get a new one. To do this, request the guest token again, update the environment and make the request
Next, you can get a list of users, games, or create a new game. And also test Hystrix, look at the configs of the services and encrypt the variables for the git repository.