任务定义:task 结构、运行时参数、输入输出声明

好,咱们开始深入 WDL 的核心——task。说白了,WDL 里的每个工作单元就是一个 task。你可以把它理解成一个“脚本任务盒子”:你把输入放进去,告诉它用什么工具跑、配多少资源,它执行完再把输出还给你。

我个人习惯把 task 分成三个部分来看:声明(input/output)、执行体(command)和运行时(runtime)。三者缺一不可,但很多新手最容易在输入输出和运行时上踩坑。咱们一个一个拆解。

task 结构的基本骨架

先看一个最简单的 task 定义:

task hello_world {
    input {
        String name
    }

    command {
        echo "Hello, ~{name}!"
    }

    output {
        String greeting = read_string("stdout")
    }

    runtime {
        docker: "ubuntu:latest"
        memory: "1 GB"
        cpu: 1
    }
}

这里面的每个块都是必须的吗?其实只有 command 是必须的。但我建议你把 input、output、runtime 都写上,哪怕是空的。为什么呢?
嗯,因为你想想看,没有显式声明输入输出,别人读你的代码就像看天书,而且后续做任务依赖时也会出问题。

我的习惯: 即使 task 没有外部输入,我也会在 input 块里留个注释。这就像是给代码留下了“接口文档”,后期维护起来轻松很多。

输入输出声明:类型与表达式

WDL 的输入输出支持多种基本类型:String、Int、Float、Boolean,还有文件类型 File 和数组 Array[Type]。我以前遇到过一哥们,把所有输入都写成 String,然后自己在 command 里做类型转换……结果可读性极差,还容易出错。

输入输出的关键点:

  • 输入变量可以在 command 里通过 ~{var_name} 引用
  • 输出必须是从执行结果中“捕获”出来的,常见方式有 read_string()、read_int()、glob() 等
  • 输出文件类型要和生成的文件路径对应准确

看一个文件处理的例子:

task process_bam {
    input {
        File bam_file
        String sample_id
        Int min_qual = 20
    }

    command {
        samtools view -h -q ~{min_qual} ~{bam_file} \
            -o ~{sample_id}.filtered.bam
    }

    output {
        File filtered_bam = "~{sample_id}.filtered.bam"
        Int read_count = read_int("read_count.txt")
    }
}

我曾经踩过的坑: 注意看输出声明中的字符串插值 ~{sample_id}.filtered.bam。如果 sample_id 里包含了路径分隔符或空格,可能会导致文件路径解析错误。我建议对样本 ID 做校验,或者使用 basename() 函数处理一下。

运行时参数:资源控制的核心

运行时参数 (runtime) 是你的 task 向执行引擎“申请资源”的地方。这直接决定了任务能不能跑、跑多快、花多少钱。我刚开始用 WDL 时,经常忘记设内存,结果任务被默认的 1GB 内存活活卡死。

常见运行时参数一览:

参数名 说明 常用值举例
docker 容器镜像,保证环境一致性 "ubuntu:20.04"
memory 申请的内存资源 "4 GB" 或 "8192 MB"
cpu 申请的 CPU 核数 2 或 4
disks 本地磁盘空间(不同后端格式不同) "local-disk 20 SSD"
maxRetries 任务失败后重试次数 2
preemptible 是否允许抢占式实例(云环境) true 或 3 (尝试次数)
timeout 任务超时时间 3600 (秒)

个人经验: 大的 task(比如比对、变异检测)我通常给 memory: "8 GB" 起步,再根据输入数据量逐步上调。小的工具类 task 给 memory: "1 GB" 加 cpu: 1 就够了。
另外,maxRetries 设成 2 或 3 是个好习惯——网络抖动、磁盘满这种偶发问题能自动恢复,不用手动重跑。

command 块:执行体的写法

command 块里写入的就是你在 shell 里会敲的命令。WDL 允许你使用 ~{} 嵌入输入变量。这里有个小技巧:如果命令比较长,可以写成多行,WDL 会自动把换行处理成空格。

command {
    python3 <

这是个嵌入 Python 脚本的例子。你看,WDL 对于复杂处理逻辑是包容的——你完全可以把 Python、R、Perl 脚本直接写在 command 里。但要注意缩进和引号的转义。

为什么这么做? 有时候把逻辑写在 WDL 里比写在单独脚本文件中更方便,特别是这个逻辑只在这个 task 里用一次。但如果你有共享的脚本库,我更推荐放在容器镜像里,或者用 File 输入传进来。

Task 内部的变量作用域

WDL 2.0 引入了 parameter_meta 和一些高级特性,但咱们还是先把基本的作用域搞清楚:

  • input { } 里声明的变量可以在整个 task 内使用
  • command 里通过 ~{} 引用输入变量
  • output { } 里的声明不能直接引用其他输出变量,只能引用输入和执行结果
  • 你可以在 command 里定义临时 shell 变量,但这些变量对 output 不可见(因为 WDL 通过捕获 stdout 或文件来实现输出)

嗯,这里要注意:output 里的文件路径是相对于 task 执行目录的。所以如果你的命令生成了 /tmp/result.txt,而你在 output 里声明 result.txt,WDL 是找不到的。除非你改了工作目录,或者把文件复制到当前目录。

一个完整的 task 示例:把知识点串起来

task fastqc_analysis {
    input {
        File fastq_file
        String sample_id
        Int threads = 2
    }

    command {
        mkdir -p ~{sample_id}_fastqc
        fastqc -t ~{threads} -o ~{sample_id}_fastqc ~{fastq_file}
    }

    output {
        File html_report = "~{sample_id}_fastqc/~{sample_id}_fastqc.html"
        File zip_file = "~{sample_id}_fastqc/~{sample_id}_fastqc.zip"
    }

    runtime {
        docker: "biocontainers/fastqc:v0.11.9_cv8"
        memory: "2 GB"
        cpu: 2
        maxRetries: 1
    }

    parameter_meta {
        fastq_file: "原始 FASTQ 文件,支持 .gz 压缩"
        sample_id: "样本标识,用于输出文件命名"
        threads: "线程数,默认 2"
        html_report: "FastQC 的 HTML 质量报告"
        zip_file: "FastQC 的压缩结果包"
    }
}

你看,这个 task 就涵盖了:输入声明、command 编写、输出捕获、运行时参数、参数元信息。每一块我都加了注释,这就是一个可读性高、易于维护的 task 模板。

我曾经踩过的坑: 有一天我发现某个 task 的输出文件总为空,查了半天才发现——command 里输出到 ./result/,而 output 里写的是 "result/summary.txt",没有用 ./ 前缀。不同 WDL 引擎对相对路径的解析可能不一样。我建议:output 里的路径尽量用反斜杠对齐命令里的写法,或者干脆用绝对路径。

知识结构总结

把今天的内容画成一张图,更直观:

task 结构:输入 · 执行 · 输出 · 运行时 task 定义 input 声明 类型: String, Int, File, Array... 默认值: min_qual = 20 command 执行体 ~{var} 插入变量 多行命令 / 嵌入脚本 output 声明 read_string, read_int, glob 文件路径匹配 runtime 运行时 docker / memory / cpu disks / maxRetries / timeout 💡 核心要点 ① command 是唯一必须的块,但 input/output/runtime 建议都写 ② 输出通过捕获 stdout 或文件路径来声明 ③ 运行时资源要“量体裁衣”,太多浪费、太少报错

这张图把 task 的四个组成部分和它们之间的依赖关系理清楚了。你看,从任务定义出发,分成输入、执行、输出三条主线,然后运行时参数“兜底”整个执行过程。结构清晰,各司其职。