基于责任链设计模式与策略模式的抽奖系统项目
1.项目系统架构

前端:使用JavaScript管理各界面的动态性,使用ajax技术从后端API获取数据。
后端:采用SpringBoot3构建后端应用,实现业务逻辑
数据库:使用数据库作为主数据库,存储用户数据和活动信息
缓存:使用Redis作为缓存层,减少数据库访问次数
消息队列:使用RabbitMQ处理异步任务,如处理抽奖行为
日志与安全:使用JWT进行用户验证,使用SLF4J+logback完成日志。
2.准备工作
2.1自定义错误码即信息
在抽奖项目中,为了方便后面快速辨认哪一层次出现异常或者报错,我们给controller层(ControllerErroorCode)和service层(ServiceErroorCode)都设置了各自的错误码即错误信息,我们也定义了一个GlobalErroorCode全局的错误码,去辨认一些我们预料不到的错误。

2.2自定义异常
在项目中,我们也给controller层和service层去设置一个自定义异常,让ControllerException自定义异常去处理controller层抛出的异常,让ServiceException自定义异常去处理service层抛出的异常。

需要注意的是,在继承的关系中使用@Data注解时,此时只会重写子类属性中的equal和hashcode方法,如果我们需要用到父类属性中的equal和hashcode方法,我们需要在子类上加上@EqualAndHashCode注解,并且callsuper=true;

2.3对象的序列化
为了更好的答应日志,模仿Spring实现了一个JacksonUtil工具类来实现对象的序列化和反序列化。
3.注册和登录接口的实现思路
3.1 注册接口
注册时,由于要将数据插入数据库中,项目中,我们对手机号进行对称加密,对密码进行MD5加密,将这两个数据加密后,在出入数据中。
注册接口实现的时序图

实现注册接口时,专门为其创建了注册请求类和返回结果类,如一下图片

实现注册接口的controller层代码

在service层实现具体的逻辑

在service层中,我们会另外创建一个impl包,里面是Service的接口中具体的实现。
实现注册接口的逻辑
首先我们要先对传入的参数进行校验,此时要进行的校验有邮箱格式,手机号格式,身份信息,校验管理员是否输入密码,校验密码格式,校验注册的邮箱是否被已经被注册,校验手机号是否已经被注册过。
在校验邮箱格式,手机号格式和密码格式时,我们用到了一个叫则表达式,具体实如下,也可以理解为一个工具类,校验邮箱格式,手机号格式和密码格式时,我们直接调用RegexUtil里面的方法就行了。
public class RegexUtil {
/**
* 邮箱:xxx@xx.xxx(形如:abc@qq.com)
*
* @param content
* @return
*/
public static boolean checkMail(String content) {
if (!StringUtils.hasText(content)) {
return false;
}
/**
* ^ 表示匹配字符串的开始。
* [a-z0-9]+ 表示匹配一个或多个小写字母或数字。
* ([._\\-]*[a-z0-9])* 表示匹配零次或多次下述模式:一个点、下划线、反斜杠或短横线,后面跟着一个或多个小写字母或数字。这部分是可选的,并且可以重复出现。
* @ 字符字面量,表示电子邮件地址中必须包含的"@"符号。
* ([a-z0-9]+[-a-z0-9]*[a-z0-9]+.) 表示匹配一个或多个小写字母或数字,后面可以跟着零个或多个短横线或小写字母和数字,然后是一个小写字母或数字,最后是一个点。这是匹配域名的一部分。
* {1,63} 表示前面的模式重复1到63次,这是对顶级域名长度的限制。
* [a-z0-9]+ 表示匹配一个或多个小写字母或数字,这是顶级域名的开始部分。
* $ 表示匹配字符串的结束。
*/
String regex = "^[a-z0-9]+([._\\\\-]*[a-z0-9])*@([a-z0-9]+[-a-z0-9]*[a-z0-9]+.){1,63}[a-z0-9]+$";
return Pattern.matches(regex, content);
}
/**
* 手机号码以1开头的11位数字
*
* @param content
* @return
*/
public static boolean checkMobile(String content) {
if (!StringUtils.hasText(content)) {
return false;
}
/**
* ^ 表示匹配字符串的开始。
* 1 表示手机号码以数字1开头。
* [3|4|5|6|7|8|9] 表示接下来的数字是3到9之间的任意一个数字。这是中国大陆手机号码的第二位数字,通常用来区分不同的运营商。
* [0-9]{9} 表示后面跟着9个0到9之间的任意数字,这代表手机号码的剩余部分。
* $ 表示匹配字符串的结束。
*/
String regex = "^1[3|4|5|6|7|8|9][0-9]{9}$";
return Pattern.matches(regex, content);
}
/**
* 密码强度正则,6到12位
*
* @param content
* @return
*/
public static boolean checkPassword(String content){
if (!StringUtils.hasText(content)) {
return false;
}
/**
* ^ 表示匹配字符串的开始。
* [0-9A-Za-z] 表示匹配的字符可以是:
* 0-9:任意一个数字(0到9)。
* A-Z:任意一个大写字母(从A到Z)。
* a-z:任意一个小写字母(从a到z)。
* {6,12} 表示前面的字符集合(数字、大写字母和小写字母)可以重复出现6到12次。
* $ 表示匹配字符串的结束。
*/
String regex= "^[0-9A-Za-z]{6,12}$";
return Pattern.matches(regex, content);
}
}
在校验身份信息时,我们只需要去遍历传过来的身份信息是否在枚举类就行了。

