AmitKumarDas / fun-with-programming

ABC - Always Be Coding
2 stars 2 forks source link

blog - thinking in golang #86

Closed AmitKumarDas closed 1 year ago

AmitKumarDas commented 2 years ago

Motivation

It is trivial nowadays to come across a programming language's best practices guides. One can even find really great ones if they search them in GitHub instead of Google. However, I have often found it difficult to apply these practices without having a context to back the same. Hence this document tries to put up a programming use case & starts to implement the same keeping in mind some of the good designs. Note that this will not try to cover all the best practices unless the use case demands it. We shall also see how these designs are malleable to changes in scenarios. One might find few of these approaches very opinionated. However, these very opinions have helped me in managing a lot of Go based projects that I have been associated with in the past.

The Problem

We want our users be able to filter logs based on the deployment of the corresponding application. Users would like to view the logs of currently deployed application as well the logs of the same application in its previous deployment. One of the important things to note here is that the application deployment constitutes the container image as well as few orthogonal properties (read Kubernetes resources) such as configs, ingress rules, affinity to a particular zone, CPU & memory limits.

Low Level Solution

Team decided to capture all the constructs that determine an application deployment instance. Generate a hash of these constructs & store this hash upto last two occurrences. Users can lookup the hashes & filter the corresponding application logs for comparisons, etc. (out of scope for our current problem)

Attempt 1

Attempt 2

// Note: All the logic seen in this attempt are
// placed in a single .go file

// Hashable defines the model / datatype / placeholder
// that hold properties used to compute the hash. Most
// of the times these properties are set by caller code
// and hence these properties should be public 
// i.e. CapitalCased
//
// Note: It is not a good idea to name this model as Hash
// or Hashing. Hashing refers to the structure that can hash.
// On the other hand Hashable refers to something that is
// meant to be hash-ed.
type Hashable struct {
  Labels      map[string]string
  Annotations map[string]string
  Finalizers  []string
  Spec        externapi.ServiceDescription
}

// HashOptions is useful to create new instances of
// Hashing
type HashOptions struct {
    Hashable
}

// Hashing computes the hash
type Hashing struct {
    hashable *Hashable
}

// NewHasher returns a new instance of Hashing
func NewHasher(opts HashOptions) *Hashing {
    return &Hashing{
        hashable: &Hashable{
            Labels:      opts.Labels,
            Annotations: opts.Annotations,
            Finalizers:  opts.Finalizers,
            Spec:        opts.Spec,
        },
    }
}

// Sum computes the final hash
func (e *Hashing) Sum() (uint64, error) {
    hash, err := hashstructure.Hash(e.h, hashstructure.FormatV2, nil)
    if err != nil {
        // NOTE: It is assumed that a real hash will
        // never return 0 as its value
        return 0, err
    }
    if hash == 0 {
        return 0, errors.Errorf("Invalid hash %d", hash)
    }
    return hash, nil
}

Attempt 3

// Note: If hashable properties need to form the payload
// of an API endpoint then these properties can be tagged 
// with json, yaml, protobuf, etc. This helps in marshaling and
// unmarshaling.

// Hashable defines the model / datatype / placeholder
// to hold structures that are used to compute the hash
type Hashable struct {
  Labels      map[string]string `json:"labels"`
  Annotations map[string]string `json:"annotations"`
  Finalizers  []string `json:"finalizers"`
  Spec        wlapi.ServiceDescription `json:"spec"`
}

Attempt 4

// Note: If Hashable is part of some API endpoint's payload 
// then it might be a good idea to rename it to Hash and
// move it to api package.

// Note: Consider the following structure under pkg/api

package api

// Hash defines the model / datatype / placeholder
// to hold structures that are used to compute the hash
type Hash struct {
  Labels      map[string]string `json:"labels"`
  Annotations map[string]string `json:"annotations"`
  Finalizers  []string `json:"finalizers"`
  Spec        wlapi.ServiceDescription `json:"spec"`
} 
// Note: Consider following logic inside pkg/hash package

package hash

import (
  "pkg/api"
)

// Hashing computes the hash
type Hashing struct {
  hashable *api.Hash
}

Attempt 5

package hash

import (
 "pkg/api"

  external "thirdparty.github.com/pkg/client"
)

// Note: In some of the cases the act of computing
// hash might require additional objects such as third
// party client(s). Hence, it is considered a good idea
// to have a structure named HashOptions whose only
// responsibility will be to construct new instances of
// Hashing. This helps the design achieve open closed
// principle since the hashing's constructor signature
// does not change with requirements.

// HashOptions is useful to create new instances of
// Hashing
type HashOptions struct {
  Client *external.Client
  Hashable *api.Hash
}

// Hashing computes the hash
//
// Note: Hashing & HashOptions look same. However,
// there are high chances for each of these structures
// to have different properties in subsequent releases.
// Hence it will be good to have separate structures each
// responsible for their own roles.
// Take for example the need to mock computation of
// hash. In the current scenario computation needs the
// availability of external API i.e. client should be able
// to communicate with the external API service.
type Hashing struct {
  client *external.Client
  hashable *api.Hash
}

// NewHasher returns a new instance of Hashing
func NewHasher(opts HashOptions) *Hashing {
  return &Hashing{
    hashable: opts.Hashable,
    client: opts.Client,
  }
}

Attempt 6

package hash

import (
 "pkg/api"

  external "thirdparty.github.com/pkg/client"
)

// Note: It is difficult to run unit tests with previous
// design attempts. In other words hash computation 
// needs the client to communicate with a running 
// external API service.

// HashOptions is useful to create new instances of
// Hashing
type HashOptions struct {
  Client *external.Client
  Hashable *api.Hash
}

// Hashing computes the hash
type Hashing struct {
  client *external.Client
  hashable *api.Hash

  computeHash func() (int64, error)
}

// NewHasher returns a new instance of Hashing
func NewHasher(opts HashOptions) *Hashing {
  return &Hashing{
    hashable: opts.Hashable,
    client: opts.Client,
  }
}