☰
Shell脚本解析INI配置文件:从格式差异到安全实践
2026/10/23 3:46:57 网站建设 项目流程

1. INI格式没有标准,但坑各有各的埋法

在Shell脚本里读取配置文件,这事我前前后后折腾了很多年。最早写部署脚本时,配置项就那么三五个,直接source一个env.sh就完事;后来脚本越写越复杂,参数一多,这就乱套了——今天这个脚本要读数据库地址,明天那个工具要拿超时时间,每次改动还得小心翼翼生怕把别的项搞坏。直到接手一个稍大点的项目,配置项攒了大几十个,我才真正开始认真考虑:到底该怎么在Shell里把INI文件解析得干净、稳当、能长期维护。

先说个反直觉的事实:INI文件根本没有严格标准。它只是一套"约定俗成",而各种软件对这套约定的理解还不太一样。Windows经典的INI格式是节 + 键 + 值,节名用方括号包裹,键和值用等号连接,注释用分号开头;到了Linux这边,很多程序默认把井号当注释,有的还支持行尾注释;systemd的配置文件长得很像INI,但键值之间允许大量空白,值还可以带引号,甚至同一行里出现多个键值对。这些五花八门的"方言"决定了,你要是直接写个grep '=' config.ini就想通用地解析,一年级数学题能算,放到真实项目里就是连环翻车。

为什么非要较真这个格式?因为在Shell生态里,配置文件是最容易被忽视但又最容易出事故的一环。配置项一旦带了空格,比如path = /opt/my app/config,朴素的cut -d= -f2会留下多余的空格,变量值就带上了"看不见的脏数据";配置项值里如果出现井号,比如密码是abc#123,简单的sed去注释逻辑会把密码腰斩。这些不是理论问题,是每个写脚本的人都会踩的坑。

所以这篇东西,我不打算只给一个"能跑就行"的玩具代码。我会从格式分析讲到工具取舍,再给出一套我自己在项目里用了很久、反复修过边界的解析方案,最后把几种常见翻车现场和应对方式都摊开来讲。适合正在写部署脚本、运维工具、CI流程,被配置文件各种怪脾气折磨过的朋友参考。

1.1 你以为的INI,和你实际遇到的INI

先把INI文件最常见的几种写法摆出来,看看到底有哪些差异。下面这段是一个比较典型的配置文件,集中了很多真实场景里会出现的特征:

# 全局配置,没有节 debug = false ; 分号也可以是注释 app_name = my-tool [database] host = 127.0.0.1 port=5432 user = postgres passwd = abc#123 # 这里的井号是值的一部分 timeout = 30 [path] data_dir = /opt/my app/data cache_dir = /var/cache/my-tool/

注意几个细节:第一,debug = false里等号两边有空格,你说键名到底是debug还是debug?如果解析器没做去空白处理,后面读debug就一定是空值。第二,port=5432这种写法在真实文件里很多,解析逻辑必须兼容"有没有空格"两种情况。第三,passwd的值里带井号,而同一行末尾又有一个井号注释,这时候"去掉注释"和"保留密码"就冲突了,怎么判定?第四,data_dir的值中间有空格,整行用IFS切成字段肯定不行,必须掰开等号取右侧。

还有一个隐藏点:文件开头如果有节(比如[global]),和完全没有节、只有键值对的文件,处理起来是两个路径。很多脚本写死了"遇到[开头就是节",一旦拿到一个没有节的INI就直接返回空,这种脆弱性相当常见。

1.2 常见的INI"方言"

我按实际使用频率,把常见的INI写法差异整理成一个表。你写解析器的时候,每一条都是需要决策的点。

