Add spring-boot-microservices-project: config server, Eureka, gateway and three services on Boot 4.1.1 + Spring Cloud 2025.1.3
Product, order and inventory services behind Spring Cloud Gateway (WebMVC), configuration from a Config Server, discovery with Eureka, a Resilience4j circuit breaker on order -> inventory and Brave tracing. Run as six plain jars; output/ holds real transcripts (config, registry, requests, trace propagation, circuit breaker, startup race and the load-balanced RestClient.Builder trap). Dockerfile and docker-compose.yml are included but were not run. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package com.ankurm.shop.order;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.cloud.client.circuitbreaker.Customizer;
|
||||
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
|
||||
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory;
|
||||
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@Configuration
|
||||
class ClientConfig {
|
||||
|
||||
/**
|
||||
* "http://product-service/..." is not a DNS name: @LoadBalanced makes the client ask Eureka for a real
|
||||
* host and port. The ObservationRegistry is what makes the client add the trace headers to the request.
|
||||
*/
|
||||
@Bean
|
||||
@LoadBalanced
|
||||
RestClient.Builder loadBalancedRestClientBuilder(ObservationRegistry observationRegistry) {
|
||||
return RestClient.builder().observationRegistry(observationRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Eureka client also asks for a RestClient.Builder. If the only one is the load-balanced one, Eureka's own
|
||||
* calls to the registry go through the load balancer, which asks Eureka for "localhost": a circular reference
|
||||
* and "No instances available for localhost". A plain @Primary builder keeps Eureka off the balanced one.
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
@ConditionalOnProperty(name = "shop.demo.primary-builder", havingValue = "true", matchIfMissing = true) // false only in scripts/gotchas.sh
|
||||
RestClient.Builder plainRestClientBuilder(ObservationRegistry observationRegistry) {
|
||||
return RestClient.builder().observationRegistry(observationRegistry);
|
||||
}
|
||||
|
||||
/** One circuit breaker named "inventory": open after 2 failures out of 4 calls, probe again after 5 seconds. */
|
||||
@Bean
|
||||
Customizer<Resilience4JCircuitBreakerFactory> inventoryBreaker() {
|
||||
return factory -> factory.configure(builder -> builder
|
||||
.circuitBreakerConfig(CircuitBreakerConfig.custom()
|
||||
.slidingWindowSize(4)
|
||||
.minimumNumberOfCalls(4)
|
||||
.failureRateThreshold(50)
|
||||
.waitDurationInOpenState(Duration.ofSeconds(5))
|
||||
.permittedNumberOfCallsInHalfOpenState(1)
|
||||
.build())
|
||||
.timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(3)).build()),
|
||||
"inventory");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.ankurm.shop.order;
|
||||
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory;
|
||||
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** Talks to inventory-service through the "inventory" circuit breaker. */
|
||||
@Component
|
||||
class InventoryClient {
|
||||
private static final Logger log = LoggerFactory.getLogger(InventoryClient.class);
|
||||
|
||||
private final RestClient client;
|
||||
private final CircuitBreakerFactory<?, ?> breakers;
|
||||
|
||||
InventoryClient(@LoadBalanced RestClient.Builder builder, CircuitBreakerFactory<?, ?> breakers) {
|
||||
this.client = builder.baseUrl("http://inventory-service").build();
|
||||
this.breakers = breakers;
|
||||
}
|
||||
|
||||
/** A plain read used only by /orders/ready to warm up and check the route to inventory-service. */
|
||||
Map<String, Object> level(long productId) {
|
||||
return client.get().uri("/inventory/{id}", productId).retrieve()
|
||||
.body(new org.springframework.core.ParameterizedTypeReference<Map<String, Object>>() { });
|
||||
}
|
||||
|
||||
enum Reservation { RESERVED, OUT_OF_STOCK, UNAVAILABLE }
|
||||
|
||||
/**
|
||||
* RESERVED and OUT_OF_STOCK are answers from a healthy inventory service. UNAVAILABLE means the call failed or the
|
||||
* circuit is open. The 409 is turned into OUT_OF_STOCK inside the protected call, so it never counts as a failure
|
||||
* and never reaches the fallback: Spring Cloud's fallback receives every exception, including ones you would
|
||||
* configure Resilience4j to ignore.
|
||||
*/
|
||||
Reservation reserve(long productId, int quantity) {
|
||||
return breakers.create("inventory").run(
|
||||
() -> {
|
||||
try {
|
||||
client.post().uri("/inventory/{id}/reserve", productId)
|
||||
.body(Map.of("quantity", quantity))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
return Reservation.RESERVED;
|
||||
} catch (HttpClientErrorException.Conflict e) {
|
||||
return Reservation.OUT_OF_STOCK;
|
||||
}
|
||||
},
|
||||
failure -> {
|
||||
log.warn("inventory call did not complete ({}); falling back", failure.getClass().getSimpleName());
|
||||
return Reservation.UNAVAILABLE;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.shop.order;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
record Order(long id, long productId, String productName, int quantity, BigDecimal total, String currency, String status) {
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.ankurm.shop.order;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
class OrderController {
|
||||
private static final Logger log = LoggerFactory.getLogger(OrderController.class);
|
||||
|
||||
record NewOrder(@Min(1) long productId, @Min(1) int quantity) {
|
||||
}
|
||||
|
||||
record ProductView(long id, String name, BigDecimal price) {
|
||||
}
|
||||
|
||||
private final RestClient products;
|
||||
private final InventoryClient inventory;
|
||||
private final OrderProperties properties;
|
||||
private final CircuitBreakerRegistry breakers;
|
||||
private final List<Order> orders = new CopyOnWriteArrayList<>();
|
||||
private final AtomicLong ids = new AtomicLong();
|
||||
|
||||
OrderController(@LoadBalanced RestClient.Builder builder, InventoryClient inventory, OrderProperties properties, CircuitBreakerRegistry breakers) {
|
||||
this.products = builder.baseUrl("http://product-service").build();
|
||||
this.inventory = inventory;
|
||||
this.properties = properties;
|
||||
this.breakers = breakers;
|
||||
}
|
||||
|
||||
@PostMapping("/orders")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
Order place(@Valid @RequestBody NewOrder request) {
|
||||
if (request.quantity() > properties.maxQuantity()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At most " + properties.maxQuantity() + " units per order");
|
||||
}
|
||||
log.info("placing order for product {} x{}", request.productId(), request.quantity());
|
||||
ProductView product = findProduct(request.productId());
|
||||
String status = switch (inventory.reserve(request.productId(), request.quantity())) {
|
||||
case RESERVED -> "CONFIRMED";
|
||||
case UNAVAILABLE -> "PENDING_INVENTORY";
|
||||
case OUT_OF_STOCK -> throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"Not enough stock for product " + request.productId());
|
||||
};
|
||||
Order order = new Order(ids.incrementAndGet(), product.id(), product.name(), request.quantity(),
|
||||
product.price().multiply(BigDecimal.valueOf(request.quantity())), properties.currency(), status);
|
||||
orders.add(order);
|
||||
log.info("order {} is {}", order.id(), order.status());
|
||||
return order;
|
||||
}
|
||||
|
||||
@GetMapping("/orders")
|
||||
List<Order> all() {
|
||||
return List.copyOf(orders);
|
||||
}
|
||||
|
||||
/** Where the values from the config server ended up. */
|
||||
@GetMapping("/orders/config")
|
||||
Map<String, Object> config() {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("currency", properties.currency());
|
||||
m.put("maxQuantity", properties.maxQuantity());
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 200 only when this service can really reach product-service and inventory-service through the load balancer.
|
||||
* The registry copy is refreshed every 30 seconds, so right after startup this answers 503 for a while.
|
||||
*/
|
||||
@GetMapping("/orders/ready")
|
||||
Map<String, Object> ready() {
|
||||
try {
|
||||
findProduct(1);
|
||||
inventory.level(1);
|
||||
return Map.of("ready", true);
|
||||
} catch (RuntimeException e) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "dependencies not visible yet: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/orders/circuit")
|
||||
Map<String, Object> circuit() {
|
||||
var cb = breakers.circuitBreaker("inventory");
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("state", cb.getState().name());
|
||||
m.put("failedCalls", cb.getMetrics().getNumberOfFailedCalls());
|
||||
m.put("notPermittedCalls", cb.getMetrics().getNumberOfNotPermittedCalls());
|
||||
return m;
|
||||
}
|
||||
|
||||
private ProductView findProduct(long id) {
|
||||
try {
|
||||
return products.get().uri("/products/{id}", id).retrieve().body(ProductView.class);
|
||||
} catch (HttpClientErrorException.NotFound e) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product " + id + " not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.shop.order;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Bound from shop.order.* , which comes from the config server (order-service.yml). */
|
||||
@ConfigurationProperties("shop.order")
|
||||
record OrderProperties(String currency, int maxQuantity) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.shop.order;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class OrderServiceApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(OrderServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
spring:
|
||||
application:
|
||||
name: order-service
|
||||
config:
|
||||
# Fail fast if the config server is not up: a service with the wrong config is worse than no service.
|
||||
import: "configserver:${CONFIG_URL:http://localhost:8888}"
|
||||
server:
|
||||
port: 8082
|
||||
eureka:
|
||||
client:
|
||||
service-url:
|
||||
defaultZone: ${EUREKA_URL:http://localhost:8761/eureka}
|
||||
instance:
|
||||
hostname: ${EUREKA_HOSTNAME:localhost}
|
||||
# Register the service name + port as the instance id so two copies of one service do not collide.
|
||||
instance-id: ${spring.application.name}:${server.port}
|
||||
Reference in New Issue
Block a user