signals

package module
v2.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 23, 2021 License: NCSA Imports: 3 Imported by: 3

README

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 reflection 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 reflection 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 reflection 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.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrMismatchedArguments = fmt.Errorf("callable arguments mismatched")

ErrMismatchedArguments is returned when argument types requested by the Connect'd function do not match the one associated with Emit.

View Source
var ErrNotCallable = fmt.Errorf("interface was not a function")

ErrNotCallable is returned when a Connect'd type is not a function when Emit is triggered by the call site. This will occur if the client code mistakenly passes a non-function type to Connect.

Functions

func Emit

func Emit(sig Signaller, values ...interface{})

Emit a series of values to the specified signal.

func EmitFunc

func EmitFunc(sig Signaller, fn func(interface{}))

EmitFunc calls the given callback which receives, individually, all of the functions that were attached to the specified signal.

func NewContext

func NewContext() *context

NewContext returns an empty, initialized signal context.

Types

type Context

type Context interface {
	// Error indicates whether the current signal processing has encountered an
	// error condition. If this is non-nil, all additional processing will be
	// halted and the error returned.
	Error() error

	// SetError to the specified error value.
	//
	// If this value is set to non-nil signal processing will halt when
	// processing the signal returning this context.
	SetError(error)

	// Abort is an advisory flag that indicates to implementations that an abort
	// condition has been reached and the codepath should be aborted. This does
	// not necessarily indicate an error condition (see Error).
	//
	// Unlike Stop, this will not halt further invocation of other functions
	// currently registered to the signal. The flag may only be toggled on using
	// MustAbort.
	Abort() bool

	// MustAbort flags the current signal for its abort state.
	MustAbort()

	// Stop indicates no further processing should occur, regardless of error
	// condition(s). If this value is true, as with a non-nil Error(), further
	// signal processing will cease.
	Stop() bool

	// MustStop further signal processing.
	MustStop()

	// SetValue to associate with this context.
	SetValue(key, value interface{})

	// Value associated with this context.
	Value(key interface{}) interface{}
}

func EmitFuncContext

func EmitFuncContext(sig Signaller, fn func(interface{}) Context) Context

EmitFuncContext calls the given callback which receives, individually, all of the functions that were attached to the specified signal and returns the accumulated signal context from each function call.

type Disconnector added in v2.2.0

type Disconnector func()

Disconnector is a convenience type defined for callbacks used to disconnect signal receivers from a signal.

func Connect

func Connect(sig Signaller, fn interface{}) (Disconnector, error)

Connect a function, fn, to the specified signal.

Connect returns two values: A Disconnector function and an error if attachment fails. It is up to the calling code to store the Disconnector if the caller ever expects to disconnect from this signal.

Disconnectors may be discarded in which case the connected signal handler can never be disconnected.

type Signaller

type Signaller interface {
	// Attach the specified function to the current signal. This is equivalent
	// to calling Connect(signal, fn). This returns one of the two defined error
	// types in this package on failure.
	Attach(fn interface{}) (Disconnector, error)

	// Clone returns a replica of the current signal for use in local contexts.
	// By default, signals are accessible via wherever they were created
	// (globally, as a singleton; locally as an instance variable). Clone()
	// allows signals to be defined in multiple locations, such as in a global
	// scope and local instance scope(s).
	//
	// Clones propagate Emit* calls to their parent signals. They do not
	// propagate on Attach().
	Clone() Signaller

	// Emit calls each of the attached functions with the values specified by
	// its interface slice.
	Emit(values []interface{})

	// EmitFunc calls each of the attached functions, passing them in as an
	// argument to the function defined here.
	EmitFunc(fn func(interface{}))

	// EmitFuncContext calls each of the attached functions, passing them in as
	// an argument to the function defined here and returns a signal Context
	// containing the error and stop states, if any, alongside any
	// context-specific values.
	EmitFuncContext(fn func(interface{}) Context) Context
}

Signaller is the interface that describes a single signal.

Signals are generated by the signals.New function.

Note: Functions exported by this interface may not be retained publicly in future revisions.

func New

func New(fn interface{}) Signaller

New creates and returns a new signal. New expects a single argument describing the type of signalling function that will be attached later via signals.Connect or Signaller.Attach.

When using typed signals via signals.EmitFunc, the fn argument may be nil.

For signals.Emit to function correctly, it must know the function signature against which the signal has been connected. To do this, a function type must be defined and a pointer to a nil value of this type must be passed to New. For example:

type onExample func(int) sig := signals.New((onExample)(nil))

will allow Signaller.Attach to check attached functions against the signature type specified and for pointer values this allows a correct zero value to be set when the connected function is called.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL