第3章 流程编写实战:输入、输出、参数、脚本与运行

好了,咱们今天来点真格的。前两章我把Nextflow的核心理念和安装环境聊了个遍,这一章,咱们直接上手写一个能跑的流程。

你可能觉得写流程就是堆砌命令行,其实不然。一个健壮的流程,核心在于四个要素:输入输出怎么声明、参数怎么传递、脚本怎么写才规范、以及运行时怎么控制资源。这些做好了,流程才能可复用、可调试。

我个人习惯,每次搭新流程时,都会先把这几个模块的骨架画好。咱们按这个顺序来拆解。

3.1 输入与输出:流程的“门”和“窗”

输入输出是数据流的边界。Nextflow里,输入不仅仅是文件路径,它更是一种通道(channel)的声明。

在Nextflow中,输入声明在process块顶部,输出也在process块顶部。这种设计让你一眼看清这个处理单元“吃”什么、“吐”什么,非常直观。

先看一个简单的例子,定义一个接收FASTQ文件的process:

process FASTQC {
    input:
    path read

    output:
    path "${read.baseName}_fastqc.zip"

    script:
    """
    fastqc ${read}
    """
}

这里有几个细节我想强调一下:

  • 输入类型:用path声明文件,用val声明普通值,用env声明环境变量。我在项目中踩过坑,把路径当val传,结果Nextflow把它当字符串处理,导致内部路径解析出错。
  • 输出模式:输出文件支持通配符。比如path "*.zip"会匹配所有zip文件。如果你不确定输出文件全名,用通配符更灵活。
  • 通道连接:输出会自动连接到下一个process的输入。这也是管道设计的精髓所在。
关于输出文件的命名:我建议你尽量用动态命名。比如用${read.baseName}来构建输出文件名,这样不同输入样本不会互相覆盖。我曾经犯过一个低级错误,给所有样本的输出都叫“result.txt”,结果并行的任务互相覆盖,跑了半天数据全乱了……

3.2 参数传递:流程的“遥控器”

参数让流程变得可配置。Nextflow用params对象来管理所有用户可设置的参数。

通常,我会把参数定义写在main.nf文件的头部,或者单独写一个nextflow.config文件。我更推荐后者,因为配置文件和流程逻辑分离,维护起来更清爽。

举个例子,定义输入目录和输出目录:

// main.nf 文件头部
params.reads = "$projectDir/data/*.fastq.gz"
params.outdir = "./results"
params.genome = "hg38"

然后在process里这样引用:

process ALIGN {
    input:
    path reads

    output:
    path "${reads.baseName}.bam"

    script:
    """
    bwa mem ${params.genome} ${reads} > ${reads.baseName}.bam
    """
}
参数默认值要合理:尽量给每个参数一个默认值,这样别人直接运行也能出结果。如果某个参数是必填项(比如参考基因组路径),可以在代码开头加一个检查,比如assert params.genome : "请提供参考基因组路径!"。这个习惯能帮你省掉很多调试时间。

我还习惯在流程开头打印所有参数,方便追踪:

log.info """
╔═══════════════════════════════════╗
║ 流程参数                         ║
╠═══════════════════════════════════╣
║ reads    : ${params.reads}
║ outdir   : ${params.outdir}
║ genome   : ${params.genome}
╚═══════════════════════════════════╝
"""

你看,这样每次运行,日志里就清清楚楚地记录了当前使用的参数配置,排查问题的时候特别有用。

3.3 脚本编写:核心逻辑的正确“姿势”

脚本是每个process真正干活的地方。Nextflow默认用Bash,但也可以切换成Python、R等语言。需要注意的是,脚本块里的代码是在独立的工作目录下执行的,和流程脚本本身的环境是隔离的。

我总结了几个写脚本时的“黄金法则”:

  • 尽量用绝对或相对路径:不要在脚本里用cd切换目录,直接通过Nextflow管理的文件路径来操作。
  • 使用$projectDir:这个变量指向流程文件所在的目录。如果你的流程依赖一些辅助脚本,可以放在bin/子目录里,Nextflow会自动把它加到$PATH中。
  • 避免硬编码临时文件:所有中间文件都生成在当前工作目录,Nextflow执行完后会自动清理。

