YAML から特定パスを指定した値の抽出

Spring や SpringBoot を使わない環境で、YAML からパスを指定した値の抽出です。
非Spring、非SpringBoot 環境で、YAMLを読む - Oboe吹きプログラマの黙示録
の応用です。
snakeyaml を使用します。
・パスは "." 区切りで並べます。
・配列の一部を抽出する場合は、インデックス [n] を後方に付けます
・受け取る型は、Object で、String か、List<String> を受け取ります。Map としては抽出しません。
・org.yaml.snakeyaml.Yaml と、YAML読込みの InputStream を指定します。
・(注意)1回の実行で InputStream 終端まで読み込みます。
という仕様で以下のメソッドを書いてみました。
pom.xml

<dependency>
  <groupId>org.yaml</groupId>
  <artifactId>snakeyaml</artifactId>
  <version>1.27</version>
</dependency>

インポートするもの

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.events.Event;
import org.yaml.snakeyaml.events.ScalarEvent;

static メソッド

/**
 * パスによる YAML抽出
 * @param yaml org.yaml.snakeyaml.Yaml インスタンス
 * @param inst yaml読込みInputStream
 * @param path "." で区切ったパス、配列は、[n] を付与
 * @return String または、List<String> を結果として返す
 */
public static Object parseYaml(Yaml yaml, InputStream inst, String path) {
   if (path.isEmpty()) throw new IllegalArgumentException("path Error");
   List<String> plist = Arrays.stream(path.split("\\.")).collect(Collectors.toList());
   String s = plist.remove(plist.size()-1);
   if (Pattern.compile("^.+\\[[0-9]+\\]$").matcher(s).matches()) {
      plist.add(s.substring(0, s.indexOf("[")));
      plist.add(s.substring(s.indexOf("[")));
   }else{
      plist.add(s);
   }
   String result = null;
   boolean sequence = false;
   int length = plist.size();
   boolean isSeq = false;
   int xseq = 0;
   List<String> list = new ArrayList<>();
   if (Pattern.compile("^\\[[0-9]+\\]$").matcher(plist.get(plist.size()-1)).matches()) {
      isSeq = true;
      length--;
      String last = plist.get(plist.size()-1);
      xseq = Integer.parseInt(last.substring(1, last.length()-1));
   }
   int n=0;
   int x=0;
   for(Iterator<Event> it = yaml.parse(new InputStreamReader(inst)).iterator();it.hasNext();) {
      Event e = it.next();
      if (e.getEventId().equals(Event.ID.MappingStart)){
         continue;
      }else if(e.getEventId().equals(Event.ID.SequenceStart)){
         sequence = true;
         x=0;
         continue;
      }else if(e.getEventId().equals(Event.ID.SequenceEnd)){
         if (list.size() > 0) {
            return list;
         }
         sequence = false;
         continue;
      }
      if (e.getEventId().equals(Event.ID.Scalar)){
         if (n < length) {
            if (plist.get(n).equals(((ScalarEvent)e).getValue())) {
               n++;
            }
         }else{
            if (sequence){
               if (isSeq) {
                  if (x==xseq){
                     result = ((ScalarEvent)e).getValue();
                     break;
                  }
               }else{
                  list.add(((ScalarEvent)e).getValue());
                  continue;
               }
               x++;
            }else{
               if (n==plist.size()) {
                  result = ((ScalarEvent)e).getValue();
                  break;
               }
            }
         }
      }
   }
   return result;
}

使用例

try(InputStream in = ClassLoader.getSystemClassLoader().getResourceAsStream("sample.yml")){
   Yaml yaml = new Yaml();
   String info1 = (String)parseYaml(yaml, in, "address.group.info1");

}catch(IOException e){
   e.printStackTrace();
}
try(InputStream in = ClassLoader.getSystemClassLoader().getResourceAsStream("sample.yml")){
   Yaml yaml = new Yaml();
   String info1 = (String)parseYaml(yaml, in, "address.group.clist[2]");

}catch(IOException e){
   e.printStackTrace();
}

