第三章 进程定义与参数:input/output 声明、变量传递、脚本编写

说实话,刚开始写 Nextflow 工作流的时候,最容易搞混的就是进程里的 input/output 到底怎么写。DSL2 之后其实规范了很多,但坑还在。这一章我们就从最基础的 process 骨架开始,把输入输出、变量传递和脚本编写一次讲透。

3.1 进程的结构与基本语法

每个 Nextflow 进程都包含三个核心区域:input、output 和 script。它们的声明顺序是固定的——先 input,再 output,最后 script。别问我为什么,只能说是 DSL 的约定。

看个最简的例子:

process mySimpleTask {
    input:
        val my_value

    output:
        path "result.txt"

    script:
        """
        echo "Processing $my_value" > result.txt
        """
}

这里的 val my_value 接收一个普通的字符串或数字;path "result.txt" 声明进程会产生一个文件;script 部分用双引号包裹的字符串就是实际要执行的命令。注意,script 块里引用 input 变量直接写 $变量名 就好,但如果是文件路径呢?稍后有细节。

关键规则:进程的 input 和 output 声明顺序必须一一对应 channel 的数据结构。如果输入是一个 tuple,那么 output 也必须是一个 tuple,否则下游会接错。

3.2 input 声明的几种姿势

input 声明不只是 val 和 path 两种,实际项目里我常用下面这些:

限定符 作用 示例
val 接收任意标量(字符串、数字) val sample_id
path 接收文件或目录路径(自动 stage 到工作目录) path "input.bam"
file 与 path 类似,但已废弃(建议用 path) file "input.fastq" (不推荐)
env 接收环境变量 env PATH
tuple 接收一组值(常与 set 配合) tuple val(sample), path(reads)
each 将输入拆成多个进程实例(类似 for 循环) each val(chromosome)

我遇到过最坑的 case:用 file 限定符时,Nextflow 旧版本会把文件复制到工作目录并改名,导致下游路径错乱。从 DSL2 起请统一用 path,它会自动处理 staging。还有一个细节:如果输入的文件名里带通配符,比如 path "*.fastq",它会自动匹配目录下的所有文件,返回一个 file 列表。这时你必须在 script 里用 ${fileList} 来引用多个文件,不能用单个 $my_file。

小经验:当进程需要多个输入文件且文件名不同时,用 tuple 最清晰。例如 tuple val(patient), path(reads1), path(reads2),这样在 script 里可以用 $reads1 和 $reads2 分别引用。

3.3 output 声明与连接下游

output 的语法和 input 几乎对称。但有一个很容易忽略的点:output 声明的顺序必须和 input 对应 channel 的 emit 顺序一致。举个例子:

process align {
    input:
        path reference
        path reads

    output:
        path "aligned.bam"
        path "alignment.log"

    script:
        """
        bwa mem $reference $reads > aligned.bam 2> alignment.log
        """
}

这个进程会输出两个文件。下游另一个进程如果要使用 aligned.bam,可以这样写:

process sort {
    input:
        path bam  // 自动接收 align 输出的第一个文件
    ...
}

如果下游想同时接收两个文件,则 input 必须用 tuple:

process verify {
    input:
        tuple path(bam), path(log)
    output:
        ...
}

你想想看,如果 output 的类型和数量对不上,Nextflow 就会报类型不匹配错。所以我的习惯是:每个进程的 input 和 output 声明写完后,先跑一次 nextflow run -stub 做空测试,确保输入输出能正确连接。

3.4 变量传递机制:params vs channel

这是新手最容易混淆的两个概念。简单粗暴地说:

  • params:是运行时的配置参数,静态的,不参与数据流。例如参考基因组路径、线程数。
  • channel:是动态的数据流,驱动进程执行。例如样本列表、测序数据文件。

在 script 块里,params.xxx 可以直接引用,但前提是你已经在 nextflow.config 或命令行里定义了它。比如:

// nextflow.config
params.threads = 8
params.ref = "/data/hg38.fa"

