Today I want to share a simple concept that I found useful when writing functions in go. Before jumping in, I want to talk about context to better illustrate the use case.
A couple of years ago, I developed a simple CLI tool to interact with packet cloud using their go SDK . As you may guess, the CLI tool had to implement all the major operations/functions provided by the cloud, meaning create, start, stop servers, manage the network environment etc … The first step was to create a function for each operation, and each function would define the options and parameters that would be submitted to the corresponding packet API. I wanted to have these wrapper functions so that the CLI logic would not have to care about how to create the packet client, or how to call the API, but only focus on the user interface, meaning define the CLI parameters, help contents etc… Below is an example of one of the wrapper functions.
func CreateDevice(projectID, hostname, plan, facility, operatingSystem, billingCycle, userData, ipxeScriptURL string, tags []string, spotInstance, alwaysPXE bool, spotPriceMax float64, terminationTime *time.Time, silent bool) error {
var err error
// Create the device
return err
}
Looking at this code now, I’m like, what was I thinking??? I think that a better approach would be to define a struct whose purpose would be to pass the parameters to the function. In fact, the guys at packet actually included in the SDK these structs for most of the API calls that take many options:
Example: here is an illustration with a device create request parameters in a struct
// DeviceCreateRequest type used to create a Packet device
type DeviceCreateRequest struct {
Hostname string
Plan string
Facility []string
OS string
BillingCycle string
ProjectID string
UserData string
Storage string
Tags []string
IPXEScriptURL string
PublicIPv4SubnetSize int
AlwaysPXE bool
HardwareReservationID string
SpotInstance bool
SpotPriceMax float64
TerminationTime *Timestamp
CustomData string
UserSSHKeys []string
ProjectSSHKeys []string
Features map[string]string
IPAddresses []IPAddressCreateRequest
}
So a refactored CreateDevice
wrapper function would look like this:
func CreateDevice(p *DeviceCreateRequest) error {
var err error
// Create the device
// We can access the parameters through *DeviceCreateRequest
return err
}
In this example, the structs are already included in the SDK, but even if they are not present, I think it still makes sense to create them as needed for the following reasons.
Readability
Compare the two function declarations to see what I mean. All the function parameters in the first function are difficult to read and also difficult to document. By using a struct, the function declaration can be more consise and easy to read. On top of that, each parameter in the struct can be easily documentated. In the packngo SDK, you can see how some relevant options are documented in the struct:
type DeviceCreateRequest struct {
// Skipping all the other parameters to make illustration
// shorther
// UserSSHKeys is a list of user UUIDs - essentialy
// a list of collaborators. The users must be a collaborator
// in the same project where the device is created.
// The user's SSH keys then go to the device.
UserSSHKeys []string
// Project SSHKeys is a list of SSHKeys resource UUIDs.
// If this param is supplied, only the listed SSHKeys will go
// to the device.
// Any other Project SSHKeys and any User SSHKeys will not
// be present in the device.
ProjectSSHKeys []string
}
Handling of default values
The function caller only has to set the parameters(options) that they are interested in. The other ones can be checked and provided with default values inside the function:
func CreateDevice(p *DeviceCreateRequest) error {
var err error
// Before creating the device, we can set default values
// when necessary
if p.OS == "" {
p.OS = "ubuntu"
}
// create the device...
return err
}
Then the caller only has to set mandatory options, and let the CreateDevice
function set default values when needed:
func main () {
// Call CreateDevice
err := CreateDevice(&CreateDeviceRequest{
Hostname: "create-device-001",
// The CreateDevice function can take care of setting
// default values for all the other parameters.
})
if err != nil {
panic(err)
}
}
By comparison, the caller would have to set all the possible options for a device create request in the first function, even though Hostname
is the only desired option in this case.
Higher maintainability
With a struct as function parameter, you can easily modify the parameters without changing the function declaration. At least in this case, it is easy to add new optional paramters to create a device, without breaking any code that rely on the CreateDevice
function.
Conclusion
I’m not sure if this is a definitive best practice, but I found it very usefull to define function paramters in a struct, especially when you have to deal with multiple paramters:
// DoSomethingParameters represents the parameters of
// the `DoSomething` function
type DoSomethingParameters struct {
ParamOne string
ParamTwo int
}
func DoSomething(p *DoSomethingParameters) error {
var err error
// do something
return err
}
Well that’s it.