Loading environment variables doesn’t have to be cumbersome, nor repetitive! Often we use os.Getenv(..)
and it seems as simple as; but the problem lies in the fact this function involves (at least on Unix systems) reading from a file env
where your environment variables are stored. Ultimately, the foresaid function is in effect performing a file input/output operation which adds a tax on the performance of your application. Loading one or two environment variables may seem harmless, but think of what happens when you have a REST handler that loads an environment variable, and that handler is called hundreds if not thousands of times in a minute?
Let’s look at an alternative approach that satisfies the following requirements:
For our program, we’ll use a library for loading dotenv files: github.com/joho/godotenv and this will be used at an early stage of execution. Go ahead and create a project and add this library as a dependency.
In the project, create a config
directory and under it, an init.go
which is where we’ll be using this library. It is in this file where we’ll be satisfying the above three requirements. The third is optional generally, but it is a good idea to type-cast and check the input of what you’re receiving as this ensures nothing malicious or erroneous gets into the very heart of your program.
The code image below features a struct which is created with the following:
type environment struct {
MusingCat string
WailingKatzer string
SilentKitten string
}
This is expecting a feline company of three cats. To make things easy we’ll simply have all of the fields as strings.
Now, we can see that the struct is ready to be populated, but there’s something that needs your attention here which you might have pondered: why are we returning a copy of the environment struct when we can return a reference. This is a limitation of this design pattern, but it can also be a strength: we don’t want environment variables to be modified when we return the environment with Environment()
so we’ll return a copy to ensure there’s no accidental overriding. Overriding environment variables is more so you want to do as part of tests, and should not be considered for production use. Always keep environment variables immutable by returning an immutable copy of the object, in this case env
.
Next we use a special function that obtains a path to the init.go
when the program is run, hence the term runtime
. The filename is retrieved, and we then use a relative path to go to the project root and retrieve the .env
file. Your .env
file should look something like this:
MUSING_CAT=meow
WAILING_KATZER=bao
SILENT_KITTEN=purr
One last thing to note, is that we’re using os.Getenv
multiple times. Later, to fulfil requirement #3, I’ll show you how we can abstract this and validate.
Without further ado, let’s try this pattern out! Create a main.go
and snip in the following:
func main() {
fmt.Println(config.Environment())
env := config.Environment()
env.SilentKitten = "baowow"
fmt.Println(config.Environment())
}
What you can see here is that the function Environment()
is in fact what is returning the immutable env
object and this will contain all the environment variables we defined as members in that object.
You can also see that we’re attempting to override the field inside env
with a new value. This will compile just fine, but when you run it you’ll find that the value has not been overridden at all:
As we returned an immutable object, we have not been able to override any of the values, so the object is safe!
# before attempted override
{meow bao purr}
# after attempted override
{meow bao purr}
Suppose we want to record at what date we recorded the dispositions of our feline friends for whatever reason; it would be ideal to consider our options. Thus, a new environment variable is born: RECORD_DATE
which we will insert into our .env
thus:
RECORD_DATE=21/08/2022
Now, we’re going to introduce two new functions: requireVar
and requireDate
with the former doing the actual loading of the environment variable:
func requireVar(envVar string) string {
if e := os.Getenv(envVar); e != "" {
return e
}
panic(fmt.Errorf("environment variable %s is required", envVar))
}
func requireDate(envVar string) time.Time {
dateString := requireVar(envVar)
date, err := time.Parse("02/01/2006", dateString)
if err != nil {
panic(err)
}
return date
}
In this example, as the code resides in init.go
and runs at a very early stage of the program, it is safe to panic as we want to fail fast rather than wait before an error is returned. The sooner we panic, the less consequential of handling such erroneous vars does arise. As you can see in the latter function, we retrieve the environment variable which by this point is a date-string, but to convert it to a date we simply need to match such a string with the format Go expects.
With the final modifications, our init.go
init
function should look like so:
type environment struct {
MusingCat string
WailingKatzer string
SilentKitten string
// new variable here:
RecordDate time.Time
}
If you run main.go
, you should now see the env
object now contains the correctly parsed date. This concludes the tutorial for loading environment variables, validated and immutable with minimised i/o load and correctly casted types. Please extend this to your liking.