Java 逆引き集 | ストリームのデバッグ技(ログ出力・中間確認) — トラブルシュート

Java Java
スポンサーリンク

ねらいと前提

「ストリームがどこでどう変わっているか」を素早く可視化できると、原因特定が一気に速くなります。中間確認の王道は peek と「小さく材質化(toList など)」の使い分けです。重要なのは、ストリームが遅延評価であること、終端操作が無いと中間操作は走らないこと、並列や短絡がログに影響することを常に意識することです。

peek による中間確認の基本

もっとも手早いデバッグは「段階ごとにのぞき見」

peek は「要素をそのまま流しつつ副作用を挟む」中間操作です。filter や map の直後に置いて、通過した値を確認します。

List<String> names = List.of("one", "two", "three", "four");

List<String> out = names.stream()
    .filter(s -> s.length() > 3)
    .peek(s -> System.out.println("after filter: " + s))   // ここで中間確認
    .map(String::toUpperCase)
    .peek(s -> System.out.println("after map: " + s))      // さらに確認
    .toList();                                             // 終端が必要

// 出力例:
// after filter: three
// after map: THREE
// after filter: four
// after map: FOUR
Java

終端(collect など)が無いと peek は動きません。デバッグ時は必ず終端を残します。

実運用のログにはロガーを使う

標準出力ではなくロガー(例:SLF4J)を使うと、並列時のスレッド名やレベル制御が行えます。

var log = org.slf4j.LoggerFactory.getLogger("stream");

list.stream()
    .filter(this::cheapCond)
    .peek(x -> {
        if (log.isDebugEnabled()) {
            log.debug("[{}] pass filter: {}", Thread.currentThread().getName(), x);
        }
    })
    .map(this::transform)
    .peek(y -> log.debug("[{}] after map: {}", Thread.currentThread().getName(), y))
    .forEach(this::sink);
Java

並列ストリームではログ行が混ざるため、スレッド名を出すと追跡しやすくなります。

並列・順序・短絡がログに与える影響

並列では出力順が崩れる

parallel() で実行すると、peek の出力順は入力順と一致しません。順序を保証したい終端は forEachOrdered を使いますが、コストが増えるため「必要な場所だけ」に限定します。

Stream.of(1,2,3,4,5).parallel()
    .map(n -> n * 2)
    .peek(n -> System.out.println("peek: " + n))
    .forEachOrdered(n -> System.out.println("ordered: " + n));
Java

短絡操作はログを「途中で止める」

limit、findFirst、anyMatch は必要数に達したらパイプラインを止めます。デバッグ時に「全部見たいのに途中で止まる」なら、短絡を外すか位置を後段に移してください。

Stream.of("A","B","C","D")
    .filter(s -> s.compareTo("C") >= 0)
    .peek(s -> System.out.println("after filter: " + s))
    .limit(1) // ここで短絡
    .forEach(System.out::println);
Java

段階的スナップショットと小さく材質化

小さく切り出して材質化し、値を落ち着いて確認する

大量データを丸ごと材質化するのは危険ですが、「先頭 N 件だけ」なら安全です。上流に limit を置いてピークメモリを抑えます。

List<String> preview = input.stream()
    .filter(this::cheapCond)
    .map(this::normalize)
    .limit(50)              // 先頭だけ見る
    .toList();

System.out.println("preview: " + preview);
Java

teeing(2 系統の終端)で値と統計を同時に見る

Java 12 以降なら、Collectors.teeing で「リスト」と「統計」などを同時取得できます。実動パイプラインを変えずに、デバッグ用の統計を添えるのに便利です。

record Debug<T>(List<T> sample, long count) {}

Debug<String> dbg = input.stream()
    .filter(this::cheapCond)
    .collect(java.util.stream.Collectors.teeing(
        java.util.stream.Collectors.limit(20).collect(java.util.stream.Collectors.toList()), // 先頭20件
        java.util.stream.Collectors.counting(),                                              // 総件数
        Debug::new
    ));

System.out.println("count=" + dbg.count() + ", sample=" + dbg.sample());
Java

例題で身につけるトラブルシュート

例題 1:クレンジングパイプラインの各段階を検証する

文字列ログを「空行除去 → トリム → エラー行抽出」する処理で、どこで値が消えているかを確認します。

List<String> logs = List.of(" INFO start ", "", "ERROR failed", " WARN retry ");

List<String> errors = logs.stream()
    .peek(s -> System.out.println("raw: '" + s + "'"))
    .filter(s -> !s.isBlank())
    .peek(s -> System.out.println("after nonBlank: '" + s + "'"))
    .map(String::trim)
    .peek(s -> System.out.println("after trim: '" + s + "'"))
    .filter(s -> s.startsWith("ERROR"))
    .peek(s -> System.out.println("after error filter: '" + s + "'"))
    .toList();

System.out.println("errors=" + errors);
Java

