This library can take a json-encoded state machine and create a runnable state machine from it. The supported features and the used terminology are modeled along the UML state chart diagram (which itself is based on finite automatas).
It allows not only to track a state but also to execute custom commands while the transitions are traversed.
The describable state machines consist of
- a set of
states
identified by names - one of them being the
initialState
- a set of
finalStates
("accepting states"); the state machine will report if one of them is reached; processing can nevertheless continue transitions
defining when to change from state to state- a set of
triggers
; they are matched against incoming events (the "input alphabet") and might trigger traversal of a transition - a state can have one or more
onEntry
behaviors attached. These are commands (code given by the user of the library) that are executed when the state is entered via a transition. - a state can also have one or more
onExit
behaviors attached which are executed when leaving the state - a transition can have one or more
effects
behaviors attached which are executed when traversing the transition - the given input is heavily checked and validated. Exceptions contain "useful" error messages (so the author of the json will know what to fix)
- User provided code (the "behaviors") is wrapped at runtime. Thrown exceptions will be logged and don't affect the state machine or other behaviors.
<dependency>
<groupId>com.github.aytchell</groupId>
<artifactId>jsonfsm</artifactId>
<version>2.0.0</version>
</dependency>
The following state chart diagram has two states which are "connected" by one transmission. The state 'Start' is the initial state; the state machine will start "from within" this state. 'Stop' is a final state. If the transmission is traversed there are several behaviors executed.
The way this lib allows a user to define commands can be described as "execute this command string on the device with id 5". What exactly "device 5" is and how it handles a given command string is completely up to the user's code.
The json-encoding for this state chart is shown in the following block.
Triggers are identified in the json via an eventSourceId
and an
eventPayload
.
Behaviors are described in a way so that the json part only contains a string
representation of the command (commandString
) and the ID of the device to execute it
(deviceId
). The user of this lib has to provide the
java implementation.
{
"initialState" : "Start",
"triggers" : [
{
"name" : "move",
"eventSourceId" : 3,
"eventPayload" : "move ya"
}
],
"states" : [
{
"name" : "Start",
"onExit" : [
{
"deviceId" : 1,
"commandString" : "Exiting1"
}, {
"deviceId" : 5,
"commandString" : "Exiting2"
}
],
"transitions" : [
{
"triggerName" : "move",
"targetState" : "Stop",
"effects" : [
{
"deviceId" : 1,
"commandString" : "Moving1"
},{
"deviceId" : 5,
"commandString" : "Moving2"
} ]
} ]
},
{
"name" : "Stop",
"onEntry" : [
{
"deviceId" : 1,
"commandString" : "Entering1"
}, {
"deviceId" : 5,
"commandString" : "Entering2"
} ]
} ],
"finalStates" : [ "Stop" ]
}
The API of this lib is quite slim. Given a string with a json encoded state machine the caller first has to start the parser:
final String jsonStateMachine = "{ ... }";
final StateMachineCompiler compiler = StateMachineParser.parse(jsonStateMachine);
The parser will also validate the given json string. If something is wrong,
it will throw a ValidationException
which will contain detailed information
what's wrong.
Parsing the json will give you a StateMachineCompiler
. This compiler knows
which eventSourceId
s might send events, and it knows, the deviceId
s
of the devices where commandString
s will be executed.
Set<Integer> eventSourceIds = compiler.getAcceptedEventSources();
Set<Integer> deviceIds = compiler.getRequiredDevices();
The eventSourceIds
will be important later during execution of the state machine.
The deviceIds
are important to actually "compile" the state machine. For
each given deviceId
the caller has to provide an implementation of the interface
DeviceCommandCompiler
.
public interface DeviceCommandCompiler {
DeviceCommand compile(String commandString) throws Exception;
}
for a given commandString
this DeviceCommandCompiler
must be able to
create an implementation of the interface DeviceCommand
.
public interface DeviceCommand {
void execute();
}
How this is done is completely up to the specific DeviceCommandCompiler
.
If DeviceCommandCompilers
are available for all the requested deviceIds
it's possible to compile the state machine:
Map<Integer, DeviceCommandCompiler> commandCompilers = Map.of(...);
final StateMachine machine = compiler.compileStateMachine(commandCompilers);
During compilation a CompilationException
might be thrown (e.g. if compiling
a command throws or if a command compiler is missing). This exception will
contain information to figure out, what's wrong.
If compilation succeeds you'll have a StateMachine
where you basically only
have to inject events and the machine takes care of the rest. Only events originating
from those eventSourceIds
indicated by the parser will trigger an action.
All others will be ignored (a log message will be emitted).
boolean isFinal = machine.injectEvent(3, "move ya");
The return value will tell you whether you've reached a final state. (Of course it's possible to leave a final state if you inject another event.) Beneath informing the caller about the final state it does not have any effects on the state machine.
The top-level structure of the accepted json format is shown in the block below.
You can see four of the five elements of a finite automata. The transitions
are
placed at their respective start state.
{
"initialState" : "...",
"triggers" : [ ... ],
"states" : [ ... ],
"finalStates" : [ ... ]
}
The initialState
is mandatory. Its value is a string denoting the name of the state where
execution of the state machine starts. Note that the machine starts inside this state.
If there are onEntry
behaviors they will not get executed on startup.
The triggers
entry is mandatory. Its value is an array of trigger descriptions
which must contain at least one entry. Each description of a trigger has this format:
{
"name" : "...",
"eventSourceId" : int,
"eventPayload" : "..."
}
A trigger describes an event coming from a specific source (identified via an ID)
and having a known payload. Every time the user's code calls
injectEvent(int eventSourceId, String eventPayload)
the state machine
tries to find a matching trigger. If one is found, and the current state
has a transition with states[].transitions[].triggerName
being equal to
the trigger's name
then this transition will be traversed.
The name
of the trigger is a string; there must not be two triggers
with the same name.
The eventSourceId
(an integer value) defines from which input source
this event is expected to arrive. The eventPayload
(a string) describes
the exact event coming from that source. It is not allowed to have two triggers with
identical content (eventSourceId
plus eventPayload
).
Of course eventPayload
might be a self-chosen description in case your
event is not a string. In this case your event source needs to have a "translation layer"
which emits events with string payloads.
Probably the biggest and most important part of the state machine description.
The states
entry is mandatory. Its value is an array of state descriptions:
{
"name" : "...",
"onEntry" : [ ... ],
"onExit" : [ ... ],
"transitions" : [ ... ]
}
The name
entry (a string) is mandatory. It denotes the name of this state.
Each state must have a unique name.
The entries onEntry
and onExit
are optional. If present, they contain
arrays of behaviors to be executed when the state is entered or left (via transition).
This happens even if the target state of a transition is the state itself
("self-transition").
A behavior is defined like this
{
"deviceId": int,
"commandString": "..."
}
where deviceId
identifies "the device" where a command is executed and commandString
describes the actual command. Note that it is called "device" even though it doesn't need
to be a piece of hardware. A "device", as used by this library, might also be a REST-call,
a logger, a connection to an MQTT broker, a command line, ... whatever you have an adapter
for.
The transitions
entry is optional (so you might have states with no way out).
If present, it contains an array of transition descriptions.
{
"triggerName": "...",
"ignore": boolean,
"targetState": "...",
"effects": [ ... ]
}
The triggerName
(a string) is mandatory. It will be matched with incoming events and
if they equal the transition will be traversed.
The entry ignore
says, whether events matching this trigger should be ignored.
If ignore
is not given its value is treated as false
. If this entry
is given and its value is true
then the entries targetState
and effects
are ignored, too (so they become optional). This flag is mostly useful for expressing
intent: as a documentation for others as well as for the state machine during execution.
If an event arrives in a state with no matching trigger the lib will log
a warning message. Ignored transitions can be seen as (internal) self-transitions without
effects and disabled entry/exit behaviors.
The targetState
entry is mandatory (except if ignore
is true
). It contains the
name of the state where the machine ends up if the transition is traversed.
The effects
entry is optional. If given, it contains an array of behaviors
which will be executed during traversal of the transition. See above at the
entry/exit behaviors for the structure.
The finalStates
entry is optional. If present its value is an array of
strings where each of them is the name of a state (if no state with the
given name exist the parser will throw an exception).
If during execution the state machine reaches one of these states, the method
StateMachine.injectEvent()
will return true
. For non-final states it will
return false
.
(The name of the current state can be queried via StateMachine.getCurrentState()
. There's
also a method StateMachine.isCurrentStateFinal()
.)
Apache 2.0 License
Created and maintained by Hannes Lerchl
Feel free to send in pull requests. Please also add unit tests and adapt the README if appropriate.