3. WDL基本语法

3.1 理解workflow与task

刚开始接触WDL时,我最困惑的就是workflow和task到底有什么区别。说白了,这俩概念是整个WDL的基石。你想想看,一个计算任务总得有个"做什么"和"怎么组织"的问题。

我个人习惯这么理解:task 是"干活的",它定义具体的计算命令;workflow 是"管事的",它编排这些task的执行顺序和数据传递。打个比方——workflow是剧本,task是演员,剧本指挥演员什么时候上场、说什么台词。

我在项目中遇到过不少新手把代码全塞在一个task里,workflow反而成了摆设。其实这样既不好复用,也不好调试。正确的做法是:把每个独立步骤拆成task,让workflow去串联。

3.2 task定义:执行的最小单元

先来看看task长什么样。一个完整的task包含四个部分:input、command、output 和 runtime。我建议你记住这个骨架。

task hello_world {
    input {
        String name
    }
    command {
        echo "Hello, ${name}!"
    }
    output {
        String greeting = read_string(stdout())
    }
    runtime {
        docker: "ubuntu:20.04"
        memory: "1 GB"
        cpu: 1
    }
}

咱们拆开看:

  • input:声明输入参数,类型和默认值都可以定义。嗯,这里要注意,参数名尽量语义化,别用a、b这种。
  • command:放你真正的命令或者脚本。支持 ${} 插值,能把input里的变量传进去。
  • output:从命令执行结果里提取数据。WDL支持多种捕获方式,read_string、read_int、read_file 都很常用。
  • runtime:指定运行环境。Docker镜像、内存、CPU核心数都在这里配。我曾经因为忘了设runtime,任务被默认资源限制卡住,排查了半天才发现。
小技巧:command块里如果用到了变量,养成用 ${var} 而不是 $var 的习惯。前者在复杂表达式里更安全,不容易解析出错。

3.3 workflow定义:编排的舞台

task是零件,workflow就是把零件组装起来的流水线。它的核心结构包括:输入声明、task调用、输出声明。来看一个例子:

workflow greet_pipeline {
    input {
        String user_name
    }
    call hello_world {
        input: name = user_name
    }
    output {
        String result = hello_world.greeting
    }
}

关键点在于 call 语句。你用 call task_name 来引用一个task,并通过 input: 把workflow的变量传给它。task执行完,用 task_name.output_name 的方式拿到结果。

我在做项目时犯过一个低级错误——在workflow里 call 了一个task,但忘了在workflow的 output 里声明对外输出。结果下游流程拿不到数据,愣是排查了两小时。记住:workflow的输出不会自动暴露,必须显式写出来。

避坑指南:workflow里 call 的task名称不能有重复。如果你需要多次调用同一个task,用别名区分:call my_task as first_step。

3.4 workflow与task的协作关系

理解了各自的语法,咱们再看它们怎么配合。我整理了一个对比表格,这样更直观:

对比维度 task workflow
角色定位 执行具体计算 编排调度任务
输入输出 定义局部参数 定义全局数据流
语法核心 command + output + runtime call + 数据传递
执行方式 独立运行 按依赖顺序执行
复用性 高,可被多个workflow调用 低,通常是顶层入口

下面这张图帮你建立整体印象。workflow负责"调兵遣将",task负责"冲锋陷阵",数据通过input/output在它们之间流动。

WDL工作流与任务关系图 workflow calculate_pipeline input: radius  |  output: result task calculate_area ▸ input: radius ▸ command: π × r² ▸ output: area 计算圆面积 task calc_volume ▸ input: area, height ▸ command: area×h ▸ output: volume 计算圆柱体积 task format_output ▸ input: volume ▸ command: format ▸ output: result 格式化输出结果 area volume 关键协作机制 Workflow 通过 call 调用 task,并通过 input/output 传递数据 每个 task 独立执行,workflow 保证依赖顺序和数据流向
核心要点:workflow 不执行具体命令,它只负责"谁先谁后、数据给谁"。具体计算全部下放到 task 里。这种分离设计让流程修改和任务复用变得非常灵活。

3.5 实战:串联一个完整流程

我们写一个完整的例子。需求很简单:输入一个半径和高度,先算圆面积,再算圆柱体积,最后把结果格式化输出。

version 1.0

task calculate_area {
    input {
        Float radius
    }
    command {
        python3 -c "
import math
radius = ${radius}
area = math.pi * radius * radius
print(area)
"
    }
    output {
        Float area = read_float(stdout())
    }
    runtime {
        docker: "python:3.9-slim"
        memory: "512 MB"
        cpu: 1
    }
}

task calculate_volume {
    input {
        Float area
        Float height
    }
    command {
        python3 -c "
area = ${area}
height = ${height}
volume = area * height
print(volume)
"
    }
    output {
        Float volume = read_float(stdout())
    }
    runtime {
        docker: "python:3.9-slim"
        memory: "512 MB"
        cpu: 1
    }
}

task format_result {
    input {
        Float volume
        String label = "圆柱体积"
    }
    command {
        echo "${label}: ${volume} 立方单位"
    }
    output {
        String result = read_string(stdout())
    }
    runtime {
        docker: "ubuntu:20.04"
        memory: "256 MB"
        cpu: 1
    }
}

workflow calculate_pipeline {
    input {
        Float radius
        Float height
    }
    call calculate_area {
        input: radius = radius
    }
    call calculate_volume {
        input: area = calculate_area.area,
               height = height
    }
    call format_result {
        input: volume = calculate_volume.volume
    }
    output {
        Float final_volume = calculate_volume.volume
        String final_result = format_result.result
    }
}

这段代码有三个task和一个workflow。注意数据是怎么一步步传下去的:calculate_area 算出面积,传给 calculate_volume,再传给 format_result。workflow 用 task_name.output_name 的方式引用上游结果。

我的习惯:每个task的runtime里都明确指定docker镜像和资源限制。这样流程移植到不同平台时,不会因为环境差异出问题。曾经有一个同事没写docker镜像,结果在集群上跑了不同的操作系统,命令行为不一致导致报错——这种坑踩一次就够了。