この出力を見れば「どの段階でドロップしたか」「トリム漏れが無いか」が一目で分かります。

例題 2:例外発生箇所の特定と惰性評価の落とし穴

map 内で例外が出るケース。終端が無いと例外が「起きないように見える」点を体感します。

Stream<String> s = Stream.of("10", "x", "30")
    .map(v -> {
        System.out.println("map before: " + v);
        int n = Integer.parseInt(v);               // "x" で例外
        System.out.println("map after: " + n);
        return n;
    });

// s.count(); // ← 終端を呼ばないと map が走らず、例外も出ない

try {
    long c = s.count(); // ここで初めて走り、"x" で例外
} catch (NumberFormatException e) {
    System.err.println("failed on: " + e.getMessage());
}
Java

ストリームは遅延評価です。中間で何が起きるかを知るには「終端を必ず呼ぶ」ことが前提になります。

デバッグ補助ユーティリティ

tap ヘルパーで peek をラップする

毎回 peek にラムダを書くのが煩雑なら、ラベル付きの tap を用意すると段階名が揃って読みやすくなります。

public final class Debug {
    public static <T> java.util.function.UnaryOperator<Stream<T>> tap(String label) {
        return s -> s.peek(x -> System.out.println(label + ": " + x));
    }
}

// 使い方
List<String> out = Debug.tap("raw")
    .andThen(s -> s.filter(v -> !v.isBlank()))
    .andThen(Debug.tap("nonBlank"))
    .andThen(s -> s.map(String::trim))
    .andThen(Debug.tap("trim"))
    .apply(Stream.of(" a ", " ", "b"))
    .toList();
Java

Supplier で同じ上流を何度も検証する

同じパイプラインに対して「ログあり」「ログなし」など複数パターンを繰り返すときは、Supplier でストリーム再生成にすると安全です。

Supplier<Stream<String>> base = () -> data.stream()
    .filter(this::cheapCond)
    .map(this::normalize);

base.get().peek(v -> System.out.println("debug: " + v)).count(); // ログあり
List<String> done = base.get().toList();                         // ログなし実行
Java

深掘り:正しさと性能を両立するコツ

副作用は「デバッグ専用」に限定する

peek は「本番ロジックを変えない」ために存在します。本番で意味のある副作用(外部リストへの add など)を入れると、並列や短絡で破綻します。ログ以外は入れないのが鉄則です。

大量データでは「サンプリング」と「統計」を優先

全件ログは現実的ではありません。先頭 N 件のサンプル、件数、ヒストグラム(範囲別件数)などの統計を出す設計に切り替えると、原因にたどり着きやすくなります。

long total = big.stream().count();
List<String> sample = big.stream().limit(100).toList();
System.out.println("total=" + total + ", sample=" + sample);
Java

並べ替えや重い中間の前に絞り込みと短絡を置く

sorted、distinct、groupingBy の前に filter と limit を置けるかを常に検討します。中間のピークを下げるだけで、ログを入れても耐えられる処理になります。

I/O 終端に寄せて材質化を避ける

デバッグでも collect せずに「逐次出力(forEach)」でファイルやネットに流す構成にすると、メモリピークを一定に保てます。のぞき見は少数件に限定し、あとは終端で外へ吐くのが安全です。

テンプレート(すぐ使える雛形)

段階ごとの peek ログ

stream
  .peek(v -> log.debug("raw: {}", v))
  .filter(this::cond)
  .peek(v -> log.debug("after cond: {}", v))
  .map(this::transform)
  .peek(v -> log.debug("after map: {}", v))
  .forEach(this::sink);
Java

サンプルと件数の同時取得

record Debug<T>(List<T> sample, long count) {}
Debug<T> dbg = stream
  .collect(java.util.stream.Collectors.teeing(
    java.util.stream.Collectors.limit(50).collect(java.util.stream.Collectors.toList()),
    java.util.stream.Collectors.counting(),
    Debug::new
  ));
Java

Supplier で再生成しながら検証

Supplier<Stream<T>> src = () -> source.stream().filter(this::cond).map(this::normalize);
src.get().peek(x -> System.out.println("debug: " + x)).count();
List<T> finalOut = src.get().toList();
Java

並列時の順序保証(必要箇所のみ)

stream.parallel()
  .map(this::expensive)
  .peek(v -> log.debug("[{}] {}", Thread.currentThread().getName(), v))
  .forEachOrdered(this::sink);
Java

まとめ

ストリームのデバッグは「peek で段階ごとにのぞく」「小さく材質化して落ち着いて確認する」「同じ上流を Supplier で何度でも再生成する」の三本柱で安定します。遅延評価・短絡・並列の影響を常に念頭に置き、ログはデバッグ専用に留めること。本番の正しさと性能を崩さずに、必要な情報だけを取り出せる設計にするのが、プロのトラブルシュートです。

タイトルとURLをコピーしました