signals

package module
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Jan 31, 2021 License: NCSA Imports: 1 Imported by: 2

README

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

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Signal

type Signal struct {
	sync.RWMutex
	// contains filtered or unexported fields
}

Signal container. This tracks registered receivers and receivers that have registered a disconnection index.

func NewSignal

func NewSignal() *Signal

NewSignal creates and returns a new signal.

Implementors must use this function to create a new signal for consumption by client code.

func (*Signal) Connect

func (s *Signal) Connect(fn interface{}, disconnector ...interface{})

Connect a new listener.

Listeners must pass in a function matching the specific function type that was registered with the signal sender (via signal.Send), casting where appropriate. For instance, given a signal function defined as:

var SignalStart = Signal() type SignalStartFunc func (int, int, string)

the signal client must define their connect instance as:

SignalStart.Connect(SignalStartFunc(func(a int, b int, c string){
  // Do something.
})

Connect() accepts a single variadic argument of the interface type (although this argument must be anything that can be compared; see Gorilla context's usage for an example of how to do this with an integer). This argument may then be used to disconnect a given receiver. If the argument is omitted, the receiver is permanently connected and cannot be removed.

func (*Signal) Disconnect

func (s *Signal) Disconnect(which interface{})

Disconnect the specified listener.

`which` must be a comparable type and must be the exact type used as the second argument to signal.Connect.

func (*Signal) Send

func (s *Signal) Send(sender signalFunc)

Send data to all listeners registered to the signal.

Implementors using the Signal interface unfortunately must perform a bit more work than clients connecting to a signal. In particular, there is some amount of unavoidable boilerplate that must be included, and the boilerplate must be protected at runtime from unexpected arguments (in the current implementation, there is presently no easier way to do this).

Implementors therefore must define their sender function as such given the signal:

var (

a = 1
b = 2
c = "testing"

)

var SignalNew = Signal() type SignalNewFunc func(int, int, string)

SignalNew.Send(func(fn interface{}){
  // Cast the receiver to the appropriate type.
  fn.(SignalNewFunc)(a, b, c)
})

Note: Cautious implementors should probably check the validity of the cast.

Unfortunately, this method does rely on runtime protections to prevent the application from exploding at the Send() point; however, for client implementations, we provide (via function signatures for each signal) compile-time checks (see connect). Compile-time checks do require that the client chooses the correct function signature to use for casting, otherwise the cast in send will fail.

Therefore, the onus is on the implementor (not the client code) to ensure Send() will function correctly and fail gracefully in the event the client does something stupid. Remember: If they can, they will.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

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