说句实在话,Shell学到函数这一章,很多人会觉得"不就是把一段命令包起来嘛,至于专门写一篇吗"。但如果你写过超过200行的部署脚本、维护过几十个站点的nginx配置、或者被半夜三点的告警电话叫起来去重启服务,就会明白:函数不是语法糖,而是把脚本从"能跑就行"推向"能维护、敢复用"的关键分水岭。
这是Linux Shell基础系列的第四篇,前几篇我们聊了变量、判断、循环,现在到了函数。这篇我会把函数的核心语法掰开揉碎讲清楚,再结合nginx管理的真实业务场景,带你写一套可直接落地的函数库——包括nginx启停、配置检查、优雅重载、日志切割、站点健康检查和配置解析。认真读完,你以后再写Shell脚本,结构感和掌控力会完全不一样。
1. 为什么需要函数:脚本从"能跑"到"能维护"的分水岭
1.1 没有函数的脚本是什么样子
我先描述一个真实场景,你肯定见过类似的脚本。假设你要在10台服务器上批量部署nginx,第一版脚本可能长这样:
#!/bin/bash for host in web01 web02 web03; do ssh "$host" "yum install -y nginx" ssh "$host" "cp /tmp/nginx.conf /etc/nginx/nginx.conf" ssh "$host" "nginx -t" ssh "$host" "systemctl start nginx" ssh "$host" "systemctl enable nginx" done看起来没问题。可是当需求变成"如果配置检查失败,就不要启动服务,还要发告警",你开始往循环里塞if判断。又过了两周,需求变成"只需要对web02执行重载操作",你发现那一段启停逻辑没法直接复用,只能复制粘贴再改参数。再往后,脚本里出现了第三份、第四份几乎相同的操作片段,只是服务器列表不同、执行动作不同。
这时候你会意识到,脚本的体积不是最大的问题,真正让人头大的是改一处要同步改好几处,遗漏任何一个副本,线上就会出问题。这就是没有模块化的代价。
1.2 函数能有针对性解决什么问题
函数解决的核心矛盾是"一次定义、多处使用"。具体来说有四件事是函数最擅长的:
第一,把重复动作收敛成一个命名单元。比如nginx的配置检查、重载、日志切割,这些操作在多个脚本里都会出现,收敛成函数后,所有脚本都去调用同一个入口,逻辑变更时只需要改函数库一处。
第二,把流程的输入输出显式化。一个函数接收什么参数、返回什么结果、是否依赖全局变量,这些边界一旦清晰,脚本的行为就变得可预期。你不需要把整段脚本读完才知道它干了什么,看到函数名和参数就能推测个大概。
第三,给复杂判断一个收容所。比如"nginx是否在运行""配置是否合法""站点是否健康",这类带状态的检查逻辑写起来很绕,放在函数里可以让调用处保持清爽,代码产生的"噪音"被隔离在函数内部。
第四,为后续的代码组织铺路。函数多了,自然会把它们集中到一个文件里,配合source语法变成自己的工具库。到了这一步,你的Shell水平已经从"写脚本"升级成了"构建工具"。
理解了为什么要用,再来看怎么用,你才会知道每一个语法细节背后到底在解决什么问题。
2. Shell函数核心语法:定义、调用、退出码与输出捕获
2.1 三种定义方式和调用时机的区别
Shell函数的定义方式有两种主流写法,加上一种兼容写法,实际项目中你会遇到:
# 写法一:最常见的POSIX风格 nginx_check() { nginx -t } # 写法二:bash的function关键字风格,括号可省略 function nginx_check { nginx -t } # 写法三:function关键字加括号,三者兼备的老写法,很少见了 function nginx_check() { nginx -t }我的建议是在新脚本里统一用写法一。原因很简单:可读性强,而且兼容sh、dash等其它shell,迁移成本低。写法二虽然是的bash特性,但使用者容易在带括号和不带括号之间搞混。
调用方法有一个很多人会踩的坑:调用函数时不要带括号。shell里函数调用就是裸写函数名,跟执行命令一样:
nginx_check如果你写成nginx_check(),那不是调用,而是在重新定义这个函数。我曾经见过同事在脚本里反复写foo()想执行函数,结果每次都在重新定义它,函数体永远不会执行。这个问题在面试和初学者中太常见了,先记下来。
另一个关键规则是函数必须先定义、后调用。这是一个顺序约束,因为函数的定义和执行在shell里是两条指令:定义指令把那一段代码保存到内存里,执行指令才真正运行它。假如脚本这样写:
start_nginx start_nginx() { systemctl start nginx }调用发生在定义之前,shell执行第一行时会报command not found,函数体根本没有被加载。解决方法是把函数定义放在脚本前部,或者把定义文件用source引入。
2.2 return只负责退出码,别用它返回字符串
新手最容易犯的错误,就是试图用return返回一个计算结果,比如这样:
get_nginx_pid() { pid=$(cat /run/nginx.pid) return "$pid" # 错误示范 }如果你真这么写,运气好的时候能拿到一个数字,运气不好直接报错。原因在于return的取值区间被限制在0到255之间的整数。这是它的第一层限制。更本质的问题是,return在语义上是用来设置函数退出码的,也就是$?的值,而不是用来给调用方吐数据的。函数体里每执行一条命令都会更新$?,但只有函数结尾处的return才决定这个函数的最终退出码。
正确做法是把return当成"成功或失败"的信号来用。比如:
nginx_check_config() { sudo nginx -t return $? }这里不写return也行,因为函数退出码默认就是函数体里最后一条命令的退出码。显式写上return $?是为了让读代码的人一眼看出"这个函数的返回值就是nginx -t的结果"。
2.3 用echo捕获函数输出:Shell函数返回值的真相
那如果确实想让函数"返回"一个字符串或数字,该怎么办?答案是用echo把值打到标准输出,在调用处用命令替换捕获它。这是Shell开发者心照不宣的惯例。
get_nginx_pid() { if [ -f /run/nginx.pid ]; then cat /run/nginx.pid else echo "unknown" fi } # 调用处捕获输出 pid=$(get_nginx_pid)$(...)会捕获函数的标准输出,把它当作一个整体替换到当前语句中。这里的原理和命令替换没有区别,因为shell在调用函数时,函数体内部所有echo的内容都进了标准输出管道。
需要注意的是,一个函数既想通过echo返回数据,又想通过return返回退出码,两者并不冲突。你可以这样设计:
check_nginx_status() { if kill -0 "$(cat /run/nginx.pid 2>/dev/null)" 2>/dev/null; then echo "running" return 0 else echo "stopped" return 1 fi }调用方同时拿两份信息:status=$(check_nginx_status)拿到状态文本,echo $?拿到退出码。在脚本里可以把退出码用于if判断,把输出文本用于日志记录,分工明确。不过在真实项目中,我更倾向于让一个函数只负责一种输出方式,要么专职返回数据,要么专职返回状态码,不要两个混用。混用意味着调用方必须小心翼翼地处理"第几行输出是数据、第几行是日志",这种心智负担会大大增加脚本的维护成本。
3. 参数、作用域与数组传递:最容易翻车的三个细节
3.1 local局部变量:不加local就是全局污染
函数内部使用变量时,默认情况下操作的是全局变量。这意味着函数执行期间对变量的修改,函数执行完之后依然有效。看一个典型的翻车现场:
init_log_path() { log_dir="/var/log/nginx" log_file="$log_dir/access.log" } init_log_path echo "$log_dir" # 输出 /var/log/nginx这个例子里log_dir和log_file都不是函数私有的,调用完函数后,它们变成了脚本里的全局变量。如果脚本别处也有个log_dir,就会被这个函数悄悄覆盖掉,排查起来极其隐蔽。
解决办法是在函数内声明变量时一律加local关键字:
init_log_path() { local log_dir="/var/log/nginx" local log_file="$log_dir/access.log" # 注意:log_file 引用 log_dir 的时机 echo "$log_file" }加了local之后,这两个变量只在函数内部有效,函数结束就销毁。这是一条铁律:函数体内所有临时变量必须加local。不要有例外,哪怕你确信这个变量名不会跟别人冲突,也要加上。养成这个习惯之后,函数的安全性会大幅提升,至少在并发或者反复调用时不会因为变量污染出现诡异问题。
有个值得一提的细节,local变量是支持引用外部变量进行初始化的,比如模板场景下可以从外部传入,也可以先在函数内local声明,再覆盖赋值。只要记住一点:声明和定义分开时,local x之后x的值变成空字符串,如果外部有同名变量,也不要再依赖它了。
3.2 把数组传给函数的正确姿势
函数参数里传递数组是Shell里公认的痛点。数组不能直接作为一个参数整体传入,因为展开数组元素时它们会被拆成多个独立的参数。下面是我演练过的一种方式,把它记下来,能解决绝大多数场景。
方式一:利用"数组展开成多参数"的特性,在函数内用$@重新收集:
iterate_sites() { local site for site in "$@"; do echo "[$site]" done } sites=(a.com b.com c.com) iterate_sites "${sites[@]}"这个写法是变通技巧,把数组元素逐个展开成位置参数,函数内部再统一处理。但它有个局限:如果函数同时还需要接收其它参数,你就得约定好哪个参数是数组、哪个是普通参数,很容易乱。
方式二:bash版本4.3以上,可以用全局引用做"数组引用传递"。这是我在生产脚本中比较认可的方式:
iterate_sites() { local -n site_array=$1 local site for site in "${site_array[@]}"; do echo "[$site]" done } sites=(a.com b.com c.com) iterate_sites sites注意这里是iterate_sites sites,传的是数组名,然后在函数里local -n site_array=$1建立了一个引用。这比展开参数的方式清晰很多,无论数组多大,都不会产生复制开销。但切记这个特性需要bash 4.3以上,CentOS 7自带的bash 4.2是不支持的。为了兼容旧版本,生产环境中我用得更多的反而是第一种展开方式,然后在函数名上做好注释说明参数约定。
3.3 函数嵌套与递归的注意事项
Shell允许函数嵌套定义,听起来很灵活,但也容易埋雷。嵌套定义的意义是内层函数只在外层函数调用时才会被定义,所以它天然是一种"按需加载"手段,可以模拟私有函数的局部性。
不过嵌套函数要注意变量作用域叠加的问题。内层函数可以访问外层函数的local变量,这个访问链是动态的,不是静态的。如果外层函数多次调用,内层函数的定义会像临时文件一样反复覆盖,性能上虽然可忽略,但读代码的人会有点疑惑。我更推荐把嵌套降到最低,非必要不嵌套,只用普通函数加命名约定比如_helper_前缀来暗示内部函数。
递归在Shell里是真实存在的需求,比如遍历目录树收集nginx配置时,标准的目录递归就可以写成函数自我调用。递归需要注意退出条件,否则shell函数会无限嵌套调用,最后栈溢出退出。我写递归函数之前会先在开头写上退出条件判断,这几乎是我自己的固定动作了。
4. nginx函数实战一:用函数库统一管理nginx生命周期
4.1 先搭一个nginx管理函数库
理解了基础语法,接下来进入真正的实战环节。我这里设计的函数库是按照"一件小事一个函数"的原则拆分的,每个函数只做一件事,函数名直接描述动作。把这个函数库保存到/etc/profile.d/nginx_lib.sh,或者随脚本source使用都可以。
#!/bin/bash # 文件名: nginx_lib.sh # 用途: nginx日常管理函数库 # 需要根据自身环境调整的配置项 NGINX_BIN="/usr/sbin/nginx" NGINX_CONF="/etc/nginx/nginx.conf" NGINX_PID="/run/nginx.pid" # 检查nginx配置语法,合法返回0,非法返回1 nginx_check_config() { sudo "$NGINX_BIN" -t -c "$NGINX_CONF" return $? } # 判断nginx是否在运行 nginx_is_running() { if [ -f "$NGINX_PID" ]; then local pid pid=$(cat "$NGINX_PID") if kill -0 "$pid" 2>/dev/null; then return 0 fi fi return 1 } # 启动nginx,如果已运行则幂等返回 nginx_start() { if nginx_is_running; then echo "nginx已经在运行 (PID: $(cat "$NGINX_PID"))" return 0 fi sudo "$NGINX_BIN" -c "$NGINX_CONF" if [ $? -eq 0 ]; then echo "nginx启动成功" else echo "nginx启动失败,请检查配置和错误日志" return 1 fi } # 停止nginx nginx_stop() { if ! nginx_is_running; then echo "nginx当前未在运行" return 0 fi sudo "$NGINX_BIN" -s stop echo "nginx已停止" } # 优雅重载nginx,重载前先检查配置 nginx_reload() { if ! nginx_check_config; then echo "配置检查未通过,已取消重载" >&2 return 1 fi sudo "$NGINX_BIN" -s reload if [ $? -eq 0 ]; then echo "nginx配置已优雅重载" else echo "重载执行失败" return 1 fi }这个函数库的核心设计思想,是把"检查配置"和"执行动作"绑定起来。nginx_reload在真正执行reload之前先调用nginx_check_config,用return值判断是否继续。如果你不这样做,很可能出现一个场景:团队里有人改错了配置,然后直接执行reload,nginx就会拒绝加载,更麻烦的是如果用的是老式服务管理方式,reload失败会让旧配置继续运行,但新配置想要生效的诉求被静默吞掉。把配置检查作为前置条件,相当于给所有变更操作装了一道安全门。
4.2 日志切割与重载联动
线上nginx跑久了,access.log会变得非常大。按天切割日志是运维的日常操作,手动执行的命令很简单,但写成函数后会规范很多:
# 按天切割指定日志,切割完联动重载让nginx重新打开日志文件 nginx_log_cut() { local log_dir=$1 local log_name=$2 local yesterday yesterday=$(date -d "yesterday" +%Y%m%d) if [ ! -f "$log_dir/$log_name.log" ]; then echo "日志文件 $log_dir/$log_name.log 不存在" >&2 return 1 fi mv "$log_dir/$log_name.log" "$log_dir/${log_name}_${yesterday}.log" # 重载让nginx重新打开原路径日志文件 if nginx_reload; then echo "完成切割: ${log_name}_${yesterday}.log" else echo "切割完成但nginx重载失败,请手动确认" >&2 return 1 fi }日志切割动作本身只涉及mv,但关键点在mv之后必须让nginx重新打开日志文件。nginx在启动时会把日志路径定向到文件描述符,mv之后,文件名变了,但nginx的句柄还指向旧文件。如果不在mv后触发reload,那后续所有日志会继续写进已经被mv走的旧文件里,看起来日志文件不增长,实际全写进了改名前的文件描述符,非常容易漏掉。这是nginx_reload被设计成"先检查配置再reload"的第二个价值所在——切割日志时实际上并不会读取新配置,只是让master进程重新打开日志句柄,所以即使配置文件没变,重载也是安全的。
5. nginx函数实战二:批量站点健康检查与配置解析
5.1 解析nginx配置提取所有server_name
很多场景下需要拿到nginx上部署了哪些站点:批量检查证书到期、批量测试站点连通性、生成站点列表文档,都依赖于先解析配置。人工去翻http.block文件效率太低了,用函数自动提取是明智做法。
# 从nginx配置目录中提取全部server_name get_all_server_names() { local conf_dir=$1 grep -hE "^\s*server_name" "$conf_dir"/*.conf 2>/dev/null \ | awk '{print $2}' \ | sed 's/;//' \ | sort -u }这个函数在处理标准配置时可以一次性拿到一个干净的去重域名列表。需要说明的是,真实nginx配置里的server_name有几种变体,如果配置里出现了正则或者通配符,比如~^(www\.)?example\.com$,上面的awk取第2列就不准确了。生产环境更稳健的写法是根据server块边界来解析,不过用grep加正则做第一版落地是完全够用的,先跑起来,再逐步加健壮性。
5.2 全站健康检查与结果对齐输出
拿到站点列表后,批量发起HTTP请求并收集返回码,是判断站点存活状态的常用手段。我把检查逻辑拆成两个函数:一个负责检查单个站点,一个负责遍历全量站点并统一打印结果。
# 检查单个站点健康状态,期望状态码默认是200 check_site_health() { local site=$1 local expect_code=${2:-200} local code code=$(curl -o /dev/null -s -w "%{http_code}" \ --connect-timeout 5 --max-time 10 \ "http://$site/") if [ "$code" = "$expect_code" ]; then echo "OK" return 0 else echo "FAIL(code=$code)" return 1 fi } # 遍历全量站点,逐行输出状态 check_all_sites() { local conf_dir=$1 local sites local site sites=$(get_all_server_names "$conf_dir") if [ -z "$sites" ]; then echo "未从 $conf_dir 下解析到任何server_name" return 1 fi echo "站点检查结果:" while IFS= read -r site; do health=$(check_site_health "$site") printf "%-30s %s\n" "$site" "$health" done <<< "$sites" }这里有一个容易被忽略的细节是while read与进程替换的搭配。我把get_all_server_names的输出重定向到while循环里,而不是用管道,是为了避免进入subshell后变量丢失的问题。如果你写get_all_server_names | while read,循环体里统计的计数器、累积的数组,在循环结束之后等于没有,这在bash中是个经典陷阱。用<<< "$sites"这种方式,while循环在原始shell环境里执行,所有变量修改都能保留下来。
5.3 把健康检查结果集成到告警脚本
光输出表格还不够,真正的价值在于把检查结果变成一个开关,驱动后续的告警或自动处理流程。我在实际脚本里是这样组合用的:
# 告警或重启前总开关 check_and_notify() { local conf_dir=$1 local site local failed=0 sites=$(get_all_server_names "$conf_dir") while IFS= read -r site; do if ! health=$(check_site_health "$site"); then failed=$((failed + 1)) echo "[需要关注] $site 异常: $health" fi done <<< "$sites" if [ "$failed" -eq 0 ]; then echo "所有站点均健康" return 0 else echo "共 $failed 个站点异常" return 1 fi }这套函数库组合起来之后,你可以在cron里直接写上一行调度任务,每天跑一次站点完整性检查;也可以在变更完nginx配置之后,立即调用check_and_notify来验证自己没有把线上站点搞挂。函数的返回值恰好作为自动化任务的退出码,配合监控系统就能把状态抛出去。
6. 函数设计复盘:我在实战中踩过的坑与沉淀下来的原则
6.1 三个典型的坑
第一个坑是我曾经在函数里直接用systemctl restart nginx,以为重启就能让配置生效。后来发现问题时,是因为重启这一动作会先停掉旧进程再启动新进程,如果新配置有问题,nginx会直接起不来,服务中断时间远大于reload。所以后来我把函数库里的重启函数单独限制在"配置变更引入的必须重启"场景,常规变更一律走nginx_reload,并在reload函数内部固定串上nginx_check_config。函数设计本质上是在约束人的操作习惯。
第二个坑是环境变量缺失的问题。如果把函数库放到cron任务里调用crontab执行shell脚本,cron的环境变量和交互式shell不一样,PATH被精简过。函数里如果直接写nginx、curl,cron下运行时可能报command not found。我在函数库顶部全部使用了绝对路径NGINX_BIN、CURL_BIN,并且允许通过环境变量覆盖默认值,这样既保证了非交互环境可用,又保留了灵活性。
第三个坑是返回值滥用。早年我在一个脚本里用函数返回"字符串",然后同时依赖函数退出码做判断。结果有一次函数内部多执行了一条echo调试语句,那行输出混进了命令替换结果,整个变量内容都变了。排查了半小时才意识到是调试语句的副作用。从那以后,我对函数的约定非常清晰:要数据就只echo数据,要状态就只return状态,绝不混用。
6.2 我对函数设计的三条约束
经过这些教训,我给自己定了三条硬性约束,写每一组函数库都会遵守。
第一条,函数尽量短小,只做一件事。一个函数超过15行,我就会下意识地考虑拆分。函数名几乎直接替代注释,让人不看函数体也能猜出它的意图。如果函数体里出现了第二层逻辑,比如既检查配置又发送告警又重启服务,我会果断拆成三个函数再组合。
第二条,所有临时变量显式加local。这一条在前面已经强调过,这里再次强调是因为它确实是Shell函数最常见的污染源。全局变量只在函数库入口处通过可覆盖的默认值定义,函数体内的中间变量一律local,绝不例外。
第三条,函数返回值与输出契约要在函数名或注释里写明。比如check_site_health返回0表示正常、1表示异常,输出一行包含状态码文本。调用方只需要依赖契约,不需要关心内部实现。这样别人接手你的脚本,或者你三个月后回看自己的代码,都能快速定位问题。
6.3 函数库的组织方式与复用
函数多了以后,把它们放到独立文件里,在需要的主脚本顶部用source引入。比如我做了一个/opt/scripts/lib/nginx_lib.sh,然后在业务脚本里这样引入:
#!/bin/bash source /opt/scripts/lib/nginx_lib.sh # 业务逻辑 nginx_check_config || exit 1 nginx_reload check_all_sites /etc/nginx/conf.dsource和直接执行脚本的区别是,source会在当前shell进程内执行函数定义脚本,函数定义完成后留在当前shell内存里,后续语句可以直接调用。如果直接执行bash nginx_lib.sh,函数定义会留在子shell里,子shell退出后就没了,主脚本完全调不到。这个细节是我见过许多团队踩过的坑:函数写好了,执行了,但调用的地方还是报command not found。
到这里函数基础与nginx实战的结合就算是完整走了一遍。从定义、调用、退出码、输出捕获,到proc函数作用域与数组传递,再到围绕nginx生命周期和站点健康检查的完整函数库。这套内容不仅是一份语法手册,更是一套能让人少写一半重复代码、减少线上事故的实战方法论。你先在自己测试环境里把函数库跑起来,把cron调度和告警串一串,很快就能感受到"一次定义、处处复用"带来的底气。