Signals: A Simple Signaling Framework
Signals currently comprises two API versions that have different semantics and
sources of inspiration. Both will be maintained for the forseeable future as
each version addresses a slightly different problem scope, but users should be
aware that the v1 branch is in a "maintenance-only" phase.
The APIs' semantics are incompatible and only the v2 branch will see new features.
Insofar as client code is concerned, integrator usage is mostly analogous
between versions. implementers should pick the version that best fits their
requirements.
Why Signals?
Users of Qt will have been exposed to the concept of "signals and slots;" it's a
powerful and expressive API for listening to or generating events. Signals
(particularly v2.x) draws some manner of inspiration from Qt in this arena.
Though the lack of generics in Go hamstrings the expressiveness of the Signals
API somewhat, and it becomes necessary to interject runtime type validation, it
is a highly useful library for integrating an event-like API that may be used to
compose a plugin framework, event consumers, and more.
Signals is not intended to be used as a message bus. There are plenty of other
libraries that perform such a task better. Signals is strictly intended to be
used as an in-process event framework and may be used in concert with a
message bus.
API differences between v1 and v2
Signals APIv1 is simpler for library developers and has slightly better
performance for most use cases than APIv2 and consists of three implementations:
The "default" implementation, which is considered the v1 canonical
implementation; the naive implementation containing a simplified API that
eschews runtime- and compile-time type safety in favor of reflection (and is
slower) but has the propensity to blow up unexpectedly; and finally the
generator implementation that uses go generate
to create custom signals
based on a descriptor file (currently a JSON-formatted list of signals and their
types). The generator implementation holds the best performance
characteristics of all versions but is the most difficult to use.
The v1 API is generally faster than the v2 API with the exception of its
naive implementation which is roughly on par with APIv2's worst case
performance.
APIv2 is more complex for library developers (implementers) but retains similar
semantics for library consumers (integrators). However, the v2 API is also
significantly more powerful and consists of only a single implementation. Some
degree of runtime type safety is provided by the Emit
API but runtime type
checks are more consistent when using the EmitFunc
and EmitFuncContext
APIs.
APIv2 provides no compile time type guarantees. If this is a requirement for
your project, you'll have to stick with APIv1's generator implementation.
The most noteable difference between the two API versions lies in the
extensibility of the v2 API and its ability to return values from within a
signal's call. The v1 API currently lacks any ability to return values at a
point where the signal Send
is triggered (Emit*
in v2) and consequently
cannot interrupt the signal's call chain. The v2 API, in contrast, does allow a
degree of preemption; when using EmitFuncContext
, if the Context
instance
returns an error or sets its Stop
value to true, the signal call chain is
interrupted and no further signals are processed.
Further, the v2 API is inspired by Qt's signals and slots. Indeed, care is taken
to ensure that any callable can be used as a connection function ("slot" in Qt
parlance), and provided EmitFunc*
calls are used by implementers, the
performance reduction over v1 isn't significant but it does require runtime type
checks to validate incoming functions which adds to the overhead.
New features are prioritized for APIv2. These same features may or may not
eventually be ported (or implemented) in APIv1.
As of v2.1.0, Signals v2 supports signal "cloning," allowing you to define
top-level global signals, clone them into a local data structure, and then
"bubble up" signals from the local to global scopes. This allows integrators the
option to bind either to the global signals defined by your application or to
local signals.
Versioning
To use the v2 API in your project:
$ go get git.destrealm.org/go/signals/v2
Sources:
import (
"git.destrealm.org/go/signals/v2"
)
To use the v1 API in your project (deprecated):
$ go get git.destrealm.org/go/signals/v1
Sources:
import (
"git.destrealm.org/go/signals"
)
Note: New code should preferentially utilize the v2 API.
APIv1 Documentation
The APIv1 documentation is available on the v1 branch.
New code should consider migrating to the v2 API.
APIv2 Documentation
Quickstart
This quickstart guide covers only the most basic usage of the v2 API.
The v2 API borrows from some of the principles underpinning the canonical v1
implementation insofar as defining package-level signals and their respective
function types. Additionally, the v2 API combines some of the philosophies of
the naive implementation by introducing runtime type checking through heavy use
of reflecton (albeit with more restrictions). This means that for most use
cases, the v2 API will range from around 1-2 orders of magnitude slower than
the v1 API; however, the v2 API reduces some complexity for library
implementers. Likewise, performance reductions can be rectified somewhat by
using typed signals which we'll cover here as well.
As with the canonical v1 API it is necessary to define both the package-level
signal against which functions may be connected and a function type
identifying valid signatures for signal clients. Continuing from our previous
examples, we might adapt the sample code as follows:
package main
import (
"fmt"
"git.destrealm.org/go/signals/v2"
)
type OnSignalTest func(string)
var SigTest = signals.New((OnSignalTest)(nil))
func main() {
signals.Connect(SigTest, OnTestSignal(func(s string){
fmt.Println(s) // Prints "example signal value"
}))
// Library implementation call:
signals.Emit(SigTest, "example signal value")
}
As you can see, from an implementer's perspective, emitting signals is
significantly easier than it is in the v1 API. Client code remains roughly
analogous, but the difference is that interaction with the signals is
performed at the module's top level using signals.Connect
and signals.Emit
.
It's possible to use the signal's API directly (Attach
and Emit
; these names
may change in future versions), but using the top-level function calls as above
provides some clarity as to the intent.
However, Emit
's internal machinery leans heavily on the reflect
ion package
to determine passed-in value types and to provide some runtime type safety to
ensure that the called function(s) match the expected signature. This is why the
argument to signals.New()
is a cast to a nil pointer of the expected function
type; signals APIv2 requires some forewarning as to the types a signal is
expected to handle, and the way to do this is to pass in a nil function pointer
to examine with reflect
.
As you might imagine, Emit
is quite slow, comparatively speaking. This can be
rectified using the EmitFunc*
calls, as we'll see.
In the following example, we use type conversion to validate the type of the
incoming function prior to use. This obviates use of the Go reflect
ion API and
wins back some performance that would otherwise be lost:
package main
import (
"fmt"
"git.destrealm.org/go/signals/v2"
)
type OnSignalTest func(string)
var SigTest = signals.New(nil)
func main() {
signals.Connect(SigTest, OnTestSignal(func(s string){
fmt.Println(s) // Prints "example signal value"
}))
signals.EmitFunc(SigTest, func(fn interface{}){
if f, ok := fn.(OnTestSignal); ok {
f("example signal value")
}
})
}
As you can see, we've adapted the call to Emit
and replaced it with EmitFunc
which allows implementers to test the incoming function against its expected
type. Further, if the implementer uses only the EmitFunc
call with no
intention of using Emit
, calls to signals.New()
may replace the function
pointer with nil
thereby bypassing much of the reflect
ion code used to
determine valid incoming types. EmitFunc
is also faster than Emit
and is
roughly on par with v1's default implementation and slightly slower than v1's
generated code.
v2's EmitFunc
also has another potential advantage (though this is shared with
v1's canonical implementation): Functions of different type signatures may be
connected to at any given time and the implementer can then use type
conversion or type switching to handle differing function types accordingly. Of
interest, it should be possible (though this isn't currently tested as of this
writing) to pass in a function that itself has functions attached matching some
particular inteface which could then be handled differently via a type switch!
APIv1's most critical omission is its inability to pass along signal status in
the event of a failure nor does it provide any means for passively preempting
signal execution. APIv2 rectifies this by introducing signals.Context
which
implements an interface providing two important functions: Error
and Stop
.
If an error condition occurs during the signal processing chain, Error
can be
coerced to return an error
. Likewise, Stop
may be used when an error
condition hasn't occurred, but the signal currently being processed wishes to
abort any further processing.
EmitFuncContext
is very similar to EmitFunc
and can be used creatively to
transform how signal processing is managed.
package main
import (
"fmt"
"git.destrealm.org/go/signals/v2"
)
type OnSignalTest func(string)
var SigTest = signals.New(nil)
func main() {
signals.Connect(SigTest, OnTestSignal(func(s string){
ctx := signals.NewSignalContext()
if s != "example signal value" {
ctx.SetError(fmt.Errorf("unexpected value"))
}
}))
signals.EmitFuncContext(SigTest, func(fn interface{}) signals.Context {
var ctx signals.Context
if f, ok := fn.(OnTestSignal); ok {
ctx = f("example signal value")
if ctx != nil && ctx.Error() != nil {
// Handle error.
}
}
return ctx
})
}
As of this writing we acknowledge that the signal context is fairly spartan in
its implementation. Further, because signals.EmitFuncContext
itself may return
a context, library implementations may grow to be somewhat unwieldy. We expect
to either eliminate the return from signals.EmitFuncContext
or make it
somewhat more useful by providing some allowance for context chaining. This API
feature should be considered to be in a state of flux.
Cloning
As of v2.1.0, Signals supports signal cloning, allowing you to define signals locally, globally, or both. Emit*
functions called on clones will bubble their events up through the call chain until the signal is either aborted or no further signals have been registered in the chain. To illustrate a global- and application-scoped signal construct:
package main
import (
"fmt"
"git.destrealm.org/go/signals/v2"
)
type OnSignalTest func(string)
var SigTest = signals.New(nil)
type Application struct {
Test signals.Signaller
// Other fields...
}
func main() {
app := &Application{
Test: SigTest.Clone(),
}
// Connect to the global signal.
signals.Connect(SigTest, OnTestSignal(func(s string){
// Do something.
}))
// Connect to the application-scoped signal:
signals.Connect(app.Test, OnTestSignal(func(s string){
// Do something.
}))
// Emit to the globally-scoped signal:
signals.EmitFunc(SigTest, func(fn interface{}) {
if f, ok := fn.(OnTestSignal); ok {
f("some value")
}
})
// Emit to the locally-scoped signal. This value will "bubble up" to the
// parent scope.
signals.EmitFunc(app.Test, func(fn interface{}) {
if f, ok := fn.(OnTestSignal); ok {
f("some value")
}
})
}
Documentation
Documentation is covered under the library's NCSA license and may be distributed
or modified accordingly provided attribute is given.
Any documentation that appears on the project wiki will also be made available
in the source distribution for offline reading.
License
signals is licensed under the fairly liberal and highly permissive NCSA license.
We prefer this license as it combines the best of the BSD and MIT licenses while
providing coverage for documentation and other works that are not strictly
considered original source code. Consequently, all signals documentation is
likewise under the purview of the same license as the codebase itself.
As with BSD and similar licenses attribution is required.
Copyright (c) 2015-2021 Benjamin A. Shelton.