一、需求
最近接到一个开发任务,需求是实现课程通知,基于同学已导入的课表,在每个同学上课前一段时间在微信公众号给对应同学发送上课提醒。提前的时间可由同学自己设置,换而言之,定时的时间是不确定的,需要先获取到用户设置的提前时间。并且,由于每天的上课时间点是固定的,即使同学可自定义提前通知时间,但系统仍可能会在短时间内触发多个定时任务,需要考虑性能。(ps:本文只讲述该功能的思路以及实现,不涉及微信公众号的配置)
二、技术选型
笔者目前了解的定时任务有三种方案。基于Quartz、基于延迟队列以及基于Redis Key过期的回调。
-
基于Quartz:对于当前需求,使用Quartz实现则需要根据用户设置动态注册多个job,并且可能在短期触发多个,而据Quartz官方文档描述,Quartz并不适合做短期大量的定时任务,可能存在性能问题,故不考虑Quartz。
The clustering feature works best for scaling out long-running and/or cpu-intensive jobs (distributing the work-load over multiple nodes). If you need to scale out to support thousands of short-running (e.g 1 second) jobs, consider partitioning the set of jobs by using multiple distinct schedulers (including multiple clustered schedulers for HA). The scheduler makes use of a cluster-wide lock, a pattern that degrades performance as you add more nodes (when going beyond about three nodes - depending upon your database’s capabilities, etc.).
-
基于延迟队列:使用延迟队列实现有两种方式。第一种方式是使用多个不同TTL的队列来定时,而对于当前需求,可能会创建过多的队列以及交换机来进行路由。第二种方式则是对一个队列中多个消息分别设置TTL,由于消息队列中的消息具有顺序性,可能会出现前面的消息还没到消费时间无法消费而导致后面的消息也无法消费的问题。综合上述思考,也不适合使用延迟队列进行实现。
-
基于Redis Key过期的回调:由于Redis的key采用的过期策略是定期过期和惰性过期,在短时间内多个Key过期触发定时任务也能保持较好的性能和实时性,因此综合考虑之后选择采用Redis Key过期回调来进行实现。
三、使用Redis Key过期回调实现定时任务需要注意的问题
Redis的Key过期回调是发生在Key过期之后,这就意味着,当我们拿到过期的Key时,无法通过过期的Key去获取其对应的值,也就是我们无法获取到该Key对应的课程通知数据。
那么我们该如何解决这个问题呢?笔者采用的方法是存储两个Key,一个Key为定时Key,有过期时间,对应的Value为空,而另一个Key为数据Key,无过期时间,对应的Value为我们所存储的课程通知数据。我们使用定时Key来实现定时,流程大致为:当定时Key过期时,我们拿到定时Key,通过定时Key获取到数据Key,再用数据Key去拿取数据,最后不要忘了将数据Key在Redis中删除即可。
四、时序图
该功能实现主要包含的Java对象有:CourseNoticeService、KeyExpirationDispatcher、WechatMsgSender(实际上还包含了数据访问对象CourseNoticeDao、微信公众号配置WechatConfig等等,但精简后仅需以上三个Java对象即可大致说明流程)。
五、主要代码实现
为避免篇幅过大,以及仅展示一些主要实现代码,相对次要的代码就不进行展示了(例如常量类、依赖的其他服务等等,实际上涉及到的常量名以及方法名基本都见名知义,仅看名称不看具体实现也不影响阅读)。
- KeyExpirationDispatcher:监听Key的过期,并将过期的Key分发给对应处理器进行处理
@Component
public class KeyExpirationDispatcher extends KeyExpirationEventMessageListener {
private final Map<String, KeyExpirationHandler> keyExpirationHandlers = new HashMap<>();
@Resource
private CourseNoticeService courseNoticeService;
public KeyExpirationDispatcher(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
dispatchExpiredKey(message.toString());
}
@PostConstruct
private void initMap() {
keyExpirationHandlers.put(COURSE_NOTICE_TIMING_KEY_PREFIX, courseNoticeService);
}
public void dispatchExpiredKey(String expiredKey) {
for(String key: keyExpirationHandlers.keySet()) {
if(expiredKey.startsWith(key)) {
keyExpirationHandlers.get(key).handleKeyExpiration(expiredKey);
break;
}
}
}
}
- KeyExpirationHandler:Key过期处理器接口,仅包含一个方法,即对过期Key的处理。CourseNoticeService接口继承了该接口。
public interface KeyExpirationHandler {
/**
* 用于处理过期的key
*
* @param expiredKey 过期的key
*/
void handleKeyExpiration(String expiredKey);
}
- WechatMsgSender:提供了向微信发起通知请求的接口
@Component
@Slf4j
public class WechatMsgSender {
@Resource
private WechatConfig wechatConfig;
private static final String REQUEST_URL_PREFIX = " https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=";
/**
* 请求微信向对应用户发送模板消息
*
* @param keywords 关键词,可以理解成模板消息中的变量
* @param templateId 模板消息的id
* @param openId 用户在公众号中的openId,指定了接收的用户
* @return 是否请求成功
*/
public boolean sendMsg(Map<String, Object> keywords, String templateId, String openId) {
String token = wechatConfig.getAccessToken();
String topcolor = wechatConfig.getTopcolor();
String keywordColor = wechatConfig.getKeywordColor();
String requestUrl = REQUEST_URL_PREFIX + token;
//将keywords封装为请求时所需的格式
Map<String, Object> data = new HashMap<>();
keywords.forEach((key, val) -> data.put(key, Map.of("value", val != null ? val : "-", "color", keywordColor)));
//请求内容
JSONObject content = new JSONObject();
content.put("touser",openId);
content.put("topcolor", topcolor);
content.put("template_id", templateId);
content.put("data", data);
//发起请求
log.info("[发送微信通知请求], 请求内容为:" + content.toJSONString());
String resp = HttpUtil.post(requestUrl, content.toJSONString());
log.info("[接收微信通知响应], 响应内容为:" + resp);
//是否请求成功
if(resp != null) {
JSONObject response = JSON.parseObject(resp);
return "OK".equalsIgnoreCase(response.getString("errmsg"));
} else{
return false;
}
}
}
- CourseNotice:实体对象,存放发送课程通知所需的数据
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CourseNotice {
/**
* 学号,唯一识别一个学生
*/
@NotNull
private String studentId;
/**
* 课程id,唯一识别一个学生的一个科目的一个时间的课
*/
@NotNull
private int courseId;
/**
* openId,在微信公众号中唯一识别一个微信用户
*/
private String openId;
/**
* 课程名称
*/
private String courseName;
/**
* 上课地点
*/
private String location;
/**
* 上课时间
*/
private String time;
}
- CourseNoticeDao:封装了关于CourseNotice数据对Redis的访问。CourseNoticeService需要依赖其来实现功能,但无须关注访问Redis的细节。
@Repository
@Validated
@Slf4j
public class CourseNoticeDao {
@Resource
private RedisTemplate<String, String> redisTemplate;
/**
* 一份数据对应两个key,一个为定时任务所需,其value为空串,另一个为存储数据所需,其value为通知数据
* key的格式分别:timing:course:notice:[学号]:[课程id]、 course:notice:[学号]:[课程id]
*/
private static final String TIMING_KEY_PREFIX = "timing:";
private static final String KEY_PREFIX = "course:notice:";
private static final String KEY_PATTERN = KEY_PREFIX + "%s:%s";
private String getKey(String studentId, String course) {
return String.format(KEY_PATTERN, studentId, course);
}
private String getTimingKey(String studentId, String course) {
return TIMING_KEY_PREFIX + getKey(studentId, course);
}
private String getKeyByTimingKey(String timingKey) {
return timingKey.substring(timingKey.indexOf(':') + 1);
}
private String getTimingKeyByKey(String key) {
return TIMING_KEY_PREFIX + key;
}
/**
* 获取所有的键
*/
@Transactional
private Set<String> getAllKeys() {
return getAllKeysWithStudentId("*");
}
/**
* 获取指定学生的所有键
*/
@Transactional
private Set<String> getAllKeysWithStudentId(String studentId) {
Set<String> keySet = redisTemplate.keys(getKey(studentId, "*"));
Set<String> timingKeySet = redisTemplate.keys(getTimingKey(studentId, "*"));
Set<String> resultSet;
if(keySet != null && timingKeySet != null) {
keySet.addAll(timingKeySet);
resultSet = keySet;
} else {
resultSet = keySet != null ? keySet : timingKeySet;
}
return resultSet;
}
/**
* 根据过期的Key来获取数据,并将对应数据Key删除
*/
@Transactional
public CourseNotice queryByTimingKey(String timingKey) {
//根据定时Key获取数据Key
String key = getKeyByTimingKey(timingKey);
//获取数据
String jsonData = redisTemplate.opsForValue().get(key);
if(jsonData == null) {
throw new RuntimeException("无法通过定时任务键找到该课程通知数据!");
}
CourseNotice courseNotice = JSONObject.parseObject(jsonData, CourseNotice.class);
redisTemplate.delete(key);
return courseNotice;
}
/**
* 插入单条数据,包括用于定时的键值以及用于存储数据的键值
*
* @param courseNotice 数据
* @param noticeTime 预期提醒时间
* @return 是否插入成功
*/
@Transactional
public boolean insert(@NotNull @Validated CourseNotice courseNotice,
@NotNull LocalTime noticeTime) {
String studentId = courseNotice.getStudentId();
String courseId = String.valueOf(courseNotice.getCourseId());
String key = getKey(studentId, courseId);
String timingKey = getTimingKey(studentId, courseId);
//校验当前时间是否已晚于预期提醒时间
LocalTime now = LocalTime.now();
if(now.isAfter(noticeTime)) {
log.warn("当前时间晚于预期提醒时间!data:{}", courseNotice);
return false;
}
redisTemplate.opsForValue().set(key, JSONObject.toJSONString(courseNotice));
//根据当前时间与预期提醒时间计算间隔分钟数,以此作为过期时间
redisTemplate.opsForValue().set(timingKey, "", Duration.between(now, noticeTime).toSeconds(), TimeUnit.SECONDS);
return true;
}
/**
* 批量插入数据
*
* @param courseNotices 课程通知数据列表
* @param noticeTimeList 预期提醒时间列表
* @return 是否全插入成功
*/
@Transactional
public boolean insertBatch(@NotNull List<CourseNotice> courseNotices, List<LocalTime> noticeTimeList) {
if(noticeTimeList.size() != courseNotices.size()) {
log.warn("课程通知列表与提前时间列表长度不一致!courseNotices:{},noticeTimeList:{}", courseNotices, noticeTimeList);
return false;
}
for(int i = 0; i < noticeTimeList.size(); i++) {
if(courseNotices.get(i) == null || noticeTimeList.get(i) == null) {
continue;
}
insert(courseNotices.get(i), noticeTimeList.get(i));
}
return true;
}
/**
* 删除所有数据
*/
@Transactional
public boolean removeAll() {
return removeAllWithStudentId("*");
}
/**
* 删除指定学生的所有数据
*/
@Transactional
public boolean removeAllWithStudentId(String studentId) {
Set<String> keys = getAllKeysWithStudentId(studentId);
if(keys == null || keys.size() == 0) {
return true;
}
Long rows = redisTemplate.delete(keys);
return rows != null && keys.size() == rows;
}
/**
* 根据课程id删除单条数据
*/
@Transactional
public boolean remove(int courseId) {
String key = getKey("*", String.valueOf(courseId));
String timingKey = getTimingKeyByKey(key);
redisTemplate.delete(key);
redisTemplate.delete(timingKey);
return true;
}
}
- CourseNoticeService:课程通知服务接口,规定了课程通知实现类所需实现的方法,并继承了KeyExpirationHandler接口,提供了处理过期Key的方法。
public interface CourseNoticeService extends KeyExpirationHandler {
/**
* 删除所有课程通知数据
*
* @return 是否删除成功
*/
boolean removeAll();
/**
* 根据学号删除相关通知数据
*
* @param studentId 学号
* @return 是否删除成功
*/
boolean removeAllWithStudentId(String studentId);
/**
* 根据课程id删除指定通知数据
*
* @param courseId 课程id
* @return 是否删除成功
*/
boolean remove(int courseId);
/**
* 填充所有学生的当天所有课程到Redis中
*/
void insertCourseNoticeToRedis();
/**
* 填充指定学生当天指定时间之后的课程通知数据到Redis中
*
* @param studentId
* @param localTime
*/
void insertCourseNoticeToRedis(String studentId, LocalTime localTime);
/**
* 根据学号刷新其课程通知数据(学生课程表有时会更改,此时其课程通知数据也应该刷新)
*
* @param studentId 学号
*/
void flushCourseNoticeByStudentId(String studentId);
}
- CourseNoticeServiceImpl:CourseNoticeService的实现类,包含了将数据填充到Redis、处理定时Key过期时的回调等等。
@Service
@EnableScheduling
@Slf4j
public class CourseNoticeServiceImpl implements CourseNoticeService {
@Resource
private CourseNoticeDao courseNoticeDao;
@Resource
private CourseService courseService;
@Resource
private ConfigurationService configurationService;
@Resource
private WechatUserService wechatUserService;
@Resource
private WechatConfig wechatConfig;
@Resource
private WechatMsgSender wechatMsgSender;
@Resource
private UserConfigDao userConfigDao;
//上课时间表
private static final LocalTime[] classTimes = {
LocalTime.of(8, 30),
LocalTime.of(9, 15),
LocalTime.of(10, 15),
LocalTime.of(11, 0),
LocalTime.of(11, 45),
LocalTime.of(13, 30),
LocalTime.of(14, 15),
LocalTime.of(15, 0),
LocalTime.of(16, 0),
LocalTime.of(16, 45),
LocalTime.of(19, 0),
LocalTime.of(19, 40),
LocalTime.of(20, 30),
LocalTime.of(21, 10)
};
//上课开始时间
private static final String[] classStartTimes = {
"08:30",
"09:15",
"10:15",
"11:00",
"11:45",
"13:30",
"14:15",
"15:00",
"16:00",
"16:45",
"19:00",
"19:40",
"20:30",
"21:10"
};
//上课结束时间
private static final String[] classEndTimes = {
"09:10",
"09:55",
"10:55",
"11:40",
"12:25",
"14:10",
"14:55",
"15:40",
"16:40",
"17:25",
"19:40",
"20:20",
"21:10",
"21:45"
};
/**
* 每天三点填充当天课程通知数据到Redis的定时任务
*/
@Scheduled(cron = "0 0 3 * * ?")
public void insertCourseNoticeToRedis() {
log.info("[定时任务]: 填充课程通知数据");
insertCourseNoticeToRedis(null, LocalTime.now());
}
/**
* 用于处理key过期事件,向微信用户发送提醒
*
* @param expiredKey 过期的key
*/
@Override
public void handleKeyExpiration(String expiredKey) {
CourseNotice courseNotice = courseNoticeDao.queryByTimingKey(expiredKey);
log.info("[触发课程通知], data: {}", courseNotice);
//构造消息内容
Map<String, Object> keywords = new HashMap<>();
keywords.put("first", "上课提醒");
keywords.put("keyword1", courseNotice.getCourseName());
keywords.put("keyword2", courseNotice.getTime());
keywords.put("keyword3", courseNotice.getLocation());
wechatMsgSender.sendMsg(keywords, wechatConfig.getCourseNoticeTemplateId(), courseNotice.getOpenId());
}
@Override
public boolean removeAll() {
return courseNoticeDao.removeAll();
}
@Override
public boolean removeAllWithStudentId(String studentId) {
return courseNoticeDao.removeAllWithStudentId(studentId);
}
@Override
public boolean remove(int courseId) {
return courseNoticeDao.remove(courseId);
}
/**
* 根据学生课表填充课程通知数据到Redis中
*
* @param studentId 所需填充课程通知数据的学号,为null时填充所有学生的数据
* @param startTime 从什么时候的课开始填充,一般为now
*/
@Override
@Transactional
public void insertCourseNoticeToRedis(String studentId, LocalTime startTime) {
//获取当前学期 (需要由管理人员每学期维护)
Configuration configuration = configurationService.queryById(ConfigurationName.CURRENT_TERM_NAME);
if (configuration == null) {
throw new RuntimeException("未设置当前学期号!");
}
String term = configuration.getContent();
//获取当天为第几周
int week = courseService.queryNowWeek(term);
//获取当天为星期几
LocalDateTime localDateTime = LocalDateTime.now();
int weekday = localDateTime.getDayOfWeek().getValue() % 7;
//获取课程列表
Course queryParam = new Course();
queryParam.setWeekday(weekday);
queryParam.setTerm(term);
queryParam.setStudentId(studentId);
List<Course> courses = courseService.queryNoticeList(queryParam, ENABLE_COURSE_NOTICE_NAME);
//获取提前时间列表并转化为hash
UserConfig param = new UserConfig();
param.setConfigName(COURSE_NOTICE_TIMING_MINUTES_NAME);
List<UserConfig> userConfigs = userConfigDao.queryAll(param);
Map<String, UserConfig> configMap = userConfigs.stream().collect(Collectors.toMap(UserConfig::getUserId, c -> c));
//过滤无需发送通知的课程数据
courses = courses.stream().filter(course -> {
if (course.getStudentId() == null
|| course.getClassWeekStart() == null || course.getClassWeekEnd() == null
|| course.getClassTimeStart() == null || course.getClassTimeEnd() == null
|| course.getWeekday() == null || course.getBiweekly() == null) {
return false;
}
boolean flag = course.getClassWeekStart() <= week && course.getClassWeekEnd() >= week;
flag = flag && (course.getBiweekly() == 0 || ((course.getBiweekly() % 2) == (week % 2)));
return flag;
}).collect(Collectors.toList());
//数据转化
List<LocalTime> noticeTimeList = new ArrayList<>();
List<CourseNotice> courseNotices = new ArrayList<>();
for(Course course: courses) {
WechatUser wechatUser = wechatUserService.queryById(course.getStudentId());
if (wechatUser == null) {
continue;
}
//构造数据
CourseNotice courseNotice = new CourseNotice();
courseNotice.setCourseId(course.getId());
courseNotice.setStudentId(course.getStudentId());
courseNotice.setCourseName(course.getCourseName() != null ? course.getCourseName() : "-");
courseNotice.setLocation(course.getLocation() != null ? course.getLocation() : "-");
String timePattern = "[%d-%d节]%s-%s";
int start = course.getClassTimeStart();
int end = course.getClassTimeEnd();
courseNotice.setTime(String.format(timePattern, start, end, classStartTimes[start - 1], classEndTimes[end - 1]));
courseNotice.setOpenId(wechatUser.getH5Openid());
UserConfig timingConfig = configMap.get(course.getStudentId());
//设置的提前时间,若不存在则使用默认值
int timingMinutes = timingConfig != null ? timingConfig.getIntValue() : DEFAULT_NOTICE_TIMING_MINUTES;
//预期提醒时间
LocalTime noticeTime = classTimes[course.getClassTimeStart() - 1].minusMinutes(timingMinutes);
//若提醒时间早于开始提醒时间,则跳过
if(startTime != null && startTime.isAfter(noticeTime)) {
continue;
}
courseNotices.add(courseNotice);
noticeTimeList.add(noticeTime);
}
//填充到Redis
courseNoticeDao.insertBatch(courseNotices, noticeTimeList);
}
@Override
@Transactional
public void flushCourseNoticeByStudentId(String studentId) {
removeAllWithStudentId(studentId);
insertCourseNoticeToRedis(studentId, LocalTime.now());
}
}
六、完成效果
假如我在10月3日早上8:30在致理楼有一门名为软件工程的课,而我设置的提前提醒时间为30分钟,那我就会在8:00时收到下面这么一条公众号通知。
以上就是本文的全部内容了,笔者目前是一名大三在校生,由于才疏学浅,思路以及代码难免有纰漏。如果文中有讲得不对的地方,或是您有更好的想法,欢迎向笔者提出建议。