🍀 简窝 Blog
📃 文章详情

Spring Boot 事件处理机制,实现代码解耦

背景

大家好,我是顾,最近 面试鸭 需要开发一个经验值体系的能力,需要对经验值进行增加、减少,有很多的触发情况,比如用户发布题解,用户浏览题目,用户邀请新用户、用户转化为会员、签到等等,为了复用经验系统的代码,以及和业务代码解耦,决定使用Spring Boot 的事件处理机制来处理各种经验值增减的逻辑。

介绍

在 Spring Boot 中,事件监听是一种 Spring 的机制,允许开发者定义和触发自定义事件,并通过注册监听器来响应这些事件。这种机制为应用程序中的事件处理提供了更高的解耦性,使代码更加清晰和灵活。Spring的事件监听机制是在JDK事件监听的基础上进行的扩展,也是使用了观察者模式并在其基础上进行进一步抽象和改进。

流程

创建自定义的事件类

把经验体系的属性封装在自定义的事件类中,通过实现 ApplicationEvent 表示这是经验值事件类

/**
 * 经验值事件类
 *
 * @author gulihua
 * @date 2024-11-25 14:27
 */
@Getter
public class ExperienceEvent extends ApplicationEvent {

    /**
     * 经验类型枚举
     */
    private UserExperienceEnum userExperience;
    /**
     * 用户ID
     */
    private Long userId;
    /**
     * 业务ID(操作的对象ID)
     */
    private Long bizId;
    /**
     * 描述
     */
    private String desc;
    /**
     * 参数
     */
    private UserExperienceRecordParamsDTO param;
    /**
     * 登录用户
     */
    private User loginUser;
    /**
     * 自定义原因
     */
    private UserExperienceRecordReasonEnum reason;
    /**
     * 是否是取消经验值事件
     */
    private boolean isCancel;

    public ExperienceEvent(Object source, UserExperienceEnum userExperience, Long userId, Long bizId, String desc, UserExperienceRecordParamsDTO param, User loginUser, boolean isCancel) {
        super(source);
        this.userExperience = userExperience;
        this.userId = userId;
        this.bizId = bizId;
        this.desc = desc;
        this.param = param;
        this.loginUser = loginUser;
        this.isCancel = isCancel;
    }

    public ExperienceEvent(Object source, UserExperienceEnum userExperience, Long userId, Long bizId, String desc, UserExperienceRecordParamsDTO param, User loginUser, UserExperienceRecordReasonEnum reason, boolean isCancel) {
        super(source);
        this.userExperience = userExperience;
        this.userId = userId;
        this.bizId = bizId;
        this.desc = desc;
        this.param = param;
        this.loginUser = loginUser;
        this.reason = reason;
        this.isCancel = isCancel;
    }
}

创建事件监听器

创建一个自定义的事件监听器,用来处理经验增加、扣除事件。如果是取消事件,需要把经验值改成负的。


/**
 * 经验值事件监听器
 *
 * @author gulihua
 * @date 2024-11-25 14:28
 */
@Slf4j
@Service
public class ExperienceEventListener implements ApplicationListener<ExperienceEvent> {

    @Resource
    private UserExperienceRecordService userExperienceRecordService;
    @Resource
    private RedissonClient redissonClient;

    @Override
    public void onApplicationEvent(ExperienceEvent experienceEvent) {
        UserExperienceRecord userExperienceRecord = new UserExperienceRecord();
        userExperienceRecord.setExperience(experienceEvent.getUserExperience().getValue());
        userExperienceRecord.setDescription(experienceEvent.getDesc());
        userExperienceRecord.setReason(experienceEvent.getUserExperience().getAddReason());
        userExperienceRecord.setUserId(experienceEvent.getUserId());
        userExperienceRecord.setBizId(experienceEvent.getBizId());
        userExperienceRecord.setExperienceType(experienceEvent.getUserExperience().getType());
        String params = JSONUtil.toJsonStr(experienceEvent.getParam());
        userExperienceRecord.setParams(params);
        // 取消操作
        if (experienceEvent.isCancel()) {
            userExperienceRecord.setExperience(userExperienceRecord.getExperience() * -1);
            userExperienceRecord.setReason(experienceEvent.getUserExperience().getCancelReason());
        }
        // 自定义原因
        if (null != experienceEvent.getReason()) {
            userExperienceRecord.setReason(experienceEvent.getReason().getValue());
        }
        userExperienceRecordService.addUserExperienceRecord(userExperienceRecord, experienceEvent.getLoginUser());
    }

}

