Golang Inversion of Control IoC
Introduction
Inversion of Control (IoC) is a programming design pattern which follows Dependency Inversion principle (DIP). It achieves decoupling the control logic and business logic. For instance, we normally will add control logic in the business logic, because it is very common in terms of human beings thinking logic. For example, we may add toggle-switch object when implement light bulb. We may also implement customized toggle-switch when making another electric device. The toggle-switch depends on the kind of electric device. Then, the various toggle-switches are made. The inversion of control is to have a generic toggle-switch with the well defined interface, the various kind of electric devices will depends on the generic toggle-switch to implement.
The IoC separates business logic (what to do) and control logic (when to do). The one example is the event handling. The event handler defines the business logic what to do. The the raised event triggers control when to do. Basically anything with the event loop, callbacks and chains of execution fall into this design pattern.
Example: Simple DB Commit and Rollback
Assuming we have a very simple key-value database. It requires to support database transactions by Commit()
and Rollback()
.
Commit()
will permanently apply all the DB commands.Rollback()
will undo all the DB commands.
This example is very preliminary to demonstrate how we can apply IoC/DIP into the practice. It assumes that the Commit()
and Rollback()
will not fail.
In order to decouple the control logic and business logic, the concrete database object embeds two objects: commit
and rollback
. It applies Golang delegation design pattern that commit
and rollback
objects delegate database object’s control logic.
type rollback []func()
type commit []func()
type db struct {
data map[string]int
rb rollback
cm commit
}
The basic database interface:
type DB interface {
// Get the value for the given key, set 'ok' to true if key exists.
Get(key string) (int, bool)
// Set the key to given value.
Set(key string, val int)
// Unset the key, making it to the last commit.
Unset(key string)
// Commit all DB commands, permanently apply the changes made in them.
Commit()
// Rollback undoes all of the DB commands issued in the recent commit.
Rollback()
}
func NewDB() DB {
return &db{
data: make(map[string]int),
rb: make([]func(), 0),
cm: make([]func(), 0),
}
}
The Commit Control
The Commit control should decouple with the DB operations from either Set()
or Unset()
.
func (c *commit) register(fn func()) {
*c = append(*c, fn)
}
func (c *commit) clear() {
fns := *c
*c = fns[:0]
}
func (c *commit) execute() {
fns := *c
// execute all DB commands in the one transaction
for _, fn := range fns {
fn()
}
// assuming commit successful, clear commit list
c.clear()
}
The Rollback Control
The Rollback control should decouple with the DB unset operations.
func (r *rollback) register(fn func()) {
*r = append(*r, fn)
}
func (r *rollback) clear() {
fns := *r
*r = fns[:0]
}
func (r *rollback) execute() {
fns := *r
for _, fn := range fns {
fn()
}
r.clear()
}
The DB Business Logic
Let’s define the Get
method
func (d *db) Get(key string) (int, bool) {
if val, ok := d.data[key]; ok {
return val, ok
}
return 0, false
}
When we Set
a key-value to DB, we actually register several functions to commit
and rollback
objects. Let them to delegate the Set
operation to control real commit or rollback.
func (d *db) Set(key string, val int) {
// register set command to the commit object
d.cm.register(func() {
d.set(key, val)
})
// for the existing key-val, register set current value the the rollback object
// otherwise, register delete function to the rollback object
curVal, has := d.Get(key)
if has {
d.rb.register(func() {
d.set(key, curVal)
})
} else {
d.rb.register(func() {
delete(d.data, key)
})
}
}
func (d *db) set(key string, val int) {
d.data[key] = val
}
When we Unset
a key-value to DB, we actually register a delete
command to commit object. For the rollback operation, it should be reversed to the last commit. The set
command is registered to rollback object.
func (d *db) Unset(key string) {
// register unset command to the commit object
d.cm.register(func() {
delete(d.data, key)
})
// register set command to the rollback object to set previous value
// do nothing if the key-value doesn't exist
curVal, has := d.Get(key)
if has {
d.rb.register(func() {
d.set(key, curVal)
})
}
}
Finally, we could define Commit
and Rollback
for DB object.
func (d *db) Commit() {
d.cm.execute()
}
func (d *db) Rollback() {
d.rb.execute()
}
Unit Test
We could use the following unit test to verify the the code above.
func TestCommitRollback(t *testing.T) {
tc := require.New(t)
data := map[string]int{
"a": 10,
"b": 20,
}
db := NewDB()
// Test Case 1: set the first entry, without commit, it is not written to DB.
db.Set("a", data["a"])
_, ok := db.Get("a")
tc.False(ok)
// Test Case 2: set another entries, then commit.
db.Set("b", data["b"])
db.Commit()
a, ok := db.Get("a")
tc.True(ok)
tc.Equal(data["a"], a)
b, ok := db.Get("b")
tc.True(ok)
tc.Equal(data["b"], b)
// Test Case 3: unset key "a", after commit, there is only key "b" in the DB.
db.Unset("a")
db.Commit()
_, ok = db.Get("a")
tc.False(ok)
b, ok = db.Get("b")
tc.True(ok)
tc.Equal(data["b"], b)
// Test Case 4: after rollback,
// - revert Unset("a") the key "a" is back to the DB.
// - revert Set("b"), the key "b" should be gone
db.Rollback()
a, ok = db.Get("a")
tc.True(ok)
tc.Equal(data["a"], a)
_, ok = db.Get("b")
tc.False(ok)
}
I used a simple key-value DB Commit
and Rollback
operation to demonstrate the Inversion of Control design pattern. Basically, the control logic commit
and rollback
do not depend on key-value DB. Rather, when DB wants to commit or rollback, it depends on the control logic. We could reuse the same control logic for other DB object, such as the document DB. It only needs to register the different DB commands to the commit
and rollback
objects. The control logic doesn’t care the business logic. It eventually realizes the decoupling of control logic and business logic.