CD Player is a sample which resembles better use case of most of use have used in a real world. CD Player itself is a really simple entity where user can open a deck, insert or change a disk, then drive player functionality by pressing various buttons like eject, play, stop, pause, rewind and backward.
How many of use have really given a thought of what it will take to make a code for a CD Player which interacts with a hardware. Yes, concept of a player is overly simple but if you look behind a scenes things actually get a bit convoluted.
You’ve probably noticed that if your deck is open and you press play, deck will close and a song will start to play if CD was inserted in a first place. In a sense when deck is open you first need to close it and then try to start playing if cd is actually inserted. Hopefully you have now realised that a simple CD Player is not anymore so simple. Sure you can wrap all this with a simple class with few boolean variables and probably few nested if/else clauses, that will do the job, but what about if you need to make all this behaviour much more complex, do you really want to keep adding more flags and if/else clauses.
Lets go through how this sample and its state machine is designed and how those two interacts with each other. Below three config sections are used withing a EnumStateMachineConfigurerAdapter.
@Override public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception { states .withStates() .initial(States.IDLE) .state(States.IDLE) .and() .withStates() .parent(States.IDLE) .initial(States.CLOSED) .state(States.CLOSED, closedEntryAction(), null) .state(States.OPEN) .and() .withStates() .state(States.BUSY) .and() .withStates() .parent(States.BUSY) .initial(States.PLAYING) .state(States.PLAYING) .state(States.PAUSED); }
@Override public void configure(StateMachineTransitionConfigurer<States, Events> transitions) throws Exception { transitions .withExternal() .source(States.CLOSED).target(States.OPEN).event(Events.EJECT) .and() .withExternal() .source(States.OPEN).target(States.CLOSED).event(Events.EJECT) .and() .withExternal() .source(States.OPEN).target(States.CLOSED).event(Events.PLAY) .and() .withExternal() .source(States.PLAYING).target(States.PAUSED).event(Events.PAUSE) .and() .withInternal() .source(States.PLAYING) .action(playingAction()) .timer(1000) .and() .withInternal() .source(States.PLAYING).event(Events.BACK) .action(trackAction()) .and() .withInternal() .source(States.PLAYING).event(Events.FORWARD) .action(trackAction()) .and() .withExternal() .source(States.PAUSED).target(States.PLAYING).event(Events.PAUSE) .and() .withExternal() .source(States.BUSY).target(States.IDLE).event(Events.STOP) .and() .withExternal() .source(States.IDLE).target(States.BUSY).event(Events.PLAY) .action(playAction()) .guard(playGuard()) .and() .withInternal() .source(States.OPEN).event(Events.LOAD).action(loadAction()); }
@Bean public ClosedEntryAction closedEntryAction() { return new ClosedEntryAction(); } @Bean public LoadAction loadAction() { return new LoadAction(); } @Bean public TrackAction trackAction() { return new TrackAction(); } @Bean public PlayAction playAction() { return new PlayAction(); } @Bean public PlayingAction playingAction() { return new PlayingAction(); } @Bean public PlayGuard playGuard() { return new PlayGuard(); }
What we did in above configuration:
With transition we mostly mapped events to expected state transitions like EJECT closing and opening a deck, PLAY, STOP and PAUSE doing their natural transitions. Few words to mention what we did for other transitions.
This machine only have six states which are introduced as an enum.
public static enum States { // super state of PLAYING and PAUSED BUSY, PLAYING, PAUSED, // super state of CLOSED and OPEN IDLE, CLOSED, OPEN }
Events represent, in a sense in this example, what buttons user would press and if user loads a cd disc into a deck.
public static enum Events { PLAY, STOP, PAUSE, EJECT, LOAD, FORWARD, BACK }
Beans cdPlayer and library are just used with a sample to drive the application.
@Bean public CdPlayer cdPlayer() { return new CdPlayer(); } @Bean public Library library() { return Library.buildSampleLibrary(); }
We can define extended state variable key as simple enums.
public static enum Variables { CD, TRACK, ELAPSEDTIME } public static enum Headers { TRACKSHIFT }
We wanted to make this samply type safe so we’re defining our own annotation @StatesOnTransition which have a mandatory meta annotation @OnTransition.
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @OnTransition public static @interface StatesOnTransition { States[] source() default {}; States[] target() default {}; }
ClosedEntryAction is a entry action for state CLOSED to simply send and PLAY event to a statemachine if cd disc is present.
public static class ClosedEntryAction implements Action<States, Events> { @Override public void execute(StateContext<States, Events> context) { if (context.getTransition() != null && context.getEvent() == Events.PLAY && context.getTransition().getTarget().getId() == States.CLOSED && context.getExtendedState().getVariables().get(Variables.CD) != null) { context.getStateMachine().sendEvent(Events.PLAY); } } }
LoadAction is simply updating extended state variable if event headers contained information about a cd disc to load.
public static class LoadAction implements Action<States, Events> { @Override public void execute(StateContext<States, Events> context) { Object cd = context.getMessageHeader(Variables.CD); context.getExtendedState().getVariables().put(Variables.CD, cd); } }
PlayAction is simply resetting player elapsed time which is kept as an extended state variable.
public static class PlayAction implements Action<States, Events> { @Override public void execute(StateContext<States, Events> context) { context.getExtendedState().getVariables().put(Variables.ELAPSEDTIME, 0l); context.getExtendedState().getVariables().put(Variables.TRACK, 0); } }
PlayGuard is used to guard transition from IDLE to BUSY with event PLAY if extended state variable CD doesn’t indicate that cd disc has been loaded.
public static class PlayGuard implements Guard<States, Events> { @Override public boolean evaluate(StateContext<States, Events> context) { ExtendedState extendedState = context.getExtendedState(); return extendedState.getVariables().get(Variables.CD) != null; } }
PlayingAction is updating extended state variable ELAPSEDTIME which cd player itself can read and update lcd status. Action also handles track shift if user is going back or forward in tracks.
public static class PlayingAction implements Action<States, Events> { @Override public void execute(StateContext<States, Events> context) { Map<Object, Object> variables = context.getExtendedState().getVariables(); Object elapsed = variables.get(Variables.ELAPSEDTIME); Object cd = variables.get(Variables.CD); Object track = variables.get(Variables.TRACK); if (elapsed instanceof Long) { long e = ((Long)elapsed) + 1000l; if (e > ((Cd) cd).getTracks()[((Integer) track)].getLength()*1000) { context.getStateMachine().sendEvent(MessageBuilder .withPayload(Events.FORWARD) .setHeader(Headers.TRACKSHIFT.toString(), 1).build()); } else { variables.put(Variables.ELAPSEDTIME, e); } } } }
TrackAction handles track shift action if user is going back or forward in tracks. If it is a last track of a cd, playing is stopped and STOP event sent to a state machine.
public static class TrackAction implements Action<States, Events> { @Override public void execute(StateContext<States, Events> context) { Map<Object, Object> variables = context.getExtendedState().getVariables(); Object trackshift = context.getMessageHeader(Headers.TRACKSHIFT.toString()); Object track = variables.get(Variables.TRACK); Object cd = variables.get(Variables.CD); if (trackshift instanceof Integer && track instanceof Integer && cd instanceof Cd) { int next = ((Integer)track) + ((Integer)trackshift); if (next >= 0 && ((Cd)cd).getTracks().length > next) { variables.put(Variables.ELAPSEDTIME, 0l); variables.put(Variables.TRACK, next); } else if (((Cd)cd).getTracks().length <= next) { context.getStateMachine().sendEvent(Events.STOP); } } } }
One other important aspect of a state machines is that they have their own responsibilies mostly around handling states and all application level logic should be kept outside. This means that application needs to have a ways to interact with a state machine and below sample is how cdplayer does it order to update lcd status. Also pay attention that we annotated CdPlayer with @WithStateMachine which instructs state machine to find methods from your pojo which are then called with various transitions.
@OnTransition(target = "BUSY") public void busy(ExtendedState extendedState) { Object cd = extendedState.getVariables().get(Variables.CD); if (cd != null) { cdStatus = ((Cd)cd).getName(); } }
In above example we use @OnTransition annotation to hook a callback when transition happens with a target state BUSY.
@StatesOnTransition(target = {States.CLOSED, States.IDLE}) public void closed(ExtendedState extendedState) { Object cd = extendedState.getVariables().get(Variables.CD); if (cd != null) { cdStatus = ((Cd)cd).getName(); } else { cdStatus = "No CD"; } trackStatus = ""; }
@OnTransition we used above can only be used with strings which are matched from enums. @StatesOnTransition is then something what user can create into his own application to get a type safe annotation where a real enums can be used.
Lets see an example how this state machine actually works.
sm>sm start Entry state IDLE Entry state CLOSED State machine started sm>cd lcd No CD sm>cd library 0: Greatest Hits 0: Bohemian Rhapsody 05:56 1: Another One Bites the Dust 03:36 1: Greatest Hits II 0: A Kind of Magic 04:22 1: Under Pressure 04:08 sm>cd eject Exit state CLOSED Entry state OPEN sm>cd load 0 Loading cd Greatest Hits sm>cd play Exit state OPEN Entry state CLOSED Exit state CLOSED Exit state IDLE Entry state BUSY Entry state PLAYING sm>cd lcd Greatest Hits Bohemian Rhapsody 00:03 sm>cd forward sm>cd lcd Greatest Hits Another One Bites the Dust 00:04 sm>cd stop Exit state PLAYING Exit state BUSY Entry state IDLE Entry state CLOSED sm>cd lcd Greatest Hits
What happened in above run: