[實作筆記] 簡單工廠與工廠方法

前情提要

最近被要求介紹一下 Factory Method 這個 Design Pattern,
以前看大話設計模式的時候,這個 Pattern 總會跟 Simple Factory 一起講.
有了一些工作經驗後,現在回頭來重新看這兩個模式.

問題

我們為什麼需這個模式 ? 我們面臨什麼樣子的問題 ?
參考以下程式:

1
2
3
4
5
6
7
public class Email:INotification
{
void Send(string message)
{
////Send Mail
}
}

先說明一下這個程式,
在整個系統中依據不同情況中,會發送不同的通知,
比如說:電子郵件(Email)、語音電話(Voice Call)或通訊軟體(Slack)等…

所以我們實際使用的場景可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class ProcessNotifyCustomerService()
{
var notify = new Email();
notify.from = "[email protected]";
notify.to = "[email protected]";

notify.Send(msg);
//// ....
}

public class ProcessNotifyDevOps()
{
var notify = new Email();
notify.from = "[email protected]";
notify.to = "[email protected]";

notify.Send(msg);
//// ....
}

public class ProcessNotifyManager()
{
var notify = new VoiceCall();
notify.server = "voiceCall.server";
notify.port = "9527";

notify.Send(msg);
//// ....
}

//// more ...

以上面的例子來說明一下簡單工廠想解決的問題:
當我們在整個系統中使用不同的 Notification 時,
我們建立整個物件的細節都在 Client 端之中,
這是非常擾人的, 特別當你要建立個複雜的物件,
你肯定不會希望每次都要重來一遍.
第一個想法就是把細節封裝起來,如下:

1
2
3
4
5
6
7
8
9
public class EmailFactory()
{
public static Email Create(){
Email notify = new Email();
notify.from = "[email protected]";
notify.to = "[email protected]";
return Email;
}
}

再進一步, 當我們有相同的行為時也可以封裝到簡單工廠之中,
在我們的例子中, 我們可以把 Email 與 VoiceCall 放在同一個工廠裡面
在這裡我們命名為 NotifyFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class NotifyFactory()
{
public static INotification Create(string type){

INotification notify;
switch (type) {
case "DevOpsEmail":
notify = new Email();
notify.from = "[email protected]";
notify.to = "[email protected]";
return notify;

case "CustomerServiceEmail":
notify = new Email();
notify.from = "[email protected]";
notify.to = "[email protected]";
return notify;

case "VoiceCall":
notify = new VoiceCall();
notify.server = "voiceCall.server";
notify.port = "9527";
return notify;

default:
throw new UnsupportedOperationException("不支援該操作");
}
}
}

簡單工廠的好處在於,
當你想改變某一個功能時你只需要修改一個點,
而且你在改動的過程入無需再次涉入建構的細節

比如說:發信通知改為播打語音電話

只需要修改Client,無需處理建構細節

而另一個好處是,如果在建立物件的細節有所調整的話,
可以只要在一處就完成所有修正.

比如說: Email Notify 更換為客服的信箱

只需要改一個地方,就完成所有的修正

工廠方法

當我們需要加入(擴展)新的商業邏輯,會修改到不止一個地方
舉例來說:
我要加入一種新的通知叫飛鴿傳書(PigeonNotify)好了,
除了修改 Client 端使用工廠建立新的 notify 外,
也要在簡單工廠裡面修改。

1
2
3
4
5
6
7
8
9
public class EmailFactory()
{
public static Email Create(){
Email notify = new Email();
notify.from = "[email protected]";
notify.to = "[email protected]";
return Email;
}
}

再進一步, 當我們有相同的行為時也可以封裝到簡單工廠之中,
在我們的例子中, 我們可以把 Email 與 VoiceCall 放在同一個工廠裡面
在這裡我們命名為 NotifyFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class NotifyFactory()
{
public static INotification Create(string type){

INotification notify;
switch (type) {
//// 中間省略
case "Pigeon":
return new PigeonNotify();
default:
throw new UnsupportedOperationException("不支援該操作");
}
}
}

面對功能的擴展,也需要修改工廠的邏輯

簡單工廠中的 Switch 會與參數耦合,
每次加入一個新的 Notify 都會異動,
這違反開放封閉原則.
改用工廠方法, 我們只需要新增一個新的工廠方法,
並在 Client 呼叫使用工廠, 不再傳遞任何參數.

Client 相依於工廠介面上,需要呼叫指定工廠取得物件

TDD to Simple Factory

目的,透過 TDD 建立出不同的通知(Notification)類別,
再透過 TDD 趨動簡單工廠,再透過重構改變為工廠方法。

  1. 利用測試作為 Client 寫出測試案例

  2. 測試案例先簡單使用 new 通過測試

    • 因為是概念性的測試,所以會缺乏實作細節,實務上可能會不只有 new

    到這一步只是一般建立物件,
    下一步開始是趨動成為簡單工廠,
    但實際上你是可以跳過簡單工廠,直接 TDD 出工廠方法的

  3. 再寫一個測試案例,來製造(Notify)功能的重複(Email、SNS)

  4. 功能重複讓我們可以抽出介面

  5. 建立簡單工廠使用邏輯分支回傳不同的(Notify)功能實作

  6. 在簡單工廠的邏輯分支使用不同工廠方法實作

    • 因為封裝了實作細節,方法簽章應該不需要任何參數,回傳值應該為 void

    下一步開始趨動成為工廠方法

  7. 讓 Client 端直接呼叫不同的工廠

    • 簡單工廠類別就會變成多餘無用的類別
  8. 因為有相同的方法簽章,所以可以抽出工廠介面

  9. 讓 Client 端相依工廠介面

可以參考以下我的 commit 順序:

參考

(fin)

[實作筆記] 怎麼建立一個網站?(五) - Hexo 的 404 頁面

What

什麼是 404 頁面呢 ?
其實這是一個 HTTP 狀態碼,代表「網頁不存在」,
與另外一種常見錯誤代碼(500/503),代表的意義並不相同,
500/503 通常是指服務整個掛了,
而 404 是指所要的資源(頁面、檔案、圖片)並不存在.

以下提供一些常見的狀態碼與代表意義

  • 200 OK
  • 403 Forbidden
  • 500 Internal Server Error
  • 503 Service Unavailable

Why

為什麼我們需要一個 404 頁面呢 ?
我直接引述

當使用者不小心進入你某些不存在或者有錯誤的頁面,就會跳出這個 404 頁面,(中略…)
而這個頁面最大的用途在於增加使用者體驗,例如畫面上會有 Logo or 返回首頁按鈕,確保使用者不會因為看到這個頁面立刻關閉。
(中略…)
搜尋引擎也會依照你是否有這個當作一個加分評比 (中略…)

How

首先執行 hexo new page 404 , 這是 Hexo 用來建立新頁面的語法,
上述的語法執行後, 會產生一個 404.md 檔, 如果你不喜歡這個檔名你也可以換掉,
ex: hexo new page page_not_found

產生的頁面如下:

1
2
3
4
5
6

---
title: page_not_found
date: 2021-04-06 10:36:15
---

這個時候我們需要調整一個重要屬性 permalink: /404.html,
這樣當 Github Page 找不到頁面時,
才會出現我們設定的 404 頁面.

另外我們可以設定 layout:true 的布林值來決定是否套用原本的樣式(預設為 true),
這個主要也是為了讓使用者”感覺”他仍然在同一個網站之中,
而減低跳轉率.

下面可以使用 markdown 語法編輯

參考

(fin)

[生活筆記] 將來銀行與純網銀一些有的沒的

Next Bank

前情提要

將來銀行是台灣三大純網銀(Line、樂天)中,
官股成份最重的一間, 也因此備受各界期待.
2019 起成立籌備處, 大舉徵才…預計 2021 取得營業執照…
2020 年 12 月樂天取得營業執照,
2021 年 2 月LINE BANK取得營業執照

一些爭議

取得執照

2019 金管會審核三家純網銀皆取得執照後,
中時的報導,「國家隊吊車尾上榜 「將來」銀行真有將來?」
經濟日報的平衡報導 「純網銀審查幕後 將來銀行不是最後一名 」
是不是有點欲蓋彌彰 ? 相關報導只剩 PTT 的留存

人事

總經理劉奕成,號稱金融界跳跳虎(求出處,意指常轉換工作)
2018 LINE拚純網銀陣前換將,劉奕成確認離職,陳立人暫代,
2019 加入將來銀行籌備處,同年被爆高層團隊領高薪,
2021 年 3 月離職. 22日臉書首次公開貼文,
內文包含目前狀況

  • 資訊系統不穩定
  • 只有一半不到的人有金融背景
  • 資訊人員不會寫程式

性騷擾的事件

將來銀行爆性騷案處理不當 女經理患憂鬱症被離職【瞎事多1】

一些人看法

金管會主委黃天牧

將來銀行想要將來 有 3 點需讓金管會滿意

  • 營運制度的改善情況
  • 人事安排
  • ??

臉書一些回應

雷神講堂

將來銀行的徵才資訊中, 針對「資安維運中心工程師 SOC Engineer」的工作內容,
起薪僅25K至65K,且須配合輪夜班……

C 君

突然懂了什麼 #領7000萬的靠背2萬5不懂開發

R 君

不懂 Domain Knowhow 的話 JD 不要亂寫,
寫一堆又給不起。

更多…

我的看法

首先我覺得將來銀行的受到的鎂光燈好像比 Line 與樂天還多,
當然也可能是我的同溫層太厚的關係.
另外在將來銀行的官網或臉書上, 我找不到正式的公關回應.
所以不太確定新聞提到的將來銀行的回應是出自何處?
總之目前還有點渾濁, 我覺得要讓子彈再飛一會兒.

相比而言 Line 與樂天的新聞就少很多,
一路看下來我覺得比較像是一場只發生在將來銀行的媒體/行銷戰,
就我而言將來銀行的 Logo 或是營銷手段是迎合年輕人的,
但是我不了解的是,一個美美的官網或臉書你只用來介紹 Logo 的設計理念跟找網紅拍小短片 ?
撰寫本篇 Blog 時的臉書最後一篇文章是在跟風鮭魚時事哏,
而不對一些傳媒的新聞作出回應 ? (可能是我看漏,有人可以提供給我的話十分感謝)
有種「行銷成功卻公關完敗」的感覺.

想說說開發相關的問題, 但覺得人事可能也是一個大問題,
先說基層吧,將銀明顯是需要即戰力的,
但就我的面試經驗而言, 技術主管連使用什麼技術作為基本的架構將銀都無法決定,
在招募上是說你會什麼都可以, 但是你總得要團隊作戰,
你的團隊要用什麼戰鬥方式會影響你的戰術, 怎麼可以不確定呢 ?
招募進來的人員不會寫程式, 那招募者是不是有問題呢 ?
開發的產品有問題, 但總之金管會至少是有個產品可以被審核,
如果以劉奕成所言, 相比其它兩家, 將銀少了 10~15 月籌備時間,
那可以等待一段時間後再作評論, 只是以市場的角度來說算是失了先機;
開發人員一半非金融背景, 這是也是當初籌備招募的亮,
如果沒有好好整合, 甚至在團隊分派系才是糟糕的
現在有點以成敗論英雄.
再來,將銀在短短的 2 年招募了 300 人, 雖說不能確定是否有達到這計劃的人數,
即使只有一半 150 人也是碰到了鄧巴數,
這肯定會有相當的衝突.
有人的地方就有江湖, 這裡還是讓子彈繼續飛吧.

「敏捷」不 ? 我反而覺得是個假議題, 特別是在籌備了這麼久後的現在,

金融背景的人,會認為系統必須內部確認 100% 沒有問題,
再請金管會來審查,但是部分資訊部門的人卻認為,
就算有一些 Bug,也沒什麼關係,事後再來修改就好了。 — <<數位時代>>

上述這段話是來自數位時代的新聞, 也是敏捷圈最多引用一段話,
但這些自稱敏捷的人會露出一種「笑他的迂」的態度,覺得金融背景不懂敏捷 ?
這個態度讓我覺得十分荒謬.
我僅以下四點作為回應:

  • 個人與互動重於流程與工具
  • 可用的軟體重於詳盡的文件
  • 與客戶合作重於合約協商 
  • 回應變化重於遵循計劃

其實將銀在招募的過程說過因為沒包伏, 所以可以建立更敏捷的文化.
但如果新聞內容為真, 與其說金融背景的人不懂敏捷,
不如說這些當初號稱敏捷進入將銀的人, 並沒有成功的建立真正的敏捷文化.
將內部 100% 確認轉化成有明確事項的 PBI ,迭代執行檢驗,
隨這一年

金管會的回應中規中矩, 就不予置評了.

參考

(fin)

[A 社筆記] Sudo 與環境變數

前情提要

使用 Azure 的 pipeline 對專案進行 CI/CD,
在某一段執行語法中,因權限不足無法建立必要的資料夾,
而導致部署失敗。
有兩個思路,一個是讓現有的使用者擁有建立資料夾的權限,
另一個想法比較單純,使用 sudo 提供足夠的權限給 CI/CD 的執行者。

問題

但是 sudo 引發了另一個問題,
設定在 pipeline 的環境變數消失了,
原因是當我們在使用 sudo 的時候,會影響到環境變數。
這個時候需要參考 /etc/sudoers 的設定

env_reset

  • env_check # 當變數含有不安全字元 %/ 會被移除
  • env_keep # 當 env_reset 為 enable 時要保留的一系列環境變數。
  • env_delete # 當 env_reset 為 enable 時要移除的一系列環境變數。
1
2
3
4
5
6
7
8
9
env_reset   If set, sudo will reset the environment to only contain
the LOGNAME, SHELL, USER, USERNAME and the SUDO_*
variables. Any variables in the caller’s environment
that match the `env_keep` and `env_check` lists are then
added. The default contents of the `env_keep` and
`env_check` lists are displayed when sudo is run by root
with the -V option. If the secure_path option is set,
its value will be used for the PATH environment
variable. This flag is on by default.

解決方法

最簡單的方法加上-E, 將 User 的環境變數先載入

1
2
3
4
The -E (preserve environment) option indicates to the security policy 
that the user wishes to preserve their existing environment variables.
The security policy may return an error if the -E option is specified
and the user does not have permission to preserve the environment.

參考

(fin)

[生活筆記] 我對單元測試的想法

問題

你對所謂 “單元” 測試的想法爲何。

想法

單元測試是一種有效的方法、手段、工具 etc…
目的是為了「品質」,從兩個角度來說:

  1. 對客戶來說: 功能符合需求
  2. *對開發者來說: 好擴充易修改

額外的好處:

  • 快速揪錯、快速回饋 : 如果團隊有執行 UT 或是 TDD 的習慣,在開發過程中就可以發現部份的錯誤
  • Test Case 就是文件、就是 Use Case
  • TDD 要從 UT 開始寫,讓開發者優先考慮與其互動的 Service 或 Module 會怎麼使用這個方法
  • UT 是重構的基礎,有了 UT 作保護網,可以大膽重構
  • *重構才能往 Design Pattern 的方向走

Mindset

如上圖,
但是我現在卡在 Design 的部份,
我可以讓測試趨動「開發」,
但是沒有辦法產生良好的設計。

所以接觸了 DDD (一知半解的狀態),
然後想到以前學過的 Design Pattern ,但是實務上並沒有套用得很靈活,
很多時候為了 DP 而 DP, 而重構我可以作到小幅度的重構, 精簡程式碼
但是如果要重構成另一個 Pattern 時就又有點卡住了,所以我想我可能沒有掌握住軟體 Design 的技巧

反思與小結

最近參加了 Implementing Domain-driven Design 的讀書會,
像是導師所說,學習了單元測試與 TDD 後,
試著應用在實務上還是有所困難的,
所以我刻意建立了一些專案用來學習。

Test First 或 TDD 不應該省略設計的部份,
Domain-driven Design 常被縮寫成 DDD,
TDD 則為 Test-Driven Development
而當華語文人士整天說著 ATDD、BDD、DDD 與 TDD 時,
有注意到這個 D(Design) 不是 D(Development) 嗎 ?

進一步來說,TDD 的要求開發之前先寫測試,意味著要先寫測試案例,
這個步驟會讓你思考「你要怎麼呼叫你的代碼」,也就是說「你要如何設計的代碼」,

接下來,我會用一個購物車的開發作為案例,
試著用這個過程找到自已的盲點。
購物車 Sample

購物車的畫面如上,我會使用 C# 的 ASP.NET Core 進行開發,
雖然我會延用 ASP.NET 所提供的 MVC 框架,但我也會試著使用 DDD 的概念去設計,
我會設計一系列的 Domain Model 並且使用 Domain Service 作為隔離,
MVC 的 Controller 我會視為 DDD 的 Application Service,
這裡會出現 View Model,不同於 Domain Model,View Model 由 UI 所需要的資料決定。
更多的細節會記錄在後續的筆記當中。

