3. Nextflow基础语法:DSL2,进程,通道,参数
从这一章开始,我们要真正上手写 Nextflow 了。我接触 Nextflow 那会儿还是 DSL1 的时代,后来升级到 DSL2,说实话变化不小。但你别怕,DSL2 的语法更干净、更模块化,尤其适合做特征学习这种多步骤的管线。
我个人习惯,学任何工具先抓住三样东西:怎么定义一个任务(进程),怎么在任务间传数据(通道),怎么让任务行为可调(参数)。掌握了这三个,你就能搭起一条能跑的管线了。下面我一个一个讲。
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" }。它会在控制台打印每个流过通道的元素,帮你确认数据没问题。