3. Workflow定义语言(WDL)基础

好,咱们开始聊WDL。这个Workflow Definition Language,说白了就是Cromwell能听懂的一套剧本。你告诉它“先做A,再做B,如果C条件满足就做D”,全靠WDL来描述。我最初接触的时候,觉得它长得挺像函数式编程,后来发现它本质上是把生物信息学的管道拆成一个个小盒子。

WDL的核心理念就两个:task(任务)和workflow(工作流)。task是最小执行单元,定义“干什么”;workflow定义“怎么串联”。我个人习惯把task想象成一个个独立的集装箱,workflow就是吊车和传送带。

3.1 WDL文件的基本骨架

先看一个最简单的WDL长什么样:

version 1.0

workflow my_workflow {
    call my_task
}

task my_task {
    command {
        echo "Hello, WDL"
    }
    runtime {
        docker: "ubuntu:latest"
    }
}

嗯,就这么几行。version声明WDL的方言版本,现在主流是1.0。workflow块包裹所有执行逻辑,task块定义具体要做的事。

我遇到过一个坑——版本号写错成draft-2,Cromwell直接报版本不兼容。你想想看,一个连版本都跑不对的WDL,后面全白搭。

3.2 Task定义详解

Task是WDL的肌肉。一条task通常包含以下部分:

指令 作用 是否必填
input 声明输入参数、默认值、类型 选填(但建议写上)
command 要执行的shell命令或脚本 必填
output 声明输出的文件/变量 必填(除非你不想拿结果)
runtime 容器镜像、CPU、内存、磁盘 强烈建议
meta 作者描述、版本号、文档链接 选填
parameter_meta 对每个输入参数的说明 选填

来看一个稍微完整的task:

task bwa_mem {
    input {
        File reference
        File read1
        File read2
        String sample_name
        Int threads = 4
    }
    command {
        bwa mem -t ~{threads} ~{reference} ~{read1} ~{read2} \
            > ~{sample_name}.sam
    }
    output {
        File sam = "~{sample_name}.sam"
    }
    runtime {
        docker: "biocontainers/bwa:v0.7.17"
        cpu: 4
        memory: "8 GB"
        disks: "local-disk 20 HDD"
    }
    meta {
        author: "Your Name"
        description: "BWA MEM alignment step"
    }
}

注意几个细节:~{}是变量插值,类似模板引擎。你可以在command里随便引用input里声明的变量。runtime里的disks是云端执行时必须的——Cromwell会按这个规格创建临时云磁盘。

我曾经犯过一个低级错误:output声明里文件路径写死了"output.sam",但实际命令生成的是sample.sam。Cromwell拿着没找到的文件,直接挂掉。后来我养成习惯,在output里也用变量:"~{sample_name}.sam"。

3.3 Workflow定义详解

Workflow的作用是编排task。它不自己干活,只告诉大家“谁先谁后、数据怎么传”。

workflow alignment_pipeline {
    call bwa_mem as alignment {
        input:
            reference = genome_fasta,
            read1 = sample_R1.fastq,
            read2 = sample_R2.fastq,
            sample_name = my_sample,
            threads = 8
    }
    call samtools_sort {
        input:
            input_sam = alignment.sam,
            sample_name = my_sample
    }
    output {
        File final_bam = samtools_sort.sorted_bam
    }
}

这里的关键是call语句。你可以理解成“调用一个任务实例”。as关键字重命名实例名,方便后面引用它的输出。input块就是给这个实例传参。

数据怎么在task之间流动?看这行:input_sam = alignment.sam。alignment是实例名,.sam是它output里的变量名。WDL会自动解析依赖关系,Cromwell知道该先跑bwa_mem,再跑samtools_sort。

命名约定:我建议把workflow里所有需要暴露的结果放到workflow的output块里。这样后续的脚本可以通过Cromwell API直接拿到最终文件路径,不用去日志里翻。

3.4 输入类型与数据模式