// 主脚本 main.nf
process bwa_mem {
    input:
        path reads
    script:
        """
        bwa mem -t ${params.threads} ${params.ref} $reads > result.sam
        """
}

注意,params 的引用要用 ${params.xxx} 而不是 $params.xxx,因为 $params 在双引号字符串里会被当作变量展开,但 Nextflow 的 DSL 对 $params 的解析有限制。我踩过这个坑:脚本里写 $params.threads,结果跑出来是空字符串。后来改成 ${params.threads} 就好了。

警告:params 的内容是静态的,如果你需要根据输入文件动态计算某个值,请用 channel 传递。例如每个样本的临时目录路径,应该通过 tuple 携带,而不是写死在 params 里。

channel 传递变量更灵活。比如从 Channel.fromList 得到的样本名,可以用 val 直接传入进程。如果变量比较复杂(如样本名+路径+分组),就用 tuple 打包传递。

3.5 脚本编写的最佳实践

script 块里写的是真正的 shell 命令或脚本。这里有几个要点:

  • 引用输入输出:使用 $变量名,但如果变量名后面紧跟着字母数字下划线,要用 ${变量名}
  • 多行脚本:用三个双引号 """ 包裹,可以自由换行
  • 导出环境变量:如果进程依赖外部程序路径,用 env 输入或直接写在 shell 里
  • 临时文件:进程有自己的工作目录,写完的临时文件如果不声明 output,运行结束会被自动清理

我见过一个很常见的 bug:在 script 里写 gzip $my_file,结果 $my_file 是一个列表,gzip 直接报错。这时候应该用 gzip ${my_file.join(' ')} 或者逐个处理。还有一个关于引号的坑:如果文件名里有空格,必须用双引号括起来,比如 "$my_file"。我曾经因为一个样本名带括号,导致整个 pipeline 中断,排查了半天才发现是 shell 解析的问题。

下面是一个完整且经过实战验证的进程模板:

process bwa_alignment {
    tag { "$sample_id" }           // 便于日志识别
    cpus params.bwa_threads
    memory params.bwa_mem.Gb
    time params.bwa_time

    input:
        tuple val(sample_id), path(reads)

    output:
        path "${sample_id}.sam"
        path "${sample_id}.bam"

    script:
        """
        # 创建临时目录,防止多进程冲突
        mkdir -p tmp_${sample_id}
        export TMPDIR=tmp_${sample_id}

        # 运行比对
        bwa mem \
            -t ${task.cpus} \
            -R '@RG\\tID:${sample_id}\\tSM:${sample_id}' \
            ${params.ref} \
            $reads > ${sample_id}.sam

        # 转换为 BAM 并排序
        samtools view -bS ${sample_id}.sam | \
            samtools sort -@ ${task.cpus} -o ${sample_id}.bam

        # 清理
        rm -rf tmp_${sample_id}
        """
}

注意这里 ${task.cpus} 是 Nextflow 自动注入的变量,等于该进程被分配到的 CPU 数。我在 SLURM 集群上经常结合 cpus 4 和 ${task.cpus} 来精确控制资源。

核心总结:input/output 是进程的接口,必须与 channel 的数据结构严格一致;params 只放静态配置,动态数据走 channel;script 里用 ${} 引用变量,注意转义和引号。做到这三点,你的 pipeline 就能稳定跑起来。

最后,画一张图把整个流程串起来,方便你对照理解:

进程定义中的输入/输出数据流 Channel (样本列表) Params (参考基因组) Process input: tuple, path script: bwa & samtools output: .sam, .bam Output Channel (BAM 文件路径) Next Process (排序/标记重复) 输入变量通过 Channel 动态驱动实例数 静态参数如 ref 放入 params 全局共享

这张图里,左边是输入来源,中间是进程本体,右边是输出流向。你可以看到 Channel 和 Params 两条线进入同一个 Process,但性质完全不同。Channel 的每个元素会生成一个独立的进程实例,而 Params 所有实例共享同一份值。这在设计大规模并行流程时非常关键。