工作中常用的設計模式--策略模式

lpe234發表於2022-11-23
一般做業務開發,不太容易有大量使用設計模式的場景。這裡總結一下在業務開發中使用較為頻繁的設計模式。當然語言為Java,基於Spring框架。

1 策略模式(Strategy Pattern)

一個類的行為或方法,在執行時可以根據條件的不同,有不同的策略(行為、方法)去執行。舉個簡單的例子:去上班,可以騎共享單車、可以選擇公交車、也可以乘坐地鐵。這裡的乘坐什麼交通工具就是針對去上班這個行為的策略(解決方案)

策略模式一般有3個角色:

  • Context: 策略的上下文執行環境
  • Strategy: 策略的抽象
  • ConcreteStrategy: 策略的具體實現

這個出現的場景其實還很多。如之前做商城時遇到的登入(手機號、微信、QQ等),及優惠券(滿減券、代金券、折扣券等)。這裡主要講一下最近遇到的兩種。一種是預先知道要走哪個策略,一種是需要動態計算才能確定走哪種策略。

1.1 靜態(引數)策略

在做增長系統時,使用者留資進線需要根據不同來源走不同的處理邏輯。而這種來源,在資料出現時就能確定。

SyncContext
/**
 * 同步上下文
 *
 */
@Data
@Builder
public class SyncContext {
    // 任務ID
    private Long taskId;
    // 任務型別 1: 自然註冊; 2: 團購使用者; 3: 落地頁留資
    private Integer taskType;
    // 所有留資相關資訊(忽略細節)
    private Object reqVO;

    // 儲存執行策略名稱(偽裝執行結果)
    private String respVO;
}
SyncStrategy
/**
 * 同步策略
 *
 */
public interface SyncStrategy {

    /**
     * 具體策略
     * @param ctx Context
     */
    void process(SyncContext ctx);
}
OtSyncStrategy
/**
 * 自然註冊
 *
 */
@Slf4j
@Service
public class OtSyncStrategy implements SyncStrategy, BeanNameAware {
    private String beanName;

    @Override
    public void process(SyncContext ctx) {
        log.info("[自然註冊] {}", ctx);
        ctx.setRespVO(beanName);
    }

    @Override
    public void setBeanName(String s) {
        beanName = s;
    }
}
AbSyncStrategy
/**
 * 團購使用者
 *
 */
@Slf4j
@Service
public class AbSyncStrategy implements SyncStrategy, BeanNameAware {
    private String beanName;

    @Override
    public void process(SyncContext ctx) {
        log.info("[團購使用者] {}", ctx);
        ctx.setRespVO(beanName);
    }

    @Override
    public void setBeanName(String s) {
        beanName = s;
    }
}
DefaultSyncStrategy
/**
 * 落地頁註冊(Default)
 *
 */
@Slf4j
@Service
public class DefaultSyncStrategy implements SyncStrategy, BeanNameAware {
    private String beanName;

    @Override
    public void process(SyncContext ctx) {
        log.info("[落地頁註冊] {}", ctx);
        ctx.setRespVO(beanName);
    }

    @Override
    public void setBeanName(String s) {
        beanName = s;
    }
}

至此,策略模式的三個角色已湊齊。但似乎還有一些問題,SyncContext中有taskType,但是該怎麼與具體的策略匹配呢?我們可以藉助Spring框架的依賴注入管理策略。

SyncStrategy
/**
 * 同步策略
 *
 */
public interface SyncStrategy {
    String OT_STRATEGY = "otStrategy";
    String AB_STRATEGY = "abStrategy";
    String DEFAULT_STRATEGY = "defaultStrategy";

    /**
     * 具體策略
     * @param ctx Context
     */
    void process(SyncContext ctx);
}

同時修改一下具體策略,指定@Service別名。將3個具體策略類修改完即可。

OtSyncStrategy
/**
 * 自然註冊
 *
 */
@Slf4j
@Service(SyncStrategy.OT_STRATEGY)
public class OtSyncStrategy implements SyncStrategy, BeanNameAware {
    private String beanName;

    @Override
    public void process(SyncContext ctx) {
        log.info("[自然註冊] {}", ctx);
        ctx.setRespVO(beanName);
    }

    @Override
    public void setBeanName(String s) {
        beanName = s;
    }
}

此時我們似乎還需要一個整合呼叫的類,否則的話就要把所有策略暴露出去。一個簡單工廠即可搞定。

SyncStrategyFactory
/**
 * 同步策略工廠類介面
 *
 */
public interface SyncStrategyFactory {
    Map<Integer, String> STRATEGY_MAP = Map.of(
            1, SyncStrategy.OT_STRATEGY,
            2, SyncStrategy.AB_STRATEGY,
            3, SyncStrategy.DEFAULT_STRATEGY
    );

