Signals: A Simple Signaling Framework
Signals currently composes two API versions that have diffrent semantics and
inspiration. Both will be maintained for the forseeable future as each version
addresses a slightly different problem scope. The APIs semantics are
incompatible.
Insofar as client code is concerned, integrator usage is mostly analogous
between versions. Implementors should pick the version that best fits their
requirements.
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 the
naive implementation which is roughly on par with APIv2's worst case
performance.
APIv2 is more complex for library developers (implementors) but retains many of
the same 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 guarnatees. 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.
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.
New features are prioritized for APIv2. These same features may or may not
eventually be ported (or implemented) in APIv1.
Versioning
To use the v1 API in your project:
import (
"git.destrealm.org/go/signals"
)
To use the v2 API in your project:
import (
"git.destrealm.org/go/signals/v2"
)
APIv1 Quickstart
This quickstart guide covers only the most basic usage of the v1 API.
To use the v1 API, it is necessary--at a minimum--to define a type encapsulating
the expected function call signature for functions that you intend too use as
the basis for your signals. In our example, we will demonstrate a type
(OnSignalTest
) and introduce the general API.
Default; or, our Canonical API
package main
import (
"fmt"
"git.destrealm.org/go/signals"
)
type OnSignalTest func(string)
var SigTest = signals.NewSignal()
func main() {
SigTest.Connect(OnSignalTest(func(s string){
// Handle the incoming string.
fmt.Println(s) // Prints "example signal value"
}))
// Library code would call the signal as:
SigTest.Send(func(fn interface{}){
if f, ok := fn.(OnSignalTest); ok {
f("example signal value")
}
})
}
In this example we introduce a few concepts. First, we declare a new function
type OnSignalTest
which we use for runtime type validation. Second, we declare
a package-level reference to a new siganl SigTest
. If we were writing a
library using signals, this value would be exported and documented as our
primary entrypoint for our package's signals. Thirdly, we introduce the signal's
API calls Connect
and Send
.
Connect
attaches a function, cast to the desired type, to handle the signal
whenever it is raised.
Send
, while called in our main()
function, would be triggered for the
(hypothetical) library whenever a signal is to be raised and the value passed to
it will be passed to all functions registered via Connect
.
Connect
can be called multiple times across multiple libraries, all for the
same signal, and each of the registered functions will receive the same value.
It is possible to disconnect signals after they've been registered by defining
a key type and passing the key's value into the Connect
function. Modifying
our example with this in mind would be implemented as:
package main
import (
"fmt"
"git.destrealm.org/go/signals"
)
type OnSignalTest func(string)
var SigTest = signals.NewSignal()
type disconnect int
func main() {
var disconnectKey disconnect = 1
SigTest.Connect(OnSignalTest(func(s string){
// Handle the incoming string.
fmt.Println(s) // Prints "example signal value"
}), disconnectKey)
// Library code would call the signal as:
SigTest.Send(func(fn interface{}){
if f, ok := fn.(OnSignalTest); ok {
f("example signal value")
}
})
// Disconnect our signal.
SigTest.Disconnect(disconnectKey)
}
The value of the disconnection key will need to be unique for each function
that is intended to be disconnectable, a new type must be defined for each
package, or both.
Note: Disconnection support is currently only available in the v1 API.
Generator-based API
The generator-based v1 API is virtually identical to the canonical
implementation but requires the additional step of generating the signal code
for your project using the generator binary and then importing that library
into your code.
To build the generator one must run:
$ go build -o generator ./signal-gen
and then create a signals.json
file at the top-level of your project.
The schema for this file is outside the scope of this README and will be added
to the signal-gen
package in the near future. Refer to the top-level
signals.json
file in this package for an example.
Once your signals code has been generated, it may be imported as with any other
package. Our example would then be modified as:
package main
import (
"fmt"
"git.destrealm.org/go/signals"
// Generated signals.
"yourproject/generated"
)
func main() {
// We assume we have a generated signal type called SigTest.
sig := generated.NewSigTest()
sig.Connect(func(s string){
// Handle the incoming string.
fmt.Println(s) // Prints "example signal value"
})
// Library code would call the signal as:
sig.Send(func(fn func(s string){
fn("example signal value")
})
}
In this example, our SigTest
signal is now a package-level component of our
generated code and can be instantiated directly using NewSigTest
. Constructor
functions are generated automatically and will always follow the semantics of
the signal name prefixed with New
.
Additionally, all runtime type checking has been abandoned for compile time
type safety*, and the API is substantially cleaner than the canonical
implementation. Further, runtime type checking has been replaced by compile
time type checking.
For particularly performance sensitive code or code that focuses on correctness
using the generated signal code may be more appropriate.
Naive API
As with the canonical API for our base implementation, the naive API is fairly
similar with two exceptions: 1) No function types need to be defined and 2) the
Send
function has been renamed to Emit
to avoid confusion with the other v1
APIs.
Our example above could be rewritten as:
package main
import (
"fmt"
"git.destrealm.org/go/signals"
)
var SigTest = signals.NewSignal()
func main() {
SigTest.Connect(func(s string){
// Handle the incoming string.
fmt.Println(s) // Prints "example signal value"
})
// Library code would call the signal as:
SigTest.Send("testing")
}
As you can see, the naive API is significantly simpler.
N.B.: The v1 naive implementation is considered unstable.
There are some important deficiencies with the naive implementation that should
preclude its use:
First, the naive implementation is not well-tested, nor are the unit tests for
it currently complete. It may not function well (or at all) for every use case.
Second, the naive implementation is about an order of magnitude slower than
either the canonical or generator-based implementations.
Use at your own risk.
APIv2 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
implementors. 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 implementor 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.
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 BSD and MIT licenses while
also providing coverage for associated documentation and other works that are
not strictly considered original source code. Consequently, all signals
documentation is likewise covered under the same license as the codebase itself.
As with BSD-like licenses, attribution is, of course, required.