snakeyaml で出力する先頭の !! について

snakeyaml で、オブジェクトからYAMLを作る - Oboe吹きプログラマの黙示録
で書いたとおり、snakeyaml がダンプする YAML は、
"!!" + 変換対象クラス名か、"!!yaml" が先頭についてしまいます。

YAML書式ではコメントは、'#' で始めてコメント行にするはずです。
2通りの対応が考えられます。いずれも安易な方法です。
・dump で Writer 出力実行直前に、'#' を Writer で出力してしまう。
・dumpAs 実行結果 String に対して、正規表現置換で、"!!" の先頭行を取り除いてしまう。

'#' を Writer で出力しておく方法、、

PrintWriter pw = new PrintWriter(new UnclosableOutputStream(System.out));
Yaml yaml = new Yaml();
pw.print("#");
yaml.dump(sample, pw);

UnclosableOutputStream は、https://oboe2uran.hatenablog.com/entry/2020/08/11/235433 です。

正規表現置換で、"!!" の先頭行を取り除いてしまう方法

String result = yaml.dumpAs(sample, Tag.YAML, DumperOptions.FlowStyle.BLOCK).replaceFirst("^!!.*\n", "");

snakeyaml で、オブジェクトからYAMLを作る

非Spring、非SpringBoot 環境で、YAMLを読む - Oboe吹きプログラマの黙示録
を書いたので、今度はJava オブジェクトから、YAML テキストを snakeyaml で出力します。

Yaml yaml = new Yaml();

基本、Yamlインスタンス作って、dumpメソッドで出力するのですが、

String res = yaml.dump(sample);

どうも、これだと、結果テキストの先頭に、"!!"の2文字に続けて、sample のクラス名の1行が先頭に
出力される。
さらに、YAML形式といっても、リストは1行で収まる書式にされている。
ダンプ例)

!!org.aaa.dto.Sample
address:
  clist: [21, 22, 23]
  group: {info1: A, info2: B}
ate: {date: 2020/09/21, datetime: '2020/09/22 11:09:22'}

snakeyaml では、Java オブジェクトから YAML 生成を目的にはしていないんじゃないかと思われる。
YAML書式になるようにダンプするなら、dumpAs メソッドでオプション指定で出力する。
  dumpAs( object, org.yaml.snakeyaml.nodes.Tag , DumperOptions.FlowStyle )
ということらしい。
2番目の引数を、Tag.YAML として実行すると、
先頭の行が、"!!yaml" として出力される。

String res = yaml.dumpAs(sample, Tag.YAML, DumperOptions.FlowStyle.BLOCK);

Tag.YAML の代わりに、null を指定すれば、"!!"の2文字に続けて、sample のクラス名になる。
FlowStyle.BLOCK を指定すれば、見慣れたYAML書式が出力される。

!!yaml
address:
  clist:
  - 21
  - 22
  - 23
  group:
    info1: A
    info2: B
ate:
  date: 2020/09/21
  datetime: 2020/09/22 11:09:22

どうも、この 先頭行 "!!" 出力させない方法がわからない!
日付、時刻を出力でも java.time.* では、書く必要があり、
非Spring、非SpringBoot 環境で、YAMLを読む - Oboe吹きプログラマの黙示録
と同様、出力用の変換クラスが必要である。
Yaml のコンストラク
  public Yaml(BaseConstructor constructor, Representer representer)
を使用すれば、YAML読込みも、出力も使用できる Yamlインスタンスになる。
この Representer representer として、java.time の LocalDate と LocalDateTime 変換用を用意すれば良い。

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.yaml.snakeyaml.nodes.Node;
import org.yaml.snakeyaml.nodes.Tag;
import org.yaml.snakeyaml.representer.Representer;

