策略模式:Spring Resource多种资源的策略选择与运行时替换
策略模式:Spring Resource多种资源的策略选择与运行时替换
适读人群:中高级Java开发者 | 阅读时长:约20分钟 | 模式类型:行为型
开篇故事
做了这么多年后端,促销活动是我最怕接到的需求之一。不是因为业务复杂,而是因为每次运营同学总会在上线前一天说:"咦,我们能不能加一个新的促销类型?"
最开始,我们的折扣计算逻辑是一大坨 if-else:
if ("full_reduction".equals(promotionType)) {
// 满减逻辑
} else if ("percentage".equals(promotionType)) {
// 折扣逻辑
} else if ("buy_x_get_y".equals(promotionType)) {
// 买X送Y逻辑
} else if ("flash_sale".equals(promotionType)) {
// 限时秒杀逻辑
}
// 每次加新类型就得改这里...后来有一次大促,运营临时要加三个新的促销类型,我改完 if-else 之后,测试发现另外两种原本正常的促销计算逻辑居然算错了——因为改 if-else 时不小心动到了别的分支。
那次之后,我彻底重构了促销计算模块,用策略模式把每种促销类型封装成独立的策略类,从此再也没有出现过这种问题。今天分享这个重构过程,同时结合 Spring Resource 的策略设计分析。
一、模式动机:消灭 if-else 的算法族
策略模式(Strategy Pattern)的核心:定义一系列算法,将每个算法封装成独立的策略类,使它们可以互相替换,让算法的变化独立于使用算法的客户端。
解决的核心问题:同一个行为有多种实现方式,且这些实现方式需要在运行时灵活切换。
二、模式结构
三、Spring Resource 的策略设计分析
Spring 的 Resource 接口是策略模式的经典应用,它统一了对不同来源资源的访问方式:
// Resource 接口(Strategy接口)
public interface Resource extends InputStreamSource {
boolean exists();
boolean isReadable();
boolean isOpen();
boolean isFile();
URL getURL() throws IOException;
URI getURI() throws IOException;
File getFile() throws IOException;
long contentLength() throws IOException;
long lastModified() throws IOException;
Resource createRelative(String relativePath) throws IOException;
String getFilename();
String getDescription();
}
// 各种具体策略实现:
// ClassPathResource: classpath:config.xml
// FileSystemResource: file:/opt/config.xml
// UrlResource: https://example.com/config.xml
// ServletContextResource: /WEB-INF/config.xml
// ByteArrayResource: 字节数组资源ResourceLoader 根据资源路径的前缀选择合适的策略:
// DefaultResourceLoader.getResource() — 策略选择逻辑
public Resource getResource(String location) {
Assert.notNull(location, "Location must not be null");
for (ProtocolResolver protocolResolver : getProtocolResolvers()) {
Resource resource = protocolResolver.resolve(location, this);
if (resource != null) return resource;
}
if (location.startsWith("/")) {
return getResourceByPath(location); // 路径资源
} else if (location.startsWith(CLASSPATH_URL_PREFIX)) {
return new ClassPathResource(location.substring(CLASSPATH_URL_PREFIX.length()), getClassLoader()); // classpath:
} else {
try {
URL url = ResourceUtils.toURL(location);
return ResourceUtils.isFileURL(url)
? new FileUrlResource(url) // file://
: new UrlResource(url); // http://, https://
} catch (MalformedURLException ex) {
return getResourceByPath(location);
}
}
}四、生产级代码实现:促销策略系统
4.1 策略接口与实现
/**
* 促销计算策略接口(Strategy)
*/
public interface PromotionStrategy {
/**
* 计算折扣金额
*/
BigDecimal calculateDiscount(PromotionContext context);
/**
* 检查是否满足促销条件
*/
boolean isApplicable(PromotionContext context);
/**
* 促销类型代码
*/
String promotionType();
/**
* 策略优先级(数值越小优先级越高)
*/
default int priority() { return 100; }
}
/**
* 促销上下文:包含计算折扣所需的所有信息
*/
@Data
@Builder
public class PromotionContext {
private String userId;
private List<CartItem> cartItems;
private BigDecimal totalAmount;
private List<String> appliedCouponIds;
private PromotionRule promotionRule; // 促销规则配置
private UserLevel userLevel; // 用户等级
}
/**
* 满减策略(ConcreteStrategyA)
*/
@Component
public class FullReductionStrategy implements PromotionStrategy {
@Override
public BigDecimal calculateDiscount(PromotionContext context) {
FullReductionRule rule = (FullReductionRule) context.getPromotionRule();
BigDecimal totalAmount = context.getTotalAmount();
// 满X减Y,可叠加
BigDecimal discount = BigDecimal.ZERO;
BigDecimal remaining = totalAmount;
while (remaining.compareTo(rule.getThreshold()) >= 0) {
discount = discount.add(rule.getReductionAmount());
remaining = remaining.subtract(rule.getThreshold());
if (!rule.isStackable()) break; // 不可叠加则只算一次
}
return discount;
}
@Override
public boolean isApplicable(PromotionContext context) {
FullReductionRule rule = (FullReductionRule) context.getPromotionRule();
return context.getTotalAmount().compareTo(rule.getThreshold()) >= 0;
}
@Override
public String promotionType() { return "FULL_REDUCTION"; }
@Override
public int priority() { return 10; } // 满减优先级较高
}
/**
* 折扣策略(ConcreteStrategyB)
*/
@Component
public class PercentageDiscountStrategy implements PromotionStrategy {
@Override
public BigDecimal calculateDiscount(PromotionContext context) {
PercentageRule rule = (PercentageRule) context.getPromotionRule();
BigDecimal discountRate = BigDecimal.ONE.subtract(
rule.getDiscountPercentage().divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP)
);
return context.getTotalAmount().multiply(discountRate).setScale(2, RoundingMode.HALF_UP);
}
@Override
public boolean isApplicable(PromotionContext context) {
PercentageRule rule = (PercentageRule) context.getPromotionRule();
// 检查用户等级是否满足要求
if (rule.getMinUserLevel() != null) {
return context.getUserLevel().getLevel() >= rule.getMinUserLevel().getLevel();
}
return true;
}
@Override
public String promotionType() { return "PERCENTAGE_DISCOUNT"; }
}
/**
* 买X送Y策略(ConcreteStrategyC)
*/
@Component
@Slf4j
public class BuyXGetYStrategy implements PromotionStrategy {
@Override
public BigDecimal calculateDiscount(PromotionContext context) {
BuyXGetYRule rule = (BuyXGetYRule) context.getPromotionRule();
// 统计符合条件的商品数量
long qualifyingItems = context.getCartItems().stream()
.filter(item -> rule.getQualifyingSkuIds().contains(item.getSkuId()))
.mapToLong(CartItem::getQuantity)
.sum();
if (qualifyingItems < rule.getBuyQuantity()) {
return BigDecimal.ZERO;
}
// 计算赠品的价值(免费商品数量)
long freeQuantity = (qualifyingItems / rule.getBuyQuantity()) * rule.getGetQuantity();
// 找到单价最低的商品作为赠品
return context.getCartItems().stream()
.filter(item -> rule.getQualifyingSkuIds().contains(item.getSkuId()))
.sorted(Comparator.comparing(CartItem::getUnitPrice))
.limit(freeQuantity)
.map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
@Override
public boolean isApplicable(PromotionContext context) {
BuyXGetYRule rule = (BuyXGetYRule) context.getPromotionRule();
return context.getCartItems().stream()
.filter(item -> rule.getQualifyingSkuIds().contains(item.getSkuId()))
.mapToLong(CartItem::getQuantity)
.sum() >= rule.getBuyQuantity();
}
@Override
public String promotionType() { return "BUY_X_GET_Y"; }
}
/**
* AI个性化折扣策略(新增策略,不需要修改任何已有代码)
*/
@Component
@Slf4j
public class AiPersonalizedDiscountStrategy implements PromotionStrategy {
@Autowired
private AiRecommendationClient aiClient;
@Override
public BigDecimal calculateDiscount(PromotionContext context) {
try {
// 调用AI模型计算个性化折扣
PersonalizedDiscountResponse response = aiClient.getPersonalizedDiscount(
context.getUserId(),
context.getTotalAmount(),
context.getCartItems().stream().map(CartItem::getSkuId).collect(Collectors.toList())
);
return response.getDiscountAmount();
} catch (Exception e) {
log.warn("AI discount calculation failed for user {}: {}", context.getUserId(), e.getMessage());
return BigDecimal.ZERO; // 降级:无折扣
}
}
@Override
public boolean isApplicable(PromotionContext context) {
// 只对高价值用户应用AI个性化折扣
return context.getUserLevel() == UserLevel.GOLD || context.getUserLevel() == UserLevel.PLATINUM;
}
@Override
public String promotionType() { return "AI_PERSONALIZED"; }
@Override
public int priority() { return 50; }
}4.2 策略注册表与上下文
/**
* 促销策略注册表(Context + Strategy Registry)
*/
@Service
@Slf4j
public class PromotionEngine {
private final Map<String, PromotionStrategy> strategyMap;
private final List<PromotionStrategy> strategies;
// Spring自动注入所有PromotionStrategy实现(策略自动注册)
public PromotionEngine(List<PromotionStrategy> strategies) {
this.strategies = strategies.stream()
.sorted(Comparator.comparingInt(PromotionStrategy::priority))
.collect(Collectors.toList());
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(PromotionStrategy::promotionType, Function.identity()));
log.info("Registered {} promotion strategies: {}",
strategyMap.size(), strategyMap.keySet());
}
/**
* 根据促销类型选择策略(运行时策略选择)
*/
public DiscountResult calculateDiscount(String promotionType, PromotionContext context) {
PromotionStrategy strategy = strategyMap.get(promotionType);
if (strategy == null) {
log.warn("No strategy found for promotion type: {}", promotionType);
return DiscountResult.noDiscount();
}
if (!strategy.isApplicable(context)) {
return DiscountResult.notApplicable(promotionType, "Conditions not met");
}
BigDecimal discount = strategy.calculateDiscount(context);
log.info("Applied promotion {} for user {}, discount: {}",
promotionType, context.getUserId(), discount);
return DiscountResult.builder()
.promotionType(promotionType)
.discountAmount(discount)
.finalAmount(context.getTotalAmount().subtract(discount))
.build();
}
/**
* 自动选择最优策略(在所有适用策略中选折扣最大的)
*/
public DiscountResult calculateBestDiscount(PromotionContext context,
List<String> availablePromotions) {
return availablePromotions.stream()
.map(type -> strategyMap.get(type))
.filter(Objects::nonNull)
.filter(strategy -> strategy.isApplicable(context))
.map(strategy -> {
BigDecimal discount = strategy.calculateDiscount(context);
return DiscountResult.builder()
.promotionType(strategy.promotionType())
.discountAmount(discount)
.finalAmount(context.getTotalAmount().subtract(discount))
.build();
})
.max(Comparator.comparing(DiscountResult::getDiscountAmount))
.orElse(DiscountResult.noDiscount());
}
/**
* 叠加所有适用的策略(促销叠加场景)
*/
public DiscountResult calculateStackedDiscount(PromotionContext context,
List<String> promotionTypes) {
BigDecimal totalDiscount = BigDecimal.ZERO;
List<String> appliedTypes = new ArrayList<>();
for (String type : promotionTypes) {
PromotionStrategy strategy = strategyMap.get(type);
if (strategy == null || !strategy.isApplicable(context)) continue;
BigDecimal discount = strategy.calculateDiscount(context);
totalDiscount = totalDiscount.add(discount);
appliedTypes.add(type);
// 更新上下文中的已优惠金额,影响后续策略计算
context = context.toBuilder()
.totalAmount(context.getTotalAmount().subtract(discount))
.build();
}
return DiscountResult.builder()
.promotionTypes(appliedTypes)
.discountAmount(totalDiscount)
.finalAmount(context.getTotalAmount())
.build();
}
}五、与相关模式的对比
策略 vs 模板方法
- 策略:通过组合,将算法封装在独立的策略类中,可以在运行时替换。
- 模板方法:通过继承,在父类中定义算法骨架,子类覆写某些步骤,编译时确定。
策略更灵活(运行时切换),模板方法更简单(不需要额外的接口和多个类)。
策略 vs 命令模式
- 策略:封装"如何做",即算法本身。
- 命令:封装"做什么",将请求(操作)封装成对象,支持队列、撤销等功能。
六、踩坑实录
坑一:策略上下文成为了上帝类
一开始 PromotionContext 里塞了很多与某些策略相关的特有字段,导致每次加新策略都要修改 PromotionContext,违反了 OCP。
解决方案:PromotionContext 只保留通用字段,策略特有的参数通过 PromotionRule 子类传入(多态参数),或者通过 Map<String, Object> extraParams 传递扩展参数。
坑二:策略选择逻辑外泄
有个同事在 OrderService 里写了一大堆代码来决定使用哪个策略,这些选择逻辑散落在多处。后来策略规则一变,需要同时修改多处代码。
正确做法:所有策略选择逻辑集中在 PromotionEngine,OrderService 只告诉 Engine"我有哪些优惠券和促销活动",由 Engine 决定用哪个策略。
坑三:忘记处理策略不存在的情况
早期代码中,strategyMap.get(type) 如果返回 null,直接调用 .calculateDiscount() 就 NPE 了。在生产环境,数据库里有一条旧的促销记录类型 LEGACY_DISCOUNT 在代码里已经删除了,查询时就报 NPE,还是线上事故。
一定要对 null 做防御处理,并提供合适的降级策略。
七、总结
策略模式是消灭 if-else 的最有效手段,也是 Spring 扩展机制的核心理念之一。
使用要点:
- 策略接口要聚焦:只定义与"算法"本身相关的方法,不要让接口承担太多职责。
- 利用 Spring 自动注入:
List<Strategy>注入所有策略实现,配合@Order控制优先级。 - 策略选择集中管理:统一在一处选择和切换策略,避免逻辑外泄。
- 运行时动态替换:策略模式天然支持运行时切换算法,这是它相对于模板方法的最大优势。
