3. DSL2 语法基础:模块化与复用

DSL2 是 Nextflow 的一次大升级。说白了,它让咱们能把 Pipeline 拆成一个个小积木,哪儿需要哪儿拼。我刚开始接触 DSL2 时也觉得麻烦,后来项目规模一上来,才明白这东西有多香——没有模块化,维护成本能涨到天上去。

这一章咱们就聊聊模块化的核心:怎么定义模块、怎么复用、怎么把模块串成流程。你不用背语法,记住几个常用套路就行。

3.1 为什么需要模块化

你想想看,一个 Pipeline 跑几十个步骤,所有代码塞在一个 main.nf 里——那画面太美我不敢看。我曾经接手过一个项目,main.nf 四千多行,光是找某个 process 的定义就得翻半天。后来重构用 DSL2 拆成模块,十几个文件,每个文件负责一件事,改起来爽多了。

模块化好处很明显:

  • 可读性——每个文件只干一件事,名字就能说明白
  • 可复用——写一次,到处 import,不用重复造轮子
  • 测试方便——模块独立,单测更简单
  • 团队协作——不同人改不同文件,冲突少

DSL2 通过 include 语句和 module 文件实现模块化。它的基本思路是:把 process 或 workflow 定义在单独文件里,主流程用 include 引入。

3.2 模块文件与 include

一个模块文件通常以 .nf 结尾。我习惯把每个模块文件放在 modules/ 目录下,按功能分类。比如:

modules/
├── fastqc.nf
├── trimming.nf
└── alignment.nf

模块文件里只写 process 定义,不写 workflow 主体。举个最简单的例子:

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

    output:
    path "*.html"
    path "*.zip"

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

在主流程文件里引入:

// main.nf
nextflow.enable.dsl=2

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

workflow {
    FASTQC( Channel.fromPath('*.fastq') )
}

这样写,主流程一目了然。如果模块有多个 process,可以用 { PROC_A; PROC_B } 格式一次引入多个。也可以给引入的 process 重命名避免冲突:

include { FASTQC as MY_FASTQC } from './modules/fastqc'
我的小习惯: 模块文件名和 process 名保持一致,找起来方便。比如 process 叫 FASTQC,文件就叫 fastqc.nf。大小写无所谓,但团队统一最好。

3.3 参数传递与模块化

模块里的 process 要用参数怎么办?DSL2 里参数传递有两种方式:

  1. 通过输入通道传——最推荐,显式、清晰
  2. 通过 params 全局变量——方便但容易搞乱命名空间

我一般只用第一种,因为全局变量在模块多了之后很容易冲突。举个例子:trimming 模块需要传一个质量阈值:

// modules/trimming.nf
process TRIMMOMATIC {
    input:
    path reads
    val quality

    output:
    path "trimmed_*.fq"

    script:
    """
    trimmomatic SE $reads trimmed_${reads} LEADING:$quality TRAILING:$quality
    """
}

主流程里传参:

include { TRIMMOMATIC } from './modules/trimming'

workflow {
    fastqc_ch = FASTQC( input_ch )
    trimmed_ch = TRIMMOMATIC( fastqc_ch, params.quality ?: 20 )
}

记得在 nextflow.config 里定义默认参数:

params {
    quality = 20
}
注意: 模块里不要直接用 params.xxx,除非你非常清楚这样做的后果。我曾经因为这个导致不同环境参数不一致,排查了半天。宁可在 workflow 层传值,也别偷懒。

3.4 构建可复用的 subworkflow

除了单个 process,你还可以把一组 process 打包成 subworkflow。subworkflow 就是一个 workflow 定义在模块文件里,主流程再 include 过来用。

举个例子,做 RNA-seq 的“比对+定量”经常一起出现,我习惯把它封装成一个 subworkflow:

// modules/rnaseq.nf
process STAR {
    input:
    path reads
    path index

    output:
    path "Aligned.sortedByCoord.out.bam"

    script:
    """
    STAR --genomeDir $index --readFilesIn $reads --outSAMtype BAM SortedByCoordinate
    """
}

process FEATURECOUNTS {
    input:
    path bam
    path gtf

    output:
    path "counts.txt"

    script:
    """
    featureCounts -a $gtf -o counts.txt $bam
    """
}

workflow RNASEQ_ALIGN {
    take:
    reads
    index
    gtf

    main:
    STAR(reads, index)
    FEATURECOUNTS(STAR.out, gtf)
}

主流程里只要一行:

include { RNASEQ_ALIGN } from './modules/rnaseq'

workflow {
    RNASEQ_ALIGN( channel.fromPath('*.fastq'), params.index, params.gtf )
}

这样,一个完整的 RNA-seq 比对流程就被“封装”成了一个黑盒子,主流程干净得像说明书。

3.5 模块化设计的最佳实践

我踩过不少坑,总结了几条原则:

原则 说明
单一职责 一个 process 只做一件事,一个模块只放功能相近的 process
显式输入输出 用通道显式传递数据,别依赖全局变量或环境变量
参数默认值 在 nextflow.config 里给所有参数设默认值,模块里只用传进来的值
命名规范 模块文件名与 process/workflow 名保持一致,大小写统一
避免循环依赖 模块之间不要互相 include,否则容易形成死循环

另外,我强烈建议给每个模块写一个简短的 README,说明输入输出是什么、依赖哪些工具。团队协作时这能省下很多沟通成本。

3.6 一张图看清模块化结构

下面这张图展示了 DSL2 模块化的典型结构:主流程文件 main.nf 通过 include 引入多个模块,每个模块包含若干 process 或 subworkflow,数据在通道中流动。

DSL2 模块化结构图 main.nf modules/fastqc.nf modules/trimming.nf modules/alignment.nf process FASTQC process TRIMMOMATIC process STAR 通道: .html .zip 通道: trimmed.fq 通道: .bam workflow 组合结果 每个模块可独立开发、测试,主流程通过 include 和通道串联
核心思想: 每个模块是一个黑盒子,只暴露明确的输入输出。主流程只负责编排,不管内部细节。这样改一个模块不会影响到其他模块,测试和部署都轻松很多。

说到测试,我在项目中总是为每个模块写一个独立的测试流程,用 include 引入模块后,用一个小数据集跑一遍,确保没改坏。这种“模块级回归测试”配合 CI,能提前拦住 90% 的集成问题。

3.7 避坑指南

  • 路径问题: include 路径用双引号括起来,相对路径相对于当前文件。我试过用单引号,结果 Nextflow 报错说找不到文件——嗯,以后乖乖用双引号。
  • 命名冲突: 多个模块可能有同名的 process,用 as 重命名。我有一次忘记重命名,两个模块的 process 互相覆盖,跑出来的结果全是错的……排查了一下午。
  • 版本控制: 模块文件一定要放进 git,并且在模块入口使用语义版本号。我习惯在模块第一行加注释 // version 1.2.0,方便追踪。
  • 不要滥用 subworkflow: 如果 subworkflow 里只有一个 process,那就别包一层,直接 include process 更清爽。