Boutique
Immutable data storage
Install / Use
/learn @johnsiilver/BoutiqueREADME
Boutique
One line summary
Boutique is an immutable state store with subscriptions to field changes.
The long summary
Boutique is an experiment in versioned, generic state storage for Go.
It provides a state store for storing immutable data. This allows data retrieved from the store to be used without synchronization.
In addition, Boutique allows subscriptions to be registered for changes to a data field or any field changes. Data is versioned, so you can compare the version number between the data retrieved and the last data pulled.
Finally, Boutique supports middleware for any change that is being committed to the store. This allows for features like debugging, long term storage, authorization checks, ... to be created.
Best use cases?
Boutique is useful for:
- A web based application that stores state on the server and not in Javascript clients. I like to use it instead of Redux.
- An application that has lots of clients, each which need to store state and receive updates.
- An application that has listeners sharing a single state with updates pushed to all listeners.
Before we get started
Go doesn't have immutable objects, does it?
Correct, Go doesn't have immutable objects. It does contain immutable types, such as strings and constants. However, immutability in this case is simply a contract to only change the data through the Boutique store. All changes in the store must copy the data before committing the changes.
On Unix based systems, it is possible to test your code to ensure no mutations. See: http://godoc.org/github.com/lukechampine/freeze
I have seen no way to do this for Windows.
What is the cost of using a generic immutable store?
There are three main drawbacks for using Boutique:
- Boutique writes are slower than a non generic implementation due to type assertion, reflection and data copies
- In very specific circumstances, Boutique can have runtime errors due to using interface{}
- Storage updates are done via Actions, which adds some complexity
The first, running slower is because we must not only type assert at different points, but reflection is used to detect changes in the data fields of the stored data. We also need to copy data out of maps, slices, etc... into new maps, slices, etc... This cost is lessened by reads of data without synchronization and reduced complexity in the subscription model.
The second, runtime errors, happen when one of two events occur. The type of data to be stored in Boutique is changed on a write. The first data type passed to the store is the only type that can be stored. Any attempt to store a different type of data will result in an error. The second way is if the data being stored in Boutique is not a struct type. The top level data must be a struct. In a non-generic store, these would be caught by the compiler. But these are generally non-issues.
The third is more difficult. Changes are routed through Actions. Actions trigger Modifers, which also must be written. The concepts take a bit to understand and you have to be careful not to mutate the data when writing Modifier(s). This adds a certain amount of complexity. But once you get used to the method, the code is easy to follow.
Where are some example applications?
You can find several example applications of varying sophistication here:
IRC like chat server/client using websockets with a sample terminal UI. Welcome back to the 70's:
http://github.com/johnsiilver/boutique/example/chatterbox
Stock buy/sell point notifier using desktop notifications:
http://github.com/johnsiilver/boutique/example/notifier
What does using Boutique look like?
Forgetting all the setup, usage looks like this:
// Create a boutique.Store which holds the State object (not defined here)
// with a Modifier function called AddUser (for changing field State.User).
store, err := boutique.New(State{}, boutique.NewModifiers(AddUser), nil)
if err != nil {
// Handle the error.
}
// Create a subscription to changes in the "Users" field.
userNotify, cancel, err := store.Subscribe("Users")
if err != nil {
// Handle the error.
}
defer cancel() // Cancel our subscription when the function closes.
// Print out the latest user list whenever State.Users changes.
go func(){
for signal := range userNotify {
fmt.Println("current users:")
for _, user := range signal.State.Data(State).Users {
fmt.Printf("\t%s\n", user)
}
}
}()
// Change field .Users to contain "Mary".
if err := store.Perform(AddUser("Mary")); err != nil {
// Handle the error.
}
// Change field .Users to contain "Joe".
if err := store.Perform(AddUser("Joe")); err != nil {
// Handle the error.
}
// We can also just grab the state at any time.
s := store.State()
fmt.Println(s.Version) // The current version of the Store.
fmt.Println(s.FieldVersions["Users"]) // The version of the .Users field.
fmt.Println(s.Data.(State).Users) // The .Users field.
Key things to note here:
- The State object retrieved from the signal requires no locks.
- Perform() calls do not require locks.
- Everything is versioned.
- Subscribers only receive the latest update, not every update. This cuts down on unnecessary processing (it is possible, with Middleware to get every update).
- This is just scratching the surface with what you can do, especially with Middleware.
Start simply: the basics
http://github.com/johnsiilver/boutique/example/basic
This application simply spins up a bunch of goroutines and we use a boutique.Store to track the number of goroutines running.
In itself, not practical, but it will help define our concepts.
First, define what data you want to store
To start with, the data to be stored must be of type struct. Now to be clear, this cannot be a pointer to struct (*struct), it must be a plain struct. It is also important to note that only public fields can received notification of subscriber changes.
Here's the state we want to store:
// State is our state data for Boutique.
type State struct {
// Goroutines is how many goroutines we are running.
Goroutines int
}
Now, we need to define Actions for making changes to the State
// These are our ActionTypes. This informs us of what kind of change we want
// to do with an Action.
const (
// ActIncr indicates we are incrementing the Goroutines field.
ActIncr boutique.ActionType = iota
// ActDecr indicates we are decrementing the Gorroutines field.
ActDecr
)
// IncrGoroutines creates an ActIncr boutique.Action.
func IncrGoroutines(n int) boutique.Action {
return boutique.Action{Type: ActIncr, Update: n}
}
// DecrGoroutines creates and ActDecr boutique.Action.
func DecrGoroutines() boutique.Action {
return boutique.Action{Type: ActDecr}
}
Here we have two Action creator functions:
- IncrGoroutines which is used to increment the Goroutines count by n
- DecrGoroutines which is used to decrement the Goroutines count by 1
boutique.Action contains two fields:
- Type - Indicates the type of change that is to be made
- Update - a blank interface{} where you can store whatever information is needed for the change. In the case of an ActIncr change, it is the number of Goroutines we are adding. It can also be nil, as sometimes you only need the Type to make the change.
Define our Modifiers, which are what implement a change to the State
// HandleIncrDecr is a boutique.Modifier for handling ActIncr and ActDecr boutique.Actions.
func HandleIncrDecr(state interface{}, action boutique.Action) interface{} {
s := state.(State)
switch action.Type {
case ActIncr:
s.Goroutines = s.Goroutines + action.Update.(int)
case ActDecr:
s.Goroutines = s.Goroutines - 1
}
return s
}
We only have a single Modifier which handles Actions of type ActIncr and ActDecr. We could have made two Modifier(s), but opted for a single one.
A modifier has to implement the following signature:
type Modifier func(state interface{}, action Action) interface{}
So let's talk about what is going on. A Modifier is called when a change is being made to the boutique.Store. It is passed a copy of the data that is stored. We need to modify that data if the action that is passed is one that our Modifier is designed for.
First, we transform the copy of our State object into its concrete state (instead of interface{}).
Now we check to see if this is an action.Type we handle. If not, we simply skip doing anything (which will return the state data as it was before the Modifier was called).
If it was an ActIncr Action, we increment .Goroutines by action.Update, which will be of type int.
If it was an ActDecr Action, we decrement .Goroutines by 1.
Let's create a subscriber to print out the current .Gouroutines number
func printer(killMe, done chan struct{}, store *boutique.Store) {
defer close(done)
defer store.Perform(DecrGoroutines())
// Subscribe to the .Goroutines field changes.
ch, cancel, err := store.Subscribe("Goroutines")
if err != nil {
panic(err)
}
defer cancel() // Cancel our subscription when this goroutine ends.
for {
select {
case sig := <-ch: // This is the latest change to the .Goroutines field.
fmt.Println(sig.State.Data.(State).Goroutines)
// Put a 1 second pause in. Remember, we won't receive 1000 increment
// signals and 1000 decrement signals. We will always receive the
// latest data, which may be far less than 2000.
time.Sleep(1 * time.Second)
case <-killMe: // We were told to die.
return
}
}
}
The close() lets others know when this printer dies.
The **store.Perform(DecrGorout