public class LocalDateRepresenter extends Representer{
   private DateTimeFormatter dateformatter;
   private DateTimeFormatter datetimeformatter;
   private Tag dateTag;
   private Tag datetimeTag;

   public LocalDateRepresenter(){
      dateTag = Tag.TIMESTAMP;
      datetimeTag = Tag.TIMESTAMP;
      dateformatter = DateTimeFormatter.ISO_LOCAL_DATE;
      datetimeformatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
      multiRepresenters.put(LocalDate.class, new RepresentLocalDate());
      multiRepresenters.put(LocalDateTime.class, new RepresentLocalDateTime());
   }
   public LocalDateRepresenter addLocalDateTimeFormat(String format) {
      this.datetimeformatter = DateTimeFormatter.ofPattern(format);
      this.datetimeTag = Tag.STR;
      return this;
   }
   public LocalDateRepresenter addLocalDateFormat(String format) {
      this.dateformatter = DateTimeFormatter.ofPattern(format);
      this.dateTag = Tag.STR;
      return this;
   }
   private class RepresentLocalDate extends RepresentDate {
      @Override
      public Node representData(Object obj){
         LocalDate localDateTime = (LocalDate)obj;
         String date = localDateTime.format(dateformatter);
         return representScalar(getTag(obj.getClass(), dateTag), date);
      }
   }
   private class RepresentLocalDateTime extends RepresentDate {
      @Override
      public Node representData(Object obj){
         LocalDateTime localDateTime = (LocalDateTime)obj;
         String date = localDateTime.format(datetimeformatter);
         return representScalar(getTag(obj.getClass(), datetimeTag), date);
      }
   }
}

ここで注目すべきは、multiRepresenters に、class と、private 定義した RepresentDate 継承クラスを
追加していくことであり、1つの Representer で、複数の型についての
変換仕様を登録できることである。
public interface Represent インターフェースで、
org.yaml.snakeyaml.nodes.Node を返すことを約束したものを定義していく。

非Spring、非SpringBoot 環境で、YAMLを読む

Spring や SpringBoot を使わない環境で YAML を読込むのにどうしようという課題で、
snakeyaml を使うのが簡単です。
( SpringBoot も結局は、snakeyaml を使っているので、Spring起動時のあの重たい起動の一部で、YAML読込みで使用されているので
安心して使えるのですが。結構古いのかも。)
https://bitbucket.org/asomov/snakeyaml/wiki/Documentation

インスタンスを生成したら、loadAs メソッドで読込ませたいクラスを指定して読み込ませます。

try(InputStream in=ClassLoader.getSystemClassLoader().getResourceAsStream("sample.yml")){
   Yaml yaml = new Yaml();
   Sample sample = yaml.loadAs(in, Sample.class);

}catch(IOException e){
   e.printStackTrace();
}

snakeyaml は、そのまま使うと日付時刻は java.util.Date への変換になってしまいます。
これに対する解は、以下にのってます。
https://bitbucket.org/asomov/snakeyaml/issues/419/add-native-support-for-parsing-serializing
これだけだと、中途半端なので、
以下のように、Yaml生成時に、LocalDate , LocalDateTime 変換用コンストラク
(org.yaml.snakeyaml.constructor.Constructor)
で、書式を指定できるように、以下のようにクラスを用意します。

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.yaml.snakeyaml.constructor.Constructor;
import org.yaml.snakeyaml.nodes.Node;
import org.yaml.snakeyaml.nodes.NodeId;
import org.yaml.snakeyaml.nodes.ScalarNode;
import org.yaml.snakeyaml.nodes.Tag;

public final class LocalDateYamlConstructor extends Constructor{
   private DateTimeFormatter datetimeformatter;
   private DateTimeFormatter dateformatter;

   public LocalDateYamlConstructor(){
      datetimeformatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
      dateformatter = DateTimeFormatter.ISO_LOCAL_DATE;
      this.yamlClassConstructors.put(NodeId.scalar, new LocalDateTimeConstructor());
   }

