第三章 WDL 基础语法:从文件骨架到命名习惯

开始写 WDL 之前,我们先搞清楚它的“骨架”长什么样。说白了,WDL 文件就是一个结构化的文本,你得告诉 Cromwell 三个东西:这是什么版本、整个流程怎么跑、每个步骤具体干什么。

我个人习惯把 WDL 文件拆成三块来看——版本、工作流定义、任务定义。理解了这三块,你就掌握了 WDL 80% 的语法。

3.1 文件结构与版本声明

WDL 文件的第一行必须是版本声明。为什么?因为 WDL 版本迭代较快,Cromwell 需要知道用哪个解析器来理解你的代码。我见过有人忘了写版本声明,结果 Cromwell 直接罢工,报了个奇怪的语法错误。

警告:版本声明必须写在文件最开头,前面不能有任何注释或空行外的内容。这是硬性规定。

一个标准的 WDL 文件长这样:

version 1.0

workflow my_first_workflow {
    input {
        String sample_name
        File reads
    }
    
    call run_alignment {
        input:
            reads = reads
    }
    
    output {
        File aligned_bam = run_alignment.aligned_bam
    }
}

task run_alignment {
    input {
        File reads
        String reference = "hg38"
    }
    
    command {
        bwa mem ${reference} ${reads} > aligned.sam
    }
    
    output {
        File aligned_bam = "aligned.sam"
    }
    
    runtime {
        docker: "biocontainers/bwa:v0.7.17"
        memory: "4 GB"
        cpu: 2
    }
}

嗯,这里要注意:version 1.0 是当前最常用的版本。之前有 draft-2 和 draft-3,但现在已经很少有人用了。如果你看到老项目还在用旧版本,建议尽快迁移。

实际经验:我接手过一个遗留 WDL 项目,里面用的 draft-2 语法,task 定义里还在用 command {} 外面套 String out = "result" 这种写法。结果 Cromwell 2.x 版本完全不认,折腾了两天才全部改完。

3.2 workflow 块:流程的指挥中心

workflow 块是整个工作流的入口。你可以把它想象成一个导演,负责调度各个 task 的执行顺序、传递参数、汇总输出。

一个 workflow 块包含四个核心部分:

  • input 块:定义工作流的输入参数,比如样本名、文件路径。
  • call 语句:调用具体的 task,并传递参数。
  • scatter 语句:并行处理多个输入(后面章节会细说)。
  • output 块:定义工作流的最终输出。

我之前犯过一个低级错误,在 workflow 里直接写 call task_name { input: ... },但忘了定义 task 的 input 参数。Cromwell 会报“missing required input”错误,排查起来挺头疼的。

小技巧:写 workflow 时,先把所有 task 的 input 和 output 在脑子里过一遍,再动手写 call 语句。这样能减少很多调试时间。

3.3 task 块:能干活的“小兵”

每个 task 代表一个具体的步骤。比如比对、排序、变异检测。task 块里必须包含 command 和 output,其他像 input、runtime、meta 是可选的。

来看一个典型的 task 结构:

task samtools_sort {
    input {
        File bam_file
        String memory_gb = "4"
    }
    
    command {
        samtools sort -m ${memory_gb}G -o sorted.bam ${bam_file}
    }
    
    output {
        File sorted_bam = "sorted.bam"
    }
    
    runtime {
        docker: "biocontainers/samtools:v1.15"
        memory: memory_gb + " GB"
        cpu: 2
    }
    
    meta {
        description: "Sort a BAM file by coordinates"
    }
}

你想想看,这里的关键是什么?command 块里的命令是直接传给 Docker 容器执行的。所以写的时候要确保容器里安装了对应工具,不然 Cromwell 会报“command not found”。

避坑指南:我曾经在 task 里写了一个复杂的管道命令 cat file | grep pattern | awk '{print $1}',结果在某个旧版 Docker 里 awk 版本不一致,输出了完全不同的结果。后来我养成了一个习惯——在 task 里尽量用显式命令,少用管道,或者明确指定工具版本。

还有一点,runtime 块里可以设置资源参数。Cromwell 会根据这些参数向云端申请资源。如果你不写 memory 和 cpu,Cromwell 会用默认值,但那个值往往偏小,跑大任务时会 OOM(内存溢出)。