在校验邮箱是否已经被注册时,我们只需要取数据库user表查询是否存在该邮箱账号即可。
但是校验手机号是否被注册时有点不一样,手机号是一个私密信息,是加密存储在数据库中的,所以这时我们要用到TypeHandler来辅助实现校验手机号是否被注册过,此时,我们在TypeHandler实现一些加密解密的逻辑,此时Mybatis遇到Encrypt类时会自动帮我们实现加密和解密。

参数校验成功后,我们就要往表中插入数据了,如下图

由于service层返回的是registerDTO类型,在controller层要进行类型转换。

此时注册接口完成了。
3.2 登录接口
在这个模块,我们实现了两个登录模式,一个是账号名+密码登录,另一个是验证码登录。
在实现登录时,由于两个登录形式的请求参数都有一个共同参数,就是身份信息,可以将身份提取出来,这样后面就可以实现一个service接口有两个具体的实现,如下图

此时就可以如下图涉及设计service层接口

3.2.1 账号名+密码登录
controller层的代码

实现思路
因为手机号和邮箱账号都是唯一信息,所以,为了兼容性,userLoginName也可以是手机号或者邮箱账号,首先判断userLoginName是手机号还是邮箱。这一步也是通过正则表达式来完成的,也就是通过自己实现的RegeUtil工具类来实现的。

校验完loginUserName之后,接着,我们就要判断传过来的参数中的身份信息信息是否与数据库中查询出来的身份信息是否相同,然后再去校验用户输入的密码是否正确,最后,我们在生成JWT令牌,由前端保存在Header中。

3.2.2 验证码登录
在验证码登录中,短信的发送使用了阿里云提供的短信服务来完成的,验证码的生成借用了Hootool中提供的工具,且验证码是存储在redis中的,在项目中我们实现了一个service接口,将验证码的生成,发送和校验都集中在一起了,如一下代码
@Service
public class VerificationCodeServiceImpl implements VerificationCodeService {
private static String VERIFICATION_CODE_PREFIX="VerificationCode";
private static Long VERIFICATION_CODE_TIMEOUT=60L;
private static String VERIFICATION_CODE_TEMPLATE_CODE="SMS_465324787";
@Autowired
private SMSUtil smsUtil;
@Autowired
private RedisUtil redisUtil;
@Override
public void sendVerificationCode(String phoneNumber) {
//1.校验手机号
if (!RegexUtil.checkMobile(phoneNumber)){
throw new ServiceException(ServiceErrorCodeConstants.PHONE_NUMBER_ERROR);
}
//2.生成验证码,调用MyCapture
String code= MyCaptureUtil.getCapture(4);
//3.发送验证码
Map<String,String> map=new HashMap<>();
map.put("code","code");
if (StringUtils.hasText(code)){
smsUtil.sendMessage(VERIFICATION_CODE_TEMPLATE_CODE,phoneNumber, JacksonUtil.writeValueAsString(map));
}
//4.缓存验证码
redisUtil.set(VERIFICATION_CODE_PREFIX+phoneNumber,code,VERIFICATION_CODE_TIMEOUT);
}
@Override
public String getVerificationCode(String phoneNumber) {
//1.校验手机号
if (!RegexUtil.checkMobile(phoneNumber)){
throw new ServiceException(ServiceErrorCodeConstants.PHONE_NUMBER_ERROR);
}
return redisUtil.get(VERIFICATION_CODE_PREFIX+phoneNumber);
}
}
实现短信验证的逻辑
首先还是要验证手机号的格式,校验身份信息,然后校验验证码是否正确,最后也是生成一个JWT令牌返回给客户端。
注意事项:将验证码存储在redis中时,建议不要将phoneNumber作为key存储在redis中,因为如果后面还有要通过手机号作为key的服务,此时就会出现value被覆盖的现象,此时我们要设置一个前缀,用手机号+前缀作为存储的key。
3.3 获取人员信息
controller层代码

