ドメインモデルをLINQで構築する(その3)

データアクセスの最適化

前回は商品(Product)のオプション一覧をドメインモデル作成しました。目標としていたテストケースをパスできたのですがその時にSELECT文が17回発行されていました。今回はこのSELECT文の実行を削減というか調整したいと思います。

発行されたSELECT文

SELECT TOP 1 [t0].[Type], [t0].[Title], [t0].[ProductID], [t0].[Price], [t0].[Size]
FROM [dbo].[Products] AS [t0]
WHERE [t0].[Title] = @p0
-- @p0: Input String (Size = 15; Prec = 0; Scale = 0) [CheeseBurgerSet]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[ProductItemID], [t0].[Name], [t0].[ProductID]
FROM [dbo].[ProductItems] AS [t0]
WHERE [t0].[ProductID] = @p0
-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[AdditionalPrice], [t0].[ProductItemID], [t0].[ProductID], [t0].[ProductItemOptionID]
FROM [dbo].[ProductItemOptions] AS [t0]
WHERE [t0].[ProductItemID] = @p0
-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [3]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[Type], [t0].[Title], [t0].[ProductID], [t0].[Price], [t0].[Size]
FROM [dbo].[Products] AS [t0]
WHERE [t0].[ProductID] = @p0
-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [3]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[Type], [t0].[Title], [t0].[ProductID], [t0].[Price], [t0].[Size]
FROM [dbo].[Products] AS [t0]
WHERE [t0].[ProductID] = @p0
-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [4]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

...以下省略...

DataMapperの導入

SQL文の最適化を行うためには、データアクセスの実装方法をケースバイケースに応じて変更できるようにしなければなりません。このため、データアクセス処理を置き換え可能なコンポーネントとして定義するようにします。まずはデータベースからの読み出しを行うコンポーネントのインタフェースを定義するようにします。

検索条件を指定してデータをロードするメソッドを1つ定義したシンプルなインタフェースです。Expressionを利用して検索条件が指定できるので、この1つのメソッドで多くの検索処理を対応可能です。
それでは、商品(Product)を検索するためのProductMapperクラスを実装します。まずは現行と同じ方式で実装します。

テストケースもProductMapperを利用するように修正します。

とりあえずテストが通ることを確認します。なお、Orcasではソースコード上で右クリックでテストが実行できるようになりました。


LoadOptionsを指定してデータを先読みさせる

LINQ to SQLはLoadOptionsを指定して関連データを先読みさせることができるので、試しにこの機能を利用して発行されるSQL文を評価してみます。

テストを実行して発行されるSQL文を確認すると、なんと33回に増えてしまいました。若干の最適化はあるようですが、余計なレコードも事前に読み込んでしまうため効率的なSQL文を発行するとは必ずしも期待できないようです。Lazy Loadを避ける場合には有効かもしれません。

SELECT [t0].[Type], [t0].[Title], [t0].[ProductID], [t0].[Price], [t0].[Size]
FROM [dbo].[Products] AS [t0]
WHERE [t0].[Title] = @p0
-- @p0: Input String (Size = 15; Prec = 0; Scale = 0) [CheeseBurgerSet]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[ProductItemID], [t0].[Name], [t0].[ProductID], [t1].[AdditionalPrice], 
[t1].[ProductItemID] AS [ProductItemID2], [t1].[ProductID] AS [ProductID2], [t1].[ProductItemOptionID], (
    SELECT COUNT(*)
    FROM [dbo].[ProductItemOptions] AS [t2]
    WHERE [t2].[ProductItemID] = [t0].[ProductItemID]
    ) AS [count]
FROM [dbo].[ProductItems] AS [t0]
LEFT OUTER JOIN [dbo].[ProductItemOptions] AS [t1] ON [t1].[ProductItemID] = [t0].[ProductItemID]
WHERE [t0].[ProductID] = @x1
ORDER BY [t0].[ProductItemID], [t1].[ProductItemOptionID]
-- @x1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[ProductGroupID], [t0].[ParentProductID], [t0].[ChildProductID]
FROM [dbo].[ProductGroup] AS [t0]
WHERE [t0].[ParentProductID] = @x1
-- @x1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[Type], [t0].[Title], [t0].[ProductID], [t0].[Price], [t0].[Size]
FROM [dbo].[Products] AS [t0]
WHERE [t0].[ProductID] = @p0
-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [3]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

...以下省略...

再帰構造があるクラスに対してLoadOptionsを指定する場合、LoadOptionがループするような指定は不可で実行時エラーになります。


エラーメッセージ
Test method BurgerShop.Test.ProductTest.TestCheeseBurgerSetWithOptimize threw exception: System.InvalidOperationException: Cycles not allowed in LoadOptions LoadWith type graph..

カスタムな読み出し処理

標準的な機能ではLoadOptions以外の最適化はないようですが、より自由にデータアクセスを行い最適化を行うようにします。今回はテーブル単位に必要なデータはどのようなものか考え、リーズナブルなテーブル単位のSELECT文でデータアクセスを行う方法にチャレンジします。この方法はデータアクセスがシンプルで見通し良いというメリットがあります。商品ドメインモデルを考えた場合、2つの再帰構造を持つ複雑な構造であること、商品件数はあまり多くないことなどを踏まえて、すべてのテーブルを全件検索するのが最もリーズナブルだと判断しました。

プログラムの補足としては DeferredLoadingEnabled に false をしているのは遅延ロードしないように指定しています。テーブル毎にデータを読み出しているので各オブジェクトのリレーションのプロパティには何も設定されていいません。このままアクセスすとデータがないということになるので、DataMapperの処理でリレーションのプロパティに対応するオブジェクトを設定していきます。LINQ to SQLのリレーションは双方向リンクに対応していて片方のプロパティにオブジェクトを設定すると自動的にもう一方のプロパティに設定されます(OneToManyの場合は追加されます)。これでオブジェクトのグラフ構造が構築できます。

あとは、LINQ to Objectでターゲットのオブジェクトを検索してProductオブジェクトを呼び出し元に返します。queryをコンパイルしているはExpression Treeではなくデリゲートを渡す必要があるためです。

なお、この処理で発行されるSQL文は期待通り4つまで減らすことができました。

SELECT [t0].[Type], [t0].[Title], [t0].[ProductID], [t0].[Price], [t0].[Size]
FROM [dbo].[Products] AS [t0]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[ProductGroupID], [t0].[ParentProductID], [t0].[ChildProductID]
FROM [dbo].[ProductGroup] AS [t0]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[ProductItemID], [t0].[Name], [t0].[ProductID]
FROM [dbo].[ProductItems] AS [t0]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[AdditionalPrice], [t0].[ProductItemID], [t0].[ProductID], [t0].[ProductItemOptionID]
FROM [dbo].[ProductItemOptions] AS [t0]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

ドメインモデルに対する変更は何も行わない

様々なデータアクセスの最適化を行ってきましたが、この間一切ドメインモデルを変更していません。この変更に対する強さはドメインモデルを利用するメリットの1つです。今回全件検索を利用してSQL文を最適化を行いましたが、件数が多くなれば違う検索方法を採用することもDataMapperを変えるだけで可能です。後からのチューニングも局所的にできるので、最初は簡単な方法を採用し必要に応じてチューニングできるということです。DataMapperの導入によって最初から不必要なコストを払う必要がなくリーズナブルな開発が可能になります。拡張可能なアーキテクチャ・スケーラビリティのあるアーキテクチャになっています。