经验值记录具体数据库的操作,包括添加记录、添加经验值数据

@Override
public int addUserExperienceRecord(UserExperienceRecord userExperienceRecord, User loginUser) {
    // 参数校验:用户要存在,经验值要合理
    Integer experience = userExperienceRecord.getExperience();
    Integer reason = userExperienceRecord.getReason();
    Long userId = userExperienceRecord.getUserId();
    log.info("userExperienceRecord params = {}", userExperienceRecord);
    if (experience > MAX_ADD_EXPERIENCE || experience == 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    if (UserExperienceRecordReasonEnum.getEnumByValue(reason) == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    if (userId <= 0) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    User user = userService.getById(userId);
    if (user == null) {
        throw new BusinessException(ErrorCode.PARAMS_ERROR);
    }
    // 往用户经验值记录表插入记录
    int insertRows = this.baseMapper.insert(userExperienceRecord);
    if (insertRows <= 0) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "数据插入失败");
    }
    // 修改用户表的经验值
    UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
    updateWrapper.eq("id", userId);
    if (experience > 0) {
        updateWrapper.setSql(String.format("experience = experience + %d", experience));
    } else {
        updateWrapper.setSql(String.format("experience = experience - %d", Math.abs(experience)));
    }
    boolean updateResult = userService.update(updateWrapper);
    if (!updateResult) {
        throw new BusinessException(ErrorCode.SYSTEM_ERROR, "数据更新失败");
    }
    return experience;
}

因为有些经验值一天是有获取上限的,不然用户可能会重复刷,所以,我们需要对有些经验值的类型去做判断,比如刷题,用户每天获取的上限是1个积分,发布题解,上限是6个积分,所以就需要在获取积分时去做判断今天是否达到了上限值。



    /**
     * 核实是否要增加记录
     *
     * @return 是否执行后续的操作
     */
    private boolean check(ExperienceEvent experienceEvent) {
        // 发布
        if (!experienceEvent.isCancel()) {
            // 只能执行一次
            if (ExperienceLimitTypeEnum.ONLY_ONE.equals(experienceEvent.getUserExperience().getLimitType())) {
                // 判断之前有没对目标做过操作
                QueryWrapper<UserExperienceRecord> oldRecord = new QueryWrapper<>();
                oldRecord.eq("userId", experienceEvent.getUserId());
                oldRecord.eq("bizId", experienceEvent.getBizId());
                oldRecord.eq("experienceType", experienceEvent.getUserExperience().getType());
                if (userExperienceRecordService.count(oldRecord) > 0) {
                    return false;
                }
            } else if (ExperienceLimitTypeEnum.DAY.equals(experienceEvent.getUserExperience().getLimitType())) {
                // 每天经验上限
                Date now = new Date();
                Date startDate = DateUtil.beginOfDay(now);
                Date endDate = DateUtil.endOfDay(now);
                Long sum = userExperienceRecordService.sumUserTodayExperienceRecord(startDate, endDate, experienceEvent.getUserExperience().getType(), experienceEvent.getUserId());
                if (sum != null && sum >= experienceEvent.getUserExperience().getDayMaxExperience()) {
                    return false;
                }
            }
        } else {
            Long sum = userExperienceRecordService.sumUserExperienceRecord(experienceEvent.getBizId(), experienceEvent.getUserExperience().getType(), experienceEvent.getUserId());
            if (sum == null || sum <= 0) {
                return false;
            }
        }

        return true;
    }

创建事件发布者