特性常见写法A常见写法B解析影响
注释符号#;都该支持,但值内#不能一刀切
行尾注释允许不允许判断"注释起点"很考验逻辑
键值分隔符=:有的配置用冒号,比如部分老系统
值两侧引号允许"或'不支持引号应该被剥掉还是保留?
缩进不缩进允许任意缩进键名前后的空格必须trim
键名含特殊字符字母数字下划线含.和-影响Shell变量名合法性
重复键后者覆盖前者为准不同解析器行为不同
空行直接忽略可能影响状态很少见,但需容忍

从这个表就能看出来,写一个解析函数,本质上是在跟"各种软件各自的脾气"打交道。没有哪个方案能100%满足所有软件生成的INI,所以要做的是:默认支持最主流的写法,然后把边界情况收敛成可控、可解释的规则。

1.3 写解析器前,先定下几条"自己的规则"

因为INI没有标准,我的建议是先给自己定一套规则,解析器只认这套规则,其余一律视为格式错误并给出警告,而不是静默猜。我后来长期使用的规则是:

  • 键值分隔符优先认=,也兼容:,取第一个出现的分隔符为准;
  • 全行注释只认行首(忽略前导空白)的#或;;
  • 行尾注释不默认开启,因为要处理"值内含#"的冲突,我把这个设计成可控选项;
  • 值两侧的引号,如果配对存在就剥掉,否则原样保留;
  • 节名如果含空格或点,读取时按字面量处理,但转成Shell变量名时要做清洗。

这四条规则保证了大多数情况下的可预期性。后面我写的解析函数就是照着这套设计来的,你可以直接改规则,但最重要的是:规则要先定下来,再谈实现,顺序不能反。

2. 先别急着造轮子:现成方案和自研脚本的取舍

很多人拿到"解析INI"这个需求,第一反应是写个两行的sed或awk直接上。这事我干过,也被坑过不少次。到底要不要自己写解析器?我的结论是:得看你的配置有多"正经"。如果只是三五个键值对、没有节、没有注释、值里不带特殊符号,那确实没必要写一个几十行的函数;但一旦配置项超过十个、有节结构、有人在里面写路径和密码,老老实实写一个通用解析器反而是省钱省力的做法。

2.1 一条命令走天下的错觉

试试下面这几种常见做法,你马上能感觉到问题出在哪:

# 用grep和cut实现,简单但脆 grep -E '^\s*[^#;]' app.ini | cut -d= -f2 # 用sed取某个键的值,写法很经典 sed -n 's/^timeout *= *\(.*\)$/\1/p' app.ini # 用awk按section提取 awk -F= '/^\[/{gsub(/[\[\]]/, "", $1); section=$1} /=/{print section, $1, $2}' app.ini

这几个问题分别在哪?cut -d= -f2在值本身包含等号时直接裂开;sed没法处理节,也不会剥掉行尾注释,遇到timeout = 30 # 秒就会取到带后缀的脏值;awk方案对空值和带引号的值也扛不住。更麻烦的是,你没法通过管道把这几个命令拼成可读的"函数复用逻辑"——今天改一处,明天另一处的脚本就要跟着改。

这还不提Windows下常见的CRLF换行。你费劲把解析命令写得挺完整,一执行发现键值后面全带\r,调试半天还以为自己正则写错了。这种浪费生命的事,我干过太多次。

2.2 有没有现成的专业工具?

Linux下有几个工具沾点边,比如crudini、systemd-analyze、grep配合一堆参数等等,但我在实际项目里用得并不多,原因有几个:

工具/命令能做什么实际痛点
crudini专门读写INI,还能调systemd风格不是所有系统默认安装,内网环境装包麻烦
systemd-analyze查看systemd配置只能解析特定文件,不能通用
python3+configparser解析能力强依赖python环境,有些精简系统没有
awk单脚本可定制复杂逻辑写出来像个 "一次性作品"

在项目里我总结出的规律是:如果目标机器完全可控、可以保证装了crudini或python3,那确实没必要重复造轮子;但如果你要写的是要分发到不同环境里的Shell脚本,指望所有机器都有这些依赖,不如写一个纯Shell函数,零外部依赖,拿来即用。这也是我后来坚持用bash实现解析器的核心原因。

