Command bus¶
Use the command bus to send requests to local handlers or a remote service. Each command holds the request data. An envelope adds metadata such as an identifier and a route. The bus passes the envelope through middleware, then runs the command where its route specifies.
Godular ships these parts:
Part |
Kind |
Bundled service |
|---|---|---|
Capability |
|
|
Capability |
|
|
Capability |
None. Implement it in your project. |
|
Capability |
None. The bundled bus does not call it. |
|
Base class |
||
Base class |
||
Base class |
Connect the services¶
Provide the handlers service, a transport of your own, and the bus:
class_name CommandsModule
extends GdlrModule
const SvcCommandBus = preload("res://addons/godular/built_ins/services/command_bus.svc.gd")
const SvcCommandHandlers = preload("res://addons/godular/built_ins/services/command_handlers.svc.gd")
static var PROVIDERS = {
CapGdlrCommandHandlers: {"use": SvcCommandHandlers},
CapGdlrCommandTransport: {"use": MyTransport},
CapGdlrCommandBus: {
"use": SvcCommandBus,
"inject": [CapGdlrCommandHandlers, CapGdlrCommandTransport],
},
}
static var EXPORTS = [CapGdlrCommandBus, CapGdlrCommandHandlers]
static var TREE_EXPORTS = [CapGdlrCommandBus]
The bundled bus takes the handlers service and the transport in this order.
Commands¶
A command extends CapGdlrCommandBus.Command. Put it in its own script file, not in an inner class, so that Godular can load it by path when it deserializes. Implement _to_dict() with the inputs of the command.
class_name AddPointsCommand
extends CapGdlrCommandBus.Command
var amount := 0
func _to_dict() -> Dictionary:
return {"amount": amount}
to_dict() returns the script path and the inputs. Command.deserialize() loads the script and restores the command. It uses a static from_dict(inputs) when the class defines one. Otherwise it creates the command with new() and sets each input as a property.
A command class can define an inner class named Payload with a static from_dict(data). CommandResult.from_dict(serialized, CommandClass) then restores the payload of a successful result with it.
Override _get_display_name() to name the command in log output.
Envelopes¶
A CommandEnvelope holds a command and a meta. CommandMeta has these fields:
Field |
Type |
Meaning |
|---|---|---|
|
StringName |
Identifier of the dispatch. Required by |
|
StringName |
Identifier shared by the commands of one workflow. |
|
StringName |
Identifier of the command that caused this one. |
|
StringName |
Key that lets a receiver ignore a repeated dispatch. |
|
float |
Timeout in seconds. Godular does not enforce it. |
|
Dictionary |
Data about who dispatched the command. |
|
CommandRoute |
Where the command runs. |
Godular does not fill any of these fields. Set them yourself.
A CommandRoute has exec_kind, target, and route_config. target names the transport adapter for remote execution. route_config holds options for the transport.
var command := AddPointsCommand.new()
command.amount = 10
var meta := CapGdlrCommandBus.CommandMeta.new()
meta.id = &"command-1"
meta.route = CapGdlrCommandBus.CommandRoute.new(CapGdlrCommandBus.ExecKind.LOCAL)
var envelope := CapGdlrCommandBus.CommandEnvelope.new(command, meta)
Dispatch¶
dispatch() accepts an envelope or a command. For a command, the bus creates a CommandMeta and sets each key of meta_params on it:
var result := await bus.dispatch(command, {
"id": &"command-1",
"route": CapGdlrCommandBus.CommandRoute.new(CapGdlrCommandBus.ExecKind.LOCAL),
})
The envelope must have a route. The bundled bus reads meta.route.exec_kind and fails on a null route.
The bundled bus executes by exec_kind:
|
Behaviour |
|---|---|
|
Runs the local handlers. Returns |
|
Sends the envelope through the transport. Returns the remote result. |
|
Runs the local handlers, then sends through the transport. Returns the remote result. |
|
Sends through the transport, then runs the local handlers. Returns the remote result. |
Any other value |
Returns |
Results¶
A CommandResult has these fields:
Field |
Meaning |
|---|---|
|
True when the command succeeded. |
|
|
|
Explanation of a failure. |
|
Data from the handler or the remote target. |
|
Free-form data. The constructor copies it. |
|
Seconds to wait before a retry. Negative when unknown. |
|
Who set the retry time. |
|
|
CommandResult.rate_limited(retry_after_s, policy_id, message) creates a NACK with the reason RATE_LIMITED, the payload {"message": message}, and rate-limit details. is_rate_limited() returns true for such a result. get_retry_after_s(default_value) returns the retry time, or the default when the time is not positive.
Results serialize with to_dict() and from_dict(). An object payload must have a to_dict() method.
Handlers¶
A handler extends GdlrCommandHandler and implements run(envelope, ctx). The bundled bus expects a CapGdlrCommandHandlers.LocalOutput:
class_name AddPointsHandler
extends GdlrCommandHandler
func run(envelope: CapGdlrCommandBus.CommandEnvelope, ctx: CapGdlrCommandHandlers.Context) -> Variant:
var command: AddPointsCommand = envelope.command
return CapGdlrCommandHandlers.LocalOutput.new([{"points": command.amount}], [])
LocalOutput has state_dispatches and effects, both arrays of dictionaries. Godular does not interpret them.
Register the handler as the core of the command pipeline:
var handlers: CapGdlrCommandHandlers
func register() -> void:
handlers.set_core(AddPointsCommand, AddPointsHandler.new().run)
The handlers service keeps one GdlrCommandPipeline per command class. run() fails when the pipeline of a command has no core.
The Context passed to a handler has two callables:
dispatch(envelope)dispatches another envelope through the bus.get_state()returnsnullin the bundled bus. Replace it in a middleware or a custom bus.
Command middleware¶
A GdlrCommandMiddleware groups callbacks for one command class. Implement any of _before, _around, and _after, and optionally _should_run_before, _should_run_around, and _should_run_after:
class_name LogMiddleware
extends GdlrCommandMiddleware
func _before(envelope: CapGdlrCommandBus.CommandEnvelope, _ctx: CapGdlrCommandHandlers.Context) -> void:
print("dispatching ", envelope.command)
func _around(next: Callable, envelope: CapGdlrCommandBus.CommandEnvelope, ctx: CapGdlrCommandHandlers.Context) -> Variant:
return await next.call(envelope, ctx)
func _after(_envelope: CapGdlrCommandBus.CommandEnvelope, _ctx: CapGdlrCommandHandlers.Context, result: Variant) -> Variant:
return result
Only _around can await. Register it with handlers.use(AddPointsCommand, LogMiddleware.new(), priority). The service reports an error when the middleware implements none of the callbacks.
use_before(), use_around(), and use_after() register single callables with priority 0. remove_by_callback(command, callback) removes them.
use_global_around(around, priority, should_run) adds an around callback to the pipelines of every command, including commands registered later. remove_global_by_callback(callback) removes it from every pipeline.
The ordering rules are the same as in Middleware.
Bus middleware¶
Bus middleware wraps the whole dispatch. bus.use(middleware) takes a callable that receives the next step and returns a callable that takes an envelope:
bus.use(func(next: Callable) -> Callable:
return func(envelope: CapGdlrCommandBus.CommandEnvelope):
print("dispatching ", envelope.command)
return await next.call(envelope)
)
The first middleware added is the outer layer. clear_middleware() removes all of them.
Transports¶
CapGdlrCommandTransport sends envelopes to remote targets. It keeps one adapter per target name. Godular does not ship a service for it. Implement send(), register_adapter(), and get_adapter().
A GdlrCommandTransportAdapter is a Node. Override _enable() and _send():
class_name HttpAdapter
extends GdlrCommandTransportAdapter
func _enable() -> void:
pass
func _send(envelope: CapGdlrCommandBus.CommandEnvelope) -> CapGdlrCommandBus.CommandResult:
var response := await post(envelope.to_dict())
return CapGdlrCommandBus.CommandResult.from_dict(response)
enable() runs _enable(), sets enabled, and emits transport_enabled. send() waits for that signal when the adapter is not enabled yet.
When route_config has a retry_after_extractor callable, send() calls it with the payload as a dictionary. A positive return value becomes retry_after_s with the source &"payload":
envelope.meta.route.route_config = {
"retry_after_extractor": func(payload: Dictionary): return payload.get("wait", -1.0),
}
Router¶
CapGdlrCommandRouter.plan(envelope) returns a CommandRoute. Godular ships no service for it, and the bundled bus does not call it. Provide a router when you decide routes in one place, and set the result on meta.route before you dispatch.
Serialization¶
Envelopes, metadata, routes, results, rate-limit details, and local outputs all have to_dict() and a static from_dict(). A round trip restores the command class and the route:
var decoded := CapGdlrCommandBus.CommandEnvelope.from_dict(envelope.to_dict())