Where developers come to connect, share, build and be inspired.

0

Context Managers in Go? Don't be ridiculous...

3435 views


Update: 8/22/2013 - scroll down to the update where I show how this pattern can be used to build a Redis context manager for atomic transactions.

I've been trying to come up with use cases for the defer keyword in Go. I really like the pattern of when some kind of object is opened like a tcp connection or file you just need to remember to defer the Close right after of the said object and no matter what code path your function takes, you can rest assured that whatever you have deferred will be closed. For the record, I am not proposing that the language should implement Context Managers but more that your application could benefit from a context manager to help minimize error/cleanup.

We see this all the time with idiomatic Go code as in the following snippet:

    fi, err := os.Open("input.txt")
    if err != nil {
        panic(err)
    }
    // close fi on exit and check for its returned error
    defer func() {
        if err := fi.Close(); err != nil {
            panic(err)
        }
    }()

So this is all well and good. But, you still have to remember to defer a Close on your opened object. So what about if we took the concept a little further and abstracted away the defer within a context manager. We see this pattern used in languages like Python with the "with" statement and additionally languages like C# with what's known as a "using" block. Basically, all you are doing is designating a scope for some action to occur such that when the scope ends a deterministic marker will tell the runtime to cleanup its resources.

So to be quite honest, I'm not even sure this would make good sense in a language like Go. After all, it is a different language however; for me I think a context manager may be useful to help abstract away some of Go's boilerplate such as the close and even some of Go's error handling.

Consider the following example of opening a file in Go and doing something trivial like reading all of its contents. Typical Go code may look something like:

func OpenFile(name string){
    fi, err := os.Open(name)
    if err != nil{
        panic(err)
    }

    b, err := ioutil.ReadAll(fi)
    if err != nil{
        panic(err)
    }

    //Do something with the data in this case just printing it to stdout
    fmt.Println(string(b))

    defer func() {
        if err := fi.Close(); err != nil {
            panic(err)
        }
    }()
}

So let's acknowledge a few things with the code above. There are three spots where error-handling is done which isn't that fun but as it turns out probably helps us developers write more robust code because we're getting into a good habit of dealing with errors. The other thing about this code is it has a defer to close the file along with dealing with a potential error on close. Now in many examples on the web I see people deferring the Close and not dealing with the error. I feel like with Go we need to be disciplined but not too much? Hey, even fmt.Println returns an error...

So perhaps we can be opportunistic here and kill two birds with one stone. Maybe we can attempt to abstract away some of the error handling and additionally make sure we defer our Close (automatically to the caller) and also deal with its possibility of an error occuring.

This calls for a context manager. Something that hopefully isn't too painful to write but helps us strategically deal with the meat of the code that we're really interested in. In the case above, I'm concerned with getting a *os.File handed to me, calling the ioutil.ReadAll() function, handling the potential error on ReadAll() and simply doing something with the data and moving on with my life. To me, that is the real meat of the code above.

Here is the concept of a "context manager" for Go that will be responsible for some error handling, while still allowing my code to run and ultimately cleaning up automatically.

Context Manager function defined below:

func Open(name string, cb func(f io.ReadWriteCloser)){
    fi, err := os.Open(name)
    if err != nil{
        panic(err)
    }
    defer func() {
            if err := fi.Close(); err != nil {
                panic(err)
            }
        }()

    cb(fi)
}

Example Usage below:

someObject := NewObject()

Open("input.txt", func(f io.ReadWriteCloser){
    b, err := ioutil.ReadAll(f)
    if err != nil{
        log.Println("Could not read all of the glory.")
                log.Fatal(err)

    }

    //Close over someObject, now we can call a method on it.
    someObject.ProcessData(string(b))
})

