文章目录
- 1.背景
- 2.需求分析
- 3.实现逻辑分析
- 4.统一过期时间设置
- 5.初始图表栈
- 6.记录图表变化
- 7.撤销操作
- 8.重做操作
- 9.删除图表处理
1.背景
在一个编辑页面中,存在多个图表,对图表的配置操作允许撤销和重做;撤销和重做只是针对页面中图表属性变化进行,例如颜色修改、位置移动、字体修改等,对图表的删除、新增操作不在撤销范围内。
撤销是把图表的配置更新为上一个状态的值,允许进行连续撤销,直到没有可撤销的记录为止,出于性能考虑一般会设置一个撤销的最大步数。重做是把图表的配置还原为撤销前的值,调用过撤销,才能调用重做,例如图表当前的状态为A,调用一次撤销后变为B,此时调用重做则变为A;允许进行连续重做,前提是之前进行过连续撤销,例如图表的当前状态为A,第一次撤销后变为B,第二次撤销后变为C,此时第一次重做变为B,第二次重做变为A;调用撤销后,紧接着图表进行新的变更,中间穿插着一次变更后,则不能再进行重做,例如图表的当前状态为A,第一次撤销后变为B,接着由B变更为C,此时是不能再进行重做变为A的。
编辑页面截图示例:
2.需求分析
(1)最大撤销步数为20步;
(2)允许连续撤销;
(3)允许连续重做;
(4)撤销之后穿插着其他操作,不能再重做还原回去;
(5)刷新页面后不能撤销到刷新之前的状态,相当于新建一个会话;
(6)编辑过程中有图表item删除,需要删除它的撤销步骤;
(7)第一次加载图表时,需要把此数据作为撤销的初始值,当图表第一次变更后,调用撤销还原为初始值;
(8)通过定时器定时去加载图表时,不再重复添加撤销的初始值;
(9)存储撤销数据的入口为图表变更之后调用updateItem接口,而updateItem接口传递过来的是图表的最新状态,调用撤销是还原为变更之前的状态;
(10)撤销操作使用Redis实现,需要保证同一个会话的缓存数据有相同的过期时间。
3.实现逻辑分析
项目使用Java开发,所以此处使用Java+Redis实现撤销重做功能;需要考虑撤销的最大步数,撤销之后穿插着其他操作则不能再重做,所以引入分布式锁Redisson进行加锁处理,防止对图表的操作有并发请求导致处理撤销逻辑混乱。具体引入过程可以参考之前的博客:
(1)Redis的key与数据类型
由前端生成一个不重复的会话sessionId,当页面刷新时重新生成sessionId,调用图表查询、删除图表、撤销、重做接口时都带上这个sessionId,此sessionId作为redis的缓存前缀key。
使用Redis的List数据类型来存放数据,因为List类型支持左边进leftPush,左边出leftPop,右边进rightPush,右边出rightPop,可以把List当栈或者队列使用。撤销操作要撤回的是上一个状态的值,越早发生的变更,越晚才撤销到,这正好是栈的特性,可以使用leftPush添加元素,leftPop弹出撤销元素;当栈的数量大于指定数量20时,使用rightPop从栈底出栈。
定义一个key为sessionId+undo的撤销List,用于存放所有的撤销记录;定义一个key为sessionId+redo的重做List,用于存放所有的重做记录;有多少个图表,定义多少个图表List,用于存放图表的所有变更过程,之所以每个图表定义一个list的原因:①图表需要撤销为初始状态,一堆图表初次加载数据时不分先后顺序;②有定时去加载图表数据的场景,不能让图表数据初始化重复;③撤销后可以重做,而整个页面是一个整体,需要记录每个图表撤销前的状态,撤销后的状态。key为sessionId+图表id,栈底为此次会话图表的初始状态,栈顶与页面中图表的状态一致。创建的list示例:
(2)初始化图表栈
每次查询图表记录时,都根据sessionId+图表id判断是否已经存在此缓存list,若是不存在,则新建一个list,把查询到的记录作为list的初始值,若是存在则不进行添加。第一次加载完图表信息后的情况:
(3)图表第一次变更
图表有状态变更,则把变化图表对应图表栈的栈顶元素取出来放到undo中,并把最新的记录存放到图表栈的栈顶中。第一次有图表状态变化后的情况:
(4)图表多次变更
页面图表状态一直与缓存图表的栈顶元素保持一致,undo中存放的都是变更之前的状态值。经过多次变更后的图表状态:
(5)撤销操作
当调用撤销接口时,从undo栈中弹出栈顶元素返回给前端,使页面中与弹出元素对应图表的状态变更为上一个状态;根据弹出元素的id找到对应的图表栈,弹出图表栈顶元素,此栈顶元素与调用撤销之前的图表状态一致,把此元素放到redo栈中,当调用完撤销之后,可以调用重做接口把图表的状态还原回去。请求撤销的流程:
调用一次撤销后的图表状态:
(6)重做操作
调用重做接口时,从redo栈中取出栈顶元素返回给前端,此处先不弹出元素,因为需要支持连续重做,而前端拿到撤销或者重做返回的图表最新状态后,立马调用updateItem接口更新图表的最新状态,我们记录图表状态变化的入口点也是updateItem接口,所以当有一次变化要保存,需要判断此次变化的状态是否是通过撤销、重做接口获取的,若是通过撤销操作获取的,则不进行undo的入栈;若是通过重做接口获取的,则让redo栈顶出栈,一开始调用redo重做接口不出栈就是为了对比新状态是否通过重做获取的。请求重做的流程:
调用重做接口,更新图表后的状态:
(7)撤销后重做
支持连续撤销,连续重做,连续撤销两次后的图表状态:
两次撤销后,进行一次重做后的图表状态:
(8)撤销后其他变更
当撤销后,没有调用重做(说明撤销前的状态是无用的),中间穿插着其他操作,则清空redo重做栈,看下当前图表状态:
(9)存储变更逻辑
当调用撤销后,前端拿到图表的上一个状态,然后调用updateItem保存图表的最新状态,此时的状态值不再往redis栈中入栈,判断是否通过撤销操作提交的依据为:找到变更图表对应图表栈,拿出栈顶元素,若是栈顶元素等于这次提交过来的新状态,则判断是通过撤销后提交过来的记录,此种情况不进行入栈。若不是通过撤销操作提交过来,则判断是否通过调用重做接口提交过来的,判断的依据为:拿出redo栈的栈顶元素,判断新提交过来的元素是否等于栈顶元素,等于则说明是通过调用重做后提交过来的记录。看下调用updateItem接口操作redis栈的流程图:
(10)删除图表
在编辑过程中,当某个图表被删除了,则需要删除此图表对应的撤销记录,删除某个图表的示意图:
4.统一过期时间设置
undo、redo和图表栈都是基于一个会话进行存储,它们不是同时创建的,编辑过程中也会加入新图表,但是一个会话里面的栈需要有统一的过期时间,出于业务的考虑,一个页面的编辑时间基本不会跨越一天,所以给栈的过期时间设置为当前时间到第二天凌晨12点的秒数+1天的秒数,这样这次会话的失效时间为明天晚上12点。获取统一过期时间的代码实现:
public Long getSecondsNextEarlyMorningAddOneDay() {
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_YEAR, 2);
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.MILLISECOND, 0);
return (cal.getTimeInMillis() - System.currentTimeMillis()) / 1000;
}
5.初始图表栈
第一次查询图表数据的时候,结果值需要作为图表栈的初始数据,定时器再去加载的时候,不重复添加到栈中,只用判断某个图表在这次会话中是否已经添加redis记录。代码实现:
//图表若是没有初始化过,则进行初始化
public void addItemInit(Item itemUndo, String sessionId) {
//添加分布式锁
String lockKey = RedissonKeyPrefixeConstants.LOCK_PAGE_UNDO_REDO+sessionId;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean res = lock.tryLock(30, TimeUnit.SECONDS);
if(res) {
//判断需要保存记录是否已经有对应的栈,有的话,则不重复添加
String itemKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":item:"+itemUndo.getId();
if(!redisTemplate.hasKey(itemKey)){//不存在,则添加
notExistItemNewAdd(itemKey,sessionId,JSONObject.toJSONString(itemUndo));
}
} else {
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
} finally {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
//不存在item,则新添加
private void notExistItemNewAdd(String itemKey, String sessionId, String jsonValue) {
redisTemplate.opsForList().leftPush(itemKey,jsonValue);
//设置过期时间为当前时间到晚上12点+1天
redisTemplate.expire(itemKey,getSecondsNextEarlyMorningAddOneDay(), TimeUnit.SECONDS);
//清空重做栈,说明中间穿插着新加入图表的操作
String redoKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":redo";
redisTemplate.delete(redoKey);
}
6.记录图表变化
图表有状态变化,调用updateItem接口更新最新状态,在此接口上添加redis的处理。判断需要保存的记录是否已经有对应的栈,若是没有,则新建一个list,把当前状态作为list的初始值,并清空redo栈。若是存在变更图表对应的栈,拿出图表栈的栈顶元素,栈顶元素若是等于这次变更值,则说明此次变更是由撤销操作触发的,不进行入栈;若是不相等,说明是新的状态,则需要入栈,在入栈之前,先判断undo栈的元素是否等于20,等于20则让undo栈底出栈,出栈元素对应的图表栈栈底也出栈,把变化的item对应图表栈顶元素添加到undo栈中,再把变化的item添加到对应图表的栈顶中,这样图表栈顶元素和页面图表的状态保持一致,undo中存的是图表的上一个状态值。判断redo栈是否存在,存在redo栈,则判断redo栈的栈顶元素是否等于变化的item,等于则说明是通过调用重做提交过来的,此时让redo栈顶出栈,不清空redo栈,这样可以支持连续调用重做;若是不等于redo栈顶元素,则说明此次提交过来的数据不是通过重做实现的,穿插着其他操作,需要清空redo栈。代码实现:
public void addItemUndo(Item itemUndo,String sessionId) {
//添加分布式锁
String lockKey = RedissonKeyPrefixeConstants.LOCK_PAGE_UNDO_REDO+sessionId;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean res = lock.tryLock(30, TimeUnit.SECONDS);
if(res) {
//判断需要保存记录是否已经有对应的栈
String itemKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":item:"+itemUndo.getId();
if(redisTemplate.hasKey(itemKey)){
//拿出图表栈栈顶元素,不出栈
String peekObject = (String)redisTemplate.opsForList().index(itemKey,0);
Item peekItem = JSONObject.parseObject(peekObject,Item.class);
//判断此次变化的item是否等于栈顶元素,比较实体是否相等,可以重写实体的hashCode、equals方法,也可以使用lombok的@Data注解实现,若是实体类有继承关系,则使用@EqualsAndHashCode(callSuper = true)注解标识连带父类字段一块参与hash计算
if(!itemUndo.equals(peekItem)){//相等说明是通过撤销操作再次提交的,不进行入栈到撤销栈中
//把图表栈顶元素放到撤销栈中,并判断数量是否等于20
addItemToUndoList(peekObject,sessionId);
//变化的item放到图表对应栈顶中,经过上面20步的限制后,可能会把key为itemKey的list清空,清空则代表着删除,需要重新设置过期时间
if(redisTemplate.hasKey(itemKey)) {
redisTemplate.opsForList().leftPush(itemKey,JSONObject.toJSONString(itemUndo));
} else {
redisTemplate.opsForList().leftPush(itemKey,JSONObject.toJSONString(itemUndo));
//设置过期时间为当前时间到晚上12点+1天
redisTemplate.expire(itemKey,getSecondsNextEarlyMorningAddOneDay(), TimeUnit.SECONDS);
}
String redoKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":redo";
if(redisTemplate.hasKey(redoKey)) {
//出栈
String redoPeekObject = (String)redisTemplate.opsForList().leftPop(redoKey);
JSONObject redoPeekJsonObject = JSONObject.parseObject(redoPeekObject);
//组件item
Item redoPeekItem = JSONObject.parseObject(redoPeekObject,Item.class);
if(!itemUndo.equals(redoPeekItem)) {//相等说明是通过重做操作再次提交的,重做栈顶出栈,leftPop方法已经出栈;不相等说明上一步不是重做,清空redo
redisTemplate.delete(redoKey);
}
}
}
} else { //不存在,则添加
notExistItemNewAdd(itemKey,sessionId,JSONObject.toJSONString(itemUndo));
}
} else {
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
} finally {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
//把图表栈顶元素放到撤销栈中,并判断数量是否等于20
private void addItemToUndoList(String peekObject,String sessionId) {
String undoKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":undo";
if(redisTemplate.hasKey(undoKey)) {
Long undoSize = redisTemplate.opsForList().size(undoKey);
//判断数量是否大于等于20步,大于等于20步则让栈底出栈
if(undoSize >= 20) {
//栈底出栈
String popUndoObject = (String)redisTemplate.opsForList().rightPop(undoKey);
JSONObject popUndoJsonObject = JSONObject.parseObject(popUndoObject);
//对应图表的栈底出栈
String popItemKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":item:"+popUndoJsonObject.get("id");
if(redisTemplate.hasKey(popItemKey)){
redisTemplate.opsForList().rightPop(popItemKey);
}
}
redisTemplate.opsForList().leftPush(undoKey,peekObject);
} else {
redisTemplate.opsForList().leftPush(undoKey,peekObject);
//设置过期时间为当前时间到晚上12点+1天
redisTemplate.expire(undoKey,getSecondsNextEarlyMorningAddOneDay(), TimeUnit.SECONDS);
}
}
tip:比较实体是否相等,可以重写实体的hashCode、equals方法,也可以使用lombok的@Data注解实现,若是实体类有继承关系,则使用@EqualsAndHashCode(callSuper = true)注解标识连带父类字段一块参与hash计算。
7.撤销操作
从undo栈中弹出栈顶元素返回给前端,根据此出栈元素获取到它对应的图表栈顶元素,图表栈顶元素状态与撤销之前页面图表的状态一致,把图表栈顶元素出栈放到redo栈中,当调用重做时能让页面的图表状态还原回去。代码实现:
public JSONObject undo(String json) {
if(StringUtils.isBlank(json)){
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "参数为空,请确保参数的准确性");
}
JSONObject jsonObject= JSONObject.parseObject(json);
if(!jsonObject.containsKey("sessionId")){
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "缺少参数sessionId,请确保参数的准确性");
}
String sessionId = jsonObject.getString("sessionId");
if(StringUtils.isEmpty(sessionId)){
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "参数sessionId为空,请确保参数的准确性");
}
//添加分布式锁
String lockKey = RedissonKeyPrefixeConstants.LOCK_PAGE_UNDO_REDO+sessionId;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean res = lock.tryLock(30, TimeUnit.SECONDS);
if(res) {
String undoKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":undo";
//不包含撤销栈,直接返回空
if(!redisTemplate.hasKey(undoKey)){
return null;
}
//弹出undo栈顶元素
String undoObject = (String)redisTemplate.opsForList().leftPop(undoKey);
//转成对象
JSONObject undoJsonObject = JSONObject.parseObject(undoObject);
String redoObject = null;
//根据栈顶元素获取到它对应的图表栈
String itemKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":item:"+undoJsonObject.get("id");
//存在对应的图表栈
if(redisTemplate.hasKey(itemKey)){
redoObject = (String)redisTemplate.opsForList().leftPop(itemKey);
}
if(StringUtils.isNotEmpty(redoObject)){
//把图表栈的栈顶元素添加到redo栈中
String redoKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":redo";
if(redisTemplate.hasKey(redoKey)) {//已经redo栈则直接追加
redisTemplate.opsForList().leftPush(redoKey,redoObject);
} else {//不包含redo栈,则添加,并设置过期时间
redisTemplate.opsForList().leftPush(redoKey,redoObject);
//设置过期时间为当前时间到晚上12点+1天
redisTemplate.expire(redoKey,getSecondsNextEarlyMorningAddOneDay(), TimeUnit.SECONDS);
}
}
//undo栈顶元素返回给前端
return undoJsonObject;
} else {
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
} finally {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
8.重做操作
从redo栈中获取栈顶元素返回给前端,不出栈,因为数据有变动保存时,需要比对是否由重做触发的,若是重做触发的则弹出redo栈顶元素,不是重做触发的则清空redo栈,这样可以支持连续调用重做。代码实现:
public JSONObject redo(String json) {
if(StringUtils.isBlank(json)){
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "参数为空,请确保参数的准确性");
}
JSONObject jsonObject= JSONObject.parseObject(json);
if(!jsonObject.containsKey("sessionId")){
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "缺少参数sessionId,请确保参数的准确性");
}
String sessionId = jsonObject.getString("sessionId");
if(StringUtils.isEmpty(sessionId)){
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "参数sessionId为空,请确保参数的准确性");
}
//添加分布式锁
String lockKey = RedissonKeyPrefixeConstants.LOCK_PAGE_UNDO_REDO+sessionId;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean res = lock.tryLock(30, TimeUnit.SECONDS);
if(res) {
String redoKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":redo";
//不包含重做栈,直接返回空
if(!redisTemplate.hasKey(redoKey)){
return null;
}
//拿出栈顶元素,不出栈,返回给前端,当调用更新数据变动时,判断新提交过来的数据是否等于重做栈的栈顶,等于则说明是通过重做提交过来的,
//此时不清空重做栈,因为需要支持多步重做;若是不等于重做栈顶元素,则清空重做栈,说明上一步不是重做
String redoObject = (String)redisTemplate.opsForList().index(redoKey,0);
JSONObject redoJsonObject = JSONObject.parseObject(redoObject);
//拿出栈顶元素返回给前端
return redoJsonObject;
} else {
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
} finally {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}
9.删除图表处理
删除图表时需要删除此图表的操作记录,undo、redo、图表栈都需要删除,否则会撤销到一个不存在的记录。代码实现:
public void deleteItem(List<Integer> idList, String sessionId) {
//添加分布式锁
String lockKey = RedissonKeyPrefixeConstants.LOCK_PAGE_UNDO_REDO+sessionId;
RLock lock = redissonClient.getLock(lockKey);
try {
boolean res = lock.tryLock(30, TimeUnit.SECONDS);
if(res) {
//根据删除图表的id,删除图表栈
for(int i = 0;i < idList.size();i++) {
String itemKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":item:"+idList.get(i);
redisTemplate.delete(itemKey);
}
//删除撤销栈
String redoKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":redo";
if(redisTemplate.hasKey(redoKey)) { //包含redo栈才处理
//获取redo栈的所有记录
List redoList = redisTemplate.opsForList().range(redoKey, 0, -1);
if(null != redoList && redoList.size() > 0) {
Iterator redoIt = redoList.iterator();
//遍历redo栈的所有记录
while(redoIt.hasNext()) {
String redoObject = (String)redoIt.next();
JSONObject redoJsonObject = JSONObject.parseObject(redoObject);
//redo栈中的元素存在于删除的图表集合中,则删除栈中的元素
if(idList.contains(redoJsonObject.getInteger("id"))){//判断是否为删除的id
redoIt.remove();
}
}
//删除撤销栈数据
redisTemplate.delete(redoKey);
//经过删除后,撤销栈里面还有数据,则重新添加到redis中
if(null != redoList && redoList.size() > 0) {
redisTemplate.opsForList().leftPushAll(redoKey,redoList);
//设置过期时间为当前时间到晚上12点+1天
redisTemplate.expire(redoKey,getSecondsNextEarlyMorningAddOneDay(), TimeUnit.SECONDS);
}
}
}
//删除重做栈
String undoKey = RedisKeyPrefixConstants.PAGE_UNDO_REDO+sessionId+":undo";
if(redisTemplate.hasKey(undoKey)) {
//获取undo栈的所有记录
List undoList = redisTemplate.opsForList().range(undoKey, 0, -1);
if(null != undoList && undoList.size() > 0) {
Iterator undoIt = undoList.iterator();
while(undoIt.hasNext()) {
String undoObject = (String)undoIt.next();
JSONObject undoJsonObject = JSONObject.parseObject(undoObject);
//undo栈中的元素存在于删除的图表集合中,则删除栈中的元素
if(idList.contains(undoJsonObject.getInteger("id"))){//判断是否为删除的id
undoIt.remove();
}
}
//删除重做栈数据
redisTemplate.delete(undoKey);
//经过删除后,重做栈里面还有数据,则重新添加到redis中
if(null != undoList && undoList.size() > 0) {
redisTemplate.opsForList().leftPushAll(undoKey,undoList);
//设置过期时间为当前时间到晚上12点+1天
redisTemplate.expire(undoKey,getSecondsNextEarlyMorningAddOneDay(), TimeUnit.SECONDS);
}
}
}
} else {
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
}
} catch (InterruptedException e) {
e.printStackTrace();
throw WebErrorUtils.commonError(null, HttpStatus.UNPROCESSABLE_ENTITY, "服务器繁忙,请稍后重试");
} finally {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}