Build. Translate. Understand.

C#版 Impureim サンドイッチにした曲レコメンデーション

2025-05-07 19:30:25

原文: Song recommendations as a C# Impureim Sandwich by Mark Seemann

リファクタリングの一例

本記事は、関数型プログラミングの設計における代替アプローチに関する一連の記事のひとつです。すでにこれまでの記事をお読みいただいていることを前提としていますが、簡単に説明すると、Oleksii HolubPure-Impure Segregation Principle(純粋・不純分離原則) という記事で提示した例を取り上げています。この例では、長時間実行されるプロセスの中でユーザー向けに曲レコメンデーションを行います。

前回の記事では、この問題におけるメモリ要件が非常に大きいため、Impureim サンドイッチ の適用は難しそうだとしつつも、それでも試してみる価値はあると述べました。リファクタリング自体はそれほど難しくなく、実際にコードがシンプルになることが分かりました。

列挙用 API

データアクセス API は Web サービスです:

「私はデータベースを所有していません。これは外部の API サービス(Spotify API のような)へのリクエストで、データを提供してくれるものです。」

すべてのデータを読み取るには、全曲および全ユーザーを列挙する手段があると仮定する必要があります。この仮定のもとで、SongService インターフェースに GetAllSongsGetAllUsers メソッドを追加します:

public interface SongService
{
    Task<IEnumerable<Song>> GetAllSongs();
    Task<IEnumerable<User>> GetAllUsers();
    Task<IReadOnlyCollection<User>> GetTopListenersAsync(int songId);
    Task<IReadOnlyCollection<Scrobble>> GetTopScrobblesAsync(string userName);
}

もちろん、これは重要な仮定であり、実際にはそのような API が存在しない可能性もあります。しかし、REST API であればページネーションされたフィードとしてこのような機能を提供することもあり得ます。何百ページ(あるいはそれ以上)にも及ぶ可能性のあるページをめくるのには時間がかかるのは間違いないため、これはバックグラウンド処理であるという点は朗報です。前回の記事で少し触れたように、この種の処理のための専用インデックスサーバーを用意するという想定もできます。最初のデータロードには(数時間かかるとしても)時間がかかることを覚悟すべきですが、一度メモリ上にデータが乗れば、1 人のユーザーのためだけでなく全ユーザー向けの推薦を計算に使い回せます。

前回の記事では、全曲を 1GB 未満のメモリで保持できる可能性があると見積もりました。スクロブルを含まないユーザーのデータも驚くほど軽く、100 万ユーザーで数十 MB 程度に収まります。最終的にメモリ使用量が懸念となるかもしれませんが、この部分に関しては心配する必要はなさそうです。

いずれにせよ、これら 2 つのメソッドの追加によって既存のサンプルコードが壊れることはなく、曲レコメンデーション問題の仕様化の記事で導入した FakeSongService クラスに実装を追加しただけです:

public Task<IEnumerable<Song>> GetAllSongs()
{
    return Task.FromResult<IEnumerable<Song>>(songs.Values);
}
 
public Task<IEnumerable<User>> GetAllUsers()
{
    return Task.FromResult(users.Select(kvp => new User(kvp.Key, kvp.Value.Values.Sum())));
}

これらの追加により、サンドイッチの第一層(というよりフェーズ)としてすべてのデータを読み込めるようになりました。

データの事前読み込み

すべてのデータを読み込む責任は DataCollector モジュールにあります:

public static class DataCollector
{
    public static async Task<IReadOnlyDictionary<int, IReadOnlyCollection<User>>>
        CollectAllTopListeners(this SongService songService)
    {
        var dict = new Dictionary<int, IReadOnlyCollection<User>>();
        foreach (var song in await songService.GetAllSongs())
        {
            var topListeners = await songService.GetTopListenersAsync(song.Id);
            dict.Add(song.Id, topListeners);
        }
        return dict;
    }
 