DDD

(fin)

[實作筆記] SonarCloud Move analysis to Java 11 更新 Github Action

前情提要

長期使用的 SonarCloud 的 Github Action 突然執行失敗了.
原因是 SonarCloud 在 2021 年的 2 月 1 號開始,不再支援使用舊的 Java 版本(1.8.0_282),
至少要更新至 Java 11.
錯誤的訊息如下:

1
2
3
4
5
6
7
8
9
10
11
INFO: ------------------------------------------------------------------------

The version of Java (1.8.0_282) you have used to run this analysis is deprecated and we stopped accepting it. Please update to at least Java 11.
Temporarily you can set the property 'sonar.scanner.force-deprecated-java-version-grace-period' to 'true' to continue using Java 1.8.0_282
This will only work until Mon Feb 15 09:00:00 UTC 2021, afterwards all scans will fail.
You can find more information here: https://sonarcloud.io/documentation/upcoming/

ERROR:
The SonarScanner did not complete successfully
08:24:36.417 Post-processing failed. Exit code: 1
Error: Process completed with exit code 1.

調整方法

移除掉原本的 SonarScanner

1
2
3
4
5
- name: SonarScanner Begin
run: dotnet sonarscanner begin /k:"Marsen.NetCore.Dojo" /o:"marsen-github" /d:"sonar.host.url=https://sonarcloud.io" /d:"sonar.login="$SONAR_LOGIN
## 中略
- name: SonarScanner End
run: dotnet sonarscanner end /d:"sonar.login="$SONAR_LOGIN

在整個方案的根目錄加上一個檔案sonar-project.properties,

1
2
3
4
5
6
sonar.organization=<replace with your SonarCloud organization key>
sonar.projectKey=<replace with the key generated when setting up the project on SonarCloud>

# relative paths to source directories. More details and properties are described
# in https://sonarcloud.io/documentation/project-administration/narrowing-the-focus/
sonar.sources=.

sonar.organization 要如何取得呢 ?
登入 SonarCloud, 右上角頭像 > My Organizations 即可查詢到 Organization Key 值。
Organization Key

上方 My Projects > Administration > Update Key
即可查詢到 Project Key 值
Project Key

最後在 Github Action Workflow 加上這段,Github Action 就復活啦

1
2
3
4
- uses: sonarsource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

參考

(fin)

[活動筆記] Role Expectations Matrix 角色期望矩陣

前情提要

自從開始接觸 Scrum 後, Fun Retrospectives 一直是我常常來作為參考的工具網站.
在工作上也會實際的運用, 在這裡特別記錄.
當然相同的方法在不同團隊使用, 我想會產生不同的效果,
所以也許未會有重複的活動也不一定.

角色期望矩陣

在 Fun Retrospectives 的分類之中, 是屬於 Team Building 的類型,
目的在於揭露團隊成員對不同角色的期望.
這個可以協助團隊同步對不同角色與自我角色的認知, 並且避免隱藏與未知的衝突.

進行的方式,

  1. 建立一列(橫軸)寫下所有(團隊中)的角色
  2. 建立一行(直軸)寫下所有(團隊中)的角色
  3. 承上, 畫出一個矩陣
  4. 詢問所有團隊成員, 請他們對所有角色的期望, 並且逐列(橫向貼)到白板之上
  5. 逐行討論所有角色的便利貼, 讓角色理解團隊的期待

A 社記錄

我額外作了一件事,
就是確定團隊成員中的角色, 主要原因是 A 社的 PO 功能是職責是有殘缺的.
討論過程

討論的結果如下:

Dev

  • 依需求/Spec/DoD 實作
  • 溝通
  • 拆分任務
  • 自我測試/驗收
  • 重構
    • 架構不符需求時
    • 效能
      • 時間
      • 空間
      • 開發效能
  • 研究

QA

  • 測試
  • 測試計劃
    • 環境建立
    • 寫測試案例
    • 風險評估
    • 優先序
  • 自動化測試

PO

  • 需求
  • 設定優先序
  • 設定 Sprint Goal/Product Goal
  • 溝通
  • 評估需求可行性

這裡有一個討論的觀點, 我覺得值得拿出來討論,
在探索的過程中, 有可能有以下幾種狀況

  • 實際上有解法,有找到:這是最理想,也是最普遍的方案
  • 實際上沒有解法,証實沒有:確定不可行,其實對產品也是一個重要的資訊,可以節省成本提早作出變化
  • 實際上有解法,沒有找到:這是最糟的情況
  • 實際上沒有解法,確說有解決方法:不是大好就是大壞,容我下面解釋

我原本認為, 如果實際上沒有解法, 卻研究出了解法, 可能是在溝通的過程中可能出現的了認知落差?
更嚴重一點, 也許是出現虛偽狡詐的情況. 但是團隊討論時, 出現了一種新的觀點,
現在作不到, 不表示未來作不到, 市場也許正好缺乏這個的解決方案.

反思一下,這不正是許多新創在募資時常用的方式,
當然不乏惡血、Juicero 或是 Nikola 這一類招謠撞騙的例子,
但是我們可以發現投資人其實是會為夢想買單的.
而要實現理論上可行, 但現實尚未成功或商品化的功能,往往是很燒錢的事.
新創產業正式將夢想與資金作結合, 而敏捷開發能加速我們探索未知.

有關於 PO 的探索, 會跑在 Dev 之前, 其中有一個很重要的目的是要提高 Dev 的勝率
排除「實際上沒有解法」或是「沒有市場價值的方案」是 PO 在探索重點方向.

討論結果

參考

(fin)

[實作筆記] Storybook CI 使用 Github Actions

