iT邦幫忙

2022 iThome 鐵人賽

DAY 18
0
Software Development

軟體架構師的自我修養系列 第 18

[Day 18] 乾淨架構實戰二部曲

  • 分享至 

  • xImage
  •  

這篇文章是乾淨架構三部曲中最重要的核心,我們在首部曲中描述了資料導向開發的問題,而在二部曲中我們會給出一個更加合理並有效率的設計與開發流程。

我們接續昨天的範例,一個簽到任務,並且試著使用一個不同的設計流程。但在我們開始前,讓我們來重新複習一下洋蔥架構。

Onion Architecture

為了讓之後的介紹更容易理解,我們先來定義洋蔥上面的圖示。

  • Entity:在乾淨架構中,entity指的是商業邏輯。與領域驅動設計的entity不同,乾淨架構中的entity可以理解成領域驅動設計的「領域」。
  • Use cases:有了領域,外層則是使用案例。使用案例是指那些使用領域知識來完成的特定商業需求或使用場景。在領域驅動設計中也稱為領域服務。
  • Controller:控制器很單純,我們在分層式架構中也有用過這個名詞,他負責處理整個領域的輸入和輸出。包含輸入驗證與轉換從客戶端傳送過來的領域知識等
  • External interfaces:最外層是整個系統外部依賴的介面,當然也包含資料庫。
  • 箭頭:在洋蔥圖上有許多箭頭,從外層指向內層,這表示引用。外層的模組可以引用內層模組,但相反方向,內層不能引用外層。

根據這些描述我們可以知道,設計的流程應該由內而外,只有在內層建立後才可以被外層引用。換句話說,要設計一個乾淨架構,首先要確立領域行為,最後才是資料庫設計。

而這流程恰恰與資料導向設計相反。

領域驅動設計

在開始實際設計之前,我先解釋一下我常用的設計流程,這流程同時也呼應了洋蔥架構。

  1. 發掘使用者故事(entities)
  2. 定義使用案例
  3. 領域物件建模
  4. 實作單元測試
  5. 開始寫功能

在之後的章節,我也會依循這個流程設計和實作。

發掘使用者故事

我們要實作的功能是簽到任務。

為了開始一個設計,我們必須要了解整個需求的全貌,作為描述需求的通用語言(ubiquitous language),我們需要寫出使用者故事。

這次需求的使用者故事會類似下面:

  1. 當使用者連續簽到要取得對應的獎勵。
  2. 顯示簽到的狀態和收到的獎勵。
  3. 當開啟禮盒會拿到100鑽。

我們透過通用語言將生硬的需求文件轉換成開發者容易理解的語意。任何需求的背後都會有個故事,設計者的工作是發掘這些故事並將之轉換成開發者能夠理解的描述。

另一方面,開發者的工作是根據設計者描述的故事寫程式。

定義使用案例

有了故事,我們接著設計這些故事需要面對的使用案例。

與故事不同的是,使用案例是參照給定的故事產生的使用者情境。例如:

  1. 簽到:當使用者連續簽到四天,第五天的第一次簽到可以得到30鑽和一個禮盒,但同一天的第二次簽到不會得到獎勵。
  2. 開啟禮盒:當開啟一個禮盒,使用者可以得到100鑽,但同個禮盒無法重複開啟。

根據上面描述,使用案例其實是使用者故事的延伸,並且將細節清楚定義。因此,透過使用案例我們就有辦法畫出流程圖來詳細解釋使用者情境。

讓我們以簽到作為流程圖的範例。

從最上層的起點開始,這表示一個使用者簽到了,因此是SignIn: now。接著,我們需要知道這次簽到與上次簽到距離多久。

如果是0天,那代表當天已經簽到過,也就沒獎勵可拿。若是大於1天,表示使用者沒有連續簽到,那麼就會重置成一個新的週期。若恰好是1天,那就表示連續簽到成立,因此我們將計數器遞增並且紀錄當下時間。

最後,從獎勵表中根據計數器取得對應的獎勵。

要顯示連續簽到幾天的狀態也不困難,只需要根據計數器的值即可。假設我們用一個陣列來表示簽到狀態。

  • 只簽到一天:[1, 0, 0, 0, 0, 0, 0]
  • 連續簽到三天:[1, 1, 1, 0, 0, 0, 0]

因此,透過計數器將對應數量的1插入陣列即可。

因為開禮盒的流程也是類似,因此我就不過多解釋了,最後的程式碼會包含開禮盒。

領域物件建模

根據使用案例,我們可以知道我們需要兩個很重要的變數:counterlast。事實上,其餘的狀態都可以透過這兩個變數推導,因此我們開始建模。

為了描述整個簽到任務,我相信每一個使用者都有屬於自己的狀態,因此我們將使用者狀態包裝成一個領域物件,稱為SignInRepo。這裡的Repo正是在領域驅動設計中的Repository

有了使用者狀態後,接著我們可以描述整個故事。在故事中有兩個操作,signIngetTimeline,分別對應了故事1的簽到和故事2的簽到狀態。

SignInRepo是最基本的使用案例,也就是洋蔥圖的entity。根據使用案例的流程圖,他會有兩個私有屬性和兩個公開方法。那為什麼update有個參數呢?理由是,從流程圖我們可以看到其中一格是counter++, set last=now,那個now是從外部傳入的。

