3. DSL2 语法详解:模块化语法、函数定义、参数化进程

好,咱们今天聊点硬核的。DSL2 语法,说白了就是 Nextflow 的一次大升级。我个人习惯把 DSL1 比作“摊大饼”,所有逻辑都糊在一个文件里;DSL2 更像是搭乐高,一个模块一个模块拼起来。做 RNA-seq 审计追踪,这步是地基。地基不稳,后面那些花里胡哨的审计报告全是空中楼阁。

3.1 为什么我执着于模块化

我记得刚入行那会儿,项目组有个 RNA-seq 流程,光是一个 main.nf 就写了 3000 行。后来要加一个新工具,改一处进程参数,牵扯出来 5 个地方要同步改。上线那天,我盯着日志看了两个小时,最后发现是参数名写串了。那滋味,不好受。

DSL2 的模块化,就是为了根治这种“屎山”。它让你把每个功能拆成一个独立的 .nf 文件,需要什么就 include 什么。你不觉得这就像审计追踪里那个“可追溯性”嘛?每个模块都有明确的入口和出口,改动一个模块,不会炸掉整个流程。

核心思想:模块即隔离。每个进程的输入、输出、参数、脚本都封装在一起,互不干扰。

3.2 定义你的第一个模块

咱们直接上代码。我建议你先创建一个 modules/ 目录,把每个工具单独放一个文件。

// modules/quant.nf
process SALMON_QUANT {
    // 这里声明输入输出,后面详细讲
    tag "Salmon quant: ${sample_id}"
    publishDir "${params.outdir}/quant", mode: 'copy'

    input:
    path fastq
    val sample_id

    output:
    path "quant_${sample_id}"

    script:
    """
    salmon quant \
        -i salmon_index \
        -l A \
        -r ${fastq} \
        -o quant_${sample_id}
    """
}

注意看,这个文件只包含一个 process。它不关心谁调它,也不关心上游是谁。你只要给它一个 fastq 文件和一个 sample id,它就把定量结果吐出来。这种“黑盒”设计,非常符合审计追踪的思路——每个步骤的输入输出都有明确的边界。

我个人习惯在模块文件名上加前缀,比如 quant_salmon.nf、qc_fastp.nf。这样一看就知道这个模块属于哪个阶段。

3.3 怎么调用模块?用 include!

在 DSL2 里,把模块拉进来用 include 就行。你想想看,这比 DSL1 的 require 优雅多了吧?

// main.nf
#!/usr/bin/env nextflow
nextflow.enable.dsl=2

include { SALMON_QUANT } from './modules/quant.nf'

workflow {
    // 假设有个 channel 叫 raw_reads
    SALMON_QUANT(raw_reads, sample_ids)
}

这就是一个最基本的调用链。你看着这个 workflow 块,是不是比之前一锅粥似的代码清晰很多?审计的时候,我只要看 workflow 块,就知道数据流是怎么走的:读入→定量→... 每一步都在哪里定义的?去对应的模块文件里找。这就是结构化的力量。

3.4 函数定义:把重复逻辑抽出来

模块化解决了进程的拆分,但有时候我们有一些纯逻辑,比如样本名字的格式化、文件路径的拼接、版本号的解析。这些写进进程里太啰嗦,写在 workflow 里又显得乱。怎么办?用函数。

DSL2 支持在 .nf 文件最外层定义函数,或者单独写一个 functions.nf,然后用 include 导入。我习惯把常用工具函数放在 lib/ 目录下。

// lib/utils.nf
def parse_sample_id(file_name) {
    // 假设文件名是: sample_A_1.fastq.gz
    def tokens = file_name.baseName.split('_')
    return tokens[0..-2].join('_')  // 返回 sample_A
}

def get_read_type(file_name) {
    if (file_name.contains('_1')) return 'R1'
    else if (file_name.contains('_2')) return 'R2'
    else return 'SE'
}

然后在主流程里调用:

// main.nf
include { parse_sample_id; get_read_type } from './lib/utils.nf'

workflow {
    fastq_ch
        .map { file ->
            def sid = parse_sample_id(file)
            def rtype = get_read_type(file)
            return tuple(sid, rtype, file)
        }
        .set { formatted_ch }
}