前情提要

前一篇文章中,
我們使用 TypeScript 開發 React Components ,
並使用 Storybook 作為測試的工具。

這篇會介紹如何與 chromatic 作結合,讓 CI/CD 運行時(本文將使用 Github Actions 作為 CI Server),
自動部署到 chromatic,同時提供自動化測試與人工審核的功能。

環境設置

使用 Github 登入 Chromatic,
雖然 Chromatic 也有提供 Bitbucket 與 GitLab 的登入方式,
但並不確定這些 CI Server 包含 Jenkins、TravisCI 或是 CircleCI 實際上怎麼結合 Storybook,
以下都以 Github 作介紹,

本機環境

安裝 chromatic

1
yarn add -D chromatic

發佈 Storybook 到 Chromatic 上

1
yarn chromatic --project-token=<project-token>

發佈完成你可以得到一個網址 https://www.chromatic.com/builds?appId=random
你可以分享網址給同事,對 UI 進行審查.
讓 Pull Request 時,自動執行的設定

雲端設定

新增專案後,可以取得 Token

新增 Project
取得 Token

在專案中設定 yaml 檔(Github Actions)
加上 .github/workflows/chromatic.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# .github/workflows/chromatic.yml
# name of our action
name: "Chromatic Deployment"
# the event that will trigger the action
on:
# Trigger the workflow on push or pull request,
# but only for the main branch
push:
branches:
- main
# what the action will do
jobs:
test:
# the operating system it will run on
runs-on: ubuntu-latest
# the list of steps that the action will go through
steps:
- uses: actions/checkout@v2
- run: cd src/marsen.react && yarn && yarn build && yarn build-storybook
- uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
storybookBuildDir: storybook-static

特殊設定,子專案

如何你和我一樣, 專案是由多個子專案組成,
那麼預設的 yaml 設定可能就不適合你.
可以參考這個 issue,
其中要特別感謝 yigityuce 的 solution,
我特別 fork 到我的 Github 帳號底下 Repo
設定調整如下:

1
2
3
4
5
6
7
8
9
# 上略
steps:
- uses: actions/checkout@v2
- run: cd src/marsen.react && yarn && yarn build && yarn build-storybook
- uses: marsen/chromatic-cli@v1
with:
workingDir: ./src/marsen.react
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
# 下略

驗收

如下圖, 左方會顯示舊版的 UI 畫面, 右方會顯示新版的 UI 畫面,
如果開啟 Diff 功能(右上角的眼鏡圖示),
即可以進行差異比對, 有差異的地方將以亮綠色顯示,
如果認同這次的變更, 選擇右上角的 Accept 反之, 選擇 Deny.
驗收

參考

(fin)

[實作筆記] React 與 Storybook 開發

前情提要

不論是 App 或是 Web, 與使用者第一線互動的就是 UI 了。
另一面在需求設計上, 我們總會想像一個畫面,
想像著使用者如何使用我們的產品,
也就是說 UI 是理想與真實的邊界。

Designer 完成了設計, Engineer 將之實作出來,
主流的開發方式會透過 Component 來節省時間。

為什麼我們需要 Storybook ?

但是真的能節省時間嗎 ?

開發人員彼此之間會不會重複造輪子? 他們又要怎麼溝通?
修改到底層元件會不會影響到上層元件? 會不會改 A 壞 B?
複雜的 Component, 特殊的情境如何測試 ?

Storybook 恰恰能解決這些問題,

  • 作為開發人員的指南和文件
  • 獨立於應用程式建立 Component
  • 測試特殊情境

對我來說,最重要的事,我可以用類似 TDD 的方式開發,
在 Storybook 的官方文件提到這個方法為 CDD.
在 TDD 中我們把一個個 Use Case 寫成 Test Case,
我們可以挪用這個觀念,
在 Storybook 中把每一個 Component 的各種狀態(State),
當作 Use Case, 然後透過 Mock State 讓 Component 呈現該有的樣貌。

心得

大前端的時代,僅僅只看 Web 的話,
我認為這個時代前端的重心就在兩個主要的技術之上,
Component 與 State Management。
而實作你可以有以下的選擇,
僅介紹我聽過的主流 Library,
Component 與 State Management 沒有絕對的搭配關係。

Component State Management
React Flux
Angular Redux
Vue Akita

改編 Storybook 教程

為什麼要改編 Storybook 教程(React Version) ?

這個教程會以一個簡單的 Todo List,
從創建應用程式、簡單的 Component 到複雜,
與狀態管理器介接, 測試到部署。

但是他缺了一味,TypeScript,
所以我自已用 TypeScript 進行了改寫並稍作一下記錄。

環境

開始

設定初始化的環境

設定 React Storybook

開啟命令提示視窗,執行以下命令以創建 React App

1
2
3
4
# Create our application:
npx create-react-app taskbox

cd taskbox

安裝 Storybook

1
2
3
4
npm i storybook

# Add Storybook:
npx -p @storybook/cli sb init

啟動開發環境的 Storybook,

1
2
# Start the component explorer on port 6006:
yarn storybook

測試與執行

1
2
3
4
5
# Run the test runner (Jest) in a terminal:
yarn test --watchAll

# Run the frontend app proper on port 3000:
yarn start

npm Storybook

下載 CSS,存檔至 src/index.css

安裝 degit

1
npm i degit

加入 Add assets (字型與 Icon)

1
2
npx degit chromaui/learnstorybook-code/src/assets/font src/assets/font
npx degit chromaui/learnstorybook-code/src/assets/icon src/assets/icon

Git Commit

1
2
> git add .
> git commit -m "first commit"

簡單的 component

src/components/ 資料夾建立 component Task.js

