AuctionEntity example in Java, #25485

This commit is contained in:
Patrik Nordwall 2018-10-08 19:36:29 +02:00
parent 70176341d9
commit b1b959df50
8 changed files with 852 additions and 0 deletions

View file

@ -0,0 +1,71 @@
/*
* Copyright (C) 2018 Lightbend Inc. <https://www.lightbend.com>
*/
package jdocs.akka.persistence.typed.auction;
import java.time.Instant;
import java.util.UUID;
/**
* An auction.
*/
public final class Auction {
/**
* The item under auction.
*/
private final UUID itemId;
/**
* The user that created the item.
*/
private final UUID creator;
/**
* The reserve price of the auction.
*/
private final int reservePrice;
/**
* The minimum increment between bids.
*/
private final int increment;
/**
* The time the auction started.
*/
private final Instant startTime;
/**
* The time the auction will end.
*/
private final Instant endTime;
public Auction(UUID itemId, UUID creator, int reservePrice, int increment, Instant startTime, Instant endTime) {
this.itemId = itemId;
this.creator = creator;
this.reservePrice = reservePrice;
this.increment = increment;
this.startTime = startTime;
this.endTime = endTime;
}
public UUID getItemId() {
return itemId;
}
public UUID getCreator() {
return creator;
}
public int getReservePrice() {
return reservePrice;
}
public int getIncrement() {
return increment;
}
public Instant getStartTime() {
return startTime;
}
public Instant getEndTime() {
return endTime;
}
}

View file

