2. Nextflow 核心概念:进程、通道、工作流、DSL2
好,咱们开始聊 Nextflow 最核心的四个概念。说实话,刚接触 Nextflow 那会儿,我被这些术语绕得有点晕。但后来我发现,理解它们其实不需要死记硬背,关键是要搞懂它们各自在管线里到底扮演什么角色。
用一句话概括:进程是干活的,通道是传数据的,工作流是编排的,DSL2 是让这一切更清晰的组织方式。
2.1 进程(Process):管线的执行单元
进程,说白了就是每个独立的计算任务。比如运行 BWA、执行 Python 脚本、调用 R 做统计,都可以包装成一个进程。
每个进程都包含三个核心部分:指令(directives)、输入(input)、输出(output)。还有一个最重要的——脚本块(script block),里面写实际要执行的命令。
我个人习惯这样组织进程的骨架:
process ALIGNMENT {
// 指令:指定资源、容器等
tag "样本名: ${sample_id}"
cpu 4
memory 8.GB
container 'biocontainers/bwa:latest'
input:
path reference
path reads
output:
path "*.bam", emit: aligned_bam
script:
"""
bwa mem $reference $reads | samtools sort -o ${reads.baseName}.bam
samtools index ${reads.baseName}.bam
"""
}
看到没?input 和 output 像合同一样规定了数据怎么进去、怎么出来。script 块里写的就是传统 Shell 命令,Nextflow 会自动把 input 变量替换成实际路径。
$variable 是 Nextflow 变量替换。如果想输出真实的 $ 符号给 Shell,必须用 \$ 转义。
进程还有几种特殊类型,最常用的是 exec 块——不启动子进程,直接在 Java 虚拟机里运行 Groovy 代码:
process SPARK_TASK {
executor 'local'
exec:
// 直接运行 Groovy 方法,适合轻量处理
println "总计 ${samples.size()} 个样本"
}
2.2 通道(Channel):数据的管道
通道是 Nextflow 里最让我觉得妙的设计。它不存数据本身,而是定义了数据流动的路径和方式。你可以把它想成一条水管——进程是水阀,通道是连接水阀的管子。
常见的通道创建方式:
| 创建方式 | 示例 | 说明 |
|---|---|---|
Channel.fromPath() |
Channel.fromPath("data/*.fastq") |
根据文件路径模式创建通道,会自动通配 |
Channel.of() |
Channel.of("sample1", "sample2") |
直接传入一组元素 |
Channel.fromList() |
Channel.fromList(sample_list) |
从列表创建通道 |
Channel.value() |
Channel.value("参考基因组hg38") |
创建只持有单个值的通道 |
这里有个知识点很容易混淆:队列通道(queue channel)和值通道(value channel)的区别。
- 队列通道:可以多次消费,每次消耗一个元素。像队列一样,元素被取走后就没了。
- 值通道:绑定一个值,任意多次消费都不会消失。比如参考基因组这种全局共享的输入就适合用值通道。
我来个对比案例:
// 队列通道——只消费一次
ch_samples = Channel.fromPath("data/*.fastq")
// 值通道——可反复使用
ch_ref = Channel.value("hg38.fa")
process ALIGN {
input:
path fastq
path reference
script:
"""
bwa mem $reference $fastq > result.bam
"""
}
// 如果 reference 是队列通道,第二次消费时会报错
workflow {
ALIGN(ch_samples, ch_ref)
}
Channel.value()。样本级别的输入用 Channel.fromPath()。我见过有人把参考基因组写成队列通道,结果跑第二批样本时发现通道已经空了……那叫一个尴尬。
2.3 工作流(Workflow):把进程粘起来
工作流的作用就是定义进程之间的连接关系。在 DSL1 时代,我们直接在脚本里调用进程函数。DSL2 引入了一等公民的 workflow 块,让逻辑更清晰。
简单工作流写法:
workflow {
// 创建输入通道
reads_ch = Channel.fromPath("data/*_R{1,2}.fastq")
.map { it -> tuple(it.baseName, it) }
.groupTuple()
// 调用进程
ALIGNMENT(reads_ch)
QUALTRIM(ALIGNMENT.out.aligned_bam)
VARIANTCALL(QUALTRIM.out.trimmed_bam)
}
你想想看,这种写法是不是像在搭积木?每个进程的输出通过 .out 属性暴露,然后直接传给下一个进程的输入。
对于复杂的管线,我习惯把工作流拆成子工作流:
workflow QC_PIPELINE {
take:
raw_reads
main:
FASTQC(raw_reads)
MULTIQC(FASTQC.out.html_reports)
emit:
MULTIQC.out.summary
}
workflow MAIN {
reads_ch = Channel.fromPath("data/*.fastq")
// 调用子工作流
qc_result = QC_PIPELINE(reads_ch)
ALIGNMENT(reads_ch, qc_result)
}
2.4 DSL2 语法:从混乱到有序
我用 DSL1 写过一阵子,说实话,那个阶段模块化很弱。所有的代码都塞在一个 .nf 文件里,几百行下来自己都看不懂。DSL2 最大的贡献是:引入模块系统和明确的作用域。
DSL2 的核心变化:
- 模块化:每个进程定义在单独的
.nf文件中,用include引入。 - workflow 块:取代了全局的进程调用,成为管线入口。
- emit 关键字:显式声明输出通道的命名。
- take/main/emit 块:子工作流有明确的输入输出接口。
看一个模块化的例子:
// 模块文件:modules/align.nf
process BWA_ALIGN {
tag "对齐: ${sample_id}"
input:
tuple val(sample_id), path(reads)
output:
tuple val(sample_id), path("*.bam"), emit: bam
script:
"""
bwa mem ref.fa $reads > ${sample_id}.bam
"""
}
// 主文件:main.nf
nextflow.enable.dsl=2
include { BWA_ALIGN } from './modules/align.nf'
workflow {
Channel.fromFilePairs("data/*_{1,2}.fastq")
.set { reads_ch }
BWA_ALIGN(reads_ch)
BWA_ALIGN.out.bam.view()
}
2.5 知识体系框架图
下面这张图总结了本章的核心概念和关系:
2.6 总结与建议
嗯,到这里核心概念基本讲完了。我再给几个实在的建议:
- 开始写第一个管线时,别追求复杂:先用两个进程和一个通道串起来跑通,再慢慢加功能。
- 养成模块化的习惯:哪怕只有三个进程,也把它们拆到不同的文件里。后面扩展起来会很舒服。
- 通道命名要有意义:别用
ch1、ch2。我因为偷懒吃过亏,三天后看代码完全不知道传的是什么数据。 - 用
-process.view()调试:加一行通道名.view()就能看到数据流,极其方便。我调试管线时至少一半时间靠它。