Blog-Archiv

Samstag, 12. Februar 2022

Custom State Design Pattern in Java

In this article I want to introduce a custom state machine that refines the classic "Gang-of-Four" State design pattern example that I presented in my last Blog.

For seeing the UML state diagram and the state/event table of the example's workflow, please refer to that article.

Why Customize the Pattern?

Advantage is that you ....

  1. .... have a reusable AbstractStateMachine with a reusable Transition class for implementing many state machines in a standard way

  2. .... don't need the big switch-case statement in StateMachine to associate an action-event with an action-method, because states do not contain action-methods any more, these methods have been replaced by a list of Transition instances

  3. .... have a more flexible inheritance concept that allows to remove action-events (transitions) inherited from a super-class without having to override it (and throw an exception from the override)

  4. .... can provide the possible actions on a follower-state without enumerating implemented action-methods as a set of action-ids (which is logic-duplication)

UML Class Diagrams

The state machine can perform a state transition given by an input state and the ID of an action to perform on that state, and it will return a follower state. The caller is responsible for constructing the input state and determining the ID of the action (event). The state machine provides just one transition at a time, sequences of transitions are up to the caller.

Every state holds a set of possibe transitions, every transition refers to an action (event) ID and a follower-state. So performing a transition is just calling the action of the transition that refers to the ID of the action. The AbstractState provides means for managing inheritance of transitions.

Source Code Files and Folders

All classes in the actions package are the same as in my preceding article, just using different imports, so I didn't repeat them here. They just expose their static ID and do nothing concrete.

Please click onto the expand-controls to see source code of the classes that have not been opened automatically:

statemachine
custom
actions
→ The actions package contains the same classes as in classic state pattern, please see my preceding article.
states
State.java
/**
 * Superclass of all states that defines transitions available in any state.
 */
public class State extends AbstractState
{
    /** @return default transitions that should be possible in all states. */
    @Override
    public Set<Transition> transitions()    {
        return Set.of(
                new Transition(
                    ReadAction.ID,    // allow "read" action in any state,
                    this)    // and stay in that state afterwards
            );
    }
}
InitialState.java
/**
 * After creation, data are in this initial state.
 */
public class InitialState extends State
{
    @Override
    public Set<Transition> transitions()    {
        return extend(
                super.transitions(),    // allow all transitions from super-class, like "read"
                new Transition(
                    CreateAction.ID,    // initial state must accept "create" action
                    this),    // stay in this state after creation
                new Transition(
                    SaveAction.ID,    // can save data
                    this),    // stay in this state afterwards
                new Transition(
                    DeleteAction.ID,    // can delete
                    new DeletedState()),    // go to deleted state state afterwards
                new Transition(
                    ReleaseAction.ID,    // can publish
                    new ReleasedState())    // go to published state state afterwards
            );
    }
}
DraftState.java
/**
 * After publishing, data can be brought back to a draft state.
 * Nearly identical to initial state, but it doesn't allow "create" action.
 */
public class DraftState extends InitialState
{
    @Override
    public Set<Transition> transitions()    {
        return remove(
                super.transitions(),    // inherit all super-transitions,
                CreateAction.ID    // but don't allow "create" action
            );
    }
}
ReleasedState.java
/**
 * From initial state or draft state, data can be published.
 */
public class ReleasedState extends State
{
    @Override
    public Set<Transition> transitions()    {
        return extend(
                super.transitions(),
                new Transition(
                        EditAction.ID,    // "edit" is supported in published state only
                        new DraftState()),    // go to draft state afterwards
                new Transition(
                        DeleteAction.ID,    // "delete" is supported
                        new DeletedState())    // go to deleted state afterwards
            );
    }
}
DeletedState.java
/**
 * Doesn't provide any transition.
 */