这个类主要用来发布具体的事件,包括发布增加经验值事件,扣除经验值事件

/**
 * 经验值事件发布者
 *
 * @author gulihua
 * @date 2024-11-25 14:54
 */
@Slf4j
@Component
@AllArgsConstructor
public class ExperienceEventPublisher {
    private ApplicationEventPublisher publisher;

    /**
     * 发布增加经验值事件
     *
     * @param userExperience 经验值枚举
     * @param userId         增加经验值的用户ID
     * @param bizId          业务ID
     * @param desc           描述
     * @param param          参数
     * @param loginUser      登录用户
     **/
    public void publishAddEvent(UserExperienceEnum userExperience, Long userId, Long bizId, String desc, UserExperienceRecordParamsDTO param, User loginUser) {
        ExperienceEvent customEvent = new ExperienceEvent(this, userExperience, userId, bizId, desc, param, loginUser, false);
        publisher.publishEvent(customEvent);
    }

    /**
     * 发布扣除经验值事件
     *
     * @param userExperience 经验值枚举
     * @param userId         扣除经验值的用户ID
     * @param bizId          业务ID
     * @param desc           描述
     * @param param          参数
     * @param loginUser      登录用户
     **/
    public void publishCancelEvent(UserExperienceEnum userExperience, Long userId, Long bizId, String desc, UserExperienceRecordParamsDTO param, User loginUser) {
        ExperienceEvent customEvent = new ExperienceEvent(this, userExperience, userId, bizId, desc, param, loginUser, true);
        publisher.publishEvent(customEvent);
    }

经验值类型枚举

在经验值事件处理中,有一个很重要的字段,那就是经验类型的枚举,很多的经验事件的处理都是围绕这个枚举值来判断的,与其说是枚举类,不如说是经验类型的配置类。里面包含了经验值类型,经验值原因,经验值的大小,每天经验值的获取上限等等。

 
/**
 * 用户经验值枚举
 *
 * @author gulihua
 */
public enum UserExperienceEnum {
    /**
     * 题解被设置为推荐答案
     */
    BEST_ANSWER(2, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getBestAnswer(),
            UserExperienceRecordReasonEnum.BEST_ANSWER.getValue(),
            UserExperienceRecordReasonEnum.CANCEL_BEST_ANSWER.getValue(),
            ExperienceLimitTypeEnum.NOT_LIMIT
    ),

    /**
     * 题解被精选
     */
    GOOD_ANSWER(3, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getGoodAnswer(),
            UserExperienceRecordReasonEnum.GOOD_ANSWER.getValue(),
            UserExperienceRecordReasonEnum.CANCEL_GOOD_ANSWER.getValue(),
            ExperienceLimitTypeEnum.NOT_LIMIT
    ),
    /**
     * 发布一篇回答讨论
     */
    ADD_ANSWER(4, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getAddAnswer(),
            UserExperienceRecordReasonEnum.ADD_ANSWER.getValue(),
            UserExperienceRecordReasonEnum.CANCEL_ADD_ANSWER.getValue(),
            ExperienceLimitTypeEnum.DAY,
            6,
            false

    ),

        /**
     * 题解被置顶
     */
    TOP_ANSWER(8, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getTopAnswer(),
            UserExperienceRecordReasonEnum.TOP_ANSWER.getValue(),
            UserExperienceRecordReasonEnum.CANCEL_TOP_ANSWER.getValue(),
            ExperienceLimitTypeEnum.NOT_LIMIT
    ),
    
    /**
     * 邀请新用户注册
     */
    INVITE_USER(5, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getInviteUser(),
            UserExperienceRecordReasonEnum.INVITE_USER.getValue(),
            UserExperienceRecordReasonEnum.CANCEL_INVITE_USER.getValue(),
            ExperienceLimitTypeEnum.NOT_LIMIT
    ),
    /**
     * 邀请用户成为会员
     */
    INVITE_USER_VIP(6, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getInviteUserVip(),
            UserExperienceRecordReasonEnum.INVITE_USER_VIP.getValue(),
            UserExperienceRecordReasonEnum.CANCEL_INVITE_USER_VIP.getValue(),
            ExperienceLimitTypeEnum.NOT_LIMIT
    ),

