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

前回に引き続き帳票シナリオです。前回アドホックな処理をリファクタリングを通して汎用的なメソッドを見つけ出し共通化を促進しました。しかし、このメソッドにはデータアクセスの処理が残っていますので、まずはデータアクセスを切り離し、さらにデータアクセスの最適化を行うようにします。

データアクセス処理の切り離し

OrderInShopで行っていたデータアクセスを取り除いていくのですが、考え方としてはドメインモデルのクラスはDataMapperで適切に生成されるという前提で処理に必要なデータを保持できるように構造に変更します。GetSalesTotalメソッドの処理に必要なデータを保持するSalesTotalプロパティ、GetSalesRankメソッドの必要なデータを保持するSalesRankプロパティを用意して処理を改訂します。あとは、DataMapperが適切にSalesTotalとSalesRankにデータを設定する仕組みを用意するだけです。

public class OrderInShop : IOrderInShop
{
    public Shop TargetShop { get; private set; }
    public Dictionary<YearMonth, decimal> SalesTotal {get; internal set;}
    public Dictionary<YearMonth, int> SalesRank { get; internal set; }

    public OrderInShop(Shop shop)
    {
        this.TargetShop = shop;
    }

    public decimal GetSalesTotal(YearMonth targetMonth)
    {
        decimal targetTotal = 0;
        if (SalesTotal.TryGetValue(targetMonth, out targetTotal))
            return targetTotal;
        throw new ArgumentOutOfRangeException();
    }

    public int GetSalesRank(YearMonth targetMonth)
    {
        int targetRank = 0;
        if (SalesRank.TryGetValue(targetMonth, out targetRank))
            return targetRank;
        throw new ArgumentOutOfRangeException();
    }
}


データマッパーの作成

店舗(Shop)を指定してOrderInShopのデータをロードしインスタンスを作成するようにします。Rankデータは他の店舗のデータも読み込む必要があるため他の全ての店舗のサマリデータを読み込む必要があります。もし、チューニング目的で絞りん込んで処理をしたい場合はシナリオに特化したLoad処理を別途作成します。例えば、SalesRankをアクセスしないシナリオであれば、該当店舗のSalesTotalのデータのみをDataMapperでロードするようにします。ただ、最初は汎用的なDataMapperを利用する方がリーズナブルで、チューニングが必要な場合に初めて導入するようにします。

public class OrderInShopMapper 
{
    private Dictionary<int, Dictionary<YearMonth, decimal>> Cache {get; set;}

    private DataContext Context { set; get;}

    public OrderInShopMapper(DataContext context)
    {
        this.Context = context;
    }

    private void LoadCache()
    {
        Cache = new Dictionary<int, Dictionary<YearMonth, decimal>>();
        var orders = Context.GetTable<Order>();
        var data = orders
                        .GroupBy(order => new
                        {
                            order.ShopID, order.OrderDateTime.Year, order.OrderDateTime.Month
                        }
                            , order => order)
                        .Select(group => new
                        {
                            ShopID = group.Key.ShopID, Year = group.Key.Year,
                            Month = group.Key.Month, Total = group.Sum(order => order.TotalPrice)
                        });
        foreach (var item in data)
        {
            if (!Cache.ContainsKey(item.ShopID)) 
                Cache.Add(item.ShopID, new Dictionary<YearMonth, decimal>());
            Cache[item.ShopID][new YearMonth(item.Year, item.Month)] = item.Total.Value;
        }        
    }

    public OrderInShop Load(Shop aShop)
    {
        if (Cache == null) LoadCache();
        return new OrderInShop(aShop) { 
            SalesTotal = Cache[aShop.ShopID], SalesRank = MakeRankList(aShop) };
    }

    ...
}


DataMapperのキャッシュ処理

このDataMapperはLoad処理で読みん込んだデータをキャッシュしています。これによって次回Load処理時すなわち他店舗のOrderInShopのロード時に、SQL文を実行しなくて済みます。当初の帳票シナリオで利用される場合などはかなり効果があります。

[TestMethod]
public void TestShopWithoutLazyLoad()
{
    using (var db = new BurgerShopDataContext())
    {
        db.Log = Console.Out;
        var shops = new ShopMapper(db);
        var orderInShops = new OrderInShopMapper(db);

        var osaka = shops.LoadByName("OSAKA");
        osaka.Order = orderInShops.Load(osaka);
        Assert.IsTrue(osaka.Order.GetSalesTotal(YearMonth.ThisMonth) > 0);
        Assert.AreEqual(1, osaka.GetSalesTarget());

        var kyoto = shops.LoadByName("KYOTO");
        kyoto.Order = orderInShops.Load(kyoto);
        Assert.AreEqual(-1, kyoto.GetSalesTarget());
    }
}
SELECT [t0].[ShopID], [t0].[Name], [t0].[Address], [t0].[Tel]
FROM [dbo].[Shops] AS [t0]
WHERE [t0].[Name] = @p0
-- @p0: Input String (Size = 5; Prec = 0; Scale = 0) [OSAKA]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT SUM([t1].[TotalPrice]) AS [Total], [t1].[ShopID], [t1].[value], [t1].[value2]
FROM (
    SELECT DATEPART(Year, [t0].[OrderDateTime]) AS [value], DATEPART(Month, [t0].[OrderDateTime]) 
AS [value2], [t0].[ShopID], [t0].[TotalPrice]
    FROM [dbo].[Orders] AS [t0]
    ) AS [t1]
GROUP BY [t1].[ShopID], [t1].[value], [t1].[value2]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1

SELECT [t0].[ShopID], [t0].[Name], [t0].[Address], [t0].[Tel]
FROM [dbo].[Shops] AS [t0]
WHERE [t0].[Name] = @p0
-- @p0: Input String (Size = 5; Prec = 0; Scale = 0) [KYOTO]
-- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1


LINQ to Objectを利用した帳票実装例

以下の例は今月のRankトップ5の店舗の名前と今月ランク・売上、先月ランクを表示する例です。LINQ to Objectを使うと帳票に出てくるようなフィルタやデータ変換の処理パターンが簡単にかけます。

[TestMethod]
public void TestShopReport()
{
    using (var db = new BurgerShopDataContext())
    {
        db.Log = Console.Out;
        var shops = new ShopMapper(db);
        var orderInShops = new OrderInShopMapper(db);
        var shopList = shops.Load();
        foreach(var shop in shopList)
            shop.Order = orderInShops.Load(shop);

        foreach(var shop in shopList
            .Where(s => s.Order.GetSalesRank(YearMonth.ThisMonth) <= 5)
            .OrderBy(s => s.Order.GetSalesRank(YearMonth.ThisMonth)))
        {
            Console.WriteLine(string.Format("Name={0} Rank={1} Sales={2} LastMonthRank={3}",
                shop.Name,
                shop.Order.GetSalesRank(YearMonth.ThisMonth),
                shop.Order.GetSalesTotal(YearMonth.ThisMonth),
                shop.Order.GetSalesRank(YearMonth.LastMonth)
                ));
        }
    }
}

Name=OSAKA Rank=1 Sales=125100.0000 LastMonthRank=2
Name=KYOTO Rank=2 Sales=3300.0000 LastMonthRank=1
...