ドメインモデルをLINQで構築する(その8)
帳票シナリオ
今回からは、LOBアプリの帳票でよく出てくる生情報をサマリーして様々な指標を算出するシナリオを考えていきます。
具体的には店舗ごとの売上指標を表示するなシナリオを目標にドメインモデルを構築していきます。
アドホックな処理
最初の実装はアドホックな帳票として共通化も一切考えずに作成してみます。画面側で処理を構築した場合このパターンになります。
[TestMethod] public void TestLogicInUI() { using (var db = new BurgerShopDataContext()) { db.Log = Console.Out; var shops= db.GetTable<Shop>(); var orders= db.GetTable<Order>(); var today = DateTime.Now; var lastMonth = today.AddMonths(-1); var lastYear = today.AddYears(-1); var summary = shops.Select(shop => new SalesSummary( shop, orders.Where(order => order.OrderDateTime.Year == today.Year && order.OrderDateTime.Month == today.Month && order.ShopID == shop.ShopID) .Sum(order => order.TotalPrice), orders.Where(order => order.OrderDateTime.Year == lastMonth.Year && order.OrderDateTime.Month == lastMonth.Month && order.ShopID == shop.ShopID) .Sum(order => order.TotalPrice), orders.Where(order => order.OrderDateTime.Year == lastYear.Year && order.OrderDateTime.Month == lastYear.Month && order.ShopID == shop.ShopID) .Sum(order => order.TotalPrice), 0)); var orderedList = summary.ToList().OrderByDescending( order => order.ThisMonth).ToList(); var idx = 1; foreach (var orderItem in orderedList) orderItem.Rank = idx++; var shopSummary =orderedList.First(); Assert.IsTrue(shopSummary.Shop.Name == "OSAKA"); Assert.IsTrue(shopSummary.ThisMonth > 0); Assert.IsTrue(shopSummary.Rank == 1); } }
public class SalesSummary { public Shop Shop { get; set; } public decimal ThisMonth { get; set; } public decimal LastMonth { get; set; } public decimal LastYear { get; set; } public int Rank { get; set; } public SalesSummary(Shop aShop, decimal? thisMonth, decimal? lastMonth, decimal? lastYear, int rank) { this.Shop = aShop; this.ThisMonth = thisMonth.HasValue ? thisMonth.Value : 0; this.LastMonth = lastMonth.HasValue ? lastMonth.Value : 0; this.LastYear = lastYear.HasValue ? lastYear.Value : 0; this.Rank = rank; } }
LINQを使ってネストしたSQL文も問題なく記述できます。日付の月・年の処理もDATEPART関数を使って上手に変換してくれています。
SELECT ( SELECT SUM([t1].[TotalPrice]) FROM [dbo].[Orders] AS [t1] WHERE (DATEPART(Year, [t1].[OrderDateTime]) = @p0) AND (DATEPART(Month, [t1].[OrderDateTime]) = @p1) AND ([t1].[ShopID] = [t0].[ShopID]) ) AS [value], ( SELECT SUM([t2].[TotalPrice]) FROM [dbo].[Orders] AS [t2] WHERE (DATEPART(Year, [t2].[OrderDateTime]) = @p2) AND (DATEPART(Month, [t2].[OrderDateTime]) = @p3) AND ([t2].[ShopID] = [t0].[ShopID]) ) AS [value2], ( SELECT SUM([t3].[TotalPrice]) FROM [dbo].[Orders] AS [t3] WHERE (DATEPART(Year, [t3].[OrderDateTime]) = @p4) AND (DATEPART(Month, [t3].[OrderDateTime]) = @p5) AND ([t3].[ShopID] = [t0].[ShopID]) ) AS [value3], @p6 AS [value4], [t0].[ShopID], [t0].[Name], [t0].[Address], [t0].[Tel] FROM [dbo].[Shops] AS [t0] -- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2007] -- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [8] -- @p2: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2007] -- @p3: Input Int32 (Size = 0; Prec = 0; Scale = 0) [7] -- @p4: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2006] -- @p5: Input Int32 (Size = 0; Prec = 0; Scale = 0) [8] -- @p6: Input Int32 (Size = 0; Prec = 0; Scale = 0) [0] -- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1
この方式は簡単にプログラムを構築できるという点は優れていますが、シナリオが少しでも違うとそれなりの変更が必要なります。また、他のシナリオでの利用を考慮していないので他のモデルとの連携の弱さや共通化の欠如などの問題があり、同じようなロジックがいくつも記述されてしまう傾向があります。
店舗(Shop)クラスへの統合
まず最初のステップとして、売上データを店舗(Shop)クラスからナビゲーションできるように統合してみます。
[TestMethod] public void TestExtendMethod() { using (var db = new BurgerShopDataContext()) { db.Log = Console.Out; var shops = db.GetTable<Shop>(); var osaka = shops.Single(shop => shop.Name == "OSAKA"); var shopSummary = osaka.GetSalesSummary(); Assert.IsTrue(shopSummary.ThisMonth > 0); Assert.IsTrue(shopSummary.Rank == 1); } }
public static SalesSummary GetSalesSummary(this Shop shop) { using (var db = new BurgerShopDataContext()) { db.Log = Console.Out; var orders = db.GetTable<Order>(); var today = DateTime.Now; var lastMonth = today.AddMonths(-1); var lastYear = today.AddYears(-1); var thisMonthTotal = orders.Where(order => order.OrderDateTime.Year == today.Year && order.OrderDateTime.Month == today.Month && order.ShopID == shop.ShopID) .Sum(order => order.TotalPrice); var lastMonthTotal = orders.Where(order => order.OrderDateTime.Year == lastMonth.Year && order.OrderDateTime.Month == lastMonth.Month && order.ShopID == shop.ShopID) .Sum(order => order.TotalPrice); var lastYearTotal = orders.Where(order => order.OrderDateTime.Year == lastYear.Year && order.OrderDateTime.Month == lastYear.Month && order.ShopID == shop.ShopID) .Sum(order => order.TotalPrice); var thisMonthSalesList = orders .Where(order => order.OrderDateTime.Year == today.Year && order.OrderDateTime.Month == today.Month) .GroupBy(order => order.ShopID, order => order) .Select(group => new { ShopID = group.Key, Total = group.Sum(order => order.TotalPrice) }) .OrderByDescending(p => p.Total); var rank = 1; foreach (var item in thisMonthSalesList) { if (item.ShopID == shop.ShopID) break; rank++; } var summary = new SalesSummary(shop,thisMonthTotal,lastMonthTotal,lastYearTotal, rank); return summary; } }
拡張メソッドで店舗(Shop)に売上サマリを取得するメソッドを追加しました。これに伴い、LINQ to SQLの処理を店舗ごとに処理するようにします。処理の粒度が小さくなり再利用が少しし易くなります。(なお、この処理でOrderのTotalPriceをNullableをfalseにするとSum処理で1件も対象がない場合にエラーになるという問題があったため、Nullableをtrueに設定して回避しています。ベータ2の問題なのかもふくめて不明です)
発行されるSQL文も期待通りです。GroupByした値でOrder byする処理も問題ありません。なお、SQL文の効率化については次回以降で考えるようにしますので今回は共通化・他クラス連携の強化に集中します。
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([t0].[TotalPrice]) AS [value] FROM [dbo].[Orders] AS [t0] WHERE (DATEPART(Year, [t0].[OrderDateTime]) = @p0) AND (DATEPART(Month, [t0].[OrderDateTime]) = @p1) AND ([t0].[ShopID] = @p2) -- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2007] -- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [8] -- @p2: Input Int32 (Size = 0; Prec = 0; Scale = 0) [1] -- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1 SELECT SUM([t0].[TotalPrice]) AS [value] FROM [dbo].[Orders] AS [t0] WHERE (DATEPART(Year, [t0].[OrderDateTime]) = @p0) AND (DATEPART(Month, [t0].[OrderDateTime]) = @p1) AND ([t0].[ShopID] = @p2) -- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2007] -- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [7] -- @p2: Input Int32 (Size = 0; Prec = 0; Scale = 0) [1] -- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1 SELECT SUM([t0].[TotalPrice]) AS [value] FROM [dbo].[Orders] AS [t0] WHERE (DATEPART(Year, [t0].[OrderDateTime]) = @p0) AND (DATEPART(Month, [t0].[OrderDateTime]) = @p1) AND ([t0].[ShopID] = @p2) -- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2006] -- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [8] -- @p2: Input Int32 (Size = 0; Prec = 0; Scale = 0) [1] -- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1 SELECT [t1].[ShopID], [t1].[value] AS [Total] FROM ( SELECT SUM([t0].[TotalPrice]) AS [value], [t0].[ShopID] FROM [dbo].[Orders] AS [t0] WHERE (DATEPART(Year, [t0].[OrderDateTime]) = @p0) AND (DATEPART(Month, [t0].[OrderDateTime]) = @p1) GROUP BY [t0].[ShopID] ) AS [t1] ORDER BY [t1].[value] DESC -- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2007] -- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [8] -- Context: SqlProvider(Sql2005) Model: AttributedMetaModel Build: 3.5.20706.1
リファクタリング
GetSalesSummaryメソッドは少し大きなメソッドなので整理目的でリファクタリングします。
public static SalesSummary GetSalesSummary(this Shop shop) { var targetMonth = YearMonth.ThisMonth; var summary = new SalesSummary(shop, shop.GetSalesTotal(targetMonth), shop.GetSalesTotal(targetMonth.AddMonths(-1)), shop.GetSalesTotal(targetMonth.AddYears(-1)), shop.GetSalesRank(targetMonth)); return summary; } public static decimal GetSalesTotal(this Shop shop, YearMonth target) { using (var db = new BurgerShopDataContext()) { db.Log = Console.Out; var orders = db.GetTable<Order>(); var total = orders.Where(order => order.OrderDateTime.Year == target.Year && order.OrderDateTime.Month == target.Month && order.ShopID == shop.ShopID) .Sum(order => order.TotalPrice); return total.HasValue ? total.Value : 0; } } public static int GetSalesRank(this Shop shop, YearMonth targetMonth) { using (var db = new BurgerShopDataContext()) { db.Log = Console.Out; var orders = db.GetTable<Order>(); var thisMonthSalesList = orders .Where(order => order.OrderDateTime.Year == targetMonth.Year && order.OrderDateTime.Month == targetMonth.Month) .GroupBy(order => order.ShopID, order => order) .Select(group => new { ShopID = group.Key, Total = group.Sum(order => order.TotalPrice) }) .OrderByDescending(p => p.Total); var rank = 1; foreach (var item in thisMonthSalesList) { if (item.ShopID == shop.ShopID) break; rank++; } return rank; } }
public struct YearMonth { ... public YearMonth(DateTime date) : this() { this.Year = date.Year; this.Month = date.Month; } public YearMonth(int year, int month) : this() { this.Year = year; this.Month = month; } public int Year { get; private set; } public int Month { get; private set; } public bool IsContains(DateTime date) { return date.Year == this.Year && date.Month == this.Month; } ... }
リファクタリングによってより汎用的なメソッドGetSalesTotal・GetSalesRankが識別されました。設計時の最初から共通処理を抽出することは案外難しいのですが、リファクタリングすると自然に共通処理が識別されてくることが多々あります。
元の処理を今回識別したメソッドを利用して記述しなおします。
[TestMethod] public void TestExtendPrimitiveMethod() { using (var db = new BurgerShopDataContext()) { db.Log = Console.Out; var shops = db.GetTable<Shop>(); var osaka = shops.Single(shop => shop.Name == "OSAKA"); Assert.IsTrue(osaka.GetSalesTotal(YearMonth.ThisMonth) > 0); Assert.IsTrue(osaka.GetSalesRank(YearMonth.ThisMonth) == 1); } }
shopSummaryという変数が無くなっただけですがosaka.GetSalesTotal(YearMonth.ThisMonth)などは、より直観的なコードになりました。
また、汎用的なメソッドが用意できたので他のシナリオへの利用も簡単で、過去3か月の売上とランクを表示したいなどの要求にも素早く対応することができます。
ビジネスルールへの統合
店舗(Shop)クラスから汎用的なメソッドが利用できるようになるとビジネスルールにこれらの情報が利用できるようになります。たとえば、店舗の評価指標として前月の売上ランクと今月の売上ランクの差をKPIの1つとするシナリオに対して先ほど導出したメソッドを再利用できます。このレベルまで利用されるとドメインモデルに組み込んだメリットはかなり大きくなります。もちろんモデルがこのポテンシャルを持っているだけでも安心してコードを記述することができます。
public static int GetSalesTarget(this Shop shop) { var thisMonth = YearMonth.ThisMonth; return shop.GetSalesRank(thisMonth.AddMonths(-1)) - shop.GetSalesRank(thisMonth); }
Shopドメインへの組み込み
拡張メソッドを利用するとかなり強力なクラスの拡張ができるのですが、この拡張が店舗(Shops)パッケージではなくお買い上げ(Orders)パッケージで行われています。これは依存関係の関係で店舗(Shops)パッケージはお買い上げ(Orders)パッケージの情報を一切参照できないためです。しかし、これでは店舗の本質的なロジックでさえ他のパッケージに記述することになり、パッケージ分割をした意味がなくなってしまいます。そこで、できるだけ店舗の処理を店舗(Shops)パッケージで記述できるようにします。
まずは、店舗(Shops)パッケージからお買い上げ(Orders)パッケージの依存性をインタフェースで定義します。店舗(Shop)クラスからお買い上げ(Orderクラス)に対するコミュニケーションを分析し最小になるように店舗(Shops)パッケージにインタフェースを定義します。もちろんこのインタフェースはユースケースや機能ごとに複数定義することもできます。ロールインタフェースの考え方が参考になります。
namespace BurgerShop.Shops { public interface IOrderInShop { decimal GetSalesTotal(YearMonth target); int GetSalesRank(YearMonth targetMonth); } }
このインタフェースを使って店舗(Shop)クラス内にOrderプロパティを定義して、あとは店舗(Shop)クラス特有のビジネスルールなどのロジックを構築していきます。
partial class Shop { public IOrderInShop Order { get; set; } public SalesSummary GetSalesSummary() { if (Order == null) throw new InvalidOperationException(); var targetMonth = YearMonth.ThisMonth; var summary = new SalesSummary(this, Order.GetSalesTotal(targetMonth), Order.GetSalesTotal(targetMonth.AddMonths(-1)), Order.GetSalesTotal(targetMonth.AddYears(-1)), Order.GetSalesRank(targetMonth)); return summary; } public int GetSalesTarget() { if (Order == null) throw new InvalidOperationException(); var thisMonth = YearMonth.ThisMonth; return Order.GetSalesRank(thisMonth.AddMonths(-1)) - Order.GetSalesRank(thisMonth); } ... }
店舗(Shop)クラスで実装したこれらの処理を動作させるためにお買い上げ(Orders)パッケージにIOrderInShopを実装したクラスを実装します。これでお買い上げ(Orders)パッケージで必要以上に店舗(Shop)の関する実装を避けることができます。
namespace BurgerShop.Orders { public class OrderInShop : IOrderInShop { public Shop TargetShop { get; private set; } public OrderInShop(Shop shop) { this.TargetShop = shop; } public decimal GetSalesTotal(YearMonth target) { using (var db = new BurgerShopDataContext()) { db.Log = Console.Out; var orders = db.GetTable<Order>(); var total = orders.Where(order => order.OrderDateTime.Year == target.Year && order.OrderDateTime.Month == target.Month && order.ShopID == TargetShop.ShopID) .Sum(order => order.TotalPrice); return total.HasValue ? total.Value : 0; } } public int GetSalesRank(YearMonth targetMonth) { using (var db = new BurgerShopDataContext()) { db.Log = Console.Out; var orders = db.GetTable<Order>(); var thisMonthSalesList = orders .Where(order => order.OrderDateTime.Year == targetMonth.Year && order.OrderDateTime.Month == targetMonth.Month) .GroupBy(order => order.ShopID, order => order) .Select(group => new { ShopID = group.Key, Total = group.Sum(order => order.TotalPrice) }) .OrderByDescending(p => p.Total); var rank = 1; foreach (var item in thisMonthSalesList) { if (item.ShopID == TargetShop.ShopID) break; rank++; } return rank; } } } }
あとは実行時に店舗(Shop)のお買い上げロールのプロパティにインスタンスを設定して動作させます。このパターンどこかで見たことがあります。そうですDIです。DIはフレームワークでよく利用されていますが、ドメインモデルでも依存性をコントロールすためにDI(IoC)を利用します。DIフレームワークを導入すればOrderの設定を自動化することももちろん可能になります。
[TestMethod] public void TestShopDI() { using (var db = new BurgerShopDataContext()) { db.Log = Console.Out; var shops = db.GetTable<Shop>(); var osaka = shops.Single(shop => shop.Name == "OSAKA"); osaka.Order = new OrderInShop(osaka); Assert.AreEqual(osaka.GetSalesTarget(),1); } }
参考までにクラス図はこんな感じです。