先上Demo动图,效果如下:


基本思路

由于redux更改数据是dispatch(action),所以很自然而然想到以action作为基本单位在服务端和客户端进行传送,在客户端和服务端用数组来存放action,那么只要当客户端和服务端的action队列的顺序保持一样,reducer是纯函数的特性可以知道计算得到的state是一样的。

一些约定

本文中C1,C2...Cn表示客户端,S表示服务端,a1,a2,a3...an表示aciton,服务端是使用koa + socket.io来编写的(作为一个前端,服务端的知识几乎为0勿喷)。

整体思路

当客户端发起一个action的时候,服务端接收到这个action,服务端做了3件事:

  1. 把action推进栈中
  2. 把该客户端a1之前的action发送该客户端(类似git push之前有个git pull的过程)
  3. 将a1发送给其他的客户端

不过上面的思路是比较笼统的,细想会发现许多问题:

  1. 如果a1到达S端的时候,C2、C3还有action在派发怎么处理?
  2. 如果a1到达S端的时候,C1正在接收其他客户端发送的action怎么处理?
  3. 如果a1发送之前,C1正在发送前一个action怎么处理?
    后面我们一一解决。

服务端派发action机制

服务端设立了2个概念:target、member指编辑对象(可能是报告、流程图等)和编辑用户,当你要发送两个action:a1、a2的时候,因为网络发送先后的不确定性,所以应该是先发送a1,然后等待客户端接收到,再发送a2,这样才能在客户端中保证a1和a2的顺序。因此,每个member会有变量pending表示在不在发送,index表示发送的最新的action在action队列中的索引。

当服务端接收到客户端的aciton的时候

this.socket.on('client send action', (action) => {
//目标将action放入队列中,并返回该action的索引
let index = this.target.addAction(action);
if (this.pending) {
this.queue.push({
method: 'sendBeforeActions',
args: [index]
})
} else {
this.sendBeforeActions(index);
}
this.target.receiveAction({
member: this,
index
})
})

这就是上面讲的当服务端收到a1的时候做的3件事情。只是这里会去判断该member是不是正在执行发送任务,如果是,那么就将发送a1前面的aciton这个动作存入到一个动作队列中,并告知target,我这个member发送了一个action。

sendBeforeActions

sendBeforeActions(refIndex) {
let {actions} = this.getCurrent(refIndex);
actions = actions.slice(0, -1);
this.pending = true;
this.socket.emit('server send before actions', { actions }, () => {
this.pending = false;
this.target.setIndex(this, refIndex);
this.sendProcess();
});
}

这个函数接收一个索引,这个索引在上面的代码中是这个member接收到的action在队列中的索引,所以getCurrent(refIndex)指到refIndex为止,还没发送给这个member的所有的action(可能为空),所以要剔除本身后actions.slice(0, -1)发送给客户端。
回调中终止发送状态,设置member最新的action的index,然后执行sendProcess函数去看看,在自己本身发送的过程中,是不是有后续的动作存入到发送队列中了

  sendProcess() {
if (this.queue.length > 0 && !this.pending) {
let current = this.queue.shift();
let method = this[current.method];
method.apply(this, current.args);
}
}

如果你注意到刚才的:

if (this.pending) {
this.queue.push({
method: 'sendBeforeActions',
args: [index]
})
}

你就会发现,如果刚才想发送before action的时候这个member在发送其他action,那么会等待这个action发送完后才触发sendProcess去执行这个发送。

还要将这个action发送给其他用户

在刚才的代码中

//this指某个member对象
this.target.receiveAction({
member: this,
index
})

就是这个触发了其他客户端的发送

//this指某个target对象
receiveAction({member, index}) {
this.members.forEach(m => {
if (m.id !== member.id) {
m.queue.push({
method: 'sendActions',
args: [index]
});
m.sendProcess();
}
})
}

