データベースやログ解析などのシステム開発において、「親となるデータ」と、それに紐付く「複数の子データ」を結合し、扱いやすい階層構造にまとめたいケースがあります。
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-01やvps-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対多の関係性を持つデータを扱う際は、ぜひ活用してください。
