3. Nextflow基础语法:DSL2,进程,通道,参数

从这一章开始,我们要真正上手写 Nextflow 了。我接触 Nextflow 那会儿还是 DSL1 的时代,后来升级到 DSL2,说实话变化不小。但你别怕,DSL2 的语法更干净、更模块化,尤其适合做特征学习这种多步骤的管线。

我个人习惯,学任何工具先抓住三样东西:怎么定义一个任务(进程),怎么在任务间传数据(通道),怎么让任务行为可调(参数)。掌握了这三个,你就能搭起一条能跑的管线了。下面我一个一个讲。

DSL2 核心概念结构 params(参数) --input,--outdir 等 Channel(通道) Channel.fromPath, Channel.of, 队列 vs 值 Process(进程) input: val x, file f / output: path result / script: """ ... """ 输出通道(下一进程的输入) 自动连接,无需手动管理 DSL2 强制要求使用 process 和 channel 显式声明 参数通过 params 注入,保持管线可重复

3.1 DSL2——新一代语法

DSL2 是 Nextflow 22.04.0 之后默认的语法模式。说白了,它把管线定义得更“像代码”了——每个进程独立,数据流通过通道显式连接,不再依赖隐性的文件路径传递。

启用 DSL2 很简单,在脚本开头加一行声明:

nextflow.enable.dsl=2

如果你用的是最新版,这行其实可以省略。但我建议还是写上,尤其是团队协作时,它明确告诉阅读者:「我用的就是 DSL2,别拿老的思路来理解」。

我的习惯: 每个新项目的第一行都写 nextflow.enable.dsl=2,哪怕当前版本默认已经支持。这样保证了脚本在不同 Nextflow 版本之间的一贯性。曾经有一次我忘了加,同事的旧版跑起来报了一堆奇怪的错,排查了半天才发现是 DSL 模式不一致。

3.2 进程(process)核心写法

进程是 Nextflow 里最小的执行单元。你可以把它理解为一个函数——接收输入,执行一段脚本,产生输出。

一个简单的进程长这样:

process hello {
    input:
    val x

    output:
    stdout

    script:
    """
    echo "Hello $x!"
    """
}

看到这里你可能会问:为什么要用 val x 而不是直接写变量名?嗯,这里要注意——Nextflow 的进程声明是语法块,input: 和 output: 后面跟的是“声明式的接收端口”。

常用的输入输出类型包括:

类型关键字说明使用场景示例
val普通值(数字、字符串、列表)参数、索引、标记
path文件或目录路径输入数据文件、输出结果
env环境变量向任务注入环境变量
stdin标准输入管道传递文本内容
stdout标准输出收集命令打印结果

我刚开始接触时,最常踩的坑是 path 和 file 的混用。DSL2 里已经统一用 path 了,旧版 file 虽然还能用,但官方明确推荐使用 path。我曾经在一个项目中用了 file,结果升级 Nextflow 后报 deprecation warning,虽然能跑但看着烦,最后还是花时间全改成了 path。

注意: 进程的 script: 块里如果写多行命令,一定要用三个双引号包裹(""" ... """),并且里面的变量引用要加 $ 符号。如果你不想让 Nextflow 解析某个变量,可以用 \$ 转义。

3.3 通道(channel)——数据流的血管

通道是 Nextflow 里最巧妙的设计。它负责把数据从一个进程送到另一个进程,而且是异步的、有缓冲的。你不需要手动管理中间文件,也不需要担心并发竞争——通道帮你兜底。

创建通道的方法有好几种,我列出最常用的:

// 从一个列表创建
ch_samples = Channel.of(["sample1", "sample2", "sample3"])

// 读取文件路径
ch_fastqs = Channel.fromPath("data/*.fastq.gz")

// 从参数读取
ch_input  = Channel.fromPath(params.input)

// 创建值通道(只存一个值,可重复使用)
ch_ref = Channel.value(params.reference)

通道按行为分两类:队列通道 和 值通道。队列通道就是上面例子中 Channel.of、Channel.fromPath 创建的——消费一个元素就少一个,像队列。值通道用 Channel.value 创建,它永远只保存一个值,而且可以被多个进程重复取用,不会消耗掉。

你想想看,特征学习管线里经常需要一份参考文件(比如参考基因组)被多个进程同时用,这时候值通道就特别合适。队列通道则适合批量处理样本列表。

3.4 参数(params)——让管线可配置

参数是让管线从硬编码变成可复用的关键。在 Nextflow 里,所有 params.xxx 形式的东西都可以在命令行通过 --xxx 覆盖。

定义参数通常放在脚本最前面:

params.input      = "$projectDir/data/samples.csv"
params.outdir     = "$projectDir/results"
params.epochs     = 100
params.model_type = "resnet18"

然后在运行管线时,可以这样覆盖:

nextflow run main.nf --epochs 200 --model_type "vit"

我没少见新人写的管线直接在进程里写死路径,结果换个人换个机器就得改代码。我个人习惯把所有可变的量都抽象成 params,这样管线就是一份干净的“食谱”,你和别人只需要提供食材(参数)就行。

另外推荐一个好做法:在脚本头部用一个 log.info 或 println 把当前参数打印出来:

println """\
===================================
  特征学习管线参数
  输入文件 : ${params.input}
  输出目录 : ${params.outdir}
  训练轮数 : ${params.epochs}
===================================
"""

这样做的好处显而易见——你每次跑完都能确认参数被正确解析,不会出现“我以为传了 A 实际跑的是 B”的乌龙。

实际案例: 有一次我在调试一个特征提取管线,同事反馈说结果总是用错了模型。我让他加了一行打印参数,果然发现他传的 --model_type 拼写错了(model 少写了个 l),Nextflow 直接用了默认值。从那以后,我所有项目的管线开头都强制打印所有参数。

3.5 组合起来:一个完整的 DSL2 示例

下面是一个微型管线,演示了参数、通道、进程的配合:

nextflow.enable.dsl=2

params.input  = "$projectDir/samples.txt"
params.outdir = "$projectDir/results"

// 读取样本列表
Channel
    .fromPath(params.input)
    .splitCsv(sep: "\t")
    .map { row -> row[0] }
    .set { samples }

process sayHello {
    input:
    val sample_id

    output:
    path "${sample_id}.txt"

    script:
    """
    echo "Hello, $sample_id!" > ${sample_id}.txt
    """
}

workflow {
    sayHello(samples)
    sayHello.out.collectFile(name: "greetings.txt", storeDir: params.outdir)
}

这个例子很简单,但它包含了 DSL2 的标志性写法:workflow { } 块负责编排进程。你不需要手动定义 process.all 或 workflow.onComplete 之类的回调,只要把需要的进程和通道连起来,Nextflow 会自动推导执行顺序。

最后提醒一句:通道操作符(如 .map、.filter、.collectFile)是 Nextflow 的精华,这些东西通常比在进程里写批量处理更优雅、更高效。我们下一节会更深入讲操作符,但本章先把基础结构扎稳。

调试小技巧: 想看看某条通道里到底有什么数据?在 workflow 里临时加一个 .view() 就行:samples.view { "当前样本: $it" }。它会在控制台打印每个流过通道的元素,帮你确认数据没问题。