Developing for Multiple Platforms With Go

08 Apr 2014

by Joel Hunsley (Developer)

The Go language was designed to be a systems language usable across multiple platforms. It has a number of built-in language and build features that make it ideal for easy cross-platform development. This post covers how we do and do not use these features, with some examples, and some things we’ve found on the way that still aren’t perfect.

This post is adapted from a presentation I made at a Go meetup. If you’re the sort of person who prefers these things in video form, it’s the Feb 10 video on this page.

Best Practices

Some of this will seem familiar if you were reading the Go Advent posts over December.

The first rule is to know what’s baked into the language directly and available in the standard library. Many things that can be annoying to deal with across multiple platforms are already handled for you, e.g. File input/output, threading and concurrency, and networking.

Interfaces

Go interfaces define a set of methods which an object must implement to satisfy the interface.

Two objects on two platforms with completely different method implementations can seamlessly satisfy the same set of go interfaces. Also, most standard library constructs support varying levels of abstraction via different interfaces they implement. It is therefore both easy and prudent to abstract all platform specifics behind interfaces. Everything that needs to be platform specific can then provide implementations of those interfaces behind build constraints

Build Constraints

Build constraints are a language construct that can be used to control the platforms and architectures on which a file will be compiled. They come in two flavors:

  • Filename-based: Simple but inflexible
  • Build constraint comment at top of file: More complex, much more flexible, and in my opinion much uglier

Build constraints using filenames: <name>[_GOOS][_GOARCH].<extension> e.g. taskbar_windows.go

GOOS and GOARCH above are placeholders for any of the valid values for those Go build environment settings. This syntax makes it simple and easy to understand a file’s constraints at a glance, without even opening it. However, it is limited to a single OS, architecture, or OS/architecture combination.

Build constraints using file comments: // +build linux,386 darwin,!cgo This comment must appear before any actual code in the file. The only things allowed beforehand are whitespace or other line comments. Commas function as an AND, spaces function as an OR. This style of build constraint is significantly more flexible than the filename-based kind. In addition to being able to specify multiple different constraints for the file, it also allows a few additional platform constraints like the “not CGO” visible above.

However, I absolutely hate that this style of constraint is based on “magic comments”. There is another usage of them in CGO, in which they are used to specify exports.

I personally believe this to be one of the most egregious semantic decisions in the language. Comments should have no effect on the code around them except to illuminate the reader. I believe there should never be circumstances where altering a comment breaks code functionality, but that’s exactly what can happen with these in Go. There could easily have been a language construct designed to be similar to comments but used only for magic.

OS and Architecture at Runtime

Is it technically possible to obtain and use this information at runtime? Yes, you can use the values GOOS and GOARCH within the runtime package and compare them to known values using standard conditionals. But it’s probably a bad idea. Runtime checking is only appropriate in the most trivial of cases. You’re otherwise simply increasing the chances of error if you later add additional platforms.

What We Do

Interfaces

Here’s a simple but concrete example of how we use interfaces for platform-independent code:

package utils

type IExec interface {
    ExecuteCommand(command string, args ...string) error
    ExecuteCommandWithOutput(command string, args ...string) (string, error)
}

var Exec = makeIExec()

And platform-specific code itself:

type windowsExecModel struct {}

func makeIExec() IExec {
    return &windowsExecModel{}
}

func (m *windowsExecModel ) ExecuteCommand(command string, args ...string) error {
    cmd := exec.Command(command, args...)
    cmd.SysProcAttr  = &syscall.SysProcAttr{HideWindow :true}
    return cmd.Run()
}

func (m *macExecModel) ExecuteCommandWithOutput(command string, args ...string) (cmdResults string, err error) {
    cmd := exec.Command(command, args...)
    bytes, err := cmd.Output()
    if err != nil {
        return "", err
    }

    return string(bytes), nil
}

This is obviously a trivial example, but we needed to do it because the default implementation of the exec.Command function doesn’t hide the command window on Windows.

Optional Implementations

You can use a minor variation on the preceding pattern when you want a feature to be compiled on platforms that support it, and ignored on others. The interface definition still looks the same:

type TaskBar interface {
    SetShutdownAction(shutdownExecutor func())
    SetTaskBarStatus(newStatus TaskBarStatus)
    StartClient() *exec.Cmd
    Clear()
}

Exposed requests, and the non-exposed variable used:

// Platform-specific, should be set in a platform-specific init
var theTaskBar TaskBar

// Functions below are delegated to the platform-specific taskbar
func SetTaskBarStatus(newStatus TaskBarStatus) {
    if theTaskBar != nil {
        theTaskBar.SetTaskBarStatus(newStatus)
    }
}

func Clear() {
    if theTaskBar != nil {
        theTaskBar.Clear()
    }
}

I’ve only shown the usage of the Clear function for brevity, but the other methods in the interface follow the same usage pattern. Then the platform-specific implementations implement the same interface, but live in another file behind a build constraint:

func init() {
    utils.CallWithPanicProtection(
        func() {
            theTaskBar = &WinTaskBar{}

            // ... More initialization ...
        }
    }
}

func (wtb *WinTaskBar) Clear() {
    // remove icon
    data := makeNotifyIconData()
    data.UID = activeIconIdentifer
    Shell_NotifyIcon(NIM_DELETE, data)
    activeIconIdentifer = 0

    // Destroy the message window
    DestroyWindow(iconWindowHandle)
    iconWindowHandle = 0
}

Native API Usage

We handle Windows API calls through a custom library imported only in _windows files. For an open-source equivalent, you can see the package w32. The main reason we didn’t use w32 itself was to control the error conventions used. When loading procs from DLLs in Go, remember to load the Unicode versions if they exist.

Here is a minor peek at our Windows API code:

package winapi

import (
    "syscall"
    "unsafe"
)

var (
    shell32 = syscall.NewLazyDLL("shell32.dll")
    shellNotifyIcon = shell32.NewProc("Shell_NotifyIconW")
)

func Shell_NotifyIcon(dwMessage DWORD, lpdata PNOTIFYICONDATA) (BOOL, error) {
    // Ensure size is assigned
    if lpdata.CbSize == 0 {
        lpdata.CbSize = DWORD(unsafe.Sizeof(*lpdata))
    }

    ret, _, callErr := shellNotifyIcon.Call(uintptr(dwMessage),
                                            uintptr(unsafe.Pointer(lpdata)))
    if ret == 0 {
        return BOOL(ret), callErr
    }
    return BOOL(ret), nil
}

We handle Mac API calls entirely outside of Go, through a wrapper app. We had a wrapper app already for other reasons, even before we discovered that some of the Mac API objects strongly to being called from a non-main thread. This is particularly annoying to deal with in a language that does concurrency so well.

CGO

We have a few platform-specific pieces of C that interact with their go counterparts. Do note that build constraints function on C files compiled with CGO as well as go files.

// +build windows,cgo

#include <stdio.h>
#include "icon_windows.h"

bool loadBitmap(
        const char* appPath, 
        const char* destPath,
        IconSize iconSizeNeeded)
{

We also have some CGO that is used for each platform, but that calls out to a C++ library that we build individually for each platform. This is probably not a best practice unless you have a specific use case. Ours is that the library is an interface to our lower-level drivers on each platform, and must be built individually per-platform anyway. The library is linked in via environment variable control in the build scripts.

set CGO_CFLAGS=-I%GOPATH%\inc
set CGO_LDFLAGS=-L%GOPATH%\lib\win\%ARCH%

What We Don’t Do - Cross-Compilation

Cross-compilation is a really cool part of the go build toolchain. The one basic requirement is that you must make go for each os/architecture combination you want to compile to. Then, using just a couple of environment variables, you can build any supported type of binary on a machine of any type.

set GOOS=darwin
set GOARCH=386
go build src/github.com/steelseries/-------/main.go

Sadly, we have to maintain two separate build environments for some of our non-Go pieces (the aforementioned library and drivers). At that point it became easier just to native-compile on each platform. But if you aren’t in the same boat, it’s a cool feature that could save you some trouble.

Pitfalls and Imperfections

Documentation

Documentation is by far my biggest gripe with the language. Currently, non-linux environments are second-class citizens for go documentation. The official go package documentation, as well as godoc.org, both only show documentation for functions and variables exported under GOOS=linux. This is true even for standard library packages like syscall and os which are guaranteed to differ by platform.

Google Groups and StackOverflow postings should be secondary sources, not your only hope. Let’s look at a pretty common case: Say you want your Windows executable to have a custom icon, like pretty much any application released ever. Good luck with official docs. What search terms give you useful information?

  • If you don’t know that icons are stored as embedded resources, you’re completely out of luck
  • If you figure that out, there are 2 different threads on golang-nuts you could stumble into, one of which has the complete answer. The other one points you in the direction of calling the linker and packer directly rather than using go build.
  • If you know that a resource file has the extension .rc, there’s one additional thread with the information

What’s the real answer?

  • In a resource file (resources_windows.rc): TRAYICON_OK ICON "resources/ssenext.ico"
  • In the build script, prior to the go build: windres -l 0 -o src/github.com/steelseries/-------/resources_windows.syso resources/resources_windows.rc

That’s all there is to it, but finding the information is much harder than it should be.

Mac OSX API/Cocoa Interaction

Go is inherently concurrent and multi-threaded. Very unfortunately, Cocoa and the OSX API are not all thread-safe. This post is from 2009, but the problems exist to this day. Being restricted to the main thread severely constrains application design and the usability of concurrency in Go. This is not directly a problem with Go, but it’s annoying when trying to design and code for multiple platforms.

Deployment

Static binaries eliminate some of the headaches of multi-platform deployments, but certainly not all of them. At least, if you want to deploy anything aside from a bare executable. It’s not really reasonable to hope that a language could solve all of the little quibbles with deployment (especially user-facing applications). But it sure would have been nice :)

In closing, Go is a very powerful and useful language that eliminates many (though not quite all) of the headaches of multi-platform development. Hopefully this post clarified some of the ways that is true.