至於SignInService,從名字可以知道,他屬於領域服務。

一但我們有了領域物件,我們就可以開始測試驅動開發(TDD)了。

實作單元測試

在TDD的開發流程中,我們首先根據我們的使用者故事寫出對應的測試,接著才是實際的程式碼。

因此,這一節我會解釋如何利用剛剛定義的故事和模型來寫測試。

讓我們用一個平常的故事作為例子,假設我們已經連續簽到六天,那麼第七天簽到我們可以得到100鑽和一個禮盒。

首先,根據故事寫出測試。

describe("step1", () => {
  it("continuous 6d and signin 7th day", () => {
    const user = "User A";
    const now = "2022-01-07 1:11:11";
    const service = new SignInService(user);

    const timeline1 = service.getTimeline();
    expect(timeline1).to.deep.equal([1, 1, 1, 1, 1, 1, 0]);

    const result = service.signIn(now);
    expect(result).to.be.equal(100);

    const timeline2 = service.getTimeline();
    expect(timeline2).to.deep.equal([1, 1, 1, 1, 1, 1, 1]);
      
    const result = service.signIn(now);
    expect(result).to.be.equal(0);
  });
});

一個故事就簡單的描述出來了:有一個使用者A,他已經連續簽到六天,當他在2022-01-07 1:11:11簽到,也就是第七天,他預期會拿到100鑽,而顯示狀態則是一個具有七個1的列表。至於當天簽到第二次,什麼都拿不到。

但這樣的故事沒有完整,因為連續六天並沒有被定義,因此我們稍微修改一下測試。

describe("step2", () => {
  it("continuous 6d and signin 7th day", () => {
    const user = "User A";
    const now = "2022-01-07 1:11:11";
    const repo = new SignInRepo(user);
    repo.restoreSignInRecord(6, "2022-01-06 5:55:55");
    const service = new SignInService(repo);

    const timeline1 = service.getTimeline();
    expect(timeline1).to.deep.equal([1, 1, 1, 1, 1, 1, 0]);

    const result = service.signIn(now);
    expect(result).to.be.equal(100);

    const timeline2 = service.getTimeline();
    expect(timeline2).to.deep.equal([1, 1, 1, 1, 1, 1, 1]);
      
    const result = service.signIn(now);
    expect(result).to.be.equal(0);
  });
});

為了實作完整的使用案例,我們新宣告了一個SignInRepo並且為他加入一個新的工具函示:restoreSignInRecord

這個工具函示也可以在未來實作功能時作為從資料庫讀取資料的接口。如此一來,這個故事就完整了,我們可以開始撰寫實際的程式碼。

開始寫功能

在前一節,我們已經有了完整的測試,因此我們開始正式實作SignInRepoSignInService

class SignInRepo {
  constructor(user) {
    this.user = user;
    this.counter = 0;
    this.last = null;
  }
  restoreSignInRecord(counter, last) {
    this.counter = counter;
    this.last = last;
  }
  update(now) {
    this.counter++;
    this.last = now;
  }
  reset() {
    this.counter = 0;
    this.last = null;
  }
}

class SignInService {
  constructor(repo) {
    this.repo = repo;
  }
  signIn(now) {
    const diffDay = dateDiff(now, this.repo.last);
    if (diffDay === 0) {
      return 0;
    }
    if (diffDay > 1) {
      this.repo.reset();
    }
    this.repo.update(now);
    return table[this.repo.counter - 1] || 0;
  }
  getTimeline() {
    const ret = [0, 0, 0, 0, 0, 0, 0];

    if (!this.repo.counter) return ret;

    for (let i = 0; i < 7; i++) {
      if (i < this.repo.counter) ret[i] = 1;
    }
    return ret;
  }
}

SignInRepo在沒有資料庫的時候很容易實作,只需要根據流程圖完成updatereset即可。

SignInService則完全是根據使用案例實作的,在流程圖中已經完整包含所有的實作細節。

透過這種方式,整個需求已經完成一半了,剩下開禮盒的設計與開發流程也是相同,因此我將完整的最後結果列在下面連結:

https://gist.github.com/wirelessr/3555c40256e51ac7c1befa4db897941b

總結領域驅動設計

事實上,上述的實作只是借用了一些領域驅動設計的名詞,並不是完全按照教科書實作的。

就我的觀點來說,領域驅動設計提供一個設計概念,讓人們了解領域的重要性,並且有能力將領域抽象化。

也就是說,你可以自由決定是否要按照教科書式的去實作Entity、Value Object、Aggregate和Repository,沒必要照單全收。實作完全取決於對需求的理解和熟練度。

這篇文章中提供了一個標準的設計流程,讓每個人都有能力拆解原始需求並且將需求轉換成領域模型。實作模型的流程則是從寫出測試開始,並達成測試驅動開發。

當然,現實世界中,一個商業需求不會像是範例中的那麼簡單,但設計流程是相同的。首先,描述故事,接著透過故事定義使用案例,緊接著根據案例寫出測試,最後實作。

但今天我們為了簡化解說過程,稍微跳過了資料庫的相關設計,因此,在三部曲的最後,我會告訴你該怎麼結合乾淨架構和資料庫,敬請期待。


上一篇
[Day 17] 乾淨架構實戰首部曲
下一篇
[Day 19] 乾淨架構實戰三部曲
系列文
軟體架構師的自我修養31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言