個人練習專案 ‧ 剛入行 · 2021
訂單開立發票系統
延續氣象排程練習專案的分層架構,套用到「訂單→發票」的業務情境:每日排程批次開立發票,並補上 FluentValidation 請求驗證、ActionFilter 效能量測,以及用 Docker 化 MSSQL 跑的 MSTest 整合測試。
專案概述
氣象開放資料排程系統 之後的練習專案,把同一 套「分層架構+Hangfire 排程」的模式,套用到更貼近實務的業務情境:訂單成立 後,由排程工作每日批次為訂單開立發票。程式庫是私有的練習專案,這裡整理設計 上的重點。
業務流程
Product(商品)→ Order / OrderDetail(訂單/訂單明細)→
Invoice / InvoiceDetail(發票/發票明細)層層關聯:建立商品、下訂單
後,背景排程每天固定時間抓取所有訂單,批次呼叫開立發票的邏輯,並把執行
過程記錄下來(工作 ID、來源工作 ID、起訖時間),方便事後追查某天的發票是哪
次排程工作產生的。
// 排程工作:先建立一筆「工作紀錄」,再串接呼叫實際開立發票的工作,
// 並用 sourceJobId 把兩個工作串起來方便追查
public void CreateJob(PerformContext context)
{
var jobId = context.BackgroundJob.Id;
// ...寫入 LogContent...
BackgroundJob.Enqueue<IHangfireJobs>(x => x.CreateInvoiceAsync(null, jobId));
}
public async Task CreateInvoiceAsync(PerformContext context, string sourceJobId)
{
var orders = await _orderService.GetAllAsync();
var result = await _invoiceService.CreateManyAsync(orders);
// ...記錄結果...
}
Hangfire 排程改為每日定時批次開立發票(cron 0 0 * * *),而不是即時觸
發,符合發票通常是「日結」的業務性質;CreateManyAsync 會先篩掉已經開過
發票的訂單,只針對尚未開票的訂單批次寫入,避免重複開票。
API 輸入驗證(FluentValidation + ActionFilter)
API 層的輸入驗證改用 FluentValidation,並且不是在每個 Action 手動呼叫
驗證,而是包成一個共用的 ParameterValidatorAttribute:Attribute 建構時
指定要用哪個 Validator 類別,執行時從 Action 的參數裡取出目標物件跑驗證,
驗證失敗就直接組出帶錯誤碼/CorrelationId 的 FailResultViewModel 短路回
傳,Action 本體完全不用寫任何驗證邏輯:
[HttpPost]
[CoreProfiling]
[ParameterValidator(typeof(OrderCreateParameterValidator))]
public async Task<IActionResult> CreateAsync([FromBody] OrderCreateParameter orderCreateParameter)
{
var dto = _mapper.Map<OrderCreateParameter, OrderDto>(orderCreateParameter);
var result = await _orderService.CreateAsync(dto);
// ...
}
OrderCreateParameterValidator/OrderUpdateParameterValidator/
ProductCreateParameterValidator/ProductUpdateParameterValidator 依
Create/Update 情境分別寫規則,跟另一層內部模型驗證(ModelValidator,
封裝 DataAnnotations 的 Validator.TryValidateObject)分開職責:前者管
「API 請求格式對不對」,後者管「往下傳的內部模型合不合法」,兩層不混在一起。
自動化效能量測
同樣用 ActionFilter 包一層效能量測(CoreProfilingAttribute,底層是
CoreProfiler),在 OnActionExecuting / OnActionExecuted 自動開始/結
束一個 Profiling Step,Action 上只要掛一個 [CoreProfiling] 就好,不需要
每個 Action 手動加碼計時。
測試策略(MSTest)
延續 Controller/Service/Repository/UnitOfWork 分層,並補上
InvoiceSystem.ServiceTests 與 InvoiceSystem.IntegrationTest 兩個測試
專案,同樣全部使用 MSTest(MSTest.TestFramework +
MSTest.TestAdapter),搭配 FluentAssertions、NSubstitute、AutoFixture:
InvoiceSystem.IntegrationTest:用TestEnvironment.Docker起一個 真的 MSSQL 容器,測試前先用DbScript目錄下的 SQL 腳本(Product_Data、Order_Data、OrderDetail_Data、Invoice_Data、InvoiceDetail_Data) 灌一批固定的測試資料,每個測試包在交易裡、跑完RollbackTransaction,驗 證「訂單已開過發票就不能重複開立」「批次開立時只處理尚未開票的訂單」這類 跟真實資料庫狀態有關的行為- 大量使用 MSTest 的
[DataRow]做參數化測試,例如驗證發票查詢在OrderId/ProductId/InvoiceId七種組合下都能篩出正確筆數,一個測試 方法涵蓋七種情境,不用複製貼上七個測試
[TestMethod()]
[DataRow(1, 0, 0, DisplayName = "輸入orderId為1")]
[DataRow(0, 1, 0, DisplayName = "輸入productId為1")]
[DataRow(0, 0, 1, DisplayName = "輸入invoiceId為1")]
[DataRow(1, 1, 0, DisplayName = "輸入orderId為1與productId為1")]
[DataRow(0, 1, 1, DisplayName = "輸入productId為1與invoiceId為1")]
[DataRow(1, 0, 1, DisplayName = "輸入orderId為1與invoiceId為1")]
[DataRow(1, 1, 1, DisplayName = "輸入orderId為1與productId為1與invoiceId為1")]
public async Task GetAsync_輸入特定參數_輸出相關資料數量(int orderId, int productId, int invoiceId)
{
// arrange / act / assert
}
學到的東西
- 同一套分層架構重複套用在第二個專案上,能明顯感覺到「哪些是可以直接複用的 骨架、哪些要依業務情境重新設計」
- 排程工作之間用
sourceJobId串起因果關係,是後來理解「如何追蹤一連串背景 工作」的起點 - 把請求驗證(FluentValidation + ActionFilter)跟內部模型驗證
(
DataAnnotations)分成兩層,職責更清楚,也是後來重視分層設計的起點 - 用 MSTest 的
[DataRow]把同一個方法的多種輸入組合收斂成一個測試方法, 比每種情境各寫一個測試方法更好維護 - 用 Docker 化的 MSSQL 搭配 SQL 腳本灌測試資料、每個測試跑完自動回滾,讓 整合測試可以重複執行也不互相污染