Skip to content

Instantly share code, notes, and snippets.

@jhartman86
Created September 1, 2017 21:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jhartman86/1ea4254b4290ddd6084c6b7a56796f96 to your computer and use it in GitHub Desktop.
Save jhartman86/1ea4254b4290ddd6084c6b7a56796f96 to your computer and use it in GitHub Desktop.
config store "singleton", kind of
/*
Global config parser. Please actually read this to understand how it works.
There are three ways to set runtime configurations:
- Environment variables
- env.json files (one only)
- CLI flags
Order matters. Environment variables are parsed first. Then the env.json
file. If the env.json file contains the same key as an environment variable
name, the value in env.json takes precedence. Lastly, CLI flags are parsed.
CLI flags will override any environment variables or env.json settings. So
the order looks like: env var > env.json > CLI flags.
As its easy to get environment variables mixed up among different shells and
run the application with, perhaps, unintended env variable settings - we want
to encourage the use of env.json files. As such, you can *set an env var
determining the env.json file to use*. Meaning its easy to group runtime
configs by file and only check a single env var (what config file am I
reading?) vs. checking a multitude of different vars. (This is the common
equivalent of being able to set the config directory location via an env
var, and thats why).
If you're working on updating this code, it shouldn't require a profound
knowledge of locking and mutexes, but it *is* required to understand how
its intended to be used (as a read-only, application-wide "singleton"
AFTER its initialized really early on in an application's bootstrapping
order). Thus, the idea is to create a new config bucket once, and pass it
around as a pointer. The config values are stored in a map "store", and
as such would not be safe for concurrent access *if we allowed writes
to the config "store" after initialization*. Thus, the config store is
not exported on the struct, but instead requires READ ONLY accessor methods
(really just `Get()`). Since its only reading from the map after its been
guaranteed to be initialized, with no way of setting/writing to the store
map at a later time, we don't need to implement mutexes for read operations...
HOWEVER - there is a "write to the store map" escape hatch, which *does*
implement mutexes, because CLI flags :(. In other words, its easy to
initialize a configBucket right out of the gate since it can just read
env vars and an env.json file that are guaranteed to be available to
initialization - but CLI flags are parsed at runtime, once the config
store is already imported (thus, created via an init() function).
The escape hatch thing is unfortunate, but we also put some safety measures
in place around it. Namely, it implements both write mutexes, AND a
once.Do() syncronizer. Meaning it can only be used once. And that one
time *should* only ever be to set the CLI flags.
If you find yourself coming here to add another setter method on the config
store - strongly consider why you need to do so, and consider architecting
a solution in a different way.
Also, the only reason there are "instances" of configBuckets is it makes
testing way easier. Really, you could import the config package and it'd
init() once and keep everything in unexported state - but that makes
testing a bitch.
Good references:
https://hackernoon.com/dancing-with-go-s-mutexes-92407ae927bf
*/
package config
import (
"fmt"
"os"
"sync"
// "path"
"strings"
"io/ioutil"
"encoding/json"
)
// Shorthand alias instead of typing map[string]string all over
type kvMap map[string]string
/*
Note regarding the single "store" key value map: there are two ways the
order-of-precedence thing could be architected. One, we could have three
independent maps (env vars, env.json vars, and CLI flags (or "extras"),
then for every `Get()`` call, search each of those maps). That makes it
reaaaally explicit. Its also three lookups (potentially). OR, we do what
we're doing at the time of this writing, which is to stuff everything
into a canonical "store" map, where we simply guarantee its order of
construction during init (eg. New()ing).
*/
type configBucket struct {
// Scope valid config vars by a prefix, eg "FLD_"
prefix string
// Absolute path to the env.json file (doesn't have to be called env.json)
envFilePath string
// Keep track of any errors *during initialization* (eg. the env.json file parsing failed)
initErrors []error
// Map of config values (only mutable internally)
store kvMap
// Ensure some methods are never run more than once
onceMapEnvVars, onceMapFileVars, onceEscapeHatch sync.Once
// Mutex lock for the escape hatch
locker sync.Mutex
}
/*
NewConfigBucket is the only entry point for creating/getting a
config bucket instance. The configBucket type is purposefully not
exported, so this is the only way to construct one.
*/
func NewConfigBucket(prefix, envFilePath string) *configBucket {
c := &configBucket{
prefix: prefix,
envFilePath: envFilePath,
store: make(kvMap),
}
c.mapEnvVars()
c.mapFileVars()
return c
}
/*
Get a value from the config bucket by key. If the key doesn't begin with
a valid prefix, return an error right away. If the value is missing, return
an error also. Why errors? If its important at runtime, make it easy for
config implementers to check. If its not important, just ignore the erorr
in implementing code:
Do something with the error:
myVal, err := config.Get("LETS_PRETEND_MISSING"); if err != nil {
panic(err)
}
Ignore the error if the value isn't critical:
myVal, _ := config.Get("WHATEVER")
... do your thang ...
*/
func (c *configBucket) Get(key string) (string, error) {
if c.validPrefix(key) != true {
return "", fmt.Errorf("Fetching config without prefix %s is not allowed: tried %s", c.prefix, key)
}
val, ok := c.store[key]; if !ok {
return "", fmt.Errorf("Missing config: %s", key)
}
return val
}
/*
EscapeHatch is a one-time setter into the config store. We need this
so we can allow the CLI flags (which aren't available immediately upon
initialization) to be set later. See notes above about the locking
strategies here.
*/
func (c *configBucket) EscapeHatch(mapIn map[string]string) {
c.onceEscapeHatch.Do(func() {
c.locker.Lock()
defer c.locker.Unlock()
for k, v := range mapIn {
if c.validPrefix(k) {
c.store[k] = v
}
}
})
}
/*
Inspect is a way to get a copy of the internal store (eg. what is the
config state). DO NOT RETURN A POINTER TO THE INTERNAL STORE - make a
new map and and assign in values then return that new copy. This
shouldn't be used hardly ever, except as a mechanism for showing the
current config state (like printing it on the CLI for a user).
*/
func (c *configBucket) Inspect() kvMap {
m := make(kvMap)
for k, v := range c.store {
m[k] = v
}
return m
}
/*
Parse OS environment variables into the config store.
*/
func (c *configBucket) mapEnvVars() {
c.onceMapEnvVars.Do(func() {
for _, kv := range os.Environ() {
pair := strings.Split(kv, "=")
if c.validPrefix(pair[0]) {
c.store[pair[0]] = pair[1]
}
}
})
}
/*
Parse the env.json file and map settings into the store. This should
only ever be called AFTER mapEnvVars() - which is done automatically
by the only accessor we export: NewConfigBucket.
*/
func (c *configBucket) mapFileVars() {
c.onceMapFileVars.Do(func() {
raw, err := ioutil.ReadFile(c.envFilePath); if err != nil {
c.initErrors = append(c.initErrors, fmt.Errorf("%s file not found.", c.envFilePath))
return
}
m := make(kvMap)
err = json.Unmarshal(raw, &m); if err != nil {
c.initErrors = append(c.initErrors, fmt.Errorf("Env file parse error: %s", err))
return
}
for k, v := range m {
if c.validPrefix(k) {
c.store[k] = v
} else {
c.initErrors = append(c.initErrors, fmt.Errorf("Skipped config key with invalid prefix from env file: %s", k))
}
}
})
}
/*
Validate if a string (a configuration key) has the required prefix.
*/
func (c *configBucket) validPrefix(s string) bool {
return strings.HasPrefix(s, c.prefix)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment