Shell脚本精读 · S14-01 | 风格约定:命名、引用、函数与错误处理

模块:S14 工程化与综合实战
篇号:S14-01 / 42
预计阅读:45 分钟
主线:Bash


文章目录

    • 本篇目标
    • 30 秒速览
    • 正文
      • 1. 风格约定解决什么问题
      • 2. 脚本文件与头部
      • 3. 命名约定
        • 3.1 变量
        • 3.2 函数
        • 3.3 退出码(团队内约定)
      • 4. 引用约定
      • 5. 函数与文件布局
      • 6. 错误处理约定
        • 6.1 三层模型
        • 6.2 `die` 与 `usage`
        • 6.3 `return` 与 `exit`
        • 6.4 与 `set -e` 协作
      • 7. 标准输出与标准错误
      • 8. 注释写什么
      • 9. 与 S13 可移植性的关系
      • 10. 风格约定速查表
    • 读脚本检查清单
    • 练习
      • 判断题
      • 改错题
      • 实操题
    • 下一篇预告

本篇目标

把前面各模块的零散习惯收成一套工程化风格约定命名引用函数布局错误与退出码。能按约定写出可读、可 review 的 Bash 脚本,并在读他人代码时快速识别main/die/usage"$@"local等模式。可移植写法见S13;本篇侧重团队内 Bash 脚本的一致风格。


30 秒速览

  • 文件#!/usr/bin/env bash+set -euo pipefail;业务放main "$@"
  • 命名:常量/环境UPPER_SNAKE;局部变量lower_snake;函数verb_nounlower_snake
  • 引用:变量、路径、"$@"一律双引号;数组用"${arr[@]}"(S02-01)。
  • 函数:上部定义工具函数;usage→exit 2die→exit 1;函数内临时量用local(S08-02)。
  • 错误:预期失败用return;致命错误die;关键步骤cmd || die或依赖-e
  • 日志:用户可见信息stdout;错误与诊断stderr

正文

1. 风格约定解决什么问题

语法正确 ≠ 好维护。多人协作或半年后自己重读时,差异来自:

问题无约定时
变量是否已定义猜、搜全文
引号是否漏写隐蔽词拆分 bug
失败是否退出set -e与手写检查混用
入口在哪从第 1 行读到尾

风格约定不是语法课,而是读写的共同语言——与S13 可移植性并列:S13 管「能不能在 dash 跑」,S14 管「Bash 项目里怎么写才一致」。


2. 脚本文件与头部

#!/usr/bin/env bash# deploy.sh — 发布静态资源到目标目录(一行用途说明)set-euopipefail
约定
shebang#!/usr/bin/env bash(专栏默认;S13-01)
第二段可选:脚本名 +一行用途(维护者第一眼)
set紧跟 shebang/注释后:set -euo pipefail(S01-04)
扩展选项团队统一再写(如shopt -s inherit_errexit),勿个人发挥

文件名:小写 + 连字符或下划线,见名知意:backup-db.shlib/common.sh。避免test.shrun.sh等泛名。


3. 命名约定

3.1 变量
类型风格示例
环境变量 / 全局常量全大写readonly DEPLOY_ROOT=/opt/app
脚本内局部 / 配置小写 + 下划线target_dirretry_count
循环临时短名可接受if(作用域很小时)
「内部用」前缀前导_(可选)_tmp_file(示意非公开 API)
readonlyDEFAULT_PORT=8080log_level=${LOG_LEVEL:-info}main(){localsrc_dir=$1localdest_dir=$2...}
  • readonly:脚本级常量,防止误改。
  • local:函数内变量(S08-02),避免污染全局命名空间。
3.2 函数
风格示例说明
动词_名词require_filecopy_tree一眼知动作
短工具名dieusagelog项目内统一即可
避免lstest与外部命令同名(S08-01)

读脚本:见到require_*ensure_*validate_*多为前置检查do_*run_*多为业务步骤

3.3 退出码(团队内约定)
常见含义
0成功
1一般错误(die、业务失败)
2用法错误(usage、参数不对)
3~125项目自定义(文档写明)

