NoSQL ドキュメント データベース
ASP.NET MVC 3 アプリケーションに RavenDB を埋め込む
Justin Schwartzenberger
Microsoft .NET Framework コミュニティで、NoSQL 活動への関心が高まりを見せています。これは、よく知られていて使用されているアプリケーションに NoSQL を実装し、その経験を共有している企業の話を絶えず耳にすることからうかがえます。こうした関心の高まりと共に、NoSQL のデータ ストアによって、開発者が現在作成しているソフトウェアにどのようなメリットまたは他の潜在的なソリューションが提供され得るかを深く理解し、見定めようとする好奇心が芽生えています。ただ、どこから着手すればよいのでしょう。また、学習はどれほど困難なのでしょう。おそらくもっと気になるのは、新しいデータ ストレージ ソリューションに着手してコードの作成に取りかかるには、どのくらいの時間と労力が必要になるかということです。ともあれ、新しいアプリケーションに SQL Server をセットアップする手順は、よくご存じでしょう。
NoSQL 型データ層を実装するという新たな選択肢が、ワタリガラス (raven) の翼に乗って .NET コミュニティに届けられました。RavenDB (ravendb.net、英語) は、.NET または Windows プラットフォーム用に設計されたドキュメント データベースで、非リレーショナル データ ストアの使用を始めるのに必要なものがすべて 1 つにまとめられています。RavenDB では、ドキュメントはスキーマのない JSON として格納されます。データ ストアと直接やり取りする RESTful API も存在しますが、真のメリットはインストールに付属している .NET クライアント API にあります。これは、Unit of Work パターンを実装し、LINQ 構文を利用してドキュメントとクエリを操作します。Entity Framework (EF) や NHibernate などのオブジェクト リレーショナル マッパー (ORM)、または WCF データ サービスを使用した経験をお持ちなら、RavenDB でドキュメントの処理に利用する API アーキテクチャにも、まったく抵抗はないでしょう。
RavenDB のインスタンスに着手し使用するための学習の道のりは、短くて緩やかです。実のところ、最もよく計画しなければならないのは、ライセンスの方針です (ただ、それさえもたいしたことはありません)。RavenDB の場合、オープン ソースのプロジェクトにはオープン ソースのライセンスが付与されますが、クローズド ソースの商用のプロジェクトには商用のライセンスが必要です。ライセンスと価格の詳細については、ravendb.net/licensing (英語) を参照してください。この Web サイトでは、新興企業やクローズド ソースの非営利プロジェクトに使用することを考えているユーザーは、無償のライセンスを利用できると述べられています。どちらにしても、なんらかのプロトタイプの作成やサンドボックスの開発に取りかかる前に、長期的な実装のメリットを理解するため、いくつかのオプションを簡単に紹介する価値はあるでしょう。
RavenDB の埋め込みと MVC
RavenDB は、次の 3 つの形態で実行可能です。
- Windows サービスとして
- IIS アプリケーションとして
- .NET アプリケーションへの埋め込みとして
最初の 2 つのセットアップ プロセスは非常にシンプルですが、実装の方法にいくぶんオーバーヘッドが生じます。.NET アプリケーションに埋め込んで使用するという 3 つ目の方法は、着手し実行するのがきわめて簡単です。実際、そのための NuGet パッケージも利用可能です。Visual Studio 2010 の [Package Manager Console] (パッケージ マネージャー コンソール) で次のコマンドを呼び出すと (または、[Manage NuGet Packages] (NuGet パッケージの管理) ダイアログ ボックスで "ravendb" という用語を検索すると)、埋め込みの RavenDB の使用を開始するのに必要なすべての参照が表示されます。
Install-Package RavenDB-Embedded
パッケージの詳細については、NuGet ギャラリー サイト (bit.ly/ns64W1、英語) を参照してください。
埋め込み型の RavenDB を ASP.NET MVC 3 アプリケーションに追加するのは簡単で、NuGet を使ってパッケージを追加し、データ ストア ファイル用にディレクトリの場所を用意するだけです。ASP.NET アプリケーションのフレームワークには App_Data という名前のよく知られたデータ ディレクトリがあり、ホストしている企業の多くが、わずかな構成または構成の必要なく App_Data ディレクトリの読み取りと書き込みを許可するアクセス権を提供しているため、データ ファイルを格納するのに適しています。RavenDB はファイル ストレージの作成時に、用意されたディレクトリ パスに数多くのディレクトリとファイルを作成します。最上位レベルのディレクトリを作成し、そこにすべてを格納するのではありません。そのため、Visual Studio 2010 でプロジェクトのコンテキスト メニューから App_Data という名前の ASP.NET フォルダーを追加したうえで、App_Data ディレクトリに RavenDB データ用のサブディレクトリを作成することをお勧めします (図 1 参照)。
図 1 App_Data ディレクトリの構造
ドキュメント データ ストアにはもともとスキーマがないので、データベースのインスタンスを作成したり、テーブルを設定したりする必要はありません。データ ストアを初期化する最初の呼び出しをコードに作成したら、データの状態を管理するのに必要なファイルが作成されます。
RavenDB クライアント API とデータ ストアのインターフェイスを確立するには、Raven.Client.IDocumentStore インターフェイスの作成および初期化を実装するオブジェクトのインスタンスが必要です。RavenDB クライアント API の DocumentStore と EmbeddedDocumentStore という 2 つのクラスでインターフェイスを実装します。また、これらのクラスは RavenDB を実行する形態に応じて使用できます。アプリケーションのライフサイクルでは、データ ストアごとに 1 つのインスタンスしか必要ありません。そこで、ドキュメント ストアへの単一の接続を管理するクラスを作成します。そのクラスでは、静的プロパティを介して IDocumentStore オブジェクトのインスタンスにアクセスでき、インスタンスを初期化する静的メソッドが含まれます (図 2 参照)。
図 2 DocumentStore のクラス
public class DataDocumentStore
{
private static IDocumentStore instance;
public static IDocumentStore Instance
{
get
{
if(instance == null)
throw new InvalidOperationException(
"IDocumentStore has not been initialized.");
return instance;
}
}
public static IDocumentStore Initialize()
{
instance = new EmbeddableDocumentStore { ConnectionStringName = "RavenDB" };
instance.Conventions.IdentityPartsSeparator = "-";
instance.Initialize();
return instance;
}
}
その静的プロパティの getter は、プライベート静的バッキング フィールドが null オブジェクトでないか調べ、null の場合は InvalidOperationException をスローします。ここでは初期化メソッドを呼び出すのではなく例外をスローし、コードをスレッドセーフに保ちます。インスタンス プロパティで初期化メソッドを呼び出し、プロパティの参照に依存しているアプリケーションが初期化を実行するようにした場合、複数のユーザーが同時にアプリケーションを実行し初期化メソッドを同時に呼び出すことになる可能性が生じます。初期化メソッドのロジックでは、Raven.Client.Embedded.EmbeddableDocumentStore という新しいインスタンスを作成し、ConnectionStringName プロパティに RavenDB NuGet パッケージのインストールにより web.config ファイルに追加された接続文字列名を設定します。web.config ファイルでは、RavenDB が認識する構文でその接続文字列の値を設定します。埋め込みローカル バージョンのデータ ストアを使用するように構成するためです。また、MVC プロジェクトの App_Data ディレクトリ内に作成した Database ディレクトリに、ファイル ディレクトリをマップします。
<connectionStrings>
<add name="RavenDB " connectionString="DataDir = ~\App_Data\Database" />
</connectionStrings>
IDocumentStore インターフェイスには、データ ストアを使用するためのメソッドがすべて含まれています。EmbeddableDocumentStore オブジェクトをインターフェイス型 IDocumentStore のインスタンスとして返し、格納します。そのため、埋め込みのバージョンから移行する必要がある場合は、EmbeddedDocumentStore オブジェクトのインスタンスを作成する処理をサーバー バージョン (DocumentStore) に柔軟に変更できます。この方法で、ドキュメント オブジェクトを管理するすべてのロジック コードは、RavenDB が実行されている形態を認識する必要がなくなります。
RavenDB は既定で、ドキュメント ID キーを REST と同様の形式で作成します。たとえば、"Item" オブジェクトは、"items/104" という形式でキーを取得することになります。オブジェクト モデル名は小文字に変換された複数形で、新しいドキュメントを作成するたびにスラッシュの後ろに一意の追跡 ID 番号が付加されます。これは MVC アプリケーションでは問題になる場合があります。スラッシュにより、新たにルート パラメーターの解析が引き起こされるためです。RavenDB クライアント API では、IdentityPartsSeparator の値を設定することでスラッシュを変換する方法が提供されています。DataDocumentStore.Initialize メソッドでは EmbeddableDocumentStore オブジェクトでメソッドの初期化を呼び出す前に IdentityPartsSeparator の値にダッシュを設定し、このルーティングの問題を回避します。
MVC アプリケーションの Global.asax.cs ファイル内に含まれている Application_Start メソッドに DataDocumentStore.Initialize 静的メソッドの呼び出しを追加することで、アプリケーションの初回実行時に IDocumentStore インスタンスが構築されるようになります。次のようなコードになります。
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
RegisterGlobalFilters(GlobalFilters.Filters);
RegisterRoutes(RouteTable.Routes);
DataDocumentStore.Initialize();
}
これにより、DataDocumentStore.Instance プロパティの静的呼び出しで IDocumentStore オブジェクトを使用でき、MVC アプリケーションに埋め込まれたデータ ストアのドキュメント オブジェクトを操作できるようになります。
RavenDB オブジェクト
RavenDB の実際の動きについて理解を深めるため、ここではブックマークを保存および管理するプロトタイプ アプリケーションを作成します。RavenDB は Plain Old CLR Object (POCO) を使用するよう設計されており、シリアル化のためのプロパティ属性を追加する必要はありません。ブックマークを表すクラスは、非常に簡単に作成できます。図 3 に示すのは、Bookmark クラスです。
図 3 Bookmark クラス
public class Bookmark
{
public string Id { get; set; }
public string Title { get; set; }
public string Url { get; set; }
public string Description { get; set; }
public List<string> Tags { get; set; }
public DateTime DateCreated { get; set; }
public Bookmark()
{
this.Tags = new List<string>();
}
}
RavenDB はドキュメントを格納するとき、オブジェクト データを JSON 構造にシリアル化します。ドキュメント ID キーの処理には、よく知られた "Id" という名前のプロパティを使用します。新しいドキュメントを作成するため呼び出したときに Id プロパティが空または null の場合、RavenDB はプロパティの値を作成し、ドキュメントの @metadata 要素に格納します (この要素はデータ ストア レベルでドキュメント キーを処理するのに使用します)。ドキュメントを要求すると、RavenDB クライアント API コードは、ドキュメント オブジェクトを読み込むときにドキュメント ID キーに Id プロパティを設定します。
サンプル Bookmark ドキュメントの JSON のシリアル化は、次の構造で表されます。
{
"Title": "The RavenDB site",
"Url": "http://www.ravendb.net",
"Description": "A test bookmark",
"Tags": ["mvc","ravendb"],
"DateCreated": "2011-08-04T00:50:40.3207693Z"
}
Bookmark クラスはドキュメント ストアを適切に使用する準備が整いましたが、Tags プロパティが UI 層で課題をもたらそうとしています。ここでは、ビューやコントローラーのアクションにロジック コードを一切入り込ませることなく、ユーザーが単一の入力フィールドのテキスト ボックスにコンマ区切りでタグの一覧を入力し、MVC モデル バインダーがすべてのデータ フィールドをマップするようにしようと考えています。これに取り組むために、カスタム モデル バインダーを使用して "TagsAsString" という名前のフォーム フィールドを Bookmark.Tags フィールドにマップします。まず、カスタム モデル バインダー クラスを作成します (図 4 参照)。
図 4 BookmarkModelBinder.cs
public class BookmarkModelBinder : DefaultModelBinder
{
protected override void OnModelUpdated(ControllerContext controllerContext,
ModelBindingContext bindingContext)
{
var form = controllerContext.HttpContext.Request.Form;
var tagsAsString = form["TagsAsString"];
var bookmark = bindingContext.Model as Bookmark;
bookmark.Tags = string.IsNullOrEmpty(tagsAsString)
? new List<string>()
: tagsAsString.Split(',').Select(i => i.Trim()).ToList();
}
}
次に、アプリケーションの起動時に BookmarkModelBinder をモデル バインダーに追加するように、Globals.asax.cs ファイルを更新します。
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
RegisterGlobalFilters(GlobalFilters.Filters);
RegisterRoutes(RouteTable.Routes);
ModelBinders.Binders.Add(typeof(Bookmark), new BookmarkModelBinder());
DataDocumentStore.Initialize();
}
モデルで現在のタグに使用する HTML のテキスト ボックスの作成を処理するため、List<string> オブジェクトをコンマ区切り形式の文字列に変換する拡張メソッドを追加します。
public static string ToCommaSeparatedString(this List<string> list)
{
return list == null ? string.Empty : string.Join(", ", list);
}
Unit of Work
RavenDB クライアント API は Unit of Work パターンに基づいています。ドキュメント ストアのドキュメントを操作するには、新しいセッションを開始し、操作が完了したら保存して、セッションを終了する必要があります。セッションは EF のデータ コンテキストと類似した方法で、変更追跡を処理し動作します。次に、新しいドキュメントを作成する例を示します。
using (var session = documentStore.OpenSession())
{
session.Store(bookmark);
session.SaveChanges();
}
変更追跡や最初のレベルのキャッシュ使用などを行えるように、HTTP 要求全体を通してセッションが実行されるようにするのが最適です。DocumentDataStore.Instance を使用して、"アクションの実行時" に新しいセッションを開始し、"アクションの実行後" に変更を保存したうえでセッション オブジェクトを破棄するコントローラー基本クラスを作成します (図 5 参照)。これにより単一のセッション開始インスタンスを使用して、アクション コードを実行している間に必要な作業をすべて行えるようになります。
図 5 BaseDocumentStoreController
public class BaseDocumentStoreController : Controller
{
public IDocumentSession DocumentSession { get; set; }
protected override void OnActionExecuting(ActionExecutingContext filterContext)
{
if (filterContext.IsChildAction)
return;
this.DocumentSession = DataDocumentStore.Instance.OpenSession();
base.OnActionExecuting(filterContext);
}
protected override void OnActionExecuted(ActionExecutedContext filterContext)
{
if (filterContext.IsChildAction)
return;
if (this.DocumentSession != null && filterContext.Exception == null)
this.DocumentSession.SaveChanges();
this.DocumentSession.Dispose();
base.OnActionExecuted(filterContext);
}
}
MVC コントローラーとビューの実装
BookmarksController アクションは、基本クラスの IDocumentSession オブジェクトを直接使用し、ドキュメントの作成、読み取り、更新、削除 (CRUD) 操作をすべて管理します。図 6 に示すのは、ブックマーク コントローラーのコードです。
図 6 BookmarksController クラス
public class BookmarksController : BaseDocumentStoreController
{
public ViewResult Index()
{
var model = this.DocumentSession.Query<Bookmark>()
.OrderByDescending(i => i.DateCreated)
.ToList();
return View(model);
}
public ViewResult Details(string id)
{
var model = this.DocumentSession.Load<Bookmark>(id);
return View(model);
}
public ActionResult Create()
{
var model = new Bookmark();
return View(model);
}
[HttpPost]
public ActionResult Create(Bookmark bookmark)
{
bookmark.DateCreated = DateTime.UtcNow;
this.DocumentSession.Store(bookmark);
return RedirectToAction("Index");
}
public ActionResult Edit(string id)
{
var model = this.DocumentSession.Load<Bookmark>(id);
return View(model);
}
[HttpPost]
public ActionResult Edit(Bookmark bookmark)
{
this.DocumentSession.Store(bookmark);
return RedirectToAction("Index");
}
public ActionResult Delete(string id)
{
var model = this.DocumentSession.Load<Bookmark>(id);
return View(model);
}
[HttpPost, ActionName("Delete")]
public ActionResult DeleteConfirmed(string id)
{
this.DocumentSession.Advanced.DatabaseCommands.Delete(id, null);
return RedirectToAction("Index");
}
}
Index アクションの IDocumentSession.Query<T> メソッドは、IEnumerable インターフェイスを実装する結果オブジェクトを返します。そのため、OrderByDescending LINQ 式を使用してアイテムを分類し、ToList メソッドを呼び出して、返すオブジェクトにデータを取り込めます。Details アクションの IDocumentSession.Load メソッドはドキュメント ID キーの値を受け取り、対応するドキュメントを Bookmark 型オブジェクトにシリアル化解除します。
HttpPost verb 属性の Create メソッドはブックマーク アイテムに CreateDate プロパティを設定し、セッション オブジェクトから IDocumentSession.Store メソッドを呼び出してドキュメント ストアに新しいドキュメント記録を追加します。HttpPost verb 属性の Update メソッドも、同様に IDocumentSession.Store メソッドを呼び出します。これは、Bookmark オブジェクトには設定済みの Id 値が格納されるためです。RavenDB はその Id を認識し、新しいドキュメントを作成するのではなく、対応するキーを使用して既存のドキュメントを更新します。DeleteConfirmed アクションは、IDocumentSession.Advanced.DatabaseCommands オブジェクトから Delete メソッドを呼び出します。これにより、先にオブジェクトを読み込む必要なく、キーを使ってドキュメントを削除する方法が提供されます。これらのどのアクションからも IDocumentSession.SaveChanges メソッドを呼び出す必要はありません。これは、コントローラー基本クラスで "アクションの実行後" に呼び出すためです。
ビューはすべて、非常にわかりやすくなっています。ビューは Create マークアップ、Edit マークアップ、および Delete マークアップにおいて Bookmark クラスに、また Index マークアップにおいてブックマークの一覧に厳密に型指定されます。それぞれのビューは、表示フィールドと入力フィールドのモデル プロパティを直接参照します。オブジェクト プロパティの参照を変更する必要がある箇所は、タグの入力フィールドです。次のコードを利用して、Create ビューと Edit ビューで ToCommaSeparatedString 拡張メソッドを使用します。
@Html.TextBox("TagsAsString", Model.Tags.ToCommaSeparatedString())
これにより、ユーザーはブックマークに関連付けられたタグを、単一のテキスト ボックスにおいてコンマ区切り形式で入力および編集できるようになります。
オブジェクトの検索
CRUD 操作の準備はすべて整ったので、最後にちょっとした機能を追加します。それは、タグでブックマークの一覧をフィルター処理する機能です。IDocumentSession.Query メソッドから返されるオブジェクトは、IEnumerable インターフェイスだけではなく、.NET Framework の IOrderedQueryable インターフェイスと IQueryable インターフェイスも実装します。そのため、LINQ を使用してクエリをフィルター処理し、分類できます。たとえば次に示すのは、過去 5 日間に作成されたブックマークのクエリです。
var bookmarks = session.Query<Bookmark>()
.Where( i=> i.DateCreated >= DateTime.UtcNow.AddDays(-5))
.OrderByDescending(i => i.DateCreated)
.ToList();
次に示すのは、ブックマークの完全な一覧をページングするクエリです。
var bookmarks = session.Query<Bookmark>()
.OrderByDescending(i => i.DateCreated)
.Skip(pageCount * (pageNumber – 1))
.Take(pageCount)
.ToList();
RavenDB は、破棄される前に少しの間存続するこれらのクエリの実行に基づき、動的なインデックスを構築します。同じパラメーター構造で同様のクエリが再度実行されるとき、この一時的な動的インデックスが使用されます。一定の期間に頻繁に使用される場合、インデックスを永続的なものにします。この場合、インデックスはアプリケーションのライフサイクルを終えても存続します。
BookmarksController クラスに次のアクション メソッドを追加すると、タグを使ってブックマークを取得できるようになります。
public ViewResult Tag(string tag)
{
var model = new BookmarksByTagViewModel { Tag = tag };
model.Bookmarks = this.DocumentSession.Query<Bookmark>()
.Where(i => i.Tags.Any(t => t == tag))
.OrderByDescending(i => i.DateCreated)
.ToList();
return View(model);
}
このアクションは、アプリケーションのユーザーに頻繁に利用されることを想定しています。実際にそうなった場合、追加作業をしなくても、RavenDB によってこの動的クエリは永続的なインデックスに変更されます。
ワタリガラス (raven) は私たちの目を覚ますために放たれた
RavenDB の登場により、ついに .NET コミュニティに NoSQL ドキュメント ストア型ソリューションがもたらされたようです。これで、ここ数年間に他の数多くのフレームワークや言語が切り開いてきた非リレーショナルの世界を、マイクロソフト中心の企業や開発者は滑るように進むことができます。マイクロソフト製品には非リレーショナルへの熱意が欠けているという嘆きを聞くことは、もうないでしょう。RavenDB のインストールには、開発者が既に利用しているデータ管理技術を模倣した簡潔なクライアント API が付属しており、.NET の開発者が非リレーショナル データ ストアを使い始め、プロトタイプを作成するのが容易になりました。リレーショナルと非リレーショナルを巡る論争は、これからも繰り返されるでしょう。ただ、"新しい何か" を簡単に試せるようになることで、アプリケーションのアーキテクチャにおいて、どこでどのように非リレーショナル ソリューションが役立つのかをより良く理解することにつながります。
Justin Schwartzenberger は、DealerHosts の CTO です。長年 Web アプリケーションの開発に深くかかわり続けており、PHP、従来の ASP、Visual Basic、VB.NET、および ASP.NET Web フォームのさまざまな構文を器用に使いこなしています。2007 年に ASP.NET MVC を早くも導入し、Web スタックをすべて MVC にリファクタリングすることを決めました。彼は記事を寄稿し、ユーザー グループでも発言しています。彼のブログは iwantmymvc.com (英語) から閲覧でき、ツイッターは twitter.com/schwarty (英語) からフォローできます。
この記事のレビューに協力してくれた技術スタッフの Ayende Rahien に心より感謝いたします。