3. DSL2语法基础
DSL2 是 Nextflow 在 20.x 版本后主推的语法范式。说白了,就是把原来写在一坨 script 块里的逻辑拆得更干净——workflow 是骨架,process 是肌肉,take/emit 是血管。我刚开始切到 DSL2 时也不太适应,总觉得多了一层定义,但跑过几个真实项目后你就明白了:这种声明式的结构才是复杂多组学管线的救星。
workflow 块——管线的骨架
workflow 块定义了整个管线的执行顺序和数据流向。它不负责具体计算,只负责调度。在 DSL2 里,你可以在一个 .nf 文件里定义多个 workflow,但主入口只有一个无名称的 workflow 块(默认执行的)。
基本结构长这样:
workflow {
ch_input = Channel.fromPath("data/*.fastq")
FASTQC(ch_input)
outputs = FASTQC.out
outputs.view()
}
看到了吗?workflow 里直接调用了 process(这里叫 FASTQC),就像调用一个函数。ch_input 是数据通道,传给 process,然后拿到 outputs。整个流程是线性的,但 Nextflow 会自动并行化——你只管定义关系,执行交给工具。
核心要点:workflow 块里不能有 shell 命令,只能有通道操作、process 调用、嵌套 workflow 调用。它就像一张流程图,告诉你下一步该跑什么。
我遇到过新手直接把 shell 命令写在 workflow 块里,结果报错半天找不到原因。嗯,这里要记住:workflow 不干活,只派活。
process 定义——最核心的执行单元
process 是真正干活的地方。它封装了一段脚本(Bash、Python、R 等)以及所需的资源、输入、输出。定义 process 就像写一个函数,但它有特殊的指令区、输入区、输出区、脚本区。
标准格式:
process FASTQC {
// 指令区:资源、标签、容器等
cpus 2
memory 4.GB
container 'biocontainers/fastqc:v0.11.9'
// 输入声明
input:
path read_file
// 输出声明
output:
path "*.html", emit: html_report
path "*.zip", emit: zip_archive
// 脚本区
script:
"""
fastqc ${read_file}
"""
}
每个 process 都包含四个部分(按顺序):
- 指令区:设置 CPU、内存、容器、标签、publishDir 等。这些指令控制执行环境,不参与数据流。
- input 块:声明输入的类型和数量。类型可以是 path、val、env、stdin、tuple 等。Nextflow 会自动把通道里的数据映射到这里。
- output 块:声明输出的类型和文件名模式,并用
emit:给输出通道命名。这样 workflow 里就能用FASTQC.out.html_report拿到它。 - script 块:实际执行的命令行。可以写多行,用
def变量?可以,但最好保持可读性。
小技巧:如果你在 script 块里需要引用 input 中定义的名字,直接使用变量名就行。比如上面 ${read_file}。但注意,文件名本身要用引号括好,防止路径里有空格。
take/emit 声明式输入输出——数据流的干道
DSL2 的最大亮点就是强制你用 take 和 emit 来收拢参数。说人话:take 定义 process 的入口,emit 定义 process 的出口。有了这两样东西,workflow 里才能连起来。
先看一个带 take 的例子:
process ALIGN {
input:
tuple val(sample_id), path(reads)
output:
tuple val(sample_id), path("${sample_id}.bam"), emit: bam_file
script:
"""
bwa mem genome.fa ${reads[0]} ${reads[1]} | samtools sort -o ${sample_id}.bam
"""
}
这里 input: 声明了一个元组(sample_id + 两条 reads)。output: 声明了同样的 sample_id 和 bam 文件,并用 emit: bam_file 取名。之后在 workflow 里调用:
workflow {
CH_ALIGN = ALIGN(CH_SAMPLE_DATA)
CH_ALIGN.bam_file.view()
}
为什么这叫“声明式”? 因为你只说了“我要什么输入”、“我产生什么输出”,Nextflow 自动帮你做通道匹配和文件暂存。不用像 DSL1 那样手动在 script 里 echo 路径、用 file() 函数,麻烦事少了一半。
注意:emit 名称在整个 workflow 作用域里必须唯一。两个 process 不要用同一个 emit 名,否则会冲突。我一开始没注意这个,折腾了两小时…后来养成习惯给每个 process 的 emit 名称加个前缀(比如 FASTQC_qual、FASTQC_zips)。
实战示例——一个简化版 RNA-seq 表达定量管线
我们来看一个实际能跑通的例子。假设我们要从 FASTQ 到基因表达矩阵,取两步:fastp 质控 + salmon 定量。用 DSL2 写出来:
// 定义 process: fastp
process FASTP {
tag "${sample_id}"
cpus 2
memory 4.GB
container 'biocontainers/fastp:0.23.2'
input:
tuple val(sample_id), path(reads)
output:
tuple val(sample_id), path("${sample_id}_clean*"), emit: clean_reads
script:
def read1 = reads[0]
def read2 = reads.size > 1 ? reads[1] : null
if (read2) {
"""
fastp -i ${read1} -I ${read2} -o ${sample_id}_clean_R1.fastq.gz \
-O ${sample_id}_clean_R2.fastq.gz
"""
} else {
"""
fastp -i ${read1} -o ${sample_id}_clean.fastq.gz
"""
}
}
// 定义 process: salmon
process SALMON_QUANT {
tag "${sample_id}"
cpus 4
memory 8.GB
container 'biocontainers/salmon:1.9.0'
input:
tuple val(sample_id), path(clean_reads)
output:
path "${sample_id}/quant.sf", emit: counts
script:
"""
salmon quant -i /ref/salmon_index \
-l A \
-1 ${clean_reads[0]} \
-2 ${clean_reads[1]} \
-o ${sample_id}
"""
}
// 主 workflow
workflow {
// 生成通道:每个样本一个元组 (id, [R1,R2])
Channel
.fromFilePairs("data/*_R{1,2}.fastq.gz", flat: true) // 这里会得到每个样本的元组
.set { sample_reads }
// 调用 process
FASTP(sample_reads)
SALMON_QUANT(FASTP.out.clean_reads)
// 查看结果
SALMON_QUANT.out.counts.view()
}
你注意到没?workflow 里直接写了 FASTP(sample_reads) 和 SALMON_QUANT(FASTP.out.clean_reads)。这就是 DSL2 的优雅——数据流从通道到 process,再从 process 的 emit 到下一个 process,完全靠声明式连接。不用再写 from into 那些啰嗦的通道操作。
个人经验:在实际项目中,我建议把每个 process 放在独立的 .nf 文件里(比如 fastp.nf, salmon.nf),然后在主 workflow 里用 include { FASTP } from './modules/fastp.nf' 引入。这样管线维护起来清晰得多——改某个工具的参数不用翻遍整个大文件。
下面这张图帮你把这三个概念的关系串起来:
从这张图你能直观地看到:workflow 是整个操作的“大盒子”,里面装了两个 process;每个 process 有自己的 take 入口和 emit 出口;数据从通道流入,经过一个 process 加工后,自动流向下一个 process 的输入里。整个过程你只需要声明输入输出,剩下的调度、并行、文件暂存全部由 Nextflow 自动完成。
最后,我再强调三个容易踩的坑:
| 坑点 | 典型错误 | 正确做法 |
|---|---|---|
| input 类型不匹配 | workflow 给 process 传了一个 val,但 input 声明 path | 检查通道元素的类型,用 Channel.fromPath 或 .map 转换 |
| emit 名称重复 | 两个 process 的 emit 都叫 result |
加前缀或上下文命名,比如 fastp_result、salmon_result |
| output 模式写错 | path "*.html" 但实际生成了 sample.html.zip |
用 path "*.{html,zip}" 或更精确的 glob |
曾经踩过的坑:有次我在 output 里写了 path "*.bam",但实际跑完生成的是 .bam.bai 索引和 .bam 主文件。结果 Nextflow 只拿到了第一个匹配的 bam,索引丢了。后来我用 tuple path("*.bam"), path("*.bai") 分别 emit,问题解决。记住:一个 output 行只匹配一种模式,多种产出要写多行。
DSL2 的 take/emit 让管线变得像搭积木一样清晰。你只需要关注每个 process 的“输入输出协议”,剩下的交给 workflow 编排。多用几个项目,你自然就能体会到这种声明式的爽快——改参数、加步骤、重用模块,都比 DSL1 从容得多。