ROMANCE DAWN for the new world

Microsoft Azure を中心とした技術情報を書いています。

ASP.NET Web API で Azure Redis Cache を利用する

クラウドデザインパターンの Cache-Aside Pattern を ASP.NET Web API で実装してみました。Cache-Aside Pattern は、オンデマンドでデータをキャッシュに効率的に読み込むパターンです。キャッシュ機構には、Azure Redis Cache(Preview)を利用しています。

Azure Redis Cache の作成

事前準備として、新しいAzure 管理ポータルで Redis Cache を作成しておきます。任意の名前を入力し、プランとDCのリージョンを選択します。なお、フル機能の旧ポータルからは作成できません。後ほど使うので、Redis Cache の Keys(接続文字列)をメモしておきます。

プロジェクトの作成

Visual Studio 2013 で、WebApplication のプロジェクトを選択し、Web API のテンプレートで作成します。Redis Cache の C# 用ライブラリの NuGet パッケージをインストールします。

  • Install-Package StackExchange.Redis

Person クラスを操作する Web API を作成します。ID と Name だけの Person クラスを定義し、スキャフォールディングで「Entity Framework を使用したアクションがある Web API 2 コントローラー」の PersonController を追加します。

Azure Redis Cache への接続

プログラムから Azure Redis Cache のインスタンスに接続するには、ConnectionMultiplexer クラスを使います。ConnectionMultiplexer のインスタンスは、アプリケーションで共有できるので、スタティックなプロパティとして実装します。Azure 管理ポータルから取得できる Redis Cache の Keys(接続文字列)を渡して接続します。

#PersonController.cs
public class PersonController : ApiController
{
    private static ConnectionMultiplexer connection;
    private static ConnectionMultiplexer Connection
    {
        get
        {
            if (connection == null || !connection.IsConnected)
            {
                connection = ConnectionMultiplexer.Connect("xxx.redis.cache.windows.net,ssl=true,password=xxx");
            }
            return connection;
        }
    }
}

.NET オブジェクト用の拡張メソッド

String や int などのプリミティブなデータ型であれば、StackExchange.Redis クライアントの StringSet と StringGet のメソッドでアクセスできますが、Person クラスのような任意の型の場合、オブジェクトをシリアル化する必要があります。今回は、Json.NET を使った JSON 形式のシリアル化を行う拡張メソッドを作成します。

#JsonNetRedisExtensions.cs
public static class JsonNetRedisExtensions
{
    public static async Task<T> GetAsync<T>(this IDatabase cache, string key)
    {
        return await DeserializeAsync<T>(cache.StringGet(key));
    }
 
    public static async Task SetAsync(this IDatabase cache, string key, object value, TimeSpan? expiry = null)
    {
        await cache.StringSetAsync(key, await SerializeAsync(value), expiry);
    }
 
    static async Task<string> SerializeAsync(object target)
    {
        if (target == null)
        {
            return null;
        }
        return await JsonConvert.SerializeObjectAsync(target);
    }
 
    static async Task<T> DeserializeAsync<T>(string json)
    {
        if (json == null)
        {
            return default(T);
        }
        return await JsonConvert.DeserializeObjectAsync<T>(json);
    }
}

データの取得

GetPerson メソッドにおいて、Cache-Aside Pattern のロジックを実装します。ConnectionMultiplexer.GetDatabase メソッドを呼び出すことで、Redis Cache への参照が返されます。キャッシュにデータがあればそのまま返却し、なければデータベースから取得したデータをキャッシュに追加してから返却します。キャッシュのキーはPerson の ID 、有効期間は10分を設定しています。

#PersonController.cs
public async Task<IHttpActionResult> GetPerson(int id)
{
    // キャッシュからデータを取得
    IDatabase cache = Connection.GetDatabase();
    var person = await cache.GetAsync<Person>(CacheKey(id));
    if (person == null)
    {
        // キャッシュになければ、データベースから取得した結果を保存
        person = await db.People.FindAsync(id);
        if (person == null)
        {
            return NotFound();
        }
        await cache.SetAsync(CacheKey(id), person, TimeSpan.FromMinutes(10));
    }
    return Ok(person);
}
 
static string CacheKey(int id)
{
    return String.Format("PersonId:{0}", id);
}

データの更新と削除

PutPerson メソッドにおいて、データベースの情報が更新されたら、キャッシュを削除します。複数クライアントからの同時更新を考えると、キャッシュを更新するよりも削除したほうが、キャッシュとデータベースの一貫性を確保し易くなります。

#PersonController.cs
public async Task<IHttpActionResult> PutPerson(int id, Person person)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }
 
    if (id != person.Id)
    {
        return BadRequest();
    }
 
    db.Entry(person).State = EntityState.Modified;
 
    try
    {
        await db.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException)
    {
        if (!PersonExists(id))
        {
            return NotFound();
        }
        else
        {
            throw;
        }
    }
 
    // キャッシュを削除
    IDatabase cache = Connection.GetDatabase();
    await cache.KeyDeleteAsync(CacheKey(id));
 
    return StatusCode(HttpStatusCode.NoContent);
}

DeletePerson メソッドにおいても、キャッシュを削除しておます。

#PersonController.cs
public async Task<IHttpActionResult> DeletePerson(int id)
{
    Person person = await db.People.FindAsync(id);
    if (person == null)
    {
        return NotFound();
    }
 
    db.People.Remove(person);
    await db.SaveChangesAsync();
 
    // キャッシュを削除
    IDatabase cache = Connection.GetDatabase();
    await cache.KeyDeleteAsync(CacheKey(id));
 
    return Ok(person);
}

まとめ

より多くのリクエストを捌く必要が出てきた場合、Azure SQL Database はボトルネックになるケースが多いです。データベースに頼り過ぎず、静的データをキャッシュするなら、Cache-Aside Pattern は有効なパターンだと思います。Azure Redis Cache が東日本リージョンにも来たことですし、 Preview から GA することを待つばかりです。