实现逻辑
在获取人员信息这个接口的实现中,我们是根据用户的身份信息去查询人员信息的,假如身份信息是admin,此时从数据库中查询的人员身份都是admin,如果传来的参数没有带有identity的信息,则代表查询user表中的所有人员信息。
此时,我们还是要创建一个一DTO为结尾的类,让controller层调用,创建的类如下图

这个UserDTO类已经将user表中所有的关于user属性都包含在一起了,因为这是一个查询用户信息的接口,在这个接口中,我们只需要用到userId,userName和identity这三个属性,如果以后要实现一个接口,获取用户的其他信息,我们可以调用这个接口就行了。
注意,这里的identity建议用我们实现的一个关于身份的枚举,这样就让我们的代码更有安全性,让身份信息都掌握在自己的手中。
还有一个今天一个新的理解,这里的phoneNumber为String类型,由于前面我们实现了一个TypeHandler,在TypeHandler我们实现了解密和加密的逻辑,当进行表的插入操作时,如果遇到Encrypt类型时,Mybatis会自动帮我们加密, 当我们进行数据库查询操作时,由于Encrypt类型对应到数据库的类型时VARCHAR(实现TypeHandler时设置的),由于注册时,我们将手机号封装成Encrypt类型,在插入数据库前,Mybatis自动将手机号加密,在存储到数据库中,当从数据库中查询手机号这个信息时,由于数据库中的手机号是VARCHAR类型,所以查询的结果中,之前在数据库加密存储的手机号,此时Mybatis也会帮我们进行解密在返回给我们,这样我们就得到了手机号的具体值了。
还有一个收货,在进行数据库的增删查改时,我们都要创建一个以DO为结尾的类,用来存储从数据库中查询到的信息,因为这样更符合规范,也可以说是解耦。
4. 奖品接口
4.1 实现图片上传
实现图片的上传,我们主要使用了Spring中给我们提供的MultipartFile类来实现,具体代码如下
以后复习项目的时候一定要看里面的注解,很重要,有自己的理解。
@Component
public class PictureServiceImpl implements PictureService {
@Value("${pic.local-path}")
private String localPath;
@Override
public String savePicture(MultipartFile multipartFile) {
//创建目录(一个路径层次),只是创建,无法保证已有该目录
File dir=new File(localPath);
//判断有无该目录,无则创建
if (!dir.exists()){
dir.mkdirs();
}
//创建索引
//将图图片的名称作为索引
String filename = multipartFile.getOriginalFilename();
assert filename!=null;
String suffix = filename.substring(filename.lastIndexOf("."));
//UUID.randomUUID()用来生成全员唯一标识符
filename = UUID.randomUUID()+suffix;
//保存图片
try {
multipartFile.transferTo(new File(localPath+"/"+filename));
} catch (IOException e) {
throw new ServiceException(ServiceErrorCodeConstants.PICTURE_UPLOAD_ERROR);
}
return filename;
}
}
实现图片上传时,我们也专门做成了一个接口

3.2 奖品的创建
时序图


注意这里的param是一个表单数据,传参时,我们要用一个@RequestPart来注明这个参数是表单数据类型,
controller层代码

创建奖品时,由于设计到了表的插入操作,所以我们要创建一个PrizeDo的类,专门为数据库操作而创建的类。
创建奖品的逻辑
先上传奖品图片,接着再将奖品信息存储在数据库中。

3.3 获取奖品列表
时序图

获取奖品列表时设计到一个翻页操作,所以后端接口在实现获取奖品列表时,要设计一个类来存储客户端传过来的currentPage和pageSize。
controller层代码
在service中具体实现获取奖品列表的逻辑,首先,我们要现货区奖品的总数量total,total是用来和pageSize(一页奖品的数量)配合求出此时奖品的分配会有多少页,接着,我们要根据offset和pageSize从数据库中进行分也查询,其中offset=(currentPage-1)*pageSize。
service层代码

SQL语句

在实现这个接口时,service层专门设计了一个PageListDTO类,如果以后还有接口设计到分页的操作,在service层就可以在去使用PageListDTO类即可。

5.创建活动
创建活动设计的数据如下图

创建活动的时序图

controller层代码

在service层实现具体的业务
首先,在创建活动的这个接口中,设计到多张数据库表的操作,设计到的表有activity表,prize表,activity_prize表和activity_user表,所以,我们希望当其中有一张表的操作出现异常时,希望数据库事务能够进行回滚,所以我们要将所有的数据库操作放在一个事务中,我们通过@Transactional(rollbackFor = Exception.class)注解来实现

5.1 创建活动第一步---校验活动信息
创建活动设计的类