2.3 什么时候必须自研,什么时候没必要

给个直观的判断方法:你打算怎么用这些配置?

如果配置只在一个脚本内部用,且格式完全由你自己掌控——比如你写了个setup.conf,从头到尾都是你生成和维护的——那source一下,或者sed抓一行就够。但一旦有下面任何一个信号,建议直接上通用解析器:

  • 配置文件来自别的模块、别的团队,你没有改格式的控制权;
  • 配置项数量多,且需要分组管理(就是有节);
  • 脚本要重复读取同一份配置,甚至要多次加载不同模块的配置;
  • 配置文件可能被人手工编辑,而编辑者不一定守规矩。

"手工编辑"这一点特别关键。程序生成的配置是听话的,人手改过的配置是不可预测的。有人会顺手在行尾加个空格,ctrl+v粘进来一段带制表符的文本,甚至用Windows记事本编辑导致换行符变成CRLF。你写的解析器如果没考虑这些,最终消耗的不是编写时间,是日后的排查时间,而且那种问题通常看起来怪得离谱。

3. 从零实现一个能实战的parse_ini函数

好,方案定了:自研一个纯Shell的解析函数。下面是我在真实项目里维护了很久的一个版本,代码不是最简的,但把各种边角情况都磨过一遍。你直接抄去用,大概率能少走很多弯路。

3.1 设计目标:一个函数解决80%场景

先说说设计目标,我在动手前想得很清楚:

  • 输入:一个INI文件路径、可选的节名、可选的键名;
  • 输出:要么打印值,要么把解析结果导出成Shell变量;
  • 行为:兼容带节和不带节、带空格的等号、注释、空值、引号、值内含井号;
  • 安全性:不会把不可信的配置文件内容直接注入当前Shell进程。

我最终写的函数分两层:内层parse_ini把整个文件解析成"节 + 键 + 值"的三元组,打印到stdout;外层再封装成"取某个节下某个键的值"或"导出所有变量"两种常用操作。这样做的好处是,内层逻辑单一,便于测试;外层接口简单,脚本里用着顺手。

3.2 完整实现与逐段解读

直接看代码。下面这个函数是用bash写的,对sh的兼容性一般,但胜在逻辑清晰。如果你要跑在纯sh环境,需要把数组部分改成拼字符串。

# 用法: # parse_ini <file> [section] [key] # 行为: # 1. 不带 section 和 key:打印 "section.key = value" 所有条目 # 2. 带 section 不带 key:打印该节所有 "key = value" # 3. 带 section 和 key:只打印该键的值 # 4. 没有节的文件:section 用 "global" 代替 function parse_ini() { local file="$1" local want_section="$2" local want_key="$3" local section="global" local line local key local value local cleaned if [[ ! -f "$file" ]]; then echo "[parse_ini] file not found: $file" >&2 return 1 fi # 先去掉Windows的CRLF,避免\r粘在值后面 # 再按行读取,逐行处理 while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" # 去掉前导空白(tab和空格) cleaned="${line#"${line%%[![:space:]]*}"}" # 跳过空行和注释行 if [[ -z "$cleaned" || "$cleaned" == \#* || "$cleaned" == \;* ]]; then continue fi # 是节 if [[ "$cleaned" == \[*\] ]]; then section="${cleaned#[}" section="${section%]}" # 节名里的空格、点号统一保留,但调用者要用时再做清洗 continue fi # 是键值行,先找第一个=或:作为分隔符 if [[ "$cleaned" == *=* ]]; then key="${cleaned%%=*}" value="${cleaned#*=}" elif [[ "$cleaned" == *:* ]]; then key="${cleaned%%:*}" value="${cleaned#*:}" else echo "[parse_ini] invalid line: $line" >&2 continue fi # 去掉键值两侧空白 key="$(trim "$key")" value="$(trim "$value")" # 如果值带配对引号,剥掉;单行内不处理转移引号 if [[ "$value" == \"*\" && "${#value}" -ge 2 ]]; then value="${value:1:${#value}-2}" elif [[ "$value" == \'*\' && "${#value}" -ge 2 ]]; then value="${value:1:${#value}-2}" fi # 输出结果 if [[ -n "$want_section" && "$section" != "$want_section" ]]; then continue fi if [[ -n "$want_key" && "$key" != "$want_key" ]]; then continue fi if [[ -n "$want_key" ]]; then printf '%s\n' "$value" else printf '%s.%s = %s\n' "$section" "$key" "$value" fi done < "$file" } # trim函数:去掉字符串首尾空白 function trim() { local s="$1" s="${s#"${s%%[![:space:]]*}"}" s="${s%"${s##*[![:space:]]}"}" printf '%s\n' "$s" }

