资讯动态

Shell脚本解析INI配置文件:从格式差异到安全实践

发布时间:2026/10/23 3:47:04 来源:尧图企业网站定制
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 port5432 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就一定是空值。第二port5432这种写法在真实文件里很多解析逻辑必须兼容有没有空格两种情况。第三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配置只能解析特定文件不能通用python3configparser解析能力强依赖python环境有些精简系统没有awk单脚本可定制复杂逻辑写出来像个 一次性作品在项目里我总结出的规律是如果目标机器完全可控、可以保证装了crudini或python3那确实没必要重复造轮子但如果你要写的是要分发到不同环境里的Shell脚本指望所有机器都有这些依赖不如写一个纯Shell函数零外部依赖拿来即用。这也是我后来坚持用bash实现解析器的核心原因。2.3 什么时候必须自研什么时候没必要给个直观的判断方法你打算怎么用这些配置如果配置只在一个脚本内部用且格式完全由你自己掌控——比如你写了个setup.conf从头到尾都是你生成和维护的——那source一下或者sed抓一行就够。但一旦有下面任何一个信号建议直接上通用解析器配置文件来自别的模块、别的团队你没有改格式的控制权配置项数量多且需要分组管理就是有节脚本要重复读取同一份配置甚至要多次加载不同模块的配置配置文件可能被人手工编辑而编辑者不一定守规矩。手工编辑这一点特别关键。程序生成的配置是听话的人手改过的配置是不可预测的。有人会顺手在行尾加个空格ctrlv粘进来一段带制表符的文本甚至用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 sectionglobal 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#*}取等号右侧。注意我用的是%%和#而不是%和##因为%%贪婪匹配是从右往左删最短的右侧、保留最长的左侧这样abc会被拆成keya、valuebc正好满足第一个等号作为分隔符的预期。这就是前面说的值里带等号也不会裂开的实现基础。引号剥除只处理整串值被一对引号包裹的情况。原因很简单INI规范里很少会有转义引号的需求如果值本身是abcdef这种那说明文件本身写得就很不规范解析器没必要为此复杂化。保留原样反而更安全。这个函数用起来很简单。给个例子# 读取[ database ]节下host项的值 host$(parse_ini app.ini database host) echo db host is: $host # 输出所有配置 parse_ini app.ini3.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注入风险解析器把keyvalue配对输出以后脚本侧通常想把变量直接变成$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_namecfg_${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:passhost:5432/mydb?sslmoderequire # 生产连接串 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脚本最舒服的状态基础工具一次做好后面的活儿都变成搭积木。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