2. 工作流基础:DSL2语法与数据流

好,咱们直接切入正题。DSL2 是什么?我个人习惯把它看成是 Nextflow 工作流的“基础设施升级版”。如果你接触过 DSL1,你会发现 DSL2 最大的变化是:把流程拆得更细了,模块化做得更好,复用性也上去了。

说白了,DSL2 就是让你写流程的时候,不再是一大坨代码堆在一起。而是像搭积木一样,每个 process 是一块积木,channel 就是连接积木的轨道。

核心观点:DSL2 不是新语言,是新的组织方式。

2.1 DSL2 的核心语法结构

一个典型的 DSL2 工作流,大概长这样:

nextflow.enable.dsl=2

process FASTQC {
    input:
    path reads

    output:
    path "*.html"

    script:
    """
    fastqc ${reads}
    """
}

process TRIM_GALORE {
    input:
    path reads

    output:
    path "*.fq.gz"

    script:
    """
    trim_galore ${reads}
    """
}

workflow {
    FASTQC(ch_reads)
    TRIM_GALORE(FASTQC.out)
}

你看,这个结构很清晰:先定义 process,再在 workflow 块里串联起来。每个 process 只干一件事,输入输出都明确写出来。嗯,这里要注意——process 不能直接调用另一个 process,得通过 channel 传数据。

2.2 process 定义:输入、输出、脚本

每个 process 都是一个独立的任务。它有三大核心部分:

  • input 声明:定义这个任务需要什么数据。常见的有 path(文件路径)、val(值类型)、env(环境变量)。
  • output 声明:定义这个任务产出的数据。可以输出多个,甚至可以用 emit 给输出命名。
  • script 块:真正要执行的命令。可以是 shell 脚本、Python、R 等等。

举个例子,我之前在一个 RNA-seq 项目里,需要跑 STAR 比对。process 是这样定义的:

process STAR_ALIGN {
    input:
    path genome_index
    tuple val(sample_id), path(read1), path(read2)

    output:
    tuple val(sample_id), path("${sample_id}.Aligned.sortedByCoord.out.bam")

    script:
    """
    STAR --genomeDir ${genome_index} \
         --readFilesIn ${read1} ${read2} \
         --outFileNamePrefix ${sample_id}.
    """
}

这里有个小细节:我用 tuple 把 sample_id 和 reads 打包在一起。为什么要这么干?因为后续的 process 可能需要同时用到 IDs 和文件,打包传递最安全。

我的习惯:每个 process 的输出尽量加 emit 命名,这样 workflow 里调用时更清晰。比如 FASTQC.out.html 而不是 FASTQC.out[0]。

2.3 channel:数据流的管道

channel 是 Nextflow 里最核心的概念。它就是一个数据管道,数据从源头流向各个 process。channel 的类型决定了数据怎么传输、怎么分发。

类型 创建方式 行为特点
value channel Channel.value(x) 只存储一个值,可以被所有 process 共享
queue channel Channel.fromPath() 单向队列,一个元素只能被消费一次
channel of channels Channel.fromList() 嵌套 channel,用于复杂分组

举个例子,你有一个样本列表,想同时跑多个分析:

// 创建 queue channel
ch_samples = Channel.fromPath("data/*.fastq.gz")

// 创建 value channel(参考基因组只传一次)
ch_ref = Channel.value("/path/to/genome.fa")

process ALIGN {
    input:
    path reads
    path reference

    script:
    """
    bwa mem ${reference} ${reads} > aligned.sam
    """
}

workflow {
    ALIGN(ch_samples, ch_ref)
}

你想想看,如果我用 Channel.value() 传基因组文件,它会被所有任务共享,而不会消耗掉。但 queue channel 里的 reads 文件,一个任务拿走就没了,别人拿不到。

我曾经踩过坑:在 DSL2 中,同一个 queue channel 不能在 workflow 里被两个不同的 process 直接消费。否则其中一个 process 拿不到数据,直接报空指针。解决办法是用 .view() 或者 .buffer() 预先复制一份。

2.4 workflow 块:流程编排的核心