1
2
3
4
5
6
7
8
9
10
11
// src/components/Task.js

import React from 'react';

export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
return (
<div className="list-item">
<input type="text" value={title} readOnly={true} />
</div>
);
}

建立 Task.stories.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/components/Task.stories.js

import React from 'react';
import Task from './Task';

export default {
component: Task,
title: 'Task',
};

const Template = args => <Task {...args} />;

export const Default = Template.bind({});
Default.args = {
task: {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX',
updatedAt: new Date(2018, 0, 1, 9, 0),
},
};

export const Pinned = Template.bind({});
Pinned.args = {
task: {
...Default.args.task,
state: 'TASK_PINNED',
},
};

export const Archived = Template.bind({});
Archived.args = {
task: {
...Default.args.task,
state: 'TASK_ARCHIVED',
},
};

隨時你都可以執行 yarn storybook 試跑來看看 storybook
調整 Storybook 的 config 檔 (.storybook/main.js)

1
2
3
4
5
6
7
8
9
10
// .storybook/main.js

module.exports = {
stories: ['../src/components/**/*.stories.js'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/preset-create-react-app',
],
};

(.storybook/preview.js) 這設定為了 log UI 上的某些操作產生的事件,
在之後我們會看到 完成(onArchiveTask)或置頂(onPinTask) 兩個事件

1
2
3
4
5
6
7
8
// .storybook/preview.js

import '../src/index.css';

// Configures Storybook to log the actions(onArchiveTask and onPinTask) in the UI.
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
};

調整 Task.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// src/components/Task.js

import React from 'react';

export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
return (
<div className={`list-item ${state}`}>
<label className="checkbox">
<input
type="checkbox"
defaultChecked={state === 'TASK_ARCHIVED'}
disabled={true}
name="checked"
/>
<span className="checkbox-custom" onClick={() => onArchiveTask(id)} />
</label>
<div className="title">
<input type="text" value={title} readOnly={true} placeholder="Input title" />
</div>

<div className="actions" onClick={event => event.stopPropagation()}>
{state !== 'TASK_ARCHIVED' && (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a onClick={() => onPinTask(id)}>
<span className={`icon-star`} />
</a>
)}
</div>
</div>
);
}

加入測試用的外掛(add on)

1
yarn add -D @storybook/addon-storyshots react-test-renderer

執行測試

1
> yarn test

