Roger Peppe, 2018-09-05
Most of the discussion about the new Go contracts proposal has been centred around hypothetical or small-scale examples. As Go is a language that's designed for programming in the large, I thought it might be interesting to see how contracts fitted with a more real-world example.
The motivation here comes from a problem we were having a while back: how to test some of our code that relied on MongoDB. We had been running a real MongoDB server for all of our tests and it was quite slow. We were investigating how reasonable it would be to create an in-memory fake implementation and use that instead.
A common approach to writing fakes in Go is to define an interface type that provides the required API, then change the code to use an interface instead of the concrete type. The original concrete type or a fake implementation can both then be used interchangably, and the code will compile and run fine with both... in theory.
In practice, things aren't quite so easy.
Here's some of the API provided by the mgo package.
package mgo
type Session struct { ... }
func (s *Session) DB(name string) *Database { ... }
type Database struct { ... }
func (db *Database) C(name string) *Collection { ... }
type Collection struct { ... }
func (c *Collection) Find(query interface{}) *Query { ... }
type Query struct { ... }
func (q *Query) Iter() *Iter { ... }
type Iter struct { ... }
func (it *Iter) Next(x interface{}) bool
A client might use the API something like this:
iter := session.DB("mydb").C("people").Find(bson.M{
"name": "bob",
})
var doc struct {
Id string `bson:"_id"`
FirstName string
Age int
}
for iter.Next(&doc) {
fmt.Println(doc.Id, doc.FirstName, doc.Age)
}
Here's an interface that we're hoping to implement:
package fakemgo
type Session interface {
DB(string) Database
}
type Database interface {
C(string) Collection
}
type Collection interface {
Find(query interface{}) Query
}
type Query interface {
Iter() Iter
}
type Iter interface {
Next(x interface{}) bool
}
If we try to use *mgo.Session
as a fakemgo.Session
, we run
up against a limitation of interface values in Go - the compatibility
only runs one level deep. Since the mgo.Session.DB
method returns
*mgo.Database
, not fakemgo.Database
, it's not compatible with
the fakemgo.Session.DB
method, so our code won't compile.
It's possible to work around this by defining a concrete "shim" type at each level that maps from one to the other.
Something like this:
package fakemgo
import "gopkg.in/mgo.v2"
func NewSession(s *mgo.Session) Session {
return sessionShim{s}
}
type sessionShim struct {
*mgo.Session
}
func (s sessionShim) DB(name string) Database {
return databaseShim{s.Session.DB(name)}
}
type databaseShim struct {
*mgo.Database
}
func (db databaseShim) C(name string) Collection {
return collectionShim{db.Database.C(name)}
}
type collectionShim struct {
*mgo.Collection
}
func (c collectionShim) Find(query interface{}) Query {
return queryShim{c.Collection.Find(query)}
}
type queryShim struct {
*mgo.Query
}
func (q queryShim) Iter() Iter {
return q.Query.Iter()
}
This approach works, but our fake package is now directly dependent
on the types from gopkg.in/mgo.v2
. As this package is now deprecated,
and there's no officially recognised replacement fork, it would be
nice if we didn't have to duplicate all the shim code for every fork
of the mgo package we decide to use.
I wondered if contracts could potentially help in this kind of situtation, because contracts, unlike interfaces, do allow the specification of deep type relationships.
To write the contract for the Session type, we'll first redefine the interface types from earlier so that they have type parameters instead of returning interface values:
package fakemgo
type Session(type DB) interface {
DB(name string) DB
}
type Database(type Collection) interface{
C(name string) Collection
}
type Collection(type Q) interface{
Find(id interface{}) Q
}
type Query(type I) interface{
Iter() I
}
type Iter interface{
Next(result interface{}) bool
}
We can now write the contract quite like this:
contract SessionContract(
s Session,
db Database,
c Collection,
q Query,
it Iter,
) {
Session(Collection)(s)
Collection(Query)(db)
Query(Iter)(query)
Iter(it)
}
Now we can write code that works interchangeably with
both both gopkg.in/mgo.v2
and github.com/globalsign/mgo
and also a fake implementation that we provide ourselves.
Let's see how that works out. Note we don't need to actually reference all the types, so we can (presumably) use the blank identifier. For the ones we don't.
func PrintBobDetails(
type Session, _, _, _, _ SessionContract,
)(session Session) {
iter := session.DB("mydb").C("people").Find(bson.M{
"name": "bob",
})
... etc
}
We might want to split this function into parts, so we don't create the Database and Collection types each time and we can easily call different functions with the same database.
func PrintVariousDetails(
type Session, _, _, _, _ SessionContract,
)(session Session) {
db := session.DB("mydb")
PrintBobDetails(db)
}
func PrintBobDetails(
type _, Database, _, _, _ SessionContract,
)(db Database) {
iter := db.C("people").Find(bson.M{
"name": "bob",
})
}
This is starting to feel a bit like hard work. We've got quite a few positional type parameters, and it's not entirely obvious that to the reader that PrintBobDetails really has chosen the correct one to use for Database. It's not a good idea to have many parameters to a function. With normal function parameters, we can avoid this kind of thing by using a struct type, but there's no way of doing that here.
In larger programs that use contracts, I suspect this issue will arise quite a bit. Contracts allow us to specify deep relationships between several types, but the fact that all the types in a contract need to be mentioned every time it's used could become a real burden.