Redux應用多人協作的思路和實現

frontdog發表於2018-06-07

先上Demo動圖,效果如下:

Alt pic

基本思路

由於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傳送給其他的客戶端

Alt pic

不過上面的思路是比較籠統的,細想會發現許多問題:

  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
複製程式碼

客戶端整體思路圖

Alt pic

主要講兩個地方:

(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,後面會持續優化這個想法,新增一些自動化測試來驗證,另外,對於服務端的儲存也還沒考慮,先在只在記憶體中跑,會思考儲存方案。希望對這方面有興趣的大神可以指導一下

相關文章