S04-01一致;不要exit 255等易与信号混淆的值。


4. 引用约定

默认规则:凡变量展开位置参数含空格或通配符风险的字符串 →双引号

# 推荐cp"$src""$dest"rm-f--"$maybe_dash_name"forfin"$@";doprocess"$f"done# 数组(S03-04)files=(a.log b.log)printf'%s\n'"${files[@]}"
写法说明
"$var"防止词拆分与意外通配符(S02-01、S02-03)
"$@"保留每个参数边界;优于$*
"${var:-default}"可选配置(S03-02)
:"${VAR:?message}"必填变量(S11-02)
'...'固定字面量、正则、不展开的内容

刻意不加引号的少数情况:算术$(( ))[[ ... ]]右侧模式、for i in 1 2 3等已知安全列表——写的时候加注释说明为何安全。

反模式(读脚本时标红):

rm$file# 空变量 → rm 无参;多词 → 多文件ssh$host"cmd"# host 含空格即断

5. 函数与文件布局

推荐自上而下四段(与 S01-04、S08-01 骨架一致):

#!/usr/bin/env bashset-euopipefail# --- 1. 常量 / 默认配置 ---readonlySCRIPT_NAME=${0##*/}# --- 2. 工具函数(无业务副作用或仅通用副作用)---log(){printf'[%s] %s\n'"$(date+%H:%M:%S)""$*">&2;}die(){printf'error: %s\n'"$*">&2;exit1;}usage(){printf'usage: %s [-v] <dir>\n'"$SCRIPT_NAME">&2;exit2;}require_cmd(){command-v"$1">/dev/null2>&1||die"command not found:$1"}# --- 3. 业务函数 ---deploy_dir(){localdir=$1[[-d"$dir"]]||die"not a directory:$dir"...}# --- 4. main 入口 ---main(){localverbose=0localtargetwhilegetopts':v'opt;docase$optinv)verbose=1;;*)usage;;esacdoneshift$((OPTIND-1))(($#==1))||usagetarget=$1require_cmdrsyncdeploy_dir"$target"}main"$@"
作用
常量readonly、默认路径
工具函数dieusagelogrequire_*
业务函数可单测、可读的步骤
main "$@"唯一顶层执行流

定义顺序:函数必须在调用前定义;main "$@"在文件末尾


6. 错误处理约定

6.1 三层模型
手段场景
预防set -u: "${VAR:?}"require_cmd启动前挡住坏环境
检测[[ -f ]]cmd、管道 +pipefail步骤中判断
报告dieusagelog→ stderr人看得懂为何停
6.2dieusage
die(){printf'error: %s\n'"$*">&2exit1}usage(){printf'usage: %s [-f] <file>\n'"${0##*/}">&2exit2}
  • usage:参数解析失败、-h以外的非法选项 →exit 2
  • die:业务/环境错误 →exit 1(或团队统一的其他非 0 码)。
6.3returnexit
# 函数内:可恢复 / 可跳过is_stale(){[[-n"${1:-}"]]||return1...}# 函数内:致命(结束整个脚本)fatal_in_main_context(){die"cannot continue"# die 内部 exit}# 库函数被 source 时:慎用 exit(S08-03)lib_fn(){printf'bad\n'>&2return1# 让调用方决定}
6.4 与set -e协作
set-euopipefail# 显式忽略(要有理由)greppatternfile||true# 显式失败信息cp"$a""$b"||die"copy failed:$a->$b"# 函数里希望「失败但不退出脚本」process_one(){convert"$1"||return1}

读脚本:混用|| true|| die、裸命令时,先确认是否开了-e(S01-04、S08-01 §9)。


7. 标准输出与标准错误

用途
stdout管道/重定向要消费的数据jsonpath列表)
stderr日志、进度、错误(logdieusage
log(){printf'[info] %s\n'"$*">&2;}result_path=$(find_upward"$start")# find_upward 只 echo 路径到 stdout

反模式echo "debug: ..."混在会进管道的命令之间,污染下游(S09-01)。


8. 注释写什么

该写不必写
非显而易见的业务规则i=$((i+1)) # 加 1
外部命令的平台假设复述代码字面意思
`
退出码、环境变量契约
# GNU date only; BSD use gdate (documented in README)expire=$(date-d'+7 days'+%F)

9. 与 S13 可移植性的关系

场景选择
团队 CI、运维机均为 Bash本篇约定 +#!/usr/bin/env bash
必须/bin/sh改用S13-02printf[、无local
开源项目README 写明 Bash 版本下限

不要shebang 写sh却用本篇 Bash 风格——这是 S13-01 红旗。


10. 风格约定速查表

主题推荐
shebang#!/usr/bin/env bash
选项set -euo pipefail
入口main "$@"
常量readonly UPPER_SNAKE
函数内变量local lower_snake
参数"$@""$1"
用法错误usage2
运行错误die1
依赖command -v(S13-02)
日志>&2

读脚本检查清单

  • 第一行 shebang 与文内语法是否一致(Bash vs sh)
  • 是否有set -euo pipefail(或团队等价物)
  • 变量、"$@"是否普遍加双引号
  • 是否有main "$@"或清晰的单一入口
  • usage/die/log命名与退出码是否一致
  • 函数内是否用local,有无全局污染
  • 错误信息是否走stderr,数据是否走stdout
  • || true是否都有理由(不是掩盖真 bug)

练习

判断题

  1. 脚本内配置常量推荐readonly MAX_RETRIES=3形式。
  2. "$@"在传递参数时通常优于$*
  3. usage函数应惯例以退出码 2 结束脚本。
  4. 所有echo都应改为写到 stdout,包括错误提示。
  5. 函数内的循环变量i应用local i声明(若函数可能被递归或重入)。
参考答案
  1. 对。
  2. 对。
  3. 对(团队可自定义,但 2 是常见约定)。
  4. 错(错误与日志应stderr,如>&2)。
  5. 对(良好习惯,避免泄漏到全局)。

改错题

指出下面片段中风格/健壮性问题(至少 4 处):

#!/usr/bin/env bashset-emax=3deploy(){src=$1dest=$2cp$src$dest}if[$#-lt1];thenechousage:$0direxit0fideploy$1/var/www
参考答案

示例答案(不唯一):

  1. -u-o pipefail(与专栏骨架不一致)。
  2. max若不变可readonly MAX=3;函数内src/destlocal
  3. cp $src $destcp "$src" "$dest"
  4. 用法错误却exit 0,应exit 2且输出到stderr
  5. main "$@""结构,参数未引号传递deploy "$1" /var/www
  6. echo usage宜改为usage函数printf 'usage: ...\n' "$0" >&2

实操题

按本篇约定,为下面需求写头部 + 工具函数 + main 骨架(业务体可...):

  • 脚本名sync-site.sh
  • 必须恰好 1 个参数(目标目录)
  • 依赖rsync不存在则die
  • 支持-ndry-run(传给rsync -n
参考答案
#!/usr/bin/env bash# sync-site.sh — 同步站点目录到目标路径set-euopipefailreadonlySCRIPT_NAME=${0##*/}die(){printf'error: %s\n'"$*">&2;exit1;}usage(){printf'usage: %s [-n] <dest-dir>\n'"$SCRIPT_NAME">&2;exit2;}require_cmd(){command-v"$1">/dev/null2>&1||die"command not found:$1"}main(){localdry_run=0localdestwhilegetopts':n'opt;docase$optinn)dry_run=1;;*)usage;;esacdoneshift$((OPTIND-1))(($#==1))||usagedest=$1require_cmdrsyncif((dry_run));thenrsync-an./site/"$dest/"elsersync-a./site/"$dest/"fi}main"$@"

下一篇预告

S14-02:《脚本模板:从 20 行工具到可维护项目》— 在风格约定之上,给出可复制目录结构多文件协作模板。