Strategy Design Pattern For Mail Sending
這次整理 TsLib.mail.MailSender,一開始以為只是 Strategy Pattern:正式環境寄給原本收件人,測試環境改寄測試收件人並加上測試主旨前綴。
但做到 TsNoticeReminder 之後,問題變得更精確:這不是單純 Strategy,而是 Strategy + Adapter。
- Strategy:
MailDeliveryPolicy決定實際收件人與主旨怎麼產生。 - Adapter:Spring CLI 把 Spring
DataSource轉成 TsLibDbConnectionProvider。 - TsLib:集中查
TEST_CONFIG,避免每個專案各自寫 SQL。
原本的問題
舊版 MailSender 會在寄信時自己查:
1
new TestConfigDao().isTest()
這在 TsMgr 這種 Web/JNDI 專案可以運作,因為舊程式預設可用 java:comp/env/jdbc/ntnupmb。
但同一個 TsLib 後來同時被三種 runtime 使用:
| runtime | 專案 | 連線來源 |
|---|---|---|
| Web/JNDI | TsMgr | Tomcat JNDI |
| 純 CLI | TsNoticeAutoDo | 啟動參數選出的 DbConnectionProvider |
| Spring CLI | TsNoticeReminder | Spring 管理的 DataSource |
如果 MailSender 自己判斷目前要怎麼查 TEST_CONFIG,它就會被迫知道 JNDI、純 CLI、Spring CLI 三種連線模型。這不合理,因為 MailSender 的責任應該是寄信,不是判斷呼叫端怎麼拿資料庫連線。
原本很容易走向這種寫法:
1
2
3
4
5
6
7
8
9
10
11
public boolean sendWithResult() {
if (isWebJndi()) {
applyLegacyJndiTestRule();
} else if (isPlainCli()) {
applyPlainCliTestRule();
} else if (isSpringCli()) {
applySpringTestRule();
}
sendMimeMessage();
}
這段的問題不是有 if,而是環境判斷被塞進共用寄信工具。之後每多一種 runtime,MailSender 就要再知道更多外部細節。
Strategy 負責寄送規則
在 Refactoring.Guru Strategy 這邊有講到:
1
define a family of algorithms, put each of them into a separate class
套到這次案例,這組可替換演算法不是路線規劃,而是寄信前的資料準備規則:
production():保留原始收件人與主旨test(...):改成測試收件人,並加上測試-主旨前綴
MailSender 只持有策略介面:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class MailSender {
private final MailDeliveryPolicy deliveryPolicy;
public boolean sendWithResult() {
MailDelivery delivery = deliveryPolicy.prepare(mailTo, subject);
for (String to : delivery.recipients()) {
message.addRecipients(RecipientType.TO, InternetAddress.parse(to));
}
message.setSubject(delivery.subject(), StandardCharsets.UTF_8.name());
Transport.send(message);
return true;
}
}
策略介面很小:
1
2
3
4
@FunctionalInterface
public interface MailDeliveryPolicy {
MailDelivery prepare(List<String> recipients, String subject);
}
正式寄送策略:
1
2
3
public static MailDeliveryPolicy production() {
return MailDelivery::new;
}
測試寄送策略:
1
2
3
4
public static MailDeliveryPolicy test(List<String> testRecipients) {
List<String> recipients = List.copyOf(testRecipients);
return (originalRecipients, subject) -> new MailDelivery(recipients, "測試-" + subject);
}
這樣 MailSender 不需要知道現在是 Web、純 CLI,還是 Spring CLI。它只知道寄信前呼叫 deliveryPolicy.prepare(...)。
Adapter 負責接 runtime
後來發現另一個問題:如果 Spring CLI 自己寫一個 Spring 版 TestConfigDao,那 SELECT IS_TEST FROM TEST_CONFIG 就散到 TsNoticeReminder 裡。這樣雖然避開了 TsLib 依賴 Spring,但 TEST_CONFIG 的查詢語意沒有真正集中。
在 Refactoring.Guru Adapter 這邊有講到:
1
allows objects with incompatible interfaces to collaborate
這句話很適合 TsNoticeReminder。Spring CLI 有的是標準 JDBC DataSource:
1
javax.sql.DataSource
TsLib DAO 需要的是:
1
2
3
4
@FunctionalInterface
public interface DbConnectionProvider {
Connection getConnection() throws DbConnFailException;
}
所以 Spring CLI 不需要自己查 TEST_CONFIG,只要做一個很薄的轉接:
1
2
3
4
5
6
7
private Connection getConnection() throws DbConnFailException {
try {
return dataSource.getConnection();
} catch (SQLException ex) {
throw new DbConnFailException("資料庫連線失敗: " + ex.getMessage());
}
}
然後呼叫 TsLib:
1
2
3
MailDeliveryPolicy createDeliveryPolicy() {
return MailDeliveryPolicies.fromTestConfig(this::getConnection, properties.testRecipients());
}
這裡沒有捨棄 Strategy。DataSource 轉 DbConnectionProvider 是 Adapter;fromTestConfig(...) 最後產生的仍然是 MailDeliveryPolicy strategy。
TEST_CONFIG 要放哪裡
這次調整後,TEST_CONFIG 的分工變成:
| 責任 | 放在哪裡 | 原因 |
|---|---|---|
SELECT IS_TEST FROM TEST_CONFIG | TsLib TestConfigDao | 這是共用資料表語意,不該散在各專案 |
IS_TEST 非空白代表測試環境 | TsLib TestConfigDao | 判斷規則集中,避免各專案解讀不同 |
| 正式/測試寄送資料怎麼轉換 | TsLib MailDeliveryPolicies | 寄送 policy 可共用 |
Spring DataSource 怎麼取得 connection | TsNoticeReminder | 這是 Spring runtime 細節 |
| CLI 要連哪個 DB | TsNoticeAutoDo | 這是純 CLI 啟動參數決定 |
| 舊 Web/JNDI fallback | TsLib legacy method | 保留 TsMgr 相容 |
目前 TsLib 提供兩種 fromTestConfig:
1
public static MailDeliveryPolicy fromTestConfig(DbConnectionProvider dataSource)
給純 CLI 使用預設測試收件人。
1
2
3
4
public static MailDeliveryPolicy fromTestConfig(
DbConnectionProvider dataSource,
List<String> testRecipients
)
給 Spring CLI 保留自己的測試收件人設定,但 SQL 仍在 TsLib。
Mermaid 類別圖
classDiagram
class MailSender {
-MailDeliveryPolicy deliveryPolicy
-List~String~ mailTo
-String subject
+sendWithResult() boolean
}
class MailDeliveryPolicy {
<<interface>>
+prepare(List~String~ recipients, String subject) MailDelivery
}
class MailDeliveryPolicies {
+production() MailDeliveryPolicy
+test(List~String~ testRecipients) MailDeliveryPolicy
+fromTestConfig(DbConnectionProvider dataSource) MailDeliveryPolicy
+fromTestConfig(DbConnectionProvider dataSource, List~String~ testRecipients) MailDeliveryPolicy
+legacyJndiTestConfig() MailDeliveryPolicy
}
class TestConfigDao {
-DbConnectionProvider dataSource
+isTest() boolean
}
class DbConnectionProvider {
<<interface>>
+getConnection() Connection
}
class ReminderMailService {
-DataSource dataSource
-ReminderProperties properties
+sendHtml(...) boolean
~createDeliveryPolicy() MailDeliveryPolicy
}
class ScheduleMailService {
-MailDeliveryPolicy mailDeliveryPolicy
+sendScheduleResultMail(ScheduleRunResult) void
+sendAdminScheduleMail(ExecArgument, ScheduleRunResult) void
}
MailDeliveryPolicies ..> MailDeliveryPolicy : creates strategy
MailDeliveryPolicies ..> TestConfigDao : reads TEST_CONFIG
TestConfigDao ..> DbConnectionProvider : gets connection
ReminderMailService ..> DbConnectionProvider : adapts DataSource
ReminderMailService ..> MailDeliveryPolicies : asks policy
ReminderMailService ..> MailSender : passes policy
ScheduleMailService ..> MailSender : passes policy
為什麼不畫 aggregation
這張圖刻意只在 MailSender 裡寫:
1
-MailDeliveryPolicy deliveryPolicy
不再額外畫 MailSender o--> MailDeliveryPolicy。
在 UML Diagrams 的 Property 說明 這邊有講到:
1
Classifier attribute may represent an association end
也就是說,attribute 本身就可以表達 MailSender::deliveryPolicy : MailDeliveryPolicy。如果同時寫 attribute 又畫一條沒有 role / multiplicity 的 aggregation-style association,容易讓讀者誤會是不是多了一個關係,或誤讀成生命週期所有權。
如果真的想用線條表示,應該二選一:不要在 class 內重複寫 attribute,並在線上標清楚:
1
MailSender --> "1" MailDeliveryPolicy : deliveryPolicy
本文選 attribute-only,因為它最直接對應 Java 欄位。
Interface realization
目前 production()、test(...) 用 lambda / method reference 表達策略,所以圖裡沒有畫具體 class implements MailDeliveryPolicy。
在 IBM 的 Interface realization relationships 這邊有講到:
1
displayed ... as a dashed line with a hollow arrowhead
如果日後策略變複雜,拆成:
1
2
class ProductionMailDeliveryPolicy implements MailDeliveryPolicy
class TestMailDeliveryPolicy implements MailDeliveryPolicy
那 Mermaid 圖就可以畫成具體 class 用虛線空心三角形指向 MailDeliveryPolicy。現在策略很短,用 lambda 反而比較容易閱讀。
三種 runtime 分工
| runtime | 目前做法 | 是否明確傳 policy | TEST_CONFIG SQL 在哪 |
|---|---|---|---|
| Web/JNDI:TsMgr | new MailSender(receivers, subject, content) | 否,暫走 legacy | TsLib legacy 透過舊 JNDI DAO |
| 純 CLI:TsNoticeAutoDo | MailDeliveryPolicies.fromTestConfig(ntnupmbConfig) | 是 | TsLib TestConfigDao |
| Spring CLI:TsNoticeReminder | MailDeliveryPolicies.fromTestConfig(this::getConnection, testRecipients) | 是 | TsLib TestConfigDao |
TsMgr 目前還可以先保留 legacy,因為它本來就是 Web/JNDI,而且同專案其他地方也直接用舊 TestConfigDao 判斷測試環境。要改成明確傳 policy 可以,但那會牽涉 Web/JNDI 的環境注入方式,應該和 TsMgr 裡其他 TestConfigDao 使用點一起整理。
不要這樣解讀
不要把 MailDeliveryPolicies 當成這次的主要 pattern。它有 factory methods 的味道,但核心是建立並交出 MailDeliveryPolicy strategy。
不要說 Spring CLI 自己查 TEST_CONFIG。現在 Spring CLI 只提供 connection,TEST_CONFIG SQL 與 IS_TEST 判斷已回到 TsLib。
不要把 Adapter 和 Strategy 混成同一件事。Adapter 解的是介面銜接問題;Strategy 解的是寄送資料準備行為可替換的問題。
也不要急著把 TsMgr legacy 路線拿掉。它不是最終理想狀態,但在 Web/JNDI 還沒整批整理前,保留舊建構子可以降低影響範圍。
最後重寫成教科書版
MailSender 是 Strategy Pattern 裡的 context。它持有 MailDeliveryPolicy,並在寄信前呼叫 deliveryPolicy.prepare(mailTo, subject)。
MailDeliveryPolicy 是 strategy interface,封裝「正式寄送」與「測試寄送」兩種寄送資料準備規則。
MailDeliveryPolicies.fromTestConfig(...) 負責用 TsLib 的 TestConfigDao 查 TEST_CONFIG,再產生對應 policy。
TsNoticeReminder 的 DataSource -> DbConnectionProvider 是 Adapter。它只轉接 Spring runtime 的連線介面,不負責解讀 TEST_CONFIG。
這樣分工後,TsLib 集中共用語意;各 runtime 只提供自己的連線來源或暫時沿用 legacy JNDI。