From 1c1a1dfaffc0a8311a3b7a5754ce3b116f7bcbf6 Mon Sep 17 00:00:00 2001 From: Ben Runchey Date: Thu, 11 Apr 2024 21:50:51 -0500 Subject: [PATCH 1/5] Initial work to have data automatically generated. To make this happen in "monolith" mode RentalApplication w/ the "simulator" profile active. If running microservices, add the "simulator" profile to the UserInterface microservice. --- compose.yaml | 2 +- .../payment/PaymentStatusNamedQueries.java | 6 + .../rental/BikeStatusNamedQueries.java | 7 + .../rental/CountOfBikesByTypeQuery.java | 4 + frontend/src/pages/IndexPage.vue | 4 +- .../rental/ui/UserInterfaceApplication.java | 2 + .../application-simulator.properties | 8 + .../bikerental/rental/RentalApplication.java | 2 + .../rental/query/BikeStatusProjection.java | 20 ++- .../rental/query/BikeStatusRepository.java | 1 + .../rental/ui/BikeRentalDataGenerator.java | 141 ++++++++++++++++++ .../rental/ui/RentalController.java | 112 ++------------ .../demo/bikerental/rental/ui/Simulator.java | 102 +++++++++++++ .../rental/ui/SimulatorConfigController.java | 33 ++++ .../application-simulator.properties | 8 + 15 files changed, 342 insertions(+), 110 deletions(-) create mode 100644 core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/payment/PaymentStatusNamedQueries.java create mode 100644 core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/BikeStatusNamedQueries.java create mode 100644 core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/CountOfBikesByTypeQuery.java create mode 100644 microservices/rental-ui/src/main/resources/application-simulator.properties create mode 100644 rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/BikeRentalDataGenerator.java create mode 100644 rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/Simulator.java create mode 100644 rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/SimulatorConfigController.java create mode 100644 rental/src/main/resources/application-simulator.properties diff --git a/compose.yaml b/compose.yaml index 29b2242..045edee 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: axonserver: #<.> - image: 'axoniq/axonserver:latest-dev' # <.> + image: 'axoniq/axonserver:latest' # <.> environment: # <.> - 'AXONIQ_AXONSERVER_STANDALONE=TRUE' ports: # <.> diff --git a/core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/payment/PaymentStatusNamedQueries.java b/core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/payment/PaymentStatusNamedQueries.java new file mode 100644 index 0000000..f7895e9 --- /dev/null +++ b/core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/payment/PaymentStatusNamedQueries.java @@ -0,0 +1,6 @@ +package io.axoniq.demo.bikerental.coreapi.payment; + +public class PaymentStatusNamedQueries { + public static final String GET_PAYMENT_ID = "getPaymentId"; + public static final String GET_ALL_PAYMENTS = "getAllPayments"; +} diff --git a/core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/BikeStatusNamedQueries.java b/core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/BikeStatusNamedQueries.java new file mode 100644 index 0000000..2b769c5 --- /dev/null +++ b/core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/BikeStatusNamedQueries.java @@ -0,0 +1,7 @@ +package io.axoniq.demo.bikerental.coreapi.rental; + +public class BikeStatusNamedQueries { + public static final String FIND_ALL = "findAll"; + public static final String FIND_ONE = "findOne"; + public static final String FIND_AVAILABLE = "findAvailable"; +} diff --git a/core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/CountOfBikesByTypeQuery.java b/core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/CountOfBikesByTypeQuery.java new file mode 100644 index 0000000..80e15ad --- /dev/null +++ b/core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/CountOfBikesByTypeQuery.java @@ -0,0 +1,4 @@ +package io.axoniq.demo.bikerental.coreapi.rental; + +public record CountOfBikesByTypeQuery(String bikeType) { +} diff --git a/frontend/src/pages/IndexPage.vue b/frontend/src/pages/IndexPage.vue index 7d7cb53..09bf151 100644 --- a/frontend/src/pages/IndexPage.vue +++ b/frontend/src/pages/IndexPage.vue @@ -71,12 +71,12 @@ import URL from '../settings' const newBikeParams = ref({ count: 10, - type: "mountainbike" + type: "mountain bike" }) const rentalsParams = ref({ - bikeType : "mountainbike", + bikeType : "mountain bike", loops : "64", concurrency : "8", abandonPaymentFactor: 0, diff --git a/microservices/rental-ui/src/main/java/io/axoniq/demo/bikerental/rental/ui/UserInterfaceApplication.java b/microservices/rental-ui/src/main/java/io/axoniq/demo/bikerental/rental/ui/UserInterfaceApplication.java index b0c46e8..9b2ab3d 100644 --- a/microservices/rental-ui/src/main/java/io/axoniq/demo/bikerental/rental/ui/UserInterfaceApplication.java +++ b/microservices/rental-ui/src/main/java/io/axoniq/demo/bikerental/rental/ui/UserInterfaceApplication.java @@ -4,8 +4,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class UserInterfaceApplication { public static void main(String[] args) { diff --git a/microservices/rental-ui/src/main/resources/application-simulator.properties b/microservices/rental-ui/src/main/resources/application-simulator.properties new file mode 100644 index 0000000..a068482 --- /dev/null +++ b/microservices/rental-ui/src/main/resources/application-simulator.properties @@ -0,0 +1,8 @@ +inventory.size=100 +inventory.bikeType=mountain bike + +rentalSimulation.bikeType=mountain bike +rentalSimulation.loops=64 +rentalSimulation.concurrency=8 +rentalSimulation.abandonPaymentFactor=0 +rentalSimulation.delayBetweenLoops=1000 \ No newline at end of file diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/RentalApplication.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/RentalApplication.java index b5aa06c..c9695e9 100644 --- a/rental/src/main/java/io/axoniq/demo/bikerental/rental/RentalApplication.java +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/RentalApplication.java @@ -16,12 +16,14 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @EntityScan(basePackageClasses = {BikeStatus.class, SagaEntry.class, TokenEntry.class}) @SpringBootApplication +@EnableScheduling public class RentalApplication { public static void main(String[] args) { diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java index 6f3a817..eb0f5b7 100644 --- a/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java @@ -1,12 +1,6 @@ package io.axoniq.demo.bikerental.rental.query; -import io.axoniq.demo.bikerental.coreapi.rental.BikeInUseEvent; -import io.axoniq.demo.bikerental.coreapi.rental.BikeRegisteredEvent; -import io.axoniq.demo.bikerental.coreapi.rental.BikeRequestedEvent; -import io.axoniq.demo.bikerental.coreapi.rental.BikeReturnedEvent; -import io.axoniq.demo.bikerental.coreapi.rental.BikeStatus; -import io.axoniq.demo.bikerental.coreapi.rental.RentalStatus; -import io.axoniq.demo.bikerental.coreapi.rental.RequestRejectedEvent; +import io.axoniq.demo.bikerental.coreapi.rental.*; import org.axonframework.eventhandling.EventHandler; import org.axonframework.queryhandling.QueryHandler; import org.axonframework.queryhandling.QueryUpdateEmitter; @@ -67,7 +61,6 @@ public void on(BikeReturnedEvent event) { updateEmitter.emit(q -> "findAll".equals(q.getQueryName()), bs); updateEmitter.emit(String.class, event.bikeId()::equals, bs); }); - } @EventHandler @@ -83,18 +76,23 @@ public void on(RequestRejectedEvent event) { }); } - @QueryHandler(queryName = "findAll") + @QueryHandler(queryName = BikeStatusNamedQueries.FIND_ALL) public Iterable findAll() { return bikeStatusRepository.findAll(); } - @QueryHandler(queryName = "findAvailable") + @QueryHandler(queryName = BikeStatusNamedQueries.FIND_AVAILABLE) public Iterable findAvailable(String bikeType) { return bikeStatusRepository.findAllByBikeTypeAndStatus(bikeType, RentalStatus.AVAILABLE); } - @QueryHandler(queryName = "findOne") + @QueryHandler(queryName = BikeStatusNamedQueries.FIND_ONE) public BikeStatus findOne(String bikeId) { return bikeStatusRepository.findById(bikeId).orElse(null); } + + @QueryHandler + public long countOfBikesByType(CountOfBikesByTypeQuery query) { + return bikeStatusRepository.countBikeStatusesByBikeType(query.bikeType()); + } } diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusRepository.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusRepository.java index 5c51473..14cd423 100644 --- a/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusRepository.java +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusRepository.java @@ -11,5 +11,6 @@ public interface BikeStatusRepository extends JpaRepository { List findAllByBikeTypeAndStatus(String bikeType, RentalStatus status); + long countBikeStatusesByBikeType(String bikeType); } diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/BikeRentalDataGenerator.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/BikeRentalDataGenerator.java new file mode 100644 index 0000000..1f57bdf --- /dev/null +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/BikeRentalDataGenerator.java @@ -0,0 +1,141 @@ +package io.axoniq.demo.bikerental.rental.ui; + +import io.axoniq.demo.bikerental.coreapi.payment.ConfirmPaymentCommand; +import io.axoniq.demo.bikerental.coreapi.rental.*; +import org.axonframework.commandhandling.gateway.CommandGateway; +import org.axonframework.messaging.responsetypes.ResponseTypes; +import org.axonframework.queryhandling.QueryGateway; +import org.axonframework.queryhandling.SubscriptionQueryResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.RequestParam; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +@Component +public class BikeRentalDataGenerator { + private static final List RENTERS = Arrays.asList("Allard", "Steven", "Josh", "David", "Marc", "Sara", "Milan", "Jeroen", "Marina", "Jeannot"); + private static final List LOCATIONS = Arrays.asList("Amsterdam", "Paris", "Vilnius", "Barcelona", "London", "New York", "Toronto", "Berlin", "Milan", "Rome", "Belgrade"); + + + private final CommandGateway commandGateway; + private final QueryGateway queryGateway; + + Logger logger = LoggerFactory.getLogger(BikeRentalDataGenerator.class); + + public BikeRentalDataGenerator(CommandGateway commandGateway, QueryGateway queryGateway) { + this.commandGateway = commandGateway; + this.queryGateway = queryGateway; + } + + + public String randomRenter() { + return RENTERS.get(ThreadLocalRandom.current().nextInt(RENTERS.size())); + } + + public String randomLocation() { + return LOCATIONS.get(ThreadLocalRandom.current().nextInt(LOCATIONS.size())); + } + + public CompletableFuture generateBikes(int bikeCount, String bikeType) { + CompletableFuture all = CompletableFuture.completedFuture(null); + for (int i = 0; i < bikeCount; i++) { + all = CompletableFuture.allOf(all, + commandGateway.send(new RegisterBikeCommand(UUID.randomUUID().toString(), bikeType, randomLocation()))); + } + return all; + } + + + public Flux generateRentals(String bikeType, + int loops, + int concurrency, + int abandonPaymentFactor, + int delay) { + + return this.internalGenerateRentals(bikeType, loops, concurrency, abandonPaymentFactor, delay); + + } + + private Flux internalGenerateRentals(String bikeType, + int loops, + int concurrency, + int abandonPaymentFactor, + int delay) { + + return Flux.range(0, loops) + .flatMap(j -> executeRentalCycle(bikeType, randomRenter(), abandonPaymentFactor, delay) + .map(r -> "OK - Rented, Payed and Returned\n") + .onErrorResume(e -> Mono.just("Not ok: " + e.getMessage() + "\n")), + concurrency); + + } + + private Mono executeRentalCycle(String bikeType, String renter, int abandonPaymentFactor, int delay) { + CompletableFuture result = selectRandomAvailableBike(bikeType) + .thenCompose(bikeId -> commandGateway.send(new RequestBikeCommand(bikeId, renter)) + .thenComposeAsync(paymentRef -> executePayment(bikeId, + (String) paymentRef, + abandonPaymentFactor), + CompletableFuture.delayedExecutor(randomDelay( + delay), TimeUnit.MILLISECONDS)) + .thenCompose(r -> whenBikeUnlocked(bikeId)) + .thenComposeAsync(r -> commandGateway.send(new ReturnBikeCommand( + bikeId, + randomLocation())), + CompletableFuture.delayedExecutor(randomDelay( + delay), TimeUnit.MILLISECONDS)) + .thenApply(r -> bikeId)); + return Mono.fromFuture(result); + } + + private CompletableFuture selectRandomAvailableBike(String bikeType) { + return queryGateway.query("findAvailable", bikeType, ResponseTypes.multipleInstancesOf(BikeStatus.class)) + .thenApply(this::pickRandom) + .thenApply(BikeStatus::getBikeId); + } + + private CompletableFuture executePayment(String bikeId, String paymentRef, int abandonPaymentFactor) { + if (abandonPaymentFactor > 0 && ThreadLocalRandom.current().nextInt(abandonPaymentFactor) == 0) { + return CompletableFuture.failedFuture(new IllegalStateException("Customer refused to pay")); + } + SubscriptionQueryResult queryResult = queryGateway.subscriptionQuery("getPaymentId", + paymentRef, + String.class, + String.class); + return queryResult.initialResult().concatWith(queryResult.updates()) + .filter(Objects::nonNull) + .doOnNext(n -> queryResult.close()) + .next() + .flatMap(paymentId -> Mono.fromFuture(commandGateway.send(new ConfirmPaymentCommand(paymentId)))) + .map(o -> bikeId) + .toFuture(); + } + private CompletableFuture whenBikeUnlocked(String bikeId) { + SubscriptionQueryResult queryResult = queryGateway.subscriptionQuery(BikeStatusNamedQueries.FIND_ONE, bikeId, BikeStatus.class, BikeStatus.class); + return queryResult.initialResult().concatWith(queryResult.updates()) + .any(status -> status.getStatus() == RentalStatus.RENTED) + .map(s -> bikeId) + .doOnNext(n -> queryResult.close()) + .toFuture(); + } + private int randomDelay(int delay) { + if (delay <= 0) { + return 0; + } + return ThreadLocalRandom.current().nextInt(delay - (delay >> 2), delay + delay + (delay >> 2)); + } + + private T pickRandom(List source) { + return source.get(ThreadLocalRandom.current().nextInt(source.size())); + } +} diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/RentalController.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/RentalController.java index 7b45b46..c0090cf 100644 --- a/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/RentalController.java +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/RentalController.java @@ -2,12 +2,8 @@ import io.axoniq.demo.bikerental.coreapi.payment.ConfirmPaymentCommand; import io.axoniq.demo.bikerental.coreapi.payment.PaymentStatus; -import io.axoniq.demo.bikerental.coreapi.rental.BikeStatus; -import io.axoniq.demo.bikerental.coreapi.rental.RegisterBikeCommand; -import io.axoniq.demo.bikerental.coreapi.rental.RentalStatus; -import io.axoniq.demo.bikerental.coreapi.rental.RequestBikeCommand; -import io.axoniq.demo.bikerental.coreapi.rental.ReturnBikeCommand; -import org.axonframework.commandhandling.CommandBus; +import io.axoniq.demo.bikerental.coreapi.payment.PaymentStatusNamedQueries; +import io.axoniq.demo.bikerental.coreapi.rental.*; import org.axonframework.commandhandling.gateway.CommandGateway; import org.axonframework.messaging.responsetypes.ResponseTypes; import org.axonframework.queryhandling.QueryGateway; @@ -15,17 +11,12 @@ import org.springframework.http.codec.ServerSentEvent; import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; -import reactor.core.publisher.FluxSink; import reactor.core.publisher.Mono; -import java.time.Duration; -import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; @CrossOrigin(origins = "*", maxAge = 3600) // tag::RentalControllerClassDefinition[] @@ -36,8 +27,6 @@ public class RentalController { public static final String FIND_ALL_QUERY = "findAll"; public static final String FIND_ONE_QUERY = "findOne"; - private static final List RENTERS = Arrays.asList("Allard", "Steven", "Josh", "David", "Marc", "Sara", "Milan", "Jeroen", "Marina", "Jeannot"); - private static final List LOCATIONS = Arrays.asList("Amsterdam", "Paris", "Vilnius", "Barcelona", "London", "New York", "Toronto", "Berlin", "Milan", "Rome", "Belgrade"); //tag::ControllerInitialization[] //tag::BusGateways[] @@ -46,9 +35,12 @@ public class RentalController { private final QueryGateway queryGateway; // <.> //end::BusGateways[] - public RentalController(CommandGateway commandGateway, QueryGateway queryGateway) { // <.> + private final BikeRentalDataGenerator bikeRentalDataGenerator; + + public RentalController(CommandGateway commandGateway, QueryGateway queryGateway, BikeRentalDataGenerator bikeRentalDataGenerator) { // <.> this.commandGateway = commandGateway; this.queryGateway = queryGateway; + this.bikeRentalDataGenerator = bikeRentalDataGenerator; } //end::ControllerInitialization[] @@ -78,7 +70,7 @@ public CompletableFuture generateBikes(@RequestParam("count") int bikeCoun CompletableFuture all = CompletableFuture.completedFuture(null); for (int i = 0; i < bikeCount; i++) { all = CompletableFuture.allOf(all, - commandGateway.send(new RegisterBikeCommand(UUID.randomUUID().toString(), bikeType, randomLocation()))); + commandGateway.send(new RegisterBikeCommand(UUID.randomUUID().toString(), bikeType, this.bikeRentalDataGenerator.randomLocation()))); } return all; } @@ -86,12 +78,12 @@ public CompletableFuture generateBikes(@RequestParam("count") int bikeCoun //end::generateBikes[] @GetMapping("/bikes") public CompletableFuture> findAll() { - return queryGateway.query(FIND_ALL_QUERY, null, ResponseTypes.multipleInstancesOf(BikeStatus.class)); + return queryGateway.query(BikeStatusNamedQueries.FIND_ALL, null, ResponseTypes.multipleInstancesOf(BikeStatus.class)); } @GetMapping("/bikeUpdates") public Flux> subscribeToAllUpdates() { - SubscriptionQueryResult, BikeStatus> subscriptionQueryResult = queryGateway.subscriptionQuery(FIND_ALL_QUERY, null, ResponseTypes.multipleInstancesOf(BikeStatus.class), ResponseTypes.instanceOf(BikeStatus.class)); + SubscriptionQueryResult, BikeStatus> subscriptionQueryResult = queryGateway.subscriptionQuery(BikeStatusNamedQueries.FIND_ALL, null, ResponseTypes.multipleInstancesOf(BikeStatus.class), ResponseTypes.instanceOf(BikeStatus.class)); return subscriptionQueryResult.initialResult() .flatMapMany(Flux::fromIterable) .concatWith(subscriptionQueryResult.updates()) @@ -104,7 +96,7 @@ public Flux> subscribeToAllUpdates() { @GetMapping("/bikeUpdatesJson") public Flux> subscribeToAllUpdatesJson() { SubscriptionQueryResult, BikeStatus> subscriptionQueryResult = queryGateway.subscriptionQuery( - FIND_ALL_QUERY, + BikeStatusNamedQueries.FIND_ALL, null, ResponseTypes.multipleInstancesOf(BikeStatus.class), ResponseTypes.instanceOf(BikeStatus.class)); @@ -117,7 +109,7 @@ public Flux> subscribeToAllUpdatesJson() { @GetMapping("/bikeUpdates/{bikeId}") public Flux> subscribeToBikeUpdates(@PathVariable("bikeId") String bikeId) { - SubscriptionQueryResult subscriptionQueryResult = queryGateway.subscriptionQuery(FIND_ONE_QUERY, bikeId, BikeStatus.class, BikeStatus.class); + SubscriptionQueryResult subscriptionQueryResult = queryGateway.subscriptionQuery(BikeStatusNamedQueries.FIND_ONE, bikeId, BikeStatus.class, BikeStatus.class); return subscriptionQueryResult.initialResult() .concatWith(subscriptionQueryResult.updates()) .doFinally(s -> subscriptionQueryResult.close()) @@ -127,17 +119,17 @@ public Flux> subscribeToBikeUpdates(@PathVariable("bikeI @PostMapping("/requestBike") public CompletableFuture requestBike(@RequestParam("bikeId") String bikeId, @RequestParam(value = "renter", required = false) String renter) { - return commandGateway.send(new RequestBikeCommand(bikeId, renter != null ? renter : randomRenter())); + return commandGateway.send(new RequestBikeCommand(bikeId, renter != null ? renter : this.bikeRentalDataGenerator.randomRenter())); } @PostMapping("/returnBike") public CompletableFuture returnBike(@RequestParam("bikeId") String bikeId) { - return commandGateway.send(new ReturnBikeCommand(bikeId, randomLocation())); + return commandGateway.send(new ReturnBikeCommand(bikeId, this.bikeRentalDataGenerator.randomLocation())); } @GetMapping("findPayment") public Mono getPaymentId(@RequestParam("reference") String paymentRef) { - SubscriptionQueryResult queryResult = queryGateway.subscriptionQuery("getPaymentId", paymentRef, String.class, String.class); + SubscriptionQueryResult queryResult = queryGateway.subscriptionQuery(PaymentStatusNamedQueries.GET_PAYMENT_ID, paymentRef, String.class, String.class); return queryResult.initialResult().concatWith(queryResult.updates()) .filter(Objects::nonNull) .next(); @@ -146,7 +138,7 @@ public Mono getPaymentId(@RequestParam("reference") String paymentRef) { @GetMapping("pendingPayments") public CompletableFuture getPendingPayments() { - return queryGateway.query("getAllPayments", PaymentStatus.Status.PENDING, PaymentStatus.class); + return queryGateway.query(PaymentStatusNamedQueries.GET_ALL_PAYMENTS, PaymentStatus.Status.PENDING, PaymentStatus.class); } @PostMapping("acceptPayment") @@ -180,11 +172,7 @@ public Flux generateData(@RequestParam(value = "bikeType") String bikeTy @RequestParam(value = "abandonPaymentFactor", defaultValue = "100") int abandonPaymentFactor, @RequestParam(value = "delay", defaultValue = "0")int delay) { - return Flux.range(0, loops) - .flatMap(j -> executeRentalCycle(bikeType, randomRenter(), abandonPaymentFactor, delay) - .map(r -> "OK - Rented, Payed and Returned\n") - .onErrorResume(e -> Mono.just("Not ok: " + e.getMessage() + "\n")), - concurrency); + return this.bikeRentalDataGenerator.generateRentals(bikeType, loops, concurrency, abandonPaymentFactor, delay); } @GetMapping("/bikes/{bikeId}") @@ -192,74 +180,6 @@ public CompletableFuture findStatus(@PathVariable("bikeId") String b return queryGateway.query(FIND_ONE_QUERY, bikeId, BikeStatus.class); } - private Mono executeRentalCycle(String bikeType, String renter, int abandonPaymentFactor, int delay) { - CompletableFuture result = selectRandomAvailableBike(bikeType) - .thenCompose(bikeId -> commandGateway.send(new RequestBikeCommand(bikeId, renter)) - .thenComposeAsync(paymentRef -> executePayment(bikeId, - (String) paymentRef, - abandonPaymentFactor), - CompletableFuture.delayedExecutor(randomDelay( - delay), TimeUnit.MILLISECONDS)) - .thenCompose(r -> whenBikeUnlocked(bikeId)) - .thenComposeAsync(r -> commandGateway.send(new ReturnBikeCommand( - bikeId, - randomLocation())), - CompletableFuture.delayedExecutor(randomDelay( - delay), TimeUnit.MILLISECONDS)) - .thenApply(r -> bikeId)); - return Mono.fromFuture(result); - } - - private int randomDelay(int delay) { - if (delay <= 0) { - return 0; - } - return ThreadLocalRandom.current().nextInt(delay - (delay >> 2), delay + delay + (delay >> 2)); - } - - private CompletableFuture selectRandomAvailableBike(String bikeType) { - return queryGateway.query("findAvailable", bikeType, ResponseTypes.multipleInstancesOf(BikeStatus.class)) - .thenApply(this::pickRandom) - .thenApply(BikeStatus::getBikeId); - } - - private T pickRandom(List source) { - return source.get(ThreadLocalRandom.current().nextInt(source.size())); - } - - private CompletableFuture whenBikeUnlocked(String bikeId) { - SubscriptionQueryResult queryResult = queryGateway.subscriptionQuery(FIND_ONE_QUERY, bikeId, BikeStatus.class, BikeStatus.class); - return queryResult.initialResult().concatWith(queryResult.updates()) - .any(status -> status.getStatus() == RentalStatus.RENTED) - .map(s -> bikeId) - .doOnNext(n -> queryResult.close()) - .toFuture(); - } - - private CompletableFuture executePayment(String bikeId, String paymentRef, int abandonPaymentFactor) { - if (abandonPaymentFactor > 0 && ThreadLocalRandom.current().nextInt(abandonPaymentFactor) == 0) { - return CompletableFuture.failedFuture(new IllegalStateException("Customer refused to pay")); - } - SubscriptionQueryResult queryResult = queryGateway.subscriptionQuery("getPaymentId", - paymentRef, - String.class, - String.class); - return queryResult.initialResult().concatWith(queryResult.updates()) - .filter(Objects::nonNull) - .doOnNext(n -> queryResult.close()) - .next() - .flatMap(paymentId -> Mono.fromFuture(commandGateway.send(new ConfirmPaymentCommand(paymentId)))) - .map(o -> bikeId) - .toFuture(); - } - - private String randomRenter() { - return RENTERS.get(ThreadLocalRandom.current().nextInt(RENTERS.size())); - } - - private String randomLocation() { - return LOCATIONS.get(ThreadLocalRandom.current().nextInt(LOCATIONS.size())); - } } diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/Simulator.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/Simulator.java new file mode 100644 index 0000000..f51010e --- /dev/null +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/Simulator.java @@ -0,0 +1,102 @@ +package io.axoniq.demo.bikerental.rental.ui; + +import io.axoniq.demo.bikerental.coreapi.rental.CountOfBikesByTypeQuery; +import io.axoniq.demo.bikerental.coreapi.rental.RegisterBikeCommand; +import org.axonframework.commandhandling.gateway.CommandGateway; +import org.axonframework.messaging.responsetypes.ResponseTypes; +import org.axonframework.queryhandling.QueryGateway; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +@Component +@Profile("simulator") +@EnableAsync +public class Simulator { + + private final CommandGateway commandGateway; + private final QueryGateway queryGateway; + private final BikeRentalDataGenerator bikeRentalDataGenerator; + + @Value("${inventory.size}") + private int inventorySize; + + @Value("${inventory.bikeType}") + private String inventoryBikeType; + + @Value("${rentalSimulation.bikeType}") + private String rentalBikeType; + + @Value("${rentalSimulation.loops}") + private int loops; + + @Value("${rentalSimulation.concurrency}") + private int concurrency; + + @Value("${rentalSimulation.abandonPaymentFactor}") + private int abandonPaymentFactor; + + @Value("${rentalSimulation.delayBetweenLoops}") + private int delayBetweenLoops; + + Logger logger = LoggerFactory.getLogger(Simulator.class); + + public Simulator(CommandGateway commandGateway, QueryGateway queryGateway, BikeRentalDataGenerator bikeRentalDataGenerator) { + this.commandGateway = commandGateway; + this.queryGateway = queryGateway; + this.bikeRentalDataGenerator = bikeRentalDataGenerator; + } + + public void updateInventoryCreationConfiguration(int sizeOfBikeInventory, String bikeType) { + this.inventorySize = sizeOfBikeInventory; + this.inventoryBikeType = bikeType; + } + + public void updateRentalGenerationConfiguration(String rentalBikeType, int loops, int concurrency, int abandonPaymentFactor, int delay ){ + this.rentalBikeType = rentalBikeType; + this.loops = loops; + this.concurrency = concurrency; + this.abandonPaymentFactor = abandonPaymentFactor; + this.delayBetweenLoops = delay; + } + + @Scheduled(fixedRate = 25000, initialDelay = 5000) + private void generateData() { + try{ + this.generateBikes(); + } catch (Exception ex) { + logger.error("error generating inventory", ex); + } + + this.generateRentals(); + } + + public void generateBikes() throws ExecutionException, InterruptedException { + + var query = new CountOfBikesByTypeQuery(this.inventoryBikeType); + long currentBikeCount = queryGateway.query(query, + ResponseTypes.instanceOf(long.class)).get(); + + if (currentBikeCount < this.inventorySize) { + for (int i = 0; i < 10; i++) { + commandGateway.send(new RegisterBikeCommand(UUID.randomUUID().toString(), this.inventoryBikeType, this.bikeRentalDataGenerator.randomLocation())); + } + } + } + + + private void generateRentals() { + this.bikeRentalDataGenerator.generateRentals(this.rentalBikeType, + this.loops, + this.concurrency, + this.abandonPaymentFactor, + this.delayBetweenLoops).subscribe(); + } +} diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/SimulatorConfigController.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/SimulatorConfigController.java new file mode 100644 index 0000000..cf85cc9 --- /dev/null +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/SimulatorConfigController.java @@ -0,0 +1,33 @@ +package io.axoniq.demo.bikerental.rental.ui; + +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Profile("simulator") +@RestController("/simulator") +public class SimulatorConfigController { + + private final Simulator simulator; + + public SimulatorConfigController(Simulator simulator) { + this.simulator = simulator; + } + + @PostMapping("/inventoryGenerationConfig") + public void updateInventoryGeneratorConfig(@RequestParam(value = "size") Integer size, + @RequestParam(value = "bikeType") String bikeType) { + this.simulator.updateInventoryCreationConfiguration(size, bikeType); + } + + @PostMapping("/rentalGenerationConfig") + public void updateRentalGeneratorConfig( + @RequestParam(value = "rentalBikeType") String rentalBikeType, + @RequestParam(value = "loops") Integer loops, + @RequestParam(value = "concurrency", defaultValue = "1") int concurrency, + @RequestParam(value = "abandonPaymentFactor", defaultValue = "100") int abandonPaymentFactor, + @RequestParam(value = "delay", defaultValue = "0")int delay) { + this.simulator.updateRentalGenerationConfiguration(rentalBikeType, loops,concurrency, abandonPaymentFactor, delay); + } +} diff --git a/rental/src/main/resources/application-simulator.properties b/rental/src/main/resources/application-simulator.properties new file mode 100644 index 0000000..a068482 --- /dev/null +++ b/rental/src/main/resources/application-simulator.properties @@ -0,0 +1,8 @@ +inventory.size=100 +inventory.bikeType=mountain bike + +rentalSimulation.bikeType=mountain bike +rentalSimulation.loops=64 +rentalSimulation.concurrency=8 +rentalSimulation.abandonPaymentFactor=0 +rentalSimulation.delayBetweenLoops=1000 \ No newline at end of file From 2b4e80582e32aba0f4d0490039b9c0d9533148c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B3mez=20G?= Date: Tue, 23 Apr 2024 10:05:19 +0200 Subject: [PATCH 2/5] Fixed caps in url word in the docs --- .../modules/ROOT/pages/create-bike-status-projection.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/modules/ROOT/pages/create-bike-status-projection.adoc b/docs/tutorial/modules/ROOT/pages/create-bike-status-projection.adoc index 0583c90..d35edad 100644 --- a/docs/tutorial/modules/ROOT/pages/create-bike-status-projection.adoc +++ b/docs/tutorial/modules/ROOT/pages/create-bike-status-projection.adoc @@ -268,7 +268,7 @@ include::example$rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/Rental } ---- -<.> The `@GetMapping` annotation configures the method to be invoked when a `GET` request to `/bikes/{bikeId}` is received, and defines the part of the url that comes after `/bikes/` to be assigned to the `bikeId` path variable. +<.> The `@GetMapping` annotation configures the method to be invoked when a `GET` request to `/bikes/{bikeId}` is received, and defines the part of the URL that comes after `/bikes/` to be assigned to the `bikeId` path variable. <.> The `@PathVariable("bikeId")` annotation instruct Spring to provide to the method argument the value of the URL that matches the `bikeId` path variable. <.> We use the `query` method of the `queryGateway` to send the query message. This time, we specify the provide `bikeId` as the query criteria as the second argument, and the `BikeStatus.class` as the type of the response we are expecting from the query. From a10cbd140d2ba89d0969d5bce5f97f0c44ebab95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B3mez=20G?= Date: Tue, 23 Apr 2024 10:44:41 +0200 Subject: [PATCH 3/5] Initial work to have data automatically generated. To make this happen in "monolith" mode RentalApplication w/ the "simulator" profile active. If running microservices, add the "simulator" profile to the UserInterface microservice. --- .../rental/query/BikeStatusProjection.java | 7 +++---- .../demo/bikerental/rental/ui/RentalController.java | 12 ++++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java index 1b01abb..e1b141a 100644 --- a/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java @@ -73,7 +73,6 @@ public void on(BikeReturnedEvent event) { updateEmitter.emit(q -> "findAll".equals(q.getQueryName()), bs); updateEmitter.emit(String.class, event.bikeId()::equals, bs); }); - } @EventHandler @@ -92,18 +91,18 @@ public void on(RequestRejectedEvent event) { //end::EventHandlers[] //tag::QueryHandlers[] //tag::findAllQueryHandler[] - @QueryHandler(queryName = "findAll") //<.> + @QueryHandler(queryName = BikeStatusNamedQueries.FIND_ALL) //<.> public Iterable findAll() { // <.> return bikeStatusRepository.findAll(); //<.> } //end::findAllQueryHandler[] - @QueryHandler(queryName = "findAvailable") //<.> + @QueryHandler(queryName = BikeStatusNamedQueries.FIND_AVAILABLE) //<.> public Iterable findAvailable(String bikeType) { //<.> return bikeStatusRepository.findAllByBikeTypeAndStatus(bikeType, RentalStatus.AVAILABLE); } - @QueryHandler(queryName = "findOne") // <.> + @QueryHandler(queryName = BikeStatusNamedQueries.FIND_ONE) // <.> public BikeStatus findOne(String bikeId) { //<.> return bikeStatusRepository.findById(bikeId).orElse(null); //<.> } diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/RentalController.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/RentalController.java index da4a185..c15fe52 100644 --- a/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/RentalController.java +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/RentalController.java @@ -77,9 +77,13 @@ public CompletableFuture generateBikes(@RequestParam("count") int bikeCoun //end::generateBikes[] //tag::findAll[] - @GetMapping("/bikes") - public CompletableFuture> findAll() { - return queryGateway.query(BikeStatusNamedQueries.FIND_ALL, null, ResponseTypes.multipleInstancesOf(BikeStatus.class)); + @GetMapping("/bikes") //<.> + public CompletableFuture> findAll() { //<.> + return queryGateway.query( //<.> + BikeStatusNamedQueries.FIND_ALL, //<.> + null, //<.> + ResponseTypes.multipleInstancesOf(BikeStatus.class) //<.> + ); } //end::findAll[] @GetMapping("/bikeUpdates") @@ -182,6 +186,6 @@ public CompletableFuture findStatus(@PathVariable("bikeId") String b return queryGateway.query(BikeStatusNamedQueries.FIND_ONE, bikeId, BikeStatus.class); //<.> } - + //end::findOneQuery[] } From ea3c6b0c794a82a6a2aac48c4e5e89c8998774ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B3mez=20G?= Date: Tue, 23 Apr 2024 11:38:40 +0200 Subject: [PATCH 4/5] Updated tutorial to use constants for the Query names --- .../pages/create-bike-status-projection.adoc | 26 ++++++++++++++++--- .../rental/query/BikeStatusProjection.java | 4 +++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/tutorial/modules/ROOT/pages/create-bike-status-projection.adoc b/docs/tutorial/modules/ROOT/pages/create-bike-status-projection.adoc index d35edad..c8aa0ce 100644 --- a/docs/tutorial/modules/ROOT/pages/create-bike-status-projection.adoc +++ b/docs/tutorial/modules/ROOT/pages/create-bike-status-projection.adoc @@ -117,6 +117,25 @@ Our next task in defining our projection is to implement the support for handlin We need to add a `@QueryHandler` method for each query we want to support. Since we already have the bike statuses persisted in the way we need to return the information, we only need to query the database and return that. +Before jumping into creating the methds to handle the queries, we need to consider how we are going to identify the different queries. + +=== Using named queries. + +Axon Framework allows different ways to identify a query message and link that query to the right method for handling it. In this tutorial we are going to start using the most simple way to identify a query: by assigning each query a name. + +We are going to use `String` constants to make sure we always refer to the same query name, both in the modules that send the query messages and in the components that are handling them. + +So, as the first step we will create a class to define and share those query names in different components. Define the following class in the `core-api` module: + +[source,java] +./core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/BikeStatusNamedQueries.java +---- +include::example$core-api/src/main/java/io/axoniq/demo/bikerental/coreapi/rental/BikeStatusNamedQueries.java[] +---- + +Now that we have the names of the queries defined, let's define the methods that will handle and respond them. + + === Implement a query to return all the bikes. Let's start by implementing a method to return all the bikes (with their status) defined in our system. Add the following method to your `BikeStatusProjection` class: @@ -130,7 +149,8 @@ include::example$rental/src/main/java/io/axoniq/demo/bikerental/rental/query/Bik <.> Since our query has no parameters (we want to retrieve the information for *all the bikes* in our system), our query handler method does not receive any parameters. It only needs to return the list of items we find in our DB. <.> As we have the information already prepared and aligned with the response format (thanks to the `EventHandler`s), we only need to retrieve the information from the repository and return it. -In short, we have defined a query handler method that Axon Framework will call upon the reception of a query message to `findAll` the bikes in our system. And the method will simply retrieve the up-to-date information from the DB and return the `BikeStatus` for all the bikes. +In short, we have defined a query handler method that Axon Framework will call upon the reception of a query message to `FIND_ALL` the bikes in our system. And the method will simply retrieve the up-to-date information from the DB and return the `BikeStatus` for all the bikes. + === Implementing support for other queries in our projection. @@ -141,12 +161,12 @@ For example, if we want to support queries to return all the available bikes, fi [source,java] .rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java ---- -include::example$rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java[tags=ClassDefinition;QueryHandlers;!*] +include::example$rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java[tags=ClassDefinition;findAvailableQueryHandler;findOneQueryHandler;!*] ---- <.> We define a new `QueryHandler` method for the `findAvailable` query. <.> The query will filter by the type of the bike, so we need to add the `bikeType` argument to the method. <.> We need to add a specific method to our `BikeStatusRepository` that implements the query to the DB. We will do that right after this. Since we are using Spring Data, the name of the method should follow a specific pattern. (More on this in a few lines) -<.> We define another `QueryHandler` method for the `findOne` query. +<.> We define another `QueryHandler` method for the `FIND_ONE` query. <.> In this query, we only need to return one bike, and we need the `bikeId` as an argument to the method. In this case, we will return a single `BikeStatus` because we are returning a single element and not a collection. <.> The default `findById` method provided by the Spring Data `JpaRepository` returns an `Optional` when we look up an item based on its `id`. This is because the `id` we are looking for may not exist in our DB. So we add a fallback to return `null` in case there is no bike with the given `bikeId` in the DB. diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java index e1b141a..8485371 100644 --- a/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/query/BikeStatusProjection.java @@ -97,16 +97,20 @@ public Iterable findAll() { // <.> } //end::findAllQueryHandler[] + //tag::findAvailableQueryHandler[] @QueryHandler(queryName = BikeStatusNamedQueries.FIND_AVAILABLE) //<.> public Iterable findAvailable(String bikeType) { //<.> return bikeStatusRepository.findAllByBikeTypeAndStatus(bikeType, RentalStatus.AVAILABLE); } + //end::findAvailableQueryHandler[] + //tag::findOneQueryHandler[] @QueryHandler(queryName = BikeStatusNamedQueries.FIND_ONE) // <.> public BikeStatus findOne(String bikeId) { //<.> return bikeStatusRepository.findById(bikeId).orElse(null); //<.> } + //end::findOneQueryHandler[] @QueryHandler public long countOfBikesByType(CountOfBikesByTypeQuery query) { return bikeStatusRepository.countBikeStatusesByBikeType(query.bikeType()); From 0926557cdc79e1147e370e29242053fa69db00d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B3mez=20G?= Date: Wed, 24 Apr 2024 13:36:35 +0200 Subject: [PATCH 5/5] Replaced forEach() with subscribe(logger::info) in Simulator --- .../java/io/axoniq/demo/bikerental/rental/ui/Simulator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/Simulator.java b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/Simulator.java index 64b0298..2dc683d 100644 --- a/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/Simulator.java +++ b/rental/src/main/java/io/axoniq/demo/bikerental/rental/ui/Simulator.java @@ -97,6 +97,6 @@ private void generateRentals() { this.loops, this.concurrency, this.abandonPaymentFactor, - this.delayBetweenLoops).toStream().forEach(logger::info); + this.delayBetweenLoops).subscribe(logger::info); } }