1. 从“硬编码”到“优雅切换”:一个真实项目的痛点

我记得几年前刚接手一个电商后台项目时,遇到了一个让我头疼了好几天的问题。项目里有一个“消息通知”模块,需要支持短信、邮件、App推送、微信模板消息等多种渠道。当时的代码大概是这样的:

@Service
public class NotifyService {
    public void send(String type, String content, String target) {
        if ("sms".equals(type)) {
            // 调用短信服务商的API,一堆参数拼接和HTTP请求
            System.out.println("发送短信给 " + target + ",内容:" + content);
        } else if ("email".equals(type)) {
            // 配置SMTP,组装邮件正文和附件
            System.out.println("发送邮件给 " + target + ",内容:" + content);
        } else if ("app_push".equals(type)) {
            // 集成个推/极光,处理设备Token
            System.out.println("发送App推送给 " + target + ",内容:" + content);
        } else if ("wechat".equals(type)) {
            // 调用微信API,处理access_token和模板ID
            System.out.println("发送微信模板消息给 " + target + ",内容:" + content);
        }
        // ... 未来可能还有钉钉、飞书等等
    }
}

这代码看着就让人窒息,对吧?每次加一个新渠道,我都要钻进这个庞大的 if-else 森林里,小心翼翼地添加一个新的分支。更麻烦的是,不同渠道的参数、配置、错误处理逻辑完全不同,全都糅在一个方法里,导致这个方法后来膨胀到了几百行,谁都不敢轻易动它。测试的时候更是噩梦,为了测一个邮件功能,我得把短信、推送的模拟逻辑都跑一遍。

这就是典型的“硬编码”策略选择,它的坏处太明显了:代码臃肿、难以维护、违反开闭原则(对扩展开放,对修改关闭)。每次变动都像是在已经摇摇欲坠的积木塔上再加一块,风险极高。

后来,我接触到了策略模式,感觉就像发现了一把万能钥匙。策略模式的核心思想是定义一族算法,将它们分别封装起来,并且使它们可以互相替换。它把变化的逻辑(怎么发通知)从不变的主体(要发通知)中抽离出来。但光知道模式还不够,在SpringBoot的世界里,如何优雅地管理这些策略,并在需要时能像在超市货架上取商品一样,轻松拿到我想要的那个实现,这才是关键。

直到我把策略模式和SpringBoot的 Map注入结合起来,才真正解决了这个痛点。这种组合拳,让我再也不用写那些丑陋的 if-else 或者 switch,业务逻辑变得清晰无比,扩展新渠道也变成了几分钟的快乐工作。下面,我就带你一步步拆解这个“优雅实践”到底是怎么玩的。

2. 核心武器拆解:策略模式与Spring的依赖注入

2.1 策略模式:化繁为简的设计艺术

策略模式其实一点也不玄乎。咱们还用发通知的例子来说。上面那一坨 if-else,本质上就是一堆不同的“发送策略”。策略模式告诉我们,应该把这些策略独立出来。

首先,我们定义一个共同的策略接口,规定所有通知方式都必须实现“发送”这个动作:

public interface NotifyStrategy {
    /**
     * 发送通知
     * @param content 内容
     * @param target  目标(手机号/邮箱/用户ID等)
     * @return 发送是否成功
     */
    boolean send(String content, String target);

    /**
     * 获取策略类型,用于后续的映射查找
     * @return 类型标识,如"sms", "email"
     */
    String getType();
}

看,这个接口就是我们的“契约”。接下来,让每种通知方式都成为履行这个契约的独立个体:

@Service
public class SmsNotifyStrategy implements NotifyStrategy {
    @Override
    public boolean send(String content, String target) {
        // 专注于短信发送的逻辑:签名、模板、调用第三方SDK...
        System.out.println("[短信策略] 正在发送短信至 " + target + " : " + content);
        // 模拟发送成功
        return true;
    }

    @Override
    public String getType() {
        return "sms";
    }
}

@Service
public class EmailNotifyStrategy implements NotifyStrategy {
    @Override
    public boolean send(String content, String target) {
        // 专注于邮件发送的逻辑:SMTP、HTML模板、附件...
        System.out.println("[邮件策略] 正在发送邮件至 " + target + " : " + content);
        return true;
    }

