Reverse the direction of the API for sending messages from
messageDefinition.send(link, { content });
to
link.send(new MessageClass(content));
and convert all message definitions to use the JSON serialisable class
pattern and explicit registration of message handlers, along with a
end-to-end based routing of messages instead of link to link.
The experience I gained from the existing way messages were defined and
sent over links was that it was difficult to understand for people
unfamiliar with the system. The automatic registration of message
handlers ment that code was being called as if by magic from nowhere
with no easy way to discover it. The manual forwarding and rules for
what can be sent where and how obscured its working.
The use of pure JSON as both input and output from message handlers made
it tedious to work with. Data had to be manually converted between the
program representation and the over the wire format, which made handler
code unplesant to write. Particularly because the over the wire format
used snake_case while the program code uses camelCase for variables.
The idea behind the decision to do it this way was to make it more clear
which data came from the wire and which from the program, and while this
worked it didn't make message handlers any nicer to write.
The direction of the API and magic registration of message handlers also
meant that a future migration to TypeScript would be troublesome. While
it may be possible to implement, the degree of TypeScript magic required
would go starkly against the principle of keep it simple stupid.
By refactoring to using the JSON serialisable class pattern most of
these issues go away. There is no longer a need to distinguish the on
wire format of messages with the program representation because the only
place that deals with the wire format of messages are the toJSON and
fromJSON methods on the classes themselves. Message handlers will work
with proper instances of classes instead of raw data, something that
makes them easier to write, and trivial to type with TypeScript.
End-to-end routing also simplifies sending messages that need to do
mulitple hops to reach the destination. And removes the need for having
all messages be specified in plugin's info module, as they can be routed
without having to be parsed completely.
The explicit registration of message handlers is something that I
thought would be tedious boilerplate code that could be automated away.
But experience now shows it to be the opposite. Allthough dull
repetition, the purpose it serves is to link together the code in a way
that humans can easily find. With explicit registration searching the
name of the message handler in the editor will show the registration that
ties it together with the type of message it handles and the kind of
registration. With implicit registration searching the name of the
message handler turns up only the handler with no clue as to what calls
it.
4.7 KiB
JSON Serialisable Classes
To serialise and deserialise data the Clusterio codebase uses a JSON serialisable data class pattern.
Instances of these classes can be serialised to JSON by passing them directly to JSON.serialise and deserialised by passing the serialised JSON representation to its static fromJSON method.
The data class also has a static jsonSchema property describing the serialised JSON representation for validation purposes.
See the following example code for a hypothetical Data class with a single property member defaulting to a value of "foo".
// Data.js
"use strictc";
/**
* A Data class example
* @alias module:Data
*/
class Data {
/** @type {string} */
member = "foo";
constructor(member) {
if (member !== undefined) { this.member = member; }
}
static jsonSchema = {
type: "object",
properties: {
"member": { type: "string" },
},
};
static fromJSON(json) {
return new this(json.member);
}
// toJSON is called by JSON.stringify when serialising this class
toJSON() {
let json = {};
// Omit property if it's set to the default value
if (this.member !== "foo") { json["member"] = this.member; }
return json;
}
}
module.exports = Data;
Serialising an instance of Data for JSON is as simple as doing serialised = JSON.stringify(data) as the toJSON method is automatically called by JSON.stringify to create a suitable object.
Deserialising is done by calling the static fromJSON method with the serialised JSON data, like data = Data.fromJSON(JSON.parse(serialised)).
By structuring the classes like this they can be easily composed together. For example a class holding a data property with an instance of the Data class from the previous example can be created as follows.
// Composed.js
"use strict";
const Data = require("./Data");
/**
* Composed data class example
* @alias module:Data
*/
class Composed {
/** @type {module:Data|undefined} */
data;
constructor(data) {
if (data) { this.data = data; }
}
static jsonSchema = {
type: "object",
properties: {
"data": Data.jsonSchema,
},
};
static fromJSON(json) {
let data;
if (json["data"]) { data = Data.fromJSON(json["data"]); }
return new this(data);
}
// toJSON is optional and not needed for this particular class
}
module.exports = Composed;
It's easy to create a new instance of Composed by calling new Composed(new Data("spam")) and serialising to and from JSON is the same as for Data.
Meaning serialised = JSON.stringify(composed) and composed = Composed.fromJSON(JSON.parse(serialised)) does as expected.
Interface description
Data classes are ES6 style class declarations with the following methods and fields:
toJSON(): any (optional)
Method returning an object or primitive suitable for passing to JSON.stringify in order to create a JSON representation of the class instance. The returned object may contain objects that have their own toJSON methods which JSON.stringify will then call recursively.
This method is optional if calling JSON.stringify on an instance without it would produce the desired serialised form, however this is rarely the case as often you'll have to provide it to convert the values fram Map/Set properites, omit default values or do similar actions.
Note that the primary use case for serialising data classes is to send them over the network, this method should therefore try to reduce the size of the resulting JSON by for example omitting properties who's values are the default value.
static fromJSON(json: any): this
Method constructing an instance of this class from one that was previously serialised by passing an instance of it to JSON.stringify.
While not a requirement it is preferrable if the input is not mutated and that no references to any any structures in the input ends up in the returned object. In other words calling fromJSON twice with the same input should produce two independent objects.
Note that directly passing the output of toJSON to fromJSON is not valid, as the output of toJSON may require further expansion calling toJSON on objects returned by it.
It has to go through JSON.stringify or an equivalent recursive expansion of toJSON.
static jsonSchema
A JSON schema describing the format of the JSON resulting from passing an instance of this class to JSON.stringify.
This should be lenient towards adding additional properties that would be ignored by fromJSON as it makes it easier to extend these classes in the future without breaking compatibility.