资讯动态

Shell脚本高效解析INI配置:sed/awk/纯Bash实战指南

发布时间:2026/10/23 1:27:39 来源:尧图企业网站定制
写Shell脚本的老哥们谁还没为配置文件头疼过。参数一多全塞在脚本里改起来翻半天放到外部配置里又得费劲在脚本里解析。INI这种简单格式在服务器上到处都是可要在纯Shell环境下把它的节、键、值、注释都处理明白还真不是一两行grep能搞定的事。这篇文章就把我平时在脚本里解析INI配置文件的几种做法完整捋一遍从最省事的sed/grep组合到能扛住特殊符号的awk方案再到不依赖外部命令的纯Bash解析函数代码可以直接抄踩过的坑也一并列出来。适合正在写部署脚本、运维工具或者任何需要在Shell环境下读取配置的人参考。1. 从一个真实需求说起为什么脚本需要读INI配置1.1 配置文件的约定与常见变体INI格式虽然名字老旧但现在依然大量存在。它的基础结构就三样东西节、键值对、注释。节用方括号包裹一般表示一组相关配置的命名空间键值对用等号分隔左边是键右边是值注释则以分号;或井号#开头。; 应用配置文件示例 # 井号开头同样是注释 [server] host 127.0.0.1 port 8080 timeout 30 [database] host db.internal port 3306 user app_user password S3cure#pass uri mysql://app_user:S3cure#passdb.internal:3306/app [log] level INFO path /var/log/myapp我故意在示例里放了两个经常让人翻车的细节password的值中间带了#不是行首所以不是注释uri的值里带了多个等号如果解析逻辑只按第一个切分就不会出问题按第二个切分会炸。实际项目中INI格式还会有更多变体比如没有节的简易keyvalue文件、节名带空格、值带引号等。处理之前先明确自己面对的是哪种形态能省不少事。1.2 直接source配置文件的诱惑与边界很多新手第一反应是配置文件里写keyvalue直接用source config.ini或者. config.ini把它加载进来不就行了吗确实如果配置文件就是纯keyvalue且没有节这招最快变量直接就变成环境变量了。但真实场景往往不允许这么干。第一INI文件有节source遇到[server]这种行会直接报语法错误所以你得先想办法把节去掉或者转换成合法变量名。第二配置值里可能有空格、特殊符号特别是带$、反引号、分号的内容一旦source这些符号会被Shell当成语法结构去执行轻则解析错乱重则执行了配置里被故意塞进去的恶意命令。所以对“外部输入”的配置文件做解析而不是直接执行是安全上的底线。所谓解析读取本质是“读它的内容而不是跑它的内容”这句区别想明白后面的代码就好理解了。2. Shell解析INI的三种主流姿势2.1 用sed/grep做定向提取快速但有边界如果脚本里只需要某个节的几项配置用sed和grep组合是成本最低的方案。思路很简单先用sed把目标节的区间截出来再用grep把注释、空行、节行过滤掉。# 提取某个节的全部有效键值对 get_section() { local file$1 section$2 sed -n /^\[${section}\]$/,/^\[/p $file \ | grep -v ^\[ \ | grep -v ^[[:space:]]*[;#] \ | grep -v ^[[:space:]]*$ } # 用法 get_section app.ini server这段代码的逻辑是sed从匹配[server]那一行开始打印直到下一个以[开头的行之前结束然后grep把节行、注释行、空行全部滤掉。输出结果就是host 127.0.0.1这类原始行后面要取值还得再处理一次。这个方案的边界在于节名如果包含.、*、[这类正则元字符sed的匹配会出问题。比如节名是app.v2^\[app.v2\]$中的.会匹配任意字符可能误伤。另外grep过滤^\ [会误删那些“值以方括号开头”的行虽然这种情况不常见但一旦遇到就是隐蔽故障。sed/grep方案适合一次性、快速、确定格式的场景不适合做通用工具。2.2 用awk做结构化解析工程里最稳的选择当配置文件结构复杂、需要多次取值或者要做成通用工具时我倾向用awk。awk本身就是按行处理文本的利器天然适合解析行式配置。关键点是按切分后值部分不能只用第二个字段否则值里再出现等号就断了。# 提取指定节的全部有效键值对 get_ini_section_awk() { local file$1 target$2 awk -F -v target$target /^\[/ { section $0 gsub(/^\[|\]$/, , section) next } /^[[:space:]]*([;#])/ || /^[[:space:]]*$/ { next } section target { key $1 value substr($0, index($0, ) 1) gsub(/^[[:space:]]*/, , key) gsub(/[[:space:]]*$/, , key) gsub(/^[[:space:]]*/, , value) gsub(/[[:space:]]*$/, , value) print key value } $file }这个版本解决了两类痛点第一取值用substr($0, index($0, ) 1)取的是整行第一个等号右边的内容值里再有等号也不影响第二节名比较用的是awk字符串比较section target不是正则匹配节名里的特殊字符不会造成误匹配。awk方案比sed方案更能扛住“脏数据”缺点是代码量稍多对不熟悉awk的人来说读起来略微费劲。2.3 用纯Bash函数做个可移植的解析器还有一种场景目标机器上不一定有awk或者出于“少依赖外部命令”的洁癖希望只用Bash内置能力完成解析。纯Bash实现也不复杂核心就是用while read逐行扫描用case判断当前行是什么类型。# 解析任意节、任意键 parse_ini() { local file$1 target_section$2 target_key$3 local current_section line key value while IFS read -r line || [ -n $line ]; do # 去掉行首尾空白 line${line#${line%%[![:space:]]*}} line${line%${line##*[![:space:]]}} [ -z $line ] continue case $line in [\;#]* ) continue ;; \[*\] ) current_section${line:1:${#line}-2} continue ;; ** ) [ $current_section $target_section ] || continue key${line%%*} value${line#*} key${key#${key%%[![:space:]]*}} key${key%${key##*[![:space:]]}} value${value#${value%%[![:space:]]*}} value${value%${value##*[![:space:]]}} if [ -z $target_key ] || [ $key $target_key ]; then printf %s%s\n $key $value fi ;; esac done $file } # 用法 parse_ini app.ini server parse_ini app.ini server portparse_ini的定位是一个“只输出、不执行”的纯读取器。它把读取到的键值对以keyvalue形式打印出来脚本拿到之后再自行处理。如果只想拿某个键的值配合参数替换就能取出get_ini_value() { local result result$(parse_ini $1 $2 $3) printf %s\n ${result#*} } # 读取 server 节的 port get_ini_value app.ini server port这个方案的优势在于可移植性极强几乎任何带Bash的环境都能跑适合作为脚本库里的一个通用函数。2.4 选型对照到底用哪个三个方案各有适用场景我一般按下面这个思路选方案依赖上手难度对特殊字符的容忍度适合场景sed/grepsed、grep低低正则符号容易误匹配临时脚本、格式固定的配置文件awkawk中高字符串比较更稳通用工具、复杂配置、重复执行纯Bash无外部命令中高高全靠逻辑判断跨平台脚本、依赖受限的环境如果你的脚本本来就要依赖sed、grep这些工具那用awk版最划算如果你的脚本主打“一个Bash跑天下”那就用纯Bash解析器。3. 实操记录从零写一个能用的INI读取器3.1 先定需求清单再动手写写代码之前我习惯先把需求列出来这样可以避免边写边加功能导致逻辑混乱。我这个INI读取器要满足以下要求支持多个节能指定读取某个节能指定读取某个节的某个键能忽略行首注释分号和井号和空行能去掉键和值两侧多余的空格值中间可能包含空格和等号不能被错误截断不执行配置内容只输出纯文本确保安全。接口设计上我用parse_ini 文件 [节名] [键名]三个参数。节名和键名都可以省略只给文件名就输出全文有效配置给节名不给键名表示“打印全部有效键值对”两个都给了就是精准取值。这样调用方式足够灵活能满足大多数脚本需求。3.2 完整实现与逐段拆解下面是我在实际项目里用的完整版本注释也一起写上方便直接拷贝parse_ini() { local file$1 target_section$2 target_key$3 local current_section line key value while IFS read -r line || [ -n $line ]; do # 去掉行首尾空白 line${line#${line%%[![:space:]]*}} line${line%${line##*[![:space:]]}} [ -z $line ] continue case $line in # 注释行 [\;#]* ) continue ;; # 节行 \[*\] ) current_section${line:1:${#line}-2} continue ;; # 键值行 ** ) [ $current_section $target_section ] || continue key${line%%*} value${line#*} key${key#${key%%[![:space:]]*}} key${key%${key##*[![:space:]]}} value${value#${value%%[![:space:]]*}} value${value%${value##*[![:space:]]}} if [ -z $target_key ] || [ $key $target_key ]; then printf %s%s\n $key $value fi ;; esac done $file }逐段拆解几个关键点while IFS read -r line || [ -n $line ]这一行是Bash安全读取文件的标准写法。IFS的目的是禁止read把行首尾的空白吃掉这样后续做trim才有意义-r防止反斜杠被解释|| [ -n $line ]是为了处理最后一行没有换行符的情况保证最后一行不会丢。trim那两行是Bash的参数扩展技巧。${line#${line%%[![:space:]]*}}的作用是删除行首空白${line%${line##*[![:space:]]}}是删除行尾空白。这两行看起来绕实际等价于常见的trim功能而且不需要启动外部命令。case分支是全文的核心判断。[; #]*匹配分号和井号开头的注释行\[*\]匹配节行**匹配包含等号的行。这里刻意把节行判断放在注释判断之后把键值判断放在最后是因为配置文件里的行最多只会匹配其中一类顺序不影响结果。取值时用key${line%%*}和value${line#*}一个按最后一个等号切、一个按第一个等号切刚好配合起来键部分截取最后等号之前的内容值部分截取第一个等号之后的内容。这样即使值里有多个等号也能正确保留。最后用printf输出keyvalue。这里故意不直接赋值给变量是因为“读取函数只负责输出调用方负责使用”职责清晰也方便外层脚本自行解析或再次封装。3.3 功能扩展带默认值读取和全局加载实际用起来有时候希望“取不到就返回默认值”这可以让脚本在缺少配置时不至于直接报错。简单封装一下get_ini_value() { local result result$(parse_ini $1 $2 $3) if [ -z $result ]; then printf %s\n $4 else printf %s\n ${result#*} fi } # 读取超时时间没有配置时默认 30 timeout$(get_ini_value app.ini server timeout 30)另一个常用需求是把整个INI一次性加载到内存后面反复取用。Bash 4以上支持关联数组可以做成一个全局配置表declare -A INI_MAP load_ini() { local file$1 section key value line while IFS read -r line || [ -n $line ]; do line${line#${line%%[![:space:]]*}} line${line%${line##*[![:space:]]}} [ -z $line ] continue case $line in [\;#]* ) continue ;; \[*\] ) section${line:1:${#line}-2} ;; ** ) key${line%%*} value${line#*} key${key#${key%%[![:space:]]*}} key${key%${key##*[![:space:]]}} value${value#${value%%[![:space:]]*}} value${value%${value##*[![:space:]]}} INI_MAP[${section}.${key}]$value ;; esac done $file } # 使用 load_ini app.ini echo ${INI_MAP[server.port]}把节名和键名用点号拼成关联数组的key正好解决不同节同键名的冲突问题。这个扩展我在处理多份配置的脚本里用得很频繁一次性读入后续全是数组访问性能也远好于反复调函数。3.4 实测效果与两个容易忽略的细节用文章开头的app.ini跑一遍parse_ini$ parse_ini app.ini server host127.0.0.1 port8080 timeout30 $ parse_ini app.ini server port port8080 $ parse_ini app.ini database hostdb.internal port3306 userapp_user passwordS3cure#pass urimysql://app_user:S3cure#passdb.internal:3306/app可以看到值里的#和多个等号都原样保留了读取逻辑没有误伤。实测中我最常踩的一个坑是如果某行值里故意写成passwordS3cure#pass # 密码解析器会把它整个当成值因为#不在行首。多数轻量INI解析器都这样处理我觉得可以接受但要在文档里写清楚“不支持行内注释”免得用户误以为能加。另一个容易忽略的是文件编码。某些Windows环境下保存的INI会带UTF-8 BOM头导致第一个节名变成\ufeffserver匹配不上。处理方式是在读取时先去掉BOMsed -i 1s/^\xEF\xBB\xBF// app.ini如果不想改原文件也可以在解析函数里打开文件前先过滤sed 1s/^\xEF\xBB\xBF// $file | while IFS read -r line || [ -n $line ]; do ... done不过管道方式会让整个函数跑在子Shell里变量传递会受影响。我建议要么提前清理文件要么把过滤逻辑放在最外层。4. 常见问题与排查手册4.1 正则匹配引发的“幽灵故障”sed/grep方案最典型的坑就是节名正则化。比如节名是web[1]在sed里直接写成/^\[web[1]\]$/会被当成字符组[1]匹配web1而不是web[1]。如果配置文件里的节名是固定的业务名比如server、database那倒没风险一旦节名由外部参数传入就必须做正则转义或者干脆换awk/Bash方案。排查这类问题有个技巧先用set -x把脚本执行过程打出来看sed到底拿到了什么参数。有时候你以为传的是web[1]实际Shell展开后变成了别的加打印一眼就能看出来。4.2 IFS、read和换行符的纠缠while read line不写IFS和-r在一些老脚本里非常常见但它有两个隐患一是read会自动把行首尾的空白吃掉导致本来key value的行变成keyvalue看起来没区别但对严格匹配的配置格式就是bug二是没有-r时行末反斜杠会触发续行逻辑比如加密后的密码含\读进来就少了一段。排查这个问题的简单方法是od -c查看原始行$ echo key value | od -c 0000000 k e y v a l u e \n如果看到行尾有\r说明文件是CRLF换行解析出来值会带一个看不见的\r。处理办法是用tr -d \r或sed s/\r$//清洗后再解析。4.3 注释、空行和“看不见的敌人”注释和空行本身不难处理难的是“看起来空但实际有空白”的行。如果配置文件里有一行只有两个空格用[ -z $line ]判断空行是失败的必须先trim再判断。这正是我在解析函数里先把每行trim一遍的原因。另一个隐蔽问题是节名或键名两侧有空格。比如[server]写成[ server ]或者键写成host 127.0.0.1。如果解析器不做trim匹配server会失败取值也取不到。就像代码里展示的节名我直接截取中括号内的内容键和值都做了trim才保证了这些常见格式都能识别。4.4 安全红线不要轻易source外部配置这是我认为最重要的一条。把配置文件直接source进Shell等于把它当代码执行。配置里如果写了port$(rm -rf /tmp/test)脚本一source就真的会执行删除命令。不要觉得“配置文件是内部人写的就没事”配置经常会被复制、修改、从外部粘贴进来风险是真实存在的。安全做法的核心就一句话配置内容只做文本处理和输出绝不执行。我上面给的parse_ini函数全程用字符串操作没有eval没有任何把值当作命令执行的路径这是配置解析器的基础底线。如果你遇到别人的脚本里用eval配合配置文件建议立刻改成解析方案。4.5 问题速查表现象可能原因处理办法值末尾多了\r或^MWindows CRLF换行tr -d \r或清洗文件第一个节始终匹配不上UTF-8 BOM头去掉BOM值含多个等号被截断解析逻辑用了第二个按第一个切分键值被脚本“执行”了直接source了配置改为解析式读取注释行过滤不干净行首有空格先trim再判断注释变量没传进去管道子Shell环境避免管道传递变量个人经验与调试技巧折腾INI解析这么久我最深的体会是解析类代码不值得每次现写把它沉淀成一两个通用函数放在脚本库里比任何临时grep都省心。我在自己的常用脚本里就是固定放一个parse_ini用到哪复制到哪参数含义固定下来团队成员用起来也不会出错。最后贡献一个小技巧。如果遇到“我明明解析出来了但脚本行为不对”的诡异情况别急着怀疑逻辑先用这串命令看文件真实内容nl -ba app.ini | sed -n 1,20p od -c app.ini | sed -n 1,20pnl -ba能显示每一行的行号配合sed截取前20行可以确认行号是否和预期一致od -c能把换行符、回车的真实形态暴露出来。大部分解析问题在这两步面前都会原形毕露比一遍遍打日志高效得多。配置文件是程序的“输入边界”把边界校验扎实后面的业务逻辑才能睡得着觉。

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

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

免费获取报价 →
↑