6 unique and lesser-known Go techniques

DadCod
9 min readJun 28, 2024

--

Today, we’re exploring 6 unique and lesser-known Go techniques.

If you are not into the mood for reading:

Generate

First, let’s talk about Go’s generate command. This tool automates code generation, saving you time and reducing mistakes. With go generate, you can add special comments in your code to run specific commands, which is great for creating boilerplate code.

//go:generate stringer -type=Status
type Status int

const (
Active Status = iota
Inactive
Pending
)

In this example, the //go:generate comment tells the Go tool to run the stringer command, which generates a string representation for the Status constants. This can be incredibly useful when you need a consistent way to convert constants to their string values throughout your codebase.

//go:generate mockgen -source=interface.go -destination=mock/interface_mock.go -package=mock
type MyInterface interface {
DoSomething(param string) error
}

In this example, the //go:generate comment runs the mockgen tool to generate a mock implementation of MyInterface. This is very useful for testing, as it allows you to create mock objects automatically.

//go:generate protoc --go_out=. --go-grpc_out=. myproto.proto

Here, //go:generate runs the protoc command to compile Protocol Buffers definitions into Go code. This is essential for applications that use gRPC for communication.

One more thing about generators: I found this cool article that discusses how to manage your tool versions. We receive an error if we don’t pre-install the stringer command, for example. One approach is to have a Makefile task that parses the tools.go file and installs those dependencies.

# Ensure the Go binary is available
GO_BIN := $(shell which go)

# Define the target for installing tools
install-tools:
@echo "Parsing tools.go and installing dependencies..."
@go list -f '{{range .Imports}}{{.}} {{end}}' tools.go | xargs -t -n 1 $(GO_BIN) install
@echo "All tools have been installed."

.PHONY: install-tools

Alternatively, because we’ve already pinned the dependencies and their versions in our go.mod file, we can achieve this through the declaration in the tools.go file.

//go:build tools
// +build tools

package main

import (
_ "golang.org/x/tools/cmd/stringer"
)

This brings us to the next technique:

Build tags

Build tags provide a powerful way to include or exclude files from the build process based on specific conditions. They can be particularly useful for managing different build environments or conditional compilation. Let’s dive into how build tags work and how they can be used effectively in your Go projects.

Build tags are special comments in Go that control when a file should be included in the package. In this case:

  • New syntax
//go:build tools

This line is the modern syntax for build tags introduced in Go 1.17. It specifies that the file should be included when the tools build tag is set.

  • Old syntax
// +build tools

This line is the legacy syntax for the same build tag, ensuring compatibility with older versions of Go.

// +build linux

package main

func main() {
fmt.Println("This code runs only on Linux")
}

In this example, the // +build linux tag at the top of the file ensures that this code is only included in the build process when targeting Linux. This allows you to maintain platform-specific code separately and avoid cluttering your codebase with conditionals.

You can also use custom build tags like // +build debug to include or exclude code based on custom conditions. This is useful for including debug or testing code without affecting the production build.

// +build debug

package main

import "fmt"

func main() {
fmt.Println("Debugging mode enabled")
}

Consider developing a cross-platform application that needs to handle platform-specific functionalities, such as file handling or system calls. Using build tags, you can maintain clean and organized code by separating platform-specific implementations into different files. This approach ensures that only the relevant code is included during the build process for each target platform, reducing the risk of platform-specific bugs and simplifying maintenance.

Functional Options Pattern

Next, let’s explore the Function Options pattern.

Consider developing a web server framework where users need to configure various parameters like host, port, timeouts, and security settings. The Function Options pattern allows users to specify only the options they care about, providing default values for the rest. This makes your API user-friendly and reduces the risk of breaking changes if new configuration options are added in the future.

This pattern provides a flexible way to handle configuration in your functions and constructors. Instead of creating multiple constructors or using complex parameter lists, the Function Options pattern allows you to use options as functions that modify the configuration of your struct.

type Server struct {
Host string
Port int
}

type Option func(*Server)

func WithHost(host string) Option {
return func(s *Server) { s.Host = host }
}

func WithPort(port int) Option {
return func(s *Server) { s.Port = port }
}

func NewServer(opts ...Option) *Server {
server := &Server{Host: "localhost", Port: 8080}
for _, opt := range opts { opt(server) }
return server
}

func main() {
server := NewServer(WithHost("example.com"), WithPort(9090))
fmt.Printf("Server running at %s:%d\n", server.Host, server.Port)
}

In this example, we have a Server struct with default values for Host and Port. The Option type is a function that modifies the server configuration. Functions like WithHost and WithPort return these options, which can then be passed to the NewServer function to customize the server's configuration. This pattern is very clean and makes it easy to add new configuration options without breaking existing code.

Comparing to the Builder Pattern

Now, let’s compare the Function Options pattern with the Builder pattern, which is commonly used in languages like Java. The Builder pattern involves creating a separate Builder struct to handle the configuration and then constructing the final object. Here’s how it looks in Go:

package main

import (
"fmt"
)

// Server struct with configuration fields
type Server struct {
Host string
Port int
}

// ServerBuilder struct for building Server instances
type ServerBuilder struct {
host string
port int
}

// NewServerBuilder initializes a new ServerBuilder with default values
func NewServerBuilder() *ServerBuilder {
return &ServerBuilder{
host: "localhost",
port: 8080,
}
}

// SetHost sets the Host of the server
func (b *ServerBuilder) SetHost(host string) *ServerBuilder {
b.host = host
return b
}

