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 图,是我自己画的结构关系。你仔细看,每个元素都有固定的位置和职责。
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 的核心语法就到这。接下来的章节,咱们会在这个结构上,搭出真正的审计追踪中间层。