如果members中存在发送方的member,那么会将发送动作存入member的发送队列中,执行sendProcess

sendActions

 sendActions(refIndex) {
let {actions} = this.getCurrent(refIndex);
if (actions.length) {
this.pending = true;
this.socket.emit('server send actions', {actions}, () => {
this.pending = false;
this.target.setIndex(this, refIndex);
this.sendProcess();
})
}
}

这个函数和sendBeforeActions几乎一样,只差要不要剔除最新的action,这样,就保证了服务端的发送action顺序

客户端IO中间件

在客户端中,将io有关的操作都封装在一个中间件中

module.exports = store => next => action => {
if (action.type === 'connection') {
//连接初始化一些事件
return initIo(action.payload)
}
if (action.type === 'disconnection') {
return socket.disconnect(action.payload)
}
if (['@replace/state'].indexOf(action.type.toLowerCase()) === -1 && !action.escapeServer && !action.temporary) {
//将action给定userId、targetId
action = actionCreator(action);
//得到新的action队列,并计算actions,然后更新到state上
let newCacheActions = [...cacheActions, action];
mapActionsToState(newCacheActions);
//发送给服务端
return delieverAction(action);
}
//这样就只允许replace state 的action进入到store里面,这个是我这个思路在实现undo、redo的一个要求,后面会讲到
next();
}

一些全局变量

具体作用后面会用到

let cacheActions = [];   //action队列,这个和服务端的action队列保持一致
let currentActions = []; //根据cacheActions计算的action
let redoActions = {}; //缓存每个用户的undo后拿掉的action
let pending = false; //是否在发送请求
let actionsToPend = []; //缓存发送队列
let beforeActions = []; //缓存pull下来的actions
let currentAction = null;//当前发送的action
let user, tid; //用户名和targetId
let initialState; //初始的state
let timeline = []; //缓存state

客户端整体思路图


主要讲两个地方:

(1)在computeActions的时候,碰到undo拿掉该用户的最后一个action,并把倒数第二个action提升到最后的原因是因为假如在该用户倒数第二个action之后还有其他用户的action发生,那么可能其他用户会覆盖掉这个用户action的设定值,那么这个用户undo的时候就无法回到之前的状态了,这时候提升相当于是undo后做了新的action,这个action就是前一次的action。这个算法是有bug的,当一个用户undo的时候,由于我们会提升他倒数第二的action,这样会导致与这个action冲突的action的修改被覆盖。这个解决冲突的策略有点问题。如果没有提升,那么如果该用户undo的时候,如果他上一个action被其他用户的action覆盖了,那么他就无法undo回去了。这个是个痛点,我还在持续探索中,欢迎大神指教。

(2)在用户pending的时候收到了actions,这个时候相当于是before actions。
下面贴几个主要函数的代码

initIo

function initIo(payload, dispatch) {
user = payload.user;
tid = parseInt(payload.tid, 10);
//初始化socket
let socket = cache.socket = io(location.protocol + '//' + location.host, {
query: {
user: JSON.stringify(user),
tid
}
});
//获取初始数据
socket.on('deliver initial data', (params) => {
...获取初始的state,actions
})
//发送action会等待pull之前的actions
socket.on('server send before actions', (payload, callback) => {
pending = false;
callback && callback();
let {actions} = payload;
actions = [...actions, ...beforeActions, currentAction];
cacheActions = [...cacheActions, ...actions];
if (actions.length > 1) {
//证明有前面的action,需要根据actions重新计算state
mapActionsToState();
}
if (actionsToPend.length) {
let action = actionsToPend.shift();
sendAction(action);
}
})
//接收actions
socket.on('server send actions', (payload, callback) => {
let {actions} = payload;
callback && callback();
if (pending) {
beforeActions = [...beforeActions, ...actions];
} else {
cacheActions = [...cacheActions, ...actions];
mapActionsToState();
}
})
}

mapActionsToState