创建活动时,我们要校验参与活动的人员是否在user表中,与活动关联的奖品是否在prize表中,奖品的等级是否在我们定义的枚举类型中,以及奖品的数量是否小于或等于人员的数量,出现不符合上面的情况,我们都希望抛出异常。
创建活动时,传过来的参数是里面包含的是一个人员的列表和奖品的列表,为了校验人员和奖品是否合法,我们要分别去user表和prize表中查询,获取到这两张表中的所有主键id,得到的结果也是一张列表,然后我们将传过来的人员Id,奖品Id和从数据库中获取到的人员id,奖品id一次去比较就行了。

接着我们在去判断奖品的数量是否小于等于人员数量,以及奖品的等级是否在我们定义的枚举类型中。

5.2 保存活动信息,活动相关人员信息,活动相关奖品信息
构造出相关的xxxDO类,然后让xxxDO作为参数传过去,进行相关表的插入操作即可。
保存活动信息

保存活动关联奖品信息,往avtivity_prize表中插数据

保存活动关联人员信息。往activity_user表中插数据

5.3 整合活动信息
为什么要整合活动信息,是为了以后抽奖活动进行准备,我们会将将整合的活动信息存储到redis缓存中。
整合的活动信息包括活动信息,人员信息和奖品信息。
进行整合活动的实现时,设计了一个ActivityDetailDTO来保存整合活动相关的信息,但是由于ActivityDetailDTO的一些关于奖品的基本属性在奖品活动关联表中不存在,在活动表中存在,所以,我们要先从数据库中去查询,获取prize的基本信息,并prize的基本信息传过去
其中ActivityDetailDTO是专门用来整合活动信息所创建的类,ActivityDetailDTO包含了抽奖活动(活动id,活动name,活动描述,活动的状态),与抽奖活动关联的奖品(一个List类型)和与抽奖活动关联的人员(一个List类型)
ActivityDetailDTO类如下
public class ActivityDetailDTO implements Serializable {
//保存活动相关信息
private Long activityId;
private String activityName;
private String desc;
private ActivityStatusEnum status;
//活动奖品相关信息
List<PrizeDTO> prizeDTOList;
//人员相关信息
List<UserDTO> userDTOList;
//判断活动是否有效
public boolean valid(){
return status.equals(ActivityStatusEnum.RUNNING);
}
@Data
public static class PrizeDTO{
private Long prizeId;
private String description;
private String imageUrl;
private String name;
private BigDecimal price;
private ActivityPrizeTiersEnum tiers;
private ActivityPrizeStatusEnum status;
private Long prizeAmount;
//判断奖品是否有效
public boolean valid(){
return status.equals(ActivityPrizeStatusEnum.INIT);
}
}
@Data
public static class UserDTO{
private Long userId;
private String userName;
private ActivityUserStatusEnum status;
//判断活动人员是否已经抽过奖
public boolean valid(){
return status.equals(ActivityUserStatusEnum.INIT);
}
}
}


我们首先整合活动信息

接着在去整合与活动关联奖品信息,在整合与活动相关联的奖品时,我们也要去整合activityPrizeDOList中关于奖品的信息,也要去整合activityPrizeDOList中缺少的prize信息,这部分呢缺少的prize信息我们通过prizeDOList来获取,但是由于activityPrizeDOList和prizeDOList都是List类型,我们是要将与活动关联的奖品信息整合,与活动不关联的奖品信息我们不需要整合,所以我们要先从prizeDOList获取到与活动关联的奖品,我们通过id来确定,我们也要进行一个去重的操作,因为我们是通过prizeDO.getId().equals(apDO.getPrizeId()),因为外面有一个apDO的循环遍历,而apDO里面一个奖品的数量可以设置多个,此时查询出来的optionalPrizeDO就会用多个相同的prizeDO的信息,我们只需要获取第一个即可。

接着整合与活动相关的人员信息

