Go语言从INI配置文件中读取需要的值

INI 文件格式是一种古老的配置文件格式。一些操作系统、虚幻游戏引擎、GIT 版本管理中都在使用 INI 文件格式。下面用从 GIT 版本管理的配置文件中截取的一部分内容,展示 INI 文件的样式。

[core]
repositoryformatversion = 0

filemode = false
bare = false
logallrefupdates = true
symlinks = false
ignorecase = true
hideDotFiles = dotGitOnly
[remote "origin"]
url = https://github.com/davyxu/cellnet
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master

INI 文件的格式

INI 文件由多行文本组成,整个配置由“[]”拆分为多个“段”(section)。每个段中又以“=”分割为“键”,和“值”。

INI 文件以“;”置于行首视为注释,本行将不会被处理和识别。INI 文件格式如下:

[sectionl]
key1=value1
key2=value2
[section2]

从 INI 文件中取值的函数

熟悉了 INI 文件的格式后,开始准备读取 INI 文件,并从文件中获取需要的数据。完整的示例代码如下所示:
package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

// 根据文件名,段名,键名获取ini的值
func getValue(filename, expectSection, expectKey string) string {
    // 打开文件
    file, err := os.Open(filename)
    // 文件找不到,返回空
    if err != nil {
        return ""
    }
    // 在函数结束时,关闭文件
    defer file.Close()
    // 使用读取器读取文件
    reader := bufio.NewReader(file)
    // 当前读取的段的名字
    var sectionName string
    for {
        // 读取文件的一行
        linestr, err := reader.ReadString('\n')
        if err != nil {
            break
        }
        // 切掉行的左右两边的空白字符
        linestr = strings.TrimSpace(linestr)
        // 忽略空行
        if linestr == "" {
            continue
        }
        // 忽略注释
        if linestr[0] == ';' {
            continue
        }
        // 行首和尾巴分别是方括号的,说明是段标记的起止符
        if linestr[0] == '[' && linestr[len(linestr)-1] == ']' {
            // 将段名取出
            sectionName = linestr[1 : len(linestr)-1]
            // 这个段是希望读取的
        } else if sectionName == expectSection {
            // 切开等号分割的键值对
            pair := strings.Split(linestr, "=")
            // 保证切开只有1个等号分割的简直情况
            if len(pair) == 2 {
                // 去掉键的多余空白字符
                key := strings.TrimSpace(pair[0])
                // 是期望的键
                if key == expectKey {
                    // 返回去掉空白字符的值
                    return strings.TrimSpace(pair[1])
                }
            }
        }
    }
    return ""
}

func main() {
    fmt.Println(getValue("example.ini", "remote \"origin\"", "fetch"))
    fmt.Println(getValue("example.ini", "core", "hideDotFiles"))
}
本例并不是将整个 INI 文件读取保存后再获取需要的字段数据并返回,这里使用 getValue() 函数,每次从指定文件中找到需要的段(Section)及键(Key)对应的值。

getValue() 函数的声明如下:

func getValue(filename, expectSection, expectKey string) string

参数说明如下。
  • filename:INI 文件的文件名。
  • expectSection:期望读取的段。
  • expectKey:期望读取段中的键。

getValue() 函数的实际使用例子参考代码如下:

func main() {
    fmt.Println(getValue("example.ini", "remote \"origin\"", "fetch"))
    fmt.Println(getValue("example.ini", "core", "hideDotFiles"))
}

运行完整代码后输出如下:

