使用 socket.io 解决多用户同时更新同一个文档的问题

问题

对于涉及文档编辑的应用,一个常见的应用场景就是多个用户试图修改同一个文档的问题。

某些情况下,多个用户可能先后读取同一个文档到本地浏览器,然后编辑修改。有的人可能只是需要简单修改,然后很快就保存离开;而有的用户可能需要修改多些内容,然后在前一个保存离开之后再存档离开。这种情景,如果不做任何处理,第二个离开的用户的存档会覆盖掉第一个用户的修改,我想这是第一个用户不愿意看到的。

方法

那么我想这里有两种解决办法:

  1. 合并两次修改。但是如果有冲突怎么办?像 Git 那样修改 conflicts?这个办法还可能需要多个用户沟通解决,实现起来也比较复杂。
  2. 锁定。只让一人修改,后来者只能读,并被告知某人正在编辑,稍后再来。这个办法简单,容易实现。

实现

我决定使用 socket.io 来实现第二个方案。websocket 的好处是实时,不用轮询来得知状态。代码也简洁。虽然这里使用 Ajax 方式也可以。

具体方法是(只考虑较少用户量):

  • 客户端(浏览器):每次试图从服务器读取一个文档时,发送

    socket.emit("toEdit", {userEmail: $scope.userEmail, docId: $scope.docId}); 
    

    等下会介绍,服务器端会有一个对象保存所有正在使用的相关文档信息,这个 docId 用来判断这份文档是否正在使用,userEmail 判断谁在占用该文档,可以方便内部用户之间协商使用时间,例如企业内部用户。

    如果发现这份文档正在被其他人使用,服务器端会发送 'isEditing' 消息告知客户端正在使用

    socket.on('isEditing', function (email) {
        alert("该文档正在被 " + email + " 编辑。”);
        $scope.disableSaveButton = true; //灰化保存按钮,防止用户保存更新
    });
    
  • 服务器端(Node.js):在 server 端使用一个对象保存已在编辑的相关信息。如下:

    //结构:{socket.id: {userEmail: email, docId: id}}
    //socket.id:  每个 socket 链接自动产生的 id,用于识别每个登录的页面,同个用户可能使用多个页面登录;
    //doc id:     文档的唯一ID, 用于识别用户准备和正在编辑的文档;
    //user email: 用于通知用户谁正占用该文档。如果是内部用户,这可以让他们自己协调使用时间。
    var editingUsersInfo = {}; 
    

    editingUsersInfo 保存了所有正在编辑文档的相关信息;如果一个页面查找的 docId 没人在使用的话,这个对象就会产生一个对应的属性。见下:

    io.on('connection', function(socket){
        socket.on('toEdit', function(c_ei){
            //s_ei: server {user email, doc id}; 服务器端每个 socket 链接对应的正在使用的信息
            //c_ei: client {user email, doc id}; 客户端试图编辑某个文档的信息
            //found_c_ei: 发现正处于编辑状态的客户和文档信息; 
            //ssid: server socket id
            var found_c_ei = _.find( editingUsersInfo, function( s_ei, ssid ){ 
                //只有文档 id 一样并且同时 socket id 不一样时才算该文档正在被编辑;
                //如果文档 id 和 socket id 都各自一样,那么说明是正在使用的用户刷新了或者在重新读取了该文档
                 if( s_ei.babyID === c_ei.babyID && (ssid != socket.id)){
                    socket.emit( "isEditing", s_ei.email );     
                    if( editingUsersInfo.hasOwnProperty(socket.id) ){
                    delete editingUsersInfo[socket.id]; }//这里是去掉已有用户搜索其他文档时遇到已在使用的情况
                    return true;
                 }
            });
            if( !found_c_ei ){ 
                editingUsersInfo[socket.id] = c_ei; //不在使用,保存起来
            }
        });
        socket.on("disconnect", function(){
            delete editingUsersInfo[socket.id]; //离线切断链接,删除保存的信息
        });
    });
    

辅助

为了防止有人打开文档之后忘记关闭或者退出,一直占用文档的情况发生,可以通过监测用户是否使用来设计倒计时,例如半小时没有任何鼠标键盘动作,那么就自动把用户登出。我使用的是这个 angular 包:http://hackedbychinese.github.io/ng-idle/

总结

很简单的实现。因为 Node.js 的单线程特性,我们也不用考虑多个用户同时访问数据库的情况。写过多线程程序的人应该知道这种 race condition 问题有多麻烦。