首页 / 资讯中心 / 文章详情

Shell函数与数组实战:从命令堆砌到结构化脚本的进阶

Shell函数与数组实战:从命令堆砌到结构化脚本的进阶 ★ FEATURED ARTICLE
写Shell脚本这件事很多人入门时觉着“不就是把命令按顺序排列一下”但脚本写到上百行、需要重复处理一组服务器或一批文件时你会突然发现代码像一坨解不开的毛线。改一处逻辑得顺着脚本翻半天复制粘贴的碎片代码到处都是。这时候你才开始意识到函数和数组这两个基础特性才是Shell编程从“命令行记事本”走向“结构化脚本”的关键分水岭。这篇文章围绕“Shell编程之函数与数组”把这两个特性的定义、参数传递、返回值、作用域、批量数据组织方式和实际组合场景完整拆解一遍。内容适合已经写过简单脚本但不知道怎么抽公共逻辑、不知道怎么处理批量数据的开发者也适合正在把运维脚本工程化、想把重复代码收敛起来的实践者。文章里所有的代码示例都在 bash 5.x 下验证过但绝大多数语法兼容 bash 3.x 与 4.x。1. 函数让脚本从线性执行走向结构化设计1.1 两种定义写法本质上没有区别Shell 函数定义有两种写法实际项目里两种都会见到。# 写法一带 function 关键字 function log_info { echo [$(date %Y-%m-%d %H:%M:%S)] INFO $* } # 写法二直接用函数名加括号 log_info() { echo [$(date %Y-%m-%d %H:%M:%S)] INFO $* }这两种写法在 bash 中的行为基本等价。写法二更接近 POSIX 风格在纯 sh 环境下可移植性更好写法一的优势在于“function”关键字让阅读者一眼就能识别这是函数定义特别是在脚本较长、函数嵌套较深时可读性更高。我个人的习惯是脚本第一行声明为#!/bin/bash时用写法二因为 bash 环境完全支持且更简洁如果脚本需要兼容 dash 或 POSIX sh就只保留用法二。关键细节在于函数必须先定义后调用。Shell 是逐行解释执行的不像 C 或 Java 有编译期处理。如果你在脚本开头就调用函数而函数定义写在后面执行时会直接报command not found。这也是新手最容易踩的第一个坑——把函数定义当成“声明”以为解释器会帮自己预先扫描一遍。1.2 参数传递函数本质上就是一个微型脚本理解 Shell 函数参数机制最简单的方式是把函数想象成一个独立的微型脚本。函数内部通过位置参数$1、$2、$3接收外部传入的值调用时不需要加括号直接写函数名再空格接参数即可。5.1 函数返回与全局变量作用域 是函数最容易被误用的地方后文会专门展开。 print_person_info() { echo 第 1 个参数$1 echo 第 2 个参数$2 echo 参数总个数$# echo 所有参数逐个独立$ } print_person_info 张三 运维工程师执行结果非常直观。有几个细节需要特别强调。第一$和$*在双引号包裹下行为完全不同。$把每个参数视为独立个体$*则把所有参数合并成单个字符串。如果某个参数本身包含空格这个差异会直接决定程序的正确性。遍历参数时永远用$。test_args() { for arg in $; do echo 参数: [$arg] done } test_args hello world shell第二$0在函数内部仍然是脚本本身的路径不是函数名。这一点和很多编程语言不同容易导致日志输出时定位出错信息困难。第三shift可以在函数内逐位消费参数。比如参数个数不确定、按配对关系处理时常见写法如下parse_options() { while (( $# 0 )); do case $1 in -h|--host) HOST$2 shift 2 ;; -p|--port) PORT$2 shift 2 ;; *) echo 未知参数: $1 return 1 ;; esac done }这样写的好处是每处理完一组参数就 shift 消费掉不需要通过复杂的索引变量来追踪读取位置。对于几十行的小脚本这种手写解析方式比 getopts 更轻量、更直白也比 getopt 命令避免了跨平台差异。1.3 返回值只能拿数字这事儿挺坑的Shell 函数的 return 语句有一个天然局限返回值只能是 0-255 之间的整数。语义上 0 表示成功非 0 表示各类失败规则和命令退出状态码完全一致。check_file() { local file$1 if [ -f $file ]; then return 0 else return 1 fi } if check_file /etc/passwd; then echo 文件存在 fi如果你想“返回字符串”有几个方案按实际经验排序。方案一全局变量传递。在函数内部把结果赋给某个约定好的全局变量函数退出后读取该变量。这种方案最稳定不会和其他输出混淆。get_user_home() { user_home local username$1 user_home$(eval echo ~$username) }方案二标准输出捕获。函数通过 echo 输出结果调用方用$(...)捕获。get_date_prefix() { echo $(date %Y%m%d) } prefix$(get_date_prefix)这个方案需要注意函数内任何输出到标准输出的内容都会被捕获。如果函数内部混入了调试用的 echo捕获的结果就会被污染引发相当隐蔽的 bug。所以函数内部的调试信息一定要写到标准错误输出或者重定向到日志文件。方案三返回值与输出分离。函数用 echo 返回业务结果错误码用 return 表达调用方先捕获输出、再判断$?。parse_version() { local version_string$1 if [[ $version_string ~ v([0-9]\.[0-9]) ]]; then echo ${BASH_REMATCH[1]} return 0 fi return 1 } version$(parse_version v1.2.3) if [ $? -eq 0 ]; then echo 解析到版本: $version fi我通常会混用方案一和方案三判断型的逻辑只用 return产出具象结果的逻辑用 echo 捕获函数内部所有日志一律输出到2。这样的代码虽然每次调用要多写几行但长期维护时查问题的成本低很多。1.4 变量作用域默认全局才是最大的坑点Shell 函数里的变量默认是全局作用域。函数内部随手写一个count0很可能就覆盖了主脚本里同名变量这也是多函数脚本里最经典的隐蔽 bug。count0 increment_count() { count5 # 直接修改了全局变量 count echo 函数内部 count$count } increment_count echo 函数外部 count$count # 输出 5而不是 0解决方案只有一个函数内部变量一律用local声明。local 关键字让变量的作用域被限制在函数及其子函数内函数执行结束后自动销毁。count0 increment_count() { local count5 echo 函数内部 count$count } increment_count echo 函数外部 count$count # 输出 0符合预期这是一个看起来非常基础、但实际项目里反复出现的问题。我在维护一个超过 800 行的部署脚本时就吃过这个亏。脚本里有一堆类似retry0、found0的临时变量某天加了一个新函数函数内部用了retry做局部循环计数结果外层等待服务启动的重试逻辑被重置导致脚本一边无限重试、一边判断条件永远不满足。最终排查了接近一个小时罪魁祸首就是一行没有加local的变量声明。所以我把这条当做铁律函数内部任何临时变量一律 local哪怕只是临时用的计数器。只有在函数里明确需要向外传递状态时才刻意使用全局变量并加前缀后缀区分比如global_xxx。2. 数组Shell 世界里组织批量数据的主心骨2.1 创建数组的几种姿势按场景选择Shell 数组的创建方式非常灵活最常见的四种场景在工程里基本覆盖了 90% 的需求。场景一直接列举元素。app_list(nginx mysql redis) echo ${app_list[0]} # 输出 nginx echo ${app_list[1]} # 输出 mysql场景二按索引逐个赋值适合下标不连续的情况。errcode[100]参数错误 errcode[101]权限不足 errcode[301]资源不存在场景三用命令输出批量生成。# 当前目录下所有后缀为 .sh 的文件 script_files(*.sh) # 纯数字序列 pids($(pgrep -u root | head -10))场景四从文件按行读取。这里有一个很重要的坑直接$(cat file)会以空格做分隔符文件中每行如果有空格就会被拆成多个元素。正确做法是mapfile或read -a。# 按行读取保留每行内容包含空格 mapfile -t lines config.txt # 或者用 while read i0 while IFS read -r line; do lines[i]$line i$((i 1)) done config.txtIFS禁止了 read 的默认行为把行首行尾空白吃掉-r禁止反斜杠转义这是我处理配置文件的固定搭配。关于这块我在第 4 章的常见问题里还会再展开。2.2 访问、遍历与增删改查一次性讲清楚访问数组元素的语法是${arr[索引]}注意花括号不能省否则 Shell 解析优先级会产生混乱。数组相关的内置表达式汇总如下。app_list(nginx mysql redis) echo ${app_list[2]} # 取第 3 个元素redis echo ${#app_list[]} # 元素个数3 echo ${!app_list[]} # 所有索引序列0 1 2 echo ${#app_list[0]} # 第 1 个元素的字符长度5遍历方式有三种各有适用场景。第一种最常用的 for 元素遍历适合只关心值不关心下标的情况。for app in ${app_list[]}; do echo 应用: $app done这里${app_list[]}必须带双引号。如果不带引号数组里某个元素含空格的话会被 Shell 重新做词拆分一个元素分裂成多个遍历结果就错了。第二种下标遍历适合需要同时拿到下标和值的场景。for index in ${!app_list[]}; do echo 下标 $index 对应的值是 ${app_list[$index]} done第三种while 加计数器适合在遍历过程中需要根据条件中断或跳转的场景。i0 while (( i ${#app_list[]} )); do echo ${app_list[$i]} i$((i 1)) done增删改查操作里增和查最常用改的本质也是查。追加元素的标准方式是arr(新元素)。删除元素用 unset这里有一个需要特别小心的语法细节。app_list(nginx mysql redis) unset app_list[1] # 删除下标为 1 的元素 mysql echo ${app_list[]} # 输出 nginx redisunset 后面跟的是数组名和下标数组名前面绝对不能加$符号下标建议用单引号包裹。照抄没问题但如果写成unset app_list[1]不加引号在某些 shellcheck 检查中会被标记为风险写法。删除后的数组会变成索引不连续的状态原来 index0 的 nginx 和 index2 的 redis 变成了相邻元素${#app_list[]}也变成了 2。这在后续基于下标的遍历中极易踩坑所以删除元素后如果还有遍历需求重新组合数组是更稳妥的做法app_list( ${app_list[]} ) # 重建连续索引切片操作在按批次处理数组时非常实用支持从指定位置截取子数组。# 从下标 1 开始取 2 个元素 sub_arr(${app_list[]:1:2})2.3 关联数组像查字典一样管理数据普通数组用整数做索引关联数组则用字符串做索引。很多从 Python 或 Go 转过来的开发者看到 Shell 竟然支持这个特性时都会露出惊喜的表情。关联数组在 bash 4.0 及以上版本中可用macOS 自带的老版本 bash 可能需要手动升级。使用关联数组前必须先声明declare -Adeclare -A process_status process_status([nginx]running [mysql]stopped [redis]running) process_status[nginx]reloading echo ${process_status[nginx]} # 输出 reloading echo ${!process_status[]} # 输出所有 keynginx mysql redis echo ${process_status[]} # 输出所有 value遍历关联数组时标准写法是for proc in ${!process_status[]}; do echo 进程 $proc 状态: ${process_status[$proc]} done关联数组在配置文件解析、状态映射、统计计数这几个场景下极其顺手。例如统计日志中各 IP 出现次数declare -A ip_count while read -r ip; do (( ip_count[$ip] )) done access.log for ip in ${!ip_count[]}; do echo $ip 出现次数: ${ip_count[$ip]} done注意(( ip_count[$ip] ))这种自增写法是关联数组结合算术表达式的一个冷门用法bash 支持但文档很少提。实际跑过多次性能比逐个判断快不少。唯一要求是 bash 版本不能太老。关联数组和普通数组的困惑点在于声明。如果忘了declare -AShell 会把它当普通数组处理给字符串下标会被强制解释成算术表达式arr[nginx]中的 nginx 会被当作变量参与数学计算通常计算结果是 0于是所有数据都塞到了同一个下标 0 上数据互相覆盖排错时非常诡异。3. 函数与数组的组合实战把两个特性拧成一股绳3.1 一个通用日志函数的封装过程组合使用的第一个经典场景是日志封装。一个项目里往往有 INFO、WARN、ERROR 三种级别如果每处都用裸 echo 输出日志混乱且无法统一控制开关。把日志逻辑收敛成函数配合定时任务和重定向一套标准的日志体系就出来了。#!/bin/bash LOG_PATH/var/log/auto_deploy.log log_write() { local level$1 shift local timestamp timestamp$(date %Y-%m-%d %H:%M:%S) echo [$timestamp] [$level] $* ${LOG_PATH} } log_info() { log_write INFO $ } log_warn() { log_write WARN $ } log_error() { log_write ERROR $ }这个封装看起来简单但有两个设计值得品味。第一log_write接收 level 之后的参数统一通过$透传调用层log_info 某服务启动失败和log_info 磁盘空间不足 请检查都能正确传递全部参数完全无感。这是函数组合的核心妙处——外层函数可以按语义组织参数内层函数统一处理格式化各层各司其职。第二log_write中 shift 把 level 消费掉以后剩余参数就能以$*的形式完整输出格式是[2025-01-10 15:04:05] [ERROR] 部署超时请手动检查。实际项目里我会再加一层LOG_ENABLED全局开关埋点是否输出一行控制。这是从裸 echo 升级到结构化日志的第 1 步后面接 json 格式化、按天轮转都不难。3.2 服务器批量状态巡检数组存目标函数管逻辑第二个经典场景是批量机器巡检。把目标服务器 IP 存进数组用函数分装检测逻辑主流程只负责遍历。整个结构可以拆成数据处理层和业务逻辑层清晰度高很多。#!/bin/bash SERVERS( 10.0.0.11 10.0.0.12 10.0.0.13 ) check_network() { local ip$1 if ping -c 2 -W 2 $ip /dev/null; then log_info $ip 网络连通 return 0 fi log_error $ip 网络不可达 return 1 } check_ssh_port() { local ip$1 local port22 if nc -zw 3 ${ip} ${port} /dev/null; then log_info $ip SSH 端口正常 return 0 fi log_error $ip SSH 端口不通 return 1 } for server in ${SERVERS[]}; do check_network $server check_ssh_port $server done核心思路在于服务器列表写在数组里想加机器就加一行监控逻辑拆进函数里要新增检测项就再加一个函数。数据结构和逻辑分离之后脚本从“读一行删一行”进化成了“改一行加一行”。这个脚本再往上扩展可以做并发检测。并发检测是函数与数组组合的进阶玩法。bash 里可以用把函数放到子进程里跑然后用wait统一回收for server in ${SERVERS[]}; do check_network $server done wait实测 10 台机器并发检测整体耗时能从串行的 30 秒压到 6 秒左右。但并发带来的问题是输出顺序变乱函数内部所有日志必须通过统一的log_write写入文件利用写入原子性避免内容交织。这是我在实际项目中用过的优化手段小规模并发不用去引入额外工具。3.3 函数库文件从单脚本走向可复用的工具集脚本写到一定程度你会发现多个脚本都需要同一个log_info、同一个check_cmd_exists、同一个parse_yaml。这时候可以抽出独立的函数库 bash 文件用 source 或.引入。# lib/common.sh log_info() { echo [$(date %Y-%m-%d %H:%M:%S)] [INFO] $* } check_cmd_exists() { command -v $1 /dev/null 21 } is_root() { [ $(id -u) -eq 0 ] }#!/bin/bash source $(dirname $0)/lib/common.sh if ! is_root; then log_info 请以 root 权限运行 exit 1 fi if ! check_cmd_exists curl; then log_error 依赖工具 curl 不存在 exit 1 fi这里有一个值得注意的点source时建议配合dirname $0或BASH_SOURCE[0]定位脚本真实路径否则从其他目录以绝对路径或相对路径执行时source 的目标文件可能找不到。SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) /dev/null pwd) source ${SCRIPT_DIR}/lib/common.shBASH_SOURCE[0]是 bash 提供的能精确拿到当前文件路径即使这个文件本身是被 source 进来的也能正确定位到它的真实位置。这个模式在做函数库、组件化脚本时价值极高。我用这个模式把自己的重复脚本从几十份压缩到了四种少量核心脚本加一个公共函数库维护成本直线下降。可以理解成公共函数的集合类似 Java 世界里把工具方法抽成 util 类只是 Shell 语言对应的是“文件 source”。4. 常见问题与排查技巧实录4.1 函数相关的高频坑位汇总函数相关的坑我按实际遇到的频率排个序。第一坑函数命名与系统命令重名。曾经有个同事写了一个test() { ... }结果所有调用test命令的语句全部走进了这个函数test -f /etc/passwd直接打印函数内 echo 的内容。函数名避免使用test、read、exit、kill这类高频系统命令名是踩坑之后最深刻的教训。command内建关键字可以强制调用系统命令但不要依赖这种方式兜底。第二坑返回值超过 255。有人用函数内循环计算一个大数字并想通过 return 传出来结果怎么拿都拿不到正确结果。return 本质只保留 8 位任何大于 255 的数字都会对 256 取模。想返回大于 255 的数值必须走 echo 捕获或者输出到变量。calc_factorial() { local n$1 local result1 for ((i 2; i n; i)); do result$((result * i)) done echo $result } # 而不是 return $result第三坑函数内用到外部变量修改后影响外部。解决方案已经在 1.4 节写清楚了就一句话内部临时变量全加 local。第四坑函数内部的 set -e 行为。如果一个函数被 set -e 约束而函数体里有某些命令返回非 0 状态没有显式判断脚本会直接终止。这种问题经常出现在条件测试[ -f file ]返回假时脚本整体退出。处理方法是把这类可能失败的判断放到 if 条件中或者临时加|| true但这些都只能缓解真正要理解的是set -e的粒度规则。简单来讲if 条件、while 条件、以及用串联的命令都不受 set -e 直接终止影响。4.2 数组相关的高频坑位汇总数组实践中最大的坑集中在“空数组”和“元素含空格”上。空数组的判断极容易写错。很多人写if [ -n ${arr[]} ]判断数组非空但空数组展开后是字符串长度为零实际行为正确而if [ -n $arr ]这类写法判断的是整个数组被当成普通字符串展开后的行为此时空数组为非空数组会丢失部分内容。最稳妥的判断是判断元素个数if (( ${#arr[]} 0 )); then echo 数组为空 fi再一个高频坑set -u与空数组的兼容问题。打开set -u把未定义变量当成错误后部分 bash 版本对空数组${arr[]}展开会报 unbound variable脚本直接异常退出。这个坑在 bash 4.4 之前很常见之后版本修复了。实际工作里如果你既要set -u又要遍历可能为空的数组建议先用set u包住遍历set u for item in ${arr[]}; do echo $item done set -u不过更好的做法是直接升级 bash 并做数组非空判断后再遍历。元素含空格的坑前面提过多次这里给出一个完整示例说明为什么数组优于普通字符串。一个目录下有my file.sh和hello.sh两个文件如果用for f in $(ls *.sh)遍历循环会输出四个“元素”因为my file.sh被空格拆成了my和file.sh用数组和${arr[]}则无此问题。files(*.sh) for f in ${files[]}; do echo 处理文件: $f done4.3 一套高效的调试方法附脚本自检技巧函数多了、数组复杂了以后调试不能靠肉眼一行行看。最常用的调试手段是bash -x它会把每个执行的命令和参数展开打印出来。bash -x ./test.sh如果想只调试某一段代码可以在脚本内部设置跟踪开关set -x # 这里是要跟踪的区域 set x为了让跟踪输出带上行号可以调整PS4export PS4$LINENO: 这样每行跟踪输出的前缀会带上当前行号定位错误行号非常方便。另外一个非常实用的技巧是 shellcheck。它是一款静态检查工具能捕捉到大量 Shell 脚本常见错误包括漏加双引号、函数外使用 local、数组 unset 语法不当等。在写复杂函数之前先过一遍 shellcheck会减少很多低级错误。单独测试函数也是一个好办法。把函数定义保存到myfuncs.sh在交互式 shell 里 source 进来然后可以像调用普通命令一样逐个函数反复验证不需要每次把整个脚本跑完排错效率更高。source myfuncs.sh log_info hello check_cmd_exists grep5. 一点提效心得写给正在把 Shell 当生产工具的人写到最后我想聊聊一个很容易被忽略的点Shell 脚本的工程化。很多人把 Shell 脚本当成一次性工具今天写一个明天写一个从来不去考虑复用性。但当我开始用函数封装通用逻辑、用数组组织批量数据、用 source 管理函数库之后自己的脚本开发速度发生了非常明显的变化。个人经验是函数命名时坚持“动词 宾语”的结构比如check_network、parse_version、write_log因为一段脚本里函数超过 10 个以后看名字就能猜行为会大大降低阅读成本。数组变量名全部用复数形式servers、files、user_list暗示这是一个集合。函数库文件按用途分块log.sh统一管理日志check.sh统一管理环境依赖检查time.sh统一管理时间格式转化。这样的模块划分在维护 10 到 20 个脚本时已经非常够用。另外一个心得是函数和数组用熟之后你会开始习惯性地用数据驱动逻辑。需要处理的机器列表放数组扩展时只管往数组里追加元素需要实现的判断逻辑放进函数扩展时只管新增函数。代码重复率降低可读性变好出问题的概率自然下降。这篇文章覆盖了函数定义、参数、作用域、返回值数组创建、遍历、增删改查、关联数组以及函数与数组的组合实战。一次性展开的内容有点多建议你照着代码敲一遍把日志函数、批量巡检脚本跑通后续自己写脚本时就能自然地把这两个特性用起来。
阅读完成 · 觉得有帮助?
咨询建站