diff --git a/README.md b/README.md index 69eb6438..12ef143c 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,15 @@ Down below is an exhaustive list of all the sample: 6. [Sequencing Policy](sequencing-policy/README.md) - Sample showing how to set up a custom `SequencingPolicy` to adjust the event sequence for a `PooledStreamingEventProcessor`. 7. [Snapshots](snapshots/README.md) - Sample showing how to configure event-sourced entity snapshotting. -8. [Stateful Event Handler](stateful-event-handler/README.md) - Sample showing a stateful event handler that can be +8. [Spring Cloud Command Routing](spring-cloud-command-routing/README.md) - Sample showing how the Spring Cloud + connector routes commands between the instances of an application, without Axon Server. +9. [Stateful Event Handler](stateful-event-handler/README.md) - Sample showing a stateful event handler that can be used as a replacement for sagas. -9. [Subscription Query - REST](subscription-query-rest/README.md) - Sample showing how to use Axon's subscription query +10. [Subscription Query - REST](subscription-query-rest/README.md) - Sample showing how to use Axon's subscription query cleanly in a REST-based controller. -10. [Subscription Query - Streaming](subscription-query-streaming/README.md) - Sample showing how to use Axon's +11. [Subscription Query - Streaming](subscription-query-streaming/README.md) - Sample showing how to use Axon's subscription query cleanly in a streaming-based controller. -11. [Workflow Saga](workflow-saga/README.md) - Sample showing a Saga-like process modelled with the AxoniQ Workflow +12. [Workflow Saga](workflow-saga/README.md) - Sample showing a Saga-like process modelled with the AxoniQ Workflow Engine. ## Topics now covered by the Axon Framework project itself diff --git a/pom.xml b/pom.xml index 4cfc106b..84b2b8c4 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ serialization-avro sequencing-policy snapshots + spring-cloud-command-routing stateful-event-handler subscription-query-rest subscription-query-streaming diff --git a/spring-cloud-command-routing/README.md b/spring-cloud-command-routing/README.md new file mode 100644 index 00000000..104037d5 --- /dev/null +++ b/spring-cloud-command-routing/README.md @@ -0,0 +1,61 @@ +# Spring Cloud Command Routing + +This sample shows how the Axoniq Framework Spring Cloud connector distributes commands between the instances of an +application, without Axon Server in between. + +Every instance runs the same application. It registers with a Spring Cloud discovery service (Consul, here) and +handles one command, `SayHello`, answering with the id of the instance that handled it: + +``` +Hello alice, from spring-cloud-command-routing-8082 +``` + +Start several instances and send requests to any of them, and you can see how commands are routed: + +1. `SayHello` declares `@Command(routingKey = "name")`. The connector hashes the routing key onto a ring of all + instances that registered with discovery, so every greeting for the same name is handled by the same instance, no + matter which instance received the request. +2. Different names are spread over the instances. +3. When an instance stops, only the names it handled move to another instance. The others stay where they were. + +There is no configuration specific to Axon: adding `axoniq-springcloud` next to a Spring Cloud discovery +implementation, such as `spring-cloud-starter-consul-discovery`, and a servlet web stack +(`spring-boot-starter-web`), is enough. Instances call each other over HTTP, on the port they registered with. + +### Running the application + +1. Start Consul. A `docker-compose.yml` is provided for this: + + ```bash + docker compose up -d + ``` + +2. Start three instances of the application, each on its own port, in separate terminals: + + ```bash + PORT=8081 ../mvnw spring-boot:run + PORT=8082 ../mvnw spring-boot:run + PORT=8083 ../mvnw spring-boot:run + ``` + + The Consul UI at [http://localhost:8500](http://localhost:8500) lists the instances as they register. + +3. Send greetings through different instances, using the `requests.http` file or `curl`: + + ```bash + for port in 8081 8082 8083; do curl localhost:$port/hello/alice; echo; done + for name in alice bob carol dave erin; do curl localhost:8081/hello/$name; echo; done + ``` + + The first loop answers from the same instance three times. The second shows names spread over the instances. + +4. Stop one of the instances, wait a few seconds for discovery to notice, and send the same greetings again. + +### Spring Boot 3 + +The sample builds against Spring Boot 4 and Spring Cloud 2025.1 by default. To run it against Spring Boot 3.5 and +Spring Cloud 2025.0 instead, enable the `spring-boot-3` profile: + +```bash +PORT=8081 ../mvnw -Pspring-boot-3 spring-boot:run +``` diff --git a/spring-cloud-command-routing/docker-compose.yml b/spring-cloud-command-routing/docker-compose.yml new file mode 100644 index 00000000..f4debe18 --- /dev/null +++ b/spring-cloud-command-routing/docker-compose.yml @@ -0,0 +1,5 @@ +services: + consul: + image: hashicorp/consul + ports: + - '8500:8500' diff --git a/spring-cloud-command-routing/pom.xml b/spring-cloud-command-routing/pom.xml new file mode 100644 index 00000000..43057b4e --- /dev/null +++ b/spring-cloud-command-routing/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + io.axoniq + code-samples + 0.0.2-SNAPSHOT + + + spring-cloud-command-routing + + Spring Cloud Command Routing + + Sample application showing how the Axoniq Framework Spring Cloud connector routes commands between the + instances of an application, without Axon Server. + + + + + 5.4.0-SNAPSHOT + + 2025.1.3 + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + io.axoniq.framework + axoniq-spring-boot-starter + + + io.axoniq.framework + axoniq-springcloud + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.cloud + spring-cloud-starter-consul-discovery + + + + + + + spring-boot-3 + + 3.5.16 + 2025.0.3 + + + + + + com.fasterxml.jackson + jackson-bom + 2.22.2 + pom + import + + + + + + diff --git a/spring-cloud-command-routing/requests.http b/spring-cloud-command-routing/requests.http new file mode 100644 index 00000000..9eb3fb02 --- /dev/null +++ b/spring-cloud-command-routing/requests.http @@ -0,0 +1,11 @@ +### Greet alice through the first instance + +GET http://localhost:8081/hello/alice + +### Greet alice through the second instance: the same instance answers + +GET http://localhost:8082/hello/alice + +### Greet bob through the first instance: possibly another instance answers + +GET http://localhost:8081/hello/bob diff --git a/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloCommandHandler.java b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloCommandHandler.java new file mode 100644 index 00000000..b6ffdfd5 --- /dev/null +++ b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloCommandHandler.java @@ -0,0 +1,26 @@ +package io.axoniq.springcloudcommandrouting; + +import org.axonframework.messaging.commandhandling.annotation.CommandHandler; +import org.springframework.cloud.client.serviceregistry.Registration; +import org.springframework.stereotype.Component; + +/** + * Answers {@link SayHello} with a greeting naming the instance that handled it. + *

