跳转至

Shell 脚本工程化:从"能跑"到"能用"

一句话说明:工程化的 Shell 脚本有规范结构、错误处理、日志记录和测试,让分析流程可靠、可维护、可交接——而不是只有写的人才能跑通的"一次性代码"。


一、为什么要"工程化"Shell 脚本

你可能觉得"Shell 脚本不就是把命令串起来嘛",但在实际工作中:

随手写的脚本工程化的脚本
没有注释,三个月后自己看不懂有注释头、功能说明、参数文档
出错了继续跑,最后结果是垃圾set -euo pipefail + trap 捕获错误,出错立即停
路径写死 /home/zhangsan/data参数化,用 getopts 接收输入
日志全混在 stdout 里有专门的日志函数,带时间戳和级别
临时文件散落一地trap 退出清理,mktemp 规范创建
换台服务器就跑不了依赖检查 + 环境变量配置

白话说:工程化 = 让脚本像正规软件一样可靠,而不是像草稿纸

面试加分:面试官看到你的脚本有 set -euo pipefail、有 trap、有日志函数,立刻知道你是有工程素养的人。


二、脚本结构规范

一个工程化 Shell 脚本应该包含以下部分(按顺序):

2.1 Shebang 行

#!/usr/bin/env bash
# 用 env bash 而不是 /bin/bash,因为不同系统 bash 路径可能不同
# env 会在 PATH 中找到 bash 的实际位置

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 ]
}

运行测试

bats test_utils.bats           # 运行单个测试文件
bats tests/                    # 运行目录下所有 .bats 文件
bats --tap tests/              # TAP 格式输出(CI 友好)

六、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 更轻量,不需要额外安装,适合中小规模流程。


九、速查表

脚本头部模板速查

#!/usr/bin/env bash
set -euo pipefail
# 脚本名 | 功能 | 作者 | 日期 | 版本 | 依赖 | 用法

常用 set 选项

选项作用白话
-e命令失败退出出错就停
-u未定义变量报错打错变量名不会变成空
-o pipefail管道失败传播管道里任一步出错都算失败
-x打印每条执行的命令Debug 模式

trap 信号速查

信号触发时机常见用途
EXIT脚本退出(任何原因)清理临时文件
ERR命令出错记录错误日志
INTCtrl+C优雅中断
TERMkill 命令优雅终止

ShellCheck 常见规则

规则号问题修复
SC2086变量没引号加双引号 "$var"
SC2155local+赋值同行拆成两行
SC2034变量未使用删除或使用
SC2164cd 没检查返回值cd dir \|\| exit
SC2006用反引号改用 $(cmd)

退出码速查

退出码含义
0成功
1一般错误
2参数错误
126权限不足
127命令不存在
130Ctrl+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
知识库 06Linux 基础命令(本项目)knowledge_base/06_Linux与Shell脚本.md
知识库 20awk/sed/进程管理进阶knowledge_base_2/20_Linux命令行进阶.md

与 06/20 的分工:06 讲"怎么用 Linux 命令",20 讲"awk/sed/进程管理的进阶用法",本篇讲"怎么把命令组织成可靠的工程级脚本"——从结构规范、错误处理、测试、到流程管理。