构建自定义 SnakeYAML 构造函数以模块化方式反序列化 yaml 文件

Build custom SnakeYAML Constructor to deserialize yaml file in a modular way

我想使用 SnakeYAML 像下面这样解析 yaml 文件:

config:
  someBoolean: true
  someString: testing action descriptors
actions:
- print: Hello world
- print: Next action is add
- add:
    left: 25
    right: 17
- print: done

此文档的目标类型是 DocumentRoot:

public class DocumentRoot {
    public Config config;
    public List<Map<String, Object>> actions;
}

public class Config {
    public String someString;
    public boolean someBoolean;
}

所以大部分文档应该被SnakeYAML直接解析成Java-Objects,比如config-Attribute。但是 actions-Attribute 应该以模块化的方式进行解析。考虑以下 ActionDescriptors:

public interface ActionDescriptor<T> {
    String actionKey();

    Class<T> actionValueType();

    void runAction(T actionValue);
}

public class AddExpression {
    public int left;
    public int right;
}

private static List<ActionDescriptor<?>> createDescriptors() {
    return List.of(new ActionDescriptor<String>() {
        @Override
        public String actionKey() {
            return "print";
        }

        @Override
        public Class<String> actionValueType() {
            return String.class;
        }

        @Override
        public void runAction(String actionValue) {
            System.out.println(actionValue);
        }
    }, new ActionDescriptor<AddExpression>() {
        @Override
        public String actionKey() {
            return "add";
        }

        @Override
        public Class<AddExpression> actionValueType() {
            return AddExpression.class;
        }

        @Override
        public void runAction(AddExpression actionValue) {
            System.out.println("calculated: " + (actionValue.left + actionValue.right));
        }
    });
}

我现在想通过以下方式使用这些 ActionDescriptor 来使用 actions 属性:

public static void main(String[] args) throws IOException {
    List<ActionDescriptor<?>> descriptors = createDescriptors();
    DocumentRoot documentRoot = createYaml(descriptors).loadAs(new FileInputStream("data/input.yaml"),
            DocumentRoot.class);
    Map<String, ActionDescriptor<?>> descriptorMap = descriptors.stream()
            .collect(Collectors.toMap(ActionDescriptor::actionKey, Function.identity()));
    if (documentRoot.config.someBoolean) {
        System.out.println(documentRoot.config.someString);
        for (Map<String, Object> actionMap : documentRoot.actions) {
            for (Entry<String, Object> entry : actionMap.entrySet()) {
                runAction(entry.getValue(), descriptorMap.get(entry.getKey()));
            }
        }
    }
}

private static <T> void runAction(Object actionValue, ActionDescriptor<T> descriptor) {
    Class<T> valueType = descriptor.actionValueType();
    if (valueType.isInstance(actionValue)) {
        descriptor.runAction(valueType.cast(actionValue));
    } else {
        System.out.println("expected '" + valueType + "' but got '" + actionValue.getClass() + "'");
    }
}

目前我使用以下方法创建 SnakeYAML 的 Yaml 实例:

private static Yaml createYaml(List<ActionDescriptor<?>> descriptors) {
    Constructor constructor = new Constructor(DocumentRoot.class);
    for (ActionDescriptor<?> descriptor : descriptors) {
        // ???
        constructor.addTypeDescription(new TypeDescription(descriptor.actionValueType()));
    }
    Yaml yaml = new Yaml(constructor);
    yaml.setBeanAccess(BeanAccess.FIELD);
    return yaml;
}

当运行程序我得到以下输出:

testing action descriptors
Hello world
Next action is add
expected 'class animatex.so.AddExpression' but got 'class java.util.LinkedHashMap'
done

但我想要下面的:

testing action descriptors
Hello world
Next action is add
calculated: 42
done

很明显,SnakeYAML 没有使用所需的类型来反序列化操作值。所以我需要以某种方式告诉位于 ??? 位置的 SnakeYaml,如果它反序列化映射条目中的值(其映射是属性 actions 列表中的条目),那么它应该使用类型 descriptor.actionValueType() 如果映射条目的相应键是 descriptor.actionKey().

我已经使用 TypeDescriptors、Constructors 和 Constructs 尝试了一些东西并深入研究了 SnakeYaml 的代码,但我只是不太明白它是如何工作的所以我无法为这个用例构建一个有效的构造函数。

如果有帮助,我还可以扩展 ActionDescriptor 接口以提供 TypeDescriptorConstructorConstruct ...

我真的很想避免在 yaml 文件中添加标签,但如果没有其他解决方案,我可能会咬紧牙关。

我的问题是:如何构建这样一个 Constructor?期待您的评论和回答:-)

