asp.net core系列 64 結合eShopOnWeb全面認識領域模型架構
一.專案分析
在上篇中介紹了什麼是"乾淨架構",DDD符合了這種乾淨架構的特點,重點描述了DDD架構遵循的依賴倒置原則,使軟體達到了低藕合。eShopOnWeb專案是學習DDD領域模型架構的一個很好案例,本篇繼續分析該專案各層的職責功能,主要掌握ApplicationCore領域層內部的術語、成員職責。
1. web層介紹
eShopOnWeb專案與Equinox專案,雙方在表現層方面對比,沒有太大區別。都是遵循了DDD表現層的功能職責。有一點差異的是eShopOnWeb把表現層和應用服務層集中在了專案web層下,這並不影響DDD風格架構。
專案web表現層引用了ApplicationCore領域層和Infrastructure基礎設施層,這種引用依賴是正常的。引用Infrastructure層是為了新增EF上下文以及Identity使用者管理。 引用ApplicationCore層是為了應用程式服務 呼叫 領域服務處理領域業務。
在DDD架構下依賴關係重點強調的是領域層的獨立,領域層是同心圓中最核心的層,所以在eShopOnWeb專案中,ApplicationCore層並沒有依賴引用專案其它層。再回頭看Equinox專案,領域層也不需要依賴引用專案其它層。
下面web混合了MVC和Razor,結構目錄如下所示:
(1) Health checks
Health checks是ASP.NET Core的特性,用於視覺化web應用程式的狀態,以便開發人員可以確定應用程式是否健康。執行狀況檢查端點/health。
//新增服務 services.AddHealthChecks() .AddCheck<HomePageHealthCheck>("home_page_health_check") .AddCheck<ApiHealthCheck>("api_health_check"); //新增中介軟體 app.UseHealthChecks("/health");
下圖檢查了web首頁和api介面的健康狀態,如下圖所示
(2) Extensions
向現有物件新增輔助方法。該Extensions
資料夾有兩個類,包含用於電子郵件傳送和URL生成的擴充套件方法。
(3) 快取
對於Web層獲取資料庫的資料,如果資料不會經常更改,可以使用快取,避免每次請求頁面時,都去讀取資料庫資料。這裡用的是本機記憶體快取。
//快取介面類 private readonly IMemoryCache _cache; // 新增服務,快取類實現 services.AddScoped<ICatalogViewModelService, CachedCatalogViewModelService>(); //新增服務,非快取的實現 //services.AddScoped<ICatalogViewModelService, CatalogViewModelService>();
2. ApplicationCore層
ApplicationCore是領域層,是專案中最重要最複雜的一層。ApplicationCore層包含應用程式的業務邏輯,此業務邏輯包含在領域模型中。領域層知識在Equinox專案中並沒有講清楚,這裡在重點解析領域層內部成員,並結合專案來說清楚。
下面講解領域層內部的成員職責描述定義,參考了“Microsoft.NET企業級應用架構設計 第二版”。
領域層內部包括:領域模型和領域服務二大塊。涉及到的術語:
領域模型(模型)
1)模組
2)領域實體(也叫"實體")
3)值物件
4)聚合
領域服務(也叫"服務")
倉儲
下面是領域層主要的成員:
下面是聚合與領域模型的關係。最終領域模型包含了:聚合、單個實體、值物件的結合。
(1) 領域模型
領域模型是提供業務領域的概念檢視,它由實體和值物件構成。在下圖中Entities資料夾是領域模型,可以看到包含了聚合、實體、值物件。
1.1 模組
模組是用來組織領域模型,在.net中領域模型通過命令空間組織,模組也就是名稱空間,用來組織類庫專案裡的類。比如:
namespace Microsoft.eShopWeb.ApplicationCore.Entities.BasketAggregate namespace Microsoft.eShopWeb.ApplicationCore.Entities.BuyerAggregate
1.2 實體
實體通常由資料和行為構成。如果要在整個生命週期的上下文裡唯一跟蹤它,這個物件就需要一個身份標識(ID主鍵),並看成實體。 如下所示是一個實體:
/// <summary> /// 領域實體都有唯一標識,這裡用ID做唯一標識 /// </summary> public class BaseEntity { public int Id { get; set; } } /// <summary> /// 領域實體,該實體行為由Basket聚合根來操作 /// </summary> public class BasketItem : BaseEntity { public decimal UnitPrice { get; set; } public int Quantity { get; set; } public int CatalogItemId { get; set; } }
1.3 值物件
值物件和實體都由.net 類構成。值物件是包含資料的類,沒有行為,可能有方法本質上是輔助方法。值物件不需要身份標識,因為它們不會改變狀態。如下所示是一個值物件
/// <summary> /// 訂單地址 值物件是普通的DTO類,沒有唯一標識。 /// </summary> public class Address // ValueObject { public String Street { get; private set; } public String City { get; private set; } public String State { get; private set; } public String Country { get; private set; } public String ZipCode { get; private set; } private Address() { } public Address(string street, string city, string state, string country, string zipcode) { Street = street; City = city; State = state; Country = country; ZipCode = zipcode; } }
1.4 聚合
在開發中單個實體總是互相引用,聚合的作用是把相關邏輯的實體組合當作一個整體對待。聚合是一致性(事務性)的邊界,對領域模型進行分組和隔離。聚合是關聯的物件(實體)群,放在一個聚合容器中,用於資料更改的目的。每個聚合通常被限制於2~3個物件。聚合根在整個領域模型都可見,而且可以直接引用。
/// <summary> /// 定義聚合根,嚴格來說這個介面不需要任務功能,它是一個普通標記介面 /// </summary> public interface IAggregateRoot { } /// <summary> /// 建立購物車聚合根,通常實現IAggregateRoot介面 /// 購物車聚合模型(包括Basket、BasketItem實體) /// </summary> public class Basket : BaseEntity, IAggregateRoot { public string BuyerId { get; set; } private readonly List<BasketItem> _items = new List<BasketItem>(); public IReadOnlyCollection<BasketItem> Items => _items.AsReadOnly(); //... }
在該專案中領域模型與“Microsoft.NET企業級應用架構設計第二版”書中描述的職責有不一樣地方,來看一下:
(1) 領域服務有直接引用聚合中的實體(如:BasketItem)。書中描述是聚合中實體不能從聚合之處直接引用,應用把聚合看成一個整體。
(2) 領域實體幾乎都是貧血模型。書中描述是領域實體應該包括行為和資料。
(2) 領域服務
領域服務類方法實現領域邏輯,不屬於特定聚合中(聚合是屬於領域模型的),很可能跨多個實體。當一塊業務邏輯無法融入任何現有聚合,而聚合又無法通過重新設計適應操作時,就需要考慮使用領域服務。下圖是領域服務資料夾:
/// <summary> /// 下面是建立訂單服務,用到的實體包括了:Basket、BasketItem、OrderItem、Order跨越了多個聚合,該業務放在領域服務中完全正確。 /// </summary> /// <param name="basketId">購物車ID</param> /// <param name="shippingAddress">訂單地址</param> /// <returns>回返回型別</returns> public async Task CreateOrderAsync(int basketId, Address shippingAddress) { var basket = await _basketRepository.GetByIdAsync(basketId); Guard.Against.NullBasket(basketId, basket); var items = new List<OrderItem>(); foreach (var item in basket.Items) { var catalogItem = await _itemRepository.GetByIdAsync(item.CatalogItemId); var itemOrdered = new CatalogItemOrdered(catalogItem.Id, catalogItem.Name, catalogItem.PictureUri); var orderItem = new OrderItem(itemOrdered, item.UnitPrice, item.Quantity); items.Add(orderItem); } var order = new Order(basket.BuyerId, shippingAddress, items); await _orderRepository.AddAsync(order); }
在該專案與“Microsoft.NET企業級應用架構設計第二版”書中描述的領域服務職責不完全一樣,來看一下:
(1) 專案中,領域服務只是用來執行領域業務邏輯,包括了訂單服務OrderService和購物車服務BasketService。書中描述是可能跨多個實體。當一塊業務邏輯無法融入任何現有聚合。
/// <summary> /// 新增購物車服務,沒有跨越多個聚合,應該不放在領域服務中。 /// </summary> /// <param name="basketId"></param> /// <param name="catalogItemId"></param> /// <param name="price"></param> /// <param name="quantity"></param> /// <returns></returns> public async Task AddItemToBasket(int basketId, int catalogItemId, decimal price, int quantity) { var basket = await _basketRepository.GetByIdAsync(basketId); basket.AddItem(catalogItemId, price, quantity); await _basketRepository.UpdateAsync(basket); }
總的來說,eShopOnWeb專案雖然沒有完全遵循領域層中,成員職責描述,但可以理解是在程式碼上簡化了領域層的複雜性。
(3) 倉儲
倉儲是協調領域模型和資料對映層的元件。倉儲是領域服務中最常見型別,它負責持久化。倉儲介面的實現屬於基礎設施層。倉儲通常基於一個IRepository介面。 下面看下專案定義的倉儲介面。
/// <summary> /// T是領域實體,是BaseEntity型別的實體 /// </summary> /// <typeparam name="T"></typeparam> public interface IAsyncRepository<T> where T : BaseEntity { Task<T> GetByIdAsync(int id); Task<IReadOnlyList<T>> ListAllAsync(); //使用領域規則查詢 Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec); Task<T> AddAsync(T entity); Task UpdateAsync(T entity); Task DeleteAsync(T entity); //使用領域規則查詢 Task<int> CountAsync(ISpecification<T> spec); }
(4) 領域規則
在倉儲設計查詢介面時,可能還會用到領域規則。 在倉儲中一般都是定義固定的查詢介面,如上面倉儲的IAsyncRepository所示。而複雜的查詢條件可能需要用到領域規則。在本專案中通過強大Linq 表示式樹Expression 來實現動態查詢。
/// <summary> /// 領域規則介面,由BaseSpecification實現 /// 最終由Infrastructure.Data.SpecificationEvaluator<T>類來構建完整的表達樹 /// </summary> /// <typeparam name="T"></typeparam> public interface ISpecification<T> { //建立一個表達樹,並通過where首個條件縮小查詢範圍。 //實現:IQueryable<T> query = query.Where(specification.Criteria) Expression<Func<T, bool>> Criteria { get; } //基於表示式的包含 //實現如: Includes(b => b.Items) List<Expression<Func<T, object>>> Includes { get; } List<string> IncludeStrings { get; } //排序和分組 Expression<Func<T, object>> OrderBy { get; } Expression<Func<T, object>> OrderByDescending { get; } Expression<Func<T, object>> GroupBy { get; } //查詢分頁 int Take { get; } int Skip { get; } bool isPagingEnabled { get;} }
最後Interfaces資料夾中定義的介面,都由基礎設施層來實現。如:
IAppLogger日誌介面
IEmailSender郵件介面
IAsyncRepository倉儲介面
3.Infrastructure層
基礎設施層Infrastructure依賴於ApplicationCore,這遵循依賴倒置原則(DIP),Infrastructure中程式碼實現了ApplicationCore中定義的介面(Interfaces資料夾)。該層沒有太多要講的,功能主要包括:使用EF Core進行資料訪問、Identity、日誌、郵件傳送。與Equinox專案的基礎設施層差不多,區別多了領域規則。
領域規則SpecificationEvaluator.cs類用來構建查詢表示式(Linq expression),該類返回IQueryable<T>型別。IQueryable介面並不負責查詢的實際執行,它所做的只是描述要執行的查詢。
public class EfRepository<T> : IAsyncRepository<T> where T : BaseEntity { //...這裡省略的是常規查詢,如ADDAsync、UpdateAsync、GetByIdAsync ... //獲取構建的查詢表示式 private IQueryable<T> ApplySpecification(ISpecification<T> spec) { return SpecificationEvaluator<T>.GetQuery(_dbContext.Set<T>().AsQueryable(), spec); } }
public class SpecificationEvaluator<T> where T : BaseEntity { /// <summary> /// 做查詢時,把返回型別IQueryable當作通貨 /// </summary> /// <param name="inputQuery"></param> /// <param name="specification"></param> /// <returns></returns> public static IQueryable<T> GetQuery(IQueryable<T> inputQuery, ISpecification<T> specification) { var query = inputQuery; // modify the IQueryable using the specification's criteria expression if (specification.Criteria != null) { query = query.Where(specification.Criteria); } // Includes all expression-based includes //TAccumulate Aggregate<TSource, TAccumulate>(this IEnumerable<TSource> source, TAccumulate seed, Func<TAccumulate, TSource, TAccumulate> func); //seed:query初始的聚合值 //func:對每個元素呼叫的累加器函式 //返回TAccumulate:累加器的最終值 //https://msdn.microsoft.com/zh-cn/windows/desktop/bb549218 query = specification.Includes.Aggregate(query, (current, include) => current.Include(include)); // Include any string-based include statements query = specification.IncludeStrings.Aggregate(query, (current, include) => current.Include(include)); // Apply ordering if expressions are set if (specification.OrderBy != null) { query = query.OrderBy(specification.OrderBy); } else if (specification.OrderByDescending != null) { query = query.OrderByDescending(specification.OrderByDescending); } if (specification.GroupBy != null) { query = query.GroupBy(specification.GroupBy).SelectMany(x => x); } // Apply paging if enabled if (specification.isPagingEnabled) { query = query.Skip(specification.Skip) .Take(specification.Take); } return query; } }
參考資料
Microsoft.NET企業級應用架構設計 第二版
&n