One of my favourite aspects of Go is the lively and growing ecosystem for third-party libraries, and I’ve ended up using absolutely dozens and dozens for little projects here and there. Compared to other popular languages, Go has made it easy to write packages in a consistent style that are easy to read, use and contribute to, by virtue of standard tools like go doc
, go fmt
and go lint
.
However, the tools don’t (or can’t) pick up on everything, so here are some additional tips from my experiences working with Go packages:
Make your package discoverable
GoDoc is probably my favourite single website in the Go world, as it’s the best source for finding libraries or looking up documentation. So, if you want people to find and use your library, you need to make sure it’s listed here.
Ideally, host your package on a platform that it supports, such as GitHub or BitBucket - and of course keep the repository public! Once you’ve published your library, go to GoDoc and enter your library’s name into the search box to ensure that it is indexed and searchable. Do the same at GoWalker if you can.
Of course, hosting your package like this makes it easier for others track development and improve it collaboratively, so it’s a no-brainer, really.
Add a package comment
Go types and values are commented by adding a comment directly above their declaration - the same applies to package declarations too.
To make your package more discoverable, and to better describe its intended purpose, include a package comment, usually in a lone file like doc.go
or package.go
:
|
|
This will then be indexed and appear at the top of results in the various Go documentation engines.
Export your exposed types
By convention, types, values and methods in your package that start with an uppercase letter are ‘exported’ and can be called by other Go packages. They also appear in the documentation generated by go doc
, so that users can see exactly what your package exposes and how to use it. Everything else is hidden by default (unless you look at the source files directly, of course.) So make sure the functionality you want to expose is named correctly.
However, there’s a subtle caveat - if a type is not visible (i.e. it starts with a lowercase letter), none of its methods will appear in documentation - even if they do start with an uppercase letter. So in the following example, the Baz()
method will not be documented because the fooBar
type leaked by NewFooBar
is not actually exported:
|
|
As a loose rule, if your package returns any custom types to callers (such as fooBar
), make sure those types are public. In this case, renaming the fooBar
type to FooBar
is enough to ensure that Baz()
appears in documentation.
The same applies for referenced types too, for example:
|
|
In this case, the Qux
type is public, and the Thing
member is public, but the fooBar
type is not public, so documentation for Qux.Thing
won’t be readily available. Again, the solution is to either conceal it as an internal detail by renaming the member to thing
, or - if you intend users to use it - rename the type to FooBar
.
Make errors distinct and discernable
Ensure users can discern between common errors without needing to resort to string comparisons. Declare error values in your package so simple comparisons like if err == foobar.ErrBazzed
can be used to recover from distinct errors gracefully (see errors like io.EOF
or http.ErrNotSupported
in the io
and http
packages for more examples.)
In practise, it means replacing errors like this, which convey information that the calling program can probably recover from:
|
|
…with errors like this, which are declared in one place, exported, conventionally-named, and clearly documented:
|
|
If this isn’t possible, expose methods that allow users to identify common errors, e.g. func IsBazzError(err error) bool
.
Keep imports lean
Avoid importing more packages than absolutely necessary, especially if they only exist to fulfil uncommon use-cases. Don’t mandate a specific library implementation where multiple implementations exist.
For example, say your library uses map[string]string
internally to keep data in-memory, but you want to now sometimes persist this to disk via a key-value store. One solution would be to import goleveldb and hook your library up to it, but suddenly you’ve added a hard third-party dependency and tied your users to a specific implementation when alternatives exist (such as levigo or BoltDB, or even the Redis client Redigo).
The preferable solution is to use to create a ‘pluggable’ system: create an interface, rework your library to use this interface, and then publish an implementation against this interface in a separate package. Your core package can then be used without any additional requirements, but users can optionally use your persistence package, or even write their own implementation for whatever backend they want to use use instead.
So, say your original package looked like this, using in-memory (ephemeral) storage for keys:
|
|
You could rework the backend storage out into its own interface, and provide an implementation that provides the existing functionality (warning: wall of code ahead):
|
|
Then your persistent GoLevelDB backend implementation can exist in a separate package:
|
|
Finally, users opt-in to your LevelDB backend by utilising it:
|
|
For a more thorough example, see my go-kvq package which supports several different k/v backends, but doesn’t mandate a specific one upon the user, and the ‘core’ library does not depend on any one implementation.
As an alternative, you can also use the “registration” mechanic adopted by the sql
library, where merely importing a package (e.g. the postgres driver github.com/lib/pq) registers it against a common package.
Use build constraints
When hooking into other libraries via CGo, there’s a good chance you’ll end up with some very platform-specific Go code. Be sure to constrain these sections of your code to their appropriate platform using the Build Constraints features of Go’s build
tool, as this will produce cleaner error messages for users on supported platforms, and make it easier to add support for other platforms down the line.
The simplest way to do this is to put your OS-specific implementations in appropriately-named files, e.g. foobar_linux.go
or foobar_windows.go
, with the commonalities going into foobar.go
. Then, when a Plan 9 developer attempts to build, they’ll get clean errors indicating a missing implementation, rather than confusing errors about missing headers. To fix it, they’ll just have to implement the appropriate platform code in foobar_plan9.go
and it’ll all work again - and it’s super easy to contribute this implementation back!
(The source for os
in the standard library is chock-full of examples demonstrating this concept.)