    @Override
    public String getType() {
        return "email";
    }
}

这样一来,每个策略类都变得职责单一、内聚性高。短信的修改不会影响到邮件,反之亦然。这就是策略模式带来的最直接好处:解耦。

2.2 Spring的魔法:当@Autowired遇到Map和List

策略定义好了,怎么管理呢?传统做法可能是在工厂类里手动 new 这些对象,但那就失去了Spring依赖注入容器管理的优势(比如自动装配、AOP、生命周期管理等)。

这里就要请出Spring的一个不太起眼但极其强大的特性:对集合类型的依赖注入。很多朋友只知道 @Autowired 可以注入单个Bean,其实它还能注入 Map 和 List。

1. 注入List: 当你注入 List<NotifyStrategy> 时,Spring会把容器中所有实现了 NotifyStrategy 接口的Bean,都收集到这个List里。顺序一般是Bean定义的顺序,但不要依赖这个顺序。

2. 注入Map<String, Interface>:这才是我们今天的主角。 当你注入 Map<String, NotifyStrategy> 时,Spring会做一件更聪明的事:它会以 Bean的名字(默认是类名首字母小写)作为Key,以Bean实例作为Value,自动组装成一个Map。

@Component
public class StrategyManager {
    // Spring会自动将所有的NotifyStrategy实现Bean装入这个Map
    // Key: Bean名称 (如 smsNotifyStrategy, emailNotifyStrategy)
    // Value: 对应的Bean实例
    @Autowired
    private Map<String, NotifyStrategy> strategyMap;

    // 也可以注入List,方便遍历或做其他操作
    @Autowired
    private List<NotifyStrategy> strategyList;

    public void printAllStrategies() {
        System.out.println("当前容器中的策略Map:" + strategyMap.keySet());
        System.out.println("当前容器中的策略List数量:" + strategyList.size());
    }
}

你可以写个测试方法调用 printAllStrategies(),会发现控制台打印出了 {smsNotifyStrategy=..., emailNotifyStrategy=...}。这个Map就是一个完整的策略仓库,我们随时可以根据Key来取用具体的策略。

注意:这里Map的Key默认是Spring Bean的名称。如果你的策略Bean使用了 @Service(“mySms”) 自定义了名字,那么Map的Key就会变成 “mySms”。这一点对于后续的查找至关重要。

3. 构建动态调用的核心:策略工厂与上下文

有了策略仓库(Map),我们还需要一个“导购员”或者“调度中心”,来根据客户(业务逻辑)的需求,从仓库里找到正确的策略。这就是策略工厂或策略上下文的角色。

3.1 基础版工厂:根据Bean名称直接获取

这是最直接的方式,假设我们的策略Bean名称(或自定义名称)就能直接作为业务中的类型标识。

@Component
public class NotifyStrategyFactory {
    @Autowired
    private Map<String, NotifyStrategy> strategyMap;

    /**
     * 根据策略类型获取具体的策略实现
     * @param strategyBeanName Spring容器中的Bean名称,如 "smsNotifyStrategy"
     * @return 策略实现
     * @throws RuntimeException 当找不到对应策略时
     */
    public NotifyStrategy getStrategy(String strategyBeanName) {
        NotifyStrategy strategy = strategyMap.get(strategyBeanName);
        if (strategy == null) {
            throw new RuntimeException("未找到对应的通知策略: " + strategyBeanName);
        }
        return strategy;
    }
}

在业务层这样使用:

@Service
public class OrderService {
    @Autowired
    private NotifyStrategyFactory notifyStrategyFactory;

    public void paySuccess(String orderId, String userId, String notifyType) {
        // ... 处理订单支付成功逻辑
        // 动态选择通知策略
        NotifyStrategy strategy = notifyStrategyFactory.getStrategy(notifyType + "NotifyStrategy");
        strategy.send("您的订单" + orderId + "已支付成功", userId);
    }
}