    public static async Task<IReadOnlyDictionary<string, IReadOnlyCollection<Scrobble>>>
        CollectAllTopScrobbles(this SongService songService)
    {
        var dict = new Dictionary<string, IReadOnlyCollection<Scrobble>>();
        foreach (var user in await songService.GetAllUsers())
        {
            var topScrobbles = await songService.GetTopScrobblesAsync(user.UserName);
            dict.Add(user.UserName, topScrobbles);
        }
        return dict;
    }
}

これら 2 つのメソッドは任意の SongService 実装で動作するため、FakeSongService と組み合わせてコードベースを動かすこともできますし、実運用用の HTTP ベース実装でページネーション付き Web API を利用することも可能です。

メソッドが返す辞書は非常に巨大になる可能性がありますが、それこそがこの演習の主要なポイントです。変更が完了し、仕様化テストで動作が維持されていることが確認できたら、データを生成してメモリ使用量を測るのが合理的です。

テーブル駆動方式

なぜ CollectAllTopListenersCollectAllTopScrobbles がそのような形の辞書を返すのか、不思議に思われたかもしれません。

Code Complete では、テーブル駆動方式 というプログラミング手法が紹介されています。ifelseswitch のような分岐処理をルックアップテーブルに置き換えるという考え方です。大枠としては、関数呼び出しをテーブル検索に置き換えることができるということです。

たとえば GetTopListenersAsync メソッドは int を入力として受け取り、Task<IReadOnlyCollection<User>> を返します。Task を無視すれば、IReadOnlyCollection<User> を返すことになります。つまり、intIReadOnlyCollection<User> に変換することになります。

そして、IReadOnlyDictionary<int, IReadOnlyCollection<User>> を持っていれば、やはり intIReadOnlyCollection<User> に変換できます。これら 2 つの API は機能的には同等です——ただし、当然ながらメモリや実行時の特性は大きく異なります。

GetTopScrobblesAsync メソッドについても同様です。string を入力として受け取り、IReadOnlyCollection<Scrobble> を返します(やはり Task を無視すれば)。同様に、IReadOnlyDictionary<string, IReadOnlyCollection<Scrobble>> も同等です。

実用面では、辞書に該当するキーが存在しない場合に対応するヘルパーメソッドが必要になります:

internal static IReadOnlyCollection<T> GetOrEmpty<T, TKey>(
    this IReadOnlyDictionary<TKey, IReadOnlyCollection<T>> dict,
    TKey key)
{
    if (dict.TryGetValue(key, out var result))
        return result;
    return Array.Empty<T>();
}

該当キーが存在しない場合、この関数は代わりに空の配列を返します。

これにより、GetTopListenersAsyncGetTopScrobblesAsync を辞書検索に置き換えるのが容易になります。

メソッドの引数を追加する

リファクタリングを行う際は、小さく制御されたステップで進めるのが良い方法です。私の マイクロコミット の様子は Git リポジトリの refactor-to-function ブランチで確認できます。ここでは概要を説明します。

まず、GetRecommendationsAsync メソッドに 2 つの辞書を引数として追加しました。元々のメソッドはこのような形でした:

public async Task<IReadOnlyList<Song>> GetRecommendationsAsync(string userName)

そして、2 つの辞書を追加した後は以下のようになりました:

public async Task<IReadOnlyList<Song>> GetRecommendationsAsync(
    string userName,
    IReadOnlyDictionary<string, IReadOnlyCollection<Scrobble>> topScrobbles,
    IReadOnlyDictionary<int, IReadOnlyCollection<User>> topListeners)

この時点では GetRecommendationsAsync メソッドはまだ topScrobblestopListeners パラメータを使用していませんが、それでもこれは Git にコミットすべき明確なチェックポイント です。私の著書 『脳に収まるコードの書き方』 に書いたように、プロダクションコードだけをリファクタリングするか、テストコードだけをリファクタリングするのが最も安全です。今回のような API 変更は両方に影響するため、その分リスクを最小化する必要があります。この変更はプロダクションコードとテストコードの両方に触れますが、テスト対象システム(SUT)の挙動は変更していません。

