Shell 脚本工程化:从"能跑"到"能用"¶
一句话说明:工程化的 Shell 脚本有规范结构、错误处理、日志记录和测试,让分析流程可靠、可维护、可交接——而不是只有写的人才能跑通的"一次性代码"。
一、为什么要"工程化"Shell 脚本¶
你可能觉得"Shell 脚本不就是把命令串起来嘛",但在实际工作中:
| 随手写的脚本 | 工程化的脚本 |
|---|---|
| 没有注释,三个月后自己看不懂 | 有注释头、功能说明、参数文档 |
| 出错了继续跑,最后结果是垃圾 | set -euo pipefail + trap 捕获错误,出错立即停 |
路径写死 /home/zhangsan/data | 参数化,用 getopts 接收输入 |
| 日志全混在 stdout 里 | 有专门的日志函数,带时间戳和级别 |
| 临时文件散落一地 | trap 退出清理,mktemp 规范创建 |
| 换台服务器就跑不了 | 依赖检查 + 环境变量配置 |
白话说:工程化 = 让脚本像正规软件一样可靠,而不是像草稿纸。
面试加分:面试官看到你的脚本有
set -euo pipefail、有trap、有日志函数,立刻知道你是有工程素养的人。
二、脚本结构规范¶
一个工程化 Shell 脚本应该包含以下部分(按顺序):
2.1 Shebang 行¶
2.2 安全模式¶
set -euo pipefail
# -e:命令失败立即退出(Exit on error)
# -u:使用未定义变量报错(Undefined variable = error)
# -o pipefail:管道中任一命令失败,整个管道失败
# 比如 cat missing_file | sort 默认只看 sort 的退出码(0)
# 加了 pipefail 会捕获到 cat 的错误
2.3 注释头(元信息)¶
# ==============================================================================
# 脚本名称:run_metagenome_qc.sh
# 功 能:宏基因组原始数据质控(fastp + host removal)
# 作 者:[用户] <pengwq@example.com>
# 创建日期:2026-05-01
# 修改日期:2026-05-03
# 版 本:v1.2.0
# 依 赖:fastp>=1.0, bowtie2>=2.4, samtools>=1.15
# 用 法:bash run_metagenome_qc.sh -i raw_dir -o clean_dir -t 8
# ==============================================================================
2.4 参数解析(getopts)¶
# --- 默认值 ---
INPUT_DIR="" # 输入目录(必填)
OUTPUT_DIR="./clean" # 输出目录(默认 ./clean)
THREADS=4 # 线程数(默认 4)
HOST_REF="" # 宿主参考基因组(可选)
# --- 用法说明函数 ---
usage() {
cat <<EOF
用法: $(basename "$0") -i <input_dir> -o <output_dir> [-t threads] [-r host_ref]
必填参数:
-i 输入目录(含 *_R1.fastq.gz 和 *_R2.fastq.gz)
-o 输出目录
可选参数:
-t 线程数(默认: 4)
-r 宿主参考基因组(bowtie2 索引前缀)
-h 显示帮助信息
EOF
exit 1
}
# --- 解析参数 ---
while getopts ":i:o:t:r:h" opt; do # 冒号表示该选项需要参数
case ${opt} in
i) INPUT_DIR="${OPTARG}" ;; # OPTARG 是选项的参数值
o) OUTPUT_DIR="${OPTARG}" ;;
t) THREADS="${OPTARG}" ;;
r) HOST_REF="${OPTARG}" ;;
h) usage ;;
\?) echo "错误: 无效选项 -${OPTARG}" >&2; usage ;; # 未知选项
:) echo "错误: 选项 -${OPTARG} 需要参数" >&2; usage ;; # 缺少参数
esac
done
# --- 必填参数检查 ---
if [[ -z "${INPUT_DIR}" ]]; then
echo "错误: 必须指定输入目录 (-i)" >&2
usage
fi
2.5 日志函数¶
# --- 日志函数 ---
log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*"; }
log_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" >&2; }
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; }
# 使用示例
log_info "开始质控,输入目录: ${INPUT_DIR}"
log_warn "宿主基因组未指定,跳过去宿主步骤"
log_error "fastp 运行失败,退出码: $?"
2.6 错误处理(trap)¶
# --- 临时目录与清理 ---
TMPDIR=$(mktemp -d "${TMPDIR:-/tmp}/metagenome_qc.XXXXXX")
# mktemp -d 创建唯一临时目录,XXXXXX 会被替换为随机字符
# trap 语法:trap '命令' 信号
# EXIT:脚本退出时执行(不管正常还是异常)
# ERR:命令出错时执行
# INT:用户按 Ctrl+C 时执行
cleanup() {
local exit_code=$?
log_info "清理临时文件: ${TMPDIR}"
rm -rf "${TMPDIR}"
if [[ ${exit_code} -ne 0 ]]; then
log_error "脚本异常退出,退出码: ${exit_code}"
fi
exit ${exit_code}
}
trap cleanup EXIT # 注册清理函数,脚本退出时自动执行
2.7 依赖检查¶
# --- 检查依赖工具是否安装 ---
check_dependency() {
local cmd="$1" # 要检查的命令名
if ! command -v "${cmd}" &>/dev/null; then # command -v 检查命令是否存在
log_error "依赖工具 '${cmd}' 未安装或不在 PATH 中"
exit 127 # 127 = command not found(标准退出码)
fi
}
check_dependency fastp
check_dependency bowtie2
check_dependency samtools
log_info "所有依赖检查通过"
2.8 退出码规范¶
# 标准退出码:
# 0 - 成功
# 1 - 一般错误
# 2 - 参数错误
# 126 - 权限不足
# 127 - 命令未找到
# 自定义退出码(用 10 以上):
# 10 - 输入文件不存在
# 11 - 输出目录创建失败
# 12 - 上游工具运行失败
三、生信 Shell 脚本完整模板¶
把上面的模块组装起来,就是一个工程级脚本:
#!/usr/bin/env bash
# ==============================================================================
# 脚本名称:run_fastp_qc.sh
# 功 能:对宏基因组双端测序数据进行质控(fastp)
# 作 者:pengwq
# 版 本:v1.0.0
# 用 法:bash run_fastp_qc.sh -i raw/ -o clean/ -t 8
# ==============================================================================
set -euo pipefail
# ---------- 日志函数 ----------
log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*"; }
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; }
# ---------- 参数默认值 ----------
INPUT_DIR=""
OUTPUT_DIR="./clean"
THREADS=4
# ---------- 用法 ----------
usage() { echo "用法: $0 -i <input> -o <output> [-t threads]"; exit 2; }
while getopts ":i:o:t:h" opt; do
case ${opt} in
i) INPUT_DIR="${OPTARG}" ;;
o) OUTPUT_DIR="${OPTARG}" ;;
t) THREADS="${OPTARG}" ;;
h) usage ;;
*) usage ;;
esac
done
[[ -z "${INPUT_DIR}" ]] && { log_error "缺少 -i 参数"; usage; }
# ---------- 依赖检查 ----------
command -v fastp &>/dev/null || { log_error "fastp 未安装"; exit 127; }
# ---------- 临时文件清理 ----------
TMPDIR=$(mktemp -d)
trap 'rm -rf "${TMPDIR}"; log_info "清理完成"' EXIT
# ---------- 创建输出目录 ----------
mkdir -p "${OUTPUT_DIR}" || { log_error "无法创建 ${OUTPUT_DIR}"; exit 11; }
# ---------- 核心逻辑 ----------
log_info "开始质控: ${INPUT_DIR} -> ${OUTPUT_DIR}"
for r1 in "${INPUT_DIR}"/*_R1.fastq.gz; do
sample=$(basename "${r1}" _R1.fastq.gz) # 从文件名提取样本名
r2="${INPUT_DIR}/${sample}_R2.fastq.gz" # 推导 R2 文件名
[[ -f "${r2}" ]] || { log_error "${r2} 不存在,跳过 ${sample}"; continue; }
log_info "处理样本: ${sample}"
fastp \
--in1 "${r1}" --in2 "${r2}" \
--out1 "${OUTPUT_DIR}/${sample}_R1.clean.fq.gz" \
--out2 "${OUTPUT_DIR}/${sample}_R2.clean.fq.gz" \
--json "${OUTPUT_DIR}/${sample}.fastp.json" \
--html "${OUTPUT_DIR}/${sample}.fastp.html" \
--thread "${THREADS}" \
--qualified_quality_phred 20 \
--length_required 50 \
2>"${OUTPUT_DIR}/${sample}.fastp.log"
log_info "完成: ${sample}"
done
log_info "全部质控完成,结果在 ${OUTPUT_DIR}"
exit 0
四、ShellCheck 静态检查¶
ShellCheck 是 Shell 脚本的"语法检查器",类似 Python 的 pylint。它能发现你意识不到的 bug。
安装与使用¶
# Ubuntu/Debian 安装
sudo apt install shellcheck # 系统包管理器安装
# conda 安装(推荐,不需要 sudo)
conda install -c conda-forge shellcheck
# 基本使用
shellcheck my_script.sh # 检查脚本
shellcheck -s bash my_script.sh # 指定 shell 为 bash
shellcheck -x my_script.sh # 跟踪 source 的文件一起检查
# 输出格式
shellcheck -f json my_script.sh # JSON 格式(可集成到 CI)
shellcheck -f gcc my_script.sh # GCC 风格(编辑器友好)
常见警告示例¶
# SC2086: 变量没有引号(可能导致分词问题)
# 错误写法:
cp $file /backup/ # 如果 file="my file.txt" 就会出错
# 正确写法:
cp "$file" /backup/ # 加引号保护空格
# SC2034: 变量赋值但未使用
UNUSED_VAR="hello" # ShellCheck 会提醒你这个没用到
# SC2155: declare/local 和赋值同行会掩盖退出码
# 错误写法:
local output=$(command) # 如果 command 失败,local 的返回值是 0
# 正确写法:
local output
output=$(command) # 分开写,$? 才能捕获 command 的退出码
# 忽略特定规则(在该行上方加注释)
# shellcheck disable=SC2086
cp $file /backup/ # 这行不再报 SC2086
五、单元测试:bats-core¶
bats-core(Bash Automated Testing System)让你给 Shell 脚本写测试,就像 pytest 对 Python 一样。
安装¶
# 通过 npm 安装(最简单)
npm install -g bats
# 或者从源码安装
git clone https://github.com/bats-core/bats-core.git
cd bats-core && sudo ./install.sh /usr/local
编写测试¶
#!/usr/bin/env bats
# 文件名:test_utils.bats
# setup() 在每个测试前运行(类似 pytest 的 fixture)
setup() {
source ./utils.sh # 加载被测试的函数
TEST_DIR=$(mktemp -d) # 创建临时目录
}
# teardown() 在每个测试后运行(清理)
teardown() {
rm -rf "$TEST_DIR"
}
# @test 定义一个测试用例
@test "log_info 输出包含 INFO 标记" {
result=$(log_info "test message") # 执行函数
[[ "$result" == *"[INFO]"* ]] # 断言输出包含 [INFO]
}
@test "check_dependency 对存在的命令返回0" {
run check_dependency bash # run 会捕获输出和退出码
[ "$status" -eq 0 ] # $status 是退出码
}
@test "check_dependency 对不存在的命令返回127" {
run check_dependency nonexistent_tool_xyz
[ "$status" -eq 127 ]
}
运行测试¶
六、Makefile 管理分析流程¶
Makefile 不只是编译 C 代码用的。在生信中,它是管理多步分析流程的利器——自动追踪依赖,只重跑需要更新的步骤。
# =========================================================
# Makefile: 宏基因组分析流程
# 用法: make all -j 4(4 个任务并行)
# =========================================================
# --- 配置 ---
SAMPLES := sample1 sample2 sample3
RAW_DIR := data/raw
CLEAN_DIR := data/clean
THREADS := 8
# --- 目标文件列表(Make 靠文件是否存在/更新来决定是否重跑) ---
CLEAN_R1 := $(patsubst %,$(CLEAN_DIR)/%_R1.clean.fq.gz,$(SAMPLES))
# patsubst 批量替换:把每个样本名变成对应的输出文件路径
# --- 默认目标 ---
.PHONY: all clean help
all: $(CLEAN_R1) # make all 会生成所有 clean 文件
# --- 规则:从原始数据生成质控数据 ---
# $* 是 stem(匹配 % 的部分,即样本名)
# $@ 是目标文件
# $< 是第一个依赖文件
$(CLEAN_DIR)/%_R1.clean.fq.gz: $(RAW_DIR)/%_R1.fastq.gz $(RAW_DIR)/%_R2.fastq.gz
@mkdir -p $(CLEAN_DIR)
fastp --in1 $(RAW_DIR)/$*_R1.fastq.gz \
--in2 $(RAW_DIR)/$*_R2.fastq.gz \
--out1 $@ \
--out2 $(CLEAN_DIR)/$*_R2.clean.fq.gz \
--thread $(THREADS)
# --- 清理 ---
clean:
rm -rf $(CLEAN_DIR)
help:
@echo "make all - 运行全部质控"
@echo "make clean - 删除质控结果"
核心优势: - 增量执行:修改了 sample2 的原始数据,make all 只会重跑 sample2 - 并行执行:make -j 4 自动并行 4 个样本 - 依赖追踪:通过文件时间戳判断哪些步骤需要重跑
七、生信常见 Shell 模式¶
7.1 批量处理¶
# --- 方法1: for 循环 ---
for fq in data/raw/*_R1.fastq.gz; do
sample=$(basename "$fq" _R1.fastq.gz)
echo "处理 ${sample}"
# ... 分析命令
done
# --- 方法2: while read(从文件读取样本列表) ---
while IFS=$'\t' read -r sample group; do # IFS 指定分隔符为 Tab
echo "处理 ${sample},分组: ${group}"
done < sample_list.tsv
7.2 GNU Parallel 并行¶
# parallel 让你用一行命令并行处理多个样本
# -j 4:同时跑 4 个任务
# {}:占位符,被输入替换
# {/.}:去掉路径和后缀的文件名
cat sample_list.txt | parallel -j 4 \
'fastp --in1 raw/{}_R1.fq.gz --in2 raw/{}_R2.fq.gz \
--out1 clean/{}_R1.fq.gz --out2 clean/{}_R2.fq.gz'
# 带进度条
parallel --bar -j 4 'process_sample {}' ::: sample1 sample2 sample3 sample4
7.3 锁文件(防止重复运行)¶
LOCKFILE="/tmp/pipeline_$(basename "$0").lock"
if [[ -f "${LOCKFILE}" ]]; then
log_error "另一个实例正在运行(锁文件: ${LOCKFILE})"
exit 1
fi
trap 'rm -f "${LOCKFILE}"' EXIT # 退出时删除锁文件
echo $$ > "${LOCKFILE}" # $$ 是当前进程 PID
7.4 断点续跑¶
# 检查某步骤的输出是否已存在,已存在则跳过
run_step() {
local step_name="$1" # 步骤名称
local output_file="$2" # 该步骤的输出文件
shift 2 # 移除前两个参数,剩下的是实际命令
if [[ -f "${output_file}" ]]; then
log_info "[跳过] ${step_name}: 输出已存在 ${output_file}"
return 0
fi
log_info "[运行] ${step_name}"
"$@" # 执行剩余参数作为命令
log_info "[完成] ${step_name}"
}
# 使用:只有输出不存在时才运行
run_step "fastp质控" "clean/sample1_R1.fq.gz" \
fastp --in1 raw/sample1_R1.fq.gz --out1 clean/sample1_R1.fq.gz
7.5 配置文件读取¶
# --- config.ini 格式 ---
# RAW_DIR=/data/raw
# OUTPUT_DIR=/data/results
# THREADS=16
# 读取配置文件(跳过注释和空行)
if [[ -f config.ini ]]; then
while IFS='=' read -r key value; do
# 跳过注释行和空行
[[ "${key}" =~ ^#.*$ || -z "${key}" ]] && continue
# 去掉首尾空格
key=$(echo "${key}" | xargs)
value=$(echo "${value}" | xargs)
# 导出为环境变量
export "${key}=${value}"
done < config.ini
log_info "已加载配置文件 config.ini"
fi
八、面试怎么答¶
Q1: 你写 Shell 脚本时会做哪些错误处理?¶
第一行加
set -euo pipefail:-e让命令失败时立即退出,-u让未定义变量报错,pipefail让管道中任一命令失败都会被捕获。然后用trap cleanup EXIT注册清理函数,确保临时文件被删除。关键步骤还会检查上一个命令的退出码$?,并用自定义退出码区分不同类型的错误。
Q2: 如何让 Shell 脚本支持断点续跑?¶
核心思路是"检查输出文件是否已存在"。每个分析步骤运行前,先检查它的输出文件是否存在且完整。如果存在就跳过,不存在就运行。我通常会封装一个
run_step函数,接收步骤名、输出文件和实际命令作为参数。这样流程中断后重新运行,已完成的步骤会自动跳过。
Q3: 怎么保证 Shell 脚本的质量?¶
三方面:第一是用 ShellCheck 做静态检查,它能发现变量没加引号、退出码被掩盖等常见 bug;第二是用 bats-core 写单元测试,测试关键函数的行为;第三是代码规范,比如统一使用双引号保护变量、用函数组织代码、每个脚本有注释头说明用法和依赖。
Q4: 如何并行处理多个样本?¶
最常用的是 GNU Parallel,比如
parallel -j 8 'fastp ...' ::: sample_list,它自动分配任务到指定数量的 CPU 核心。相比xargs -P,parallel 支持更好的进度显示和错误处理。另一个方案是用 Makefile 管理流程,make -j 4可以根据文件依赖关系自动并行。
Q5: Makefile 在生信流程中有什么优势?¶
Makefile 通过文件时间戳追踪依赖关系。比如质控→组装→分箱三步,如果只是修改了组装的参数,
make会自动跳过质控、重跑组装和分箱。它还支持-j并行执行独立任务。对比 Snakemake/Nextflow,Makefile 更轻量,不需要额外安装,适合中小规模流程。
九、速查表¶
脚本头部模板速查¶
常用 set 选项¶
| 选项 | 作用 | 白话 |
|---|---|---|
-e | 命令失败退出 | 出错就停 |
-u | 未定义变量报错 | 打错变量名不会变成空 |
-o pipefail | 管道失败传播 | 管道里任一步出错都算失败 |
-x | 打印每条执行的命令 | Debug 模式 |
trap 信号速查¶
| 信号 | 触发时机 | 常见用途 |
|---|---|---|
EXIT | 脚本退出(任何原因) | 清理临时文件 |
ERR | 命令出错 | 记录错误日志 |
INT | Ctrl+C | 优雅中断 |
TERM | kill 命令 | 优雅终止 |
ShellCheck 常见规则¶
| 规则号 | 问题 | 修复 |
|---|---|---|
| SC2086 | 变量没引号 | 加双引号 "$var" |
| SC2155 | local+赋值同行 | 拆成两行 |
| SC2034 | 变量未使用 | 删除或使用 |
| SC2164 | cd 没检查返回值 | cd dir \|\| exit |
| SC2006 | 用反引号 | 改用 $(cmd) |
退出码速查¶
| 退出码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 一般错误 |
| 2 | 参数错误 |
| 126 | 权限不足 |
| 127 | 命令不存在 |
| 130 | Ctrl+C 中断 |
| 137 | 被 kill -9 终止 / OOM |
十、延伸资源¶
| 资源 | 说明 | 地址 |
|---|---|---|
| ShellCheck 在线版 | 粘贴脚本即检查 | https://www.shellcheck.net |
| bats-core 文档 | 测试框架完整文档 | https://bats-core.readthedocs.io |
| Google Shell 风格指南 | 工业级 Shell 编码规范 | https://google.github.io/styleguide/shellguide.html |
| GNU Make 手册 | Makefile 完整参考 | https://www.gnu.org/software/make/manual/ |
| GNU Parallel 教程 | 并行执行工具教程 | https://www.gnu.org/software/parallel/parallel_tutorial.html |
| Bash Pitfalls | 常见 Bash 陷阱集合 | https://mywiki.wooledge.org/BashPitfalls |
| 知识库 06 | Linux 基础命令(本项目) | knowledge_base/06_Linux与Shell脚本.md |
| 知识库 20 | awk/sed/进程管理进阶 | knowledge_base_2/20_Linux命令行进阶.md |
与 06/20 的分工:06 讲"怎么用 Linux 命令",20 讲"awk/sed/进程管理的进阶用法",本篇讲"怎么把命令组织成可靠的工程级脚本"——从结构规范、错误处理、测试、到流程管理。