Post

Strategy Design Pattern For Mail Sending

Strategy Design Pattern For Mail Sending

這次整理 TsLib.mail.MailSender,一開始以為只是 Strategy Pattern:正式環境寄給原本收件人,測試環境改寄測試收件人並加上測試主旨前綴。

但做到 TsNoticeReminder 之後,問題變得更精確:這不是單純 Strategy,而是 Strategy + Adapter

  • Strategy:MailDeliveryPolicy 決定實際收件人與主旨怎麼產生。
  • Adapter:Spring CLI 把 Spring DataSource 轉成 TsLib DbConnectionProvider
  • TsLib:集中查 TEST_CONFIG,避免每個專案各自寫 SQL。

原本的問題

舊版 MailSender 會在寄信時自己查:

1
new TestConfigDao().isTest()

這在 TsMgr 這種 Web/JNDI 專案可以運作,因為舊程式預設可用 java:comp/env/jdbc/ntnupmb

但同一個 TsLib 後來同時被三種 runtime 使用:

runtime專案連線來源
Web/JNDITsMgrTomcat JNDI
純 CLITsNoticeAutoDo啟動參數選出的 DbConnectionProvider
Spring CLITsNoticeReminderSpring 管理的 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。DataSourceDbConnectionProvider 是 Adapter;fromTestConfig(...) 最後產生的仍然是 MailDeliveryPolicy strategy。

TEST_CONFIG 要放哪裡

這次調整後,TEST_CONFIG 的分工變成:

責任放在哪裡原因
SELECT IS_TEST FROM TEST_CONFIGTsLib TestConfigDao這是共用資料表語意,不該散在各專案
IS_TEST 非空白代表測試環境TsLib TestConfigDao判斷規則集中,避免各專案解讀不同
正式/測試寄送資料怎麼轉換TsLib MailDeliveryPolicies寄送 policy 可共用
Spring DataSource 怎麼取得 connectionTsNoticeReminder這是 Spring runtime 細節
CLI 要連哪個 DBTsNoticeAutoDo這是純 CLI 啟動參數決定
舊 Web/JNDI fallbackTsLib 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目前做法是否明確傳 policyTEST_CONFIG SQL 在哪
Web/JNDI:TsMgrnew MailSender(receivers, subject, content)否,暫走 legacyTsLib legacy 透過舊 JNDI DAO
純 CLI:TsNoticeAutoDoMailDeliveryPolicies.fromTestConfig(ntnupmbConfig)TsLib TestConfigDao
Spring CLI:TsNoticeReminderMailDeliveryPolicies.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 的 TestConfigDaoTEST_CONFIG,再產生對應 policy。

TsNoticeReminder 的 DataSource -> DbConnectionProvider 是 Adapter。它只轉接 Spring runtime 的連線介面,不負責解讀 TEST_CONFIG

這樣分工後,TsLib 集中共用語意;各 runtime 只提供自己的連線來源或暫時沿用 legacy JNDI。

This post is licensed under CC BY 4.0 by the author.