function mapActionsToState(actions) {
actions = actions || cacheActions;
if (actions.length === 0) {
return replaceState(dispatch)(initialState);
}
let {newCurrentActions, newRedoActions} = computeActions(actions);
let {same} = diff(newCurrentActions); let state = initialState;
if (timeline[same]) {
state = timeline[same];
timeline = timeline.slice(0, same + 1);
}
if (same === -1) {
timeline = [];
}
let differentActions = newCurrentActions.slice(same + 1);
differentActions.forEach(action => {
state = store.reducer(state, action);
timeline.push(state);
});
currentActions = newCurrentActions;
redoActions = newRedoActions;
store.canUndo = () => currentActions.some(action => action.userId === user.id);
store.canRedo = () => !!(redoActions[user.id] || []).length;
return replaceState(dispatch)(state);
}

computeActions

function computeActions(actions) {
let newCurrentActions = [];
let newRedoActions = {};
actions.forEach(action => {
let type = action.type.toLowerCase();
newRedoActions[action.userId] = newRedoActions[action.userId] || [];
if (type !== 'redo' && type !== 'undo') {
newCurrentActions.push(action);
newRedoActions[action.userId] = [];
}
if (type === 'undo') {
let indexes = [];
for (let i = newCurrentActions.length - 1; i >= 0; i--) {
if (newCurrentActions[i].userId === action.userId) {
indexes.push(i);
}
if (indexes.length === 2) {
break;
}
}
if (indexes.length > 0) {
let redo = newCurrentActions.splice(indexes[0], 1)[0];
newRedoActions[action.userId].push(redo);
}
if (indexes.length > 1) {
let temp = newCurrentActions.splice(indexes[1], 1);
newCurrentActions.push(temp[0]);
}
}
if (type === 'redo') {
let redo = newRedoActions[action.userId].pop();
newCurrentActions.push(redo);
}
});
return {
newCurrentActions,
newRedoActions
}
}

diff

function diff(newCurrentActions) {
let same = -1;
newCurrentActions.some((action, index) => {
let currentAction = currentActions[index];
if (currentAction && action.id === currentAction.id) {
same = index;
return false;
}
return true;
});
return {
same
}
}

结束语

讲了一堆,不知道有没有将自己的思路讲清楚,自己的demo也运行了起来,测试只用了两个浏览器来模拟测试,总感觉一些并发延时出现还会有bug,后面会持续优化这个想法,添加一些自动化测试来验证,另外,对于服务端的存储也还没考虑,先在只在内存中跑,会思考保存方案。希望对这方面有兴趣的大神可以指导一下

