cwl 是一种工作流描述语言,官方提供了系列工具,但是是 python 版本的
由于个人项目主要是基于GO进行开发,现对 Go 运行 CWL 的现状做一二说明:
实现列表
项目目前使用的基础,支持语言的解析和运行,也提供了测试,但完成度较低:
尚不支持 Workflow,不支持 ExpressionTool, 只支持 CommandLineTool
-
CommandLineTool 的部分支持存在问题(26/64):
- 不支持 SchemaDefRequirement
- 对 InlineJavascriptRequirement 的支持不够完善
- 不支持 STDIN 等
- 对 Any Type 的支持不够完善
- 对 initialWorkDir 的支持不够完善
- 对 shellQuote 的支持不够完善
- 对 successCode 的支持不够完善
- ... ...
一个 CWL 的 解析器,没有执行器,对应版本为 version 1.0 , 提供了解析测试
一个CWL K8s 执行器,其解析部分基于 otiai10 forked 版本进行开发(但不通过 otiai10 的测试),提供了测试工具,但未报道通过率
CWL 特点
CWL 语法非常灵活,例如:
- slice , Map 等效写法
CWL 的设计比较灵活,为了方便手写,同一表达可以有多种方式,例如:
inputs:
- id: x
type: File
inputs:
x:
type: File
inputs:
x: File
均是合法的表达,导致了在静态语言中进行解析的困难。
- 特殊缩写法 例如,简写
[{
"extype": "string"
}, {
"extype": "string?"
}, {
"extype": "string[]"
}, {
"extype": "string[]?"
}]
在经过 salad 预处理后,可以展开为:
{
"extype": "string"
},
{
"extype": [
"null",
"string"
]
},
{
"extype": {
"type": "array",
"items": "string"
}
},
{
"extype": [
"null",
{
"type": "array",
"items": "string"
}
]
}
]
类似的通过预处理规则来简化CWL书写的地方还有很多,但也提高了解析难度。
实际,CWL标准特别定义了一种 JSON 扩展 ,可以通过 salad预处理 来处理 CWL JSON 的验证和解析
但由于尚未有 salad 的 GO 语言实现,目前大部分实现都是直接进行 CWL 的 解析
GO 语言中的解析
- 基于 map[string]interface{} 的逐层解析法:
otiai10 中的处理方式是,定义好数据结构,通过 interface{} 逐层解析:
// Root ...
type Root struct {
Version string
Class string
Hints Hints
Doc string
Graphs Graphs
BaseCommands BaseCommands
Arguments Arguments
Namespaces Namespaces
Schemas Schemas
Stdin string
Stdout string
Stderr string
Inputs Inputs `json:"inputs"`
// ProvidedInputs ProvidedInputs `json:"-"`
Outputs Outputs
Requirements Requirements
Steps Steps
ID string // ID only appears if this Root is a step in "steps"
Expression string // appears only if Class is "ExpressionTool"
// Path
Path string `json:"-"`
// InputsVM
InputsVM *otto.Otto
}
// UnmarshalJSON ...
func (root *Root) UnmarshalJSON(b []byte) error {
docs := map[string]interface{}{}
if err := json.Unmarshal(b, &docs); err != nil {
return err
}
return root.UnmarshalMap(docs)
}
// UnmarshalMap decode map[string]interface{} to *Root.
func (root *Root) UnmarshalMap(docs map[string]interface{}) error {
for key, val := range docs {
switch key {
case "cwlVersion":
root.Version = val.(string)
case "class":
root.Class = val.(string)
case "hints":
root.Hints = root.Hints.New(val)
case "doc":
root.Doc = val.(string)
case "baseCommand":
root.BaseCommands = root.BaseCommands.New(val)
...
// New constructs new "Inputs" struct.
func (ins Inputs) New(i interface{}) Inputs {
dest := Inputs{}
switch x := i.(type) {
case []interface{}:
for _, v := range x {
dest = append(dest, Input{}.New(v))
}
case map[string]interface{}:
for key, v := range x {
input := Input{}.New(v)
input.ID = key
dest = append(dest, input)
}
}
return dest
}
...
省略说明了 通过 yaml2json 将 CWL YAML 转换为 JSON 的过程
通过定义每一层数据结构的 New(i interface{}) 函数来进行解析的处理
一些字段有多个格式时,则通过组合构造结构进行处理:
// Requirement represent an element of "requirements".
type Requirement struct {
Class string
InlineJavascriptRequirement
SchemaDefRequirement
DockerRequirement
SoftwareRequirement
InitialWorkDirRequirement
EnvVarRequirement
ShellCommandRequirement
ResourceRequirement
Import string
}
func (_ Requirement) New(i interface{}) Requirement {
dest := Requirement{}
switch x := i.(type) {
case map[string]interface{}:
for key, v := range x {
switch key {
case "class":
dest.Class = v.(string)
case "coresMin":
fmt.Println(v)
dest.CoresMin = int(v.(float64))
case "coresMax":
fmt.Println(v)
dest.CoresMax = int(v.(float64))
case "ramMin":
fmt.Println(v)
dest.RAMMin = int(v.(float64))
case "ramMax":
dest.RAMMax = int(v.(float64))
case "dockerPull":
dest.DockerPull = v.(string)
...
这种方式看起来逻辑比较清晰,数据结构需要合理设计,解析过程需要按设计的规则推进
- 基于 reflect 的通用化解析法
buchanae 采用了另一种解析方式,通过反射的方式来进行解析:
func LoadDocumentBytes(b []byte, base string, r Resolver) (Document, error) {
...
l := loader{base, r}
// Parse the YAML into an AST
yamlnode, err := yamlast.Parse(b)
...
// Being recursively processing the tree.
var d Document
start := node(yamlnode.Children[0])
start, err = l.preprocess(start)
if err != nil {
return nil, err
}
err = l.load(start, &d)
if err != nil {
return nil, err
}
if d != nil {
return d, nil
}
return nil, nil
}
yamlast.Parse 将文档解析为 Node AST ,
// Node represents a node within the AST.
type Node struct {
Kind int
Line, Column int
Tag string
Value string
Implicit bool
Children []*Node
Anchors map[string]*Node
}
l.load(n node, t interface{}) 则 会根据 Node 的类型 (Mapping/Seq/Scalar)和 t 的反射类型 以及元素类型,调用不同的处理函数
// describes the type conversion being requested,
// in order to look up a registered handler.
typename := strings.Title(typ.Name())
if typ.Kind() == reflect.Slice {
typename = strings.Title(typ.Elem().Name())
typename += "Slice"
}
if typ.Kind() == reflect.Map {
typename = strings.Title(typ.Elem().Name())
typename += "Map"
}
handlerName := nodeKind + "To" + typename
// look for a handler. if found, use it.
if _, ok := loaderTyp.MethodByName(handlerName); ok {
m := loaderVal.MethodByName(handlerName)
nval := reflect.ValueOf(n)
outv := m.Call([]reflect.Value{nval})
errv := outv[1]
if !errv.IsNil() {
return errv.Interface().(error)
}
resv := outv[0]
val.Set(resv)
return nil
}
...
通过复杂的函数,将解析请求转发到了不同的解析函数中,例如 MappingToRequirement, MappingToRequirementSlice, SeqToRequirementSlice 等等,再通过特定的 loadReqByName 从 特定字段获取到具体的数据类型进行解析
func (l *loader) MappingToRequirement(n node) (Requirement, error) {
class := findKey(n, "class")
return l.loadReqByName(class, n)
}
func (l *loader) loadReqByName(name string, n node) (Requirement, error) {
switch strings.ToLower(name) {
case "dockerrequirement":
d := DockerRequirement{}
err := l.load(n, &d)
return d, err
case "resourcerequirement":
r := ResourceRequirement{}
err := l.load(n, &r)
return r, err
case "envvarrequirement":
r := EnvVarRequirement{}
err := l.load(n, &r)
return r, err
case "shellcommandrequirement":
s := ShellCommandRequirement{}
err := l.load(n, &s)
return s, err
case "inlinejavascriptrequirement":
j := InlineJavascriptRequirement{}
err := l.load(n, &j)
return j, err
case "schemadefrequirement":
r := SchemaDefRequirement{}
err := l.load(n, &r)
return r, err
case "softwarerequirement":
r := SoftwareRequirement{}
err := l.load(n, &r)
return r, err
case "initialworkdirrequirement":
r := InitialWorkDirRequirement{}
err := l.load(n, &r)
return r, err
case "subworkflowfeaturerequirement":
return SubworkflowFeatureRequirement{}, nil
case "scatterfeaturerequirement":
return ScatterFeatureRequirement{}, nil
case "multipleinputfeaturerequirement":
return MultipleInputFeatureRequirement{}, nil
case "stepinputexpressionrequirement":
return StepInputExpressionRequirement{}, nil
}
return UnknownRequirement{Name: name}, nil
// TODO logging
//return nil, fmt.Errorf("unknown requirement name: %s", name)
}
从中可以看出, 这个方案虽然使用了GO的很多高级特性,但并未使解析过程简单,过度抽象反而使其更加的难以理解和开发。
相应的也有好处,比如在对语言做扩展时,例如扩展 Requirement / Hit 时,只需要添加一个对应接口的实现,处理方式比较符合 GO 语言的设计模式
Better Parser ?
实现一个 Go Schema_SALAD 预处理器,将 CWL JSON 转化为 standard JSON 可能是一个更加合适的方案?