測試結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
yarn run v1.22.0
$ react-scripts test
(node:52888) DeprecationWarning: \`storyFn\` is deprecated and will be removed in Storybook 7.0.

https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-storyfn
PASS src/components/storybook.test.js (14.703 s)
Storyshots
Task
√ Default (13 ms)
√ Pinned (2 ms)
√ Archived (1 ms)

› 3 snapshots written.
Snapshot Summary
› 3 snapshots written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 3 written, 3 total
Time: 16.716 s
Ran all test suites related to changed files.

簡單的 component 改用 typescript

首先,Task.js 調整副檔名為 Task.tsx
Task.stories.jsTask.stories.tsx.
測試檔案 storybook.test.js 也一併修改 storybook.test.ts

並修改 .storybook/main.js

1
2
3
4
module.exports = {
stories: ['../src/components/**/*.stories.tsx'],
/// 略…
};

建立 tsconfig.json

1
> tsc --init

用 TypeScript 改寫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// src/components/Task.tsx
import React from 'react';

export enum TaskState{
Inbox = 'TASK_INBOX',
Pinned = 'TASK_PINNED',
Archived = 'TASK_ARCHIVED'
}

export interface TaskArgs {
item:TaskItem,
onArchiveTask: (id:string) => void,
onPinTask: (id:string) => void
}

export class TaskItem{
id: string = ''
title: string = ''
state: TaskState = TaskState.Inbox
updatedAt?: Date
}

export default function Task(args:TaskArgs) {
return (
<div className={`list-item ${args.item.state}`}>
<label className="checkbox">
<input
type="checkbox"
defaultChecked={args.item.state === TaskState.Archived}
disabled={true}
name="checked"
/>
<span className="checkbox-custom" onClick={()=>args.onArchiveTask(args.item.id)} />
</label>
<div className="title">
<input type="text" value={args.item.title} readOnly={true} placeholder="Input title" />
</div>

<div className="actions" onClick={event => event.stopPropagation()}>
{args.item.state !== TaskState.Archived && (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a onClick={()=>args.onPinTask(args.item.id)}>
<span className={`icon-star`} />
</a>
)}
</div>
</div>
);
}

改寫 Task.store.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// src/components/Task.stories.tsx

import React from 'react';
import Task, { TaskItem, TaskArgs, TaskState } from './Task';
import { Story } from '@storybook/react/types-6-0';

export default {
component: Task,
title: 'Task',
};

const Template:Story<TaskArgs> = args => <Task {...args} />;

var defaultItem:TaskItem = {
id:'1',
title:'Test Task',
state:TaskState.Inbox,
updatedAt: new Date(2018, 0, 1, 9, 0),
};

export const Default = Template.bind({});
Default.args = { item: defaultItem, };

export const Pinned = Template.bind({});
var pinnedItem = Copy(defaultItem);
pinnedItem.state=TaskState.Pinned
Pinned.args = { item: pinnedItem };

export const Archived = Template.bind({});
var archivedItem = Copy(defaultItem);
archivedItem.state=TaskState.Archived;
Archived.args = {item: archivedItem};

function Copy(obj:any) {
return Object.assign({},obj);
}

組合成複雜的 component (TypeScript 版本)

與教程最主要的不同之處在於使用了 TypeScript 的語法撰寫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
 // src/components/TaskList.tsx
import React from 'react';
import Task, { TaskItem, TaskState } from './Task';
import { connect } from 'react-redux';
//import { archiveTask, pinTask } from '../lib/redux';

export interface TaskListProps {
loading?:boolean;
tasks: TaskItem[];
onArchiveTask: (id:string)=>void;
onPinTask: (id:string)=>void;
}

export function PureTaskList(props:TaskListProps) {
const events = {
onArchiveTask:props.onArchiveTask,
onPinTask:props.onPinTask,
};

const LoadingRow = (
<div className="loading-item">
<span className="glow-checkbox" />
<span className="glow-text">
<span>Loading</span> <span>cool</span> <span>state</span>
</span>
</div>
);

if (props.loading) {
return (
<div className="list-items">
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
</div>
);
}

if (props.tasks === undefined || props.tasks.length === 0) {
return (
<div className="list-items">
<div className="wrapper-message">
<span className="icon-check" />
<div className="title-message">You have no tasks</div>
<div className="subtitle-message">Sit back and relax</div>
</div>
</div>
);
}

const tasksInOrder = [
...props.tasks.filter(t => t.state === TaskState.Pinned), //< ==== 固定頂部
...props.tasks.filter(t => t.state !== TaskState.Pinned),
];


return (
<div className="list-items">
{tasksInOrder.map(item => (
<Task key={item.id} item={item} {...events}/>
))}
</div>
);
}

export default connect(
(props:TaskListProps) => ({
tasks: props.tasks.filter(t => t.state === TaskState.Inbox || t.state === TaskState.Pinned ),
})
)(PureTaskList);

TaskList.stories.tsx 設置,也是使用 TypeScript 撰寫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// src/components/TaskList.stories.tsx

import React from 'react';
import { TaskList, TaskListArgs } from './TaskList';
import { TaskItem, TaskState } from './Task';
import { Story } from '@storybook/react/types-6-0';

export default {
component: TaskList,
title: 'TaskList',
decorators: [(story: () => React.ReactNode) => <div style={{ padding: '3rem' }}>{story()}</div>],
excludeStories: /.*Data$/,
};

const Template:Story<TaskListArgs> = args => <TaskList {...args} />

var defaultItem:TaskItem = {
id:'1',
title:'Test Task',
state:TaskState.Inbox,
updatedAt: new Date(2018, 0, 1, 9, 0)
};

export const Default = Template.bind({});
Default.args = {
tasks: [
{ ...defaultItem, id: '1', title: 'Task 1' },
{ ...defaultItem, id: '2', title: 'Task 2' },
{ ...defaultItem, id: '3', title: 'Task 3' },
{ ...defaultItem, id: '4', title: 'Task 4' },
{ ...defaultItem, id: '5', title: 'Task 5' },
{ ...defaultItem, id: '6', title: 'Task 6' },
],
};

export const WithPinnedTasks = Template.bind({});
WithPinnedTasks.args = {
tasks: [
...Default.args.tasks!.slice(0,5),
{ id: '6', title: 'Task 6 (pinned)', state: TaskState.Pinned },
],
};

export const Loading = Template.bind({});
Loading.args = {
tasks: [],
loading: true,
};

export const Empty = Template.bind({});
Empty.args = {
...Loading.args,
loading: false,
};

介接 Store 資料

建立 Redux

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// src/lib/redux.ts

// A simple redux store/actions/reducer implementation.
// A true app would be more complex and separated into different files.
import { createStore } from 'redux';
import { TaskItem, TaskState } from '../components/Task';

export const archiveTask = (id: string) => {
console.log("archive task:"+id);
return ({ type: TaskState.Archived, id })
};

export const pinTask = (id: string) => {
console.log("pin task:"+id);
return ({ type: TaskState.Pinned, id })
};

// The reducer describes how the contents of the store change for each action
export const reducer = (state: any, action: { id:string; type: TaskState; }) => {
switch (action.type) {
case TaskState.Archived:
case TaskState.Pinned:
return taskStateReducer(action.type)(state, action);
default:
return state;
}
};

// The initial state of our store when the app loads.
// Usually you would fetch this from a server
const defaultTasks:Array<TaskItem> = [
{ id: '1', title: 'Something', state: TaskState.Inbox },
{ id: '2', title: 'Something more', state: TaskState.Inbox },
{ id: '3', title: 'Something else', state: TaskState.Inbox },
{ id: '4', title: 'Something again', state: TaskState.Inbox },
];


// We export the constructed redux store
export default createStore(reducer, { tasks: defaultTasks });

// All our reducers simply change the state of a single task.
function taskStateReducer(taskState: TaskState) {
return (state: { tasks: TaskItem[]; }, action: { id: string; }) => {
return {
...state,
tasks: state.tasks.map(task =>
task.id === action.id ? { ...task, state: taskState } : task
),
};
};
}

修改 TaskList.tsx 視作一個 container 與 redux 作介接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/components/TaskList.tsx
import React from 'react';
import Task, { TaskItem, TaskState } from './Task';
import { connect } from 'react-redux';
import { archiveTask, pinTask } from '../lib/redux';

// 中略...

export default connect(
(props:TaskListArgs) => ({
tasks: props.tasks.filter(t => t.state === TaskState.Inbox || t.state === TaskState.Pinned ),
}),
dispatch => ({
onArchiveTask: (id: string) => dispatch(archiveTask(id)),
onPinTask: (id: string) => dispatch(pinTask(id)),
}))(TaskList);

加上 Page InboxScreen

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//src/components/InboxScreen.js

import React from 'react';
import { connect } from 'react-redux';
import TaskList from './TaskList';

export class InboxScreenArgs {
error:string | undefined
}

export function InboxScreen(args:InboxScreenArgs) {
if (args.error) {
return (
<div className="page lists-show">
<div className="wrapper-message">
<span className="icon-face-sad" />
<div className="title-message">Oh no!</div>
<div className="subtitle-message">Something went wrong</div>
</div>
</div>
);
}

return (
<div className="page lists-show">
<nav>
<h1 className="title-page">
<span className="title-wrapper">TaskBox</span>
</h1>
</nav>
<TaskList />
</div>
);
}

export default connect((props:InboxScreenArgs) => (props))(PureInboxScreen);

一樣也加上 Story ,InboxScreen.stories.tsx
讓我們可以透過 Storybook 作人工 E2E 測試

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//src/components/InboxScreen.stories.tsx

import React from 'react';
import { Provider } from 'react-redux';
import { InboxScreenArgs, InboxScreen } from './InboxScreen';
import { Story } from '@storybook/react/types-6-0';
import store from '../lib/redux'

export default {
component: InboxScreen,
decorators: [(story: () => React.ReactNode) => <Provider store={store}>{story()}</Provider>],
title: 'InboxScreen',
};

const Template:Story<InboxScreenArgs> = args => <PureInboxScreen {...args} />;

export const Default = Template.bind({});

export const Error = Template.bind({});
Error.args = {
error: 'Something',
};

完整代碼可以參考此處

參考

(fin)

[上課筆記] 針對遺留代碼加入單元測試的藝術

題目

  • 聽到需求的時候,不要先想邏輯,先想怎麼驗收 ?

  • OO 善用封裝繼承的特性,不要修改 Product 類別,不要修改測試方法

  • Extract and Override 的 SOP

    • 找到 Dependency
    • Extract Method
    • private → protected
    • 建立 SUT 的子類
    • Override Dependency
    • Create Setter
    • 改測試
    • Assign 依賴值
    • 無法適用的情境
      • final
      • static
  • SPR 的 anti Pattern

    • 職責過多
    • 單元切得過細
  • 測試案例為什麼用 Snake 寫法?

    • 測試案例是用來描述情境
    • 反思:中文表達力強但描述精準度低
    • 要看見測試案例的因果關係
    • 測試案例的設計
      • 2+2 = 4
      • 2x2 = 4
      • 2^2 = 4
  • 重構測試案例

    • 只呈現與情境相關的資訊
  • 養成用 Domain 情境而不是 Code Level 的語言

  • 不要為了測試把 Production Code 搞得複雜

  • Mock Framework 的本質與要解決的問題是什麼

  • 3A原則與Given When Then

  • 減少 Content Switch(利用工具)

  • 時間要花在刀口上

    • Unit Test
    • Pair Programming
    • Code Review
    • 整合測試
    • QA 測試
    • Alpha/Beta 測試
    • 部署策略
  • Test Anti-Patter

    • 小心過度指定(使用 Argument Matcher
    • 測試案例之間相依
    • 一次測不只一件事(Data Driven)
    • 測試案例名字過於實作細節
  • 不要為了寫測試使用 Virtual

  • StoreProcedure 的測試不是單元測試,但是很好測試,只有與 Table 相依,透過 ORM 可以輕易作到,

Nice to Have

  • Coverage

    • Missing Test Cases
    • Dead Code
    • 實例分享:大家都不知道*
    • 童子軍法則(趨勢>數字)
    • > 0%
  • ROI

    • 出過問題
    • 經常變動的
      • 對不會變動且運作正確的程式寫測試是種浪費
      • 共用的模組
      • 商業價值
  • 架構設計

    • 三層式架構
    • 六角架構

課程總覽與個人的建議順序

課程 說明 補充
熱血 Coding Dojo 活動 點燃動機的一堂課 對我個人影響最大的一堂課
極速開發 學過這堂課,會比較清楚 91 的日常開發是怎麼作的 這裡是一個檻,你需要學習 Vim 也可能需要買好一點的 IDE
針對遺留代碼加入單元測試的藝術 單元測試的基本概念 強烈建議讀過「單元測試的藝術」與 91的「30天快速上手TDD」雖然有點久遠,但是好東西是經得起年歲的
演化式設計:測試驅動開發與持續重構 2日課程,如果只能上一堂課的話,我會選這堂 資訊量很大的課程,不過如果上過前面的課,這時候應該可以苦盡甘來
Clean Coder:DI 與 AOP 進階實戰 進階課程 如果上過前面的課,這時候應該可以苦盡甘來,但是建議可以對 Design Patter 稍作功課

反思:如果是我會怎麼設計內訓課程?

  • 附錄課程

    • 為什麼寫測試?
    • 第一個測試(計算器或 FizzBuzz)
    • 5 種假物件(Test Double)
      • Stub(模擬回傳)/Mock(前置驗証)/Fake/Spy:驗証互動(後置驗証)/Dummy
      • 小心不要落入名詞解釋
  • 主要課程(假設學生已經有一定的理解)

    • 第一個測試
      • 3A原則
    • 3正1反
    • 三種互動
      • 呼叫次數,參數,順序
      • 狀態改變
      • 回傳值
    • 隔離相依
      • 原生操作
      • 使用框架
      • 測試案例
        • 可讀性:增進理解 / 規格書 / 穿越時空的傳承
    • TDD
    • 重構
    • Design Pattern

課程上的反思

  • Setter 的重構要加強
  • 依賴注入點,不一定要在建構子也不一定要在 Prod (抽方法)
  • 要有能力判斷切入點
    • 善用工具
    • 練習
  • C# 參數善用Optional
  • OOP & UT
  • Fp & UT
  • UT & Refactor & Design Pattern
  • Pattern 工廠/簡單工廠/
  • 存在監控 Running Code 計算方法呼叫次數的工具嗎 ?
  • 快速打出六個0 ?

參考

(fin)

Please enable JavaScript to view the LikeCoin. :P