workflow 是 DSL2 的“总调度室”。它决定数据从哪里来、经过哪些 process、最后输出什么。

基本语法也很简单:

workflow {
    // 定义输入
    reads_ch = Channel.fromFilePairs("data/*_{R1,R2}.fastq.gz")

    // 调用 process,注意这里是直接传 channel
    FASTQC(reads_ch)
    TRIM_GALORE(FASTQC.out)

    // 也可以显式指定输出
    FASTQC.out.view { "QC report: $it" }
    TRIM_GALORE.out | VIEW
}

process VIEW {
    input:
    path trimmed

    script:
    """
    ls -lh ${trimmed}
    """
}

这里有个小技巧:你可以用 | 管道符把 channel 串起来。但说实话,我在项目里用得不多,因为直接传参数更直观。

2.5 模块化设计:复用与组合

DSL2 最大的优势是模块化。你可以把常用的 process 封装成模块,放在 modules/ 目录下。然后在主流程里引用。

比如,我整理了一套 QC 模块:

// modules/fastqc.nf
process FASTQC {
    input:
    path reads

    output:
    path "*.html", emit: report

    script:
    """
    fastqc ${reads}
    """
}

// main.nf
include { FASTQC } from './modules/fastqc'

workflow {
    reads = Channel.fromPath("data/*.fastq.gz")
    FASTQC(reads)
}

这个好处太明显了。你不同项目之间可以复用模块,团队协作时每个人的 process 定义都统一。

我个人建议:把每一步的输入、输出都写清楚,尤其是输出用 emit 命名。这样 workflow 调用时,就能直接用 FASTQC.out.report 拿到结果了。

2.6 数据流的常见模式

在实际项目中,数据流不会这么简单。我列出几种我遇到过的典型模式:

  • 一对多分发:一个 channel 里的元素,被多个 process 独立消费。比如同一个样本同时跑 QC 和比对。
  • 多对一合并:多个 channel 的数据合并成一个 tuple,传给一个 process。比如把样本 IDs 和文件路径打包。
  • 分支与合并:先分叉成两条路径,各自处理,最后再汇合。比如测序数据先过滤,然后一条做比对,一条做定量,最后对比结果。

举个例子:

workflow {
    reads = Channel.fromFilePairs("data/*_{R1,R2}.fastq.gz")
    
    // 分支1:比对
    STAR_ALIGN(genome_index, reads)
    
    // 分支2:定量
    SALMON_QUANT(salmon_index, reads)
    
    // 合并结果
    COMPARE_RESULTS(STAR_ALIGN.out, SALMON_QUANT.out)
}

你想想看,这样写是不是很顺?数据流清晰,每个 process 职责单一,维护起来也方便。

下面是 DSL2 核心构件关系图:

DSL2 工作流核心构件关系图 输入数据 (Channel) Process A(QC) Process B(比对) queue channel queue channel Process C(合并) Process D(报告) value channel value channel 最终输出结果 channel 传递数据驱动流程 workflow 编排整个过程

这张图展示的是 DSL2 里最典型的数据流模式:输入 channel 先分发给两个 process,各自处理完成后,再通过不同的 channel 汇合到下游 process,最后输出结果。workflow 块负责把这些组件串联起来。

2.7 避坑指南:我踩过的几个坑

讲几个我实际遇到过的问题:

  • channel 被多次消费:在 DSL2 中,同一个 queue channel 不能被两个 process 同时消费。如果你需要多路分发,用 .into{} 复制。
  • process 命名冲突:模块化后,同一个 process 名可能出现在不同模块里。解决办法是用 include 时指定别名,比如 include { FASTQC as MY_FASTQC }。
  • 输出不完整:process 的 output 声明必须和实际产生的文件匹配。不匹配的话,流程会卡住。

一个小技巧:调试时多用 .view() 打印 channel 里的内容。比如 ch_samples.view { "Sample: $it" }。这样一眼就能看出数据流对不对。

好了,DSL2 的基础内容就这些。你看,核心就是 process + channel + workflow 这三个东西。记住它们的关系,后面学高阶用法就顺了。