ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Bash readonly 详解:只读变量、数组与函数的安全防护实践

Bash readonly 详解:只读变量、数组与函数的安全防护实践 说实话我第一次被 Bash 的readonly救回来是在一次部署脚本事故里。当时PROJECT_ENV这个变量在脚本前半段被某个公共配置文件悄悄覆盖成了测试环境的 ID结果一套服务全连到了错误的后端排查了整整一下午才发现是变量冲突。后来我在脚本入口处给关键变量加了readonly这类问题基本绝迹。很多写 Shell 的人对readonly的印象就是“一个锁变量的命令”其实它在只读变量、数组、函数三个层面都能发挥作用而且每个层面的坑都不太一样。这篇就按照变量、数组、函数、原理、实战的顺序把我用过的东西和踩过的坑都摊开聊一遍。不管你是给团队写公共脚本、维护 CI 流水线还是只是自己写点自动化工具这篇内容应该都能直接帮上忙。1. readonly 到底锁住了什么先搞懂这张“只读锁”1.1 最小示例把一个变量锁死需要几步直接看命令。在 Bash 里声明一个只读变量最常用的就是readonly加赋值readonly APP_NAMEdeploy-tool APP_NAMEnew-name # bash: APP_NAME: readonly variable第二行一执行bash 立刻报错APP_NAME还是原来的值。如果你用的 shell 是 zsh报错信息会略有差异但行为一致。这里我想让你注意一件事readonly不是“赋值命令”它是在给变量身上挂一个“只读属性”。变量一旦带上这个属性后续任何形式的赋值都会失败包括、、read向变量里读数据以及用unset试图删掉它。我见过不少人以为 “readonly 只是约定俗成代码里注意点就没事”其实不是。它是 shell 层面的强制约束只要你还在这个 bash 进程里工作它就绕不过去。理解这一点后面数组、函数的用法就很好懂了。1.2 三个真实误用现场为什么你需要这层保护第一类场景是source公共配置。很多人会把公共变量放在一个config.sh里然后到处source。问题来了如果config.sh里定义了SERVER_ENV你的主脚本又自己定义了一遍source顺序一错后加载的会把前面的覆盖掉。我在团队里遇到过不止一次最后查出来都是source顺序导致的。第二类场景是函数内部的全局污染。在函数里直接写VARxxx如果这个变量在函数外已经存在同名变量函数内的赋值就会悄悄改掉外面的值。这对某些“临时辅助脚本”可能无所谓但对长时间运行的自动化脚本来说可能是灾难。你很难定位到“到底是谁改了配置”。第三类场景是 CI 脚本。很多人会在流水线里把构建参数通过环境变量传进去脚本里又对同一个变量做二次赋值导致构建出来的产物版本错乱。把关键参数声明为只读是成本最低的防呆手段。至少报错会直接告诉你哪一行在改而不用你自己去猜。看到这里你应该明白了readonly解决的核心问题是“防止关键变量被误改”。它不是语法糖而是一种便宜好用的运行时保护。1.3 readonly、declare -r、local -r 的关系Bash 里能产生只读属性的命令不止一个很多人会被搞混我列个表一次讲清楚命令格式适用位置作用常用组合readonly namevalue任意位置设置只读变量-a数组、-A关联数组、-f函数declare -r namevalue任意位置和readonly等价但可组合多种属性-ra、-rA、-rxlocal -r namevalue函数内部声明只读局部变量函数结束自动销毁无readonly和declare -r在普通标量变量上完全等价区别在于declare可以和其他属性位组合。local -r则是局部变量的专用写法更安全也更推荐。2. 只读变量声明方式、作用域与组合用法2.1 声明只读变量的四种姿势Bash 里声明只读变量至少有四种等价写法我直接列出来# 方式一先赋值再锁定 APP_ENVproduction readonly APP_ENV # 方式二赋值的同时锁定 readonly APP_ENVproduction # 方式三用 declare 加只读属性写法更统一 declare -r APP_ENVproduction # 方式四函数内部声明只读局部变量 function init_env() { local -r TEMP_FILE/tmp/env_${RANDOM}.lock echo $TEMP_FILE }前三种本质上没什么区别readonly和declare -r在 Bash 里对普通标量变量是完全等价的内建命令。区别在方式四local -r多了一个“局部”的限定它声明的变量只在当前函数里存在函数执行完就销毁这能避免污染全局作用域是我写脚本时最推荐的一种局部保护。2.2 全局只读与局部只读作用域怎么算这里有一个新手特别容易踩的坑在函数内部执行readonly APP_ENVproduction它创建的其实是全局只读变量不是函数私有的。因为readonly本身不管作用域它只负责给当前可见的那个同名变量挂属性。如果你在函数外用local声明过局部变量那readonly会挂到局部变量上但如果你没做任何局部声明它挂的就是全局变量。所以我想强调一个结论除非你明确在函数里写local -r否则别在函数里直接readonly一个普通变量。因为函数一执行这个变量就全局只读了后面的脚本都没法再改它排查起来非常费劲。我早期就犯过这个错把父脚本里一个很重要的临时目录变量锁死了导致脚本后半段想换目录都不行。2.3 与 export 组合给子进程传“不可变”配置有时候你希望子脚本或外部命令也能拿到这个值但又不希望它当前 shell 里被改来改去。最直接的做法是declare -rx BUILD_VERSIONv1.2.3-x表示导出到环境变量-r表示只读两个属性可以叠加。这样设置之后当前 shell 里无法修改它子进程也会通过环境变量拿到BUILD_VERSION。看个例子declare -rx BUILD_VERSIONv1.2.3 bash -c echo 子脚本读取: $BUILD_VERSION # 子脚本可以读取到 v1.2.3不过这里必须说清楚只读属性不会被子进程继承。子进程拿到的是一个普通字符串它可以在自己的进程里随意修改。也就是说readonly的约束只在当前进程内有效跨进程之后的保护等于零。理解这个边界很重要不然你会误以为“子进程也会遵守”。2.4 一旦声明错了怎么解答案很残酷没有运行时解开的办法。Bash 设计上就不允许对只读变量做unset或重新赋值所以一旦声明错了要么在当前进程里凑合用要么直接退出重开一个 shell。所以我的习惯是在脚本顶部集中声明只读变量不要写到一半突然加一行readonly。这样即使错了也在最显眼的位置排查成本低。如果你真有“这个变量前半段需要改后半段不能再改”的需求那也是不行的readonly不具备“解锁”能力。这时更好的方案是换一个变量名或者把逻辑拆到不同函数里用局部作用域去控制。2.5 空字符串和未定义两个容易看走眼的场景readonly FOO这个写法会创建一个值为空的只读变量。它和“没有定义”完全不同因为在set -u模式下访问未定义变量会报错而访问值为空的已定义变量不会报错。但如果你以为“先声明一个空的只读变量以后再用FOOxxx给它赋值”那就会碰壁只读属性一旦生效赋值必然报错。另一个容易看走眼的情况是命令替换。readonly FOO$(generate_id)这条命令会先执行generate_id再把输出结果赋给FOO然后锁定。如果你期望的是“每次读取FOO时都重新计算”那这个想法是错的。只读变量锁定的是值不是表达式。3. 只读数组配置表与参数白名单的利器3.1 如何声明一个只读数组数组和普通变量一样可以挂只读属性。最稳妥的写法是declare -ra SUPPORTED_ENVS(dev test prod) declare -rA SERVICE_PORTS([web]8080 [db]3306 [cache]6379)第一行是只读索引数组第二行是只读关联数组。为什么说用declare -ra比用readonly -a稳妥因为readonly -a在 bash 里虽然能用但不同 bash 版本对readonly后面直接跟数组赋值表达式的解析略有差异一旦出现兼容性问题脚本表现会很迷惑。而declare -ra name(...)这种写法在 Bash 4.x 和 5.x 里都很稳定我推荐直接记住这一种。声明完之后用declare -p SUPPORTED_ENVS可以看到类似declare -ra SUPPORTED_ENVS([0]dev [1]test [2]prod)的输出。看到那个-ra了吗r就是只读a就是数组。3.2 尝试修改只读数组的三种报错对只读数组的修改会触发立即报错SUPPORTED_ENVS[0]local # bash: SUPPORTED_ENVS[0]: readonly variable SUPPORTED_ENVS(staging) # bash: SUPPORTED_ENVS: readonly variable unset SUPPORTED_ENVS[1] # bash: unset: SUPPORTED_ENVS[1]: cannot unset: readonly variable第一种是改单个元素第二种是用追加元素第三种是删除元素全部都会被拦截。也就是说数组一旦进入只读状态整个数组的“形状”和“内容”都被锁死了。这也是它适合做配置表的原因你不想让某个函数在运行过程中偷偷往列表里加环境或者把手一抖把某个服务地址改掉。3.3 一个真实配置表案例我有一段经常用的部署脚本片段可以给你参考declare -ra CLUSTER_NODES( node-a.internal node-b.internal node-c.internal ) for node in ${CLUSTER_NODES[]}; do echo 准备同步到: $node rsync -avz ./dist/ deploy${node}:/var/www/app/ done这个数组被声明为只读之后即使后续脚本里有人写了CLUSTER_NODES(...)想整体替换也会直接报错不会出现“同步到一半发现数组内容变了”这种诡异问题。把机器列表、镜像列表、端口映射这类静态数据放进只读数组是我自己在运维脚本里最常用的做法。3.4 数组元素里的命令替换与循环遍历只读数组锁住的是“数组本身”不是元素里命令的输出结果。比如declare -ra THRESHOLDS($(get_threshold))这行声明时get_threshold会先执行结果作为元素存进数组然后整个数组被锁定。后面再执行get_threshold已经不影响这个数组了。这个应该好理解但我在实际代码里见过有人误以为只读数组会“每次读取时都重新计算”所以顺手提一句。还要注意在for循环里遍历只读数组时循环变量node本身不是只读的你可以对node做临时修改。只读数组限制的是“不能改数组的槽位”不是“不能读取后做二次处理”。4. 只读函数防止被覆盖的“公共库守门员”4.1 两个命令锁定函数函数也可以通过readonly锁定。命令是log_info() { echo [INFO] $*; } readonly -f log_info锁完之后再尝试重定义同一个函数log_info() { echo [自定义] $*; } # bash: log_info: readonly function如果你想用统一的声明风格也可以写declare -rf log_info。效果完全一样。不过有个细节readonly -f带函数名时是锁定函数如果不带函数名则会列出当前 shell 里所有只读函数。同理declare -rf也可以用来查看但实战里我更推荐用declare -p或者declare -F来看函数是否存在因为输出更直观。4.2 公共库文件里的典型用法我最推荐把只读函数用在公共库文件里。假设你写了一个common.sh里面定义了一批通用函数给多个脚本调用# common.sh log_info() { echo [INFO] $*; } log_error() { echo [ERROR] $* 2; } readonly -f log_info log_error如果某个下游脚本里也定义了一个同名log_info在source common.sh之前它会先被自己的定义覆盖。但如果common.sh在最后执行了readonly -f那当这个脚本source完common.sh之后log_info就再也无法被重定义。这相当于给公共函数加了一道“最终版本”标识既然大家都在用同一套日志格式就别改来改去。另外配合export -f还可以让子 bash 进程也能调用这些只读函数。readonly -f负责锁定义export -f负责导出定义两者不冲突。4.3 readonly -f 的边界锁得住函数锁不住一切先说清楚readonly -f只保证函数定义不可被重新声明、不可被unset它不会阻止函数内部修改外部变量也不会阻止你调用其他命令。比如下面这个函数change_global() { GLOBAL_VALUEmodified; }即使change_global被声明成只读函数它内部依然可以修改GLOBAL_VALUE。因为只读函数管的是“函数名字不能被覆盖”不是“函数运行时的行为被限制”。如果你想同时锁住函数内部用到的一些变量还得另外配合只读变量一起用。还有一个容易混淆的点readonly -f funcname不会把函数定义导出给子进程。如果你在子 bash 脚本里想继续使用这个函数必须显式export -f funcname。这两个属性是独立设置的我见过有人以为设了只读就等于导出结果子脚本里一直找不到函数排查了半天。4.4 别名、同名变量与只读函数的优先级只读函数还有一个容易被忽略的边界alias 的优先级高于函数。如果你在脚本里写了alias log_infoecho 自定义 log_info hello即使log_info是只读函数上面的调用也会走 alias而不是函数本体。readonly -f锁得住函数定义锁不住用户的 alias 定义。所以在实际脚本里如果要保证函数一定被执行最好避免使用容易被 alias 覆盖的函数名或者在脚本开头unalias一批已知的 alias。函数名和变量名在 Bash 里是两套命名空间同名也互不干扰。你可以有一个只读函数foo同时还有一个同名只读变量foo。排查的时候要用declare -p foo看变量用declare -F foo看函数别搞混了。5. 幕后机制与排查手段5.1 变量属性位declare -p 是照妖镜Bash 在内部为每个变量维护了一组属性标志位。-r是只读-a是索引数组-A是关联数组-x是环境变量-i是整数-f是函数。这些标志位可以叠加比如declare -ra就是“只读 索引数组”declare -rAx就是“只读 关联数组 导出环境变量”。想知道一个变量当前带哪些属性用declare -p 变量名查看最直接declare -p APP_ENV # 输出类似declare -rx APP_ENVproduction出现-r就表示只读属性已生效。这个命令在排查“为什么这个变量改不动”时特别管用比看代码猜来猜去快得多。我自己的习惯是每次报readonly variable就直接declare -p看一下属性再根据属性组合判断是环境问题还是脚本问题。5.2 readonly 与子 shell为什么子进程也会报错有同学会问我只在父 shell 里声明了只读变量进入子 shell 后还能改吗答案是子 shell 会继承父 shell 的变量副本并且只读属性会跟着副本一起保留。所以你尝试在子 shell 里给只读变量赋值依然会报readonly variable。但这里要区分“子 shell”和“外部程序”。子 shell 是当前 bash 派生的新 bash它们共享同一套变量属性逻辑而外部程序比如执行一个 Python 脚本通过环境变量拿到这个值后它看到的只是一个普通字符串没有什么只读概念。这意味着如果你希望跨进程保护配置不能指望readonly本身而是要在子进程内部自己加防御逻辑。5.3 用 trap 和 set -x 快速定位是谁改了只读变量脚本一大很容易出现“某一行试图修改只读变量导致报错”的情况。最快的定位手段是在脚本开头加上trap echo [DEBUG] $BASH_COMMAND DEBUG这样每条命令执行前都会打印出来。你只要找到报错前最后一行打印出的命令基本就是问题源头。如果不想加trap也可以直接bash -x script.sh跑一遍看输出里报错前最后一条被 trace 到的命令。我在帮同事排查时就靠这一招把藏在几百行脚本里的冲突给揪了出来。5.4 用 readonly -p 做环境审计readonly不带参数时会列出当前 shell 里所有只读变量。这个输出非常适合用来做环境审计readonly -p | grep -E APP_|DEPLOY_ # 输出类似 # declare -rx APP_ENVproduction # declare -rAPP_NAMEdeploy-tool # declare -ra DEPLOY_REGIONS([0]us-east [1]eu-west)如果你要接手一个别人写的复杂脚本第一件事可以先跑readonly -p看看入口处已经锁了哪些东西。这样后续排查“为什么变量改不了”会省很多时间。6. 实战能直接抄的模板与避坑清单6.1 组合模板入口脚本 公共库一个我比较推荐的组合思路是入口脚本锁定“环境级变量”公共库锁定“函数级接口”。# deploy.sh入口脚本 set -euo pipefail declare -rx APP_ENV${APP_ENV:-production} declare -ra DEPLOY_REGIONS(us-east eu-west ap-south) source ./common.sh if [[ ${DEPLOY_REGIONS[*]} ! * ${TARGET_REGION} * ]]; then echo 不支持的 region: $TARGET_REGION 2 exit 1 fi do_deploycommon.sh里面只对函数做保护不轻易锁全局变量# common.sh log_info() { echo [INFO] $(date %F %T) $*; } readonly -f log_info这个组合的好处是环境级配置在入口固定下来后面无论怎么source都不会乱公共库的函数接口也被保护不会因为调用方的同名定义而行为漂移。变量和函数各管各的责任清晰。6.2 防止公共库被重复加载公共库被重复source会带来两个问题一是函数重复定义二是全局变量被重新赋值。用只读变量可以做一层轻量防重复加载# common.sh if [[ -n ${_COMMON_LOADED-} ]]; then return 0 2/dev/null || exit 0 fi readonly _COMMON_LOADED1第一次source时_COMMON_LOADED被声明为只读第二次source时由于它已经有了值直接return后面的代码不会再执行。这里用${_COMMON_LOADED-}而不是${_COMMON_LOADED}是为了避免set -u在变量未定义时报错。这个细节我在不少脚本里见过容易漏。6.3 常见报错速查表我整理了一张表列几个最常见的报错场景和解决办法报错信息原因解决办法bash: VAR: readonly variable对只读变量赋值找到赋值行改用其他变量名bash: arr[0]: readonly variable修改只读数组元素不要修改或改为新数组bash: unset: VAR: cannot unset: readonly variable对只读变量执行unset无法 unset只能接受或换 shellbash: func: readonly function重定义只读函数去掉函数重定义或去掉readonly -f子 shell 内报readonly variable子 shell 继承了只读属性在子 shell 内不要修改该变量这张表实际排查时可以直接对照。看到readonly variable基本就是触碰了只读保护看到cannot unset可以确认是unset操作。绝大多数情况解决办法都不是“强行解锁”而是调整代码逻辑。6.4 最后说一个分组习惯我在团队里长期坚持一个习惯被source的公共库文件里除非注释里明确写了“此变量不可覆盖”否则我不会在里面直接写readonly。因为公共库是被多个脚本复用的如果库文件一进来就把某个变量锁死下游脚本就失去灵活性了。readonly更适合放在最上层的入口脚本里也就是能决定整个项目“这一轮跑什么配置”的地方。这样保护目标清楚后续维护也不容易吵架。我个人在实际操作中最深的一点体会是readonly的价值不在于“阻止坏人”而在于“让错误更早暴露”。一个试图给只读变量赋值的操作如果不锁可能要到部署中段才暴露出问题锁了之后它在第一行就会报错。这几十毫秒的报错时间能帮你省下几小时的排查时间。如果你还没在脚本里用过只读变量、数组和函数建议找一个小项目先试试把入口处的关键配置锁起来跑几个星期再回头看你会感受到它的好处。
返回列表