+refs/heads/*:refs/remotes/origin/*
dotGitOnly

代码输出中,“+refs/heads/*:refs/remotes/origin/*”表示 INI 文件中 "remote" 和 "origin" 的 "fetch" 键对应的值:dotGitOnly 表示 INI 文件中 "core" 段中的键为 "hideDotFiles" 的值。

注意代码第 2 行中,由于段名中包含双引号,所以使用“\”进行转义。

getValue() 函数的逻辑由 4 部分组成:即读取文件、读取行文本、读取段和读取键值组成。接下来分步骤了解 getValue() 函数的详细处理过程。

读取文件

Go语言的 OS 包中提供了文件打开函数 os.Open()。文件读取完成后需要及时关闭,否则文件会发生占用,系统无法释放缓冲资源。参考下面代码:
// 打开文件
file, err := os.Open(filename)

// 文件找不到,返回空
if err != nil {
    return ""
}

// 在函数结束时,关闭文件
defer file.Close()
代码说明如下:
  • 第 2 行,filename 是由 getValue() 函数参数提供的 INI 的文件名。使用 os.Open() 函数打开文件,如果成功打开,会返回文件句柄,同时返回打开文件时可能发生的错误:err。
  • 第 5 行,如果文件打开错误,err 将不为 nil,此时 getValue() 函数返回一个空的字符串,表示无法从给定的 INI 文件中获取到需要的值。
  • 第 10 行,使用 defer 延迟执行函数,defer 并不会在这一行执行,而是延迟在任何一个 getValue() 函数的返回点,也就是函数退出的地方执行。调用 file.Close() 函数后,打开的文件就会被关闭并释放系统资源。

INI 文件已经打开了,接下来就可以开始读取 INI 的数据了。

读取行文本

INI 文件的格式是由多行文本组成,因此需要构造一个循环,不断地读取 INI 文件的所有行。Go语言总是将文件以二进制格式打开,通过不同的读取方式对二进制文件进行操作。Go语言对二进制读取有专门的代码抽象,bufio 包即可以方便地以比较常见的方式读取二进制文件。
// 使用读取器读取文件
reader := bufio.NewReader(file)

// 当前读取的段的名字
var sectionName string

for {

    // 读取文件的一行
    linestr, err := reader.ReadString('\n')
    if err != nil {
        break
    }

    // 切掉行的左右两边的空白字符
    linestr = strings.TrimSpace(linestr)

    // 忽略空行
    if linestr == "" {
        continue
    }

    // 忽略注释
    if linestr[0] == ';' {
        continue
    }

    //读取段和键值的代码
    //...
}
代码说明如下:
  • 第 2 行,使用 bufio 包提供的 NewReader() 函数,传入文件并构造一个读取器。
  • 第 5 行,提前声明段的名字字符串,方便后面的段和键值读取。
  • 第 7 行,构建一个读取循环,不断地读取文件中的每一行。
  • 第 10 行,使用 reader.ReadString() 从文件中读取字符串,直到碰到“\n”,也就是行结束。这个函数返回读取到的行字符串(包括“\n”)和可能的读取错误 err,例如文件读取完毕。
  • 第 16 行,每一行的文本可能会在标识符两边混杂有空格、回车符、换行符等不可见的空白字符,使用 strings.TrimSpace() 可以去掉这些空白字符。
  • 第 19 行,可能存在空行的情况,继续读取下一行,忽略空行。
  • 第 24 行,当行首的字符为“;”分号时,表示这一行是注释行,忽略一整行的读取。

读取 INI 文本文件时,需要注意各种异常情况。文本中的空白符就是经常容易忽略的部分,空白符在调试时完全不可见,需要打印出字符的 ASCII 码才能辨别。

抛开各种异常情况拿到了每行的行文本 linestr 后,就可以方便地读取 INI 文件的段和键值了。

读取段

行字符串 linestr 已经去除了空白字符串,段的起止符又以“[”开头, 以“]”结尾,因此可以直接判断行首和行尾的字符串匹配段的起止符匹配时读取的是段,如下图所示。

INI 文件的段名解析
图:INI 文件的段名解析

此时,段只是一个标识,而无任何内容,因此需要将段的名字取出保存在 sectionName(己在之前的代码中定义)中,待读取段后面的键值对时使用。
// 行首和尾巴分别是方括号的,说明是段标记的起止符
if linestr[0] == '[' && linestr[len(linestr)-1] == ']' {

    // 将段名取出
    sectionName = linestr[1 : len(linestr)-1]

    // 这个段是希望读取的
}
代码说明如下:
  • 第 2 行,linestr[0] 表示行首的字符,len(linestr)-1 取出字符串的最后一个字符索引随后取出行尾的字符。根据两个字符串是否匹配方括号,断定当前行是否为段。
  • 第 5 行,linestr 两边的“[”和“]”去掉,取出中间的段名保存在 sectionName 中,留着后面的代码用。

读取键值

这里代码紧接着前面的代码。当前行不是段时(不以“[”开头),那么行内容一定是键值对。别忘记此时 getValue() 的参数对段有匹配要求。找到能匹配段的键值对后,开始对键值对进行解析,参考下面的代码:
else if sectionName == expectSection {

    // 切开等号分割的键值对
    pair := strings.Split(linestr, "=")

    // 保证切开只有1个等号分割的简直情况
    if len(pair) == 2 {

        // 去掉键的多余空白字符
        key := strings.TrimSpace(pair[0])

        // 是期望的键
        if key == expectKey {

            // 返回去掉空白字符的值
            return strings.TrimSpace(pair[1])
        }
    }

}
代码说明如下:
  • 第 1 行,当前的段匹配期望的段时,进行后面的解析。
  • 第 4 行,对行内容(linestr)通过 strings.Split() 函数进行切割,INI 的键值对使用“=”分割,分割后,strings.Split() 函数会返回字符串切片,类型为 []string。这里只考虑一个“=”的情况,因此被分割后,strings.Split() 函数返回的字符串切片有 2 个元素。
  • 第 7 行,只考虑切割出 2 个元素的情况。其他情况会被忽略, 键值如没有“=”、行中多余一个“=”等情况。
  • 第 10 行,pair[O] 表示“=”左边的键。使用 strings.TrimSpace() 函数去掉空白符,如下图所示。
  • 第 13 行,键值对切割出后,还需要判断键是否为期望的键。
  • 第 16 行,匹配期望的键时,将 pair[1] 中保存的键对应的值经过去掉空白字符处理后作为函数返回值返回。

lNI 的键值解析
图:lNI 的键值解析