整合活动信息的方法
private ActivityDetailDTO convertToActivityDetailDTO(ActivityDO activityDO,
List<PrizeDO> prizeDOList,
List<ActivityPrizeDO> activityPrizeDOList,
List<ActivityUserDO> activityUserDOList) {
//整合活动相关信息
ActivityDetailDTO activityDetailDTO =new ActivityDetailDTO();
activityDetailDTO.setActivityId(activityDO.getId());
activityDetailDTO.setActivityName(activityDO.getActivityName());
activityDetailDTO.setDesc(activityDO.getDescription());
activityDetailDTO.setStatus(ActivityStatusEnum.forName(activityDO.getStatus()));
//整合奖品相关信息
//apDO:{prizeID,amount,status},{prizeID,amount,status}
//prizeDo:{prizeId,name......},{prizeId,name......}
List<ActivityDetailDTO.PrizeDTO> prizeDTOList = activityPrizeDOList.stream()
.map(apDO ->{
ActivityDetailDTO.PrizeDTO prizeDTO = new ActivityDetailDTO.PrizeDTO();
prizeDTO.setPrizeId(apDO.getPrizeId());
Optional<PrizeDO> optionalPrizeDO= prizeDOList.stream()
.filter(prizeDO -> prizeDO.getId().equals(apDO.getPrizeId()))
.findFirst();
//如果prizeDO为空,则不执行ifPresent方法,不为空才执行ifPresent方法
optionalPrizeDO.ifPresent(prizeDO -> {
prizeDTO.setDescription(prizeDO.getDescription());
prizeDTO.setImageUrl(prizeDO.getImageUrl());
prizeDTO.setName(prizeDO.getName());
prizeDTO.setPrice(prizeDO.getPrice());
});
prizeDTO.setTiers(ActivityPrizeTiersEnum.forName(apDO.getPrizeTiers()));
prizeDTO.setStatus(ActivityPrizeStatusEnum.forName(apDO.getStatus()));
prizeDTO.setPrizeAmount(apDO.getPrizeAmount());
return prizeDTO;
}).collect(Collectors.toList());
activityDetailDTO.setPrizeDTOList(prizeDTOList);
//整合人员相关信息
List<ActivityDetailDTO.UserDTO> userDTOList = activityUserDOList.stream()
.map(activityUserDO -> {
ActivityDetailDTO.UserDTO userDTO = new ActivityDetailDTO.UserDTO();
userDTO.setUserId(activityUserDO.getUserId());
userDTO.setUserName(activityUserDO.getUserName());
userDTO.setStatus(ActivityUserStatusEnum.forName(activityUserDO.getStatus()));
return userDTO;
}).collect(Collectors.toList());
activityDetailDTO.setUserDTOList(userDTOList);
return activityDetailDTO;
}
5.4 将整合的活动信息存储到redis中
储存时,我们将ACTIVITY_ + activityId作为key来存储到redis中
问题思考:redis存储失败,我们要不要抛异常呢?
答案是不用。
如果抛出异常,关于数据库的操作就是就会进行事务的回滚,那我们到底要不要让数据库的操作进行回滚呢?
答案也是不用。
因为我们已经将活动整合的信息存储到数据库中了,假设未来用户在查询用户信息时,由于活动信息是存储在redis中,当用户本来想查询一个抽奖活动的信息时,本来这个抽奖活动应该是存储在redis中的,但是由于某些原因存储失败了,所以,从redis中就获取到了一个null,此时直接去数据库中查询就行了,从数据库中查到了活动信息,接着在将其存储到redis里面就行了,因为数据库中本来就存储了抽奖活动的信息,所以以后查询抽奖活动信息到的接口时, 我们就要考虑到抽奖活动存储到redis失败这一点。
通过以上分析,我们就知道当活动存储到redis失败时,我们不需要让事务进行回滚,也就是不用抛异常,我们直接catch住异常就行了

5.5 从redis中获取抽奖活动信息

