问题背景
当先的课表系统中,有一项自动开关门的功能。系统每 2 分钟执行一次巡检
如果在开关门过程中失败了,会进行钉钉消息的推送提醒
现在我们面临一个这个的问题:
08:00 失败
08:02 失败
08:04 失败
08:06 失败
...
钉钉会一直接收失败的信息:
A322 开门失败
A322 开门失败
A322 开门失败
A322 开门失败
...此时,管理群中的消息会发生爆炸💥
问题的关键
让我们来看看之前的失败处理机制:
/**
* 处理单个教室的门状态
* 逻辑说明:
* 1. 根据教室查询该教室所有门的数据。
* 2. 根据当前日期和时间判断教室门是否应该常开(期望状态 expectedOpen)。
* 3. 遍历每一扇门:
* a. 获取门对应的控制器信息(DoorDevice)。
* - 如果门没有绑定控制器,跳过。
* b. 查询控制器上门的真实状态 realOpen(boolean,true=开,false=非开)。
* c. 如果控制器状态与期望状态不一致,向控制器下发指令进行状态修正:
* - 如果期望开门,设置为“常开”;
* - 否则设置为“在线”。
* e. 根据期望状态更新数据库中门的状态:
* 4. 异常处理:
* - 方法内部捕获异常,保证定时任务不会因为单门异常而中断。
*
* @param room 当前教室
* @param term 当前学期
* @param date 当前日期
* @param now 当前时间
*/
public Boolean handleRoomDoor(Room room, Term term, LocalDate date, LocalTime now) {
// 1. 根据教室查询对应的门
List<LockAccess> lockAccesses = this.lockAccessService.findByRoom(room);
// 2. 当前时间是否应该常开
Boolean expectedOpen = this.doorShouldBeOpen(room, term, date, now);
boolean operated = false;
for (LockAccess lockAccess : lockAccesses) {
try {
// 1 手动控制期判断
if (isManualControlMode(lockAccess)) {
logger.debug("教室 {} 处于手动控制期,跳过自动巡检", room.getName());
continue;
}
// 2 自动控制
Boolean result = handleAutoControlModeDoor(room, lockAccess, expectedOpen);
if (result == null) {continue;} // 不用进行操作
operated = true;
if (!result) { return false; } // 👈 开门失败返回为false
} catch (Exception e) {
// todo: 抛出异常
logger.warn("【自动开门】异常:room=" + room.getName() + ", doorId=" + lockAccess.getId() + ", msg=" + e.getMessage());
return false; // 👈 失败返回为false
}
}
return operated ? true : null;
}可以看得出我们对失败的情况,并没有去处理,而仅仅的返回的一个 false,这样的话,在定时任务 checkAndCorrentDoorStatus 这个方法中会每 2 分钟执行,然后发送信息
@Scheduled(fixedDelay = 2 * 60 * 1000, initialDelay = 30 * 1000)
public void checkAndCorrectDoorStatus() {
// 1.获取对应的学期
Term term = this.termRepository.findTermsByStateIsTrueAndDeletedFalse()
.stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("没有找到激活学期"));
LocalDate today = LocalDate.now();
LocalTime now = LocalTime.now();
// 2. 获取所有的教室数据
List<Room> rooms = (List<Room>) this.roomRepository.findAll();
Map<Boolean, List<String>> successRoomsMap = new HashMap<>(); // 操作成功的教室
Map<Boolean, List<String>> failRoomsMap = new HashMap<>(); // 操作失败的教室
successRoomsMap.put(true, new ArrayList<>());
successRoomsMap.put(false, new ArrayList<>());
failRoomsMap.put(true, new ArrayList<>());
failRoomsMap.put(false, new ArrayList<>());
// 按房间号升序排序
rooms.sort(Comparator.comparing(Room::getName));
// 3. 遍历所有的教室数据,去进行开门或关门操作
for (Room room : rooms) {
Boolean result = this.handleRoomDoor(room, term, today, now); // 👈 这里调用
boolean expectedOpen = this.doorShouldBeOpen(room, term, today, now);
if (result == null) {
continue;
}
// 处理门的结果是否成功
if (result) {
successRoomsMap.get(expectedOpen).add(room.getName());
} else {
failRoomsMap.get(expectedOpen).add(room.getName());
}
}
// 此处的true和false意味着是否期待开门,true:开门 false:关门
List<String> openSuccessRooms = successRoomsMap.get(true);
List<String> openFailRooms = failRoomsMap.get(true);
List<String> closeFailRooms = failRoomsMap.get(false);
this.sendDingDingMessage(openSuccessRooms, openFailRooms, closeFailRooms);
}如何解决呢
本质上,我们需要对开/关门失败的教室进行处理。对反复失败的教室进行标记,同时还需要有一定次数的重试机制。而不是一直重复一直重复
解决方案
最后的解决方案,还是得益于老师的思想点播
自己的浅层想法
🤔 一开始我的解决方法:失败后,尝试 5 次。
如果第 5 次仍旧失败。那么在此推送一条提醒信息(类似于 A322开门失败,已连续尝试5次,停止本轮重试。)并且停止尝试,等到下一个关门的时间点,才再次尝试
在尝试的这 5 次中,如果有一次成功,那么发送消息。如果一直都是失败,只在最后一次发送消息
老师给出的具体方向 🧭
潘老师给出了更为详细的实现方向:
老师并没有失败多少次的角度思考,而是从状态是否发生变化的角度来考虑解决方向(这个非常接近 Angular 的变更检测思想,关注状态而非其他)
最后呢,稍微将两种方式结合了一下。既实现老师提供的消息缓存、对比是否发生变化,如果发生变化再进行消息的发送;同时,实现次数限制的失败熔断机制
最终实现
核心实现的时序图:
🟢 熔断机制(5次失败后停止重试)
消息去重机制
1. 消息类型的规定
/**
* 自动开关门发送钉钉消息的类型
*/
@Getter
public enum DoorMessageType {
INFO((byte) 0, "自动开关门成功"),
ERROR((byte) 1, "自动开关门失败");
private final byte value;
private final String description;
DoorMessageType(byte value, String description) {
this.value = value;
this.description = description;
}
}2. 用于标记重试次数的数据model
/**
* 自动开关门失败次数、是否停止重试数据模型
* 用于判断重试次数是否达到熔断次数;达到=>停止重试
*/
@Data
public class DoorRetryState {
private Boolean expectedOpen; // 当前目标状态(true:开门;false:关门)
private int failCount; // 连续失败次数
private boolean exhausted; // 是否已经停止重试
}
3. 新增统一的钉钉信息发送入口方法
/**
* 根据信息状态来决定是否发送钉钉消息
* - 当 type = INFO 时,自动开关门成功:此时不发送消息,而是打印日志
* - 当 type = ERROR 时,自动开关门失败:
* 此时,判断 message 与上次消息是否一致。一致,不发送;不一致,发送失败信息
* @param type 门锁信息类型
* @param message 消息内容
*/
public void sendDingMessage(DoorMessageType type, String message) {
if (type == DoorMessageType.INFO) {
this.dingDingService.postToDingDingRobotOfText(message);
return;
}
String cacheMessage = messageCacheMap.get(type);
if (Objects.equals(cacheMessage, message)) {
logger.debug("动开关门失败消息未变化,跳过发送!");
return;
}
messageCacheMap.put(type, message);
this.dingDingService.postToDingDingRobotOfText(message);
}4. 添加所需要的重试相关方法
- 获取重试状态
/**
* 获取重试状态
*
* @param room 实验室
* @param expectedOpen 目标状态
* @return 返回重置后的“重试状态”
*/
public DoorRetryState getRetryState(Room room, boolean expectedOpen) {
DoorRetryState state = retryStateMap.computeIfAbsent(room.getId(), id -> new DoorRetryState());
if (!Objects.equals(state.getExpectedOpen(), expectedOpen)) {
logger.info("教室[{}]进入新的控制阶段,重置重试状态", room.getName());
state.setExpectedOpen(expectedOpen);
state.setFailCount(0);
state.setExhausted(false);
}
return state;
}- 开门失败的处理
/**
* 教室开关门失败处理
* 开关门失败后,设置 DoorRetryState 的失败信息
* 在尝试 5 次仍旧失败后,发送钉钉消息提示用户
* @param room 实验教室
* @param expectedOpen 目标状态
*/
public void recordFail(Room room, boolean expectedOpen) {
DoorRetryState state = getRetryState(room, expectedOpen);
state.setFailCount(state.getFailCount() + 1);
logger.warn("教室[{}]自动{}失败,第{}次", room.getName(), expectedOpen ? "开门" : "关门", state.getFailCount());
if (state.getFailCount() == 5) {
state.setExhausted(true);
String message = String.format("【课表自动开门】\\n\\n%s%s连续失败5次,系统已停止重试", room.getName(), expectedOpen ? "开门" : "关门");
sendDingMessage(DoorMessageType.ERROR, message);
logger.error("教室[{}]自动{}连续失败5次,停止重试", room.getName(), expectedOpen ? "开门" : "关门");
}
}- 重试成功的处理
/**
* 重试过程中,成功开关门的处理
* 在重试过程中,如果成功开/关门。此时清空失败计数,并设置exhausted=false
* @param room 实验教室
* @param expectedOpen 目标状态
*/
public void recordSuccess(Room room, boolean expectedOpen) {
DoorRetryState state = getRetryState(room, expectedOpen);
if (state.getFailCount() > 0) {
logger.info("教室[{}]自动{}恢复成功,清空失败计数", room.getName(), expectedOpen ? "开门" : "关门");
String message = String.format(
"【课表自动开门】\n\n%s%s恢复成功",
room.getName(),
expectedOpen ? "开门" : "关门"
);
sendDingMessage(DoorMessageType.INFO, message);
}
state.setFailCount(0);
state.setExhausted(false);
}5. 在 handleRoomDoor 中添加失败的处理
public Boolean handleRoomDoor(Room room, Term term, LocalDate date, LocalTime now) {
// 1. 根据教室查询对应的门
List<LockAccess> lockAccesses = this.lockAccessService.findByRoom(room);
// 2. 当前时间是否应该常开
Boolean expectedOpen = this.doorShouldBeOpen(room, term, date, now);
// 3. 判断当前教室是否已熔断 👈
DoorRetryState retryState = getRetryState(room, expectedOpen);
if (retryState.isExhausted()) {
logger.warn("教室[{}]自动{}已熔断,跳过重试", room.getName(), expectedOpen ? "开门" : "关门");
return null;
}
boolean operated = false;
for (LockAccess lockAccess : lockAccesses) {
try {
// 1 手动控制期判断
if (isManualControlMode(lockAccess)) {
logger.debug("教室 {} 处于手动控制期,跳过自动巡检", room.getName());
continue;
}
// 2 自动控制
Boolean result = handleAutoControlModeDoor(room, lockAccess, expectedOpen);
if (result == null) {continue;} // 不用进行操作
operated = true;
if (!result) { // 失败返回为false 👈
recordFail(room, expectedOpen);
return false;
}
} catch (Exception e) {
// todo: 抛出异常
logger.warn("【自动开门】异常:room=" + room.getName() + ", doorId=" + lockAccess.getId() + ", msg=" + e.getMessage());
recordFail(room, expectedOpen); // 异常返回为false 👈
return false;
}
}
if (operated) { // 成功调用 recordSuccess 👈
recordSuccess(room, expectedOpen);
}
return operated ? true : null;
}修改后的自动开关门的时序图如下:
测试
修改完代码之后,最重要的就是测试。
但是呢,我遇到一个棘手的问题。当前我的电脑没办法连接上第三方的服务端(提供门锁控制的)
之前系统考虑到了这种情况,按道理来说启用的 mock 模式,是不需要真实的连接上第三方的微耕服务的。
但是,我们的系统并没有完全实现 mock,在系统启动之初,startup 中的 V005SyncDoorStatus 会真实的调用 wgWebApi 这个API,也就是说需要真实的与第三方的微耕服务建立正确的连接
control-door:
type: mock❓我们该如何测试呢?
🔑 单元测试!!!!
我们当前只需要测试,我们所实现的功能是否正常;因为我们不需要管是否能正常开门,我们需要管的是:如果存在多条相同的错误提示,是否会2min间隔重复发送
所以,单元测试是一个正确的且不二选择
这里只展示2个稍微重要一点的单元测试:
@ExtendWith(MockitoExtension.class)
public class AutoControllerDoorScheduleTest {
@InjectMocks
private AutoControlDoorSchedule autoControlDoorSchedule;
@Mock
private DingDingService dingDingService;
private Room room;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(autoControlDoorSchedule, "messageCacheMap", new HashMap<>());
room = new Room();
room.setId(1L);
room.setName("A322");
}
/**
* 测试首次开门成功是否发送钉钉消息
*/
@Test
void infoMessage_shouldSendDirectly() {
autoControlDoorSchedule.sendDingMessage(
DoorMessageType.INFO,
"开门成功");
verify(dingDingService).postToDingDingRobotOfText("开门成功");
}
/**
* 测试多次不同信息的开门失败,是否会发送多条钉钉信息
*/
@Test
void errorMessage_differentMessage_shouldSendThreeTimes() {
List<String> messages = List.of(
"A开门失败",
"B开门失败",
"C开门失败");
messages.forEach(message -> {
autoControlDoorSchedule.sendDingMessage(DoorMessageType.ERROR, message);
});
verify(dingDingService, times(3)).postToDingDingRobotOfText(anyString());
}
/**
* 测试多次相同的开门失败信息是否只发送一次钉钉消息
*/
@Test
void errorMessage_sameMessage_shouldSendOnlyOnce() {
String message = "A315开门失败";
autoControlDoorSchedule.sendDingMessage(DoorMessageType.ERROR, message);
autoControlDoorSchedule.sendDingMessage(DoorMessageType.ERROR, message);
autoControlDoorSchedule.sendDingMessage(DoorMessageType.ERROR, message);
verify(dingDingService, times(1)).postToDingDingRobotOfText(message);
}
/**
* 测试连续失败5次,是否会发送包含“连续失败5次”的钉钉信息(用于提醒用户,系统不在进行尝试)
*/
@Test
void recordFail_shouldSendAlarWhenFailFiveTimes() {
for (int i = 0; i < 5; i ++) {
autoControlDoorSchedule.recordFail(room, true);
}
verify(dingDingService, times(1)).postToDingDingRobotOfText(contains("连续失败5次"));
}
/**
* 测试前3次失败,第4次成功时,是否会发送包含“恢复成功”的钉钉信息(且只调用1次钉钉消息发送的API)
*/
@Test
void recordSuccess_shouldSendRecoverMessageAfterFailures() {
autoControlDoorSchedule.recordFail(room, true);
autoControlDoorSchedule.recordFail(room, true);
autoControlDoorSchedule.recordFail(room, true);
autoControlDoorSchedule.recordSuccess(room, true);
verify(dingDingService, times(1)).postToDingDingRobotOfText(contains("恢复成功"));
}
}测试通过截图(只展示部分)
结语
通过这次需求开发,我最大的收获并不是实现了一个自动开关门的优化功能,而是体会到了系统设计与业务思考的重要性。
很多时候,解决问题的第一反应往往只是让功能能够运行起来,但一个更合理的方案,往往需要结合业务场景、用户体验以及系统行为进行综合考虑。这些思考并不是短时间内就能形成的,而是需要不断学习和积累。
同时,也很感谢潘老师在这个过程中给予的指导。相比直接给出答案,老师更多的是引导我从不同角度分析问题,让我对“状态变化”“告警设计”“单元测试”等内容有了更深入的理解。这样的实践机会十分宝贵,也让我对软件开发有了更多思考。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。