← 中文资源

简体中文版本

.env 文件解析器

解析点环境文件,支持 export 前缀、单双引号、行尾注释与键名校验,返回普通映射。

阅读英文原文

功能说明

本地开发和容器编排常用 .env 文件管理配置,但格式比 JSON 宽松,手写解析容易漏掉引号、转义和注释。这个解析器逐行处理:忽略空行与整行注释,去掉可选的 export 前缀,按第一个等号切分键值,再按值的写法解码。

单引号内的内容原样保留,适合包含空格和特殊符号的口令;双引号使用 Go 的转义规则,支持换行和引号;未加引号的值会去掉行尾以空格加井号开头的注释。键名必须符合常见的环境变量命名规范,非法键直接报错并带行号。

关键代码

parseEnvValue 先检查首尾是否成对的单引号或双引号;双引号分支调用 strconv.Unquote,能正确处理反斜杠转义,引号不配对时返回错误。无引号分支只把空格加井号视为注释起点,因此口令里的井号不会被误删。ParseEnv 用 strings.Cut 在第一个等号处切分,值里可以继续包含等号,例如连接串参数。返回的映射覆盖重复键的旧值。

使用注意

该解析器不做变量插值,形如美元符号引用其他变量的写法会原样保留。不支持跨行值,长证书建议挂载文件而不是塞进环境变量。解析成功不代表配置可用,数值和地址仍需业务校验。环境变量会出现在进程信息中,敏感值应使用密钥管理服务。读取真实文件时要限制权限并避免把内容写进日志。

验证方式

示例文档解析后包含五个键:接口地址、应用名、空值、带空格的口令和端口。应用名的双引号内包含转义引号,输出应保留为 Demo 加引号加 API;口令原样输出,端口值只保留 8080,行尾注释被去除。非法的 1BAD 键会返回错误,提示打印为 true。

代码示例

package main

import (
	"fmt"
	"regexp"
	"sort"
	"strconv"
	"strings"
)

var envKeyPattern = regexp.MustCompile("^[A-Za-z_][A-Za-z0-9_]*$")

func parseEnvValue(raw string) (string, error) {
	if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
		return raw[1 : len(raw)-1], nil
	}
	if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' {
		unquoted, err := strconv.Unquote(raw)
		if err != nil {
			return "", fmt.Errorf("bad quoted value: %w", err)
		}
		return unquoted, nil
	}
	if index := strings.Index(raw, " #"); index >= 0 {
		raw = strings.TrimSpace(raw[:index])
	}
	return raw, nil
}

func ParseEnv(data []byte) (map[string]string, error) {
	values := make(map[string]string)
	for number, raw := range strings.Split(string(data), "\n") {
		line := strings.TrimSpace(strings.TrimSuffix(raw, "\r"))
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
		key, value, ok := strings.Cut(line, "=")
		if !ok {
			return nil, fmt.Errorf("line %d: missing '='", number+1)
		}
		key = strings.TrimSpace(key)
		if !envKeyPattern.MatchString(key) {
			return nil, fmt.Errorf("line %d: invalid key %q", number+1, key)
		}
		parsed, err := parseEnvValue(strings.TrimSpace(value))
		if err != nil {
			return nil, fmt.Errorf("line %d: %w", number+1, err)
		}
		values[key] = parsed
	}
	return values, nil
}

func main() {
	document := []byte("# service settings\n" +
		"export API_URL=https://api.example.test\n" +
		"export APP_NAME=\"Demo \\\"API\\\"\"\n" +
		"EMPTY=\n" +
		"DB_PASSWORD='p@ss word'\n" +
		"PORT=8080 # public port\n")
	values, err := ParseEnv(document)
	if err != nil {
		panic(err)
	}
	keys := make([]string, 0, len(values))
	for key := range values {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	for _, key := range keys {
		fmt.Printf("%s=%q\n", key, values[key])
	}

	_, err = ParseEnv([]byte("1BAD=x"))
	fmt.Println("invalid key rejected:", err != nil)
}