    /**
     * 签到
     */
    SIAN_IN(15, UserExperienceRecordServiceImpl.USER_EXPERIENCE_RULE.getSignin(),
            UserExperienceRecordReasonEnum.SIGN_IN.getValue(),
            UserExperienceRecordReasonEnum.CANCEL_SYSTEM.getValue(),
            ExperienceLimitTypeEnum.DAY,
            true,
            1
    ),
// ...  其他类型的枚举
    ;

    /**
     * 经验类型
     */
    private int type;
    /**
     * 经验值
     */
    private int value;
    /**
     * 添加经验值原因
     */
    private int addReason;

    /**
     * 扣除经验值原因
     */
    private int cancelReason;

    /**
     * 经验值获取上限类型
     */
    private ExperienceLimitTypeEnum limitType;

    /**
     * 是否限制操作次数(优先级比经验值上限高)
     */
    private boolean isLimitOperation;

    /**
     * 限制操作的次数
     */
    private int operationLimit;

    /**
     * 每天经验值上限
     */
    private int dayMaxExperience;


    public int getOperationLimit() {
        return this.operationLimit;
    }

    public ExperienceLimitTypeEnum getLimitType() {
        return this.limitType;
    }

    public int getValue() {
        return this.value;
    }

    public int getAddReason() {
        return this.addReason;
    }

    public int getCancelReason() {
        return this.cancelReason;
    }


    public boolean getIsLimitOperation() {
        return this.isLimitOperation;
    }

    public int getType() {
        return this.type;
    }

    public int getDayMaxExperience() {
        return this.dayMaxExperience;
    }

    UserExperienceEnum(int type, int value, int addReason, int cancelReason, ExperienceLimitTypeEnum limitType) {
        this.type = type;
        this.value = value;
        this.addReason = addReason;
        this.cancelReason = cancelReason;
        this.limitType = limitType;
    }

    UserExperienceEnum(int type, int value, int addReason, int cancelReason, ExperienceLimitTypeEnum limitType, int dayMaxExperience, boolean isLimitOperation) {
        this.type = type;
        this.value = value;
        this.addReason = addReason;
        this.cancelReason = cancelReason;
        this.dayMaxExperience = dayMaxExperience;
        this.limitType = limitType;
        this.isLimitOperation = isLimitOperation;
    }

    UserExperienceEnum(int type, int value, int addReason, int cancelReason, ExperienceLimitTypeEnum limitType, boolean isLimitOperation, int operationLimit) {
        this.type = type;
        this.value = value;
        this.addReason = addReason;
        this.cancelReason = cancelReason;
        this.limitType = limitType;
        this.isLimitOperation = isLimitOperation;
        this.operationLimit = operationLimit;
    }

调用

比如在发布题解时,需要增加经验值,直接调用发布事件的方法即可,指定类型枚举

/**
     * 每日首次发布题解,增加经验值
     *
     * @param questionAnswer 答案
     * @param loginUser      登录用户
     **/
    private void addAnswerExperience(QuestionAnswer questionAnswer, User loginUser) {
        String url = String.format("/question/%s?comment=%s", questionAnswer.getQuestionId(), questionAnswer.getId());

        String descriptionTemplate = "发布一篇回答讨论,<a href=\"%s\" target=\"_blank\">查看该讨论</a>";
        String description = String.format(descriptionTemplate, url);
        UserExperienceRecordParamsDTO paramsDTO = new UserExperienceRecordParamsDTO();
        paramsDTO.setQuestionId(questionAnswer.getQuestionId());
        paramsDTO.setAnswerId(questionAnswer.getId());

        experienceEventPublisher.publishAddEvent(
                UserExperienceEnum.ADD_ANSWER,
                questionAnswer.getUserId(),
                questionAnswer.getId(),
                description,
                paramsDTO,
                loginUser
        );
    }

在需要扣除经验值的时候,比如题解被删除时,调用扣除经验值的事件,可以指定自定义的原因


