Created
September 1, 2017 21:14
-
-
Save jhartman86/1ea4254b4290ddd6084c6b7a56796f96 to your computer and use it in GitHub Desktop.
config store "singleton", kind of
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
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