@ -0,0 +1,236 @@
/*
* Copyright (C) 2018 Lightbend Inc. <https://www.lightbend.com>
*/
package jdocs.akka.persistence.typed.auction;
import akka.Done;
import akka.actor.typed.ActorRef;
import akka.persistence.typed.ExpectingReply;
import java.util.UUID;
/**
* An auction command.
*/
public interface AuctionCommand {
/**
* Start the auction.
*/
final class StartAuction implements AuctionCommand, ExpectingReply<Done> {
/**
* The auction to start.
*/
private final Auction auction;
private final ActorRef<Done> replyTo;
public StartAuction(Auction auction, ActorRef<Done> replyTo) {
this.auction = auction;
this.replyTo = replyTo;
}
@Override
public ActorRef<Done> replyTo() {
return replyTo;
}
public Auction getAuction() {
return auction;
}
}
/**
* Cancel the auction.
*/
final class CancelAuction implements AuctionCommand, ExpectingReply<Done> {
private final ActorRef<Done> replyTo;
public CancelAuction(ActorRef<Done> replyTo) {
this.replyTo = replyTo;
}
@Override
public ActorRef<Done> replyTo() {
return replyTo;
}
}
/**
* Place a bid on the auction.
*/
final class PlaceBid implements AuctionCommand, ExpectingReply<PlaceBidReply> {
private final int bidPrice;
private final UUID bidder;
private final ActorRef<PlaceBidReply> replyTo;
public PlaceBid(int bidPrice, UUID bidder, ActorRef<PlaceBidReply> replyTo) {
this.bidPrice = bidPrice;
this.bidder = bidder;
this.replyTo = replyTo;
}
@Override
public ActorRef<PlaceBidReply> replyTo() {
return replyTo;
}
public int getBidPrice() {
return bidPrice;
}
public UUID getBidder() {
return bidder;
}
}
interface PlaceBidReply {}
/**
* The status of placing a bid.
*/
enum PlaceBidStatus {
/**
* The bid was accepted, and is the current highest bid.
*/
ACCEPTED(BidResultStatus.ACCEPTED),
/**
* The bid was accepted, but was outbidded by the maximum bid of the current highest bidder.
*/
ACCEPTED_OUTBID(BidResultStatus.ACCEPTED_OUTBID),
/**
* The bid was accepted, but is below the reserve.
*/
ACCEPTED_BELOW_RESERVE(BidResultStatus.ACCEPTED_BELOW_RESERVE),
/**
* The bid was not at least the current bid plus the increment.
*/
TOO_LOW(BidResultStatus.TOO_LOW),
/**
* The auction hasn't started.
*/
NOT_STARTED(BidResultStatus.NOT_STARTED),
/**
* The auction has already finished.
*/
FINISHED(BidResultStatus.FINISHED),
/**
* The auction has been cancelled.
*/
CANCELLED(BidResultStatus.CANCELLED);
public final BidResultStatus bidResultStatus;
PlaceBidStatus(BidResultStatus bidResultStatus) {
this.bidResultStatus = bidResultStatus;
}
public static PlaceBidStatus from(BidResultStatus status) {
switch (status) {
case ACCEPTED:
return ACCEPTED;
case ACCEPTED_BELOW_RESERVE:
return ACCEPTED_BELOW_RESERVE;
case ACCEPTED_OUTBID:
return ACCEPTED_OUTBID;
case CANCELLED:
return CANCELLED;
case FINISHED:
return FINISHED;
case NOT_STARTED:
return NOT_STARTED;
case TOO_LOW:
return TOO_LOW;
default:
throw new IllegalStateException();
}
}
}
/**
* The result of placing a bid.
*/
final class PlaceBidResult implements PlaceBidReply {
/**
* The current price of the auction.
*/
private final int currentPrice;
/**
* The status of the attempt to place a bid.
*/
private final PlaceBidStatus status;
/**
* The current winning bidder.
*/
private final UUID currentBidder;
public PlaceBidResult(PlaceBidStatus status, int currentPrice, UUID currentBidder) {
this.currentPrice = currentPrice;
this.status = status;
this.currentBidder = currentBidder;
}
public int getCurrentPrice() {
return currentPrice;
}
public PlaceBidStatus getStatus() {
return status;
}
public UUID getCurrentBidder() {
return currentBidder;
}
}
final class PlaceBidRejected implements PlaceBidReply {
private final String errorMessage;
public PlaceBidRejected(String errorMessage) {
this.errorMessage = errorMessage;
}
public String getErrorMessage() {
return errorMessage;
}
}
/**
* Finish bidding.
*/
final class FinishBidding implements AuctionCommand, ExpectingReply<Done> {
private final ActorRef<Done> replyTo;
FinishBidding(ActorRef<Done> replyTo) {
this.replyTo = replyTo;
}
@Override
public ActorRef<Done> replyTo() {
return replyTo;
}
}
/**
* Get the auction.
*/
final class GetAuction implements AuctionCommand, ExpectingReply<AuctionState> {
private final ActorRef<AuctionState> replyTo;
public GetAuction(ActorRef<AuctionState> replyTo) {
this.replyTo = replyTo;
}
@Override
public ActorRef<AuctionState> replyTo() {
return replyTo;
}
}
}

View file

