如何在 Spring 环境下优雅的实现策略模式

背景

最近在忙一个需求,大致就是给满足特定条件的用户发营销邮件,但是用户的来源有很多方式:从 ES 查询的、从 csv 导入的、从 MongoDB 查询….. 需求很简单,但是怎么写的优雅,方便后续扩展,就存在很多门道了。

我们的项目是基于 Spring Boot 开发的,因此这篇文章也会基于 Spring Boot 作为基础框架,教你如何使用 Spring 依赖注入的特性,优雅的实现策略模式。

1. 简单粗暴

最简单粗暴直接的方式莫过于 if...else… 了,伪代码如下:

1
2
3
4
5
6
7
if(来源 == ES){
// TODO: ES Query
}else if(来源 == CSV){
// TODO: Read CSV File
}else if(来源 == MongoDB){
// TODO: MongoDB Query
}

如果后面还需要从其他平台获取,那就在接着添加 else if...,这种方式固然简单直接,但是当后续扩展的方式越来越多,相应的if...else...也会越来越长,emmm…..怎么说呢,黑猫白猫,能抓到老鼠的就是好猫。

2. 策略模式

在 Spring 环境下实现策略模式异常简单,毕竟 Spring 提供的依赖注入简直就是开发利器~

既然是策略模式,那么定义策略肯定是首当其冲,策略我们使用枚举实现最佳。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum GroupType {
/**
* 从 ES 查询
*/
ES,
/**
* 从 MongoDB 查询
*/
MONGODB,
/**
* 从 文件 读取
*/
FILE
}

下一步,我们定义一个接口,用于抽象通用的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public interface IGroupSelect {
/**
* 人群获取方式
*
* @return 人群选择枚举
*/
GroupType type();

/**
* 查询满足条件的用户
*
* @param groupQuery 查询条件
* @return 满足条件的用户
*/
default List<GroupUser> queryUser(GroupQuery groupQuery) {
checkQueryCondition(groupQuery);
return doQuery(groupQuery);
}

/**
* 事前校验查询条件
*
* @param groupQuery 查询条件
* @throws IllegalArgumentException 参数异常
*/
void checkQueryCondition(GroupQuery groupQuery) throws IllegalArgumentException;

/**
* 真正的查询方法
*
* @param groupQuery 查询条件
* @return 满足条件的用户
*/
List<GroupUser> doQuery(GroupQuery groupQuery);

}

这一步,小伙伴们有没有发现里面也包含了模板方法模式呢?

然后就是不同策略的具体实现了。

  • ES 策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Slf4j
@Service
public class EsGroupSelect implements IGroupSelect {

/**
* 人群获取方式
*
* @return 人群选择枚举
*/
@Override
public GroupType type() {
return GroupType.ES;
}

/**
* 事前校验查询条件
*
* @param groupQuery 查询条件
* @throws IllegalArgumentException 参数异常
*/
@Override
public void checkQueryCondition(GroupQuery groupQuery) throws IllegalArgumentException {
log.info("groupQuery = {}", groupQuery);
}

/**
* 查询满足条件的用户
*
* @param groupQuery 查询条件
* @return 满足条件的用户
*/
@Override
public List<GroupUser> doQuery(GroupQuery groupQuery) {
List<GroupUser> result = new ArrayList<>();
// TODO:
// 1. 复杂的 ES 查询逻辑
// 2. 根据条件筛选满足条件的用户数据
for (int i = 1; i <= 15; i++) {
result.add(GroupUser.of("ES用户" + i, i + "@es.com"));
}
return result;
}
}
  • 文件策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Slf4j
@Service
public class FileGroupSelect implements IGroupSelect {
/**
* 人群获取方式
*
* @return 人群选择枚举
*/
@Override
public GroupType type() {
return GroupType.FILE;
}

/**
* 事前校验查询条件
*
* @param groupQuery 查询条件
* @throws IllegalArgumentException 参数异常
*/
@Override
public void checkQueryCondition(GroupQuery groupQuery) throws IllegalArgumentException {
log.info("groupQuery = {}", groupQuery);
}

/**
* 查询满足条件的用户
*
* @param groupQuery 查询条件
* @return 满足条件的用户
*/
@Override
public List<GroupUser> doQuery(GroupQuery groupQuery) {
List<GroupUser> result = new ArrayList<>();
// TODO:
// 1. 复杂的解析、读文件
// 2. 根据条件筛选满足条件的用户数据
for (int i = 1; i <= 3; i++) {
result.add(GroupUser.of("文件读取用户" + i, i + "@file.com"));
}
return result;
}
}
  • MongoDB 策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Slf4j
@Service
public class MongoGroupSelect implements IGroupSelect {
/**
* 人群获取方式
*
* @return 人群选择枚举
*/
@Override
public GroupType type() {
return GroupType.MONGODB;
}

/**
* 事前校验查询条件
*
* @param groupQuery 查询条件
* @throws IllegalArgumentException 参数异常
*/
@Override
public void checkQueryCondition(GroupQuery groupQuery) throws IllegalArgumentException {
log.info("groupQuery = {}", groupQuery);
}

/**
* 查询满足条件的用户
*
* @param groupQuery 查询条件
* @return 满足条件的用户
*/
@Override
public List<GroupUser> doQuery(GroupQuery groupQuery) {
List<GroupUser> result = new ArrayList<>();
// TODO:
// 1. 复杂的 MongoDB 查询逻辑
// 2. 根据条件筛选满足条件的用户数据
for (int i = 1; i <= 7; i++) {
result.add(GroupUser.of("MongoDB用户" + i, i + "@mongo.com"));
}
return result;
}
}

现在到了最后一步,就是如何通过 Spring 优雅的实现策略模式的选择呢?敲黑板,考试必考!

我们通过定义一个工厂类,然后使用 Spring 的依赖注入特性,可以注入一个接口的多个实现,这里采用 List<IGroupSelect> 的形式注入,Spring 也支持通过 Map<String,IGroupSelect> 的形式注入,如果使用 Map 注入,那么 key 就是类名,小伙伴们自己也可以测试一下~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class GroupSelectFactory {
@Autowired
private List<IGroupSelect> groupSelectList;

/**
* 根据人群类型选择具体的实现类
*
* @param type 人群类型
* @return 人群选择具体实现类
*/
public IGroupSelect getGroupSelect(GroupType type) {
Optional<IGroupSelect> groupSelectOptional = groupSelectList.stream().filter(t -> t.type() == type).findAny();
return groupSelectOptional.orElseThrow(() -> new IllegalArgumentException("暂不支持该人群方式"));
}
}

最后写个定时任务测试一下吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Autowired
private GroupSelectFactory groupSelectFactory;

/**
* 模拟定时发送营销邮件
*/
@Scheduled(cron = "0/10 * * * * ?")
public void sendEmailTask() {
List<SendEmailTask> taskList = new ArrayList<>();
for (GroupType groupType : GroupType.values()) {
GroupQuery groupQuery = new GroupQuery("虚头巴脑的 " + groupType.name() + " 查询条件");
taskList.add(SendEmailTask.of(groupType, groupQuery));
}

taskList.forEach(task -> {
List<GroupUser> groupUsers = groupSelectFactory.getGroupSelect(task.getType()).queryUser(task.getQuery());
log.info("groupUsers = {}", groupUsers);
});

}

@Data
@NoArgsConstructor
@AllArgsConstructor(staticName = "of")
static class SendEmailTask implements Serializable {
private static final long serialVersionUID = -3461263089669779193L;
private GroupType type;
private GroupQuery query;
}

观察控制台,看看日志输出吧~

总结

  • 本文使用策略模式实现不同人群的查询,后续如果要增加短信、微信、钉钉的消息发送,是不是也可以用策略模式实现呢?

  • 使用 Spring 的依赖注入特性,可以注入一个接口的多个实现,很容易就实现了策略模式的选择,这样后续添加一种策略的时候,完全不需要改动主要逻辑,只需添加具体实现即可。

  • 细心的小伙伴可以发现,本文虽然是讲策略模式,其实里面还包含了模板方法、工厂模式,多种设计模式的协同作战,食用味道更佳哟~


配套代码:https://github.com/xkcoding/practice_demo/tree/master/strategy-design-pattern-in-spring

-------------本文结束  感谢您的阅读-------------
o(╯□╰)o我只要一毛钱的鼓励~~