函数的好处是什么?我可以用单元测试单独测它。比如写个测试,给一个假文件名,看返回值对不对。这在审计追踪里很重要——你得证明你的数据处理逻辑是没问题的。函数越纯粹,越好测。

3.5 一张图看明白 DSL2 的结构

下面这张 SVG 图,是我自己画的结构关系。你仔细看,每个元素都有固定的位置和职责。

DSL2 模块化架构与审计追踪的关系 Process 模块 ( quant.nf / qc.nf ) 函数定义 ( utils.nf / formatters.nf ) Workflow 编排 ( main.nf ) 审计追踪日志层 记录每个模块的实例名称、参数、时间戳、输入输出哈希值 结构化数据结构层 标准化输入/输出 channel,确保参数化进程的健壮性 模块参数化后 审计日志自动丰富

3.6 参数化进程:让模块更灵活

模块要是写死了,那就成了一次性筷子。真正的生产流程,同一个工具可能用不同的参数跑两遍,比如第一次用默认参数,第二次用更严格的过滤参数。这个时候,参数化就派上用场了。

怎么做?在进程里用 params 命名空间来声明模块的专属参数。我建议你别把全局 params 直接塞进进程,而是通过 input 传进来。

// modules/qc_fastp.nf
process FASTP_QC {
    tag "Fastp: ${sample_id}"
    publishDir "${params.outdir}/qc", mode: 'copy'

    input:
    path reads
    val sample_id
    val extra_params   // 比如 "--cut_front --cut_tail"

    output:
    path "*.html"
    path "*.json"

    script:
    """
    fastp \
        -i ${reads} \
        -o ${sample_id}_clean.fq.gz \
        ${extra_params} \
        -h ${sample_id}_fastp.html \
        -j ${sample_id}_fastp.json
    """
}

然后调用时,动态传参:

// main.nf 片段
include { FASTP_QC } from './modules/qc_fastp.nf'

workflow {
    // 假设 reads_ch 和 sample_ch 已经准备好
    default_params = Channel.value("")
    strict_params  = Channel.value("--cut_front --cut_tail -q 20 --n_base_limit 5")

    // 第一次跑:默认
    FASTP_QC(reads_ch, sample_ch, default_params)
    
    // 第二次跑:严格
    FASTP_QC(reads_ch, sample_ch, strict_params)
}
千万别在进程内部写死参数!我曾经在一个老项目里看到,一个 trimmomatic 进程里直接写了 LEADING:3 TRAILING:3,后来导师说要用 SLIDINGWINDOW:4:15,结果我手动改了 7 个地方。嗯,以后再也不想这样干了。DSL2 的参数化进程,就是为了让我们不再干这种蠢事。

3.7 input 声明与元组传递

在 DSL2 里,input: 块支持多种类型:path、val、env、tuple。我个人最常用 tuple,因为它能打包多个数据,让 channel 更整洁。

process MULTI_INPUT {
    input:
    tuple val(sample_id), path(fastq_r1), path(fastq_r2), val(extra_args)
    
    // 这样传进来的一个 channel item 就包含四个元素
    // 审计日志里可以直接记录这个 tuple 的内容
}

你看,这种结构化的输入,天然适合审计追踪。我只要把 tuple 里每个字段的含义记录在案,后续回看时数据流一目了然。

3.8 本章小记与个人经验

说实话,我最早做 RNA-seq 流程重构时,DSL2 刚出 Beta。我当时一个晚上把 3000 行的 DSL1 拆成了 12 个模块,第二天 CI 全过了。那种爽感,不亚于审计报告一次性通过。

模块化、函数定义、参数化进程,这三者加在一起,就是一套强健的结构化体系。你想想看,如果每个进程的输入输出都是参数化的、可追溯的,那后续做审计日志生成时,你只需要遍历 workflow 的执行信息,直接写出每个进程的实例 ID、输入值、输出路径和时间戳。这就是地基。

记住这条原则:能参数化的,绝不定死;能模块化的,绝不堆在一起;能用函数抽出来的,绝不写在进程里。

好,DSL2 的核心语法就到这。接下来的章节,咱们会在这个结构上,搭出真正的审计追踪中间层。