@ -0,0 +1,241 @@
/*
* Copyright (C) 2018 Lightbend Inc. <https://www.lightbend.com>
*/
package jdocs.akka.persistence.typed.auction;
import akka.Done;
import akka.persistence.typed.ExpectingReply;
import akka.persistence.typed.PersistenceId;
import akka.persistence.typed.javadsl.CommandHandler;
import akka.persistence.typed.javadsl.CommandHandlerBuilder;
import akka.persistence.typed.javadsl.Effect;
import akka.persistence.typed.javadsl.EventHandler;
import akka.persistence.typed.javadsl.PersistentBehavior;
import static jdocs.akka.persistence.typed.auction.AuctionCommand.*;
import static jdocs.akka.persistence.typed.auction.AuctionEvent.*;
import java.time.Instant;
import java.util.Arrays;
import java.util.Optional;
import java.util.UUID;
/**
* Based on https://github.com/lagom/online-auction-java/blob/master/bidding-impl/src/main/java/com/example/auction/bidding/impl/AuctionEntity.java
*/
public class AuctionEntity extends PersistentBehavior<AuctionCommand, AuctionEvent, AuctionState> {
private final UUID entityUUID;
public AuctionEntity(String entityId) {
// when used with Cluster Sharding this should use EntityTypeKey, or PersistentEntity
super(new PersistenceId("Auction|" + entityId));
this.entityUUID = UUID.fromString(entityId);
}
// Command handler for the not started state.
private CommandHandlerBuilder<AuctionCommand, AuctionEvent, AuctionState, AuctionState> notStartedHandler =
commandHandlerBuilder(state -> state.getStatus() == AuctionStatus.NOT_STARTED)
.matchCommand(StartAuction.class, this::startAuction)
.matchCommand(PlaceBid.class, (state, cmd) -> Effect().reply(cmd, createResult(state, PlaceBidStatus.NOT_STARTED)));
// Command handler for the under auction state.
private CommandHandlerBuilder<AuctionCommand, AuctionEvent, AuctionState, AuctionState> underAuctionHandler =
commandHandlerBuilder(state -> state.getStatus() == AuctionStatus.UNDER_AUCTION)
.matchCommand(StartAuction.class, (state, cmd) -> alreadyDone(cmd))
.matchCommand(PlaceBid.class, this::placeBid)
.matchCommand(FinishBidding.class, this::finishBidding);
// Command handler for the completed state.
private CommandHandlerBuilder<AuctionCommand, AuctionEvent, AuctionState, AuctionState> completedHandler =
commandHandlerBuilder(state -> state.getStatus() == AuctionStatus.COMPLETE)
.matchCommand(StartAuction.class, (state, cmd) -> alreadyDone(cmd))
.matchCommand(FinishBidding.class, (state, cmd) -> alreadyDone(cmd))
.matchCommand(PlaceBid.class, (state, cmd) -> Effect().reply(cmd, createResult(state, PlaceBidStatus.FINISHED)));
// Command handler for the cancelled state.
private CommandHandlerBuilder<AuctionCommand, AuctionEvent, AuctionState, AuctionState> cancelledHandler =
commandHandlerBuilder(state -> state.getStatus() == AuctionStatus.CANCELLED)
.matchCommand(StartAuction.class, (state, cmd) -> alreadyDone(cmd))
.matchCommand(FinishBidding.class, (state, cmd) -> alreadyDone(cmd))
.matchCommand(CancelAuction.class, (state, cmd) -> alreadyDone(cmd))
.matchCommand(PlaceBid.class, (state, cmd) -> Effect().reply(cmd, createResult(state, PlaceBidStatus.CANCELLED)));
private CommandHandlerBuilder<AuctionCommand, AuctionEvent, AuctionState, AuctionState> getAuctionHandler =
commandHandlerBuilder(AuctionState.class)
.matchCommand(GetAuction.class, (state, cmd) -> Effect().reply(cmd, state));
private CommandHandlerBuilder<AuctionCommand, AuctionEvent, AuctionState, AuctionState> cancelHandler =
commandHandlerBuilder(AuctionState.class)
.matchCommand(CancelAuction.class, this::cancelAuction);
// Note, an item can go from completed to cancelled, since it is the item service that controls
// whether an auction is cancelled or not. If it cancels before it receives a bidding finished
// event from us, it will ignore the bidding finished event, so we need to update our state
// to reflect that.
private Effect<AuctionEvent, AuctionState> startAuction(AuctionState state, StartAuction cmd) {
return Effect().persist(new AuctionStarted(entityUUID, cmd.getAuction()))
.thenReply(cmd, __ -> Done.getInstance());
}
private Effect<AuctionEvent, AuctionState> finishBidding(AuctionState state, FinishBidding cmd) {
return Effect().persist(new BiddingFinished(entityUUID))
.thenReply(cmd, __ -> Done.getInstance());
}
private Effect<AuctionEvent, AuctionState> cancelAuction(AuctionState state, CancelAuction cmd) {
return Effect().persist(new AuctionCancelled(entityUUID))
.thenReply(cmd, __ -> Done.getInstance());
}
/**
* The main logic for handling of bids.
*/
private Effect<AuctionEvent, AuctionState> placeBid(AuctionState state, PlaceBid bid) {
Auction auction = state.getAuction().get();
Instant now = Instant.now();
// Even though we're not in the finished state yet, we should check
if (auction.getEndTime().isBefore(now)) {
return Effect().reply(bid, createResult(state, PlaceBidStatus.FINISHED));
}
if (auction.getCreator().equals(bid.getBidder())) {
return Effect().reply(bid, new PlaceBidRejected("An auctions creator cannot bid in their own auction."));
}
Optional<Bid> currentBid = state.lastBid();
int currentBidPrice;
int currentBidMaximum;
if (currentBid.isPresent()) {
currentBidPrice = currentBid.get().getBidPrice();
currentBidMaximum = currentBid.get().getMaximumBid();
} else {
currentBidPrice = 0;
currentBidMaximum = 0;
}
boolean bidderIsCurrentBidder = currentBid.filter(b -> b.getBidder().equals(bid.getBidder())).isPresent();
if (bidderIsCurrentBidder && bid.getBidPrice() >= currentBidPrice) {
// Allow the current bidder to update their bid
if (auction.getReservePrice()>currentBidPrice) {
int newBidPrice = Math.min(auction.getReservePrice(), bid.getBidPrice());
PlaceBidStatus placeBidStatus;
if (newBidPrice == auction.getReservePrice()) {
placeBidStatus = PlaceBidStatus.ACCEPTED;
}
else {
placeBidStatus = PlaceBidStatus.ACCEPTED_BELOW_RESERVE;
}
return Effect().persist(new BidPlaced(entityUUID,
new Bid(bid.getBidder(), now, newBidPrice, bid.getBidPrice())))
.thenReply(bid, newState -> new PlaceBidResult(placeBidStatus, newBidPrice, bid.getBidder()));
}
return Effect().persist(new BidPlaced(entityUUID,
new Bid(bid.getBidder(), now, currentBidPrice, bid.getBidPrice())))
.thenReply(bid, newState -> new PlaceBidResult(PlaceBidStatus.ACCEPTED, currentBidPrice, bid.getBidder()));
}
if (bid.getBidPrice() < currentBidPrice + auction.getIncrement()) {
return Effect().reply(bid, createResult(state, PlaceBidStatus.TOO_LOW));
} else if (bid.getBidPrice() <= currentBidMaximum) {
return handleAutomaticOutbid(bid, auction, now, currentBid, currentBidPrice, currentBidMaximum);
} else {
return handleNewWinningBidder(bid, auction, now, currentBidMaximum);
}
}
/**
* Handle the situation where a bid will be accepted, but it will be automatically outbid by the current bidder.
*
* This emits two events, one for the bid currently being replace, and another automatic bid for the current bidder.
*/
private Effect<AuctionEvent, AuctionState> handleAutomaticOutbid(
PlaceBid bid, Auction auction, Instant now, Optional<Bid> currentBid, int currentBidPrice, int currentBidMaximum) {
// Adjust the bid so that the increment for the current maximum makes the current maximum a valid bid
int adjustedBidPrice = Math.min(bid.getBidPrice(), currentBidMaximum - auction.getIncrement());
int newBidPrice = adjustedBidPrice + auction.getIncrement();
return Effect().persist(Arrays.asList(
new BidPlaced(entityUUID,
new Bid(bid.getBidder(), now, adjustedBidPrice, bid.getBidPrice())
),
new BidPlaced(entityUUID,
new Bid(currentBid.get().getBidder(), now, newBidPrice, currentBidMaximum)
)
))
.thenReply(bid, newState -> new PlaceBidResult(PlaceBidStatus.ACCEPTED_OUTBID, newBidPrice, currentBid.get().getBidder()));
}
/**
* Handle the situation where a bid will be accepted as the new winning bidder.
*/
private Effect<AuctionEvent, AuctionState> handleNewWinningBidder(PlaceBid bid,
Auction auction, Instant now, int currentBidMaximum) {
int nextIncrement = Math.min(currentBidMaximum + auction.getIncrement(), bid.getBidPrice());
int newBidPrice;
if (nextIncrement < auction.getReservePrice()) {
newBidPrice = Math.min(auction.getReservePrice(), bid.getBidPrice());
} else {
newBidPrice = nextIncrement;
}
return Effect().persist(new BidPlaced(
entityUUID,
new Bid(bid.getBidder(), now, newBidPrice, bid.getBidPrice())
))
.thenReply(bid, newState -> {
PlaceBidStatus status;
if (newBidPrice < auction.getReservePrice()) {
status = PlaceBidStatus.ACCEPTED_BELOW_RESERVE;
} else {
status = PlaceBidStatus.ACCEPTED;
}
return new PlaceBidResult(status, newBidPrice, bid.getBidder());
});
}
@Override
public AuctionState emptyState() {
return AuctionState.notStarted();
}
@Override
public CommandHandler<AuctionCommand, AuctionEvent, AuctionState> commandHandler() {
return notStartedHandler
.orElse(underAuctionHandler)
.orElse(completedHandler)
.orElse(getAuctionHandler)
.orElse(cancelledHandler)
.build();
}
@Override
public EventHandler<AuctionState, AuctionEvent> eventHandler() {
return eventHandlerBuilder()
.matchEvent(AuctionStarted.class, (state, evt) -> AuctionState.start(evt.getAuction()))
.matchEvent(BidPlaced.class, (state, evt) -> state.bid(evt.getBid()))
.matchEvent(BiddingFinished.class, (state, evt) -> state.withStatus(AuctionStatus.COMPLETE))
.matchEvent(AuctionCancelled.class, (state, evt) -> state.withStatus(AuctionStatus.CANCELLED))
.build();
}
private PlaceBidResult createResult(AuctionState state, PlaceBidStatus status) {
Optional<Bid> lastBid = state.lastBid();
if (lastBid.isPresent()) {
Bid bid = lastBid.get();
return new PlaceBidResult(status, bid.getBidPrice(), bid.getBidder());
} else {
return new PlaceBidResult(status, 0, null);
}
}
private Effect<AuctionEvent, AuctionState> alreadyDone(ExpectingReply<Done> cmd) {
return Effect().reply(cmd, Done.getInstance());
}
}

View file

@ -0,0 +1,108 @@
/*
* Copyright (C) 2018 Lightbend Inc. <https://www.lightbend.com>
*/
package jdocs.akka.persistence.typed.auction;
import java.util.UUID;
/**
* A persisted auction event.
*/
public interface AuctionEvent {
/**
* The auction started.
*/
final class AuctionStarted implements AuctionEvent {
/**
* The item that the auction started on.
*/
private final UUID itemId;
/**
* The auction details.
*/
private final Auction auction;
public AuctionStarted(UUID itemId, Auction auction) {
this.itemId = itemId;
this.auction = auction;
}
public UUID getItemId() {
return itemId;
}
public Auction getAuction() {
return auction;
}
}
/**
* A bid was placed.
*/
final class BidPlaced implements AuctionEvent {
/**
* The item that the bid was placed on.
*/
private final UUID itemId;
/**
* The bid.
*/
private final Bid bid;
public BidPlaced(UUID itemId, Bid bid) {
this.itemId = itemId;
this.bid = bid;
}
public UUID getItemId() {
return itemId;
}
public Bid getBid() {
return bid;
}
}
/**
* Bidding finished.
*/
final class BiddingFinished implements AuctionEvent {
/**
* The item that bidding finished for.
*/
private final UUID itemId;
public BiddingFinished(UUID itemId) {
this.itemId = itemId;
}
public UUID getItemId() {
return itemId;
}
}
/**
* The auction was cancelled.
*/
final class AuctionCancelled implements AuctionEvent {
/**
* The item that the auction was cancelled for.
*/
private final UUID itemId;
public AuctionCancelled(UUID itemId) {
this.itemId = itemId;
}
public UUID getItemId() {
return itemId;
}
}
}

