img

這是俺整理公司新訓內容的第五篇文章,目標是使用三層式架構 (3-Layer Architecture) 來切分服務的關注點和職責

什麼是分層?分層可以吃嗎?

天地混沌如雞子,商業邏輯生其中。

萬八千歲,天地開闢。表現層為天。資料層為地。商業邏輯層在其中……

    --民明書坊《盤古與他的CRUD之旅》

根據民明書坊的文獻記載,我們常聽到的「天地玄黃,宇宙洪荒」云云,其實指的就是上古時期的開發狀況。當時世界還是一片混沌,所有的程式碼都混雜成一坨,不是所有東西寫在一起你儂我儂,一言不合就三千行;就是依賴關係交錯複雜,改了北極壞南極。

要說有多亂呢,大概就算前人嘗試引入了 MVC,也只是改成把所有程式都塞在 Controller 而已,其絕望程度可見一斑。

這時候隔壁課的老盤調過來接刀,一看不得了,便決定先對這屎山整頓一番。他大喝一聲,那些靠近使用者的便上浮起來化作了天,親近資料庫的便沉澱下去變成了地,而所有的商業邏輯就連接著兩者,支撐起了整個專案。這也就是分層架構的由來。

三層式架構

分層架構是運用最為廣泛的架構模式,幾乎每個軟體系統都需要通過層(Layer)來隔離不同的關注點(Concern Point),以此應對不同需求的變化,使得這種變化可以獨立進行;此外,分層架構模式還是隔離業務複雜度與技術複雜度的利器。 -- Ray’s Notes

一般來說,最常見的分層架構就是三層式架構了。

三層式架構顧名思義就是把應用程式分成三層,通常會分成「展示層、商業邏輯層、資料存取層」。

分層架構01

現在讓我們認識一些他們的分工吧!

三層式架構的常見分層有:

  1. 展示層(Presentation Layer)
  • 咱們軟體的門面。負責搞定需要跟外部使用者互動的部份,例如接收使用者的請求、路由的控制、呼叫的流程控制等等
  • 日常工作就是確實地接收使用者的請求,然後叫商業邏輯層去處理,最後把商業邏輯層弄好的東西奉上給使用者
  • 大多時候的開發會在 Controller 進行,例如 ProductController
  1. 商業邏輯層(Business Layer)
  • 咱們軟體的核心。負責處理商業邏輯,也就是商業規則和相關的邏輯處理都在這裡進行
  • 日常工作就是接收展示層的呼叫、和資料存取層拿資料。在這個來往的過程中將資料內容進行商業邏輯的處理
  • 常見的後綴有 BLL, Service 等等,例如 ProductService
  1. 資料存取層(Data Layer)
  • 顧名思義,就是負責存取資料的相關操作
  • 日常工作就是根據商業邏輯層的要求,去資料庫存取資料
  • 常見的後綴有 DAL, Repository 等等,例如 ProductRepository

另外一些比較中大型的架構會把共用的部份抽出來,就會多一層:

  • 共用層(Common Layer)
    • 負責放一些各層之間會共用到的工具。例如擴充方法、列舉等等
    • 因為是用來放各種工具的,所以需要分離出一層,來讓各層都可以使用

用最常見的餐廳來比喻的話,資料庫大概就像是存放食材的冰箱、其他服務的API就像是供應商之類的,各層的分工就會像這樣:

  • 資料層:負責採買食材、去冰箱拿食材等等
  • 業務層:根據各式各樣的食譜把食材變成料理
  • 展示層:負責櫃台的點餐,將做好的料理呈上給客人

對這種過於模糊的比喻不太能理解的朋友,假設今天某網站的管理後台有個查詢某客戶的請求,可能會是這樣子:

  1. 展示層 收到使用者查詢客戶的請求
    • ex: GET Cust/1 -> CustController.Get(int custId)
  2. 展示層 檢查是否登入和權限 ex: [Authorize(Roles = UserRole.Admin)]
  3. 展示層 呼叫商業邏輯層查詢客戶 ex: CustService.Get(int custId)
  4. 商業邏輯層 呼叫資料存取層查詢客戶 ex: CustRepository.Get(int custId)
  5. 資料存取層 從 Cust 資料表取回資料
  6. 商業邏輯層 繼續執行,發現有「查詢客戶時需同時列出該客戶五筆最近訂單簡介」的規則
    • 別問為什麼,問就是 Feature
  7. 商業邏輯層 呼叫資料存取層
    • ex: OrderRepository.GetLast(int custId, int count = 5)
  8. 資料存取層 從 Order 資料表取回資料
  9. 商業邏輯層 根據規則組裝資料,回傳給展示層
  10. 展示層 取得商業邏輯層回覆的資料,回傳給使用者

大概就是這種感覺。

分層架構02

當然,這邊還是要再提醒一下:分層架構並不是只有三層式架構,根據需求也可能會再增加或是減少。同樣地,分層架構只是一種「分工的概念」,並不是只限於軟體,也不一定得就是這三層。

曾經也有聽過「瀏覽器(展示層)、伺服器(商業邏輯層)、資料庫(資料存取層)」的說法,也看過遊戲是用「整體策略、基本操作」等動作規模去分層的,因此只需要有「把工作依據職責切分到不同層」的概念就可以了,切莫要走火入魔。

關於 DTO

我們回到上面的例子,可以注意到各層之間必須要頻繁地溝通、傳遞資訊。因此我們就會需要一些物件來幫忙在各層之間傳遞這些資料,這些只有資料欄位、沒有任何方法的物件就是 DTO(Data Transfer Object, 資料傳輸物件)

在分層架構裡面使用 DTO 是絕對必要的,除了層與層之間需要讓資料傳遞之外,也能帶來幾個好處,例如:

  • 封裝過多的參數
    • 針對參數過多的方法,我們可以收納成 Dto 來隱藏複雜性
    • 封裝前 GetProductList(int custLevel, string custName, int.....)
    • 封裝後 GetProductList(GetProductParameter parameter)
  • 減少層與層之間的耦合
    • 當有修改欄位的時候,有時只需變動 DTO 就可以了
    • 各層之間的溝通使用 Dto 傳輸,可以減少各層直接彼此影響的耦合狀況

這裡其實就是將「資料」和「方法」切割開來封裝的概念。如果切分得不錯的話,在呼叫的過程就會像是工廠流水線的感覺,資料隨著運輸帶到達每個工作區,然後工作區對資料進行處理之後,再送上運輸帶前往下個工作區,經歷了壯闊的旅程(?)之後最後終於到達使用者手上。

不過相對的,每層之間都用 DTO 去通訊、過多的參數封裝成 DTO 等等,都會增加整個系統中的類別數量,有時候甚至會多到畫面上列不下的程度(我的 DTO 就像宇宙一樣廣闊!區區方案總管休想裝得下我!),反而造成管理和修改上的困難。

因此 DTO 的應用場景,例如說是每一層都需要獨立的 DTO 嗎?或是使用同一個 DTO 來操作呢?會不會共用了參數反而造成耦合呢?都要到開發的時候來作取捨。

關於上面這段重複建立 DTO 的問題,我們在最後的 QA 階段 再稍微聊一下吧,先讓我們把鏡頭轉回分層架構。

為什麼我們需要分層架構?

專案分層架構其目的就是為了要職責確立、關注點分離,讓不同的方法或類別去做該做的事情而且只專注於這些方法、類別的職責上 -- mrkt

當我們想知道為什麼要使用分層架構,最快的方式就是了解一下採用分層架構的諸多好處。且讓我數給你聽:

更符合單一職責、關注點分離

也就是我們在單一職責原則篇曾提到的:「把工作交給負責該職責的類別去做,自己只需要關注在自己正在處理的職責即可」。如果一來就可以封裝出邊界、減少彼此耦合影響的機會,也可以減少閱讀大量不相關的程式碼。

關於單一職責的介紹,和我們著重職責所帶來的好處等等,
可以參閱之前的:菜雞與物件導向 (10): 單一職責原則

快速鎖定目標、縮小範圍

因為關注點分離成多個層(Layer)了,當物件的設計有符合職責的話,在開發或除錯時,甚至能像查詢表格一樣迅速!

分層架構04

當分層和類別的定義都合乎職責的時候,在接收到需求的同時也就能抓出目標範圍在哪了,例如:

  • 這次針對某功能的一些判斷邏輯有變動 => OK,先從 Service 開始看
  • 這個資料表的欄位名字被換掉了 => OK,往 Repository 前進
  • 這個路由可以幫我調整一下嗎 => OK,Controller 改起來

原先你可能要先閱讀一坨又臭又長的程式碼,經歷一場垃圾探險記、花一堆時間從裡面找到你要改的地方才能開工。有明確的架構之後,就可以大幅地提升精準度,直接往相應職責的地方去就對了。

因此,乾淨、分工明確的架構可以給你大方向的指引,替你省下許多垃圾時間,實在是舒服許多。

適合多人合作、減少碰撞率

因為範圍縮小了,多人合作的時候就可以靈活地去調度、搭配,提升開發時的效率。

除了平常的你負責A功能、我負責B功能以外,也可以嘗試讓比較熟悉資料庫的朋友處理資料存取層、負責和 API 使用端商談規格的朋友先在展示層開好 API 接口等等。

如此就有了可以根據狀況從分層或是功能等方向去分工的彈性。並且這樣的分工也能減少碰撞率 ……至少會讓 Git 的衝突少一點囧。

增加程式碼的複用性

最後也是最有感覺的就是增加程式碼的複用性了。在商業邏輯中可能很多地方都會用到其他來源的資料來加工組合,例如查詢客戶的時候要一併列出訂單、推薦商品的時候要附上目前的優惠活動等等。

這時候如果分層明確,一些像是資料存取層中的「查詢訂單」這種簡單又符合單一職責的方法,我們就可以加以取用,靈活組合出目前的需求。

比起到處都把同一段撈資料的 Code 複製貼上複製貼上,然後要修改的時候整個遍地開花的狀況,能夠重複使用實在是舒服許多。

到目前為止這應該是我最有感覺的一項,當你需要撈某個資料出來,發現隊友或是之前的自己已經寫好一個乾淨可用好擴充的 Function 在那邊讓你直接呼叫,那感覺真的是一個爽呀!

提供抽換的靈活度

這一項比較像是前面各項產生的結果。由於我們根據了不同職責來拆分出各層,因此當我們面臨問題的時候是可以整層進行抽換的。

例如說當我們除了原先的網頁以外,還要提供 API 服務,那麼展示層即使抽換成了 Web API 專案,對整體的架構仍不會有影響,還是能保持同一套商業邏輯。

又或者是說今天這個專案的資料來源要從 MySQL 換到 MSSQL 之類的,我們就可以把資料存取層替換掉,而不影響商業邏輯和展示層的運作。

享受以上優點的前提

但要真的享受到上述的各項優點,還是需要達成一定的條件的,並不是說直接切三塊就「完!」這樣。例如說:

  • 物件的設計需要遵守 SOLID 原則
    • 包括物件符合單一職責、各個物件之間使用介面來減少耦合等等
    • 其實這一項還蠻直覺的:畢竟如果你的方法完全不管單一職責,動不動就塞個上百行的 SQL 之類的,當然就很難重複利用了嘛
  • 需要降低各層之間的依賴
    • 為了解除層與層之間的直接依賴,因此會需要使用介面和依賴注入等解耦手段
    • 只有將各層之間的耦合降低,才能實現可替換、關注點分離等效果
    • 關於依賴注入的概念,可以參照 依賴注入章節
    • 註:本篇的範例會先建立簡單的分層,下一篇才會進入依賴注入的實作
  • 可能會有額外的開發成本
    • 即使是再簡單的功能,例如單純的查詢,也必須要貫穿每一層。自然就增加了開發成本
      • 有時候只是為了要多提供一個欄位給使用者,就會變成從上到下的每一層都需要修改
    • 不過我個人覺得比起東西都塞在一起然後動輒上千行還改東壞西的,我是很能接受這些成本啦囧

本節的參考資料

由於接下來我們就要進入實作了,因此在這邊先放上分層架構概念的參考資料,提供給想更了解三層式架構觀念的朋友們。

實作

實作範例的架構與前言

在這次的範例中,我會採用在公司時的切割標準:各層之間不同方向的通訊都有獨立的 DTO 來負責。

當然每一層和每個 DTO 的名稱都是很彈性的,例如 Ray 大大的範例中,Service 和 Repository 的 DTO 部份就是使用 Dto 和 Entity 來命名。

故這部份還請各位根據專案慣例作調整,我個人還是習慣用這套命名方式來處理。本範例的架構大致上會長這樣:

分層架構03

此外,實作部份主要的參考來源為:

對想了解分層架構的朋友們,可以也看過 mrkt 大大的操作流程,整體的敘述比較深入。

但要特別注意,由於我個人平時工作上比較常收到動輒好幾項查詢條件要包裝成單一支 API 的 Parameter DTO 這類需求,故已較為習慣建出一卡車的 DTO 這種方式。

而像 mrkt 大大的系列文是示範將已有的程式拆分為分層架構的模式、Ray 大大的範例程式的 DTO 命名和這篇的範例不同等等,因為環境、條件和習慣的差別,各家的分層方式和命名都會不太一樣,主要還是看團隊的慣例啦。

而且畢竟都要寫了,當然要用我習慣的流程來記錄嘛。作者特權!

因此本篇的範例,或是說每一篇說明分層的範例在操作的流程、DTO 的建立和使用上都會有所不同。還請有交叉閱讀的朋友們稍加留意。

