Skip to main content

control.rip

Compile time options in Go applications

Making software features and functionality configurable improves the quality of both the code base and the end product. This can aid in debugging, increase code maintainability, and make code more testable. Sometimes, however, you may not want configuration parameters to change after compilation. Many programming languages provide patterns for this. Let’s take a look at a simple pattern for compile time options in Go applications using build tags and the cmd/ project structure.

Table of contents

Security concerns

Before we get started, it should be noted that some people may look at this and get the idea to compile credentials or secrets into their code.

Do not put secrets in your source code. If you are compiling secrets into your code, you are doing it wrong.

Lastly, this pattern will not stop someone from reading or editing compiled code. This pattern is meant to help prevent configuration mistakes. It will not stop or even deter reverse engineering efforts.

Build tags

Build tags (or build constraints) provide a way to specify which Go source files will be compiled. You have probably seen a few in source filenames, such as linux and windows. Build tags can be specified by writing a special comment before the package clause inside a source file. The following snippet contains the first three lines of a source file included only in builds for Linux operating systems:

// +build linux

package main

It is very important that an empty new line follow the build tag comment. Otherwise the build tag(s) will be considered a normal comment, and will be ignored. The following snippet demonstrates the incorrect usage of the build tag comment:

// THIS IS WRONG - There must be an empty new line between the lines below.

// +build linux
package main

A magic comment will likely annoy some people - but it is what it is. There are also a few magic build tags, such as operating system names. This is covered in the documentation linked above. Build tags can be targeted at compile time using the -tags command line argument for go build or go run.

Leveraging build tags

Custom build tags can be specified by simply adding them in the build tags comment. Let’s pretend that we need to define a function that returns a bool which relates to the specified build tag. The following snippet demonstrates this by creating a tag named debug:

// +build debug

package myapp

func DisableMTLS() bool {
	return true
}

The above code snippet demonstrates how to define a tag named debug and include a function named DisableMTLS that returns true. Now, we need a non-debug implementation of this function. How about we use the build tag release to differentiate it?

// +build !debug release

package myapp

func DisableMTLS() bool {
	return false
}

The above file will be included when any tag besides debug is specified, or when release is specified. This allows us to continue compiling the application without specifying any build tags. Thus, the release tag acts as a sensible default, and should stop us from accidentally compiling a debug application variant.

Lastly, we can call this function from our application:

package main

import (
	"fmt"

	"github.com/stephen-fox/myapp"
)

func main() {
	fmt.Printf("DisableMTLS is %v\n", myapp.DisableMTLS())
}

Let’s take a look at what specifying different build tags does:

go run main.go
# Prints "DisableMTLS is false".

go run -tags release main.go
# Prints "DisableMTLS is false".

go run -tags debug main.go
# Prints "DisableMTLS is true".

Pretty neat! But I think we can do better.

Integrating with the cmd/ project structure

In the previous example, we implemented compile time options for a command line application using Go’s build tags. The compile options' source files were intertwined with other code, and were not clearly separated. Not exactly great for maintainability.

We can improve this by moving the compile time options into a separate Go package containing only compile time options. Since these options are meant for an application, and not a library, we should place the package near the main package. Personally, I am a big fan of the unofficial project layout, which designates the cmd/ directory for application-specific code.

Here is what our new project structure looks like (this example project can be found on my GitHub):

myapp/
|-- doc.go
|-- go.mod
|-- library.go
|-- cmd/
    |-- myapp/
        |-- main.go
        |-- compileoptions/
            |-- doc.go
            |-- options_debug.go
            |-- options_release.go

Now we can move our compile time options into the compileoptions package, and document how to use them in the doc.go file. This project structure clearly communicates how compile time options are implemented in the project.

This pattern also prevents us from accidentally using compileoptions in both the command line application, and in the library code. If library.go imported code from compileoptions, and main.go imported code from both compileoptions and myapp, then compilation would fail due to an import cycle.

Our code in main.go will look like this:

package main

import (
	"fmt"

	"github.com/stephen-fox/myapp/cmd/myapp/compileoptions"
)

func main() {
	fmt.Printf("DisableMTLS is %v\n", compileoptions.DisableMTLS())
}

This clearly documents where the compile time logic and configuration is stored. Compilation remains simple; only one source file is needed when running go build or go run:

go run cmd/myapp/main.go
go run -tags debug cmd/myapp/main.go

It should be noted that this example demonstrates only a small slice of what is possible with this pattern. Constants, structs, and other functionality can be selectively implemented with this pattern.

I hope you find this information helpful. Good night, and good luck.

Updates and corrections

  • May 12, 2022 - Move sections around to be consistent with new post structure
  • September 13, 2020 - Style references more closely to APA format. General changes for new blog theme

References