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,2 @@
|
||||
target/
|
||||
run/
|
||||
@@ -0,0 +1,6 @@
|
||||
# One Dockerfile for all six services: docker build --build-arg SERVICE=order-service -t shop/order-service .
|
||||
# Build the jars first (mvn -DskipTests package). NOT RUN when this repository was written: the sandbox had no Docker.
|
||||
FROM eclipse-temurin:21-jre
|
||||
ARG SERVICE
|
||||
COPY ${SERVICE}/target/${SERVICE}-1.0.0.jar /app.jar
|
||||
ENTRYPOINT ["java", "-Xmx256m", "-jar", "/app.jar"]
|
||||
@@ -0,0 +1,57 @@
|
||||
# spring-boot-microservices-project
|
||||
|
||||
A small e-commerce system on **Spring Boot 4.1.1** and **Spring Cloud 2025.1.3**, written for the article
|
||||
[Spring Boot Microservices Project: Gateway, Discovery, Config and 3 Services](https://ankurm.com/spring-boot-microservices-project/).
|
||||
|
||||
```
|
||||
client ──> gateway :8080 ──> product-service :8081
|
||||
├─> order-service :8082 ──> product-service (read the price)
|
||||
│ └──> inventory-service (reserve stock, circuit breaker)
|
||||
└─> inventory-service :8083
|
||||
config-server :8888 serves every service's configuration (and the gateway's routes)
|
||||
discovery-server :8761 (Eureka) every service registers here and finds the others here
|
||||
```
|
||||
|
||||
| Module | What it is |
|
||||
|---|---|
|
||||
| `config-server` | Spring Cloud Config Server, native profile; files in `src/main/resources/config/` |
|
||||
| `discovery-server` | Eureka server |
|
||||
| `gateway` | Spring Cloud Gateway Server WebMVC; routes come from the config server (`gateway.yml`) |
|
||||
| `product-service` | `GET /products`, `GET /products/{id}` |
|
||||
| `inventory-service` | stock levels, `POST /inventory/{id}/reserve`, a switch to simulate an outage |
|
||||
| `order-service` | `POST /orders`; calls product-service and inventory-service through the load balancer and a Resilience4j circuit breaker |
|
||||
|
||||
## Run it (no Docker needed)
|
||||
|
||||
Needs JDK 21+, Maven 3.9+, curl and python3.
|
||||
|
||||
```bash
|
||||
./scripts/run-all.sh # build, start the six services, run the scenarios, stop everything
|
||||
./scripts/start.sh # or: start the services and leave them running
|
||||
./scripts/demo.sh # re-run the scenarios (rewrites output/)
|
||||
./scripts/stop.sh
|
||||
```
|
||||
|
||||
Start order matters: **config server first** (everything else reads its configuration from it), then Eureka, then the rest.
|
||||
|
||||
## Docker
|
||||
|
||||
`Dockerfile` and `docker-compose.yml` are provided but were **not run**: the sandbox this was written in had no Docker.
|
||||
Treat them as a starting point, not a tested artifact.
|
||||
|
||||
## Captured output
|
||||
|
||||
Every file in `output/` is written by `scripts/demo.sh` from real requests. Nothing is typed by hand.
|
||||
|
||||
| File | What it shows |
|
||||
|---|---|
|
||||
| `01-config-server.txt` | What the config server serves and the value the order service ended up with |
|
||||
| `02-eureka-registry.txt` | The instances registered in Eureka |
|
||||
| `03-gateway-requests.txt` | Requests through the gateway: products, stock, orders, and the error cases |
|
||||
| `04-trace-propagation.txt` | One request, one trace id, in the logs of four services |
|
||||
| `05-circuit-breaker.txt` | The circuit breaker going CLOSED, OPEN, and back to CLOSED |
|
||||
|
||||
## Versions
|
||||
|
||||
Spring Boot 4.1.1, Spring Framework 7, Spring Cloud 2025.1.3 (its BOM is built against Boot 4.0.8; it ran here on 4.1.1),
|
||||
Tomcat 11, Java 21 (OpenJDK 21.0.12). JDK 25 was not available when this was written.
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ankurm.shop</groupId>
|
||||
<artifactId>shop</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>config-server</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-config-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.shop.config;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.config.server.EnableConfigServer;
|
||||
|
||||
/** Serves the files under src/main/resources/config to every other service. Start this one first. */
|
||||
@SpringBootApplication
|
||||
@EnableConfigServer
|
||||
public class ConfigServerApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ConfigServerApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
spring:
|
||||
application:
|
||||
name: config-server
|
||||
profiles:
|
||||
active: native
|
||||
cloud:
|
||||
config:
|
||||
server:
|
||||
native:
|
||||
search-locations: classpath:/config
|
||||
server:
|
||||
port: 8888
|
||||
@@ -0,0 +1,9 @@
|
||||
# Served to EVERY service that asks the config server (the "application" file is the shared default).
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,circuitbreakers
|
||||
tracing:
|
||||
sampling:
|
||||
probability: 1.0
|
||||
@@ -0,0 +1,25 @@
|
||||
# Routes live here, not in the gateway jar: change a route, restart the gateway, no rebuild.
|
||||
spring:
|
||||
cloud:
|
||||
gateway:
|
||||
server:
|
||||
webmvc:
|
||||
routes:
|
||||
- id: products
|
||||
uri: lb://product-service
|
||||
predicates:
|
||||
- Path=/api/products/**
|
||||
filters:
|
||||
- StripPrefix=1
|
||||
- id: orders
|
||||
uri: lb://order-service
|
||||
predicates:
|
||||
- Path=/api/orders/**
|
||||
filters:
|
||||
- StripPrefix=1
|
||||
- id: inventory
|
||||
uri: lb://inventory-service
|
||||
predicates:
|
||||
- Path=/api/inventory/**
|
||||
filters:
|
||||
- StripPrefix=1
|
||||
@@ -0,0 +1,3 @@
|
||||
shop:
|
||||
inventory:
|
||||
low-stock-threshold: 5
|
||||
@@ -0,0 +1,4 @@
|
||||
shop:
|
||||
order:
|
||||
currency: EUR
|
||||
max-quantity: 10
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ankurm.shop</groupId>
|
||||
<artifactId>shop</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>discovery-server</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.shop.discovery;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
|
||||
|
||||
/** The service registry: every other service registers here and looks the others up here. */
|
||||
@SpringBootApplication
|
||||
@EnableEurekaServer
|
||||
public class DiscoveryServerApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DiscoveryServerApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
spring:
|
||||
application:
|
||||
name: discovery-server
|
||||
server:
|
||||
port: 8761
|
||||
eureka:
|
||||
instance:
|
||||
hostname: ${EUREKA_HOSTNAME:localhost}
|
||||
client:
|
||||
# This server is the registry; it must not try to register with itself or fetch from itself.
|
||||
register-with-eureka: false
|
||||
fetch-registry: false
|
||||
server:
|
||||
# Dev setting: a registry with one client would otherwise enter "self-preservation" and never evict dead instances.
|
||||
enable-self-preservation: false
|
||||
@@ -0,0 +1,39 @@
|
||||
# All six services on one network: mvn -DskipTests package && docker compose up --build
|
||||
# NOT RUN when this repository was written (no Docker in the sandbox). The same system was run as plain jars with
|
||||
# scripts/start.sh, and everything in output/ comes from that. Inside the compose network the services reach each
|
||||
# other by service name, so only the three environment variables below differ from the jar run.
|
||||
x-service: &service
|
||||
restart: on-failure # a service that starts before the config server is up exits and is retried
|
||||
environment: &env
|
||||
CONFIG_URL: http://config-server:8888
|
||||
EUREKA_URL: http://discovery-server:8761/eureka
|
||||
|
||||
services:
|
||||
config-server:
|
||||
build: { context: ., args: { SERVICE: config-server } }
|
||||
ports: ["8888:8888"]
|
||||
discovery-server:
|
||||
build: { context: ., args: { SERVICE: discovery-server } }
|
||||
ports: ["8761:8761"]
|
||||
environment: { EUREKA_HOSTNAME: discovery-server }
|
||||
product-service:
|
||||
<<: *service
|
||||
build: { context: ., args: { SERVICE: product-service } }
|
||||
environment: { <<: *env, EUREKA_HOSTNAME: product-service }
|
||||
depends_on: [config-server, discovery-server]
|
||||
inventory-service:
|
||||
<<: *service
|
||||
build: { context: ., args: { SERVICE: inventory-service } }
|
||||
environment: { <<: *env, EUREKA_HOSTNAME: inventory-service }
|
||||
depends_on: [config-server, discovery-server]
|
||||
order-service:
|
||||
<<: *service
|
||||
build: { context: ., args: { SERVICE: order-service } }
|
||||
environment: { <<: *env, EUREKA_HOSTNAME: order-service }
|
||||
depends_on: [config-server, discovery-server]
|
||||
gateway:
|
||||
<<: *service
|
||||
build: { context: ., args: { SERVICE: gateway } }
|
||||
environment: { <<: *env, EUREKA_HOSTNAME: gateway }
|
||||
ports: ["8080:8080"]
|
||||
depends_on: [config-server, discovery-server]
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ankurm.shop</groupId>
|
||||
<artifactId>shop</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>gateway</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-gateway-server-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-micrometer-tracing-brave</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-tracing-bridge-brave</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ankurm.shop.gateway;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/** The single public entry point. The routes are in config-server/src/main/resources/config/gateway.yml. */
|
||||
@SpringBootApplication
|
||||
public class GatewayApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(GatewayApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ankurm.shop.gateway;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* Logs one line per request. It runs inside the tracing filter, so the line carries the trace id the gateway
|
||||
* created (or accepted from the caller) and passes on to every service behind it.
|
||||
*/
|
||||
@Component
|
||||
class RequestLogFilter extends OncePerRequestFilter {
|
||||
private static final Logger log = LoggerFactory.getLogger(RequestLogFilter.class);
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return !request.getRequestURI().startsWith("/api/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
log.info("gateway received {} {}", request.getMethod(), request.getRequestURI());
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
spring:
|
||||
application:
|
||||
name: gateway
|
||||
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: 8080
|
||||
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}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ankurm.shop</groupId>
|
||||
<artifactId>shop</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>inventory-service</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-micrometer-tracing-brave</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-tracing-bridge-brave</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.ankurm.shop.inventory;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
class InventoryController {
|
||||
private static final Logger log = LoggerFactory.getLogger(InventoryController.class);
|
||||
|
||||
record ReserveRequest(int quantity) {
|
||||
}
|
||||
|
||||
private final InventoryProperties properties;
|
||||
private final Map<Long, AtomicInteger> stock = new ConcurrentHashMap<>(Map.of(
|
||||
1L, new AtomicInteger(20), 2L, new AtomicInteger(8), 3L, new AtomicInteger(3)));
|
||||
private final AtomicBoolean down = new AtomicBoolean(false);
|
||||
private final AtomicInteger reserveCalls = new AtomicInteger();
|
||||
|
||||
InventoryController(InventoryProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@GetMapping("/inventory/{productId}")
|
||||
Map<String, Object> level(@PathVariable long productId) {
|
||||
AtomicInteger s = stock.get(productId);
|
||||
if (s == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No stock record for product " + productId);
|
||||
}
|
||||
return view(productId, s.get());
|
||||
}
|
||||
|
||||
@PostMapping("/inventory/{productId}/reserve")
|
||||
Map<String, Object> reserve(@PathVariable long productId, @RequestBody ReserveRequest request) {
|
||||
reserveCalls.incrementAndGet();
|
||||
if (down.get()) {
|
||||
log.warn("simulating an outage: refusing to reserve product {}", productId);
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "inventory is down");
|
||||
}
|
||||
AtomicInteger s = stock.get(productId);
|
||||
if (s == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No stock record for product " + productId);
|
||||
}
|
||||
int remaining = s.addAndGet(-request.quantity());
|
||||
if (remaining < 0) {
|
||||
s.addAndGet(request.quantity());
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Only " + s.get() + " of product " + productId + " in stock");
|
||||
}
|
||||
log.info("reserved {} of product {}, {} left", request.quantity(), productId, remaining);
|
||||
return view(productId, remaining);
|
||||
}
|
||||
|
||||
/** Test switch: POST /inventory/chaos?down=true makes every reserve call fail with 503. */
|
||||
@PostMapping("/inventory/chaos")
|
||||
Map<String, Object> chaos(@RequestParam boolean down) {
|
||||
this.down.set(down);
|
||||
log.info("outage simulation is now {}", down ? "ON" : "OFF");
|
||||
return Map.of("down", down);
|
||||
}
|
||||
|
||||
/** How many reserve calls actually reached this service (a circuit-breaker test needs this). */
|
||||
@GetMapping("/inventory/stats")
|
||||
Map<String, Object> stats() {
|
||||
return Map.of("reserveCalls", reserveCalls.get());
|
||||
}
|
||||
|
||||
private Map<String, Object> view(long productId, int quantity) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("productId", productId);
|
||||
m.put("quantity", quantity);
|
||||
m.put("lowStock", quantity <= properties.lowStockThreshold());
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.shop.inventory;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** Bound from shop.inventory.* , which comes from the config server (inventory-service.yml). */
|
||||
@ConfigurationProperties("shop.inventory")
|
||||
record InventoryProperties(int lowStockThreshold) {
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.shop.inventory;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class InventoryServiceApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(InventoryServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
spring:
|
||||
application:
|
||||
name: inventory-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: 8083
|
||||
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}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ankurm.shop</groupId>
|
||||
<artifactId>shop</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>order-service</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-micrometer-tracing-brave</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-tracing-bridge-brave</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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}
|
||||
@@ -0,0 +1,21 @@
|
||||
# GET /api/orders/ready through the gateway, every 2 seconds, starting the moment the last service reported healthy.
|
||||
# 200 means the gateway can route to the order service AND the order service can reach product and inventory.
|
||||
|
||||
/api/products/1 t+ 1s HTTP 200 {"id":1,"name":"Mechanical keyboard","price":89.90}
|
||||
/api/inventory/1 t+ 0s HTTP 200 {"productId":1,"quantity":20,"lowStock":false}
|
||||
/api/orders/ready t+ 0s HTTP 500 {"timestamp":"2026-10-21T17:07:18.322Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+ 2s HTTP 500 {"timestamp":"2026-10-21T17:07:20.364Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+ 4s HTTP 500 {"timestamp":"2026-10-21T17:07:22.396Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+ 6s HTTP 500 {"timestamp":"2026-10-21T17:07:24.421Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+ 8s HTTP 500 {"timestamp":"2026-10-21T17:07:26.444Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+10s HTTP 500 {"timestamp":"2026-10-21T17:07:28.466Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+12s HTTP 500 {"timestamp":"2026-10-21T17:07:30.490Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+14s HTTP 500 {"timestamp":"2026-10-21T17:07:32.513Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+16s HTTP 500 {"timestamp":"2026-10-21T17:07:34.542Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+18s HTTP 500 {"timestamp":"2026-10-21T17:07:36.570Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+20s HTTP 500 {"timestamp":"2026-10-21T17:07:38.595Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+22s HTTP 500 {"timestamp":"2026-10-21T17:07:40.634Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+24s HTTP 500 {"timestamp":"2026-10-21T17:07:42.658Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+26s HTTP 500 {"timestamp":"2026-10-21T17:07:44.686Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+28s HTTP 500 {"timestamp":"2026-10-21T17:07:46.709Z","status":500,"error":"Internal Server Error","path
|
||||
/api/orders/ready t+30s HTTP 200 {"ready":true}
|
||||
@@ -0,0 +1,16 @@
|
||||
# What the config server hands out (port 8888). The services fetch this at startup.
|
||||
|
||||
--- order-service: which files were used and the values ---
|
||||
name: order-service profiles: ['default']
|
||||
source: order-service.yml
|
||||
shop.order.currency = EUR
|
||||
shop.order.max-quantity = 10
|
||||
source: application.yml
|
||||
management.endpoints.web.exposure.include = health,info,circuitbreakers
|
||||
management.tracing.sampling.probability = 1.0
|
||||
|
||||
--- the same value as the order service itself reports it (GET /api/orders/config through the gateway) ---
|
||||
$ curl -X GET localhost:8080/api/orders/config
|
||||
HTTP 200
|
||||
{"currency":"EUR","maxQuantity":10}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# What is registered in Eureka (port 8761)
|
||||
|
||||
GATEWAY instance=gateway:8080 status=UP
|
||||
INVENTORY-SERVICE instance=inventory-service:8083 status=UP
|
||||
ORDER-SERVICE instance=order-service:8082 status=UP
|
||||
PRODUCT-SERVICE instance=product-service:8081 status=UP
|
||||
@@ -0,0 +1,46 @@
|
||||
# Requests through the gateway (port 8080). The client only ever talks to 8080.
|
||||
|
||||
--- products ---
|
||||
$ curl -X GET localhost:8080/api/products
|
||||
HTTP 200
|
||||
[{"id":1,"name":"Mechanical keyboard","price":89.90},{"id":2,"name":"USB-C dock","price":129.00},{"id":3,"name":"27-inch monitor","price":249.50}]
|
||||
|
||||
$ curl -X GET localhost:8080/api/products/2
|
||||
HTTP 200
|
||||
{"id":2,"name":"USB-C dock","price":129.00}
|
||||
|
||||
$ curl -X GET localhost:8080/api/products/99
|
||||
HTTP 404
|
||||
{"timestamp":"2026-10-21T17:07:49.313Z","status":404,"error":"Not Found","path":"/products/99"}
|
||||
|
||||
--- stock ---
|
||||
$ curl -X GET localhost:8080/api/inventory/3
|
||||
HTTP 200
|
||||
{"productId":3,"quantity":3,"lowStock":true}
|
||||
|
||||
--- orders ---
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":2}'
|
||||
HTTP 201
|
||||
{"id":1,"productId":1,"productName":"Mechanical keyboard","quantity":2,"total":179.80,"currency":"EUR","status":"CONFIRMED"}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":3,"quantity":4}'
|
||||
HTTP 409
|
||||
{"timestamp":"2026-10-21T17:07:49.685Z","status":409,"error":"Conflict","path":"/orders"}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":2,"quantity":11}'
|
||||
HTTP 400
|
||||
{"timestamp":"2026-10-21T17:07:49.721Z","status":400,"error":"Bad Request","path":"/orders"}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":99,"quantity":1}'
|
||||
HTTP 404
|
||||
{"timestamp":"2026-10-21T17:07:49.763Z","status":404,"error":"Not Found","path":"/orders"}
|
||||
|
||||
$ curl -X GET localhost:8080/api/orders
|
||||
HTTP 200
|
||||
[{"id":1,"productId":1,"productName":"Mechanical keyboard","quantity":2,"total":179.80,"currency":"EUR","status":"CONFIRMED"}]
|
||||
|
||||
--- stock after the first order ---
|
||||
$ curl -X GET localhost:8080/api/inventory/1
|
||||
HTTP 200
|
||||
{"productId":1,"quantity":18,"lowStock":false}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# One request, one trace id, four services. Lines are 'service | traceId-spanId | message' from each service's log.
|
||||
|
||||
--- POST /api/orders sent to the gateway; trace id 6ad8f16556352b627323a47cb0510c20 ---
|
||||
gateway | 6ad8f16556352b627323a47cb0510c20-7323a47cb0510c20 | gateway received POST /api/orders
|
||||
order-service | 6ad8f16556352b627323a47cb0510c20-fe6e6271c03b82ea | placing order for product 2 x1
|
||||
product-service | 6ad8f16556352b627323a47cb0510c20-a01e14c8e2434acc | looking up product 2
|
||||
inventory-service | 6ad8f16556352b627323a47cb0510c20-419570d38f840942 | reserved 1 of product 2, 7 left
|
||||
order-service | 6ad8f16556352b627323a47cb0510c20-fe6e6271c03b82ea | order 2 is CONFIRMED
|
||||
|
||||
--- a caller that already has a trace id (W3C traceparent header): the same id comes out the other end ---
|
||||
gateway | 4bf92f3577b34da6a3ce929d0e0e4736-91a3561198164d6c | gateway received GET /api/products/1
|
||||
product-service | 4bf92f3577b34da6a3ce929d0e0e4736-bce2498c85b72fc7 | looking up product 1
|
||||
@@ -0,0 +1,70 @@
|
||||
# order-service -> inventory-service behind a Resilience4j circuit breaker (4-call window, 50% threshold, 5 s open)
|
||||
|
||||
--- healthy: circuit state, then one order ---
|
||||
$ curl -X GET localhost:8080/api/orders/circuit
|
||||
HTTP 200
|
||||
{"state":"CLOSED","failedCalls":0,"notPermittedCalls":0}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
|
||||
HTTP 201
|
||||
{"id":3,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"CONFIRMED"}
|
||||
|
||||
--- switch inventory-service to failing (POST /inventory/chaos?down=true), then place 4 orders ---
|
||||
$ curl -X POST localhost:8080/api/inventory/chaos?down=true
|
||||
HTTP 200
|
||||
{"down":true}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
|
||||
HTTP 201
|
||||
{"id":4,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
|
||||
HTTP 201
|
||||
{"id":5,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
|
||||
HTTP 201
|
||||
{"id":6,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
|
||||
HTTP 201
|
||||
{"id":7,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}
|
||||
|
||||
$ curl -X GET localhost:8080/api/orders/circuit
|
||||
HTTP 200
|
||||
{"state":"OPEN","failedCalls":2,"notPermittedCalls":2}
|
||||
|
||||
--- circuit is OPEN: 2 more orders. inventory reserve calls before and after prove the calls were not even attempted ---
|
||||
$ curl -X GET localhost:8080/api/inventory/stats
|
||||
HTTP 200
|
||||
{"reserveCalls":8}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
|
||||
HTTP 201
|
||||
{"id":8,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
|
||||
HTTP 201
|
||||
{"id":9,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"PENDING_INVENTORY"}
|
||||
|
||||
$ curl -X GET localhost:8080/api/inventory/stats
|
||||
HTTP 200
|
||||
{"reserveCalls":8}
|
||||
|
||||
$ curl -X GET localhost:8080/api/orders/circuit
|
||||
HTTP 200
|
||||
{"state":"OPEN","failedCalls":2,"notPermittedCalls":4}
|
||||
|
||||
--- inventory-service recovers; wait 6 seconds (> the 5 s open window), then order again ---
|
||||
$ curl -X POST localhost:8080/api/inventory/chaos?down=false
|
||||
HTTP 200
|
||||
{"down":false}
|
||||
|
||||
$ curl -X POST localhost:8080/api/orders -d '{"productId":1,"quantity":1}'
|
||||
HTTP 201
|
||||
{"id":10,"productId":1,"productName":"Mechanical keyboard","quantity":1,"total":89.90,"currency":"EUR","status":"CONFIRMED"}
|
||||
|
||||
$ curl -X GET localhost:8080/api/orders/circuit
|
||||
HTTP 200
|
||||
{"state":"CLOSED","failedCalls":0,"notPermittedCalls":0}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# order-service started with --shop.demo.primary-builder=false (the only RestClient.Builder is the @LoadBalanced one)
|
||||
|
||||
--- the log lines that give it away ---
|
||||
org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'scopedTarget.eurekaClient': Requested bean is currently in creation: Is there an unresolvable circular reference or an as
|
||||
c.n.d.s.t.d.RedirectingEurekaHttpClient : Request execution error. endpoint=DefaultEndpoint{ serviceUrl='http://localhost:8761/eureka/}, exception=No instances available for localhost
|
||||
com.netflix.discovery.DiscoveryClient : DiscoveryClient_ORDER-SERVICE/order-service:8092 - registration failed Cannot execute request on any known server
|
||||
|
||||
--- is instance order-service:8092 in Eureka? ---
|
||||
order-service:8092 registered: False
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm.shop</groupId>
|
||||
<artifactId>shop</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>spring-boot-microservices-project</name>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<spring-cloud.version>2025.1.3</spring-cloud.version>
|
||||
</properties>
|
||||
|
||||
<modules>
|
||||
<module>config-server</module>
|
||||
<module>discovery-server</module>
|
||||
<module>gateway</module>
|
||||
<module>product-service</module>
|
||||
<module>inventory-service</module>
|
||||
<module>order-service</module>
|
||||
</modules>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-dependencies</artifactId>
|
||||
<version>${spring-cloud.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ankurm.shop</groupId>
|
||||
<artifactId>shop</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
<artifactId>product-service</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-micrometer-tracing-brave</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-tracing-bridge-brave</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.shop.product;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record Product(long id, String name, BigDecimal price) {
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ankurm.shop.product;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
class ProductController {
|
||||
private static final Logger log = LoggerFactory.getLogger(ProductController.class);
|
||||
|
||||
private final Map<Long, Product> products = Map.of(
|
||||
1L, new Product(1, "Mechanical keyboard", new BigDecimal("89.90")),
|
||||
2L, new Product(2, "USB-C dock", new BigDecimal("129.00")),
|
||||
3L, new Product(3, "27-inch monitor", new BigDecimal("249.50")));
|
||||
|
||||
@GetMapping("/products")
|
||||
List<Product> all() {
|
||||
log.info("listing {} products", products.size());
|
||||
return products.values().stream().sorted((a, b) -> Long.compare(a.id(), b.id())).toList();
|
||||
}
|
||||
|
||||
@GetMapping("/products/{id}")
|
||||
Product one(@PathVariable long id) {
|
||||
log.info("looking up product {}", id);
|
||||
Product p = products.get(id);
|
||||
if (p == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product " + id + " not found");
|
||||
}
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.shop.product;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class ProductServiceApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ProductServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
spring:
|
||||
application:
|
||||
name: product-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: 8081
|
||||
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}
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the scenarios against a running system (scripts/start.sh) and writes output/NN-*.txt.
|
||||
# Every file under output/ is the real response of a real request; nothing is typed by hand.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
GW=http://localhost:8080
|
||||
OUT=output
|
||||
mkdir -p $OUT run/tmp
|
||||
|
||||
# curl wrapper: prints the command, then "HTTP <status>" and the body.
|
||||
call() { # file method path [json-body]
|
||||
local file=$1 method=$2 path=$3 body=${4:-} args=()
|
||||
[ -n "$body" ] && args=(-H 'Content-Type: application/json' -d "$body")
|
||||
local code; code=$(curl -s -o run/tmp/body -w '%{http_code}' -X "$method" "${args[@]}" "$GW$path")
|
||||
{ printf '$ curl -X %s %s%s\n' "$method" "localhost:8080$path" "${body:+ -d '$body'}"
|
||||
printf 'HTTP %s\n' "$code"; [ -s run/tmp/body ] && { cat run/tmp/body; echo; }; echo; } >> "$file"
|
||||
}
|
||||
section() { printf -- '--- %s ---\n' "$2" >> "$1"; }
|
||||
trace_lines() { # trace id -> "service | traceId-spanId | message" for every service, in time order
|
||||
for s in gateway order-service product-service inventory-service; do
|
||||
grep -h "$1" run/logs/$s.log
|
||||
done | sort | sed -E 's/^[^ ]+ +[A-Z]+ [0-9]+ --- \[([^]]*)\] \[[^]]*\] \[([^]]*)\] [^ ]+ *: (.*)$/\1 | \2 | \3/'
|
||||
}
|
||||
|
||||
# The gateway and its load balancer refresh their copy of the registry every 30 seconds,
|
||||
# so the first requests after startup fail (the gateway answers 500: "Unable to find instance"). Wait until the gateway routes work and the order service can reach both of its dependencies.
|
||||
echo "waiting for the gateway to see all services..."
|
||||
R=$OUT/00-startup-race.txt; : > $R
|
||||
echo "# GET /api/orders/ready through the gateway, every 2 seconds, starting the moment the last service reported healthy." >> $R
|
||||
echo "# 200 means the gateway can route to the order service AND the order service can reach product and inventory." >> $R
|
||||
echo >> $R
|
||||
for path in /api/products/1 /api/inventory/1 /api/orders/ready; do
|
||||
start=$(date +%s)
|
||||
for _ in $(seq 1 60); do
|
||||
code=$(curl -s -o run/tmp/body -w '%{http_code}' $GW$path)
|
||||
printf '%-20s t+%2ss HTTP %s %s\n' "$path" "$(( $(date +%s) - start ))" "$code" "$(head -c 90 run/tmp/body | tr -d '\n')" >> $R
|
||||
[ "$code" = 200 ] && break
|
||||
sleep 2
|
||||
done
|
||||
done
|
||||
|
||||
# ---- 01 config server
|
||||
F=$OUT/01-config-server.txt; : > $F
|
||||
echo "# What the config server hands out (port 8888). The services fetch this at startup." >> $F; echo >> $F
|
||||
section $F "order-service: which files were used and the values"
|
||||
curl -s localhost:8888/order-service/default | python3 -c '
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print("name:", d["name"], " profiles:", d["profiles"])
|
||||
for ps in d["propertySources"]:
|
||||
print("source:", ps["name"].split("/")[-1])
|
||||
for k, v in ps["source"].items():
|
||||
print(" ", k, "=", v)' >> $F
|
||||
echo >> $F
|
||||
section $F "the same value as the order service itself reports it (GET /api/orders/config through the gateway)"
|
||||
call $F GET /api/orders/config
|
||||
|
||||
# ---- 02 registry (a service shows up in Eureka after its first registration, which can lag its startup by a few seconds)
|
||||
for _ in $(seq 1 60); do
|
||||
n=$(curl -s -H 'Accept: application/json' localhost:8761/eureka/apps | python3 -c 'import sys,json; print(sum(len(a["instance"]) for a in json.load(sys.stdin)["applications"]["application"]))' 2>/dev/null || echo 0)
|
||||
[ "$n" -ge 4 ] && break
|
||||
sleep 2
|
||||
done
|
||||
F=$OUT/02-eureka-registry.txt; : > $F
|
||||
echo "# What is registered in Eureka (port 8761)" >> $F; echo >> $F
|
||||
curl -s -H 'Accept: application/json' localhost:8761/eureka/apps | python3 -c '
|
||||
import sys, json
|
||||
for a in sorted(json.load(sys.stdin)["applications"]["application"], key=lambda a: a["name"]):
|
||||
for i in a["instance"]:
|
||||
print("%-18s instance=%-22s status=%s" % (a["name"], i["instanceId"], i["status"]))' >> $F
|
||||
|
||||
# ---- 03 requests through the gateway
|
||||
F=$OUT/03-gateway-requests.txt; : > $F
|
||||
echo "# Requests through the gateway (port 8080). The client only ever talks to 8080." >> $F; echo >> $F
|
||||
section $F "products"
|
||||
call $F GET /api/products
|
||||
call $F GET /api/products/2
|
||||
call $F GET /api/products/99
|
||||
section $F "stock"
|
||||
call $F GET /api/inventory/3
|
||||
section $F "orders"
|
||||
call $F POST /api/orders '{"productId":1,"quantity":2}'
|
||||
call $F POST /api/orders '{"productId":3,"quantity":4}'
|
||||
call $F POST /api/orders '{"productId":2,"quantity":11}'
|
||||
call $F POST /api/orders '{"productId":99,"quantity":1}'
|
||||
call $F GET /api/orders
|
||||
section $F "stock after the first order"
|
||||
call $F GET /api/inventory/1
|
||||
|
||||
# ---- 04 tracing
|
||||
F=$OUT/04-trace-propagation.txt; : > $F
|
||||
echo "# One request, one trace id, four services. Lines are 'service | traceId-spanId | message' from each service's log." >> $F; echo >> $F
|
||||
curl -s -o /dev/null -X POST -H 'Content-Type: application/json' -d '{"productId":2,"quantity":1}' $GW/api/orders
|
||||
sleep 1
|
||||
TID=$(grep -h "placing order for product 2" run/logs/order-service.log | tail -1 | sed -E 's/.*\[([0-9a-f]{32})-[0-9a-f]{16}\].*/\1/')
|
||||
section $F "POST /api/orders sent to the gateway; trace id $TID"
|
||||
trace_lines "$TID" >> $F
|
||||
echo >> $F
|
||||
section $F "a caller that already has a trace id (W3C traceparent header): the same id comes out the other end"
|
||||
CALLER=4bf92f3577b34da6a3ce929d0e0e4736
|
||||
curl -s -o /dev/null -H "traceparent: 00-$CALLER-00f067aa0ba902b7-01" $GW/api/products/1
|
||||
sleep 1
|
||||
trace_lines "$CALLER" >> $F
|
||||
|
||||
# ---- 05 circuit breaker
|
||||
F=$OUT/05-circuit-breaker.txt; : > $F
|
||||
echo "# order-service -> inventory-service behind a Resilience4j circuit breaker (4-call window, 50% threshold, 5 s open)" >> $F; echo >> $F
|
||||
section $F "healthy: circuit state, then one order"
|
||||
call $F GET /api/orders/circuit
|
||||
call $F POST /api/orders '{"productId":1,"quantity":1}'
|
||||
section $F "switch inventory-service to failing (POST /inventory/chaos?down=true), then place 4 orders"
|
||||
call $F POST '/api/inventory/chaos?down=true'
|
||||
for _ in 1 2 3 4; do call $F POST /api/orders '{"productId":1,"quantity":1}'; done
|
||||
call $F GET /api/orders/circuit
|
||||
section $F "circuit is OPEN: 2 more orders. inventory reserve calls before and after prove the calls were not even attempted"
|
||||
call $F GET /api/inventory/stats
|
||||
call $F POST /api/orders '{"productId":1,"quantity":1}'
|
||||
call $F POST /api/orders '{"productId":1,"quantity":1}'
|
||||
call $F GET /api/inventory/stats
|
||||
call $F GET /api/orders/circuit
|
||||
section $F "inventory-service recovers; wait 6 seconds (> the 5 s open window), then order again"
|
||||
call $F POST '/api/inventory/chaos?down=false'
|
||||
sleep 6
|
||||
call $F POST /api/orders '{"productId":1,"quantity":1}'
|
||||
call $F GET /api/orders/circuit
|
||||
echo "demo finished; files:"; ls -1 $OUT
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproduces the one trap that costs the most time: a load-balanced RestClient.Builder that Eureka's own client picks up.
|
||||
# Needs the system running (scripts/start.sh). Starts a second order-service on :8092 WITHOUT the @Primary plain builder,
|
||||
# captures what it logs and whether it registered, then stops it. Writes output/06-eureka-lb-trap.txt.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
unset JAVA_TOOL_OPTIONS
|
||||
F=output/06-eureka-lb-trap.txt
|
||||
LOG=run/logs/order-service-broken.log
|
||||
nohup java -Xmx200m -XX:+UseSerialGC -jar order-service/target/order-service-1.0.0.jar \
|
||||
--server.port=8092 --shop.demo.primary-builder=false > $LOG 2>&1 &
|
||||
echo $! > run/order-service-broken.pid
|
||||
sleep 30
|
||||
{
|
||||
echo "# order-service started with --shop.demo.primary-builder=false (the only RestClient.Builder is the @LoadBalanced one)"
|
||||
echo
|
||||
echo "--- the log lines that give it away ---"
|
||||
grep -m1 "BeanCurrentlyInCreationException" $LOG | sed -E 's/^[^ ]+ +[A-Z]+ [0-9]+ --- \[[^]]*\] \[[^]]*\] \[[^]]*\] //' | cut -c1-220
|
||||
grep -m1 "No instances available for localhost" $LOG | sed -E 's/^[^ ]+ +[A-Z]+ [0-9]+ --- \[[^]]*\] \[[^]]*\] \[[^]]*\] //' | cut -c1-220
|
||||
grep -m1 "registration failed" $LOG | sed -E 's/^[^ ]+ +[A-Z]+ [0-9]+ --- \[[^]]*\] \[[^]]*\] \[[^]]*\] //' | cut -c1-220
|
||||
echo
|
||||
echo "--- is instance order-service:8092 in Eureka? ---"
|
||||
curl -s -H 'Accept: application/json' localhost:8761/eureka/apps | python3 -c '
|
||||
import sys, json
|
||||
ids = [i["instanceId"] for a in json.load(sys.stdin)["applications"]["application"] for i in a["instance"]]
|
||||
print("order-service:8092 registered:", "order-service:8092" in ids)'
|
||||
} > $F
|
||||
kill "$(cat run/order-service-broken.pid)" 2>/dev/null; rm -f run/order-service-broken.pid
|
||||
cat $F
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build, start all six services, run the scenarios, stop everything. Regenerates output/.
|
||||
# Needs JDK 21+, Maven 3.9+, curl and python3. No Docker needed.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -B -q -DskipTests package
|
||||
trap 'scripts/stop.sh' EXIT
|
||||
scripts/start.sh
|
||||
scripts/demo.sh
|
||||
scripts/gotchas.sh
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Starts the six services as plain jars, in dependency order, and waits until each one answers.
|
||||
# Logs: run/logs/<service>.log PIDs: run/<service>.pid (stop with scripts/stop.sh)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p run/logs
|
||||
unset JAVA_TOOL_OPTIONS
|
||||
|
||||
start() { # service port
|
||||
local name=$1 port=$2
|
||||
nohup java -Xmx200m -XX:+UseSerialGC -jar "$name/target/$name-1.0.0.jar" > "run/logs/$name.log" 2>&1 &
|
||||
echo $! > "run/$name.pid"
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -fs "http://localhost:$port/actuator/health" >/dev/null 2>&1; then echo "$name up on :$port"; return; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "$name did not come up; see run/logs/$name.log" >&2; exit 1
|
||||
}
|
||||
|
||||
# the config server must be first: the others read their config from it at startup
|
||||
start config-server 8888
|
||||
start discovery-server 8761
|
||||
start product-service 8081
|
||||
start inventory-service 8083
|
||||
start order-service 8082
|
||||
start gateway 8080
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stops whatever scripts/start.sh started, using the PID files (never pkill -f).
|
||||
cd "$(dirname "$0")/.."
|
||||
for f in run/*.pid; do
|
||||
[ -e "$f" ] || continue
|
||||
kill "$(cat "$f")" 2>/dev/null || true
|
||||
rm -f "$f"
|
||||
done
|
||||
echo stopped
|
||||
Reference in New Issue
Block a user