第三章 进程定义与参数:input/output 声明、变量传递、脚本编写
说实话,刚开始写 Nextflow 工作流的时候,最容易搞混的就是进程里的 input/output 到底怎么写。DSL2 之后其实规范了很多,但坑还在。这一章我们就从最基础的 process 骨架开始,把输入输出、变量传递和脚本编写一次讲透。
3.1 进程的结构与基本语法
每个 Nextflow 进程都包含三个核心区域:input、output 和 script。它们的声明顺序是固定的——先 input,再 output,最后 script。别问我为什么,只能说是 DSL 的约定。
看个最简的例子:
process mySimpleTask {
input:
val my_value
output:
path "result.txt"
script:
"""
echo "Processing $my_value" > result.txt
"""
}
这里的 val my_value 接收一个普通的字符串或数字;path "result.txt" 声明进程会产生一个文件;script 部分用双引号包裹的字符串就是实际要执行的命令。注意,script 块里引用 input 变量直接写 $变量名 就好,但如果是文件路径呢?稍后有细节。
3.2 input 声明的几种姿势
input 声明不只是 val 和 path 两种,实际项目里我常用下面这些:
| 限定符 | 作用 | 示例 |
|---|---|---|
val |
接收任意标量(字符串、数字) | val sample_id |
path |
接收文件或目录路径(自动 stage 到工作目录) | path "input.bam" |
file |
与 path 类似,但已废弃(建议用 path) | file "input.fastq" (不推荐) |
env |
接收环境变量 | env PATH |
tuple |
接收一组值(常与 set 配合) |
tuple val(sample), path(reads) |
each |
将输入拆成多个进程实例(类似 for 循环) | each val(chromosome) |
我遇到过最坑的 case:用 file 限定符时,Nextflow 旧版本会把文件复制到工作目录并改名,导致下游路径错乱。从 DSL2 起请统一用 path,它会自动处理 staging。还有一个细节:如果输入的文件名里带通配符,比如 path "*.fastq",它会自动匹配目录下的所有文件,返回一个 file 列表。这时你必须在 script 里用 ${fileList} 来引用多个文件,不能用单个 $my_file。
tuple 最清晰。例如 tuple val(patient), path(reads1), path(reads2),这样在 script 里可以用 $reads1 和 $reads2 分别引用。
3.3 output 声明与连接下游
output 的语法和 input 几乎对称。但有一个很容易忽略的点:output 声明的顺序必须和 input 对应 channel 的 emit 顺序一致。举个例子:
process align {
input:
path reference
path reads
output:
path "aligned.bam"
path "alignment.log"
script:
"""
bwa mem $reference $reads > aligned.bam 2> alignment.log
"""
}
这个进程会输出两个文件。下游另一个进程如果要使用 aligned.bam,可以这样写:
process sort {
input:
path bam // 自动接收 align 输出的第一个文件
...
}
如果下游想同时接收两个文件,则 input 必须用 tuple:
process verify {
input:
tuple path(bam), path(log)
output:
...
}
你想想看,如果 output 的类型和数量对不上,Nextflow 就会报类型不匹配错。所以我的习惯是:每个进程的 input 和 output 声明写完后,先跑一次 nextflow run -stub 做空测试,确保输入输出能正确连接。
3.4 变量传递机制:params vs channel
这是新手最容易混淆的两个概念。简单粗暴地说:
- params:是运行时的配置参数,静态的,不参与数据流。例如参考基因组路径、线程数。
- channel:是动态的数据流,驱动进程执行。例如样本列表、测序数据文件。
在 script 块里,params.xxx 可以直接引用,但前提是你已经在 nextflow.config 或命令行里定义了它。比如:
// nextflow.config
params.threads = 8
params.ref = "/data/hg38.fa"
// 主脚本 main.nf
process bwa_mem {
input:
path reads
script:
"""
bwa mem -t ${params.threads} ${params.ref} $reads > result.sam
"""
}
注意,params 的引用要用 ${params.xxx} 而不是 $params.xxx,因为 $params 在双引号字符串里会被当作变量展开,但 Nextflow 的 DSL 对 $params 的解析有限制。我踩过这个坑:脚本里写 $params.threads,结果跑出来是空字符串。后来改成 ${params.threads} 就好了。
params 的内容是静态的,如果你需要根据输入文件动态计算某个值,请用 channel 传递。例如每个样本的临时目录路径,应该通过 tuple 携带,而不是写死在 params 里。
channel 传递变量更灵活。比如从 Channel.fromList 得到的样本名,可以用 val 直接传入进程。如果变量比较复杂(如样本名+路径+分组),就用 tuple 打包传递。
3.5 脚本编写的最佳实践
script 块里写的是真正的 shell 命令或脚本。这里有几个要点:
- 引用输入输出:使用
$变量名,但如果变量名后面紧跟着字母数字下划线,要用${变量名} - 多行脚本:用三个双引号
"""包裹,可以自由换行 - 导出环境变量:如果进程依赖外部程序路径,用
env输入或直接写在 shell 里 - 临时文件:进程有自己的工作目录,写完的临时文件如果不声明 output,运行结束会被自动清理
我见过一个很常见的 bug:在 script 里写 gzip $my_file,结果 $my_file 是一个列表,gzip 直接报错。这时候应该用 gzip ${my_file.join(' ')} 或者逐个处理。还有一个关于引号的坑:如果文件名里有空格,必须用双引号括起来,比如 "$my_file"。我曾经因为一个样本名带括号,导致整个 pipeline 中断,排查了半天才发现是 shell 解析的问题。
下面是一个完整且经过实战验证的进程模板:
process bwa_alignment {
tag { "$sample_id" } // 便于日志识别
cpus params.bwa_threads
memory params.bwa_mem.Gb
time params.bwa_time
input:
tuple val(sample_id), path(reads)
output:
path "${sample_id}.sam"
path "${sample_id}.bam"
script:
"""
# 创建临时目录,防止多进程冲突
mkdir -p tmp_${sample_id}
export TMPDIR=tmp_${sample_id}
# 运行比对
bwa mem \
-t ${task.cpus} \
-R '@RG\\tID:${sample_id}\\tSM:${sample_id}' \
${params.ref} \
$reads > ${sample_id}.sam
# 转换为 BAM 并排序
samtools view -bS ${sample_id}.sam | \
samtools sort -@ ${task.cpus} -o ${sample_id}.bam
# 清理
rm -rf tmp_${sample_id}
"""
}
注意这里 ${task.cpus} 是 Nextflow 自动注入的变量,等于该进程被分配到的 CPU 数。我在 SLURM 集群上经常结合 cpus 4 和 ${task.cpus} 来精确控制资源。
${} 引用变量,注意转义和引号。做到这三点,你的 pipeline 就能稳定跑起来。
最后,画一张图把整个流程串起来,方便你对照理解:
这张图里,左边是输入来源,中间是进程本体,右边是输出流向。你可以看到 Channel 和 Params 两条线进入同一个 Process,但性质完全不同。Channel 的每个元素会生成一个独立的进程实例,而 Params 所有实例共享同一份值。这在设计大规模并行流程时非常关键。