    /**
     * 根據任務型別獲取具體策略
     *
     * @param taskType 任務型別
     * @return 具體策略
     */
    SyncStrategy getStrategy(Integer taskType);

    /**
     * 執行策略  // XXX: 其實這塊放這裡有背單一職責的,同時也不符合Factory本意。
     *
     * @param ctx 策略上下文
     */
    void exec(SyncContext ctx);
}
SyncStrategyFactoryImpl
/**
 * 策略工廠具體實現
 *
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class SyncStrategyFactoryImpl implements SyncStrategyFactory {

    // 這塊可以按Spring Bean別名注入
    private final Map<String, SyncStrategy> strategyMap;

    @Override
    public SyncStrategy getStrategy(Integer taskType) {
        if (!STRATEGY_MAP.containsKey(taskType) || !strategyMap.containsKey(STRATEGY_MAP.get(taskType))) {
            return null;
        }
        return strategyMap.get(STRATEGY_MAP.get(taskType));
    }

    @Override
    public void exec(SyncContext ctx) {
        Optional.of(getStrategy(ctx.getTaskType())).ifPresent(strategy -> {
            log.info("[策略執行] 查詢策略 {}, ctx=>{}", strategy.getClass().getSimpleName(), ctx);
            strategy.process(ctx);
            log.info("[策略執行] 執行完成 ctx=>{}", ctx);
        });
    }
}

至此,可以很方便的在Spring環境中,透過注入SyncStrategyFactory來呼叫。

最後補上單測

/**
 * 策略單測
 *
 */
@Slf4j
@SpringBootTest
class SyncStrategyFactoryTest {

    @Autowired
    SyncStrategyFactory strategyFactory;

    @Test
    void testOtStrategy() {
        final SyncContext ctx = SyncContext.builder().taskType(1).build();
        strategyFactory.exec(ctx);
        Assertions.assertEquals("otStrategy", ctx.getRespVO());
    }

    @Test
    void testAbStrategy() {
        final SyncContext ctx = SyncContext.builder().taskType(2).build();
        strategyFactory.exec(ctx);
        Assertions.assertEquals("abStrategy", ctx.getRespVO());
    }

    @Test
    void testDefaultStrategy() {
        final SyncContext ctx = SyncContext.builder().taskType(3).build();
        strategyFactory.exec(ctx);
        Assertions.assertEquals("defaultStrategy", ctx.getRespVO());
    }

    @Test
    void testOtherStrategy() {
        final SyncContext ctx = SyncContext.builder().taskType(-1).build();
        strategyFactory.exec(ctx);
        Assertions.assertNull(ctx.getRespVO());
    }
}

1.2 動態(引數)策略

其實在上面的策略模式中,也可以將taskType放到具體策略中,作為一個後設資料處理。在選擇具體策略時,遍歷所有策略實現類,當taskType與當前引數匹配時則終止遍歷,由當前策略類處理。

在上述落地頁註冊中,向CRM同步資料時,需要校驗的資料比較多。因為不同地區落地頁引數各不相同,同時有些歷史落地頁。

這種其實可以在策略類中新增校驗方法,如boolean match(StrategyContext ctx)。具體見程式碼

LayoutContext
/**
 * 佈局上下文
 *
 */
@Data
@Builder
public class LayoutContext {
    // 落地頁版本(Landing Page Version)
    private String lpv;

    // 國家地區
    private String country;
    // 渠道號
    private String channel;

    // 最終處理結果 拿到佈局ID
    private String layoutId;
}
LayoutStrategy
/**
 * 佈局處理策略
 *
 */
public interface LayoutStrategy {

    /**
     * 校驗是否匹配該策略
     *
     * @param ctx 策略上下文
     * @return bool
     */
    boolean match(LayoutContext ctx);

    /**
     * 具體策略處理
     *
     * @param ctx 策略上下文
     */
    void process(LayoutContext ctx);
}
具體佈局處理策略
/**
 * 幼兒佈局
 *
 */
@Slf4j
@Order(10)
@Service
public class LayoutChildStrategy implements LayoutStrategy {
    // 幼兒特殊渠道號(優先順序最高)
    private static final String CHILD_CHANNEL = "FE-XX-XX-XX";

    @Override
    public boolean match(LayoutContext ctx) {
        return Objects.nonNull(ctx) && CHILD_CHANNEL.equals(ctx.getChannel());
    }

    @Override
    public void process(LayoutContext ctx) {
        log.info("[幼兒佈局] 開始處理");
        ctx.setLayoutId("111");
    }
}
/**
 * 根據LPV進行判斷的策略
 */
@Slf4j
@Order(20)
@Service
public class LayoutLpvStrategy implements LayoutStrategy {
    // 需要走LPV處理邏輯的渠道號
    private static final Set<String> LPV_CHANNELS = Set.of(
            "LP-XX-XX-01", "LP-XX-XX-02", "XZ-XX-XX-01", "XZ-XX-XX-02"
    );

    @Override
    public boolean match(LayoutContext ctx) {
        return Objects.nonNull(ctx) && Objects.nonNull(ctx.getChannel()) && LPV_CHANNELS.contains(ctx.getChannel());
    }

    @Override
    public void process(LayoutContext ctx) {
        log.info("[LPV佈局] 開始處理");
        ctx.setLayoutId("222");
    }
}
/**
 * 預設處理策略
 */
@Slf4j
@Order(999)
@Service
public class LayoutDefaultStrategy implements LayoutStrategy {

    @Override
    public boolean match(LayoutContext ctx) {
        // 兜底策略
        return true;
    }

    @Override
    public void process(LayoutContext ctx) {
        log.info("[預設佈局] 開始處理");
        ctx.setLayoutId("999");
    }
}

最後,工廠類:

/**
 * 佈局處理工廠
 *
 */
public interface LayoutProcessFactory {

    /**
     * 獲取具體策略
     *
     * @param ctx 上下文
     * @return Strategy
     */
    Optional<LayoutStrategy> getStrategy(LayoutContext ctx);

    /**
     * 策略呼叫
     *
     * @param ctx 上下文
     */
    void exec(LayoutContext ctx);
}
/**
 * 佈局處理工廠實現
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class LayoutProcessFactoryImpl implements LayoutProcessFactory {

    // Spring會根據@Order註解順序注入
    private final List<LayoutStrategy> strategyList;

    @Override
    public Optional<LayoutStrategy> getStrategy(LayoutContext ctx) {
        return strategyList.stream()
                .filter(s -> s.match(ctx)).findFirst();
    }

    @Override
    public void exec(LayoutContext ctx) {
        log.info("[佈局處理] 嘗試處理 ctx=>{}", ctx);
        getStrategy(ctx).ifPresent(s -> {
            s.process(ctx);
            log.info("[佈局處理] 處理完成 ctx=>{}", ctx);
        });
    }
}

最後的最後,單測:

@SpringBootTest
class LayoutProcessFactoryTest {

    @Autowired
    private LayoutProcessFactory processFactory;

    @Test
    void testChild() throws IllegalAccessException {
        // 透過反射獲取Channel
        final Field childChannel = ReflectionUtils.findField(LayoutChildStrategy.class, "CHILD_CHANNEL");
        assertNotNull(childChannel);
        childChannel.setAccessible(true);  // XXX: setAccessible 後續可能會禁止這樣使用
        String childChannelStr = (String) childChannel.get(LayoutChildStrategy.class);
        // 初始化Context
        LayoutContext ctx = LayoutContext.builder().channel(childChannelStr).build();
        //
        processFactory.exec(ctx);
        assertEquals("111", ctx.getLayoutId());
    }

    @Test
    void testLpv() {
        LayoutContext ctx = LayoutContext.builder().channel("LP-XX-XX-02").build();
        processFactory.exec(ctx);
        assertEquals("222", ctx.getLayoutId());
    }

    @Test
    void testDefault() {
        final LayoutContext ctx = LayoutContext.builder().build();
        processFactory.exec(ctx);
        assertEquals("999", ctx.getLayoutId());
    }
}

2 思考

策略模式能給我們帶來什麼?

  1. 對業務邏輯進行了一定程度的封裝,將不易變和易變邏輯進行了分離。使得後續的業務變更,僅修改相應的策略或者新增策略即可。
  2. 但再深層思考一下。之前易變和不易變邏輯修改代價可能相差不大,而使用設計模式之後,使得易變程式碼修改代價降低,但不易變程式碼修改代價則上升。所以在使用時要三思而後行。
  3. 策略模式消除了if-else嗎?好像沒有,只是把這個選擇權向後移(或者說交給呼叫者)了。
  4. 策略讓原本混雜在一個檔案甚至是一個函式里面的程式碼,打散到數個檔案中。如果每塊邏輯只是簡單的幾行程式碼,使用策略反而會得不償失。還不如if-else或者switch淺顯易懂、一目瞭然。

策略模式跟其他模式有啥區別?

  1. 模板模式有點像。不過模板模式主要是在父類(上層)對一些動作、方法做編排。而由不同子類去做具體動作、方法的實現。重點在於編排。
  2. 橋接模式有點像。不過橋接有多個維度的變化,策略可以認為是一維的橋接。

3 後續

本打算一篇文章將常用的設計模式一塊講講,貼上程式碼似乎有點長,還是分開說吧。


封面圖來源: https://refactoring.guru/desi...

相關文章