alcumus-behaviours

1.1.36 • Public • Published

Stateful Behaviours

I'm English and in English, 'behaviour' is spelled with a 'u' :)

Behaviours are a way of decorating a document or object with one or more components that can manage and process the contents of the document. Behaviours are used widely in computer game platforms like Unity's MonoBehaviour and PlayCanvas's pc.script where isolated atomic functionality can be developed and applied to multiple objects.

Behaviours work well in loosely coupled systems as they provide a way to call methods at specific moments on the instance of a document without having to maintain event references which can lead to memory leaks. As you can decorate a document with multiple behaviours you can easily aggregate the functionality required for a complex system from simple consitituent components.

Most programs contain some kind of "stateful" functionality often implemented using if or switch statements. Declarative Finite State Machines provide a more structured way of implementing stateful functionality by defining a finite set of possible states and providing functionality that varies by state, including the ability to write enter() and exit() functions to be executed as states are transitioned.

This library allows you to declare behaviours as Declarative Finite State Machines and apply them to any object, additionally providing a method to serialize and deserialize objects to JSON maintaining declared behaviours and state on rehydration. Each document is assigned a single state, and each behaviour can choose to implement whichever states are necessary to provide their own functionality.

Using this library you can:

  • Add multiple behaviours to any object
  • Declare behaviours that contain data and methods
  • Declare behaviours that have polymorphic methods based on current state
  • Manage state transitions (including denying transition)
  • Declare dynamic states (for instance states driven by configuration) and have them map to well defined state definitions in the behaviour
  • Serialize and rehydrate objects that have attached behaviours to JSON. When rehydrated the state and methods of the behaviours are maintained
  • Declare different implementations of behaviours on server and client side as necessary
  • Use events to modify standard behaviours and to supply missing behaviours dynamically and in other ways customise the serialization and deserialization of objects

Installation

npm install --save alcumus-behaviours

Use

You can add behaviours to any object (that doesn't declare properties called behaviours or _behaviours) by calling Behaviours.initialize. This adds the behaviours property which is used to interact with all of the other funcitonality provided.

import * as Behaviours from "behaviours";
 
// Initialize an object
 
let myObject = {};
Behaviours.initalize(myObject);
 
// Serialize an object
let result = Behaviours.stringify(myObject);
 
// Parse an object from a string (and re-establish any attached behaviours)
let myObject = Behaviours.parse(result);
 
// Register a behaviour
Behaviours.register("example", {
    initialize() {
        this.argument = this.argument || "one";
        console.log(this.argument);
    },
    methods: {
        test() {
            console.log("test");
        }
    } 
});
 
// Attach behaviours to an object
myObject.behaviours.add("example", {argument: "testing "}); // LOGS: testing
myObject.behaviours.add("example"); // LOGS: one
myObject.behaviours.sendMessage("test"); // LOGS: test x 2
myObject.behaviours.test(); // LOGS: test x 2
 

Simple Behaviours

A behaviour allows you to attach functionality to a document, each behaviour carries its own instance parameters and can supply methods to the underlying document. Unless explicitly called on an instance, a method with the same name will be called on all behaviours that support it which are attached to the object.

Behaviours may modify the document, or just be responsible for notifications or state changes.

Each behaviour should be as limited as possible in its functionality to maximise reuse.

Behaviours are most powerful when implemented as processing in a loosely coupled environment where there are key moments to call key functions and pass processing to the behaviours methods.

Declaring Behaviours

You declare a behaviour by registering an object with the Behaviours system, using a unique name.

import * as Behaviours from 'behaviours';
 
Behaviours.register("myBehaviour", {
    initialize() {
        /* Function to be called when the behaviour is added */
    },
    defaults: {
        /* A default set of name value pairs to initialize the instance */
        someProperty: "some value"
    },
    destroy() {
        /* Function to be called when the behaviour is removed
            or the document destroyed.
        */
    },
    methods: {
        /* List of methods to declare, 'this' will be the instance */
        test() {
            console.log(this);
        }
    },
    states: {
        /* List of state definitions - see later */
    }
});
 

Initializing Objects And Adding Behaviours

Once you have registered some behaviours you are ready to add them to documents to aid in processing. You may add the same behaviour to an object more than once, each instance has its own state.

Behaviours should be registered before being added to an object or before rehydrating objects from storage, this is normally achieved at startup time. Advanced functionality can be implemented using events to dynamically create behaviours that are missing or are programatically designed. See later.

Firstly you need to make the document capable of having behaviours:

let myDocument = {content: "Some content"};
Behaviours.initialize(myDocument);

Once you have initialized the document it will have a behaviours property which is used to access the rest of the functionality.

You add a new instance of a behaviour by calling the add(behaviourName, optionalInstance) method.

myDocument.behaviours.add("myBehaviour"); // Adds a behaviour with no instance data
myDocument.behaviours.add("myBehaviour", {arg1: 1, arg2: "2"}); // Add with instance data

Each behaviour can have an initialize() function declared which will be called when the behaviour is added. postInitialize() will be called after a setTimeout(fn, 0).

Calling Behaviour Methods

Methods are more useful than events in the behaviours system as they will not hold a reference to the component making memory leaks far less likely.

Methods are the primary way that the outer program will interact with the behaviours. Normally a program will call methods are well known intervals or as a reaction to some user event.

Methods declared on behaviours (and on the states within behaviours) are added to the behaviours property and additionally they can be called programmatically by using the sendMessage function available on behaviours.

The result of calling an undefined method using sendMessage or calling a method that has no definition in the current state but otherwise exists in some state will result in an undefined result

sendMessage

sendMessage is used when you want to programmatically call a method that may or may not exist. Frequestly this method will be used when the name of the method has been configured in some kind of interface.

let result = myDocument.behaviours.sendMessage(configuration.method, configuration.parameter);
Directly calling a method

A framework will often have a series of methods it calls at well known points. Normally you will call those methods using the direct call.

let result = myDocument.behaviours.doSomething("with a parameter");

Implementing Behaviour methods

You write behaviour methods as functions that can take parameters, during execution the this will point to the specific instance of the behaviour and the behaviours instance has a read only document property so you can get access to the underlying document.

 
    function processDocument(time) {
        if(!this.processed) {
            this.processed = true;
            this.document.contents = `PROCESSED@${time}:${this.document.contents}`;
        }
    }
    
    Behaviours.register("myBehaviour", {methods: {processDocument}});
    let myDocument = { contents: "some contents"};
    Behaviours.initialize(myDocument);
    myDocument.behaviours.add("myBehaviour");
    myDocument.behaviours.processDocument(Date.now());
 

Removing Behaviours

  • Temporary behaviours are not persisted and will not exist on objects that are rehydrated
  • Calling DOCUMENT.behaviours.destroy() will remove all behaviours from a document
  • Calling destroy on an instance of a behaviour will remove it
  • Calling DOCUMENT.behaviours.remove(behaviourName, instance) will remove a specific instance from the document or DOCUMENT.behaviours.remove(behaviourName) will remove all behaviours of a type from a document.

When a behaviour is removed its destroy() function will be invoked.

Communicating Between Behaviours

Normally behaviours are quite isolated and use methods to communicate loosely. There may be circumstances in which a behaviour needs to communicate specifically with another instance.

Instances may access other instances through the instances property on the behaviours property. The instances property contains arrays of each type, keyed by the behaviour name.

Behavoiur.register("someBehaviour", {
    methods: {
        doSomething() {
            console.log("here");
        }
    }
});
Behavoiur.register("otherBehaviour", {
    methods: {
        test() {
            this.document.instances.someBehaviour[0].doSomething();
        }
    }
});
let document = { contents: "Some contents" };
Behaviours.initialize(document);
document.behaviours.add("someBehaviour");
document.behaviours.add("otherBehaviour");
document.behaviours.test();

Setting Execution Order & Cancelling Subsequent Calls

You can specify the order in which instances of behaviours are called when executing methods. You can specify the order by adding a _priority property to the instance of the behaviour.

If a behaviour method throws a new Behaviours.Cancel then the remaining methods will not be called on the other behaviours, but no exception will be thrown to the controlling code.

        let counter = 0;
        const test = {};
        Behaviours.register("test", {
            methods: {
                test() {
                    counter++;
                    expect(counter).to.equal(this.testValue);
                    if (this.terminate) {
                        throw new Behaviours.Cancel;
                    }
                }
            }
        });
        Behaviours.initialize(test);
        test.behaviours.add('test', {testValue: 2, terminate: true});
        test.behaviours.add('test', {_priority: 10, testValue: 1});
        test.behaviours.add('test', {_priority: 101, testValue: 3});
        test.behaviours.test();
        expect(counter).to.equal(2);

Serialization

You will often want to store documents in some permanent storage and then retrieve them with the behaviours attached and states maintained. To enable this, you can use the Behaviours.stringify(object, replacer, spaces) method to store the object and Behaviours.parse(object, reviver) to rehydrate it. The parameters are the same as the JSON methods with the same names.

When the document is rehydrated the system will re-attach the behaviours and fire events during the process. One key event is the behaviour.add.BEHAVIOURNAME event, which will be fired for each behaviour type that has not been registered.

State Machines

Declarative Finite State Machines provide a method to declare functionality that varies based on the current state of a document and processes that should occur as the state transitions.

In addition state changes may be programmatically blocked.

In Stateful Behaviours each document has a single state that is accessed using someObject.behaviours.state

The default State

When the system is initialized the document will be in the default state. This state is also used if the state property is set to anything that == undefined (note that the returned value of state in this case will be 'default').

Declaring States

You declare states inside a behaviour:

Behaviours.register("myStatefulBehaviour", {
    states: {
        default: {
            methods: {
                test() {
                    console.log("test in DEFAULT");
                }
            }
        },
        example: {
            methods: {
                test() {
                    console.log("test in EXAMPLE");
                }
            }
        }
    }
});

Setting State

You set the state using the state property on the behaviours added to a document.

let document = { contents: "Some contents" };
Behaviours.initialize(document);
document.behaviours.add("myStatefulBehaviour");
document.behaviours.test(); // LOGS: test in DEFAULT
document.behaviours.state = "example";
document.behaviours.test(); // LOGS: test in EXAMPLE

Functionality Implemented On State Change

You can have functions execute as the state changes. An exit() function is called on the current state, followed by an enter() funciton on the target state. If the functions are asynchronous then the exit will complete before the enter is called.

Behaviours.register("myStatefulBehaviour", {
    states: {
        default: {
            enter() {
                console.log("enter DEFAULT");  
            },
            exit() {
                console.log("exit DEFAULT");
            }
        },
        example: {
            enter() {
                console.log("enter EXAMPLE");  
            },
            exit() {
                console.log("exit EXAMPLE");
            }
        }
    }
});
 
let document = { contents: "Some contents" };
Behaviours.initialize(document);
document.behaviours.add("myStatefulBehaviour"); // LOGS: enter DEFAULT
document.behaviours.state = "example"; // LOGS: exit DEFAULT, enter EXAMPLE
document.behaviours.state = null; // LOGS: exit EXAMPLE, enter DEFAULT

Controlling State Changes

You can cancel a state change by implementing canExit(context) and canEnter(context) methods on the states involved in the transition. These functions can cancel state change by changing the value of context.canChange. Additionally context contains startState and endState which may be used when deciding whether to allow the change.

 Behaviours.register("myStatefulBehaviour", {
     states: {
         default: {
             enter() {
                 console.log("enter DEFAULT");  
             },
             exit() {
                 console.log("exit DEFAULT");
             },
             canExit(context) {
                 this.attempts = (this.attempts || 0) + 1;
                 context.canChange = this.attempts >= 2;
                 console.log(`DEFAULT canExit:${context.canChange}`);
             }
         },
         example: {
             canEnter(context) {
                 this.attempts = (this.attempts || 0) + 1;
                 context.canChange = this.attempts >= 2;
                 console.log(`EXAMPLE canEnter:${context.canChange}`);
             },
             enter() {
                 console.log("enter EXAMPLE");  
             },
             exit() {
                 console.log("exit EXAMPLE");
             }
         }
     }
 });
 
 let document = { contents: "Some contents" };
 Behaviours.initialize(document);
 document.behaviours.add("myStatefulBehaviour"); // LOGS: enter DEFAULT
 document.behaviours.state = "example"; // LOGS: DEFAULT canExit:false
 document.behaviours.state = "example"; // LOGS: DEFAULT canExit:true, EXAMPLE canEnter:false
 document.behaviours.state = "example"; // LOGS: DEFAULT canExit:true, EXAMPLE canEnter:true, exit DEFAULT, enter EXAMPLE
 

Default Functionality

When a state does not provide a method, but it is declared on the behaviour then the behaviour method is used instead.

Behaviours.register("myStatefulBehaviour", {
    methods: {
        test() {
            console.log("Default method")
        }  
    },
    states: {
        default: {
            methods: {
                test() {
                    console.log("Test method");
                }
            }
        },
        example: {}
    }
});
let document = { contents: "Some contents" };
Behaviours.initialize(document);
document.behaviours.add("myStatefulBehaviour");
document.behaviours.test(); // LOGS: Test method
document.behaviours.state = "example";
document.behaviours.test(); // LOGS: Default method
 

Asynchronous Functionality

All methods appended to the behaviours interface are also added with an xxxAsync version. sendMessage also has a sendMessageAsync version.

You use this protocol to issue a method asynchronously across all attached behaviours - note that while methods on multiple behaviours will be started in behaviour order, the system does not wait for each method to complete before starting the next.

async function doSomething() {
    await myObject.behaviours.testAsync();
    await myObject.behaviours.sendMessageAsync("someMethod", "param1", 2, 3);
}

enter() and exit() functions may return a promise, in which case all of the exit functions will be run to completion before enter methods start.

If you need to ensure a state transition is complete before proceeding in code you can either await DOCUMENT.behaviours.ready or use the setState(state) function which returns a promise.

await myDocument.behaviours.setState("newState");

Implementation Detail

When an object is decorated with behaviours it will have a read only behaviours property and a private _behaviours member that contains all of the data. All interaction with methods and state happens through the behaviours property.

Advanced Features

Front End/Back End functionality

You may implement behaviours differently on front and back end as required. They may have different methods etc based on the requirement of the interface. The document must have all of the behaviours available to successfully rehydrate, but this could be achieved with dummy behaviours on one side if required.

Supplying Missing Behaviours

If a behaviour is not available when an object is rehydrated an exception will be thrown. You can avoid this by dynamically providing a behaviour through the response to an event.

Behavour.events.on("behaviour.add.*", function(context, info) {
    info.availableBehaviour = {
        /* Any kind of behaviour definition */
    };
});

The info passed to the event contains the behaviourName being created and the instance containing the behaviours data. You set the availableBehaviour to an object defining the state (or just empty for no functionality).

You can also supply the name of the behaviour rather than * to write specific behaviour handling.

Using Events To Customise Functionality

Behaviour.events provides a way of writing event handlers to override standard functionality. For instance an event is fired before every method call to allow methods to be overridden. The event handler function should always have a first parameter of context (from local-events/hooked-events) and the second (and additional parameters) are listed below.

Behaviour.events.on('behaviour.stringify', function(context, {source, replacer, space}) {
    /* Do something */
}); 

 ---------------------------------------------------------------------------------
 NAME behaviour.  | PARAMETERS           |  PURPOSE
 ---------------------------------------------------------------------------------
 stringify        | { source, replacer,  |  Called before stringifying
                  |   space }            |  an object             
 ---------------------------------------------------------------------------------
 stringified      | { source, replacer,  |  Called after stringification
                  |   space, result }    |  with the output result
 ---------------------------------------------------------------------------------
 parse            | { source, reviver }  |  Called before reviving an object
 ---------------------------------------------------------------------------------
 parsed.pre       | { source, reviver,   |  Called after parsing the object
                  |   result }           |  but before behaviours are 
                  |                      |  attached              
 ---------------------------------------------------------------------------------
 parsed.post      | { source, revivier,  |  Called after parsing when the 
                  |   result }           |  behaviours have been attached
 ---------------------------------------------------------------------------------
 BEHAVIOUR.MSG    | instance, ...params  |  Called when a method is called on 
                  |                      |  a behaviour to override standard 
  * not proceeded |                      |  functionality (include initialize
    by            |                      |  and destroy and all custom
    behaviour.    |                      |  methods)              
 ---------------------------------------------------------------------------------
 add.X            | { availableBehaviour,|  Called when a behaviour is missing
                  |   behaviourName,     |                        
                  |   instance }         |                        

See tests for examples.

Readme

Keywords

none

Package Sidebar

Install

npm i alcumus-behaviours

Weekly Downloads

1

Version

1.1.36

License

ISC

Unpacked Size

57.6 kB

Total Files

3

Last publish

Collaborators

  • itinfrastructure_alcumusgroup
  • andtrobs
  • mattmogford-alcumus
  • michael.john.talbot