    /**
     * 题解被删除,扣除经验值
     *
     * @param oldQuestionAnswer 答案
     * @param loginUser         登录用户
     **/
    private void deleteAnswerCancelExperience(QuestionAnswer oldQuestionAnswer, User loginUser) {
        String url = String.format("/question/%s", oldQuestionAnswer.getQuestionId());
        UserExperienceRecordParamsDTO paramsDTO = new UserExperienceRecordParamsDTO();
        paramsDTO.setQuestionId(oldQuestionAnswer.getQuestionId());
        paramsDTO.setAnswerId(oldQuestionAnswer.getId());

        UserExperienceEnum userAddAnswerExperienceEnum = UserExperienceEnum.ADD_ANSWER;
        String descriptionTemplate = "回答讨论被删除,扣除经验值,<a href=\"%s\" target=\"_blank\">查看题目</a>";
        String description = String.format(descriptionTemplate, url);
        experienceEventPublisher.publishCancelEvent(
                userAddAnswerExperienceEnum,
                oldQuestionAnswer.getUserId(),
                oldQuestionAnswer.getId(),
                description,
                paramsDTO,
                UserExperienceRecordReasonEnum.DELETE_ANSWER,
                loginUser
        );
    }

异步处理

经验值的增加减少并不是用户操作时立马关心的,如果经验值处理的时间过长也会影响请求的响应时间,会阻塞主线程,所以可以把触发事件处理成异步的操作,首先可以创建一个异步的线程池配置,专门用来处理经验值的相关事件,使用@EnableAsync来开启异步支持,它会由Spring创建一个代理,在单独的线程中去执行任务。

@Configuration    
@EnableAsync    
public class AsyncConfig implements AsyncConfigurer {
    // 经验值线程池
    @Bean("experienceThreadPool")
    public TaskExecutor experienceThreadPool() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(10);
        executor.setKeepAliveSeconds(100);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("experiencePool-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        executor.initialize();
        return executor;
    }
}

然后再需要异步处理的方法上加上@Async("experienceThreadPool")注解,这样这个方法会在刚刚自定义的线程池中去执行。

    @Override
    @Async("experienceThreadPool")
    public void onApplicationEvent(ExperienceEvent experienceEvent) {
        UserExperienceRecord userExperienceRecord = new UserExperienceRecord();
        userExperienceRecord.setExperience(experienceEvent.getUserExperience().getValue());
        userExperienceRecord.setDescription(experienceEvent.getDesc());
        userExperienceRecord.setReason(experienceEvent.getUserExperience().getAddReason());
        userExperienceRecord.setUserId(experienceEvent.getUserId());
        userExperienceRecord.setBizId(experienceEvent.getBizId());
        userExperienceRecord.setExperienceType(experienceEvent.getUserExperience().getType());
        String params = JSONUtil.toJsonStr(experienceEvent.getParam());
        userExperienceRecord.setParams(params);
        // 取消操作
        if (experienceEvent.isCancel()) {
            userExperienceRecord.setExperience(userExperienceRecord.getExperience() * -1);
            userExperienceRecord.setReason(experienceEvent.getUserExperience().getCancelReason());
        }
        // 自定义原因
        if (null != experienceEvent.getReason()) {
            userExperienceRecord.setReason(experienceEvent.getReason().getValue());
        }
        userExperienceRecordService.addUserExperienceRecord(userExperienceRecord, experienceEvent.getLoginUser());
    }

总结

使用 Spring 的异步事件处理将一些耗时的操作从主线程中分离出来,避免阻塞主线程,从而提高系统的吞吐量。对于不需要立即完成的任务,比如说经验值的处理,操作日志的记录等,可以进行异步处理,从而提高用户体验。它最重要的功能就是解耦,Spring Event 允许模块之间通过事件通信,减少模块之间的直接依赖,提高代码的可维护性和可扩展性,发布者只需要发布事件即可,并不需要关心具体的业务实现。

📑 目录