Using Composition Versioning in Go libraries
This is a series of blog posts about how the njones/socketio server was built. There are some things that I think are unconventional, and I would like to know if they are more popular than I think. Please leave a comment if you’ve seen these techniques before, it would be nice to see how common they are.
Versioning any API is tricky. This is not the version number, but the actual application interface. As a library creator it would be awesome if users just use the latest-and-greatest-bug-fixed-and-updated version of the API, and that’s it end of story.
Unfortunately this is not how any library creator or maintainer can expect for their users to act. There will always be a need for some users to use an earlier version over the most current version. Or at least some version between the first and the last. So what’s the best way to allow this to happen? What’s the best way to keep all of the versions in sync? If we know it’s going to happen, what’s the best way to plan for it. These are important questions, and I think my approach below solves these problems nicely.
Versioning in the SocketIO API layers nicely. Which means that it’s mostly backwards compatible with many features of earlier versions still exposed or used the same in later versions. This approach works nicely with embedding, a feature that’s uniquely promoted prominently by the Go language.
Using Composition
In Go we’ll use embedding which is composition (without a field name) to expose methods of earlier versions of the API in later versions. Then we can incorporate bug fixes and enhancements in methods in later versions. This allowing the embedding means that later features can use earlier embedded features easily. It also means that there’s no need to recreate earlier features that have been embedded.
In Go this is represented as the following:
type Service1 struct {}
func (s *Service1) MethodA (a string) string { return “a: ” + a }
type Service2 struct { Service1 }
func (s *Service2) MethodB (b string) string { return “b: ” + b }
type Service3 struct { Service2 }
func (s *Service3) MethodA (a string) string { return “v3.” + s.Service2.MethodA(a) }
The above code is showing basic embedding of the Service1 inside of a Service2 struct and Service2 struct embedded within a Service3 struct. Also it’s showing how MethodB extends the Service2 API beyond the Service1 API of MethodA. Also it shows how slight variations in an API can be included by overriding MethodA in the Service3 API to modify the Service1.MethodA output
The idea is similar to one that is used by Stripe to represent its HTML API exposed to developers.
The main tricky part is when a method is removed from a later version of an API. There are a few ways to handle this. As this is a backwards incompatible change that needs to be handled. Other incompatibles can be handled the same way.
One is to create the method on the new version struct but make it “Unimplemented” or “Deprecated”
type Service1 struct {}
func (s *Service1) MethodA (a string) string { return “a: ” + a }
type deprecated error
type Service2 struct { Service1 }
// MethodA for service 2 has a new signature that *could* break compatibility during compile time
func (s *Service2) MethodA () deprecated { return deprecated(fmt.Errorf(“I’m Deprecated”)) }
Another way is to name a field in the later version struct and make that unimplemented as a error object. The advantage over creating a new function, is that there is no way to use it in new versions of the API. This won’t compile either.
type Service1 struct {}
func (s *Service1) MethodA (a string) string { return “a: ” + a }
type deprecated struct {}
type Service2 struct {
Service1
MethodA deprecated // won’t compile if Service2 tries to use MethodA as a function
}
The cleanest way is to break the embedding chain and connect all of the methods that are the same together. This is a much nicer look for the later version API’s even though it’s more work to do.
type Service1 struct {}
func (s *Service1) MethodA (a string) string { return “a: ” + a }
func (s *Service1) MethodB (b string) string { return “b: ” + b }
type Service2 struct {
s1 Service1
}
// MethodA doesn’t exist on the Service2 struct,
func (s *Service2) MethodB (b string) string { return “v2.” + s.s1. MethodB(b) }
func (s *Service2) MethodC (c string) string { return “c:” + c }
Now that we know how it works for the simple case, we can look at how we use it in njones/socketio. We still need to figure one thing out, and that’s how to represent the instance of the versioned struct should it be either Return Interfaces or Accept Interfaces and Return Structs as per the Go proverb. In njones/socketio we use both ways:
// Return Interfaces
type Service interface { MethodA() string }
func NewService(…Option) Service
Or:
// Accept Interfaces and Return Structs
func NewService() *Service3
So this is the basics of using composition versioning. How do we use it in njones/socketio
Returning Interfaces
For the EngineIO server we return an Interface of the composition versioned struct.
func NewServer(…Option) eio.Server
Pros:
- There only needs to be one NewServer method
- We can use the registry design pattern to include latest versions and use the latest one
- We can use a private struct to embed the composition so we don’t have a large exposed surface for documentation
- The versions are hidden if we use private structs
Cons:
- We return an interface that exposes only a limited set of methods
- The versions are hidden if we use private struct
- Requires a switch
<var>.typeto know the struct version
This works for the EngineIO server because the consumer of the server is an internal API that will need to use the ServeHTTP method. (There are checks for other methods, which are checked through interface assertion)
Returning Concrete Structs
For the SocketIO server we return a concrete type.
func NewServer(…Option) *sio.ServerV5
Pros:
- Adheres to Go Proverbs and learned wisdom
- Easier to change the methods that the API implements
Cons:
- Need to have build tags in multiple files to get an instantiated object
- Each Struct will be documented, when there are a lot of versions this could get unwieldy
Conclusion
This works for the SocketIO server because it’s the server that the library users will usually use. It also is the one that will contain possibly different methods for the different API versions.
We are able to use two different kinds of composition versioning within the library. There are pros and cons to each way of using them.