这种方式简单,但有个缺点:业务代码需要关心Spring Bean的命名规则(notifyType + “NotifyStrategy”),耦合了技术细节,不太优雅。

3.2 进阶版工厂:引入枚举进行优雅映射

更常见的做法是,定义一个业务枚举,将业务代码中的类型标识(如sms, email)与具体的策略Bean关联起来。这样业务层只需要使用枚举,无需知道背后的Bean叫什么。

第一步:定义业务枚举

public enum NotifyType {
    SMS("sms", "短信通知"),
    EMAIL("email", "邮件通知"),
    APP_PUSH("app_push", "应用推送"),
    WECHAT("wechat", "微信模板消息");

    private final String code; // 业务代码,用于前端传递或数据库存储
    private final String description;

    // 这里可以加一个字段关联Bean名称,但更推荐在工厂里维护映射关系
    // private final String beanName;

    NotifyType(String code, String description) {
        this.code = code;
        this.description = description;
    }

    public String getCode() {
        return code;
    }
    // 根据code获取枚举的静态方法(略)
}

第二步:升级策略工厂,内部维护映射

@Component
public class NotifyStrategyFactory {
    @Autowired
    private Map<String, NotifyStrategy> strategyMap;

    // 内部映射:业务类型 -> Spring Bean名称
    private static final Map<String, String> TYPE_TO_BEAN_NAME = new HashMap<>();
    static {
        TYPE_TO_BEAN_NAME.put(NotifyType.SMS.getCode(), "smsNotifyStrategy");
        TYPE_TO_BEAN_NAME.put(NotifyType.EMAIL.getCode(), "emailNotifyStrategy");
        TYPE_TO_BEAN_NAME.put(NotifyType.APP_PUSH.getCode(), "appPushNotifyStrategy");
        TYPE_TO_BEAN_NAME.put(NotifyType.WECHAT.getCode(), "wechatNotifyStrategy");
    }

    /**
     * 根据业务类型码获取策略
     * @param typeCode 业务类型码,如 "sms"
     * @return 策略实现
     */
    public NotifyStrategy getStrategyByType(String typeCode) {
        String beanName = TYPE_TO_BEAN_NAME.get(typeCode);
        if (beanName == null) {
            throw new IllegalArgumentException("不支持的策略类型: " + typeCode);
        }
        NotifyStrategy strategy = strategyMap.get(beanName);
        if (strategy == null) {
            throw new RuntimeException("策略Bean未找到: " + beanName);
        }
        return strategy;
    }

    /**
     * 更优雅的方式:直接传入枚举
     * @param notifyType 通知类型枚举
     * @return 策略实现
     */
    public NotifyStrategy getStrategy(NotifyType notifyType) {
        return getStrategyByType(notifyType.getCode());
    }
}

第三步:在策略接口实现中关联类型 为了让映射更自动化,我们可以让每个策略实现类自己上报它的业务类型。修改策略接口和实现:

public interface NotifyStrategy {
    boolean send(String content, String target);
    // 策略类自己声明其负责的业务类型
    String getTypeCode();
}

@Service
public class SmsNotifyStrategy implements NotifyStrategy {
    @Override
    public boolean send(String content, String target) { ... }

    @Override
    public String getTypeCode() {
        return NotifyType.SMS.getCode(); // 返回 "sms"
    }
}

然后工厂可以优化为自动扫描建立映射,避免硬编码:

@Component
public class NotifyStrategyFactory {
    private final Map<String, NotifyStrategy> strategyMap = new HashMap<>();

    // 使用构造器注入,在Bean创建时立即初始化映射
    @Autowired
    public NotifyStrategyFactory(List<NotifyStrategy> strategies) {
        for (NotifyStrategy strategy : strategies) {
            strategyMap.put(strategy.getTypeCode(), strategy);
        }
        System.out.println("策略工厂初始化完成,可用策略: " + strategyMap.keySet());
    }

    public NotifyStrategy getStrategy(String typeCode) {
        NotifyStrategy strategy = strategyMap.get(typeCode);
        if (strategy == null) {
            throw new IllegalArgumentException("未找到类型为 [" + typeCode + "] 的通知策略");
        }
        return strategy;
    }
}

这种方式是最优雅的!工厂在启动时,通过构造器接收到所有策略Bean的List,然后遍历每个策略,调用其 getTypeCode() 方法,用业务类型码作为Key,策略实例本身作为Value,构建一个内部的 Map<String, NotifyStrategy>。业务层使用起来就非常清爽了:

@Service
public class UserService {
    @Autowired
    private NotifyStrategyFactory notifyStrategyFactory;

    public void sendWelcomeMessage(String userId, String notifyTypeCode) {
        String message = "欢迎注册我们的服务!";
        NotifyStrategy strategy = notifyStrategyFactory.getStrategy(notifyTypeCode);
        boolean success = strategy.send(message, userId);
        // 处理发送结果...
    }
}

4. 实战深化:处理复杂场景与高级技巧

掌握了基本玩法后,我们来看看在实际项目中可能遇到的更复杂的场景以及如何应对。

4.1 场景一:策略需要动态参数或配置

有些策略可能需要特定的配置才能初始化。比如短信策略需要 appKey 和 appSecret,邮件策略需要 host 和 port。我们可以利用Spring的 @ConfigurationProperties 或 @Value 为每个策略单独配置。

为策略类添加配置:

@Service
@ConfigurationProperties(prefix = "notify.sms") // 从application.yml读取notify.sms下的配置
public class SmsNotifyStrategy implements NotifyStrategy {
    private String appKey;
    private String appSecret;
    private String signName;

    // setter 方法必须提供,Spring会通过它们注入配置值
    public void setAppKey(String appKey) { this.appKey = appKey; }
    public void setAppSecret(String appSecret) { this.appSecret = appSecret; }
    public void setSignName(String signName) { this.signName = signName; }

    @Override
    public boolean send(String content, String target) {
        System.out.println("使用appKey[" + appKey + "]发送短信,签名[" + signName + "]");
        // 实际调用短信API
        return true;
    }
    // ... getTypeCode()
}

在 application.yml 中配置:

notify:
  sms:
    app-key: "your_sms_key"
    app-secret: "your_sms_secret"
    sign-name: "我的公司"
  email:
    host: "smtp.example.com"
    port: 587
    username: "noreply@example.com"

这样,每个策略所需的配置都被清晰地隔离和管理,工厂在注入时,Spring已经帮我们完成了配置的绑定和Bean的初始化。

4.2 场景二:策略的懒加载与条件化装配

不是所有策略在应用启动时都需要。也许有些策略依赖某些特定的开关,或者只在特定环境下才启用。这时可以用Spring的 @ConditionalOnProperty 或 @ConditionalOnClass 注解。

@Service
@ConditionalOnProperty(name = "notify.wechat.enabled", havingValue = "true")
public class WechatNotifyStrategy implements NotifyStrategy {
    // 只有当配置文件中 notify.wechat.enabled=true 时,这个Bean才会被创建和注入到Map中
    @Override
    public boolean send(String content, String target) { ... }
    @Override
    public String getTypeCode() { return "wechat"; }
}

@Service
@ConditionalOnClass(name = "com.thirdparty.push.PushClient") // 只有当类路径存在这个客户端类时才生效
public class AppPushNotifyStrategy implements NotifyStrategy {
    // 依赖了特定的第三方推送SDK
    @Override
    public boolean send(String content, String target) { ... }
    @Override
    public String getTypeCode() { return "app_push"; }
}

使用条件化装配后,你的策略工厂和Map注入机制完全不用改。如果某个策略的装配条件不满足,它根本不会出现在Spring容器里,自然也不会被注入到Map中。getStrategy 方法在调用时如果发现对应的 typeCode 不存在,就会抛出我们预先定义好的异常,比如“当前环境未启用微信通知功能”。这实现了策略的动态可插拔,非常灵活。

4.3 场景三:策略执行前后的统一处理(AOP)

我们可能需要在所有策略执行前后做一些统一的事情,比如记录日志、监控执行时间、进行权限校验等。这时,面向切面编程(AOP)就派上用场了。

@Aspect
@Component
@Slf4j
public class NotifyStrategyAspect {

    // 拦截所有NotifyStrategy接口的send方法
    @Around("execution(* com.yourpackage.strategy.NotifyStrategy.send(..))")
    public Object aroundSend(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();
        String target = (String) args[1];
        String strategyName = joinPoint.getTarget().getClass().getSimpleName();

        log.info("开始执行策略 [{}] 的 [{}] 方法,目标: {}", strategyName, methodName, target);
        long startTime = System.currentTimeMillis();

        try {
            Object result = joinPoint.proceed(); // 执行实际的send方法
            long costTime = System.currentTimeMillis() - startTime;
            log.info("策略 [{}] 执行成功,耗时: {}ms", strategyName, costTime);
            return result;
        } catch (Exception e) {
            log.error("策略 [{}] 执行失败,目标: {}", strategyName, target, e);
            throw e; // 可以选择重试、降级或直接抛出
        }
    }
}

通过AOP,我们将横切关注点(日志、监控)从业务策略中彻底剥离。每个策略类只需要关心自己最核心的发送逻辑,代码变得更加纯粹和易于测试。

5. 避坑指南与最佳实践

踩过不少坑之后,我总结了一些关键点,能帮你更快更稳地上手这种模式。

1. 确保Map的Key唯一且明确 这是最容易出问题的地方。Spring默认用Bean名称作为Map的Key。如果你没有指定,它就是类名首字母小写(如 smsNotifyStrategy)。一定要确保你的查找逻辑(无论是直接传Bean名,还是通过枚举映射)能精确匹配到这个Key。我建议在策略接口中强制要求实现 getTypeCode() 方法,并在工厂初始化时使用这个code作为Key,这样最可控。

2. 处理“找不到策略”的异常 业务代码调用 factory.getStrategy(type) 时,如果传入的 type 不对应任何已注册的策略,一定要有友好的异常处理。不要直接返回 null,这会导致后续的NPE。像我们上面那样抛出明确的 IllegalArgumentException 或自定义的业务异常,并在全局异常处理器中捕获,返回给前端清晰的错误信息。

3. 策略的无状态与线程安全 理想情况下,你的策略实现应该是无状态的(Stateless)。即它们不包含会随着调用而改变的成员变量。所有的配置都应该在初始化时注入(通过 @Value 或 @ConfigurationProperties),业务数据通过方法参数传递。这样能保证策略Bean是单例且线程安全的。如果某个策略必须有状态,请仔细考虑其作用域(@Scope)和并发访问问题。

4. 单元测试变得极其简单 由于策略模式的高内聚和低耦合,单元测试写起来非常愉快。你可以单独测试每一个策略类,Mock掉它的外部依赖(如HTTP客户端)。对于使用策略的Service类,你可以Mock策略工厂,让它返回一个Mock的策略对象,从而专注于测试Service本身的业务逻辑。

@SpringBootTest
class SmsNotifyStrategyTest {
    @Autowired
    private SmsNotifyStrategy smsStrategy;

    @Test
    void testSendSms() {
        // 可以轻松配置测试用的属性,或者Mock内部的HTTP客户端
        boolean result = smsStrategy.send("测试内容", "13800138000");
        assertTrue(result);
    }
}

5. 与Spring Cloud整合的思考 在微服务架构下,这种模式依然适用,而且可以玩出更多花样。例如,你可以将策略的“类型”与配置中心(如Nacos、Apollo)的动态配置绑定,实现不重启服务就动态切换或降级某个通知渠道。或者,将策略工厂本身设计成一个轻量的“规则引擎”,根据从配置中心读取的规则来决定最终使用哪个策略。

从我自己的经验来看,将策略模式与SpringBoot的Map注入结合,绝不仅仅是为了消灭 if-else。它更是一种思维模式的转变,让你从“过程式”的编码,转向“声明式”的组装。当你习惯这种模式后,你会发现系统中很多类似的“分支选择”逻辑都可以被这样优雅地重构,代码的可读性、可维护性和可扩展性都会得到质的提升。下次当你再看到一堆 if-else 或 switch 时,不妨先停下来想想:这里是不是藏着一个等待被抽象的策略家族?

更多推荐