Shell脚本精读 · S14-02 | 脚本模板:从 20 行工具到可维护项目

模块 :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))
      • [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_SOURCESCRIPT_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
工具 dieusagelogrequire_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 "$@"

读脚本路径

  1. bin/deploy.shROOT_DIRsource 顺序
  2. 再看 lib/deploy_lib.sh 里真实 rsync 参数
  3. 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 测试
  • 一档脚本是否被误扩成无结构大文件(该升格)

练习

判断题

  1. 20 行 cron 脚本也必须拆成 lib/ 才规范。
  2. 三档项目的 lib/common.sh 顶层不宜随意 exit 1
  3. bin/deploy.sh 应使用 BASH_SOURCE 定位项目根,而不是假设用户在项目根执行。
  4. .env 应提交到 git,方便同事共享密钥。
  5. 第二个入口脚本复制同一套 die/log 时,适合抽到 lib/common.sh

参考答案

  1. 错(一档单文件即可;过度拆分会增加路径成本)。
  2. 对(source 时 exit 会结束当前 Shell,S08-03)。
  3. 对。
  4. 错(.env 含密钥,应 gitignore ;只提交 .env.example)。
  5. 对。

实操题

在纸上或本地建三档目录,写出 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 行,内含 dietar 打包、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。

相关推荐
x-cmd1 小时前
Linus 9 年前说「模糊测试有效」——今天已成 OSS-Fuzz 时代铁律
linux·ai·rust·开源·自动化·agent·安全性测试
六点_dn2 小时前
Linux学习笔记-chmod命令
linux·笔记·学习
AOwhisky2 小时前
Python 基础语法(上篇 + 下篇)——综合自测题
linux·windows·python
浮江雾3 小时前
Flutter第四节------核心概念学习笔记:从基础语法到异步编程
linux·服务器·开发语言·笔记·flutter·入门·基础
FoldWinCard3 小时前
D4 Linux 进程 内存 等命令
linux·运维·服务器
在线OJ的阿川3 小时前
仿muduo库实现高并发服务器
linux·服务器·c++
M海洋3 小时前
Ubuntu dialout 串口权限授权
linux·单片机·ubuntu
j7~3 小时前
【Linux 系统】二十.进程间通信(共享内存、消息队列、信号量)
linux·运维·消息队列·共享内存·信号量·system v
YMWM_3 小时前
python-venv虚拟环境
linux·开发语言·python