C# 関連データの未取得とnullをLazyFieldで区別する
C#で関連データを必要なときだけ取得する場合、値がnullかどうかだけでは取得済みか判断できません。
まだ取得していない場合と、取得した結果データが存在しなかった場合の両方がnullになるためです。
値と取得状態をまとめて保持するLazyField<T>を使用して、未取得とデータなしを区別する方法を紹介します。
nullだけで判定すると再取得される
以下のように、値がnullならデータベースから取得する処理を考えます。
問題のある例if (_lastLogin is null)
{
_lastLogin = await LoadLastLoginAsync(cancellationToken);
}ログイン履歴が存在しない場合、取得結果はnullになります。
次に同じ処理を呼び出したときも条件を満たすため、データが存在しないことを確認するクエリを繰り返し実行してしまいます。
必要なのは、値とは別に「取得が完了したか」を保持することです。
値と取得済みフラグを分ける
データを読み込む関数と、取得済みの値を保持するクラスを作成します。
以下はnullable参照型を有効にしたC#の例です。
LazyField.csusing System;
using System.Threading;
using System.Threading.Tasks;
public sealed class LazyField<T>
{
private T _value = default!;
private readonly Func<CancellationToken, Task<T>>? _loader;
private bool _loaded;
private LazyField(Func<CancellationToken, Task<T>> loader)
{
_loader = loader;
}
private LazyField(T value)
{
_value = value;
_loaded = true;
}
public static LazyField<T> FromLoader(Func<CancellationToken, Task<T>> loader)
=> new LazyField<T>(loader);
public static LazyField<T> FromValue(T value)
=> new LazyField<T>(value);
public bool IsLoaded => _loaded;
public async Task<T> GetOrLoadAsync(CancellationToken cancellationToken)
{
if (_loaded) return _value;
if (_loader is null)
{
throw new InvalidOperationException("Loader is not available.");
}
_value = await _loader(cancellationToken);
_loaded = true;
return _value;
}
}FromLoaderにはデータを取得する関数を渡します。これにより、クラス内にデータベース固有の処理を書く必要がなくなります。
取得結果がnullでも、処理が正常に完了すれば_loadedをtrueにします。
| 状態 | _loaded | _value |
|---|---|---|
| まだ取得していない | false | 初期値 |
| 取得したがデータがない | true | null |
| 取得してデータがあった | true | 取得した値 |
default!の!は、値を非nullに変換するものではありません。Tがnullableな型なら、取得済みのnullをそのまま返せます。
すでに値が分かっている場合
新しくオブジェクトを作成するときは、関連データが存在しないことを最初から把握している場合があります。
この場合は読み込み関数を用意せず、FromValueで取得済みの値として初期化します。
取得済みの値から作成する例var lastLogin = LazyField<DateTimeOffset?>.FromValue(null);
var roles = LazyField<string[]>.FromValue(Array.Empty<string>());1件の関連データが存在しない場合はnull、複数件の関連データが存在しない場合は空のコレクションとして表せます。
FromValue側のコンストラクターでは、最初から_loadedをtrueにしています。
nullの取得結果も保持されることを確認する
データベースの代わりに、呼び出し回数を数える関数を渡して確認します。
使用例var loadCount = 0;
var lastLogin = LazyField<DateTimeOffset?>.FromLoader(_ =>
{
loadCount++;
return Task.FromResult<DateTimeOffset?>(null);
});
await lastLogin.GetOrLoadAsync(CancellationToken.None);
await lastLogin.GetOrLoadAsync(CancellationToken.None);
Console.WriteLine(loadCount); // 1
Console.WriteLine(lastLogin.IsLoaded); // True値はnullですが、2回目は取得済みの値を返すため、読み込み関数は1回だけ実行されます。
取得に失敗した場合
読み込み処理のあとに_loaded = trueを実行しているため、例外が発生した場合は未取得のままです。
キャンセルされた場合も同様です。あとからもう一度呼び出せば、読み込み関数を再度実行します。
ただし、これはLazyField自身の状態についての説明です。実際に再試行できるかどうかは、読み込み関数やデータベース接続の状態にも依存します。
並列実行と一覧表示には注意する
この実装にはロックがありません。同じインスタンスに対して複数の処理が同時にGetOrLoadAsyncを呼び出すと、読み込み関数が複数回実行される可能性があります。
同時に呼び出す用途では、排他制御や実行中のTaskを共有する仕組みを別途検討する必要があります。
また、一覧の各行で関連データを読み込むと、行数に応じてクエリが増える可能性があります。
一覧で必ず必要なデータはまとめて取得し、取得済みの値をFromValueで渡す方法も考えられます。
LazyFieldは取得結果を同じインスタンス内で保持するため、外部で更新されたデータを自動で読み直すこともありません。
必要になった時点で取得する処理と、一覧向けの一括取得を使い分けることが大切です。