3.4 命名规范:看上去很小的事,影响很大

命名规范这东西,很多人觉得无所谓。我刚开始写 WDL 时也这么想。直到有一天,我在一个项目里看到了 task task123、call call_call 这种命名,整个人都不好了。

WDL 的命名规则很宽松,但有几个硬性要求:

  • 只能包含字母、数字、下划线。
  • 不能以数字开头。
  • 区分大小写,SampleName 和 samplename 是两个不同的变量。
  • 建议使用 camelCase(驼峰命名),WDL 官方推荐这种做法。

一些常见的命名习惯对比:

推荐 不推荐 原因
run_alignment runAlignment123XYZ 过于冗长,难以阅读
aligned_bam alignedBAM_output_v2_final 版本号不需要放在名字里
sample_id Sample-Id 连字符不合法
input_reads input 过于通用,容易混淆
个人建议:task 名用动词短语,比如 run_bwa_mem、sort_bam;输出变量用名词短语,比如 aligned_bam、sorted_bam。这样看代码时一目了然。

另外,WDL 里有几个保留关键字,不能用作变量名或 task 名,比如 workflow、task、call、input、output、scatter、if、then、else、runtime、meta、parameter_meta。

我见过有人把 task 命名为 call,结果 Cromwell 解析到 call call {} 时直接报 syntax error。你要记好了,这些关键字不能碰。

3.5 完整的 WDL 文件示例

把上面讲的东西串起来,一个完整的 WDL 工作流长这样:

version 1.0

workflow qc_pipeline {
    input {
        File fastq_file
        String sample_id
    }
    
    call fastqc_analysis {
        input:
            fastq = fastq_file,
            sample = sample_id
    }
    
    call multiqc_summary {
        input:
            fastqc_report = fastqc_analysis.html_report
    }
    
    output {
        File qc_report = multiqc_summary.summary_html
    }
}

task fastqc_analysis {
    input {
        File fastq
        String sample
    }
    
    command {
        fastqc -o /output ${fastq}
        mv ${fastq}_fastqc.html ${sample}_fastqc.html
    }
    
    output {
        File html_report = "${sample}_fastqc.html"
    }
    
    runtime {
        docker: "biocontainers/fastqc:v0.11.9"
        memory: "2 GB"
        cpu: 1
    }
}

task multiqc_summary {
    input {
        File fastqc_report
    }
    
    command {
        multiqc . -o /output
    }
    
    output {
        File summary_html = "multiqc_report.html"
    }
    
    runtime {
        docker: "ewels/multiqc:v1.11"
        memory: "2 GB"
        cpu: 1
    }
}

这个例子看起来简单,但包含了我们刚才讲的所有要点:版本声明、workflow 块、task 块、输入输出、命名规范。你可以把它当作一个模板,以后写 WDL 文件就照着这个结构来。

关键总结:WDL 语法并不复杂,难的是把工作流逻辑理清楚。我写 WDL 的习惯是:先在纸上画出流程图,确定哪些步骤可以并行,哪些必须串行,然后再动笔写代码。这样能省下很多调试精力。
WDL 文件结构总览 WDL 文件 版本声明 version 1.0 workflow 块 工作流定义 task 块 步骤定义(可有多个) input 块 call 语句 output 块 command(必须) output(必须) runtime(可选) 命名规范:camelCase / 不用关键字 / 表意清晰 task 用动词(run_align),变量用名词(aligned_bam)

这张图把 WDL 文件的结构串起来了。你看,顶层是文件,下面分出三个主要部分:版本声明、workflow 块、task 块。workflow 块内部有 input、call、output;task 块内部有 command、output、runtime。再加上命名规范这个贯穿始终的“软性要求”,就构成了完整的 WDL 基础语法。

我个人建议,初学阶段不用急着背语法细节。你只需要记住 WDL 文件的这个骨架结构,然后对照着写几个小例子,很快就能上手。我在项目里教新人写 WDL 时,都是让他们先抄这个结构,再慢慢加功能。

练习建议:找一个小任务,比如把 BAM 文件做排序和索引。试着写出一个包含两个 task 的 workflow。不需要跑起来,先让语法通过 Cromwell 的验证就行。