View file

@ -0,0 +1,77 @@
/*
* Copyright (C) 2018 Lightbend Inc. <https://www.lightbend.com>
*/
package jdocs.akka.persistence.typed.auction;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
/**
* The auction state.
*/
public final class AuctionState {
/**
* The auction details.
*/
private final Optional<Auction> auction;
/**
* The status of the auction.
*/
private final AuctionStatus status;
/**
* The bidding history for the auction.
*/
private final List<Bid> biddingHistory;
public AuctionState(Optional<Auction> auction, AuctionStatus status, List<Bid> biddingHistory) {
this.auction = auction;
this.status = status;
this.biddingHistory = biddingHistory;
}
public static AuctionState notStarted() {
return new AuctionState(Optional.empty(), AuctionStatus.NOT_STARTED, Collections.emptyList());
}
public static AuctionState start(Auction auction) {
return new AuctionState(Optional.of(auction), AuctionStatus.UNDER_AUCTION, Collections.emptyList());
}
public AuctionState withStatus(AuctionStatus status) {
return new AuctionState(auction, status, biddingHistory);
}
public AuctionState bid(Bid bid) {
if (lastBid().filter(b -> b.getBidder().equals(bid.getBidder())).isPresent()) {
// Current bidder has updated their bid
List<Bid> newBiddingHistory = new ArrayList<>(biddingHistory);
newBiddingHistory.remove(newBiddingHistory.size() - 1); // remove last
newBiddingHistory.add(bid);
return new AuctionState(auction, status, newBiddingHistory);
} else {
List<Bid> newBiddingHistory = new ArrayList<>(biddingHistory);
newBiddingHistory.add(bid);
return new AuctionState(auction, status, newBiddingHistory);
}
}
public Optional<Bid> lastBid() {
if (biddingHistory.isEmpty()) {
return Optional.empty();
} else {
return Optional.of(biddingHistory.get(biddingHistory.size() - 1));
}
}
public Optional<Auction> getAuction() {
return auction;
}
public AuctionStatus getStatus() {
return status;
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (C) 2018 Lightbend Inc. <https://www.lightbend.com>
*/
package jdocs.akka.persistence.typed.auction;
/**
* Auction status.
*/
public enum AuctionStatus {
/**
* The auction hasn't started yet (or doesn't exist).
*/
NOT_STARTED,
/**
* The item is under auction.
*/
UNDER_AUCTION,
/**
* The auction is complete.
*/
COMPLETE,
/**
* The auction is cancelled.
*/
CANCELLED
}

View file

@ -0,0 +1,53 @@
/*
* Copyright (C) 2018 Lightbend Inc. <https://www.lightbend.com>
*/
package jdocs.akka.persistence.typed.auction;
import java.time.Instant;
import java.util.UUID;
/**
* A bid.
*/
public final class Bid {
/**
* The bidder.
*/
private final UUID bidder;
/**
* The time the bid was placed.
*/
private final Instant bidTime;
/**
* The bid price.
*/
private final int bidPrice;
/**
* The maximum the bidder is willing to bid.
*/
private final int maximumBid;
public Bid(UUID bidder, Instant bidTime, int bidPrice, int maximumBid) {
this.bidder = bidder;
this.bidTime = bidTime;
this.bidPrice = bidPrice;
this.maximumBid = maximumBid;
}
public UUID getBidder() {
return bidder;
}
public Instant getBidTime() {
return bidTime;
}
public int getBidPrice() {
return bidPrice;
}
public int getMaximumBid() {
return maximumBid;
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (C) 2018 Lightbend Inc. <https://www.lightbend.com>
*/
package jdocs.akka.persistence.typed.auction;
/**
* The status of the result of placing a bid.
*/
public enum BidResultStatus {
/**
* The bid was accepted, and is the current highest bid.
*/
ACCEPTED,
/**
* The bid was accepted, but was outbidded by the maximum bid of the current highest bidder.
*/
ACCEPTED_OUTBID,
/**
* The bid was accepted, but is below the reserve.
*/
ACCEPTED_BELOW_RESERVE,
/**
* The bid was not at least the current bid plus the increment.
*/
TOO_LOW,
/**
* The auction hasn't started.
*/
NOT_STARTED,
/**
* The auction has already finished.
*/
FINISHED,
/**
* The auction has been cancelled.
*/
CANCELLED
}