INI 是一种历史悠久但依然广泛使用的配置文件格式,因其结构简单、易于阅读,常用于小型项目或嵌入式系统中。本文将带你用 Go 语言从零实现一个功能完整的 INI 配置文件解析器,支持以下特性:
- 节(Section)与键值对(Key-Value)
- 节前注释(Leading Comments)与行尾注释(Inline Comments)
- 自动类型推断(整数、浮点数、布尔值、字符串)
- 带转义的双引号字符串(如 "say "hello"")
- 保留原始顺序(节顺序与键顺序)
- 完整的读写 Roundtrip 能力(解析后写回内容一致)
我们将通过 词法分析(Lexer) + 语法解析(Parser) + 序列化(Writer) 的经典三段式结构来构建这个解析器,并附带完整的单元测试与性能基准。
一、数据结构设计
首先定义核心数据结构,用于在内存中表示 INI 文件的内容:
// IniConfig 表示整个 INI 配置
type IniConfig struct {
SectionOrder []string // 节的出现顺序(保留原始顺序)
Sections map[string]*Section // 节名 -> 节内容
}
// Section 表示一个节(如 [server])
type Section struct {
Name string // 节名
KeyOrder []string // 键的出现顺序
Keys map[string]*Key // 键名 -> 键值
LeadingComments []string // 节前的注释(包括空行)
}
// Key 表示一个键值对
type Key struct {
Name string // 键名
Value interface{} // 值(支持多种类型)
Comment string // 行尾注释(如 ; 这是注释)
}
说明:我们只保留"节前注释",不保留"节后注释"或"键后空行",这是为了简化设计,同时满足大多数使用场景。
二、词法分析器(Lexer)
INI 文件的词法单元(Token)类型包括:
const (
ILLEGAL TokenType = iota
EOF
SECTION // [section]
KEY // key
COMMENT // ; 或 #
EQUALS // =
NEWLINE // \n
)
词法分析器 Lexer 逐字符扫描输入,识别出上述 Token。关键逻辑包括:
- 遇到 [ 开始读取节名,直到 ]
- 遇到 ; 或 # 读取整行作为注释
- 遇到字母或 _ 开始读取标识符(键名)
- 跳过空格和制表符,但保留换行符(用于注释分组)
func (l *Lexer) NextToken() Token {
l.skipWhitespace()
switch l.ch {
case 0: return Token{Type: EOF}
case '\n': return Token{Type: NEWLINE, Line: l.line}; l.line++; l.readChar()
case '[': // 解析 [section]
case ';', '#': // 解析注释
case '=': return Token{Type: EQUALS}; l.readChar()
default:
if isLetter(l.ch) || l.ch == '_' {
return Token{Type: KEY, Value: l.readIdentifier()}
}
// 非法字符
}
}
三、语法解析器(Parser)
解析器 ParseINI 驱动词法分析器,构建 IniConfig 对象。
1. 注释处理
使用 pendingComments 缓存当前尚未归属的注释(或空行)。当遇到 [section] 时,将这些注释分配给该节作为 LeadingComments。
var pendingComments []string
for {
tok := lexer.NextToken()
switch tok.Type {
case COMMENT:
pendingComments = append(pendingComments, tok.Value)
case NEWLINE:
pendingComments = append(pendingComments, "")
case SECTION:
currentSection = &Section{
Name: tok.Value,
LeadingComments: filterComments(pendingComments),
}
pendingComments = nil // 清空
}
}
filterComments 会移除开头的空行,避免节前出现多余空白。
2. 键值解析
键后可能有三种情况:
- key = value ; comment
- key ; comment(无等号,值为 true)
- key(无等号无注释,值为 true)
我们通过 splitValueAndComment 分离值与行尾注释:
valStr, trailing := splitValueAndComment(remainder)
value = parseValue(valStr)
comment = trailing
3. 值类型自动推断(parseValue)
parseValue 按优先级尝试解析:
- 整数(8080 → int64)
- 浮点数(3.14 → float64)
- 布尔值(true/false,不区分大小写)
- 带引号字符串(支持 " 和 \ 转义)
- 原始字符串(其他情况)
func parseValue(s string) interface{} {
s = strings.TrimSpace(s)
if s == "" { return "" }
if v, _ := strconv.ParseInt(s, 10, 64); err == nil { return v }
if v, _ := strconv.ParseFloat(s, 64); err == nil { return v }
if strings.ToLower(s) == "true" { return true }
if strings.ToLower(s) == "false" { return false }
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
if unquoted, err := parseQuotedString(s); err == nil {
return unquoted
}
}
return s // fallback
}
转义处理:parseQuotedString 支持 " 和 \,其他字符原样保留。
四、序列化(WriteTo)
将 IniConfig 写回 INI 格式文本,需注意:
- 保留节和键的原始顺序
- 节前注释原样输出
- 布尔值 true 且无注释时,省略 = true(如 tls)
- 字符串含空格、=、#、;、" 时自动加双引号并转义
func formatValue(v interface{}) string {
switch val := v.(type) {
case string:
if val == "" || strings.ContainsAny(val, " \t=\"#;") {
escaped := strings.ReplaceAll(val, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return `"` + escaped + `"`
}
return val
case bool: return strconv.FormatBool(val)
case int64: return strconv.FormatInt(val, 10)
case float64: return strconv.FormatFloat(val, 'g', -1, 64)
default: return fmt.Sprintf("%v", v)
}
}
五、完整示例
[test]
msg = "say \"hello\" world"
path = "C:\\Program Files\\中文"
normal = hello
flag
解析后:
- msg → say "hello" world
- path → C:\Program Files\中文
- normal → hello
- flag → true
写回后内容与原始输入语义一致,实现 Roundtrip 安全。
六、测试与性能
我们编写了全面的单元测试,覆盖:
- 基本键值解析
- 注释处理(节前、行尾)
- 类型推断(整数、浮点、布尔、字符串)
- 转义字符串
- 空白处理
- Get/Set 操作
- Roundtrip 一致性
性能基准(AMD Ryzen 7 5700U):
BenchmarkParse-16 110815 10804 ns/op 6384 B/op 152 allocs/op
BenchmarkGetSet-16 4462018 264 ns/op 79 B/op 3 allocs/op
BenchmarkWrite-16 172732 7585 ns/op 2241 B/op 85 allocs/op
解析一个典型配置文件仅需 10 微秒,性能完全满足日常使用。
七、总结
本文实现的 INI 解析器具备以下优势:
✅ 功能完整:支持注释、转义、类型推断
✅ 语义保真:Roundtrip 安全,写回内容可再次解析
✅ 结构清晰:Lexer + Parser + Writer 三段式设计
✅ 性能优秀:微秒级解析,低内存分配
✅ 测试充分:覆盖边界情况与异常输入
虽然 Go 官方未提供 INI 支持,但通过本文的实现,你可以轻松在项目中集成一个轻量、可靠、可维护的配置方案。
完整源码
ini.go
package main
import (
"fmt"
"io"
"os"
"strconv"
"strings"
"unicode"
)
// ========================
// 数据结构
// ========================
type IniConfig struct {
SectionOrder []string
Sections map[string]*Section
}
type Section struct {
Name string
KeyOrder []string
Keys map[string]*Key
LeadingComments []string // 只保留节前注释
}
type Key struct {
Name string
Value interface{}
Comment string // 行尾注释
}
// ========================
// Lexer
// ========================
type TokenType int
const (
ILLEGAL TokenType = iota
EOF
SECTION
KEY
COMMENT
EQUALS
NEWLINE
)
type Token struct {
Type TokenType
Value string
Line int
}
type Lexer struct {
input string
position int
readPosition int
ch byte
line int
}
func NewLexer(input string) *Lexer {
l := &Lexer{input: input, line: 1}
l.readChar()
return l
}
func (l *Lexer) readChar() {
if l.readPosition >= len(l.input) {
l.ch = 0
} else {
l.ch = l.input[l.readPosition]
}
l.position = l.readPosition
l.readPosition++
}
func (l *Lexer) peekChar() byte {
if l.readPosition >= len(l.input) {
return 0
}
return l.input[l.readPosition]
}
func (l *Lexer) skipWhitespace() {
for l.ch == ' ' || l.ch == '\t' {
l.readChar()
}
}
func (l *Lexer) readIdentifier() string {
start := l.position
for isLetter(l.ch) || isDigit(l.ch) || l.ch == '_' || l.ch == '-' || l.ch == '.' {
l.readChar()
}
return l.input[start:l.position]
}
func (l *Lexer) readLineRemainder() string {
start := l.position
for l.ch != '\n' && l.ch != 0 {
l.readChar()
}
return l.input[start:l.position]
}
func isLetter(ch byte) bool {
return ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z')
}
func isDigit(ch byte) bool {
return '0' <= ch && ch <= '9'
}
func (l *Lexer) NextToken() Token {
var tok Token
l.skipWhitespace()
switch l.ch {
case 0:
tok = Token{Type: EOF, Line: l.line}
case '\n':
tok = Token{Type: NEWLINE, Value: "\n", Line: l.line}
l.line++
l.readChar()
case '[':
l.readChar()
tok.Type = SECTION
start := l.position
for l.ch != ']' && l.ch != 0 && l.ch != '\n' {
l.readChar()
}
tok.Value = strings.TrimSpace(l.input[start:l.position])
if l.ch == ']' {
l.readChar()
}
case ';', '#':
tok.Type = COMMENT
start := l.position
for l.ch != '\n' && l.ch != 0 {
l.readChar()
}
tok.Value = l.input[start:l.position]
l.readChar()
case '=':
tok = Token{Type: EQUALS, Value: "=", Line: l.line}
l.readChar()
default:
if isLetter(l.ch) || l.ch == '_' {
tok.Type = KEY
tok.Value = l.readIdentifier()
} else {
tok = Token{Type: ILLEGAL, Value: string(l.ch), Line: l.line}
l.readChar()
}
}
return tok
}
// ========================
// splitValueAndComment
// ========================
func splitValueAndComment(s string) (value, comment string) {
s = strings.TrimLeftFunc(s, unicode.IsSpace)
inQuotes := false
for i, ch := range s {
if ch == '"' {
inQuotes = !inQuotes
}
if !inQuotes && (ch == ';' || ch == '#') {
value = strings.TrimRightFunc(s[:i], unicode.IsSpace)
comment = strings.TrimLeftFunc(s[i+1:], unicode.IsSpace)
return
}
}
value = s
comment = ""
return
}
// ========================
// 解析带转义的双引号字符串
// ========================
func parseQuotedString(s string) (string, error) {
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
return "", fmt.Errorf("not a quoted string")
}
inner := s[1 : len(s)-1]
var result strings.Builder
for i := 0; i < len(inner); i++ {
c := inner[i]
if c == '\\' && i+1 < len(inner) {
next := inner[i+1]
if next == '"' || next == '\\' {
result.WriteByte(next)
i++ // skip next char
continue
}
}
result.WriteByte(c)
}
return result.String(), nil
}
// ========================
// Parser
// ========================
func ParseINI(input string) (*IniConfig, error) {
lexer := NewLexer(input)
cfg := &IniConfig{
Sections: make(map[string]*Section),
SectionOrder: nil,
}
var currentSection *Section
var pendingComments []string
for {
tok := lexer.NextToken()
switch tok.Type {
case EOF:
return cfg, nil
case COMMENT:
pendingComments = append(pendingComments, tok.Value)
case NEWLINE:
pendingComments = append(pendingComments, "")
case SECTION:
currentSection = &Section{
Name: tok.Value,
Keys: make(map[string]*Key),
KeyOrder: nil,
LeadingComments: filterComments(pendingComments),
}
cfg.Sections[tok.Value] = currentSection
cfg.SectionOrder = append(cfg.SectionOrder, tok.Value)
pendingComments = nil
case KEY:
if currentSection == nil {
return nil, fmt.Errorf("key %q outside any section at line %d", tok.Value, tok.Line)
}
keyName := tok.Value
var value interface{} = true
var comment string
nextTok := lexer.NextToken()
if nextTok.Type == EQUALS {
remainder := lexer.readLineRemainder()
valStr, trailing := splitValueAndComment(remainder)
value = parseValue(valStr)
comment = trailing
} else if nextTok.Type == COMMENT {
comment = nextTok.Value
} else if nextTok.Type == NEWLINE || nextTok.Type == EOF {
// ok
} else {
return nil, fmt.Errorf("unexpected token %q after key at line %d", nextTok.Value, tok.Line)
}
if _, exists := currentSection.Keys[keyName]; !exists {
currentSection.KeyOrder = append(currentSection.KeyOrder, keyName)
}
currentSection.Keys[keyName] = &Key{
Name: keyName,
Value: value,
Comment: comment,
}
case ILLEGAL:
return nil, fmt.Errorf("illegal character %q at line %d", tok.Value, tok.Line)
}
}
}
func filterComments(lines []string) []string {
var result []string
for _, line := range lines {
if line != "" || len(result) > 0 {
result = append(result, line)
}
}
return result
}
// ========================
// parseValue:支持转义引号
// ========================
func parseValue(s string) interface{} {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
return v
}
if v, err := strconv.ParseFloat(s, 64); err == nil {
return v
}
if strings.ToLower(s) == "true" {
return true
}
if strings.ToLower(s) == "false" {
return false
}
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
if unquoted, err := parseQuotedString(s); err == nil {
return unquoted
}
}
return s
}
func (cfg *IniConfig) Get(section, key string) (interface{}, bool) {
if sec, ok := cfg.Sections[section]; ok {
if k, ok := sec.Keys[key]; ok {
return k.Value, true
}
}
return nil, false
}
func (cfg *IniConfig) Set(section, key string, value interface{}) {
sec, exists := cfg.Sections[section]
if !exists {
sec = &Section{
Name: section,
Keys: make(map[string]*Key),
KeyOrder: []string{key},
LeadingComments: nil,
}
cfg.Sections[section] = sec
cfg.SectionOrder = append(cfg.SectionOrder, section)
sec.Keys[key] = &Key{Name: key, Value: value}
return
}
if _, keyExists := sec.Keys[key]; !keyExists {
sec.KeyOrder = append(sec.KeyOrder, key)
}
sec.Keys[key] = &Key{Name: key, Value: value}
}
// ========================
// WriteTo
// ========================
func (cfg *IniConfig) WriteTo(w io.Writer) error {
for i, secName := range cfg.SectionOrder {
sec := cfg.Sections[secName]
for _, c := range sec.LeadingComments {
if c == "" {
fmt.Fprintln(w)
} else {
fmt.Fprintln(w, c)
}
}
fmt.Fprintf(w, "[%s]\n", secName)
for _, keyName := range sec.KeyOrder {
key := sec.Keys[keyName]
if key.Value == true && key.Comment == "" {
fmt.Fprintf(w, "%s\n", key.Name)
} else {
valStr := formatValue(key.Value)
line := fmt.Sprintf("%s = %s", key.Name, valStr)
if key.Comment != "" {
line += " ;" + key.Comment
}
fmt.Fprintln(w, line)
}
}
if i < len(cfg.SectionOrder)-1 {
fmt.Fprintln(w)
}
}
return nil
}
func formatValue(v interface{}) string {
switch val := v.(type) {
case string:
if val == "" || strings.ContainsAny(val, " \t=\"#;") {
// Escape quotes and backslashes
escaped := strings.ReplaceAll(val, "\\", "\\\\")
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
return `"` + escaped + `"`
}
return val
case bool:
return strconv.FormatBool(val)
case int64:
return strconv.FormatInt(val, 10)
case float64:
return strconv.FormatFloat(val, 'g', -1, 64)
default:
return fmt.Sprintf("%v", v)
}
}
// ========================
// main(用于测试)
// ========================
func main() {
input := `[test]
msg = "say \"hello\" world"
path = "C:\\Program Files\\中文"
normal = hello
flag
`
cfg, err := ParseINI(input)
if err != nil {
panic(err)
}
if msg, ok := cfg.Get("test", "msg"); ok {
fmt.Printf("Parsed msg: [%s]\n", msg) // should be: say "hello" world
}
if path, ok := cfg.Get("test", "path"); ok {
fmt.Printf("Parsed path: [%s]\n", path) // should be: say "hello" world
}
fmt.Println("\n--- Roundtrip ---")
cfg.WriteTo(os.Stdout)
}
ini_test.go
// 文件:ini_test.go
package main
import (
"bytes"
"fmt"
"reflect"
"testing"
)
// ========================
// 功能测试
// ========================
func TestParseSimple(t *testing.T) {
input := `[server]
host = localhost
port = 8080
tls
`
cfg, err := ParseINI(input)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if len(cfg.SectionOrder) != 1 || cfg.SectionOrder[0] != "server" {
t.Errorf("Expected section 'server', got %v", cfg.SectionOrder)
}
sec := cfg.Sections["server"]
if sec == nil {
t.Fatal("Section 'server' not found")
}
if host, ok := cfg.Get("server", "host"); !ok || host != "localhost" {
t.Errorf("host = %v, expected 'localhost'", host)
}
if port, ok := cfg.Get("server", "port"); !ok || port != int64(8080) {
t.Errorf("port = %v, expected 8080", port)
}
if tls, ok := cfg.Get("server", "tls"); !ok || tls != true {
t.Errorf("tls = %v, expected true", tls)
}
}
func TestParseWithComments(t *testing.T) {
input := `; Server config
[server]
host = localhost ; inline comment
port = 1111
; Database section
[database]
user = admin
password = "my secret"
timeout = 5.5
`
cfg, err := ParseINI(input)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
serverSec := cfg.Sections["server"]
if serverSec.LeadingComments[0] != "; Server config" {
t.Errorf("Wrong leading comments: %v", serverSec.LeadingComments)
}
if hostKey := serverSec.Keys["host"]; hostKey.Comment != "inline comment" {
t.Errorf("Inline comment mismatch: %q", hostKey.Comment)
}
// [database] 的 leading 应包含 "; Database section"
dbSec := cfg.Sections["database"]
if len(dbSec.LeadingComments) != 1 || dbSec.LeadingComments[0] != "; Database section" {
t.Errorf("Database leading comment: %v", dbSec.LeadingComments)
}
}
func TestRoundtrip(t *testing.T) {
cases := []string{
`[empty]`,
`[test]
key`,
`[test]
key = value`,
`[test]
key = "quoted value"`,
`[numbers]
int = 42
float = 3.14
bool = true`,
`; comment
[section]
key = value ; inline
; trailing`,
}
for i, input := range cases {
cfg, err := ParseINI(input)
if err != nil {
t.Fatalf("Case %d: Parse failed: %v", i, err)
}
var buf bytes.Buffer
err = cfg.WriteTo(&buf)
if err != nil {
t.Fatalf("Case %d: Write failed: %v", i, err)
}
output := buf.String()
// Parse again
cfg2, err := ParseINI(output)
if err != nil {
t.Fatalf("Case %d: Re-parse failed:\nInput:\n%s\nOutput:\n%s\nError: %v", i, input, output, err)
}
// Compare structures (simplified)
if !reflect.DeepEqual(cfg.SectionOrder, cfg2.SectionOrder) {
t.Errorf("Case %d: SectionOrder mismatch", i)
}
for secName, sec1 := range cfg.Sections {
sec2 := cfg2.Sections[secName]
if sec2 == nil {
t.Errorf("Case %d: Section %s missing in roundtrip", i, secName)
continue
}
if !reflect.DeepEqual(sec1.KeyOrder, sec2.KeyOrder) {
t.Errorf("Case %d: KeyOrder mismatch in %s", i, secName)
}
for k, v1 := range sec1.Keys {
v2 := sec2.Keys[k]
if v2 == nil || !reflect.DeepEqual(v1.Value, v2.Value) || v1.Comment != v2.Comment {
t.Errorf("Case %d: Key %s mismatch: %v vs %v", i, k, v1, v2)
}
}
if !reflect.DeepEqual(sec1.LeadingComments, sec2.LeadingComments) {
t.Errorf("Case %d: LeadingComments mismatch in %s", i, secName)
}
}
}
}
func TestGetSet(t *testing.T) {
cfg := &IniConfig{
Sections: make(map[string]*Section),
SectionOrder: nil,
}
cfg.Set("app", "debug", true)
cfg.Set("app", "port", int64(3000))
cfg.Set("db", "host", "localhost")
if v, ok := cfg.Get("app", "debug"); !ok || v != true {
t.Errorf("Get debug failed: %v", v)
}
if v, ok := cfg.Get("app", "port"); !ok || v != int64(3000) {
t.Errorf("Get port failed: %v", v)
}
if v, ok := cfg.Get("db", "host"); !ok || v != "localhost" {
t.Errorf("Get host failed: %v", v)
}
// Overwrite
cfg.Set("app", "port", int64(5000))
if v, ok := cfg.Get("app", "port"); !ok || v != int64(5000) {
t.Errorf("Overwrite failed: %v", v)
}
// Non-existent
if _, ok := cfg.Get("missing", "key"); ok {
t.Error("Get non-existent key should fail")
}
}
func TestQuotedValues(t *testing.T) {
input := `[test]
empty = ""
space = "hello world"
quote = "say \"hi\""
special = "a;b#c"
`
cfg, err := ParseINI(input)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
tests := map[string]string{
"empty": "",
"space": "hello world",
"quote": `say "hi"`,
"special": "a;b#c",
}
for key, expected := range tests {
if v, ok := cfg.Get("test", key); !ok || v != expected {
t.Errorf("Key %s: expected %q, got %q", key, expected, v)
}
}
}
// ========================
// 性能测试
// ========================
const largeINI = `
; Generated config
[server]
host = localhost
port = 8080
tls = true
workers = 16
timeout = 30.5
log_level = info
; more settings
max_conns = 1000
buffer_size = 65536
[database]
host = db.example.com
port = 5432
user = admin
password = "secret123"
ssl = true
pool_size = 20
[cache]
type = redis
addr = 127.0.0.1:6379
ttl = 3600
[feature_flags]
new_ui = true
beta = false
experimental = true
`
func BenchmarkParse(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := ParseINI(largeINI)
if err != nil {
b.Fatalf("Parse failed: %v", err)
}
}
}
func BenchmarkGetSet(b *testing.B) {
cfg, _ := ParseINI(largeINI)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cfg.Get("server", "port")
cfg.Set("server", "dynamic_key_"+fmt.Sprint(i%100), i)
}
}
func BenchmarkWrite(b *testing.B) {
cfg, _ := ParseINI(largeINI)
b.ResetTimer()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
_ = cfg.WriteTo(&buf)
}
}
// ========================
// 辅助函数(用于测试)
// ========================
// 如果你在 ini.go 中没有暴露 Section/Key 结构,可加 getter,但为测试我们假设可访问
// 或者通过 Get + WriteTo 间接验证
func TestEmptyAndWhitespace(t *testing.T) {
input := `
[ spaced ]
key = value
`
cfg, err := ParseINI(input)
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if v, ok := cfg.Get("spaced", "key"); !ok || v != "value" {
t.Errorf("Expected 'value', got %v", v)
}
}
func TestBooleanParsing(t *testing.T) {
input := `[bools]
t1 = true
t2 = True
t3 = TRUE
f1 = false
f2 = False
f3 = FALSE
not_bool = trueish
`
cfg, _ := ParseINI(input)
tests := map[string]interface{}{
"t1": true,
"t2": true,
"t3": true,
"f1": false,
"f2": false,
"f3": false,
"not_bool": "trueish",
}
for k, expected := range tests {
if v, ok := cfg.Get("bools", k); !ok || v != expected {
t.Errorf("Key %s: expected %v (%T), got %v (%T)", k, expected, expected, v, v)
}
}
}
写在最后
是否支持多行字符串? 是否允许重复键(如 PHP 的 key[])? 是否支持节继承(如 [dev:base])? 这些都可以作为后续扩展方向。INI 虽小,但做好并不容易。希望本文能为你提供一个扎实的起点。