So the first function named "Open" defines the context manager. Notice that it operates on a ReadWriteCloser so hopefully we can find alternative ways to use it on ReadWriteClosers in general. In this example, Open let's us pass in two things. The name of a file and a callback function to run. I considered passing in the error into the callback but I felt that since I was willing to abstract away some of the responsibility to handling of the errors to the context manager that I felt it was unnecessary for my case. In fact, in my case I am okay with a panic if I can't read/write files. Also notice that the context manager is handling the closing of the file for me along with its possible errors. Again, i'm willing to let the context manager handle those errors for me.

The usage example is simply the usage of the context manager. We call the context manager and pass in our file name to operate on and the callback with the ReadWriteCloser. If all goes well, we can expect a properly setup *os.File handle that is ready to use. In my usage example, I'm simply calling the ReadAll() function which also may return an error. In this case, I should deal with this error local to my usage of the context manager because I chose to use the ReadAll function. I may have used something different though, like a Scanner instead.

This example is obviously trivial but the more we can utilize the context manager the more we can cut down on error handling and now we don't have to remember to defer our Close method. Also, because we have introduced a closure, we can now reference items that we "closed over" and make use of whatever is accessible within our context manager. I cannot stress this enough how critical this idea is. It's often exploited in JavaScript but with judicious usage we can greatly benefit from it.

This concept also doesn't apply to simply files. Perhaps it would also be worthwhile coming up with a context manager for doing something like a Redis transaction. Perhaps in that scenario we can have a context manager that allows us to specify multiple commands that should be executed in a transaction and then relinquishes our Redis connection back to a pool.

Update 11/22/2013

Here is an example of a Context Manager for the Redigo library to do a Redis transaction. This is a somewhat more involved example, but you will see the benefits upon its usage in the following code block.

type Transaction struct {
    err     error
    success func(interface{})
    reply   interface{}
}

func NewTransaction() *Transaction {
    return &Transaction{}
}

func (t *Transaction) Do(cb func(conn redis.Conn)) *Transaction {
        //pool is a global object that has been setup in my app
    c := pool.Get()
    defer c.Close()
    c.Send("MULTI")
    cb(c)
    reply, err := c.Do("EXEC")
    t.reply = reply
    t.err = err
    return t
}

func (t *Transaction) OnFail(cb func(err error)) *Transaction {
    if t.err != nil {
        cb(t.err)
    } else {
        t.success(t.reply)
    }
    return t
}

func (t *Transaction) OnSuccess(cb func(reply interface{})) *Transaction {
    t.success = cb
    return t
}

Example usage of the Redis Transaction:

    NewTransaction().Do(func(c redis.Conn) {
        c.Send("INCR", "Counter")
        c.Send("DECR", "Counter")
        c.Send("INCR", "Counter")
        c.Send("DECR", "Counter")
        c.Send("INCR", "Counter")
        c.Send("DECR", "Counter")
        c.Send("INCR", "Counter")
        c.Send("DECR", "Counter")
    }).OnSuccess(func(reply interface{}) {
        log.Println("Success!")
        log.Println(reply)
    }).OnFail(func(err error) {
        log.Println("Oh no, transaction failed, alert user.")
        log.Println(err)
    })

Notice how the Transaction class gives us a higher abstraction over dealing with errors as well as dealing with cleanup of giving our connection back to the Redis connection pool. In this scenario, all I care about is that my transaction either completed or failed. And that in both scenarios, I have the means to deal with the error or the reply from Redis' atomic reply. Please note: Redis transactions do not behave the same way as SQL transactions. Also, let us make a note of the things my context manager is handling for me: Grabbing a connection from the Redis pool, returning the connection, calling the appropriate Success or Fail handler while injecting the appropriate error or reply, handling the Redis commands for doing a "MULTI"/"EXEC" command before/after my code.

The example Redigo transaction context manager above is simply an example that can be used with Gary Burd's Redigo client for Go. Should you want to use the code above you'll need to visit the github page for install instructions: https://github.com/garyburd/redigo

I hope this was useful. Also, if you find this useful please give me some feedback in the comments below. I do think there could be some good use cases for context managers in Go and I'm still searching for answers.

Thanks,

-Ralph

Add a comment