+ * Every instance runs this handler. Which one handles a given command is decided by the connector's routing, so the + * instance id in the reply shows where the command went. + */ +@Component +class HelloCommandHandler { + + private final String instanceId; + + HelloCommandHandler(Registration registration) { + this.instanceId = registration.getInstanceId(); + } + + @CommandHandler + String handle(SayHello command) { + return "Hello " + command.name() + ", from " + instanceId; + } +} diff --git a/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloController.java b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloController.java new file mode 100644 index 00000000..03f10782 --- /dev/null +++ b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/HelloController.java @@ -0,0 +1,26 @@ +package io.axoniq.springcloudcommandrouting; + +import org.axonframework.messaging.commandhandling.gateway.CommandGateway; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.util.concurrent.CompletableFuture; + +/** + * Sends a {@link SayHello} command for every request, and replies with whatever the handling instance answered. + */ +@RestController +class HelloController { + + private final CommandGateway commandGateway; + + HelloController(CommandGateway commandGateway) { + this.commandGateway = commandGateway; + } + + @GetMapping("/hello/{name}") + CompletableFuture hello(@PathVariable String name) { + return commandGateway.send(new SayHello(name), String.class); + } +} diff --git a/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SayHello.java b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SayHello.java new file mode 100644 index 00000000..318c8468 --- /dev/null +++ b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SayHello.java @@ -0,0 +1,16 @@ +package io.axoniq.springcloudcommandrouting; + +import org.axonframework.messaging.commandhandling.annotation.Command; + +/** + * Asks to greet someone. + *

+ * The {@code routingKey} tells the Spring Cloud connector to route by {@code name}: every command for the same name is + * handled by the same instance, no matter which instance it was sent from. + * + * @param name the name of whom to greet, and the key the command is routed by + */ +@Command(routingKey = "name") +public record SayHello(String name) { + +} diff --git a/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SpringCloudCommandRoutingApplication.java b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SpringCloudCommandRoutingApplication.java new file mode 100644 index 00000000..0abd2758 --- /dev/null +++ b/spring-cloud-command-routing/src/main/java/io/axoniq/springcloudcommandrouting/SpringCloudCommandRoutingApplication.java @@ -0,0 +1,15 @@ +package io.axoniq.springcloudcommandrouting; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Starts one instance of the application. Start several, each on its own port, to form a cluster. + */ +@SpringBootApplication +public class SpringCloudCommandRoutingApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringCloudCommandRoutingApplication.class, args); + } +} diff --git a/spring-cloud-command-routing/src/main/resources/application.properties b/spring-cloud-command-routing/src/main/resources/application.properties new file mode 100644 index 00000000..7af8e29e --- /dev/null +++ b/spring-cloud-command-routing/src/main/resources/application.properties @@ -0,0 +1,10 @@ +spring.application.name=spring-cloud-command-routing +server.port=${PORT:8080} + +# Every instance registers with Consul under the same service name, and so joins the same cluster. +# The instance id has to be unique per instance, and is what the replies name. +spring.cloud.consul.host=localhost +spring.cloud.consul.port=8500 +spring.cloud.consul.discovery.instance-id=${spring.application.name}-${server.port} +# This sample has no actuator, so there is no health endpoint for Consul to check. +spring.cloud.consul.discovery.register-health-check=false