你在这里和 YAML 本身作斗争。 YAML 定义你应该使用 tags 来表示节点的类型,如果你需要明确地这样做的话。它看起来像这样:

config:
  someBoolean: true
  someString: testing action descriptors
actions:
- !print Hello world
- !print Next action is add
- !add
  left: 25
  right: 17
- !print done

通过 SnakeYAML 加载这将相当简单,您甚至可以让 DocumentRoot.actions 直接成为 List<ActionDescriptor<?>> 类型。

你的方法试图做“穷人的标签”而不是使用现有的特征。 SnakeYAML 的界面在这种情况下很难使用,因为它希望您使用实际标签来做这样的事情。

您告诉 YAML actions 映射中的值是 Object。如果这样做,YAML 将构造通用集合类型,例如 LinkedHashMap 因为您没有给它任何更多细节,这就是导致错误的原因。为了克服这个问题,您必须根据 SnakeYAML 生成的一般 LinkedHashMap 值手动构建内部结构。

我强烈建议改用标签。 shows how to define custom tags for classes implementing an abstract interface. You also need to set the actual type of the actions content as described in the SnakeYAML docs.

第一步是避免嵌套泛型。为此,我们可以调整 class DocumentRoot 如下:

public class DocumentRoot {
    public Config config;
    public List<ActionMap> actions;
}

public class ActionMap {
    private final Map<String, Object> actions;

    public ActionMap(Map<String, Object> actions) {
        this.actions = actions;
    }
}

我们将地图包装到 ActionMap 类型的对象中。现在我们需要告诉 SnakeYAML 如何将 MappingNode(在 yaml 文件中看起来像地图的任何东西)解析为 ActionMap 类型的对象。我找到了一种扩展 class org.yaml.snakeyaml.constructor.Constructor 的方法,这样很容易实现:

public class MyConstructor extends Constructor {
    public MyConstructor(Class<?> rootClass,
            Map<Class<?>, BiFunction<Function<Node, Object>, MappingNode, Object>> mappingNodeConstructors,
            Map<Class<?>, BiFunction<Function<Node, Object>, SequenceNode, Object>> sequenceNodeConstructors) {
        super(rootClass);
        this.yamlClassConstructors.put(NodeId.mapping, new ConstructMapping() {
            @Override
            public Object construct(Node node) {
                for (Entry<Class<?>, BiFunction<Function<Node, Object>, MappingNode, Object>> entry : mappingNodeConstructors
                        .entrySet()) {
                    if (entry.getKey().isAssignableFrom(node.getType())) {
                        if (node.isTwoStepsConstruction()) {
                            throw new YAMLException("Unexpected 2nd step. Node: " + node);
                        } else {
                            return entry.getValue().apply(MyConstructor.this::constructObject, (MappingNode) node);
                        }
                    }
                }
                return super.construct(node);
            }

            @Override
            public void construct2ndStep(Node node, Object object) {
                throw new YAMLException("Unexpected 2nd step. Node: " + node);
            }
        });
        this.yamlClassConstructors.put(NodeId.sequence, new ConstructSequence() {
            @Override
            public Object construct(Node node) {
                for (Entry<Class<?>, BiFunction<Function<Node, Object>, SequenceNode, Object>> entry : sequenceNodeConstructors
                        .entrySet()) {
                    if (entry.getKey().isAssignableFrom(node.getType())) {
                        if (node.isTwoStepsConstruction()) {
                            throw new YAMLException("Unexpected 2nd step. Node: " + node);
                        } else {
                            return entry.getValue().apply(MyConstructor.this::constructObject, (SequenceNode) node);
                        }
                    }
                }
                return super.construct(node);
            }

            @Override
            public void construct2ndStep(Node node, Object object) {
                throw new YAMLException("Unexpected 2nd step. Node: " + node);
            }
        });
    }
}

请注意,我们完全忽略了 SnakeYAML 所谓的第二步,据我所知,它仅用于使用引用的 yaml 文件。因为我不需要这个功能,所以我忽略了它。另请注意,对于此示例,我们不需要处理 SequenceNode,但对某些人来说它可能仍然有用。

SnakeYAML 的解析工作如下:

  1. 将文档解析为 Node-Objects
  2. 标记 Node- 具有目标类型的对象
  3. Node 对象转换为所需的目标类型

对于第三步,SnakeYAML 使用 ConstructMappingconstruct 方法将 MappingNode(任何在 yaml 文件中看起来像地图的东西)转换成它的目标类型.类似地,它使用 SequenceMappingconstruct 方法将 SequenceNode(任何在 yaml 文件中看起来像列表的东西)转换成它的目标类型。