テストコードはこのようになります:

[<Property>]
let ``No data`` () =
    Gen.userName |> Arb.fromGen |> Prop.forAll <| fun userName ->
    task {
        let srvc = FakeSongService ()
        let sut = RecommendationsProvider srvc
 
        let! topScrobbles = DataCollector.CollectAllTopScrobbles srvc
        let! topListeners = DataCollector.CollectAllTopListeners srvc
        let! actual =
            sut.GetRecommendationsAsync (userName, topScrobbles, topListeners)
 
        Assert.Empty actual } :> Task

テストでは、DataCollector を使ってデータを辞書にロードし、それを GetRecommendationsAsync に渡します。現時点ではメソッドはそのデータをまだ使用していませんが、いざというときに使えるようにはなっています。

この No data テストのバージョンを、曲レコメンデーション問題の仕様化の記事にある従来のバージョンと比較してみると良いでしょう。

関数へのリファクタリング

コードは今、依存関係の注入(Dependency Injection)から依存関係の排除(Dependency Rejection)へのリファクタリングを行う準備が整いました。FakeSongService に含まれるデータは、2つのディクショナリと同一の内容を持つため、実質的に同じデータの異なる表現にすぎません。このため、リファクタリングは1つずつメソッド呼び出しを置き換えるかたちで進めることができます。

すでに述べた通り、これらのディクショナリは SongService の各クエリと機能的に等価であり、互いに容易に置き換えることが可能です。たとえば、GetRecommendationsAsync メソッド内の最初の非純粋な処理は以下のようなものです:

var scrobbles = await _songService.GetTopScrobblesAsync(userName);

これを同等のディクショナリ参照に置き換えると、次のように書けます:

var scrobbles = topScrobbles.GetOrEmpty(userName);

ディクショナリの参照は純粋関数であるため、この操作には await を使う必要がありません。

この時点では、GetRecommendationsAsync の残りの部分ではまだ注入された SongService を使っていますが、すべてのテストはパスしており、この小さく独立した変更を Git にコミットできます。

同様の手順を繰り返せば、SongService のクエリ呼び出しを1つずつ取り除くことができます。呼び出しは3つしかないため、3段階で制御された変更が完了します。最後の非純粋なクエリが置き換えられた後、C# のコンパイラは GetRecommendationsAsync メソッドの宣言にある async キーワードが不要だと警告します。

実際、もはや async は必要なく、メソッド自体も非同期ではありません。Task を返す理由もなくなり、Async という名前の接尾辞も誤解を招くものとなります。

public IReadOnlyList<Song> GetRecommendations(
    string userName,
    IReadOnlyDictionary<string, IReadOnlyCollection<Scrobble>> topScrobbles,
    IReadOnlyDictionary<int, IReadOnlyCollection<User>> topListeners)

この時点で、GetRecommendations メソッドは注入された SongService を使わなくなります。そしてこれは RecommendationsProvider クラスの唯一のメソッドであるため、この依存関係を(再び)排除できます。

さらに、このクラスはフィールドを一切持たなくなるため、クラス自体および GetRecommendations 関数を static にしてしまっても問題ありません。以下が最終的な関数の全体です:

public static IReadOnlyList<Song> GetRecommendations(
    string userName,
    IReadOnlyDictionary<string, IReadOnlyCollection<Scrobble>> topScrobbles,
    IReadOnlyDictionary<int, IReadOnlyCollection<User>> topListeners)
{
    // 1. 自ユーザーのトップスクロブルを取得
    // 2. 同じ曲を聴いた他ユーザーを取得
    // 3. そのユーザーのトップスクロブルを取得
    // 4. 曲を集約してレコメンドを生成
 
    var scrobbles = topScrobbles.GetOrEmpty(userName);
    var scrobblesSnapshot = scrobbles
        .OrderByDescending(s => s.ScrobbleCount)
        .Take(100)
        .ToArray();
 
    var recommendationCandidates = new List<Song>();
    foreach (var scrobble in scrobblesSnapshot)
    {
        var otherListeners = topListeners.GetOrEmpty(scrobble.Song.Id);
        var otherListenersSnapshot = otherListeners
            .Where(u => u.TotalScrobbleCount >= 10_000)
            .OrderByDescending(u => u.TotalScrobbleCount)
            .Take(20)
            .ToArray();
 
        foreach (var otherListener in otherListenersSnapshot)
        {
            var otherScrobbles =
                topScrobbles.GetOrEmpty(otherListener.UserName);
            var otherScrobblesSnapshot = otherScrobbles
                .Where(s => s.Song.IsVerifiedArtist)
                .OrderByDescending(s => s.Song.Rating)
                .Take(10)
                .ToArray();
 
            recommendationCandidates.AddRange(
                otherScrobblesSnapshot.Select(s => s.Song)
            );
        }
    }
 
    var recommendations = recommendationCandidates
        .OrderByDescending(s => s.Rating)
        .Take(200)
        .ToArray();
 
    return recommendations;
}

構造は 元の記事 にあるオリジナルのバージョンに似ています。コードがよりシンプルになった今(非同期処理が不要になったため)、さらにリファクタリングを進めることもできるでしょう。この C# の例ではここで区切りますが、F# に移植した際には、より積極的にリファクタリングを進めるつもりです。

サンドイッチ構造

この一連の取り組みの目的のひとつは、Impureim サンドイッチへのリファクタリングの方法を示すことです。上記の GetRecommendations メソッドは、このサンドイッチの「純粋な中身」にあたりますが、それでは全体のサンドイッチ構造とはどのようなものなのでしょうか?

このコードベースにおいて、サンドイッチはユニットテストという形でのみ存在しています。最もシンプルなものは、やはり No data テストです:

[<Property>]
let ``No data`` () =
    Gen.userName |> Arb.fromGen |> Prop.forAll <| fun user ->
    task {
        let srvc = FakeSongService ()
 
        let! topScrobbles = DataCollector.CollectAllTopScrobbles srvc
        let! topListeners = DataCollector.CollectAllTopListeners srvc
        let actual = RecommendationsProvider.GetRecommendations (user, topScrobbles, topListeners)
 
        Assert.Empty actual } :> Task

上のコードスニペットでは、テスト内の該当箇所を色分けして示しました。最後の行を「副作用あり」として赤く分類するのは多少無理があります。というのも Assert.Empty は少なくとも決定的(deterministic)であり、失敗時に例外を投げるという点を除けば、厳密に言えば副作用があるとは言えないからです。実際、アサーションを純粋関数としてリファクタリングするのは難しくありません。

代わりに、このサンドイッチの「下側のパン」は、副作用を持つ何かが実行される可能性のある場所と考えるとよいでしょう。たとえば、バックグラウンドサービスが曲レコメンデーション結果を保存する、CQRSスタイルのマテリアライズド・ビューとして実現されるかもしれません。

このようにして見てみると、上記のテストコードは Impureim サンドイッチの一例をスケッチしたものと言えます:まず DataCollector メソッドを使ってデータを事前に読み込み、次に GetRecommendations を呼び出し、最後にその結果を用いて何らかの処理を行います。

結論

この記事で紹介した変更には2つの目的があります。1つは、非純粋な処理を純粋関数へとリファクタリングし、Impureim サンドイッチという構造を実現する方法を示すこと。もう1つは、概念実証として、すべてのデータをあらかじめ読み込む前提が実際に成立するかどうか、つまりすべてのデータがメモリに収まるのかを検証することです。

この点についてはまだ議論が残っていますが、この記事もすでに長くなっていますので、続きは別の記事で取り上げることにします。

次回: 曲レコメンデーションの概念検証におけるメモリ測定


インデックスへ戻る