不過就像上面引用的 mrkt 大大說的:「『專案分層架構』這個題目相當難以說明,因為要分幾層、怎麼分層、各層有什麼職責、要做什麼事?這些都可以再細分成很多個主題來說明」

所以希望各位不要拘泥於流程上的順序或是命名之類的,而是理解到這些都會依照實務上的狀況去做決策和調整。最後再重申一次:分層架構是一種分工的概念,所謂「兵無常勢,水無常形」請各位施主見機行事。善哉善哉。

那麼我們就準備開始囉!

大致上的步驟

感謝和同事 Sian 的談話才整理這套步驟,基本上我習慣的流程如下:

  1. 建立 Service 層
  2. 建立 Repository 層
  3. 建立 Controller 及 DTO (Parameter、ViewModel)
  4. 建立 Service 的介面及 DTO (Info、ResultModel)
  5. 建立 Repository 的介面及 DTO (Condition、DataModel)
  6. 將 Service 注入到 Controller, 將 Repository 注入到 Service
  7. 安裝 AutoMapper,後續使用 AutoMapper 處理來處理 DTO 的轉換
  8. Controller 實作,並接上 Service 的介面
  9. Service 實作,並接上 Repository 的介面
  10. Repository 實作
  11. 進行整合測試,呼叫 Controller 試試看是否成功取得資料
  12. 自由發揮

可以注意到其實就是:

  • 切出分層
  • 開介面和用到的 DTO
  • 用實作銜接各層
  • 測試

這樣子的流程。

但這邊要說明幾點:

  • 第 3 項的 Controller 我們會沿用本系列的專案,等等會稍作說明
  • 第 6 項的注入我們會在下一個章節再進行介紹。這邊就先直接使用 new 的方式處理
  • 同上,由於尚未使用注入,故 8 ~ 10 的實作部分會改從 Repository 開始實作

以上部分就…等等實作就會瞭了。那讓我們先介紹一下目前的專案狀況吧!

範例專案背景

接著回到專案的介紹,如果並不是很在乎專案狀況,而是想看實作過程的朋友,請跳到 建立分層 繼續閱讀。

我們會使用這個系列的 ProjectN 專案繼續操作。該專案在先前的 ApiDapper 章節中,已經建立了一個對卡牌資料表進行 CRUD 的 CardController。詳細部份就不附了(畢竟這篇都會打掉嘛)大致上長這樣:

[ApiController]
[Route("[controller]")]
public class CardController : ControllerBase
{
    /// <summary>
    /// 查詢卡片列表
    /// </summary>
    [HttpGet]
    [Produces("application/json")]
    public IEnumerable<CardViewModel> GetList()
    {
        // 查詢卡片的一些操作
    }

    /// <summary>
    /// 查詢卡片
    /// </summary>     
    [HttpGet]
    [Produces("application/json")]
    [ProducesResponseType(typeof(CardViewModel), 200)]
    [Route("{id}")]
    public CardViewModel Get(
        [FromRoute] int id)
    {
        // 查詢指定 ID 的卡片的一些操作
    }

    /// <summary>
    /// 新增卡片
    /// </summary>
    /// <param name="parameter">卡片參數</param>
    /// <returns></returns>
    [HttpPost]
    public IActionResult Insert(
        [FromBody] CardParameter parameter)
    {
        // 新增卡片的一些操作
    }

    /// <summary>
    /// 更新卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <param name="parameter">卡片參數</param>
    /// <returns></returns>
    [HttpPut]
    [Route("{id}")]
    public IActionResult Update(
        [FromRoute] int id,
        [FromBody] CardParameter parameter)
    {
        // 更新卡片的一些操作
    }

    /// <summary>
    /// 刪除卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>
    [HttpDelete]
    [Route("{id}")]
    public IActionResult Delete(
        [FromRoute] int id)
    {
        // 刪除卡片的一些操作
    }
}

其中用來回傳顯示卡片內容的 Card 類別為了和分層的 DTO 命名一致,已經改為 CardViewModel

/// <summary>
/// 卡片
/// </summary>
public class CardViewModel
{
    /// <summary>
    /// 卡片編號
    /// </summary>
    public int Id { get; set; }

    /// <summary>
    /// 卡片名稱
    /// </summary>
    public string Name { get; set; }

    /// <summary>
    /// 卡片描述
    /// </summary>
    public string Description { get; set; }

    /// <summary>
    /// 攻擊力
    /// </summary>
    public int Attack { get; set; }

    /// <summary>
    /// 血量
    /// </summary>
    public int Health { get; set; }

    /// <summary>
    /// 花費
    /// </summary>
    public int Cost { get; set; }
}

以及當新增及修改卡片時使用的 CardParameter

/// <summary>
/// 卡片參數
/// </summary>
public class CardParameter
{
    /// <summary>
    /// 卡片名稱
    /// </summary>
    public string Name { get; set; }

    /// <summary>
    /// 卡片描述
    /// </summary>
    public string Description { get; set; }

    /// <summary>
    /// 攻擊力
    /// </summary>
    public int Attack { get; set; }

    /// <summary>
    /// 血量
    /// </summary>
    public int Health { get; set; }

    /// <summary>
    /// 花費
    /// </summary>
    public int Cost { get; set; }
}

如果像本節開頭提到的,並沒有切分查詢和傳入的 DTO 時,這兩個就有可能使用同一個 Model,遇到這種狀況還請不要太驚慌了。

不過既然已經提到說不採用共用 Model 而是全部獨立的原因是「常會有多條件的查詢參數」之類的,這邊就補一下查詢列表用的參數吧。

如此如此,在 Parameter 資料夾下新增了一個 CardSearchParameter

/// <summary>
/// 卡片搜尋參數
/// </summary>
public class CardSearchParameter
{
    /// <summary>
    /// 卡片名稱
    /// </summary>
    public string Name { get; set; }

    /// <summary>
    /// 攻擊力下限
    /// </summary>
    public int? MinAttack { get; set; }

    /// <summary>
    /// 攻擊力上限
    /// </summary>
    public int? MaxAttack { get; set; }

    /// <summary>
    /// 血量下限
    /// </summary>
    public int? MinHealth { get; set; }

    /// <summary>
    /// 血量上限
    /// </summary>
    public int? MaxHealth { get; set; }

    /// <summary>
    /// 花費值下限
    /// </summary>
    public int? MinCost { get; set; }

    /// <summary>
    /// 花費值上限
    /// </summary>
    public int? MaxCost { get; set; }
}

請注意因為參數是選填的,所以部分欄位使用的是 Nullable。畢竟在卡牌遊戲中,想查詢「攻擊力3以下的卡片」是很平常的需求嘛。

接著這個參數就放回到 CardControllerGetList() 裡面作為參數吧:

/// <summary>
/// 查詢卡片列表
/// </summary>
/// <returns></returns>
[HttpGet]
[Produces("application/json")]
public IEnumerable<CardViewModel> GetList(
    [FromQuery] CardSearchParameter parameter)
{
        // 查詢卡片的一些操作
}

畢竟是 GET 方法,標示一下 FromQuery 才是好習慣呦。

到這邊就說明完目前的專案狀況了,讓我們捲起袖子,開始切分層吧!

建立分層

接著讓我們先來建立分層吧,這邊採用建立「類別庫」的方式來分層,對於一些比較小的專案,使用資料夾來分層也是沒有問題的。

首先,讓我們在方案總管對著方案按下右鍵,選擇 加入 → 新增專案

image-20210921153907902

接著找到類別庫,由於範例專案是使用 .net Core 3.1 的版本,故選擇 .net Core 為目標的類別庫:

image-20210921154045369

接著讓我們用專案名稱 + Service 來命名這個專案:

image-20210921154155709

下個畫面選擇完版本之後,就可以在解決方案看到多了一個 Service 的專案囉:

image-20210921154319429

然後那個 Class1.cs 可以砍掉,我們不會用到。

接著請重複以上的步驟,建立 Repository 和 Common 的類別庫:

image-20210921154728674

補充:如果想要把專案做排序或整理的朋友,可以對解決方案右鍵,選擇 加入 → 新增方案資料夾,用資料夾去做排序和整理

有的時候我都懷疑其他人的分層用 Business 和 Data 命名,該不會是為了排序起來比較好看……?!

加入參考

建立完類別庫之後,接著我們就要來加入參考,也就是設定這些庫之間的依賴方向。

一般的分層架構的依賴關係會是從上到下,也就是 展示層 → 商業邏輯層 → 資料存取層,然後他們三個都參考共用層。

分層架構05

現在讓我們從最外圍開始:對 ProjectN (或是你 WEB/API 等等所在的專案)右鍵 → 加入 → 專案參考

image-20210921162356712

然後讓它參考我們的 Service 層以及共用的 Common:

image-20210921163054655

同樣地,也請各位對 Service 和 Repository 進行同樣的操作加入參考。各層的參考關係現在應該要是這樣的:

  • API or APP: Service + Common
  • Service: Repository + Common
  • Repository: Common

小提示:如果想確認目前專案的相依性,可以在方案總管的解決方案上按下右鍵 → 專案相依性

建立介面與 DTO

在這個小節,你可能會需要對介面有些了解。還不太了解的朋友,可以參照上個系列的介面篇

對於分層是否要使用介面有疑惑的朋友,可以參考 mrkt 大大的這篇 專案分層架構建議 中的「問題三、一定要用介面嗎?」的段落。

接著,讓我們開始著手處理介面的部分吧,先讓我們在 Service 中新增一個叫做 Interface 的資料夾來放我們的介面。

image-20210921182347662

接著因為是做卡片管理的 CRUD,當然要用 Card 當前綴。並且由於習慣的關係,只要是 Interface 我會在最前面加上一個 I 來標示,所以讓我們在該資料夾下建立一個 ICardService

image-20210921194022072

image-20210921194110374

如此一來就會建立一個空的介面。啊,記得加上 Public 呦。

/// <summary>
/// 卡片管理服務
/// </summary>
public interface ICardService
{

}

接著讓我們補上 CRUD 的五個方法,如果是去 Controller 複製的朋友要注意這邊的 DTO 已經不同囉:

/// <summary>
/// 卡片管理服務
/// </summary>
public interface ICardService
{
    /// <summary>
    /// 查詢卡片列表
    /// </summary>
    /// <returns></returns>
    IEnumerable<CardResultModel> GetList(CardSearchInfo info);

    /// <summary>
    /// 查詢卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>   
    CardResultModel Get(int id);

    /// <summary>
    /// 新增卡片
    /// </summary>
    /// <param name="parameter">卡片參數</param>
    /// <returns></returns>
    bool Insert(CardInfo info);

    /// <summary>
    /// 更新卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <param name="parameter">卡片參數</param>
    /// <returns></returns>
    bool Update(int id, CardInfo info);

    /// <summary>
    /// 刪除卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>
    bool Delete(int id);
}

因為我們還沒建立對應的 DTO,所以會有紅字也是正常的。我個人平常都是邊捏介面邊開 DTO 就是了。

畢竟介面就是各層之間溝通的契約,傳遞的 DTO 當然也就是契約內容了。所以在決定這功能要幹嘛的時候就會順便捏起來。

綜上所述,接著就讓我們來把 DTO 補上吧,因為我們只有 Card 一條線,所以這裡的 DTO 就會是對應展示層的那三個 DTO,反過來說也就是說,如果你的架構裡會有多個 Service 互相協作,或是 Service 需要從多個 Repository 取得資料的話,諸如此類需要多個部份互相配合的時侯,DTO 就不一定會是和上一層對照起來的了。這一點還請注意。

畢竟這個範例只有單線,已經是最最最簡單的狀況了,還是有蠻多亂七八糟的地方是難以表達的…屆時再請各位切身體會了。

所以請容我再貼一次九成像的 DTO 吧 XD。此外,因為都九成像了,呈現的時候就先不加上欄位註解囉。

關於存放的位置,我個人習慣會再開一個 Models 或是 Dtos 的資料夾來放這些 DTO,如果功能比較多的就再多一層做個分類。本範例的 DTO 路徑如註解。

// ProjectN.Service.Dtos.ResultModel
public class CardResultModel
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public int Attack { get; set; }
    public int Health { get; set; }
    public int Cost { get; set; }
}


// ProjectN.Service.Dtos.Info
public class CardInfo
{
    public string Name { get; set; }
    public string Description { get; set; }
    public int Attack { get; set; }
    public int Health { get; set; }
    public int Cost { get; set; }
}


// ProjectN.Service.Dtos.Info
public class CardSearchInfo
{
    public string Name { get; set; }
    public int? MinAttack { get; set; }
    public int? MaxAttack { get; set; }
    public int? MinHealth { get; set; }
    public int? MaxHealth { get; set; }
    public int? MinCost { get; set; }
    public int? MaxCost { get; set; }
}

不要忘了要回到 Interface 的地方好好 using 進來呦:

image-20210921200741002

現在我們的 Service 層應該會是這個樣子的:

image-20210921200831418

接著就讓我們用同樣的節奏來處理 Repository 吧。

首先仍然是先建立一個 Interface 資料夾、建立一個 ICardRepository,並加上我們的 CRUD 五天王:

/// <summary>
/// 卡片管理服務
/// </summary>
public interface ICardRepository
{
    /// <summary>
    /// 查詢卡片列表
    /// </summary>
    /// <returns></returns>
    IEnumerable<CardDataModel> GetList(CardSearchCondition info);

    /// <summary>
    /// 查詢卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>   
    CardDataModel Get(int id);

    /// <summary>
    /// 新增卡片
    /// </summary>
    /// <param name="parameter">卡片參數</param>
    /// <returns></returns>
    bool Insert(CardCondition info);

    /// <summary>
    /// 更新卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <param name="parameter">卡片參數</param>
    /// <returns></returns>
    bool Update(int id, CardCondition info);

    /// <summary>
    /// 刪除卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>
    bool Delete(int id);
}

接著是我們的 DTO 們:

// ProjectN.Repository.Dtos.DataModel
public class CardDataModel
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public int Attack { get; set; }
    public int Health { get; set; }
    public int Cost { get; set; }
}


// ProjectN.Repository.Dtos.Condition
public class CardCondition
{
    public string Name { get; set; }
    public string Description { get; set; }
    public int Attack { get; set; }
    public int Health { get; set; }
    public int Cost { get; set; }
}


// ProjectN.Repository.Dtos.Condition
public class CardSearchCondition
{
    public string Name { get; set; }
    public int? MinAttack { get; set; }
    public int? MaxAttack { get; set; }
    public int? MinHealth { get; set; }
    public int? MaxHealth { get; set; }
    public int? MinCost { get; set; }
    public int? MaxCost { get; set; }
}

這邊特別提一下 CardSearchCondition 好了。

我個人是喜歡先在 Repository 中把一些簡單、跟資料有關的欄位先用 Condition 允許第一次篩選,如果有複雜的、需要別的條件的就再 Service 進行篩選。如此一來如果有別的 Service 需要用差不多的條件查詢該資料的時候,就可以用組合這些參數的方式來重複使用這個 Repository。

也就是說和 Service 時提到的時候一樣:如果有需要多個資料來源合併處理時,每層的 DTO 就會有所不同。

這個時候的 Repository 參數應該注重在「針對這個資料,有哪些基本的過濾條件?」下去設計,維持這個篩選的條件是和這個資料來源有關的,這樣才真的能夠重複利用

千萬不要為了需求需要多個資料來源,就打破單一職責原則,把所有參數都丟到 Repository 然後才想辦法把資料表之類的兜在一起去滿足需求,這樣就難以複用、本末倒置了。

如果這樣說可能有點難以理解的話,想像一個「查詢重要訂單」功能的參數同時有「該訂單的交易金額大於五十萬」和「該訂單客戶年收入大於五十萬」這兩個查詢條件,很明顯一個是從訂單本身的資料內容下去篩選、而另一個是從客戶的資料內容下去篩選。

這種時候就應該是分別去呼叫 訂單的 Repo 和 客戶的 Repo 來拿到相對應的資料,例如說有客戶年收入的條件時,先取出年收入符合的客戶,再用這些客戶的編號取得對應的訂單等等(具體順序看資料大小),那麼這種場合兩者的參數 DTO 就會自然地和 Service 的 DTO 不一樣了。

請盡量不要為了一路打到底就硬把這兩個條件綑綁在一起,弄成什麼「以交易金額與客戶年收入查詢訂單」的「專用」方法,這樣將來就很難重複使用了。

(不過凡事都有例外嘛,當你的效能爆炸到被要求只能在 SQL 就先做完篩選的時候,或是只能從舊有功能移植過來的時候,還是要乖乖想辦法就是了囧…)

聊得遠了,讓我們回到 ProjectN 的 Repository,目前應該會是長這個樣子:

image-20210921204201764

到這邊我們的介面和功能都訂好啦,接著就讓我們開始實作這些介面吧!

實作 Repository

註:本篇範例還不會用到依賴注入,因此會將需要用到的依賴對象,例如 Controller 中的 IService 實體直接在建構式裡面建立出來。也因為必須建立實體的關係,故必須從最底的 Repository 開始實作。

如果是已經有在使用依賴注入的朋友,還請自行在腦內調整一下。不過我個人是覺得實作的順序沒有什麼關係啦,各位朋友有習慣的就按自己習慣的就好溜。

而還不太知道依賴注入的朋友們,我們下一篇再來說明。或是也可以先參照依賴反轉原則的範例自己改看看,此處就先按下不表。

首先讓我們從 Repository 開始實作吧!

第一步就是建立實作的資料夾,方便和 Interface 做個區隔,所以這邊在 Repository 建立一個 Implement 資料夾,並在裡面建立 CardRepository

image-20210926181827673

建立之後,別忘記把類別改為 Public 並告訴他我們要實作介面 ICardRepository(記得 using):

public class CardRepository : ICardRepository
{

}

接著 IDE 通常都會提醒一下說你有哪些要求沒有做到,這邊就讓我偷懶一下直接讓 IDE 產生空方法:

image-20210926182828485

public class CardRepository : ICardRepository
{
    public IEnumerable<CardDataModel> GetList(CardSearchCondition info)
    {
        throw new NotImplementedException();
    }

    public CardDataModel Get(int id)
    {
        throw new NotImplementedException();
    }
    
    public bool Insert(CardCondition info)
    {
        throw new NotImplementedException();
    }

    public bool Update(int id, CardCondition info)
    {
        throw new NotImplementedException();
    }
    
    public bool Delete(int id)
    {
        throw new NotImplementedException();
    }
}

各位朋友在切出資料存取層的實作時,如果是從舊專案移植過來的,也可以從「把操作資料表的部份都先剪過來」來起手。

從資料庫裏面操作資料的 CRUD 相關部份我們在上一篇的 Dapper 章節已經做得差不多了,這邊就不再贅述,稍微補上這次新增的查詢列表就行。

補充:為了避免我之後回來抄的時候想不起來,這邊也放一下資料表目前狀況好了: image-20211002135411755

/// <summary>
/// 卡片管理
/// </summary>
/// <seealso cref="ProjectN.Repository.Interface.ICardRepository" />
public class CardRepository : ICardRepository
{
    /// <summary>
    /// 連線字串
    /// </summary>
    private readonly string _connectString = @"Server=(LocalDB)\MSSQLLocalDB;Database=Newbie;Trusted_Connection=True;";

    /// <summary>
    /// 查詢卡片列表
    /// </summary>
    /// <param name="info"></param>
    /// <returns></returns>
    public IEnumerable<CardDataModel> GetList(CardSearchCondition condition)
    {
        var sql = "SELECT * FROM Card";

        var sqlQuery = new List<string>();
        var parameter = new DynamicParameters();

        if (condition.MinCost.HasValue)
        {
            sqlQuery.Add($" Cost >= @MinCost ");
            parameter.Add("MinCost", condition.MinCost);
        }

        if (condition.MaxCost.HasValue)
        {
            sqlQuery.Add($" Cost <= @MaxCost ");
            parameter.Add("MaxCost", condition.MaxCost);
        }

        if (condition.MinAttack.HasValue)
        {
            sqlQuery.Add($" Attack >= @MinAttack ");
            parameter.Add("MinAttack", condition.MinAttack);
        }

        if (condition.MaxAttack.HasValue)
        {
            sqlQuery.Add($" Attack <= @MaxAttack ");
            parameter.Add("MaxAttack", condition.MaxAttack);
        }

        if (condition.MinHealth.HasValue)
        {
            sqlQuery.Add($" Health >= @MinHealth ");
            parameter.Add("MinHealth", condition.MinHealth);
        }

        if (condition.MaxHealth.HasValue)
        {
            sqlQuery.Add($" Health <= @MaxHealth ");
            parameter.Add("MaxHealth", condition.MaxHealth);
        }

        if (string.IsNullOrWhiteSpace(condition.Name) is false)
        {
            sqlQuery.Add($" Name LIKE @Name ");
            parameter.Add("Name", $"%{condition.Name}%");
        }
        
        if (sqlQuery.Any())
        {
            sql += $" WHERE {string.Join(" AND ", sqlQuery)} ";
        }

        using (var conn = new SqlConnection(_connectString))
        {
            var result = conn.Query<CardDataModel>(sql, parameter);
            return result;
        }
    }
	
    /// <summary>
    /// 查詢卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>
    public CardDataModel Get(int id)
    {
        var sql =
            @"		
                SELECT * 
                FROM Card 
                Where Id = @id
            ";

        var parameters = new DynamicParameters();
        parameters.Add("Id", id, System.Data.DbType.Int32);

        using (var conn = new SqlConnection(_connectString))
        {
            var result = conn.QueryFirstOrDefault<CardDataModel>(sql, parameters);
            return result;
        }
    }


    public bool Insert(CardCondition condition)
    {
        var sql =
            @"
                INSERT INTO Card 
                (
                   [Name]
                  ,[Description]
                  ,[Attack]
                  ,[Health]
                  ,[Cost]
                ) 
                VALUES 
                (
                    @Name
                   ,@Description
                   ,@Attack
                   ,@Health
                   ,@Cost
                );

                SELECT @@IDENTITY;
            ";

        using (var conn = new SqlConnection(_connectString))
        {
            var result = conn.Execute(sql, condition);
            return result > 0;
        }
    }

    /// <summary>
    /// 更新卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <param name="condition"></param>
    /// <returns></returns>
    public bool Update(int id, CardCondition condition)
    {
        var sql =
            @"
                UPDATE Card
                SET 
                    [Name] = @Name
                   ,[Description] = @Description
                   ,[Attack] = @Attack
                   ,[Health] = @Health
                   ,[Cost] = @Cost
                WHERE
                    Id = @id
            ";

        var parameters = new DynamicParameters();

        parameters.AddDynamicParams(condition);
        parameters.Add("Id", id, System.Data.DbType.Int32);

        using (var conn = new SqlConnection(_connectString))
        {
            var result = conn.Execute(sql, parameters);
            return result > 0;
        }
    }

    /// <summary>
    /// 刪除卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>
    public bool Delete(int id)
    {
        var sql =
            @"
                DELETE FROM Card
                WHERE Id = @Id
            ";

        var parameters = new DynamicParameters();
        parameters.Add("Id", id, System.Data.DbType.Int32);

        using (var conn = new SqlConnection(_connectString))
        {
            var result = conn.Execute(sql, parameters);
            return result > 0;
        }
    }
}

實作 Service

接著是我們 Service 層的實作。要注意的是,在 DTO 的轉換上,推薦使用 AutoMapper 來處理,讓方法關注在商業邏輯本身,而不用被 DTO 的賦值過程洗版。

補充:對 AutoMapper 不太熟悉的朋友,可以先閱讀 AutoMapper —— 類別轉換超省力

如果是在本篇流程不打算使用 AutoMapper 的朋友,請在使用到 AutoMapper 的場合自行 new 出目標物件並賦值即可。

首先一樣先建立實作用的資料夾,並建立 CardService

image-20211003091808904

並且讓 IDE 幫忙把要實作的介面都先列出來:

public class CardService : ICardService
{
    public IEnumerable<CardResultModel> GetList(CardSearchInfo info)
    {
        throw new NotImplementedException();
    }

    public CardResultModel Get(int id)
    {
        throw new NotImplementedException();
    }

    public bool Insert(CardInfo info)
    {
        throw new NotImplementedException();
    }

    public bool Update(int id, CardInfo info)
    {
        throw new NotImplementedException();
    }
    
    public bool Delete(int id)
    {
        throw new NotImplementedException();
    }
}

那因為這個範例還沒有什麼需要注意的商業邏輯,因此我們就先在 Service 做一個承上(Controller)啟下(Repository)的動作。

也就是說每個方法負責去接收 Controller 的請求,並呼叫 Repository 來完成工作,並用 AutoMapper 來進行過程的轉換。

因此我們的 Service 要能夠呼叫到 Repository 和 Mapper,現在先讓我們把 Repository 當作私有成員建立進來:

public class CardService : ICardService
{
    private readonly ICardRepository _cardRepository;

    /// <summary>
    /// 建構式
    /// </summary>
    public CardService()
    {
        this._cardRepository = new CardRepository();
    }

    // 其他實作部分
}

接著是 DTO 的對映部分,先建立 Service 的對映表。我個人習慣跟隨公司慣例開一個 Mappings 資料夾來放:

image-20211003094809104

補充:當專案還有 Mappings, Enum(列舉)和其他設定檔等等,資料夾就會變得挺多的。

這種時候,也可以把這類基礎建設相關的都放到 Infrastructure 資料夾做個整理,閱讀和操作上會比較舒服。

public class ServiceMappings : Profile
{
    public ServiceMappings()
    {
        // Info -> Condition
        this.CreateMap<CardInfo, CardCondition>();
        this.CreateMap<CardSearchInfo, CardSearchCondition>();

        // DataModel -> ResultModel
        this.CreateMap<CardDataModel, CardResultModel>();
    }
}

接著讓我們也把 AutoMapper 的部份放到建構式裡面吧:

public class CardService : ICardService
{
    private readonly IMapper _mapper;
    private readonly ICardRepository _cardRepository;

    /// <summary>
    /// 建構式
    /// </summary>
    public CardService()
    {
        var config = new MapperConfiguration(cfg =>
			cfg.AddProfile<ServiceMappings>());

        this._mapper = config.CreateMapper();
        this._cardRepository = new CardRepository();
    }
    
    // 其他實作部分
}

接著就化身為無情的串接機器,把各個實作接起來。

例如說 查詢列表 GetList,標準流程就是 轉換參數 DTO、呼叫目標方法、轉換回傳 DTO:

/// <summary>
/// 查詢卡片列表
/// </summary>
/// <param name="info"></param>
/// <returns></returns>
public IEnumerable<CardResultModel> GetList(CardSearchInfo info)
{
    var condition = this._mapper.Map<CardSearchInfo, CardSearchCondition>(info);
    var data = this._cardRepository.GetList(condition);

    var result = this._mapper.Map<
        IEnumerable<CardDataModel>, 
    	IEnumerable<CardResultModel>>(data);

    return result;
}

當然實際上還會根據需求,在這邊做一些商業邏輯的處理,例如說呼叫多個 Repository 方法、參數內容換成內部商業邏輯定義好的代號等等。

叮嚀一下:如果感覺公開方法裡面做的事情太多的話,還請考慮是不是職責太複雜、並嘗試適當地拆出私有方法或其他類別呦。

那麼這邊就直接補上剩下的方法,貼上整個 CardService 吧:

/// <summary>
/// 卡片管理
/// </summary>
/// <seealso cref="ProjectN.Service.Interface.ICardService" />
public class CardService : ICardService
{
    private readonly IMapper _mapper;
    private readonly ICardRepository _cardRepository;

    /// <summary>
    /// 建構式
    /// </summary>
    public CardService()
    {
        this._cardRepository = new CardRepository();

        var config = new MapperConfiguration(cfg => 
			cfg.AddProfile<ServiceMappings>());

        this._mapper = config.CreateMapper();
    }

    /// <summary>
    /// 查詢卡片列表
    /// </summary>
    /// <param name="info"></param>
    /// <returns></returns>
    public IEnumerable<CardResultModel> GetList(CardSearchInfo info)
    {
        var condition = this._mapper.Map<CardSearchInfo, CardSearchCondition>(info);
        var cards = this._cardRepository.GetList(condition);

        var result = this._mapper.Map<
            IEnumerable<CardDataModel>, 
        	IEnumerable<CardResultModel>>(cards);

        return result;
    }

    /// <summary>
    /// 查詢卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>
    public CardResultModel Get(int id)
    {
        var card = this._cardRepository.Get(id);
        var result = this._mapper.Map<CardDataModel, CardResultModel>(card);
        return result;
    }

    /// <summary>
    /// 新增卡片
    /// </summary>
    /// <param name="info"></param>
    /// <returns></returns>
    public bool Insert(CardInfo info)
    {
        var condition = this._mapper.Map<CardInfo, CardCondition>(info);
        var result = this._cardRepository.Insert(condition);
        return result;
    }

    /// <summary>
    /// 更新卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <param name="info"></param>
    /// <returns></returns>
    public bool Update(int id, CardInfo info)
    {
        var condition = this._mapper.Map<CardInfo, CardCondition>(info);
        var result = this._cardRepository.Update(id, condition);
        return result;
    }

    /// <summary>
    /// 刪除卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>
    public bool Delete(int id)
    {
        var result = this._cardRepository.Delete(id);
        return result;
    }
}

實作 Controller

最後就是讓我們的 Controller 來接上 Service 的介面,把方法公開出去啦!

同樣的也先放一下 Mappings:

public class ControllerMappings : Profile
{
    public ControllerMappings()
    {
        // Parameter -> Info
        this.CreateMap<CardParameter, CardInfo>();
        this.CreateMap<CardSearchParameter, CardSearchInfo>();

        // ResultModel -> ViewModel
        this.CreateMap<CardResultModel, CardViewModel>();
    }
}

最後一樣把 Controller 的各方法補上:

/// <summary>
/// 卡片管理
/// </summary>
/// <seealso cref="Microsoft.AspNetCore.Mvc.ControllerBase" />
[ApiController]
[Route("[controller]")]
public class CardController : ControllerBase
{
    private readonly IMapper _mapper;
    private readonly ICardService _cardService;

    /// <summary>
    /// 建構式
    /// </summary>
    public CardController()
    {
        var config = new MapperConfiguration(cfg =>
        	cfg.AddProfile<ControllerMappings>());

        this._mapper = config.CreateMapper();
        this._cardService = new CardService();
    }

    /// <summary>
    /// 查詢卡片列表
    /// </summary>
    /// <returns></returns>
    [HttpGet]
    [Produces("application/json")]
    public IEnumerable<CardViewModel> GetList(
        [FromQuery] CardSearchParameter parameter)
    {
        var info = this._mapper.Map<
            CardSearchParameter, 
        	CardSearchInfo>(parameter);

        var cards = this._cardService.GetList(info);

        var result = this._mapper.Map<
            IEnumerable<CardResultModel>,
        	IEnumerable<CardViewModel>>(cards);

        return result;
    }

    /// <summary>
    /// 查詢卡片
    /// </summary>
    /// <remarks>我是附加說明</remarks>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>
    /// <response code="200">回傳對應的卡片</response>
    /// <response code="404">找不到該編號的卡片</response>          
    [HttpGet]
    [Produces("application/json")]
    [ProducesResponseType(typeof(CardViewModel), 200)]
    [Route("{id}")]
    public CardViewModel Get(
        [FromRoute] int id)
    {
        var card = this._cardService.Get(id);

        var result = this._mapper.Map<
            CardResultModel,
        	CardViewModel>(card);

        return result;
    }

    /// <summary>
    /// 新增卡片
    /// </summary>
    /// <param name="parameter">卡片參數</param>
    /// <returns></returns>
    [HttpPost]
    public IActionResult Insert(
        [FromBody] CardParameter parameter)
    {
        var info = this._mapper.Map<
            CardParameter,
        	CardInfo>(parameter);

        var isInsertSuccess = this._cardService.Insert(info);
        if (isInsertSuccess)
        {
            return Ok();
        }
        return StatusCode(500);
    }

    /// <summary>
    /// 更新卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <param name="parameter">卡片參數</param>
    /// <returns></returns>
    [HttpPut]
    [Route("{id}")]
    public IActionResult Update(
        [FromRoute] int id,
        [FromBody] CardParameter parameter)
    {
        var targetCard = this._cardService.Get(id);
        if (targetCard is null)
        {
            return NotFound();
        }

        var info = this._mapper.Map<
            CardParameter,
        	CardInfo>(parameter);

        var isUpdateSuccess = this._cardService.Update(id, info);
        if (isUpdateSuccess)
        {
            return Ok();
        }
        return StatusCode(500);
    }

    /// <summary>
    /// 刪除卡片
    /// </summary>
    /// <param name="id">卡片編號</param>
    /// <returns></returns>
    [HttpDelete]
    [Route("{id}")]
    public IActionResult Delete(
        [FromRoute] int id)
    {
        this._cardService.Delete(id);
        return Ok();
    }
}

測試一下有沒有接上去

image-20211003114955056

打完收工!

自問自答心得篇

實作結束之後,這邊就留個版面放一些上面沒提到/塞不進去/老人碎碎念的部份。

感謝 Sian 提供的建議和示範,這邊補上一些關於我個人分層上遇到的一些問題和想法,整理成Q&A的方式。

關於重複建立 DTO 的問題

Q: 資料處理給商業邏輯要開一個 DataModel,商業邏輯出去又要開一個 ResultModel……每次都要開一堆重複的 DTO 很麻煩!一定要這樣做嗎?

A: 不一定。

我這邊也遇過一些系統,由於場景相對單純,存取上基本只有增修查改,商業邏輯也多是驗證和內容資料的處理,因此就採用一個 Dto 貫穿三層的方式進行。例如說跟訂單有關的就只使用一個 OrderDto、跟產品有關的就只用一個 ProductDto 這樣,整體用起來會比較像是一個實體對應一個 Dto 的感覺。

我個人認為「要不要把各層之間溝通用的 DTO 都獨立出來」,基本上就是在問這個系統:

  • 會不會有「你對資料表比較熟悉,負責資料存取層;我對外制定規格,負責展示層」等等,這種需要針對分層去分工的時候?
  • 每一層之間傳遞的 Model 是否會在各層進一步加工,每一層需要傳遞的資料會不會有所不同?
  • 每一層之間呼叫方法的參數是否會不同,有沒有需要把參數封裝成一個物件?

例如說:

  • 資料存取層將資料表的資料查詢出來,但該功能展示給使用者的時候並不需要這麼多欄位
  • 商業邏輯層需要針對這個內容去和別的資料進行組裝、運算
    • 例如說需求不只是單純的客戶查詢,而是客戶平均消費排行榜之類的
  • 展示層需要進行一些給使用者介面顯示時的調整
    • 浮點數顯示的時候只到小數後兩位之類的

像這種分工明確的情況下,就很容易會遇到每一層之間傳遞的 Dto 內容必須不同的情況。

以上的狀況如果都共用同一個 DTO 反而綁手綁腳的,把各層之間盡責地拆分開來,更可以降低彼此間的耦合,讓修改的範圍變小、並盡量只在符合該職責的地方修改,整體會比較靈活。

回到問題上來說,要不要確實地把每一層的 DTO 做拆分?或是想採用一個 DTO 代表該資料來貫穿整個系統?我個人覺得都還是蠻彈性的,可以根據系統的複雜度去嘗試。

如果你可能會根據分層去指派分工,又或者是有些地方會頻繁地修改、每一層之間傳遞的資訊常常會有所差異,甚至是收到的需求常常挺客製化的時候,拆分開來在往後的修改就可能可以迴避一些耦合上的問題。但相對的,因為 Model 終究還是變多了,有時候也會遇到重複且多餘的修改。

反過來說如果系統的工作單一,場景相對單純,例如說是針對某個職責去架設的、提供給其他系統使用的小型服務。那麼先採用 DTO 對映資料內容並共用的做法,在開發上反而比較迅速。

就再請各位使用時細細品味吧。我都是前輩怎麼開我就怎麼寫啦

三層式架構跟 MVC 一樣嗎?

這個吼,不太一樣啦(抓頭)

雖然兩者的目標都算是「區分職責+解除耦合」的感覺,但並不是一樣的東西。

MVC 是一種框架,主要分為 View, Controller, Model,特別強調此三者並不能直接類比到三層式架構的三層。我們從兩邊的角度來比對一下:

以 MVC 的角度出發,對應三層式中的商業邏輯和資料存取都是塞到 Model 中處理的(當然也會遇到全部塞在 Controller 的朋友囧),這時候最大的差別就是有沒有區分出商業邏輯。畢竟三層式就是為了要能重複使用而分層的嘛。

反過來從三層式架構的角度出發的話,MVC 也只是用在展示層的一種模式而已。例如說展示層使用 MVC 的架構,往下接到商業邏輯等等。實際上使用分層的話,最終不管展示層是 MVC 框架、WebForm、Web Api 等等,對底下的商業邏輯層等等都不會有任何影響。

因此兩者是可以並存的不同層級下的不同拆分方式,並不能簡單地一概而論。

這個問題也可以參考以下文章,分享給大家:

Service 可以依賴 Service 嗎?

可以。既然人家都寫好了不用白不用

基本上來說在兩個 Service 之間就是一般物件和物件的關係,如果需要對方的公開方法,當然可以依賴對方。

不過為了避免循環參考,和同事經過了一些討論,還是有一些地方可以注意:

  • 如果只是純粹的流程控制,可以把工作還給流程控制的 Controller,讓該功能的 Controller 依序呼叫 Service 處理
  • 如果是多個商業邏輯的整合,可以用一個高階的 Service 去整合負責較小職責的 Service,避免循環參考或則是依賴關係混亂

另外在使用上也要注意呼叫對象是否有經過一些複雜的商業處理,如果那正是你要的,例如說你就是要訂單計算之後的結果,那當然沒有問題;倘若只是需要乾淨的資料,也可以乾脆往下去依賴 Repository 就可以,避免商業邏輯間意料之外的耦合。

所以請不要被從上到下這個方向束縛了,Service 也可以是厚厚的一層。

小結

本篇記錄了為什麼要分層,以及我個人平常開新專案的分層步驟。

最後針對分層架構,總結一下幾個筆記重點:

  • 分層是為了分離關注點。讓每一層的職責明確、專注在各自的工作
  • 分層帶來的好處:
    • 修改時能更快鎖定目標、縮小範圍
    • 適合多人合作,提升開發效率
    • 增加程式碼的複用性
    • 必要的時候可以抽換掉某一層
  • 分層的前提:
    • 物件的設計要遵守 SOLID 原則
    • 使用依賴注入來解除層與層之間的依賴
    • 額外的開發成本
  • 常見的三層式架構:
    • 展示層:負責和外部使用者互動
    • 商業邏輯層:負責處理商業規則和相關的邏輯處理
    • 資料存取層:負責存取資料的相關操作
    • 共用層:負責擴充方法等不屬於任何一層的共用模組
  • 資料傳輸物件(DTO)
    • 層與層之間需要資料的傳遞,因此我們會建立只有欄位沒有方法的 DTO 來傳輸資料
    • DTO 的轉換挺麻煩的,這時候就可以考慮使用 AutoMapper 這類工具
  • 分層本身是分工的概念
    • 要分成幾層、每一層負責什麼工作,這些都是需要決策的
    • 綜上所述,請根據專案需求和團隊慣例作調整

大概這樣。

實作的部份也因為分層這個題目太廣了,其實有點不太知道要怎麼寫比較好,放置了一段時間。

最後決定就接續先前文章的進度,用平常習慣的方式調整一下來跑完一輪。當然內容還有許多地方是需要調整的:例如我們下一篇要加入的依賴注入,或是將方法改寫為非同步等等。

內文的範例也延續了單純的 CRUD,並沒有完整展現商業邏輯分工出來的魅力,算是一些小遺憾。最後補充一些本篇的參考資料。如果有想要補充或討論的朋友,也歡迎分享您的看法,感謝感謝。

那麼,我們下篇再見囉~

本系列下一篇:菜雞新訓記 (6): 使用 依賴注入 (Dependency Injection) 來解除強耦合吧

參考資料

本系列文章