现在我们可以使用 MyConstructor 的实例来告诉 SnakeYAML 如何将 MappingNode 解析为 ActionMap:

private static Yaml createYaml(List<ActionDescriptor<?>> descriptors) {
    Yaml yaml = new Yaml(createConstructor(descriptors));
    yaml.setBeanAccess(BeanAccess.FIELD);
    return yaml;
}

private static Constructor createConstructor(List<ActionDescriptor<?>> descriptors) {
    Map<String, ActionDescriptor<?>> descriptorMap = descriptors.stream()
            .collect(Collectors.toMap(ActionDescriptor::actionKey, Function.identity()));
    Constructor result = new MyConstructor(DocumentRoot.class, Map.of(ActionMap.class, (constructor, mnode) -> {
        Map<String, Object> actionMap = new LinkedHashMap<>();
        for (NodeTuple entry : mnode.getValue()) {
            Node actionKeyNode = entry.getKeyNode();
            Node actionValueNode = entry.getValueNode();
/* (1) */   String actionKey = (String) constructor.apply(actionKeyNode);
/* (2) */   Class<?> actionValueType = descriptorMap.get(actionKey).actionValueType();
/* (3) */   actionValueNode.setType(actionValueType);
/* (4) */   Object actionValue = constructor.apply(actionValueNode);
/* (5) */   actionMap.put(actionKey, actionValue);
        }
        return new ActionMap(actionMap);
    }), Map.of());
    TypeDescription typeDescription = new TypeDescription(DocumentRoot.class);
    typeDescription.addPropertyParameters("actions", ActionMap.class);
    result.addTypeDescription(typeDescription);
    return result;
}

在这里,我们告诉 MyConstructor 它可以使用给定的 lambda 将 MappingNode 转换为 ActionMap。此 lambda 迭代 MappingNode 的所有条目。对于每个条目,它 (1) 提取 actionKey,(2) 根据 actionKey 确定 actionValueType,(3) 将条目的值 Node 标记为actionValueType,(4) 回调 SnakeYAML 将值 Node 转换为 actionValueType 并且 (5) 在 actionMap 中为确定的 [=37 创建一个新条目=] 和 actionValue。最后它将 actionMap 包装成 ActionMap.

最后方法createConstructor创建了一个TypeDescriptor来告诉SnakeYAMLclassDocumentRootactions属性的泛型类型参数是ActionMap。由于 Java 的类型擦除,这是必需的。

我将代码调整为 运行 如下操作:

public static void main(String[] args) throws IOException {
    List<ActionDescriptor<?>> descriptors = createDescriptors();
    DocumentRoot documentRoot = createYaml(descriptors).loadAs(new FileInputStream("data/input.yaml"),
            DocumentRoot.class);
    if (documentRoot.config.someBoolean) {
        System.out.println(documentRoot.config.someString);
        for (ActionMap actionMap : documentRoot.actions) {
            for (ActionDescriptor<?> descriptor : descriptors) {
                runAction(actionMap, descriptor);
            }
        }
    }
}

private static <T> void runAction(ActionMap actionMap, ActionDescriptor<T> descriptor) {
    actionMap.getActionValue(descriptor).ifPresent(v -> descriptor.runAction(v));
}

其中getActionValue是class中的一个方法 ActionMap:

public <T> Optional<T> getActionValue(ActionDescriptor<T> descriptor) {
    if (actions.containsKey(descriptor.actionKey())) {
        Object actionValue = actions.get(descriptor.actionKey());
        Class<T> valueType = descriptor.actionValueType();
        if (valueType.isInstance(actionValue)) {
            return Optional.of(valueType.cast(actionValue));
        } else {
            throw new RuntimeException("expected '" + valueType + "' but got '" + actionValue.getClass() + "'");
        }
    } else {
        return Optional.empty();
    }
}

正如@flyx 在他们的回答中指出的那样,这种方法实现了“穷人的标签”,而不是使用 yaml 和 SnakeYAML 的现有标签功能。因此,在使用这种方法之前,请考虑使用 yaml 和 SnakeYAML 中现有的标记功能。

然而,这种方法正是我想要的,而且我可能不是唯一的方法,例如 ansible 似乎在其任务列表中使用了类似的 yaml 布局。在我的实际用例中,在单个列表条目中执行多个操作也很有意义,而 yaml 标签无法直接实现。

在现实世界的应用程序中,人们可能想要添加更好的错误处理和一些比类型 BiFunction<Function<Node, Object>, MappingNode, Object>> 更专业的 class。我省略了这些改进,以防止这个答案变得更长。