这个函数里几个细节,值得展开讲讲。

CRLF处理那行:line="${line%$'\r'}"。很多人会忽略这个,但Windows记事本编辑的配置文件常常把\r留在行尾,要是不去掉,后面key="$(trim "$key")"虽然能掐掉空白,但\r不一定被[:space:]识别为空白,结果就是"看起来一样,字符串就是比对不上"。这一行属于"不加后悔,加了无感知"的保险。

前导空白清理:cleaned="${line#"${line%%[![:space:]]*}"}"。这段有点绕,我解释下:${line%%[![:space:]]*}取的是从第一个非空白字符开始的所有内容,前面那段就是"前导空白",再用${line#...}把它剥掉。这样TAB开头的配置行也能正常识别。

键值拆分:用${cleaned%%=*}取等号左侧,用${cleaned#*=}取等号右侧。注意我用的是%%和#,而不是%和##,因为%%贪婪匹配是从右往左删最短的右侧、保留最长的左侧,这样a=b=c会被拆成key=a、value=b=c,正好满足"第一个等号作为分隔符"的预期。这就是前面说的"值里带等号也不会裂开"的实现基础。

引号剥除:只处理"整串值被一对引号包裹"的情况。原因很简单:INI规范里很少会有转义引号的需求,如果值本身是"abc"def"这种,那说明文件本身写得就很不规范,解析器没必要为此复杂化。保留原样反而更安全。

这个函数用起来很简单。给个例子:

# 读取[ database ]节下host项的值 host="$(parse_ini app.ini database host)" echo "db host is: $host" # 输出所有配置 parse_ini app.ini

3.3 为什么不直接source这个INI文件?

我知道有人会问:直接source app.ini不就行了?节用[database]会语法报错,这是最大的问题。但就算把所有方括号行都想办法滤掉,直接source还有一个致命隐患:配置文件里如果有一行是PATH="$(rm -rf /)",source执行时就会真的执行命令替换,或者更隐蔽的,__evil() { ... }; __evil这类组合。生产环境里配置文件的来源没那么可控,把不可信文本直接丢进当前Shell执行,本身就是一种非常危险的做法。

所以解析器必须输出"纯数据"。数据怎么用,由脚本自己决定,这也是我坚持用printf而不是eval/source来交付结果的核心理由。守住这一条,你写的解析器才谈得上安全性。

4. 借力awk处理复杂场景:节合并、转义与系统配置兼容

parse_ini函数能解决大部分日常问题,但还有两类场景让我折腾了很久:一是需要在一个文件里处理重复键、行内注释等更刁钻的写法;二是要给systemd风格或Windows风格的配置文件做兼容。这时候纯bash的字符串处理开始显得笨重,我会换awk上阵,逻辑算起来更顺手。

4.1 awk版解析器的实现思路

awk做文本解析的优势在于它天生按行处理,字段、正则、数组一应俱全。我写过一个awk版,功能和前面bash版基本对齐,但对"行内注释"的支持做得更细。先看代码:

function parse_ini(file, want_section, want_key, _line, _sec, _k, _v, _q) { _sec = "global" while ((getline _line < file) > 0) { # 去掉CR sub(/\r$/, "", _line) # 跳过空行和全行注释 if (_line ~ /^[[:space:]]*([#;]|$)/) continue # 节 if (_line ~ /^[[:space:]]*\[.*\]/) { _sec = _line gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", _sec) continue } # 分离键值 if (_line ~ /^[[:space:]]*[^=:]+[=:]/) { _k = _line sub(/^[[:space:]]+/, "", _k) sub(/[=:].*$/, "", _k) sub(/[[:space:]]+$/, "", _k) _v = _line sub(/^[^=:]*[=:][[:space:]]*/, "", _v) } else { continue } # 处理行尾注释(只去掉值后面独立出现的#或;) # 规则:如果#或;前面有空白,才视为注释 if (_v ~ /[[:space:]](#|;)/) { sub(/[[:space:]](#|;).*$/, "", _v) } # 去掉包裹引号 if (_v ~ /^".*"$/ || _v ~ /^'.*'$/) { _v = substr(_v, 2, length(_v) - 2) } if (want_section != "" && _sec != want_section) continue if (want_key != "" && _k != want_key) continue if (want_key != "") print _v else printf "%s.%s = %s\n", _sec, _k, _v } close(file) }

这个版本最大的不同在行尾注释处理上。bash版我默认不处理行尾注释,是因为没法可靠分辨"值里的井号"和"注释井号";awk版我加了一条实用的启发规则:如果#或;前面是空白字符,才认定它是注释起点。这样passwd = abc#123会保留abc#123,而timeout = 30 # 秒会被去掉# 秒,正好符合大多数人的书写习惯。当然代价是,如果有人写key = value#notcomment还没加空格,解析结果就是value#notcomment,这属于"规则之外的容忍",我觉得可以接受。

4.2 重复键:谁覆盖谁?

INI文件里同一个节下写了相同键名怎么办?不同工具处理方式不一样。我见过三种主流行为:

策略代表场景后果
后者覆盖前者多数程序解析器后写的位置生效
前者优先部分老系统先定义的不动
报重复错误严格校验工具直接失败

我实际观察下来,"后者覆盖前者"最符合"手工改配置"的直觉——你往往在文件底部追加改动,期望新值生效。所以我自己的解析器采用这个策略:循环读取时,后读到的键值对覆盖之前的。这样实现的代价很小,但语义明确,不会出现"改了半天不生效"。

4.3 systemd风格和Windows行尾的兼容

systemd配置里有几个反常规的写法,让我不得不对解析器做额外调整:

  • 键和值之间可以有大量空格,比如TimeoutStartSec = 5min,这我们前面的trim已经处理了;
  • 值可以是空字符串,比如ExecStart=,这要求解析器允许key =这种情况且value为空;
  • 值内部可能包含引号用来表示带空格的路径,我们的"剥掉包裹引号"规则正好能覆盖;
  • 行尾可以用\续行,这一点真正严肃处理起来很复杂,我通常建议:如果配置里需要续行,就让上游导出成JSON或YAML,别硬啃INI。

Windows行尾的CRLF问题,我在两个版本里都做了显式处理。不要觉得多余,我在某次线上排查里,发现一整个配置文件的键名尾部都挂着\r,导致key和strict_mode两个名字看起来相同但匹配不到,浪费了两小时。加一行处理,收益极高。

5. 把配置安全接进真实脚本:校验、翻车现场与测试

写了解析器,最后一步是把读出来的配置用起来。这一步看着简单,其实最容易翻车。下面把几个真实项目的血泪教训整理一下。

5.1 键名合法性与"source注入"风险

解析器把key=value配对输出以后,脚本侧通常想把变量直接变成$key。但INI文件的键名不一定合法:比如database.host、server-port、run mode,这些点、横杠、空格都没法直接当Shell变量名。我的做法是做一个export_config函数,把键名清洗成合法Shell变量名:

function export_config() { local file="$1" local section="$2" local key local val local var_name while IFS= read -r kv; do key="${kv%%=*}" val="${kv#*=}" # 把点、横杠、空格全部转成下划线 var_name="$(printf '%s' "$key" | tr -cs 'A-Za-z0-9_' '_')" # 前缀保证不是数字开头 var_name="cfg_${var_name}" # 用export导出,避免直接eval printf -v "$var_name" '%s' "$val" export "$var_name" done < <(parse_ini "$file" "$section") }

这里我特意没用eval。用printf -v配合变量名动态赋值,能避免"值内容里带分号、引号、命令替换"时被当成代码执行。eval看起来省事,但在处理不可信配置时就是定时炸弹。另外加了cfg_前缀,即使键名是1234这种数字开头的奇怪名字,也保证变量名合法。

5.2 常见翻车现场汇总

我把自己和身边同事踩过的坑整理成一张表,每个都是真实发生过的:

症状根因对策
值取出来带^M文件是CRLF换行解析器剥\r
键名有\t模板文件转义残留键名做trim
值里的#被截断行尾注释一刀切只认"空白+井号"为注释
节名大小写不一致手工编辑改了大写需要比较前统一小写
多个同键值取到旧值重复键策略不清明确"后者覆盖"
路径值带空格被拆开用IFS直接切字段按又一个分隔符取值
配置文件含BOM头编辑器存成UTF-8 with BOM解析器首行去BOM
节名含点号解析错[x.y]里的点被当成key分隔节名整体提取

BOM头的问题值得一提。存成UTF-8 with BOM的配置文件,第一行第一个字符是\xEF\xBB\xBF,如果恰好是[global]这种节行,节名就变成了\xEF\xBB\xBFglobal,所有节内匹配全部失败。我的解决办法是在读取第一行时,单独剥一次前缀:line="${line#\$'\xef\xbb\xbf'}",具体到bash里可以写成line="${line#$(printf '\xef\xbb\xbf')}"。别嫌脏,它能救你一次。

5.3 用一份测试配置把解析器钉死

写解析器必须配测试,不然改一次坏一次。我常用的做法是准备一份"魔鬼配置",里面塞满各种边界情况,然后跑几组断言:

# 全局段 debug = false app_name = "my tool" [database] host = db.example.local url = postgres://user:pass@host:5432/mydb?sslmode=require # 生产连接串 timeout = 30 empty_value =

测试命令如下:

# 期望输出 false parse_ini test.ini global debug # 期望输出 "db.example.local" parse_ini test.ini database host # 期望输出完整URL,不能因为#被截断 parse_ini test.ini database url # 期望输出空行 parse_ini test.ini database empty_value # 不带参数时,期望把所有条目全部打出来 parse_ini test.ini | wc -l

我在每次改动解析器之后,都会跑一遍这组断言。这不是形式主义,而是因为解析器这种工具,改动一行就可能影响一大堆别的脚本,没有测试兜底,线上出问题再排查,代价高得多。

关于配置文件解析,我最后还想补一句:不要试图让解析器满足所有格式变体,而是明确声明"支持什么、不支持什么",然后在脚本里对不支持的情况快速失败并给出清晰报错。这种"严格模式"在配置文件领域比"宽容模式"更省心——宽容的结果往往是,错误配置被静默接受,程序用一种诡异的方式跑起来,到时候查问题的人必定骂娘。

我自己经过这一轮轮的踩坑和修补之后,已经把parse_ini彻底沉淀成项目里的公共函数。每次新项目要接配置文件,我直接复制过去,改改规则就行。这也是写Shell脚本最舒服的状态:基础工具一次做好,后面的活儿都变成搭积木。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询