来看一个完整的多步骤流程示例:

workflow {
    // 定义输入通道,读取所有fastq.gz文件
    Channel.fromPath(params.reads)
        | view { "处理文件: $it" }
        | FASTQC
        | ALIGN
}

process FASTQC {
    input:
    path reads

    output:
    path "${reads.baseName}_fastqc.zip"

    script:
    """
    fastqc ${reads}
    """
}

process ALIGN {
    input:
    path reads

    output:
    path "${reads.baseName}.bam"

    script:
    """
    bwa mem hg38 ${reads} > ${reads.baseName}.bam
    """
}

嗯,这里要注意:管道操作符|把每一步串起来,数据流很清晰。不过,如果中间某个process失败了,后面的步骤会自动跳过。这也是Nextflow设计里比较优雅的地方。

3.4 运行流程:从命令行到作业调度器

流程写好了,怎么跑?最简单的方式就是直接执行:

nextflow run main.nf

但实际项目中,我们通常需要指定参数、资源、以及执行环境。我常用的运行方式有这样几种:

运行方式 命令示例 适用场景
本地运行(调试) nextflow run main.nf -process.cpus 2 小数据量、代码调试
提交到集群 nextflow run main.nf -profile cluster 大规模生产数据
使用Docker/Singularity nextflow run main.nf -with-docker 保证环境一致性

我个人习惯是在nextflow.config里预定义好几个profile,比如:

profiles {
    standard {
        process.executor = 'local'
        process.cpus = 4
        process.memory = '8 GB'
    }
    cluster {
        process.executor = 'slurm'
        process.queue = 'normal'
        process.cpus = 8
        process.memory = '16 GB'
    }
    docker {
        docker.enabled = true
        docker.image = 'biocontainers/bwa:v0.7.17'
    }
}

然后运行时只需要指定profile:

nextflow run main.nf -profile cluster -params-file params.yaml

这样,用户只需要改params.yaml文件就能调整输入数据,而不用动流程代码。

资源预估要留余量:有一次我在集群上跑一个比对流程,给每个任务分配了8核32GB内存,结果某个样本特别大,跑着跑着OOM了(内存溢出)。后来我改成动态资源请求,核心思路是根据输入文件大小估算资源。比如,文件大于5GB的给16GB内存,否则给8GB。你可以用task.memory结合file(inputFile).size()来实现。

3.5 一图胜千言:流程执行时的数据流

说了这么多理论,咱们用一张图来总结一下流程运行时的数据流向:

流程运行时数据流与执行结构 输入数据 FASTQ / BAM / 参数 参数定义 params / config 执行环境 本地 / 集群 / Docker Channel.fromPath params.genome executor 通道(Channel)数据流 FASTQC input: path reads output: path *_fastqc.zip ALIGN input: path reads output: path *.bam 输出结果 BAM / ZIP / 报告 每个process独立工作目录 失败自动跳过下游任务

从这张图可以看到,数据从输入文件和参数出发,经过通道组装,流入一个个process。每个process都有自己独立的工作目录,互不干扰。执行环境(本地、集群、容器)决定了这些任务具体跑在哪台机器上。

3.6 避坑指南:我走过的那些弯路

最后,我把自己在实战中碰到的几个典型问题列出来,希望能帮你少走点弯路:

  • 路径引用问题:在script块里,我习惯用$baseDir或$projectDir来引用流程脚本所在的目录。不要用$PWD,因为工作目录随时可能变化。
  • 资源限制:曾经有一个混合计算任务,一部分需要大量内存,一部分需要大量CPU。我统一设了一个资源规格,结果内存密集型任务跑得慢,CPU密集型任务又浪费资源。后来我用withName语法对不同process设置不同资源,效率提升明显。
  • 输入文件扫描:使用Channel.fromPath时,记得给个合理的glob模式,比如*_R{1,2}.fastq.gz。不然容易误扫进一些临时文件或目录。
一个小技巧:如果你不确定当前通道里有哪些文件,可以在workflow里加一个view操作,比如Channel.fromPath(params.reads) | view { "当前输入:$it" }。这样Nextflow运行时会打印出每个输入文件的具体路径,调试起来特别方便。