【C#】LINQのGroupJoinで親データと子データを階層的に結合する

データベースやログ解析などのシステム開発において、「親となるデータ」と、それに紐付く「複数の子データ」を結合し、扱いやすい階層構造にまとめたいケースがあります。

C#のLINQにあるGroupJoinメソッドを使用すると、SQLの「左外部結合(LEFT OUTER JOIN)」に近い感覚で、かつオブジェクト指向的な「1対多」の構造を一度のクエリで構築できます。

今回は、インフラ監視システムを想定し、「管理対象サーバー」と「発生したエラーログ」を紐付け、サーバーごとのエラー状況をリスト化する実装例を紹介します。


目次

GroupJoinメソッドの活用シーン

GroupJoinは、2つのコレクションをキーで結合しますが、通常のJoinとは異なり、結合した結果をコレクションとして保持する点が特徴です。

  • Join: フラットな行の羅列になります(親情報が子の数だけ重複する)。
  • GroupJoin: 親1つにつき、子要素のリストを持つ形になります(親の中に子の配列が入るイメージ)。

これにより、紐付くデータが存在しない場合でも親データを維持しつつ(空のリストとして扱われる)、データが存在する場合はそれらをまとめて処理することが可能です。

実践的なコード例:サーバーとエラーログの紐付け

以下のコードは、サーバー台帳(マスター)と、日々蓄積されるエラーログのリストを結合し、サーバーごとにエラー内容を出力する例です。エラーが発生していないサーバーも一覧に含まれる点に注目してください。

using System;
using System.Collections.Generic;
using System.Linq;

namespace InfrastructureMonitor
{
    // サーバー情報(親)
    public record ServerInfo(int Id, string HostName, string Role);

    // エラーログ(子)
    public record ErrorLog(int ServerId, string Message, DateTime Timestamp);

    class Program
    {
        static void Main()
        {
            // サーバーのマスターデータ
            var servers = new[]
            {
                new ServerInfo(101, "vps-web-01", "Web Server"),
                new ServerInfo(102, "vps-db-primary", "Database"),
                new ServerInfo(103, "vps-cache-01", "Redis Cache"),
                new ServerInfo(104, "vps-backup-01", "Storage") // エラーなし
            };

            // 発生したエラーログのデータ
            var errorLogs = new[]
            {
                new ErrorLog(101, "Connection timed out", DateTime.Parse("2023-10-01 10:00")),
                new ErrorLog(102, "Deadlock detected", DateTime.Parse("2023-10-01 10:05")),
                new ErrorLog(101, "503 Service Unavailable", DateTime.Parse("2023-10-01 10:10")),
                new ErrorLog(102, "Transaction log full", DateTime.Parse("2023-10-01 10:15")),
                new ErrorLog(999, "Unknown Host Error", DateTime.Parse("2023-10-01 12:00")) // 登録外のサーバー(除外される)
            };

            // GroupJoinによる階層的な結合
            // 第1引数: 結合する子シーケンス (errorLogs)
            // 第2引数: 親のキー (s.Id)
            // 第3引数: 子のキー (e.ServerId)
            // 第4引数: 結果を生成するセレクタ
            var serverStatusList = servers.GroupJoin(
                errorLogs,
                server => server.Id,
                log => log.ServerId,
                (server, logs) => new
                {
                    ServerName = server.HostName,
                    Role = server.Role,
                    ErrorCount = logs.Count(),
                    Errors = logs // ここには該当するErrorLogのIEnumerableが入る
                }
            );

            // 結果の出力
            foreach (var status in serverStatusList)
            {
                Console.WriteLine($"[{status.Role}] {status.ServerName}");
                
                // エラーが存在するか確認
                if (status.Errors.Any())
                {
                    Console.ForegroundColor = ConsoleColor.Red;
                    foreach (var err in status.Errors)
                    {
                        Console.WriteLine($"  - {err.Timestamp:HH:mm} : {err.Message}");
                    }
                    Console.ResetColor();
                }
                else
                {
                    Console.ForegroundColor = ConsoleColor.Green;
                    Console.WriteLine("  - Status OK (No Errors)");
                    Console.ResetColor();
                }
                Console.WriteLine(); // 空行
            }
        }
    }
}

実行結果

[Web Server] vps-web-01
  - 10:00 : Connection timed out
  - 10:10 : 503 Service Unavailable

[Database] vps-db-primary
  - 10:05 : Deadlock detected
  - 10:15 : Transaction log full

[Redis Cache] vps-cache-01
  - Status OK (No Errors)

[Storage] vps-backup-01
  - Status OK (No Errors)

技術的なポイント

1. 外部結合として機能する

上記の例で、vps-backup-01vps-cache-01には対応するエラーログが存在しません(errorLogsの中に該当するServerIdがない)。通常のJoinメソッドであればこれらのサーバーは結果から消えてしまいますが、GroupJoinの場合は親データが残り、logs引数には空のシーケンスが渡されます。これにより、「エラーがないサーバー」も「正常」としてレポートに出力できます。

2. メモリ効率と遅延実行

GroupJoinも他のLINQメソッドと同様に遅延実行されます。ただし、結果を列挙する際、内部的にはルックアップテーブル(ハッシュテーブルのような構造)が生成されるため、結合対象のデータ量が非常に多い場合はメモリ使用量に注意が必要です。データベース(Entity Frameworkなど)に対して発行する場合は、適切なSQL(LEFT JOIN)に変換されるため、メモリの心配は少なくなります。

3. フラット化したい場合

階層構造ではなく、CSVのような「行データ」として出力したい場合は、GroupJoinの結果に対してSelectManyを適用し、DefaultIfEmptyを使用することで実現できます。

// GroupJoinの結果をフラットな行リストに変換する(SQLのLEFT JOINの結果セットと同じ形)
var flatLog = serverStatusList.SelectMany(
    x => x.Errors.DefaultIfEmpty(), // エラーがない場合はnullを許容するコレクションにする
    (parent, child) => new 
    { 
        parent.ServerName, 
        ErrorMessage = child?.Message ?? "No Error" // nullチェックが必要
    }
);

まとめ

GroupJoinを使用することで、複雑なループ処理を書くことなく、親データと子データを整理された状態で結びつけることができます。「ヘッダーと明細」「フォルダとファイル」「サーバーとログ」といった1対多の関係性を持つデータを扱う際は、ぜひ活用してください。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次