best_practices

Last updated: 2024-09-17 21:09:15.919095 File source: link on GitLab

Standards and Best Practices

Introduction

This document is a store of best practices to be followed for the development of Nunet components. The current file is more focused on Golang best practices for building Device Management Service (DMS), which is the core component of the Nunet platform.

Table of Contents

Naming Conventions

File Names

  1. All file names should be in lowercase. And it is recommended to use underscore between two words. Example: capability_comparator.go

  2. The files containing tests should have _test.go suffix in the name Example: capability_comparator_test.go

Variable Names

The name of any error variable must be err or prefixed with err. Consistent naming for error variables makes the code more readable and easier to follow. See below for an illustrative example.

file, err := os.Open(filename)
userData, errFetch := fetchUserData(userID) if errFetch != nil { return errFetch }
If err != nil && errFetch != nil {....}

Structs

Named fields improve code readability and reduce errors caused by misordered fields. For example,

person := Person{ Age: 11 } and not Person{11}:

Units

Including the unit(e.g. time) in the name. Append the interval type to the consts, for example dialupTimeoutSecond or dialupTimeoutMillisecond

Including the unit in the name clarifies its meaning and prevents unit-related bugs.

Formatting and Style

Line Size

It is recommended to limit the line size to 100 characters per line. Keeping line lengths manageable (e.g., under 80-100 characters) helps readability, especially on smaller screens.

Function Size

Aim for functions that fit within a single screen of code without scrolling

This usually means keeping functions under 20-30 lines, making them easier to read and understand.

Code Structure

Avoid Global Variables

As a general principle, it is recommended to avoid using global variables as much as possible, because it can lead to tight coupling between components of the system. It also simplifies testing.

As an alternative, it is suggested to use dependency injection instead.

Group API Handlers

It is recommend to use objects (structs) to group related handlers. See below for an illustrative example.

func (s *RESTServer) InitializeRoutes() {
    v1 := s.router.Group("/api/v1")

    onboarding := v1.Group("/onboarding")
    {
        onboarding.GET("/metadata", s.GetMetadataHandler)
        onboarding.GET("/provisioned", s.ProvisionedCapacityHandler)
        onboarding.GET("/address/new", s.CreatePaymentAddressHandler)
    }
}

Type Definition

Type definitions should be done on top of the page. This provides a clear overview of the structures and types used in the file.

The recommended order of type definitions is

  1. consts

  2. interfaces

  3. structs

A logical order (e.g., constants, interfaces, structs, functions) helps readers understand the code structure quickly. It is recommeded that the Constructor should be defined first immediately after the struct section.

Functions and Methods

  1. Accept interfaces and return structs

This follows Go’s idiomatic way to make code more flexible and easier to test. Special cases: When needed to return the interface, we have another constructor returning the interface

  1. Return pointers to structs in constructors

This has several benefits:

  • Efficiency: Reduced Copying: When you return a pointer, you avoid copying the entire struct. This is especially important for large structs, as copying can be expensive in terms of performance. Memory Allocation: Using pointers ensures that only one instance of the struct is created and manipulated, reducing memory overhead.

  • Mutability: Modification: With a pointer, the receiver can modify the original struct's data. This is useful when you want to update fields of the struct after it's been constructed. Shared State: If multiple parts of the program need to share and modify the same instance, pointers facilitate this by allowing all references to point to the same underlying data.

  • Consistency: Interface Implementation: Methods that implement interfaces often require pointer receivers to modify the struct. Returning pointers ensures that these methods can be used as intended. Idiomatic Go: Returning pointers is a common and idiomatic practice in Go, aligning with community conventions and expectations.

  1. Avoid very similar methods doing almost the same thing without adding any business logic

For example, in teh below interface, the second and third method are simply returning a filtered value of GetGPUs. This kind of practice decreases the abstraction of an interface.

type Resources interface {
     GetGPUs()
     GetGPUsByVendor(vendor)
     GetGPU(id string)
}

Below is another example where the second method probably does not have any additional business logic but is added to execute a for loop. It is best to avoid such definitions, and keep only the first method.

type Mailbox interface {
    SendMessage(actorID string)
    SendMessageToActors(actors []string)
}

Error Handling

  1. Always wrap errors and return them

Wrapping errors with context provides more informative error messages.

func readConfigFile(filepath string) error {
    file, err := os.Open(filepath)
    if err != nil {
        // Wrapping the error with context
        return fmt.Errorf("failed to open config file at %s: %w", filepath, err)
    }
    defer file.Close()

    return nil
}
xerror 
Func XYZ() error{
Return errors.New(“”)
}
  1. Don’t log errors in functions that return errors

Logging should be done at the top level where errors are handled, not within lower-level functions.

func readConfigFile(filepath string) error {
    file, err := os.Open(filepath)
    if err != nil {
        // Incorrect: Logging the error here
        fmt.Printf("Error opening file: %v\n", err)
        return err
    }
    defer file.Close()

    return nil
}

Non error logs levels can be used as needed.

  1. Consistent Error Handling

Handle errors consistently. Avoid ignoring errors, and handle them as soon as possible.

  1. Avoid Panics

Use panics only for unrecoverable errors. For recoverable errors, use error returns

Testing

  1. Use table-driven tests

Table-driven tests are a common Go pattern that make it easy to add new test cases and improve test readability.

func TestSomething(t *testing.T) {
    tests := []struct{
        input string
        expected string
    }{
        {"input1", "expected1"},
        {"input2", "expected2"},
    }

    for _, tt := range tests {
        result := Something(tt.input)
        assert.Equal(t, tt.expected, result)
    }
}
  1. Use assert library for testing

Using an assert library makes tests cleaner and provides better error messages when tests fail.

Use assert.Equal(t, expected, actual) 
NOT if expected != equal {}
  1. Only use mocks if not otherwise possible

Avoiding excessive mocking leads to more reliable tests that don't break with internal changes.

General

  1. Elements in maps are not ordered and randomly retrieved

Never rely on ordering in a map. Using maps in tests can help ensure your code doesn’t rely on specific orderings, improving robustness.

  1. Do not ignore "contexts"

Contexts are critical for handling deadlines, cancellation signals, and request-scoped values across API boundaries and goroutines.

Note: Telemetry uses contexts heavily, see how this is affected

  1. Avoid Unnecessary Slices Allocations

Preallocate slices when the size is known to avoid unnecessary allocations.

func collectSquares(n int) []int {
    squares := make([]int, 0, n)  // preallocate the slice with capacity n

    for i := 1; i <= n; i++ {
        squares = append(squares, i*i)  // adding elements without additional allocations
    }

    return squares
}

func main() {
    result := collectSquares(10000)
    fmt.Println("Collected", len(result), "squares.")
}
  1. Avoid Exporting Unnecessary Types and Functions

Keep the API surface small by only exporting types and functions that are intended for public use.

Check for nil Before Dereferencing Pointers

Always check for nil pointers before dereferencing to avoid panics.

if obj == nil { return errors.New("object is nil") } fmt.Println(obj.Field)
  1. Use filepath Package for File Path Manipulation

Use path/filepath for manipulating file paths to ensure cross-platform compatibility. Don’t create path by concatenation “/” or “\”

Code Quality

  1. It is recommended to use golangci-lint Linter.

Linters enforce coding standards and catch common mistakes early, improving code quality.

  1. Avoid magic numbers and use consts

Constants give meaningful names to otherwise obscure numbers, improving code readability.

Go Idioms

  1. Avoid new keyword if possible

Using composite literals (e.g., &Person{}) is more idiomatic and clear in Go.

Last updated