CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
函数与脚本结构 脚本骨架、参数、source vs bash。
函数与脚本结构
这一课你将能做到
定义和调用带参数的函数,说清 return 与 echo 两种「返回数据」方式的区别
用 local、source、set -euo pipefail 搭出可维护的生产级脚本骨架
写一个包含日志函数、错误处理、main 入口的部署/检查类脚本,结构对标公司运维脚本规范
为什么要学
脚本一超过 50 行,没有函数就是灾难:同一段「检查进程在不在」的逻辑复制五遍,改一处漏四处。函数让脚本获得和 Java 一样的能力——封装、复用、单一职责。面试官看你的脚本,先看有没有 set -euo pipefail、有没有 main 函数、错误怎么处理,这三点直接暴露工程素养,比背多少命令都有说服力。
人话:函数就像 Java 的方法:起个名字、收点参数、干完活给个交代。区别是 Shell 的 return 只能返 0-255 当状态码,要「返回数据」得靠 echo 输出 + 命令替换接住——这个差异是本课最重要的知识点。
先建立图像
生产级脚本的通用骨架(先看整体再拆解):
#!/bin/bash
─────────────────────────────
set -euo pipefail ← 第一层:全局安全带
─────────────────────────────
常量与默认配置 ← 第二层:可调参数集中放
─────────────────────────────
函数定义区 ← 第三层:log / die / 业务函数
log() 统一日志格式
die() 报错即退出
业务函数们
─────────────────────────────
main "$@" ← 第四层:唯一入口
对应到 Java 你已经熟悉的结构:
Shell Java 类比 函数 方法 $1 $2方法参数 return N 返回状态码(≈ 抛异常与否) echo 字符串 return 返回值 source xx.sh import / include set -euo pipefail 全局 try-catch + fail-fast
跟着做
步骤 1:定义与调用第一个函数
cat > func1.sh << 'EOF'
#!/bin/bash
# 定义:function 关键字可省略,推荐写法如下
greet() {
echo "你好, $1" # $1 是函数的第一个参数
}
greet "deploy" # 调用:函数名 + 参数,不是括号!
greet "reviewer"
EOF
bash func1.sh
注意:调用写 greet "deploy",写成 greet("deploy") 会报错——Shell 函数不是 C 系语法。
步骤 2:return 与 echo 的两种「返回」
cat > func2.sh << 'EOF'
#!/bin/bash
# 方式一:return 返回状态码(0 成功,非 0 失败)
file_exists() {
[[ -f "$1" ]]
# 函数最后一个命令的退出码自动成为 return 值,可省略 return
}
# 方式二:echo 输出数据,调用方用命令替换接住
today() {
date +%F
}
# 判断逻辑走 return
if file_exists /etc/hostname; then
echo "hostname 文件存在"
fi
# 取数据走 echo + $()
d=$(today)
echo "今天是 $d"
# 两者可结合:输出数据 + 退出码
get_error_count() {
grep -c ERROR /opt/myapp/logs/app.log 2>/dev/null || echo 0
}
echo "错误行数:$(get_error_count)"
EOF
bash func2.sh
背下来:return 传「成败」,echo 传「数据」 。想 return 一个字符串是做不到的,用 echo。
步骤 3:local 变量与作用域 cat > func3.sh << 'EOF'
#!/bin/bash
name="全局"
rename() {
local name="局部" # 不加 local 会污染全局!
echo "函数内:$name"
}
rename
echo "函数外:$name" # 仍是 全局
EOF
bash func3.sh
函数里可变的临时变量一律 local,这是脚本能维护的前提,和 Java 方法内局部变量同理。
步骤 4:log 与 die:所有脚本的标配 cat > lib.sh << 'EOF'
#!/bin/bash
log() {
echo "[$(date '+%F %T')] $*"
}
die() {
log "ERROR: $*" >&2 # 错误走 stderr
exit 1
}
EOF
$* 把所有参数拼成一句话,>&2 把输出重定向到标准错误——日志和错误分流,采集系统才能正确归类。
步骤 5:source 与脚本模块化 cat > main.sh << 'EOF'
#!/bin/bash
source ./lib.sh # 加载函数库,当前 Shell 直接获得 log/die
TARGET=${1:?用法:$0 <目录>}
[[ -d "$TARGET" ]] || die "目录不存在:$TARGET"
log "开始检查 $TARGET"
log "文件数:$(find "$TARGET" -type f | wc -l)"
EOF
mkdir -p ~/func-lab/demo && touch ~/func-lab/demo/a.txt
cd ~/func-lab && bash main.sh demo
# 对比:source 与 bash 的区别
cat > env-demo.sh << 'EOF'
export DEMO_VAR=hello
myfunc() { echo "我是函数"; }
EOF
bash env-demo.sh; echo "bash 方式后:$DEMO_VAR" # 空:子进程里执行
source env-demo.sh; echo "source 方式后:$DEMO_VAR" # hello:当前 Shell 执行
myfunc # 函数也留在当前 Shell
bash xx.sh 开子进程跑完即焚;source xx.sh 在当前 Shell 里执行,变量函数全留下 。.bashrc 之所以要 source,就是这个原理。
步骤 6:set -euo pipefail 与 main 收口 cat > deploy-demo.sh << 'EOF'
#!/bin/bash
set -euo pipefail
# -e:任何命令失败立即退出(不再默默往下跑)
# -u:引用未定义变量直接报错(拼错变量名当场暴露)
# -o pipefail:管道中任何一环失败,整条管道算失败
source ./lib.sh
APP_NAME="demo-app"
check() {
log "检查 $APP_NAME"
[[ -d "/opt/$APP_NAME" ]] || die "/opt/$APP_NAME 不存在"
}
backup() {
log "备份 $APP_NAME"
tar -czf "/tmp/$APP_NAME-$(date +%F).tar.gz" -C /opt "$APP_NAME"
}
main() {
check
backup
log "全部完成"
}
main "$@"
EOF
cd ~/func-lab && bash deploy-demo.sh demo-app && ls -lh /tmp/demo-app-*.tar.gz
main "$@" 把脚本参数转交给 main 函数,逻辑全在函数里,排查时一眼定位。这就是可以直接放进公司仓库的脚本形态。
讲清楚要点
函数先定义后调用 :bash 逐行解释执行,调用写在定义前会报 command not found。所以约定「函数区在上,main 在下」。
函数内部 $1 是函数自己的参数 ,与脚本参数 $1 是两个世界;$# 同理在函数内变成函数的参数个数。想拿脚本参数,在 main 里显式传递 main "$@"。
return 数值上限 255 :return 256 等于 return 0。所以「返回行数、大小」这类数据绝不能走 return,必须 echo + $()。
set -e 不是万能的 :条件判断里的命令失败(if、&&、|| 右侧)不触发退出;管道默认只看最后一环(pipefail 补上)。理解边界才不会被「它怎么没退出」困惑。
$? 检查要在命令后立刻做 :中间插入一条 echo,$? 就变成 echo 的退出码(0)。要么紧跟检查,要么直接依赖 set -e。
库脚本只放函数和常量 :库文件被 source,任何顶层执行语句都会在 source 时跑一遍。lib.sh 里出现 rm、启动服务这类语句是重大事故源。
你可能会懵的地方
$* 和 $@ 在函数里怎么用?
和脚本顶层规则一致:遍历用 "$@"(保参数边界),拼一句话用 "$*"。log 函数里 echo "$*" 就是把「多个参数拼成一条日志」。
函数名能不能和命令重名?
能,函数优先于外部命令。有人用 grep() { command grep --color=auto "$@"; } 给 grep 加默认参数,command 用于调原名避免无限递归。日常别滥用,坑队友。
如何让函数在每次开终端都可用?
写进 ~/.bashrc,或单独建 ~/.bash_functions 再在 .bashrc 里 source 它。改完 source ~/.bashrc 立即生效。
脚本退出码到底是什么在决定?
最后一条被执行命令的退出码;显式 exit N 可随时指定;main "$@" 收尾的脚本,main 里最后一条命令决定成败。crontab、CI 正是读这个码判断任务成败。
递归函数 Shell 支持吗?
支持,语法和 Java 方法递归一样。但 Shell 没有尾递归优化,深层递归性能差。需要递归遍历时优先考虑 find 或 tree 这类现成命令。
错法对照 错法 问题 正解 greet ("deploy") 调用带括号Shell 不认 C 系语法 greet "deploy"函数内不 local 直接赋值 污染全局变量,连环事故 临时变量一律 local return "some string"return 只接受 0-255 数据用 echo,调用方 $() 接 在 if 外写 [[ ... ]] 但依赖其失败 set -e 下脚本直接退出 判断逻辑放进 if / && / || 语境 lib.sh 顶层写了执行语句 source 时被意外执行 库文件只放函数、常量定义
卡点急救
command not found 调用函数时 :检查函数是否在调用点之前定义;是否拼写不一致;是否忘了 source 库文件。
unbound variable 报错 :这是 set -u 在保护你——引用了未定义变量。给变量补默认值 ${VAR:-},或修正拼写。上线脚本建议带着 -u 跑一遍测试。
脚本中途失败但看不出哪行 :加 PS4='+ $LINENO: ' set -x 调试模式,每条命令执行前打印行号;或临时 bash -x script.sh 运行。排查完记得关掉,trace 日志量很大。
面试常问
Shell 函数如何返回字符串?
函数不能 return 字符串(return 仅 0-255 状态码)。标准做法是函数内 echo 输出,调用方用命令替换接收:result=$(my_func)。副作用变量(需多处共享)也可用全局变量,但要控制数量。
set -euo pipefail 分别是什么?为什么生产脚本要加?
-e 任何命令失败立即退出,避免「前一步备份失败还继续删除旧包」的灾难;-u 禁止引用未定义变量,拦截拼写错误;-o pipefail 让管道任一环节失败都判定失败,避免 grep x file | wc -l 在 grep 失败时返回假成功。三者合称脚本安全带。
source 和 bash 执行脚本的区别?
bash xx.sh 创建子 Shell 执行,变量、函数、cd 都随子进程销毁;source xx.sh 在当前 Shell 环境中逐行执行,变量函数直接生效。典型应用:.bashrc 必须 source、配置文件加载、让 cd/export 影响当前终端。
自测三问
函数既要返回「是否成功」又要返回数据,怎么设计?
set -e 在哪些情况下不会触发退出?
为什么说 lib.sh 里不能有顶层执行语句?
小结一句话 函数 + local + source + set -euo pipefail + main 收口——这套组合拳让你的脚本从「一次性命令堆」升级为可测试、可维护、可进公司仓库的工程件。
延伸