Settings
Settings allow for an consistent, transparent and uniform way to setup
configurable types which are composed of other configurable types
especially in the context of test-fixtures.
The basic pattern of such a setting is
type Setting func(*T) error
type T struct { field any }
func (t *T) Apply(oo ...Setting) {
for _, o := range oo {
o(t)
}
}
func SetField(value any) Setting {
return func(t *T) error {
t.field = value
}
}
To just set a field-value this seems over-engineered but often enough we
have to do more. On the up-side this closure-settings approach gives us
- strong encapsulation
- all the flexibility which is ever needed
- they avoid to crowd T's namespace with setters (instead they crowd
the package namespace ;)
In the above example we have an package specific settings-type. This
package provides an cross-package Setting
type to setup uniformly a
configurable type which is composed of other configurable types. In
order for an cross-package setting to know where it should be applied to,
we need a small indirection which is implemented in this package as
settings.Contexts
.
Let T
of package pkg
be a composed type of the types T1, ..., TN
from their respective packages pkg1, ..., pkgN
then the implementation
of T
and a TI
looks like this
package pkt
import (
"settings"
"pkg1"
// ...
"pkgI"
// ...
"pkgN"
"github.com/google/uuid"
)
type Setting = settings.Setting
var tSettingsContextID = uuid.New().String()
type T struct {
field any
t1 *pkg1.T1
// ...
ti *pkgI.TI
// ...
tn *pkgN.TN
scc *settings.Contexts
}
// Apply settings for given T-instance t and for its composing instances
// T1, ..., TN.
func (t *T) Apply(ss ...Setting) error {
if t.scc != nil {
return t.scc.Apply(ss...)
}
t.scc = new(settings.Contexts)
t.t1 = new(pkg1.T1)
// ...
t.ti = new(pkgI.TI)
// ...
t.tn = new(pkgN.TN)
// register composing types to t.scc settings.Contexts
err := t.scc.Apply(
pkg1.Register(t.t1, t.scc),
// ...
pkgI.Register(t.ti, t.scc),
// ...
pkgN.Register(t.tn, t.scc),
)
if err != nil {
return err
}
return t.occ.Register(tOptionsContextID, t, oo...)
}
func stx(scc *settings.Contexts) *T {
return scc.Get(tSettingsContextID).(*T)
}
func SetField(value any) Option {
return func(occ *settings.Contexts) error {
stx(occ).field = value
return nil
}
}
package pkgI
import (
"options"
"github.com/google/uuid"
)
type Setting = settings.Setting
var tiSettingsContextID = uuid.New().String()
type TI struct {
scc *settings.Contexts
field any
}
// Register registers given TI instance ti at applying settings context.
type Register(ti *TI) Setting {
return func(scc *settings.Contexts) error {
return stx.Register(tiSettingsContextID, ti)
}
}
func (ti *TI) Apply(ss ...Setting) error {
if ti.scc == nil {
ti.scc := new(settings.Contexts)
_ = ti.scc.Register(tiSettingsContextID, ti) // can't error
}
return = ti.scc.Apply(ss...)
}
func stx(scc *settings.Contexts) *TI {
return scc.Get(tiSettingsContextID).(*TI)
}
type SetField(value any) Settings {
return func(scc *settings.Contexts) error {
stx(scc).field = value
return nil
}
}
What we get for this effort is that we can do this
package main
import (
"pkg"
"pkgI"
)
func main() {
t := new(pkg.T)
err := t.Apply(
pkg.SetField("value of pkg.T-option"),
pkgI.SetField("value of pkgI.TI-option"),
)
// ...
}
I.e. we have an uniform, consistent and transparent way of setting up a
configurable type composed of other configurable types. The limit here
is that we can't have two instances of the same composing type in this
involved in this process. E.g. a car could not - among other things -
be composed of four instances of the type wheel in this manner.
design patterns
See pkg/view
as an exemplary use-case.
location and abbreviations
Let pkg
be a package that uses options. Then everything that
concerns options is defined in pkg/settings.go
. An instance of
settings.Contexts is typically abbreviated with occ
and a type instance
in the function of an options context with otx
. The later abbreviation
is also used for a convenience function which casts an "untyped" options
context to its underlying type.
finalizer
If there are settings which are optional or depend on each other it might
be desirable at the end of an initial options application to execute a
finalizing option ensuring that all needed aspects of a type are set to
reasonable defaults. For this provided settings.Contexts
implements
Contexts.Finalize
which takes a function as arguments which will be
called at the end of an Contexts.Apply
-call. Is there more than one
finalizer set they are processed: 'last in first out'.
internal options for testing
Let pkg
be a package. Then one may define in
pkg/internal/settings.go
package internal
type Setting = settings.Setting
var TSettingsContextID = uuid.New().String()
type TSettings interface {
M_1( /* ... */ ) // ...
// ...
M_n( /* ... */ ) // ...
}
func RegisterT(t any, cb func(TSettings)) settings.Settings
And define in pkg/settings.go
package pkg
import (
"errors"
"git.sr.ht/~slukits/eventsourced/pkg/internal"
"git.sr.ht/~slukits/settings"
)
func init() {
internal.RegisterT =
func(t any, cb func(internal.TSettings)) settings.Setting {
t_, ok := t.(*T)
return func(occ *settings.Contexts) error {
if !ok {
return errors.New("pkg: T: register: type error")
}
to := &tSettings{t: t_}
err := occ.Register(internal.TSettingsContextID, to)
if err == nil && cb != nil {
// this if makes it easier to have full coverage ...
cb(to)
}
// ... while still reporting unlikely but possible error
return err
}
}
}
type tSettings struct { // implements internal.TOptions
t *T
}
func (t *tSettings) M_1( /* ... */ ) { // ... }
// ...
func (t *tSettings) M_n( /* ... */ ) { // ... }
In this way the testing backend can register an instance of T in its
settings.Contexts instance and decide how it wants the features of the
TOptions implementation be accessible by a testing backend consumer.