alcumus-behaviours v1.1.36
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 orDOCUMENT.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.
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
5 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
6 years ago