Redux应用多人协作的思路和实现的更多相关文章

  1. 利用git 进行多人协作开发

    现在,大部分项目都是用 git 来管理代码的,但当项目变大.多人协作时,git 的使用就变得复杂了,这时就需要在 git 使用的流程上来思考如何更优的使用 git. 对于大部分 web 项目而言,并不 ...

  2. Git多人协作工作流程

    前言 之前一直把Git当做个人版本控制的工具使用,现在由于工作需要,需要多人协作维护文档,所以去简单了解了下Git多人协作的工作流程,发现还真的很多讲解的,而且大神也已经讲解得很清楚了,这里就做一个简 ...

  3. Android github 快速实现多人协作

    前言:最近要做github多人协作,也就是多人开发.搜索了一些资料,千篇一律,而且操作麻烦.今天就整理一下,github多人协作的简单实现方法. 下面的教程不会出现:公钥.组织.team.pull r ...

  4. git学习:多人协作,标签管理

    多人协作: 查看远程库的信息, git remote 推送分支到远程库 git push origin master/dev 注意:master是主分支,时刻需要与远程同步 dev是开发分支,也需要与 ...

  5. Git学习笔记(7)——多人协作

    本文主要记录了,多人协作时,产生冲突时的解决情况. 多人环境创建 首先我们需要模拟一个多人环境.前面的Git的学习都是在Ubuntu上面,现在我们也搭建一个win环境吧.安装win环境下的Git,很简 ...

  6. 记录git多人协作开发常用的流程,供新手参考

    声明:博主写的博客都是经过自己总结或者亲测成功的实例,绝不乱转载.读者可放心看,有不足之处请私信我,或者给我发邮件:pangchao620@163.com. 写作目的: 记录一下我看完廖学锋老师的gi ...

  7. Unity3D多人协作开发环境搭建

    多人协作 说到多人协作开发,大家都会想到要使用版本控制工具来管理项目,当然最常用的要数SVN和Git了,但是SVN管理Unity3D项目的确有一些不尽人意的地方. 比如:两个人修改了同一个场景,SVN ...

  8. git学习笔记11-git多人协作-实际多人怎么开发

    当你从远程仓库克隆时,实际上Git自动把本地的master分支和远程的master分支对应起来了,并且,远程仓库的默认名称是origin. 要查看远程库的信息,用git remote: $ git r ...

  9. 支持多人协作的在线免费作图工具:ProcessOn

    之前朋友给我推荐一款作图工具ProcessOn,出于好奇我就研究了一下它,今天我就给大家简单介绍一下这款免费的在线作图工具:ProcessOn 首先使用ProcessOn我们需要有一个帐号,这样每次操 ...

随机推荐

  1. C语言Ⅰ博客作业09

    这个作业属于那个课程 C语言程序设计II 这个作业要求在哪里 https://edu.cnblogs.com/campus/zswxy/CST2019-3/homework/10029 我在这个课程的 ...

  2. 【Python】【基础知识】【异常】【Python的异常】报错、警告

    Python的异常 异常的层次结构: BaseException [所有异常的基类] +-- SystemExit [解释器请求退出] +-- KeyboardInterrupt [用户中断执行(通常 ...

  3. 灾备系统 RTO与RPO

    出处: https://blog.51cto.com/se7en/1085442 http://www.iso27001.org.cn/fuwu/it/iso22301/show_511.html h ...

  4. Spring4学习回顾之路04—引用其他Bean,集合数据注入,内部Bean

    引用其他Bean 组件应用程序的Bean经常需要相互协作以完成应用程序的功能,所以要求Bean能够相互访问,就必须在Bean配置文件中指定Bean的引用.在Bean的配置文件中可以用过<ref& ...

  5. ARST第二周打卡

    Algorithm : 做一个 leetcode 的算法题 题目:一个无序数组里有99个不重复正整数,范围从1到100,唯独缺少一个整数.如何找出这个缺失的整数? int FindOneMissNum ...

  6. 石子合并(直线版+环形版)&(朴素写法+四边形优化+GarsiaWachs算法)

    石子合并-直线版 (点击此处查看题目) 朴素写法 最简单常见的写法就是通过枚举分割点,求出每个区间合并的最小花费,从而得到整个区间的最小花费,时间复杂度为O(n^3),核心代码如下: ; i < ...

  7. hard or 9102 字符串DP---Educational Codeforces Round 57 (Rated for Div. 2)

    题意:http://codeforces.com/problemset/problem/1096/D 思路:参考:https://blog.csdn.net/qq_41289920/article/d ...

  8. python-day6(正式学习)

    流程控制之while循环 语法 循环就是一个重复的过程,人需要重复做某项工作,那么计算机也一样.当ATM验证失败,那么计算机就会让我们再输入一次密码.这时候我们说的循环就是while循环,while循 ...

  9. T100——动态更改Label的说明

    例子: #設定科目名稱    IF g_prog = 'aapt300' THEN       CALL cl_set_comp_att_text("lbl_apca036",cl ...

  10. QT目录模型QDirModel的使用(一个model同时连接tree,list,table)

    3#include <QApplication>#include <QAbstractItemModel>#include <QAbstractItemView># ...