   public LocalDateYamlConstructor addLocalDateTimeFormat(String format) {
      this.datetimeformatter = DateTimeFormatter.ofPattern(format);
      return this;
   }
   public LocalDateYamlConstructor addLocalDateFormat(String format) {
      this.dateformatter = DateTimeFormatter.ofPattern(format);
      return this;
   }

   private class LocalDateTimeConstructor  extends ConstructScalar  {
      @Override
      public Object construct(Node node) {
         if (node.getTag().equals(Tag.TIMESTAMP)){
            if (node.getType().equals(LocalDate.class)) {
               return LocalDate.parse(((ScalarNode)node).getValue(), dateformatter);
            }else{
               return LocalDateTime.parse(((ScalarNode)node).getValue(), datetimeformatter);
            }
         }else if(node.getTag().equals(Tag.STR) && node.getType().equals(LocalDate.class)){
            return LocalDate.parse(((ScalarNode)node).getValue(), dateformatter);
         }else if(node.getTag().equals(Tag.STR) && node.getType().equals(LocalDateTime.class)){
            return LocalDateTime.parse(((ScalarNode)node).getValue(), datetimeformatter);
         }else{
            return super.construct(node);
         }
      }
   }
}

Yaml インスタンス生成時に、これを指定します。

Yaml yaml = new Yaml(
   new LocalDateYamlConstructor()
   .addLocalDateTimeFormat("yyyy/MM/dd HH:mm:ss")
   .addLocalDateFormat("yyyy/MM/dd")
);
Sample sample = yaml.loadAs(in, Sample.class);

リストからユニーク要素を抽出する

リストから重複要素を抽出する。 - Oboe吹きプログラマの黙示録
を書いたので、
自然に次は、リストからユニークな要素=重複していない要素を抽出したリストを
Collector として生成するのは、以下になります。

public static <T> Collector<T, ?, List<T>> uniquedList(){
   Map<T, Integer> map = new HashMap<>();
   return Collectors.reducing(new ArrayList<T>()
   ,t->{
      map.put(t, Optional.ofNullable(map.get(t)).map(i->i+1).orElse(1));
      return Arrays.asList(t);
   },(n1, n2)->{
      return map.entrySet().stream().filter(e->e.getValue() == 1)
         .map(e->e.getKey())
         .collect(Collectors.toList());
   });
}

リストから重複要素を抽出する。

リスト、またはストリームから重複した要素だけを抽出してリストにしたい場合、
equals 、hashCode が正しく実装されていることが前提だが、
直感的かもしれないが、以下のように Collectors.groupingBy で取得できる。

リストでもStreamを取得できるので、Stream から書くと。。

Stream<String> targets;

// TODO String の stream を targets とする

List<String> list = targets
                    .collect(Collectors.groupingBy(t->t))
                    .entrySet().stream()
                    .filter(e->e.getValue().size() > 1)
                    .map(e->e.getKey())
                    .collect(Collectors.toList());

容易に書けるので苦はないが、でも、いくつもこの処理が必要で沢山これを書くのは、
書き間違えてしまうと元も子もない。
そこで、Collector<T, ?, List<T>> を返すメソッドで用意すれば、
Stream終端操作で書き易くコードがすっきりしてくる。

public static <T> Collector<T, ?, List<T>> duplicatedList(){
   Map<T, Integer> map = new HashMap<>();
   return Collectors.reducing(new ArrayList<T>(),
   t->{
      map.put(t, Optional.ofNullable(map.get(t)).map(i->i+1).orElse(1));
      return Arrays.asList(t);
   },(n1, n2)->{
      return map.entrySet().stream().filter(e->e.getValue() > 1)
         .map(e->e.getKey())
         .collect(Collectors.toList());
   });
}
Stream<String> targets;

// TODO String の stream を targets とする

List<String> list = targets.collect(duplicatedList());

ただし、equals 、hashCode が正しく実装されていることが前提