Go plugins
Exploring Go's plugin package.
cobra is a library for creating powerful modern CLI applications, and is currently used by popular projects like Kubernetes, Hugo, etc.
This post focuses on adding plugins to a Cobra application, as an example for introducing Go’s plugin
package.
Go and Plugins ¶
Plugins are useful for extending an application’s feature list. Go generally supports two kinds of plugins:
Compile-time plugins ¶
Compile-time plugins consist of code packages that get compiled into the application’s main binary. Compile-time plugins are easy to discover and register as they’re baked in the binary itself.
Run-time plugins ¶
Run-time plugins hook up to an application at run-time.
A special build mode enables compiling packages into shared object (.so
) libraries. The plugin
package provides simple functions for loading shared libraries and getting symbols from them.
An example ¶
Let’s try using run-time plugins for our Cobra application. As a first step, we will need to build a CLI using Cobra:
Code can be found here.
// main.go
package main
import (
"errors"
"fmt"
"log"
"plugin"
"github.com/spf13/cobra"
)
func main() {
mainCmd := &cobra.Command{
Use: "main",
Short: "Simple cobra app",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Main app called!")
},
}
// Load the plugin.
pluginCmd, err := LoadPlugin("./plugin/plugin.so", "PluginCmd")
if err != nil {
log.Fatalf("failed to load plugin: %v\n", err)
}
// Register the plugin at runtime.
mainCmd.AddCommand(pluginCmd)
if err := mainCmd.Execute(); err != nil {
log.Fatalf("error: %v\n", err)
}
}
The above code snippet does the following:
- Create a main command
- Load and register the plugin (explained later)
- Execute the main command
Here is the code for loading a plugin:
// main.go
// LoadPlugin loads a cobra command from a shared object file.
func LoadPlugin(pluginPath, cmdName string) (*cobra.Command, error) {
p, err := plugin.Open(pluginPath)
if err != nil {
return nil, err
}
c, err := p.Lookup("New" + cmdName)
if err != nil {
return nil, err
}
// Get access to the plugin function and get the command.
pluginFunc, ok := c.(func() *cobra.Command)
if !ok {
return nil, errors.New("failed to perform a lookup.")
}
pluginCmd := pluginFunc()
return pluginCmd, nil
}
In the above code snippet, we use Open()
for loading the plugin object. We assume that the function exposing the command has this naming convention: New<CommandName>
, for example, NewPluginCmd
, etc.
Since there is no way for looking up all symbols (exported functions), having a naming convention helps, as it ensures that the lookups are easy to maintain.
Since we have the command name, we perform a lookup using our naming convention. Once we have the symbol, we assert the type of the symbol. In our case, we expect the signature of func() *cobra.Command
. This function is used for retrieving the cobra command, which is eventually returned to the caller.
Let’s implement the plugin:
// plugin/plugin.go
package main
import (
"fmt"
"github.com/spf13/cobra"
)
func NewPluginCmd() *cobra.Command {
pluginCmd := &cobra.Command{
Use: "plugin",
Short: "Plugin command",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Plugin called!")
},
}
return pluginCmd
}
We can now build the plugin using -buildmode=plugin
. The plugin
buildmode expects you to have at least one main
package.
go build -buildmode=plugin .
Tada! We can see a plugin.so
in our directory:
$ tree
.
├── go.mod
├── go.sum
├── main.go
└── plugin
├── plugin.go
└── plugin.so
1 directory, 5 files
Here is a visualization on how the run-time plugin is registered into our Cobra app:
Let’s run the main app:
$ go run main.go --help
Simple cobra app
Usage:
main [flags]
main [command]
Available Commands:
completion Generate the autocompletion script for the specified shell
help Help about any command
plugin Plugin command
Flags:
-h, --help help for main
Use "main [command] --help" for more information about a command.
Nice! The plugin got registered to the main Cobra app at runtime!
Let’s run the plugin:
$ go run main.go plugin
Plugin called!
We now have a plugin loaded at run-time, and is accessible through the main CLI.
Challenges ¶
Run-time plugins are a nice addition; however, they have some challenges:
- Plugins don’t work in Windows. (related: #19282.)
- The main app and the plugin must be compiled with the same Go version and must have the same value for the
GOPATH
variable. - Packages imported by the main app and the plugin must have the same version.
Tips ¶
Here are some tips while building and using plugins:
- Plugins should focus on providing one functionality only.
- Developers who import plugins at run time must consider a plugin as a black box.
- Always ensure that third-party plugins do not get access to critical components like databases, etc. Remember, the black box analogy? Third-party plugins aren’t trustworthy at all times.
- Since plugins are independent components that are loaded at runtime, extensive documentation is required. Symbol names used for lookup should be documented properly.
- Plugins shouldn’t be slow. Slower the plugin, it has high impact on the performance of the program, as they’re loaded at runtime.
Distributing plugins ¶
Ok, we have plugins. But how do we distribute them?
The easiest way to distribute plugins would be to use a plugin registry/index. The main app can request for a plugin from the registry and load the plugin at runtime.
Here is a neat visualization on how the distribution flow works:
The main app asks for plugin2
from the “Plugin Handler” component. The Plugin Handler component has two components:
- Plugin Fetcher: This component fetches the plugin from the registry and stores it in the user’s filesystem.
- Plugin Loader: Checks the central directory for downloaded plugins, returns the requested plugin.
The plugin fetcher downloads plugin2
from the registry, which is eventually returned by the plugin loader. The main app now loads plugin2
at runtime.
Conclusion ¶
Plugins allow an application to extend its functionality. Plugin support was requested by the Cobra community a couple of times (#691, #1026, #1361), but at the time of posting this blog, plugins are not natively supported by Cobra.
Plugins can be a great way to reduce your binary size. Your CLI can choose to have a subset of your available commands, and the commands with lesser priority can be set up at runtime. This however comes at a cost of maintenance, but allows builds to be faster due to less bloat in your CLI.
I would also recommend taking a look at RPC-based plugin systems, for example, hashicorp/go-plugin and natefinch/pie.
Further reading ¶
- Eli Bendersky’s Post on Go Plugins.
- golang/go #19282: Good read on why Windows support is difficult, and discussions on Go’s plugin support being “half-baked”.
- Source code for the plugin package: What could be better than this?