service整体代码
@Override
@Transactional(rollbackFor = Exception.class)
public ActivityCreateDTO createActivity(CreateActivityParam param) {
//1.校验活动信息
checkActivityInfo(param);
//2.保存活动信息
ActivityDO activityDO=new ActivityDO();
activityDO.setActivityName(param.getActivityName());
activityDO.setDescription(param.getDescription());
activityDO.setStatus(ActivityStatusEnum.RUNNING.name());
activityMapper.insert(activityDO);
//3.保存活动关联的奖品信息
List<CreatePrizeByActivityParam> prizeParams = param.getActivityPrizeList();
List<ActivityPrizeDO> activityPrizeDOList = prizeParams.stream()
.map(createPrizeByActivityParam -> {
ActivityPrizeDO activityPrizeDO = new ActivityPrizeDO();
activityPrizeDO.setActivityId(activityDO.getId());
activityPrizeDO.setPrizeId(createPrizeByActivityParam.getPrizeId());
activityPrizeDO.setPrizeAmount(createPrizeByActivityParam.getPrizeAmount());
activityPrizeDO.setPrizeTiers(createPrizeByActivityParam.getPrizeTiers());
activityPrizeDO.setStatus(ActivityPrizeStatusEnum.INIT.name());
return activityPrizeDO;
}).collect(Collectors.toList());
activityPrizeMapper.batchInsert(activityPrizeDOList);
//4.保存活动关联人员信息
List<CreateUserByActivityParam> userParams = param.getActivityUserList();
List<ActivityUserDO> activityUserDOList = userParams.stream()
.map(userParam ->{
ActivityUserDO activityUserDO = new ActivityUserDO();
activityUserDO.setActivityId(activityDO.getId());
activityUserDO.setUserId(userParam.getUserId());
activityUserDO.setUserName(userParam.getUserName());
activityUserDO.setStatus(ActivityUserStatusEnum.INIT.name());
return activityUserDO;
}).collect(Collectors.toList());
activityUserMapper.batchInsert(activityUserDOList);
//5.整合活动信息,存放在redis中,为以后的抽将做准备
//ActivityDetailDTO:活动+人员+奖品
//由于ActivityDetailDTO的一些关于活动的基本属性在奖品活动关联表中不存在,在活动表中存在,所以,我们要先从数据库中去查询,获取prize的基本信息
List<Long> prizeIds = activityPrizeDOList.stream()
.map(ActivityPrizeDO::getPrizeId)
.distinct()
.collect(Collectors.toList());
List<PrizeDO> prizeDOList = prizeMapper.batchSelectByIds(prizeIds);
ActivityDetailDTO detailDTO=convertToActivityDetailDTO(activityDO,prizeDOList,activityPrizeDOList,activityUserDOList);
//将整合的活动信息存储在redis中
cacheActivity(detailDTO);
//构造返回
ActivityCreateDTO activityCreateDTO = new ActivityCreateDTO();
activityCreateDTO.setActivityId(activityDO.getId());
return activityCreateDTO;
}
6.实现抽奖活动
在实现抽奖功能时,后端需要提供三个接口,分别是查询某个抽奖活动的完整信息,保存抽奖信息(扭转状态,比如一个奖品被抽过了,就要将该奖品在数据库的状态扭转为以被抽取状态)和查询中奖列表。
6.1查询某个抽奖活动的完整信息
在查询抽奖活动的完整信息时,我们要先从redis中去查询,如果在redis中查到我们想要的活动的完整信息1,我们直接返回结果即可,如果我们没有从redis中成功获取到活动的信息,我们就要去查表(prize表,user表,activity_prize表和activity_user表),然后重新整合活动信息,重新存储到redis中,然后再返回整合的活动信息。

6.2 保存中奖信息
在抽奖项目中,前端要向后端确认中奖者,最终才敢将中奖名单显示出来,但是此时,后端要处理的事情有点多,此时后端就不能迅速的给前端返回响应,就会导致前端等待一点时间,等到后端返回响应后,才能将中奖者名单展现出来,但是这样用户的体验不好,我们希望当前端点击确认中奖时,前端就立刻能够将中奖者名单展现出来。
此时,我们就可以异步去处理抽奖流程。
当前端向后端发送一个确认中奖者名单时,后端直接就给前端返会一个响应,此时前端后面就可以立刻将中奖者名单展现出来,由于是异步处理抽将流程的,后面就会找个合适的时间去处理抽奖流程(保存中奖信息,扭转对应状态和完成通知行为) ,在异步处理抽奖流程中,我们必须成功,如果失败,我们就得想办法去处理。
这个异步处理抽奖流程通过RabbitMQ来实现,如下图

6.3 处理抽奖流程---责任链设计模式与策略模式
首先,我们要对param中的参数进行校验,判断请求是否有效,我们要对活动,活动奖品,活动人员和活动人员数量与活动奖品数量是否匹配进行校验。
DrawPrizeParam类是用来记录活动中奖的信息的,这些信息包括活动信息,奖品信息和中奖人员(是一个List类型)
如下图

其次,我们要进行状态的扭转(非常重要,这部分用到了责任链设计模式和策略模式) ,要扭转状态的对象有3个,分别是活动奖品状态,活动人员状态,活动状态,针对与活动奖品和活动人员状态的转换,先要判断活动奖品的状态和活动人员的转态是否不是COMPLETED,如果不是,我们才进行状态的扭转,如果是,我们就不进行状态的扭转。但是,活动的转态扭转有一点特殊,活动转态的扭转必须等活动奖品的状态全部扭转完之后,才能进行活动的扭转,也就是说活动状态的扭转依赖于活动奖品状态的扭转。
如果我们直接按部就班的按以上的思路来编写代码,那么此时代码会有两个问题。
第一个问题:活动状态的扭转依赖于奖品状态的扭转,这样会导致代码的维护性差,因为活动状态的扭转依赖于多个决策点,因此这里的状态的扭转顺序很重要,这就导致了代码的不易维护。
我们可以通过责任链设计模式和策略模式解决以上问题,具体细节如下图
在责任链模式中,我们将扭转状态的顺序写死,在每一个AbstractActivityOperator的子类中重写下图的sequence方法,将需要先扭转状态的sequence方法的返回值设置为1,

