2

Fairly new to Go, essentially in the actual code I'm writing I plan to read from a file which will contain environment variables, i.e. API_KEY=XYZ. Means I can keep them out of Version control. The below solution 'works' but I feel like there is probably a better way of doing it.

The end goal is to be able to access the elements from the file like so m["API_KEY"] and that would print XYZ. This may even already exist and I'm re-inventing the wheel, I saw Go has environment variables but it didn't seem to be what I was after specifically.

So any help is appreciated.

Playground

Code:

package main

import (
    "fmt"
    "strings"
)

var m = make(map[string]string)

func main() {

    text := `Var1=Value1
    Var2=Value2
    Var3=Value3`

    arr := strings.Split(text, "\n")

    for _, value := range arr {
        tmp := strings.Split(value, "=")
        m[strings.TrimSpace(tmp[0])] = strings.TrimSpace(tmp[1])
    }

    fmt.Println(m)

}
icza
  • 389,944
  • 63
  • 907
  • 827
Mikey
  • 2,606
  • 1
  • 12
  • 20
  • If it does the job I would not worry about it. Go is not about elegant code, but about working code... – RickyA Oct 04 '16 at 08:09
  • 1
    That said, I would put a test `if len(tmp) >= 2` before accessing it (`tmp[0]`) because that may fail. – RickyA Oct 04 '16 at 08:13
  • @RickyA Very good point, I actually haven't really put any error checking in there. Will add it into what I'm working on, thanks. – Mikey Oct 04 '16 at 08:15

2 Answers2

4

First, I would recommend to read this related question: How to handle configuration in Go

Next, I would really consider storing your configuration in another format. Because what you propose isn't a standard. It's close to Java's property file format (.properties), but even property files may contain Unicode sequences and thus your code is not a valid .properties format parser as it doesn't handle Unicode sequences at all.

Instead I would recommend to use JSON, so you can easily parse it with Go or with any other language, and there are many tools to edit JSON texts, and still it is human-friendly.

Going with the JSON format, decoding it into a map is just one function call: json.Unmarshal(). It could look like this:

text := `{"Var1":"Value1", "Var2":"Value2", "Var3":"Value3"}`

var m map[string]string
if err := json.Unmarshal([]byte(text), &m); err != nil {
    fmt.Println("Invalid config file:", err)
    return
}

fmt.Println(m)

Output (try it on the Go Playground):

map[Var1:Value1 Var2:Value2 Var3:Value3]

The json package will handle formatting and escaping for you, so you don't have to worry about any of those. It will also detect and report errors for you. Also JSON is more flexible, your config may contain numbers, texts, arrays, etc. All those come for "free" just because you chose the JSON format.

Another popular format for configuration is YAML, but the Go standard library does not include a YAML parser. See Go implementation github.com/go-yaml/yaml.

If you don't want to change your format, then I would just use the code you posted, because it does exactly what you want it to do: process input line-by-line, and parse a name = value pair from each line. And it does it in a clear and obvious way. Using a CSV or any other reader for this purpose is bad because they hide what's under the hood (they intentionally and rightfully hide format specific details and transformations). A CSV reader is a CSV reader first; even if you change the tabulator / comma symbol: it will interpret certain escape sequences and might give you different data than what you see in a plain text editor. This is an unintended behavior from your point of view, but hey, your input is not in CSV format and yet you asked a reader to interpret it as CSV!

One improvement I would add to your solution is the use of bufio.Scanner. It can be used to read an input line-by-line, and it handles different styles of newline sequences. It could look like this:

text := `Var1=Value1
Var2=Value2
Var3=Value3`

scanner := bufio.NewScanner(strings.NewReader(text))

m := map[string]string{}
for scanner.Scan() {
    parts := strings.Split(scanner.Text(), "=")
    if len(parts) == 2 {
        m[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
    }
}
if err := scanner.Err(); err != nil {
    fmt.Println("Error encountered:", err)
}

fmt.Println(m)

Output is the same. Try it on the Go Playground.

Using bufio.Scanner has another advantage: bufio.NewScanner() accepts an io.Reader, the general interface for "all things being a source of bytes". This means if your config is stored in a file, you don't even have to read all the config into the memory, you can just open the file e.g. with os.Open() which returns a value of *os.File which also implements io.Reader, so you may directly pass the *os.File value to bufio.NewScanner() (and so the bufio.Scanner will read from the file and not from an in-memory buffer like in the example above).

Community
  • 1
  • 1
icza
  • 389,944
  • 63
  • 907
  • 827
  • 1
    Can't really argue with that, brilliant answer. Primarily the only reason I chose the plaintext format was due it being how I've seen it handled in other languages. Will give the other question you mentioned a read after I've digested your answer fully. Thanks though :D – Mikey Oct 04 '16 at 10:44
  • @Pigeon Made a couple of more edits to make it even more useful / informative. – icza Oct 04 '16 at 18:09
3

1- You may read all with just one function call r.ReadAll() using csv.NewReader from encoding/csv with:

r.Comma = '='
r.TrimLeadingSpace = true

And result is [][]string, and input order is preserved, Try it on The Go Playground:

package main

import (
    "encoding/csv"
    "fmt"
    "strings"
)

func main() {
    text := `Var1=Value1
    Var2=Value2
    Var3=Value3`

    r := csv.NewReader(strings.NewReader(text))
    r.Comma = '='
    r.TrimLeadingSpace = true

    all, err := r.ReadAll()
    if err != nil {
        panic(err)
    }
    fmt.Println(all)
}

output:

[[Var1 Value1] [Var2 Value2] [Var3 Value3]]

2- You may fine-tune csv.ReadAll() to convert the output to the map, but the order is not preserved, try it on The Go Playground:

package main

import (
    "encoding/csv"
    "fmt"
    "io"
    "strings"
)

func main() {
    text := `Var1=Value1
    Var2=Value2
    Var3=Value3`
    r := csv.NewReader(strings.NewReader(text))
    r.Comma = '='
    r.TrimLeadingSpace = true
    all, err := ReadAll(r)
    if err != nil {
        panic(err)
    }
    fmt.Println(all)
}

func ReadAll(r *csv.Reader) (map[string]string, error) {
    m := make(map[string]string)
    for {
        tmp, err := r.Read()
        if err == io.EOF {
            return m, nil
        }
        if err != nil {
            return nil, err
        }
        m[tmp[0]] = tmp[1]
    }
}

output:

map[Var2:Value2 Var3:Value3 Var1:Value1]
  • That's an interesting way to go about it, if I wanted to also trim any whitespace with `strings.TrimSpace()` would it be a case of looping through elements and updating them, or is there a way to apply it to the csv reader? Just so I can avoid the `[ Var2 Value2]` – Mikey Oct 04 '16 at 08:28
  • Looks interesting, will see if anyone else has any suggestions. If not I'll accept this answer later. – Mikey Oct 04 '16 at 09:08