// SetPort sets the Port of the server
func (b *ServerBuilder) SetPort(port int) *ServerBuilder {
b.port = port
return b
}

// Build constructs the Server with the provided configuration
func (b *ServerBuilder) Build() *Server {
return &Server{
Host: b.host,
Port: b.port,
}
}

func main() {
builder := NewServerBuilder()
server := builder.SetHost("example.com").SetPort(9090).Build()
fmt.Printf("Server running at %s:%d\n", server.Host, server.Port)
}

In this example, the ServerBuilder struct is responsible for configuring and constructing the Server. Each configuration method, like SetHost and SetPort, returns the builder itself, allowing for method chaining. Finally, the Build method constructs the Server instance with the configured values.

Choosing between these patterns depends on the specific needs of your project. For most Go applications, the Function Options pattern is more idiomatic and aligns well with Go’s simplicity and flexibility.

Error Wrapping

Imagine you’re developing a microservice that interacts with multiple external services, like a database and a third-party API. When an error occurs, simply returning the error isn’t helpful for diagnosing the root cause. By wrapping errors, you can provide a clear and detailed context of what operation failed and why, which is crucial for debugging and maintaining reliable services.

Error handling is crucial in any programming language, and Go makes it easy with its built-in error type. However, simply returning errors isn’t always enough. You often need to provide context for what went wrong. This is where error wrapping with fmt.Errorf comes in handy. It allows you to add context to your errors, making debugging much easier.

func doSomething() error {
err := someFunction()
if err != nil {
return fmt.Errorf("doSomething failed: %w", err)
}
return nil
}

In this example, if someFunction returns an error, we wrap it with additional context using fmt.Errorf. The %w verb is used to include the original error. This wrapped error provides a complete picture of what went wrong, making it easier to trace the source of the problem.

func fetchData(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch data from %s: %w", url, err)
}
defer resp.Body.Close()

data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return data, nil
}

In this example, network and I/O errors are wrapped with context that includes the URL being accessed, providing more useful error messages.

Using Context for Cancellation

Imagine you’re developing a web server that handles incoming HTTP requests. Some requests might involve long-running operations, such as querying a database or calling an external API. Using context for cancellation, you can gracefully handle client cancellations (e.g., if the user navigates away from the page) and avoid wasting resources on operations that are no longer needed. This improves the efficiency and responsiveness of your server.

The context package is an essential tool for managing cancellations and timeouts in concurrent Go programs. By passing a context through your functions, you can signal cancellation across goroutines and control the lifespan of operations. This improves resource management and ensures that your application can respond to changes promptly.

func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func() {
time.Sleep(2 * time.Second)
cancel()
}()

<-ctx.Done()
fmt.Println("Operation canceled:", ctx.Err())
}

In this example, we create a context with a cancel function. A goroutine simulates some work and then calls cancel to signal that the operation should be canceled. The main function waits for the context to be canceled and then prints the cancellation error. This pattern allows you to handle cancellations gracefully and avoid wasting resources on unnecessary operations.

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

select {
case <-time.After(3 * time.Second):
fmt.Println("Operation timed out")
case <-ctx.Done():
fmt.Println("Context canceled:", ctx.Err())
}
}

Using context.WithTimeout, you can set a timeout for operations. If the operation takes longer than the specified time, the context will be canceled automatically.

func main() {
deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

select {
case <-time.After(3 * time.Second):
fmt.Println("Operation timed out")
case <-ctx.Done():
fmt.Println("Context canceled:", ctx.Err())
}
}

Similarly, context.WithDeadline allows you to set a specific time at which the context will be canceled, providing even more control over your operations.

JSON Tagging for Structs

Imagine you’re building a RESTful API that returns user data. Some fields, like passwords or internal IDs, should not be exposed to clients for security reasons. By using JSON tags, you can control the visibility of struct fields, ensuring that sensitive information is never sent in the API response. This approach helps maintain data privacy and security while providing a clean and consistent API interface.

Customizing JSON encoding and decoding with struct tags can give you precise control over how your data is marshaled and unmarshaled. This is particularly useful for APIs where you need to ensure that the JSON output matches specific requirements and excludes sensitive information.

type User struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
Age int `json:"-"`
}

type APIUser struct {
User
Status string `json:"status"`
}

func main() {
user := User{Name: "John Doe", Email: "john.doe@example.com", Age: 30}
apiUser := APIUser{User: user, Status: "active"}

jsonData, err := json.Marshal(apiUser)
if err != nil {
fmt.Println(err)
return
}

fmt.Println(string(jsonData))
}

In this example, the User struct has JSON tags that control which fields are included in the JSON output. The Email and Age fields use the omitempty and - tags, respectively, to conditionally include or exclude them. The APIUser struct embeds User and adds a Status field, allowing you to customize the JSON output for different contexts.

Additional Useful JSON Tags:

  1. omitempty: Omits the field from JSON if it has an empty value (zero value for the type).
  2. -: Completely excludes the field from JSON encoding and decoding.
  3. json:"name,string": Encodes/decodes the field as a JSON string, useful for numeric fields that need to be strings in JSON.
  4. json:"name,omitempty,string": Combines omitempty and string encoding/decoding.

Conclusion

That’s it for our 6 unique Go techniques! By incorporating these into your projects, you’ll write more efficient, idiomatic, and powerful code. If you enjoyed this article, give it a clap, and don’t forget to subscribe. Check out my youtube channel where there is a video version!

--

--