public class DeletedState extends State
{
    @Override
    public Set<Transition> transitions()    {
        return Set.of();    // support no further actions and states
    }
}
StateMachine.java
public class StateMachine extends AbstractStateMachine
{
    @Override
    public Set<AbstractAction> getActions() {
        return Set.of(
                new CreateAction(),
                new ReadAction(),
                new DeleteAction(),
                new SaveAction(),
                new ReleaseAction(),
                new EditAction()
            );
    }
}
AbstractAction.java
AbstractAction is the same as in classic state pattern, please see my preceding article.
AbstractState.java
/**
 * Reusable superclass for all states. A state aggregates transitions.
 */
public abstract class AbstractState
{
    /**
     * @return transitions that should be possible in this state,
     *         or an empty set when none, but never null.
     */
    public abstract Set<Transition> transitions();
    
    /**
     * @return all possible actions to be performed on this state,
     *         derived from the action-ids in all possible transitions.
     */
    public final Set<String> actionIds()    {
        final Set<String> actionIds = new HashSet<>();
        for (Transition transition : transitions())
            actionIds.add(transition.actionId);
        
        return Collections.unmodifiableSet(actionIds);
    }
    
    /**
     * Convenience method to avoid code duplications.
     * @param inheritedTransitions required, use <i>super.transitions()</i> for that.
     * @param moreTransitions required, further transitions to add.
     * @return the union of <i>inheritedTransitions</i> and <i>moreTransitions</i>.
     */
    protected final Set<Transition> extend(Set<Transition> inheritedTransitions, Transition... moreTransitions)    {
        final Set<Transition> extended = new HashSet<>();
        
        for (Transition inheritedTransition : inheritedTransitions)
            addButAssertNotContained(extended, inheritedTransition);
        
        for (Transition moreTransition : moreTransitions)
            addButAssertNotContained(extended, moreTransition);
        
        return Collections.unmodifiableSet(extended);
    }
    
    /**
     * Convenience method to avoid code duplications.
     * @param inheritedTransitions required, use <i>super.transitions()</i> for that.
     * @param actionIdsToRemove required, action-ids referencing transitions to remove.
     * @return a new set consisting of the <i>inheritedTransitions</i> minus those
     *         transitions that reference an action contained in <i>actionsToRemove</i>.
     */
    protected final Set<Transition> remove(Set<Transition> inheritedTransitions, String... actionIdsToRemove)    {
        final Set<String> toRemove = Set.of(actionIdsToRemove);
        final Set<Transition> reduced = new HashSet<>();
        
        for (Transition inheritedTransition : inheritedTransitions)
            if (false == toRemove.contains(inheritedTransition.actionId))
                addButAssertNotContained(reduced, inheritedTransition);
        
        return Collections.unmodifiableSet(reduced);
    }
    
    /** @return the hash of this class. */
    @Override
    public int hashCode() {
        return getClass().hashCode();
    }
    
    /** @return the hash of this class. */
    @Override
    public boolean equals(Object o) {
        return getClass().equals(o.getClass());
    }


    private void addButAssertNotContained(Set<Transition> set, Transition transition) {
        if (set.contains(transition))
            throw new IllegalArgumentException(
                "Transitions referring to the same action are not allowed in the same state: "+
                transition.actionId);
        
        set.add(transition);
    }
}
AbstractStateMachine.java
/**
 * Reusable abstraction of a state machine.
 */
public abstract class AbstractStateMachine
{
    /** @return the actions this state machine supports. */
    protected abstract Set<AbstractAction> getActions();

    /**
     * Transitions from given state according to given action, performing that action.
     * @return the follower state resulting from done transition.
     */
    public AbstractState transition(AbstractState state, String actionId)    {
        for (Transition transition : state.transitions())
            if (transition.actionId.equals(actionId))
                return perform(state, transition, actionId);
        
        throw new IllegalStateException(
                "No transition in state "+state.getClass().getSimpleName()+
                " can dispatch action "+actionId);
    }

    /** @return the ids of actions that can be performed on given state. */
    public Set<String> followerActionIds(AbstractState followerState)    {
        return followerState.actionIds();
    }


