1.0.1 • Published 5 years ago

@henson/fsm v1.0.1

Weekly downloads
1
License
MIT
Repository
-
Last release
5 years ago

Generic Finite State Machine

Table of Contents

This component is a generic purpose state machine framework which can be used by both frontend and backend for enforcing the input state machine on application to make application easier to understand, easier to test and more robust to the corner cases.

The Henson OAuth portal frontend SPA is built upon fsm.

The reasons of having a state machine in frontend app

A typical way of building a frontend app usually starts with the UI design pages, these pages are the concrete representation and interpretation of the user requirements, the UI design pages might be provided by customers or by UI designers from development team, but either way, there is a big chance that the UI design will not fully cover all the corners of the requirements(because normally UI design pages demonstrates the requirements in a linear way, a main flow with a few branches to cover some abnormal flows), then to developers, if they build the app directly from the UI design, the product will be built in a linear way too, which leads to three problems:

  • Scattered business logic, UI design is the representation of the requirements, or business logic, and normally each UI component in UI design will carry some of business logic, which inevitably leads to that the business logic get scattered in different UI components, which makes understanding the core business logic of the app from code level become very hard, also once business logic changes, more effort will be put into locating and updating impacted UI components
  • More code, during the development process, when developers stumble upon the abnormal branches or flows which are not covered by UI design, they will start to add "if … else …" clauses to handle them, which increases the number of lines of code and the development cost, test cost etc.(take OAuth portal UI design as an example, in customers UI design, it doesn’t specify what the application should behave if user taps "get new passcode" twice in a quick succession, should application send the request twice or just once if the last request hasn't been finished?)
  • Unexpected behavior, if developer misses the corner cases or abnormal flows during the development and test phase, then in production environment, if such a corner case happens, application will be in an unknown state, and in the worst case scenario which might will crash the app or make app malfunction, and also to trouble shooting such a problem is usually very hard.

solution to these problems

Use a state machine.

When developer receives the UI design from customer, the first thing she/he needs to do is not implementing app right away, but abstracting the requirements from the UI design pages first, and then describing abstracted requirement in a state machine, because state machine defines the boundary of the problem domain, so which can help developer to find out all the possible corner cases or abnormal flows that UI designer or customer hasn't thought of at very beginning, since state machine only accepts the what defined within it so it can naturally ignore the input it doesn't understand and keep application from falling into weird state.

In a OO programming world, when we model an entity in a problem domain, we don't directly create a concrete class on that entity, instead we abstract that entity to some higher level first and then create an abstract class/interface based on it, the concrete class implementation for that entity comes later, so in this practice we don't write the code directly from the real world concrete entities, but we normally do it in this order:

real world entity -> abstraction -> implementation

Abstraction is such a general methodology which can not only be applied to OO programming but to other software domains as well, GUI based application is one of them, when we build a GUI based application, we should build an abstraction based on the UI design first, the abstraction helps to find out the boundary of the application and find out missing pieces in the original requirements, the abstraction can be in many forms, here we use a state machine to present it, because the interaction happens on GUI is essentially state transferring, so modeling it in state machine feels very natural, once we have the state machine in place, we can start to implement the UI components based on it.

npm.io

The View-State-Event pattern

fsm takes a view-state-event approach to integrate with application, which is very much like the Flux/Redux equiped with a state machine.

npm.io

  • View(UI component) performs Actions
  • Action generates Events
  • Event changes States
  • State affects Views

Benefits

  • the business flow is described in a single place, easier to grasp and maintain the business logic
  • less code, less if-else clauses
  • testability, consider it as a MVP-ish pattern
  • different levels of code reusability, which makes it flexible on requirement changes
  • view-businessFlow separation allows more fine-grained frontend task split
  • robust application, no weird state, application is confined in state machine boundary
  • test efficient, less code less test cases, simpler test cases

Usage

import { fsm } from '@henson/common';

const stateC: fsm.StateCollection = fsm.create();
// add state into state collection with state transfer rules
stateC.add('start').with('eventA').to('stateA');
stateC.add('stateA').with('eventB').to('end');

// validate the input states before creating a state machine out of it
stateC.validate();

// create a state machine from provided states
const myFsm: fsm.FiniteStateMachine = stateC.createStateMachine();
// change state
myFsm.fire(someAction, somePayload).then(data => {
  // state is successfully changed, get the latest state and do your things here
  const newState = myFsm.state;
}).catch(err => {
  // state transfer fails, do your things for exception handling here
}
);

state machine

To use fsm, application must first create a StateCollection with method create(), and then use add(), with() and to() functions to provide state transfer rules into states as example shown above.

Once states are all feeded into state collection, a validate() method must be called to validate the provided states, once the states are validated, user can use createStateMachine() function to create a FiniteStateMachine out of states just provided.

interface StateCollection {
  add(stateName: string): State;
  validate(): void;
  createStateMachine(): FiniteStateMachine;
}

A state transfer rule example:

states.add('start').with('eventA').to('stateA');

It read: "start" state will be transfered to "stateA" state when event "eventA" happens

methods from StateCollection

add

  add(stateName: string): State

This method adds a state to StateCollection by providing a state name, if a state is already added for this state name, then the existing state will be returned, otherwise a newly created state is returned

validate

  validate(): void

This method will check all added states for state machine integrity, on error it will throw two Error exceptions:

  • start or end state missing ...: indicate that start or end state is missing in the provided state machine.
  • unreachable states ...: the unreachable states in provided state machine are found.

This method MUST be called before calling createStateMachine() to create a state machine.

createStateMachine

  createStateMachine(): FiniteStateMachine

This method will create a state machine from the StateCollection, this function will throw an Error exception if states are not validated.

A StateCollection can be used to create multiple state machines, each state machine will manage its own state transfer but share the same state definitions, multiple state machines with one StateCollection can be used by backend application which keeps state machine per user/session while with the same StateCollection

State

interface State {
  readonly name: string;
  readonly transferRules: Map<Event, StateTransferRule>;
  with(event: Event): StateTransferRule;
}

with

  with(event: Event): StateTransferRule

This method will add a state transfer rule into the state, the stateTransferRule indicates which state will be transfered to once the given event happens.

StateTransferRule

interface StateTransferRule {
  readonly toState: State;
  to(toState: string): void;
}

to

  to(stateName: string): void

This method defines which state this rule will transfer the state machine to when given event happens

FiniteStateMachine

Once createStateMachine() is successfully called, a finite state machine will be returned to application, the application can then use it to access the application state or to change the state, an application can create more than one state machines.

interface FiniteStateMachine {
  state: string;
  history: Event[];
  maxHist: number;
  lastFeedback: any;
  fire(actions: Action, payload?: any): Promise<any>;
  ignore(): boolean;
}
  • state: the current state of state machine
  • history: the received event history of state machine
  • maxHist: the maximum number of received events state machine will keep in history, by default it's 20
  • lastFeedback: the return data from the latest performed action

fire

  fire(actions: Action, payload?: any): Promise<any>;

This method is the only way to change the state of the state machine, the method is called with a Action and optionally with a payload, the state machine can accept or reject the requested action depends on the current state of the application. If state machine accepts the action, the action will be performed and a Promise will be returned, if the promise is resolved, then state is successfully transfered for this action, the application can then carry on the following actions upon the state transfer, if the promise is reject, then state will stay unchanged and with an Error thrown out.

Thrown exceptions of fire:

  • having an action at now ...: state machine throws this exception when state machine is still processing an Action while application requests to perform another Action.
  • event denied ...: state machine throws this exception when an event emitted by a Action is not found on this Action's "outcomes" list, the event will be ignored and state will stay unchanged if this happens.
  • action refused ...: state machine throws this exception when application requests to perform a Action that is not allowed at current state according to the state machine.

ignore

  ignore(): boolean;

This method is used to ignore the onging action in state machine, if state machine is executing a Action when this method is called, the event returned by the Action will be ignored and the return data from the action will be replaced by undefined and the method will return true to the caller, if state machine is not doing anything when this method is called, a false will be returned and nothing will be changed.

Note that, when state machine is processing a fire() execution, it will be in busy mode, and will reject all other Action requests from application except ignore request.

Action

In a state machine, the only way to transfer one state to another is by events, the events are caused by the actions taken, so to make the state machine work, the actions must be provided by application.

Important Note: The action MUST be stateless to encourage the reusability for different state machines, which means the execute() method in Action must be side-effect free like a pure function, it SHOULDN'T change any global variables, or class variables or instance variables, it shouldn't change anything at all, otherwise the Action will become unpredictable everytime it's called and so will be the state machine, keep in mind that, an Action is just pure function wrapped in a class.

The event names returned by Action MUST match the event names in state collection.

interface Action {
  name: string;
  outcomes: Event[];
  execute(payload?: any): Promise<Feedback> | Feedback;
}

interface Feedback {
  event: Event;
  data?: any;
}
  • name: the name of the action
  • outcomes: A list of event names that might will be caused by this action

execute

  execute(payload?: any): Promise<Feedback> | Feedback;

The method which will be called by state machine to carry out the action, the method should return a Feedback to state machine either synchronously or asynchronously via a Promise, the event in Feedback will be used to determine the next state of state machine, and the data in the Feedback will be either returned to application via a Promise, or via lastFeedback.

Exmaple action:

class Cancel implements Action {
  private readonly eClosed = events.cancelled;

  get name(): string {
    return 'cancel';
  }

  get outcomes(): string[] {
    return [this.eClosed];
  }

  execute(): Feedback {
    return { event: this.eClosed };
  }
}

Burst mode

fsm provides a helper function called burst to execute 2 Actions in a row, as its name says, it first fires the first Action, and then calls the callback function to notify the state change and then loads the second Action with the return data from first Action as its input and fire it again.

function burst(action: Action, connectingAction: Action, onChange: (state: string) => void,
               fsm: FiniteStateMachine, payload?: any): Promise<any> {
  return fsm.fire(action, payload).then((wrappedPromise: any) => {
    onChange(fsm.state);
    return fsm.fire(connectingAction, wrappedPromise)
  }).then((data: any) => {
    onChange(fsm.state);
    return data;
  }).catch((err: any) => err);
}

Handling The "Middle" State

For GUI based applications, there is always the need to be able to support the "middle" state, such a middle state normally is the states like "loading", "fetching", "freshing" etc. To support the middle state in fsm, a good practice is split your action into two fsm Actions, and let the first Action yield a middle state, and the second one yield the final state.

Below is an example which splits a fetch operation into two Actions, so application can have a "fetching" state in its state machine, and this "fetching" state can be used to render the loading bar on the screen while fetch is still onging.

class SendUEVerificationRequest implements fsm.Action {
  private readonly eSent = events.ue_verif_sent;

  get name(): string {
    return 'send_ue_verification_request';
  }

  get outcomes(): string[] {
    return [this.eSent];
  }

  execute(payload: UEInfo): fsm.Feedback {
    const context = fetch(verifyUEUri, getOpt('POST', payload), timeout);
    // we wrap the promise returned by fetch into an object and pass it down to the second Action
    // it's essentially important to wrap the returned promise into an object, otherwise the returned 
    // promise will be resolved by fsm.fire().then() which then will let the second Action get the
    // resolved value instead of a promise, and then the middle state will be lost

    // return a sent event, and this event will let state machine transfer to a "fetching" state
    return {event: this.eSent, data: { context }};
  }
}

class ReceiveUEVerificationRsp implements fsm.Action {
  private readonly eVerified = events.ue_verif_suc;
  private readonly eFailed = events.ue_verif_fail;

  get name(): string {
    return 'receive_ue_verification_response';
  }

  get outcomes(): string[] {
    return [this.eFailed, this.eVerified];
  }

  // unwrap the promise and return the final state 
  execute({ context }: {context: Promise<Response>}): Promise<fsm.Feedback> {
    return context.then(data => {event: this.eVerified}).catch(err => {event: this.eFailed});
  }
}

// use the actions
const send = new SendUEVerificationRequest();
const recv = new ReceiveUEVerificationRsp();
fsm.fire(send, somepayload).then(wrappedPromise => 
  // now the state machine should be in "fetching" state, lets carry on the request
  fsm.fire(recv, wrappedPromise)).then(data =>
  // now the state machine should be in final state
  ...
);

// or with burst()
burst(send, recv, onStateChange, fsm, somepayload).then(data =>
  // now the state machine should be in final state
  ...
);

Examples

For how to define Action, detailed examples can be found in file test/fsm_example/actions.ts.

For how to define state collection, detailed examples can be found in file test/fsm_example/statemachine.ts.

For how to use fsm in an application, please check the test cases in test/test-fsm.ts

1.0.1

5 years ago