此时为什么要多创建一个currMap呢?
因为后面要将状态扭转完后的对象从map中删除,我们希望operatorMap是保持不变的,所以我们多创建一个currMap。
由于后面是通过遍历currMap来进行状态扭转的,在currMap存的是AbstractActivityOperator的子类信息,在遍历currMap时,currMap中所有sequence方法的返回值为1的AbstractActivityOperator的子类都会先进行状态扭转,因为状态扭转的顺序如上图已经写死了,在这之后,才回去处理活动状态的扭转。

这样,如果以后会增加其他条件的状态扭转之后,活动状态才能进行扭转,此时,我们只需要创建其他AbstractActivityOperator的子类,重写下图中的两个方法。
因为后面判断状态是否需要扭转是通过遍历operator来处理的,此时,我们就不用去修改主程序的代码,只需要去增加几个子类就行了,这样代码的拓展性和维护性就变高了。

其中sequence方法是责任链设计模式的体现,needConvert方法是策略模式的体现,。
策略模式是怎么体现的呢?
每一个AbstractActivityOperator的子类中都重写了needConvert,这是用来判断是否要实现状态扭转的方法,如果需要状态扭转就返回true,不需要状态扭转就返回false。
如果没有按照策略模式来写代码,那么进行活动状态的扭转时的if条件中一定会包含其他对象状态是否已经成功扭转的条件,如果我们通过策略模式,通过创建几个AbstractActivityOperator的子类,通过重写的needConvert方法来判断是否需要进行转态的扭转就行了,如果后面还要增加活动状态扭转的条件,我们也还只是需要多创建几个AbstractActivityOperator的子类就行了。
我的理解
感觉核心就是AbstractActivityOperator和那个Map,因为map中存储了需要进行状态转换的对象信息,所以,我们只需要遍历map即可完成状态扭转的操作,后续还要增加其他要进行状态扭转的对象时,我们只需要将其注入到map中即可


活动相关状态扭转的代码流程图



6.3处理抽奖流程---策略模式和责任链模式
1.策略模式的定义
策略模式就是将类中一些会因为未来实际需求发生变化而导致需要大改的方法抽象出来,将其抽象成一个抽象类,并且将每种可能得变化对应地交给抽象类的一个子类去负责。
对应到我的项目中,由于活动状态的扭转依赖于活动奖品的状态和活动人员的状态,只有活动奖品的状态和活动人员的状态都处于完成状态,此时才能扭转活动状态。
如果此时直接在一口气在一个方法中既完成活动人员的扭转和活动奖品状态的扭转,此时再去扭转活动状态,但是如果未来需求发生了变化,活动状态的扭转又增加了一些其他条件,此时可能就需要在这个方法中增加很多代码,且在判断活动能否进行状态扭转时中的if里面填写的条件中一定会包含其他甚至更多其他对象状态是否扭转成功的条件
为了解决这个问题,此时就可以使用策略者模式,此时通过创建一个抽象类AbstractActivity0perator,将状态扭转的方法放到抽象类中
该抽象类中有3个抽象方法,分别是控制状态扭转顺序的方法(责任链模式的体现),判断是否需要进行状态扭转的方法和进行状态扭转的方法
此时根据需求,有哪些对象需要进行状态扭转,就创建该抽象类对应的子类并重写抽象类的方法即可。
以我的抽奖项目为例,有活动奖品和活动人员和活动状态需要扭转,就创建对应的子类,ActivityOperator、PrizeOperator、UserOperator,每个子类根据自己的扭转策略去重写对应方法。让对应的子类去完成对应对象的状态扭转即可,如果未来需求发生了变化,此时只需根据需求的变化去增加对应的子类即可

这种让一个子类完成对应对象的状态扭转,大大提升了代码的解耦性,也方便了未来根据需求变化对代码的维护
2.责任链模式
责任链模式是一种行为设计模式,它允许一个请求沿着处里者对象组成的责任链进行传递。每个处里者对象都有责任去处理请求,在处理请求时,如果符合处理请求,此时处里者对象就处理该请求,如果不符合处理的请求,将请求传递给下一个处里者对象去处理
以我的项目为例
处里者对象的体现,有3个处理者对象,就是使用策略模式中创建的那几个子类,并且都实现了needConvert和convertStatus方法,这两个方法是分别用来判断能否进行状态扭转和进行状态扭转的方法
责任链的体现:由于活动状态的扭转必须等活动奖品的状态和活动人员的状态都扭转成功后,活动的状态才能进行扭转,由于每个子类中都重写了sequence方法,其中活动奖品状态扭转子类和活动人员状态扭转子类的sequence的方法的返回值都是1,活动状态扭转子类的sequence方法的返回值是2,我将这些处里者对象都存储到一个哈希表里面,此时就可以按照每个处理者队形中sequence方法的返回值来维护状态扭转的顺序
请求的传递的体现:由于将所有的处理者存储到一个哈希表里面了,此时通过迭代器遍历该哈希表中的处理者对象,每遍历到一个处里者对象,就调用该处里者对象的needConvert方法,根据请求里面的参数来判断当前该处里者是否能处理该请求
处理请求的体现:如果needConvert方法返回true,说明此时符合状态扭转的条件,此时就调用处里者对象中的状态扭转的方法,实现状态的扭转,每次一个处里者成功处理一个请求,此时就要将该处里者从责任链中剔除,也就是将该处里者对象中从哈希表中删掉,防止请求被重复处理
且如果某个处理者在处理请求时发生了异常,此时就抛出异常,并且进行状态的回滚,此时也可以看做责任链的终止
6.4 保存中奖者名单----saveWinnerRecords(DrawPrizeParam param)
为了获取中奖者信息,我们要去查询4个表分别是user表,prize表,activity_prize表和activity表,然后构造中奖信息,并将中奖信息信息存到winning_record表中。
此时,我们还要将中奖信息存储在redis,我们要分两个维度来存储,一个是奖品维度来存储,另一个是活动维度来存储,奖品维度是为了单个中奖信息存储的,而活动维度是为了一整个抽奖活动的中奖信息准备的,不过以活动维度来讲信息存储到redis时,我们要等抽奖活动的状态为COMPLETED时,才能存储到redis中。

6.5通知中奖者信息----并发处理
保存完中奖者信息之后,我们就要去通知中奖者,通知中奖者,我们分别通过短信和邮件的形式去通知中奖者,这一部分我们通过线程池来并发来实现短信和邮件的方式去通知中奖者。

6.6实现消息回滚
当抽奖行为或者在状态扭转时出现异常时,我们实现状态回滚,如果出现异常,有两个方面需要进行回滚,分别是状态回滚(活动状态,人员状态,奖品状态)与中奖者名单进行回滚。
在进行状态回滚前,我们都要去判断是否需要进行状态回滚或者进行中奖者名单回滚。
1.状态回滚
按照正常思路,如果我们要判断活动,人员或者奖品是否需要进行状态回滚时,我们可能要去查3张表,但是我们在处理人员和奖品的状态扭转时,是有一个事务的一致性的,也就是说,人员和奖品的状态一定是同时扭转成功或者失败(不包括活动),所以我们只需要去判断其中一个即可,对于活动是否需要进行状态扭转,如果此时奖品的状态进行了回滚,那么活动的状态也是要进行回滚的。

进行状态扭转时,我们也使用了一个简单的策略模式,同时,还要更新缓存的内容。

2.中奖者名单回滚
如果抽奖行为发生异常,如何判断中奖者名单需要进行回滚呢?
只需要通过activityId和prizeId去对应的数据库表中去查,看有没有数据即可,如果有数据就要进行数据的回滚,没有就不用进行回滚。
回滚的操作就是删除表中对应的数据即可。
6.7实现死信队列
如果我们在想mq的发送消息的过程中发生异常,此时正常队列就会将发送失败的消息给丢弃掉,此时,我们就要设计一个死信队列去存储发送异常的消息,后续将死信队列与正常队列的交换机进行绑定,接着设计一个死信对列消费者,在死信队列中去处理发送异常的消息(无非就是将死信队列中的消息重新发送给正常对列去处理)
但是上面的设计是一个实时的过程,也就是一个消息发送异常,就会立即丢个死信队列,然后死信对列有奖发送异常的消息重新发送给正常的队列去处理消息,这个过程中会不断出现异常,没有给我们解决bug的时间。
解决思路
1.接收到异常消息后,可以将异常消息存储到数据库表中
2.存放后,当前异常消息消费完成,死信队列消息处理完成,但异常消息被我们持久化存储到数据库表中了
3.去解决异常
4.解决完异常后,我们在去判断死信对列中是否存在数据,如果存在,表示有消息未处理,此时就去处理消息
5.处理消息:将消息发送给普通队列进行处理
6.8查询活动的中奖信息
我们也是以两个角度来实现查询活动的中奖信息,分别是奖品维度和活动维度,奖品维度就是查询一个奖品的中奖信息,活动维度就是查询一整个活动的中奖信息。
步骤:首先,我们先从redis中去查询有没有存储到相关的中奖信息,如果redis中没有存储到相关的中奖信息,此时就要去从数据库中查询相关的表,并整合相关的中奖信息存储到redis中。

更多推荐


所有评论(0)