    private AbstractState perform(AbstractState oldState, Transition transition, String actionId) {
        final AbstractAction action = findAction(actionId);
        final AbstractState newState = transition.followerState;
        action.perform(oldState, newState);
        return newState;
    }

    private AbstractAction findAction(String actionId) {
        for (AbstractAction action : getActions())
            if (action.getId().equals(actionId))
                return action;
        
        throw new IllegalArgumentException("No action found for id "+actionId);
    }
}
Transition.java
/**
 * A transition connects exactly one action with one follower state,
 * and it represents exactly one state/event table cell.
 * Transitions are unique within one state, identified by their actionId,
 * so that one state can never have two transitions with same actionId.
 */
public class Transition
{
	public final String actionId;
	public final State followerState;
	
	public Transition(String actionId, State followerState) {
		this.actionId = Objects.requireNonNull(actionId);
		this.followerState = Objects.requireNonNull(followerState);
	}
	
	/** @return hash of actionId. */
	@Override
	public int hashCode() {
		return actionId.hashCode();
	}
	
	/** @return true if local actionId is equal to that in given transition. */
	@Override
	public boolean equals(Object o) {
		final Transition other = (Transition) o;
		return actionId.equals(other.actionId);
	}
}

Source Descriptions

The essential difference to the classic State pattern is that action-events are no more implemented as methods in a state but represented by a list of transitions.

Transitions

A transition connects exactly one action-id with exactly one follower state, and it represents exactly one state/event table cell. Transitions are identified by their action-id, because they must be unique within one state (table column), so that one state can never have two transitions with the same action-id. (If your workflow requires such, you should consider to spawn another state that inherits from its parent.)

Transitions do not carry the action instance directly, they just refer to its ID, so that the AbstractStateMachine can perform a given action-id when it knows all available connected action classes. This is what happens in AbstractStateMachine.transition() method. The set of actions is provided by its workflow-specific sub-class StateMachine in getActions() method.

Using transitions makes it easy to determine follower action-ids. You just have to enumerate all transitions of the follower-state and collect their actionids. This is what happens in AbstractStateMachine.followerActionIds() method.

States

When you look at AbstractState, you see the protected utility methods extend() and remove(). They serve to reuse a set of transitions inherited from a super-class. (Being able to inherit from another state is an important feature I would never want to miss, it prevents lots of code duplications!)

In our workflow it is the DraftState that inherits from InitialState (because these two are almost identical), and then uses remove() to get rid of the "create" action.

  • The super-class State declares that the "read" action is possible in any state (should not the sub-class remove it)
  • Then InitialState extends it and adds "create", "save", "delete" and "release" actions
  • As DraftState doesn't allow "create", it inherits all actions from InitialState but removes CreateAction.ID

That way you can maintain the transitions in InitialState without having to care for DraftState, and vice-versa!

The methods extend() and remove() also check that no ambiguous transition gets added, by calling addButAssertNotContained(). This state machine implementation does not allow to have two transitions with same action-id in one state. That means, any action-id has exactly one follower-state in the context of its state.

State Machine

The AbstractStateMachine is a base class for many different state machines that can then be written in a standard way. It requires that all possible actions are defined by a sub-class, everything else can be done like implemented in its very simple private perform() method.

When you look at the concrete StateMachine class that was made for the documentation-workflow, you see that it provides the action instances for that workflow.

Mind that the state machine is the only one that knows concrete action classes, all other participants just use action-IDs. Not even the caller of the state machine knows the action classes or can determine them. That means there is a "loose binding" between transitions and actions, in other words, you could provide different action semantics for a workflow by using the same transitions but different action classes. Just override StateMachine.getActions(), or use a protected factory method for action instances there.

Resume

I hope I could show how the state design pattern can be customized for better code reusage. The difference to the classic variant is not so big. Using the Transition API instead of defining action-methods is just a matter of habit change.




Keine Kommentare: