模块 :S14 工程化与综合实战
篇号 :S14-02 / 42
预计阅读 :50 分钟
主线:Bash
文章目录
-
- 本篇目标
- [30 秒速览](#30 秒速览)
- 正文
-
- [1. 先选档位,再套模板](#1. 先选档位,再套模板)
- [2. 一档模板:20 行工具](#2. 一档模板:20 行工具)
- [3. 二档模板:单文件 CLI](#3. 二档模板:单文件 CLI)
- [4. 三档模板:多文件小项目](#4. 三档模板:多文件小项目)
-
- [4.1 推荐目录](#4.1 推荐目录)
- [4.2 库文件 `lib/common.sh`](#4.2 库文件
lib/common.sh) - [4.3 库文件 `lib/deploy_lib.sh`](#4.3 库文件
lib/deploy_lib.sh) - [4.4 入口 `bin/deploy.sh`](#4.4 入口
bin/deploy.sh)
- [5. 配置与环境变量](#5. 配置与环境变量)
-
- [5.1 `config/defaults.env`](#5.1
config/defaults.env) - [5.2 `.env.example`](#5.2
.env.example) - [5.3 加载顺序(约定写进 README)](#5.3 加载顺序(约定写进 README))
- [5.1 `config/defaults.env`](#5.1
- [6. `scripts/env.sh`(可选,给人用)](#6.
scripts/env.sh(可选,给人用)) - [7. 何时从一档升到二档、二档升到三档](#7. 何时从一档升到二档、二档升到三档)
- [8. README 最小模板](#8. README 最小模板)
- [9. 质量门禁(提交前)](#9. 质量门禁(提交前))
- [10. 三档对照总表](#10. 三档对照总表)
- [11. 读项目脚本的顺序(预告 S14-03)](#11. 读项目脚本的顺序(预告 S14-03))
- 读脚本检查清单
- 练习
- 下一篇预告
本篇目标
在 S14-01 风格约定 与 S08-03 模块化 基础上,给出三档可直接复制 的脚本模板:20 行一次性工具 、单文件 CLI 、多文件小项目 。能判断脚本该停在哪一档、何时拆 lib/、如何写 README 与 .env.example ,并用 ShellCheck / bash -n 做最低质量门禁。
30 秒速览
- 一档(工具):单文件、无参数或极少参数,适合 cron 一次性任务。
- 二档(CLI) :单文件 +
main/getopts/trap,百行内可维护。 - 三档(项目) :
bin/+lib/+ 配置 + README,多入口共享库。 - 升级信号:重复函数、多入口、配置变多、需要单独测试库函数。
- 路径 :入口用
BASH_SOURCE算SCRIPT_DIR,不依赖 cwd(S08-03)。 - 质量 :提交前
shellcheck+bash -n(S12-02)。
正文
1. 先选档位,再套模板
| 档位 | 行数量级 | 典型场景 | 文件数 |
|---|---|---|---|
| 一档·工具 | ~20~40 | 备份单目录、清理 /tmp、发一条通知 |
1 |
| 二档·CLI | ~80~200 | 带 -h/-v 的部署、批处理、运维命令 |
1 |
| 三档·项目 | 200+ | 多子命令、共享库、团队长期维护 | 多个 |
原则 :能用一档解决就不要上三档;重复第二次 再抽函数;第二个入口脚本 再建 lib/。
读仓库时先看:bin/ 有几个入口 、lib/ 是否纯定义 、README 写 source 还是执行。
2. 一档模板:20 行工具
适合:逻辑直线、参数 0~1 个、无人扩展。
bash
#!/usr/bin/env bash
# prune-logs.sh --- 删除 7 天前的 *.log(cron 用)
set -euo pipefail
readonly DAYS=7
readonly LOG_DIR=${LOG_DIR:-/var/log/myapp}
find "$LOG_DIR" -maxdepth 1 -name '*.log' -mtime +"$DAYS" -print -delete
| 要素 | 说明 |
|---|---|
| shebang + set | S01-04 最小可靠集 |
| 一行用途 | cron/同事一眼懂 |
readonly + 环境默认 |
LOG_DIR 可覆盖,不改代码 |
无 main |
太短可不包装;超过 ~30 行建议升格二档 |
cron 注意:写清路径,用绝对路径;错误进邮件:
bash
# crontab 示例
0 3 * * * /opt/scripts/prune-logs.sh >>/var/log/prune.log 2>&1
3. 二档模板:单文件 CLI
适合:需要 用法说明 、选项 、清理(trap),仍是一个人维护。
bash
#!/usr/bin/env bash
# backup-dir.sh --- 打包目录为 .tar.gz
set -euo pipefail
readonly SCRIPT_NAME=${0##*/}
# --- 工具函数 ---
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
usage() { printf 'usage: %s [-k] <srcdir>\n' "$SCRIPT_NAME" >&2; exit 2; }
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "command not found: $1"
}
# --- 业务 ---
make_archive() {
local src=$1 keep=$2
local out tmp
[[ -d "$src" ]] || die "not a directory: $src"
out=$(basename "$src").tar.gz
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT INT TERM
log "archiving $src -> $out"
tar -czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")"
if (( keep )); then
[[ -f "$out" ]] && cp -a "$out" "${out}.bak"
fi
mv "$tmp" "$out"
trap - EXIT INT TERM
log "done: $out"
}
# --- 入口 ---
main() {
local keep=0
local src
while getopts ':k' opt; do
case $opt in
k) keep=1 ;;
*) usage ;;
esac
done
shift $(( OPTIND - 1 ))
(( $# == 1 )) || usage
src=$1
require_cmd tar
make_archive "$src" "$keep"
}
main "$@"
与 S14-01 对照:
| 块 | 内容 |
|---|---|
| 头部 | shebang、用途、set -euo pipefail |
| 工具 | die、usage、log、require_cmd |
| 业务 | make_archive(可再拆) |
| 末尾 | main "$@" |
选项解析 用 getopts (S08-04);临时文件 配 trap(S11-01)。
4. 三档模板:多文件小项目
适合:多个子命令、库函数复用、配置外置。
4.1 推荐目录
text
myapp/
README.md
.env.example # 环境变量示例,不含密钥
bin/
deploy.sh # 入口:可执行
rollback.sh
lib/
common.sh # die、log、require_cmd
deploy_lib.sh # 部署相关函数
config/
defaults.env # 非敏感默认(可被 .env 覆盖)
tests/
smoke.sh # 可选:bash -n + 干跑
| 目录 | 职责 |
|---|---|
bin/ |
可执行入口,短、只组装调用 |
lib/ |
被 source ,函数与常量,不 顶层 exit(S08-03) |
config/ |
默认值;密钥走环境变量,不进仓库 |
tests/ |
烟雾测试、固定样例(非必须 junit 级) |
4.2 库文件 lib/common.sh
bash
# lib/common.sh --- 公共库,请 source,勿直接执行
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
printf 'source this file, do not execute\n' >&2
exit 1
fi
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "command not found: $1"
}
4.3 库文件 lib/deploy_lib.sh
bash
# lib/deploy_lib.sh
_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly DEFAULT_REMOTE="${DEFAULT_REMOTE:-user@host:/var/www}"
deploy_sync() {
local src=$1 remote=${2:-$DEFAULT_REMOTE}
require_cmd rsync
rsync -a --delete "$src/" "$remote/"
}
4.4 入口 bin/deploy.sh
bash
#!/usr/bin/env bash
# bin/deploy.sh --- 同步站点到远程
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# 可选:加载配置(存在才加载)
[[ -f "${ROOT_DIR}/config/defaults.env" ]] && source "${ROOT_DIR}/config/defaults.env"
[[ -f "${ROOT_DIR}/.env" ]] && source "${ROOT_DIR}/.env"
source "${ROOT_DIR}/lib/common.sh"
source "${ROOT_DIR}/lib/deploy_lib.sh"
usage() {
printf 'usage: %s [-n] [remote]\n' "${0##*/}" >&2
exit 2
}
main() {
local dry=0 remote
while getopts ':n' opt; do
case $opt in
n) dry=1 ;;
*) usage ;;
esac
done
shift $(( OPTIND - 1 ))
remote=${1:-$DEFAULT_REMOTE}
if (( dry )); then
log "dry-run: would sync ${ROOT_DIR}/site/ -> $remote"
return 0
fi
deploy_sync "${ROOT_DIR}/site" "$remote"
log "deployed to $remote"
}
main "$@"
读脚本路径:
- 从
bin/deploy.sh找ROOT_DIR、source顺序 - 再看
lib/deploy_lib.sh里真实rsync参数 config/defaults.env里默认主机、路径
5. 配置与环境变量
5.1 config/defaults.env
bash
# 非敏感默认;提交到 git
DEFAULT_REMOTE=user@staging:/var/www
RETAIN_DAYS=7
5.2 .env.example
bash
# 复制为 .env 后填写;.env 加入 .gitignore
DEFAULT_REMOTE=user@prod:/var/www
API_TOKEN=replace_me
5.3 加载顺序(约定写进 README)
text
环境变量(已 export) > .env > config/defaults.env > 脚本内 readonly 默认
入口脚本用 存在才 source ,避免本机无 .env 时报错:
bash
[[ -f "${ROOT_DIR}/.env" ]] && source "${ROOT_DIR}/.env"
必填项在 main 开头 用 : "${VAR:?}" 检查(S11-02)。
6. scripts/env.sh(可选,给人用)
与 S08-03 一致:面向交互终端 ,不是每个 bin/ 入口的前置条件。
bash
# scripts/env.sh --- 文档:source scripts/env.sh
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export PATH="${ROOT}/bin:${PATH}"
source "${ROOT}/lib/common.sh"
deploy() { "${ROOT}/bin/deploy.sh" "$@"; }
- 开发 :
source scripts/env.sh后敲deploy - CI/cron :直接
/path/to/bin/deploy.sh,不依赖 env.sh
7. 何时从一档升到二档、二档升到三档
| 信号 | 建议 |
|---|---|
| 超过 ~80 行 且多段逻辑 | 抽函数;仍单文件 |
| 两个以上运维脚本复制粘贴同一函数 | 建 lib/common.sh |
第三个 bin/ 入口 |
三档布局 + README |
| 需要 mock 配置做多环境 | config/ + .env |
| 库函数要被「单测」式调用 | lib/ 纯函数 + tests/smoke.sh |
不要过早拆文件:单文件 150 行但结构清晰,仍优于三个空洞文件。
8. README 最小模板
markdown
# myapp scripts
Bash 4+。运维同步与回滚工具。
## 快速开始
chmod +x bin/deploy.sh
cp .env.example .env # 编辑 DEFAULT_REMOTE
./bin/deploy.sh -n # 干跑
## 命令
| 脚本 | 作用 |
|------|------|
| bin/deploy.sh | 同步 site/ 到远程 |
| bin/rollback.sh | 回滚上一版本 |
## 配置
- config/defaults.env --- 默认(可提交)
- .env --- 本机覆盖(不提交)
## 检查
shellcheck bin/*.sh lib/*.sh
bash -n bin/deploy.sh
读陌生项目:README 表格 往往比翻目录快。
9. 质量门禁(提交前)
| 检查 | 命令 | 说明 |
|---|---|---|
| 语法 | bash -n script.sh |
不执行,只解析 |
| 静态 | shellcheck script.sh |
S12-02 |
| 干跑 | -n 选项 或 DRY_RUN=1 |
不真写远程/不真删 |
| 选项 | ./script.sh -h |
用法与 exit 2 |
tests/smoke.sh 示例:
bash
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
for f in "${ROOT}"/bin/*.sh "${ROOT}"/lib/*.sh; do
bash -n "$f"
done
shellcheck "${ROOT}"/bin/*.sh "${ROOT}"/lib/*.sh
"${ROOT}/bin/deploy.sh" -n
printf 'smoke ok\n'
10. 三档对照总表
| 项 | 一档 | 二档 | 三档 |
|---|---|---|---|
| 文件 | 1 | 1 | bin + lib + config |
main |
可选 | 必须 | 每个入口一个 |
getopts |
少见 | 常见 | 各入口自有 |
source |
无 | 少见 | 必须 |
SCRIPT_DIR |
常不需要 | 需要时再加 | 入口必算 |
| README | 一行注释 | 简短说明 | 完整 |
| ShellCheck | 建议 | 建议 | 建议 + smoke |
11. 读项目脚本的顺序(预告 S14-03)
打开一个脚本仓库时,建议路径:
text
README → bin/ 入口列表 → 选一个入口的 source 链 → lib/ → config/
S14-03 将在一段真实风格 的长脚本上,把 S01~S14 的知识点串起来做逐段拆解(专栏必读三星之一)。
读脚本检查清单
- 判断档位:单文件工具 vs 多文件项目
- 入口是否在
bin/,库是否只被 source -
source路径 是否基于BASH_SOURCE(S08-03) - 配置分层:defaults / .env / 环境变量 谁先谁后
- 文档写的是 执行
bin/x.sh还是 先 source env.sh - 是否有
-n/dry-run 或 smoke 测试 - 一档脚本是否被误扩成无结构大文件(该升格)
练习
判断题
- 20 行 cron 脚本也必须拆成
lib/才规范。 - 三档项目的
lib/common.sh顶层不宜随意exit 1。 bin/deploy.sh应使用BASH_SOURCE定位项目根,而不是假设用户在项目根执行。.env应提交到 git,方便同事共享密钥。- 第二个入口脚本复制同一套
die/log时,适合抽到lib/common.sh。
参考答案
- 错(一档单文件即可;过度拆分会增加路径成本)。
- 对(source 时
exit会结束当前 Shell,S08-03)。 - 对。
- 错(
.env含密钥,应 gitignore ;只提交.env.example)。 - 对。
实操题
在纸上或本地建三档目录,写出 bin/hello.sh :source lib/common.sh ,定义 main ,接受一个名字参数,打印 hello, <name> 到 stdout,日志 log 到 stderr。
参考答案
lib/common.sh(节选):
bash
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then exit 1; fi
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
bin/hello.sh:
bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/common.sh"
usage() { printf 'usage: %s <name>\n' "${0##*/}" >&2; exit 2; }
main() {
(( $# == 1 )) || usage
log "greeting $1"
printf 'hello, %s\n' "$1"
}
main "$@"
设计题
backup.sh 80 行,内含 die、tar 打包、find 清理;即将增加 restore.sh 也要用 die 和日志格式。应维持一档、升二档还是升三档?目录怎么摆?
参考答案
升三档(轻量):
text
lib/common.sh # die、log、require_cmd
bin/backup.sh
bin/restore.sh
两入口共享库;若 restore 逻辑很长,再加 lib/backup_lib.sh。单文件 backup 已 80 行且第二入口在即,不必强行塞回一档。
下一篇预告
S14-03 :《综合实战:拆解一段真实开源/运维脚本》--- 专栏必读三星 收官篇:从 README → 入口 → source 链 → 条件/循环/管道 逐段读,串联 S01~S14。