How can we use Spring Cloud Gateway to transition from a monolithic web project to a micro/macro service architecture?
Precondition
Let us imagine a web project that is serving a mobile app and one website through a REST API, everything tight & coupled in one big monolith box.
Monolith project: https://github.com/GhenadieCebanu/monolit-app
API endpoints:
EventController – https://{{hostname}}/v1/get-out/events
UsersController – https://{{hostname}}/ v1/get-out/users
Problem
EventController is highly used and we want to scale it up. On the other hand, UsersController is less used and does not require a lot of resources. The only option for this scenario is to scale up the entire monolith app (we want instant response for our users), but we all know that AWS services are not cheap.
Possible solution
Break down the monolith app in two separate services so we can scale them up/down independently and, at the same time, preserve the API contract because of the mobile apps (forcing users to update iOS or Android app should be the last option).
- Event management project: https://github.com/GhenadieCebanu/event-management
- User management project: https://github.com/GhenadieCebanu/user-management
and our star here – API gateway project, based on Spring Cloud Gateway
- API Gateway project: https://github.com/GhenadieCebanu/api-gateway
Spring Cloud Gateway
Before describing how we can use it, I would like to mention some benefits, which come with this framework.
- It will protect your client from freezing – you can set timeout and fallback URL for any request, so the client will not freeze waiting for a response. You can read here an insightful article from Uncle Bob (Robert C. Martin) about CircuitBreaker and how it works.
- It can be used as a central place for logging request/responses, for setting up SSL communication, for setting security configuration, and more (for all these cases, the gateway will be set as the only entry point).
- Intuitive to use.
- Highly configurable.
Set up routes in Spring Cloud Gateway
We want to keep the same contract with the client, in our case the following two URLs must be accessible:
- http://{{hostname}}/v1/get-out/events
- http://{{hostname}}/ v1/get-out/users
In RoutesConfig, we can set up like this:.route(r -> r.path(“/v1/get-out/events”) .uri(serviceDiscoveryProperties.eventsServiceUri)) .route(r -> r.path(“/v1/get-out/users”) .uri(serviceDiscoveryProperties.userManagementServiceUri))
.route(r -> r.path("/v1/get-out/events") .uri(serviceDiscoveryProperties.eventsServiceUri)) .route(r -> r.path("/v1/get-out/users") .uri(serviceDiscoveryProperties.userManagementServiceUri))
This is the simplest configuration that will redirect all requests with path “/v1/get-out/events” to the event management project and “/v1/get-out/users” to the user management project.
In order to test this, we have a nice library, wiremock, which can be used to mock behavior for any endpoint; below, you can find a test example:
@Test void shouldHitEventsEndpoint() { // Given stubFor(get("/v1/get-out/events").willReturn(ok("Found"))); // When, Then webTestClient.get().uri("/v1/get-out/events").exchange() .expectStatus().isOk(); }
Let’s enhance a bit our routing by adding custom request and response headers, and break the call if the request takes longer than 1 second.
.route(r -> r.path("/v1/get-out/events") .filters(f -> f .circuitBreaker(c -> { }) .addRequestHeader("view", "legacy") .addResponseHeader("response-service", "event-management") ) .uri(serviceDiscoveryProperties.eventsServiceUri))
An example configuration for circuitbreaker can be found here: api.gateway.config.CircuitBreakerConfig
Request timeout duration can be set here: api.gateway.config.CircuitBreakerProperties
Let’s suppose it’s the first release of our user management service; for safety reasons, we can add Fallback URI to legacy (old monolith app) – please check the configuration below:
.route(r -> r.path("/v1/get-out/events") .filters(f -> f .circuitBreaker(c -> { c.setFallbackUri("forward:/fallback/v1/get-out/events"); }) .addRequestHeader("view", "legacy") .addResponseHeader("response-service", "event-management") ) .uri(serviceDiscoveryProperties.eventsServiceUri)) .route(r -> r.path("/fallback/v1/get-out/**") .filters(f -> f .rewritePath("/fallback/(?<segment>.*)", "/${segment}") .addResponseHeader("response-service", "monolit-app")) .uri(serviceDiscoveryProperties.fallbackUri))
We can also set up a retry policy for each endpoint:
.route(r -> r.path("/v1/get-out/users") .filters(f -> f .retry(retryConfig -> { retryConfig.setRetries(3); retryConfig.setBackoff(Duration.ofSeconds(2), Duration.ofSeconds(16), 2, true); retryConfig.setStatuses(HttpStatus.GATEWAY_TIMEOUT, HttpStatus.REQUEST_TIMEOUT, HttpStatus.BAD_REQUEST, HttpStatus.NOT_FOUND); }) .addResponseHeader("response-service", "user-management") ) .uri(serviceDiscoveryProperties.userManagementServiceUri))
Using the above configuration, in case the user management service will return any response status that is configured in retryConfig.setStatuses, then the gateway will retry 3 times, first retry after 2 sec., then 4s., 8s., and the last one after 16sec (retry timing depends on user management service).
Log request and response
By implementing org.springframework.cloud.gateway.filter.GlobalFilter we gain access to org.springframework.web.server.ServerWebExchange that have requests/responses – please check api.gateway.filter.HttpRequestLoggerFilter and api.gateway.filter.HttpResponseLoggerFilter.
Other magical stuff
- We can build a redirect URI based on the request body
Example:
.route(r -> r.path("/v1/get-out/users") .and() .method(POST) .and() .readBody(String.class, s -> !s.isBlank()) .filters(f -> f .circuitBreaker(c -> { }) .filter(bodyToUriFilterFactory.apply( new SetValueFromBodyToPath("/v1/get-out/users") )) .filter((exchange, chain) -> { final ServerHttpRequest request = exchange.getRequest(); final ServerHttpRequest newRequest = request.mutate().method(GET).build(); return chain.filter(exchange.mutate().request(newRequest).build()); })) .uri(serviceDiscoveryProperties.userManagementServiceUri))
and test:
@Test void shouldTranslatePostToGet() { // Given stubFor(get("/v1/get-out/users/1").willReturn(ok("Found"))); // When, Then webTestClient.post().uri("/v1/get-out/users") .contentType(APPLICATION_JSON).bodyValue(Map.of("id","1")) .exchange().expectStatus().isOk(); }
Here we redirected POST to GET and used the body to build GET URI (/v1/get-out/users/1), you can find this example in RoutesConfig.java.
- Add custom response header for all requests: gateway.filter.GlobalHeaderFilter
@Component public class GlobalHeaderFilter implements GlobalFilter, Ordered { @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 1; } @Override public Mono<Void> filter(final ServerWebExchange exchange, final GatewayFilterChain chain) { exchange.getResponse().getHeaders().set("global-header", "header-value"); return chain.filter(exchange); } }
This is one way to transition from monolith to micro/macro services. I hope you find it useful.
About Ghenadie Cebanu
A graduate of Computer Science BSc and Information Security MA, Ghenadie has over 7 years of experience in software development (primary language Java). One thing he enjoys when working closely with a client is the opportunity to understand the business needs, not only the technical aspects of the project and the tasks involved. Ghenadie is also passionate about photography, cycling, hiking, and table tennis. Check out Ghenadie’s photos on Flickr and Unsplash.