WDL支持丰富的数据类型。用得最多的是这几类:

  • 基本类型:String, Int, Float, Boolean
  • 文件类型:File, File?(可选文件), Array[File](文件列表)
  • 映射和结构体:Map[String, String], Pair[File, String]
  • 对象:Object 或自定义 struct

举个例子,处理多个样本时,你可以这么声明:

workflow multi_sample {
    input {
        Array[File] r1_files
        Array[File] r2_files
        Array[String] sample_names
    }
    scatter (i in range(length(r1_files))) {
        call align_pair {
            input:
                r1 = r1_files[i],
                r2 = r2_files[i],
                sample = sample_names[i]
        }
    }
}

scatter是WDL里做并行最常用的结构。它会自动把数组展开成多个独立任务。我管它叫“隐式循环”——你写一次,Cromwell帮你并行成N份。

小心数组越界:我见过最蠢的bug就是r1_files长度10,sample_names长度9。Cromwell不会自动截断或补全,它直接报数组越界。建议在workflow启动前做输入校验,或者用select_first等方法兜底。

3.5 作用域与变量可见性

WDL的作用域规则比一般脚本语言简单:

  • workflow级别的变量在所有call里可见(但需要显式传入input)
  • task内部定义的变量只在本task可见
  • scatter块内定义的变量只在块内可见
  • 在workflow里引用task的输出,必须用task_instance.output_name

我踩过的一个坑:在task的runtime里想用workflow的变量,结果直接引用导致解析失败。后来才发现runtime块里不能直接用workflow变量——你得通过input把变量传进task,才能在command和output里用。runtime只能写字面量或task input里的变量,这点很坑。

3.6 条件执行:if 与 select

WDL里条件逻辑用if语句实现:

workflow conditional_pipeline {
    Boolean run_extra = true
    
    call basic_analysis
    
    if (run_extra) {
        call extra_analysis {
            input:
                previous_result = basic_analysis.result
        }
    }
}

注意if块里的task只有在条件为true时才会调度。这很符合云端按需计费的逻辑——如果条件不满足,Cromwell连启动信号都不会发出。

经验之谈:我见过很多人在workflow里套多层if-else,最后代码读起来像意大利面。我建议尽量用select_first或if then else来做分支选择,而不是用嵌套if。WDL 1.0之后的if已经支持else了。

3.7 WDL的整体结构图

下面这张图展示了WDL文件里各层级的关系:

WDL 文件结构总览 WDL 文件 (version 1.0) Workflow &; 输入声明 (input) &; 调用任务 (call) &; 散射并行 (scatter) &; 条件分支 (if / else) &; 输出声明 (output) Task &; 输入参数 (input) &; 执行命令 (command) &; 输出声明 (output) &; 运行时属性 (runtime) &; 元数据 (meta / parameter_meta) 核心关系 1. Workflow 通过call调用Task 2. Task之间 通过输出/输入传数据 3. Scatter生成 多份独立Task实例 4. If控制 整块Task是否执行 5. Runtime定义 每个Task的资源需求 6. Docker镜像 保证执行环境一致 7. 一个WDL 可包含多个Task 但只能含一个Workflow

3.7 小经验总结

写WDL这件事,上手很快,踩坑也很快。我概括几个原则:

  • 变量插值用~{},别用${}——那是shell的,WDL里不认识。
  • output必须和命令生成的文件名一致,否则Cromwell找不到文件会直接失败。
  • 尽量在每个task里写上runtime,尤其是docker镜像。云端执行没有镜像等于裸奔,环境不一致会哭死。
  • Workflow里的output块别省。就算你只想看中间结果,写上才能暴露给外部API。
  • 用scatter做并行之前,确认你的任务本身是幂等的——并行副本之间不能有状态冲突。
调试技巧:我刚学WDL时,本地先用java -jar cromwell.jar run my_workflow.wdl --inputs inputs.json跑一遍。Cromwell会在当前目录生成cromwell-executions文件夹,里面每个task的调用日志、stdout、stderr都在,翻一遍就知道哪里出的问题。