Java+Redis撤销重做功能实现
作者:清云青云
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进行加锁处理,防止对图表的操作有并发请求导致处理撤销逻辑混乱。具体引入过程可以参考之前的博客: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(); } } }
到此这篇关于Java+Redis实现撤销重做功能的文章就介绍到这了,更多相关Java+Redis实现撤销重做功能内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!