简介

即时通讯云后台由数据服务器和推送服务器组成,其中数据服务器由开发者从Github获得源码,自行部署,保证开发者对其 数据的绝对控制。验证系统使用了OAtuth2.0,对外接口是RESTful Api。如果您只是配置部署后台,请转至服务器部署

开发准备

先从Github下载源码,目前后台源码是nodejs版本, 可以使用Webstorm、Visual Studio或其它IDE打开源码。

下面看看源码结构


    +
    //这里所有业务代码
    | -- app
        //业务核心代码
        | -- core
        //实体,可以持久化的
        | -- model
        //使用的一些工具模块,目前有AES加密库,DB操作库,邮件操作库,汉字拼音库
        | -- plugins
        //用提供给router的入口,目前已经慢慢由Core的代码代替
        | -- service
    //附件存储临时位置,开发者可以另行定义,后面会讲到
    | -- atts
    //路由,所有RESTful Api的入口都是这里实现
    | -- router
    //一些基础的测试
    | -- test
    //放一些静态文件
    | -- public
    //主要是404和error时的展示模板
    | -- views
    | -- app.js
    //启动入口,使用node server.js启动本程序
    | -- server.js
                        

类结构介绍

实体结构请看下图:

简单介绍一下各类的意义

  • User。用户类
  • Users。User的集合,管理User的生命周期
  • UserConversation。用户会话类,目前支持单聊&群聊类型
  • UserConversations。UserConversation的集合,管理其的生命周期
  • UserSession。群组类,即是对Session的收藏
  • UserSessions。UserSession的集合,管理其的生命周期
  • Contact。联系人类。
  • Contacts。Contact的集合,管理其的生命周期
  • Session。会话类,所有会话,包括单聊,群聊都以此为单位
  • Sessions。Session的集合,管理其的生命周期
  • SessionMember。会话成员类,即是所有参与此会话的成员
  • SessionMembers。SessionMember的集合,管理其的生命周期
  • Message。消息类,聊天产生的信息,支持各种类型
  • Messages。Message的集合,管理其的生命周期
以上类都带有createdAtupdatedAt两个属性,分别是加入时间最后修改时间

这里有两个核心类,分别是UserSession。用户包括联系人,群组及用户会话;Session包括成员和消息。 从UML图可以看出,其它类均围绕这两个类展开。

同步机制

同步机制是保证客户端和服务器数据一致的重要机制,目前分五种集合:

  • 用户会话范围:用户
  • 联系人范围:用户
  • 群组范围:用户
  • SessionMember范围:Session
  • Message范围:Session

基本原理

同步机制的原理很简单,目前同步机制是以数据集合为单位的。每个数据集合同步流程为:

  1. 获得本地时间戳(如果没有,定为1900年1月1日 0时0分0秒)
  2. 以此时间戳访问业务服务器取得数据
  3. 将取得数据更新至本地(有三种情况:新增的,修改的,删除的)
  4. 取此数据最大的updatedAt时间为新的本地时间戳,保存这个时间戳.
  5. 判断数据是否同步完,如果没有,重回第1步

而服务端每更改数据,均要更新其updatedAt属性,如此,客户端便可以最少的流量,最少的请求获得完整数据。有了此同步机制, 各数据搜索也可以直接搜索本地数据,不用再浪费网络请求资源。

这里要特别指出,为了让各客户端能同步到被删除的指令,所有的数据删除并不是真正意义上的删除,而改其栏位isDelete,当其为 true时就表示他已经被删除了。

用户

用户即是即时通讯的帐户体系,先看app/core/user.js的结构:


    var User=function(users,entity){
        this.users=users;
        this.entity=entity;
        this.id=entity.id;
        this.userConversations=new UserConversations(this); //用户会话集合
        this.contacts=new Contacts(this); //联系人集合

        //各项属性
        for(var index in this.entity)
            this[index]=this.entity[index];

    };
                        

其中各项属性如下(参考app/model/user.js):


    var Obj=db.define("User",{
        email:db.String,
        name:db.String,
        password:db.String,
        role:db.String,
        phone:db.String,
        jobTitle:db.String, //职位
        address:db.String,
        status:db.String, //用户状态,有效(active),封禁(inactive)
        avatarUrl:db.String,
        isDelete:db.Boolean //是否已删除
    });
                        

代码上app/core/user.js引用了app/model/user.js,前者负责实现User各项功能调用,后者只是持久定义。 本程序基本所有类都遵循代码逻辑处理和数据持久分开。

帐户对接

如您的业务系统已有帐户体系,您可以有两种方式来完成帐户对接

1、同步帐户体系

调用RESTful API将您业务系统的帐户体系全部注册一遍

2、改写用户接口

只要重写用户的以下几个方法即可:

  • 获取:app/core/users.js的方法get
  • 注册:app/core/users.js的方法register
  • 删除:app/core/users.js的方法delete
  • 保存:app/core/user.js的方法store

用户会话

用户会话是此用户交流过的会话收藏列表,主要显示会话最新消息用户未读消息数。用户会话 应该是客户端访问最频繁的数据集合,为了增加服务负载,用户会话均读取入内存,将性能做到最优。

查看下其结构


    var Obj=db.define("UserConversation",{
        ownerId:db.String, //所属者Id,此时是用户Id
        targetId:db.String,
        name:db.String,
        type:db.String, //['p2p','group']
        top:db.Boolean, //置顶
        avatarUrl:db.String,
        isDelete:db.Boolean
    });
                        

用户会话有以下几点需要注意的地方:

  • 所有用户会话均由服务端创建,客户端只需建立SessionSessionMmember,当发消息时会自动创建对应的会话
  • 用户会话的用户未读消息数并未持久化,故服务器重启后此值会重置0
  • 用户会话的updatedAt也是最新消息的updatedAt
  • 用户会话的targetId属性分两种情况:type=='p2p'时是对方UserIdtype=='group'时是SessionId
  • 用户会话的名称头像分两种情况:type=='p2p'时是对方的名称头像,非最新,需要客户端处理获得最新名称头像;type=='group'时是Session的名称头像,一直保持最新。

获得最新用户会话


    UserConversations.prototype.getList=function(clientTime,cb){

        //支持单参数访问
        if(clientTime && !cb) {
            cb = clientTime;
            clientTime=new Date(1900,1,1);
        }

        var that=this;
        var lastConvrs=[];
        var tobj=this;
        thenjs()
            .then(function(cont){
                //先查看是否已载入过用户会话列表
                if(that.list)
                    return cont(null,that.list);

                var clientId=that.user.users.client.id;
                var userid=that.user.id;
                //没有就去取出最新有更新100条
                //此处限定了100条是为了避免一次载入大量的用户会话使服务内存爆了。
                UserConversationModel.getLatest(clientId,userid,100,function(error,objs){
                    if(that.list)
                        return  cont(null,that.list);
                    that.list=[];
                    for(var i=0;iclientTime)
                        lastConvrs.push(obj);
                }
                cont();
            })
            //更新每一个用户会话
            .each(lastConvrs,function(cont,uc){
                if(uc.updatedAt>tobj.timeslip || !uc.lastMessage)
                    return uc.refresh(cont);
                cont();
            })
            //排序返回
            .then(function(cont,list){
                lastConvrs.sort(function(a,b){
                    return a.updatedAt- b.updatedAt;
                });

                cb(null,lastConvrs);
            })
            //出错处理
            .fail(function(cont,error){
                cb(error);
            });

    };
                        

用户会话刷新


    UserConversation.prototype.refresh=function(cb){
        //找到群组查看消息
        var that=this;
        var tobj={};
        thenjs()
            //先找到此用户会话对应的Session
            .then(function(cont){
                that.getSession(cont);
            })
            .then(function(cont,session){
                if(!session)
                    return cb();
                tobj.session=session;
                //再找到在此Session对应的SessionMember(群成员)
                tobj.session.getMemberByUserId(that.userId,function(error,member){
                    //找不到群成员,不必刷新
                    if(error)
                        return cb();

                    //群成员被删除,不用刷新
                    if(member.isDelete) {
                        if(session.type=="group") {
                            that.name = session.name;
                            that.avatarUrl = session.avatarUrl;
                        }
                        return cb();
                    }

                    tobj.member=member;
                    cont(null,session);
                });
            })
            //同步type=='group'的头像名称
            .then(function(cont,session){
                if(session.type=="group") {
                    that.name = session.name;
                    that.avatarUrl=session.avatarUrl;
                }
                tobj.session.getLastMessage(cont);
            })
            //更新最后一条消息
            .then(function(cont,lastMessage){
                if(lastMessage) {
                    if(lastMessage.createdAt &&lastMessage.createdAt>that.updatedAt)
                        that.updatedAt = lastMessage.createdAt;
                    that.lastMessage ={
                        sender:lastMessage.sender,
                        type:lastMessage.type,
                        content:lastMessage.content
                    };

                }
                cont();
            })
            //更新最新未读条数
            .then(function(cont){
                that.unread=tobj.member.unread;
                cont();
            })
            .then(function(cont){
                cb();
            })
            //错误处理
            .fail(function(cont,error){
                cb(error);
            });
    };
                            

联系人

联系人也是常说的好友,其结构为:


    var Obj=db.define("Contact",{
        userId:db.String, //对方用户Id
        ownerId:db.String, //所属者Id,此时是用户Id
        name:db.String,
        title:db.String, //备注,别名,绰号等
        remark:db.Text,  //其它非名称类的备注
        avatarUrl:db.String, //头像
        isDelete:db.Boolean
    });
                        

由上可知,Contact保存了冗余的数据name,avatarUrl,这冗余数据并不会及时的更新,需要客户 端处理保持。这里设定冗余数据的目的是为了客户端第一次加载时可以快速地展示,增加用户体验。而其头像&名称的及时性权重级别 并没有那么高,可以在后补即可。

群组

群组,其实就是对Session的收藏。

群组和讨论组在IM较常见,群组更多是有明确组织意义的群聊,而讨论组更多是临时的群聊。在平常使用习惯中, 普通用户是很难分清现在建的这个群聊是群组还是临时的讨论组。为了解决这个问题,我们采用以下策略:

  1. 首次创建的群聊是讨论组
  2. 当某用户认为此讨论组的意义开始升华为群组了,此用户收藏之。
  3. 但在其它用户中此群聊还是讨论组

下面看下结构:


    var Obj=db.define("UserSession",{
        owner:db.String, //所属者Id,此时是用户Id
        sessionId:db.String,
        name:db.String,
        avatarUrl:db.String,
        isDelete:db.Boolean
    });
                        

由上可知,UserSession也保存了冗余的数据name,avatarUrl,也是为了第一次载入会更快。

Session

Session是整个程序的核心,是重中之重。每个Session均包括SessionMemberMessage, 单聊和群聊只是Session的类型p2p,group差异,同时通知推送也以Session为处理单位。

其结构如下:


    var Obj=db.define("Session",{
        mark:db.String, //标识。当type=='p2p'时,mark为两者UserId顺序排列,中间以","隔开
        secureType:db.String, //安全类型。 ['public','private']
        type:db.String, //基础类型。 ['p2p','group'...]
        description:db.String, //描述
        name:db.String, //名称。当type=='group'且用户未自定义名称时,名称取前4名成员名称组合
        nameChanged:db.Boolean, //用户是否自定义名称。如果自定义后,name将不再自动变更
        messageUpdatedAt:db.DateTime, //最新消息的更新时间
        avatarUrl:db.String, //头像
        isDelete:db.Boolean //是否删除
    });
                        

 

用户会话刷新


    Session.prototype.autoUpdateName=function(cb){

        //只有群才能自动重命名
        if(this.type!="group")
            return cb();

        //已经由用户定义过名称,不用再自动更名
        if(this.nameChanged)
            return cb();

        var that=this;
        thenjs()
            .then(function(cont){
                that.getMembers(cont);
            })
            .then(function(cont,members){
                if(members.length<0)
                    return cb();

                var names=[];
                //找出前4名成员
                for(var i=0;i<members.length && names.length<4;i++){
                    var member=members[i];

                    if(!member.isDelete)
                        names.push(members[i].name);
                }

                var n=names.join(",");
                //名称没变,返回
                if(n==that.name)
                    return cb();

                that.name=n;
                that.entity.updatedAt=new Date();
                that.entity.name=n;
                //更新
                SesssionModel.update(that.entity,["name","updatedAt"],cont);
            })
            .then(function(cont){
                that.updatedAt=that.entity.updatedAt;
                that.name=that.entity.name;
                if(cb)
                    cb();
            })
            //错误处理
            .fail(function(cont,error){
                cb(error);
            })
    };
                            

SessionMember

SessionMember是参与会话群聊的成员。其结构如下:


    var Obj=db.define("SessionMember",{
        userId:db.String,
        sessionId:db.String,
        name:db.String, //此会话的名称
        role:db.String, //角色。['master','admin','user']
        status:db.String, //状态,有效(active),封禁(inactive)
        hasConvr:db.Boolean, //对应用户会话是否存在
        lastReadTime:db.DateTime, //读取本会话最后一条消息的时间(已读消息状态关键忏悔)
        avatarUrl:db.String, //头像
        isDelete:db.Boolean
    });
                        

和联系人一样,会话成员也保存着冗余的name,avatarUrl,为了使客户端更快的载入,更好的体验。

creatdAt

在前面有提到,本程序所有类都有这个属性,意为数据创建加入时间,在SessionMember中createdAt有不一样的意义。

在会话成员删除时,客户端需要被明确告知此成员何时被删除,以便其维护和推送服务成员同步。 服务端在每个会话成员删除时会刷新其createdAt属性为最新,客户端将createdAt定为会话成员增删同步的时间戳即可。 不使用updatedAt作为会话成员增删同步的时间戳是为了避免太过频繁的同步。

消息

查看消息结构:


    var Obj=db.define("Message",{
        sessionId:db.String,
        sender:db.String, //发送者
        content:db.Text, //消息体
        type:db.String, //类型
        updatedTime:db.BigInt, //时间戳,至毫秒级
        isDelete:db.Boolean
    });
                        

content属性

消息内容格式是可以定制的,此属性目前建议保存json字符串,使用很方便。目前建议几种格式:

  • 系统消息

    
        {
            type:"system"
            content:"{\"text\":\"这是一条测试系统消息\"}"
        }
                            
  • 普通文字消息

    
        {
            type:"text"
            content:"{\"text\":\"这是一条测试文字消息\"}"
        }
                                    
  • 图片消息

    
        {
            type:"image"
            content:"{\"src\":\"图片地址\", \"thumbnail\":\"略缩图地址\", \"width\":80, \"height\":100}"
        }
                                    
  • 视频消息

    
        {
            type:"video"
            content:"{\"src\":\"视频地址\", \"thumbnail\":\"略缩图地址\", \"width\":80, \"height\":100}"
        }
                                    
  • 音频消息

    
        {
            type:"audio"
            content:"{\"src\":\"音频地址\",\"second\":220}"
        }
                                    
  • 地理位置消息

    
        {
            type:"location"
            content:"{\"thumbnail\":\"略缩图地址\", \"width\":80, \"height\":100, \"longitude\":123.32232, \"latitude\":32.94833}"
        }
                                    

直接取得历史消息

消息除了正常的数据同步之外,还提供了直接取得历史消息的接口。多用于未保存历史记录的客户端,如web等。

系统消息的发送

目前由服务端自动发送系统消息有以下三种情况:

  1. 单聊开始时,会发您已和对方是好友,可以开始聊天了
  2. 群聊新增成员时,会发某某加入了群聊
  3. 群聊有成员离开时,会发某某离开了群聊

代码如下:


    var systemMessage_memberAdd=function(uid,member,cb){

        uid=uid||"system";

        var that=this;
        if(that.type=="p2p") {
            that.getMembers(function (error, list) {
                if (error)
                    return cb();
                if (list.length != 2)
                    return cb();
                that.addMessage(uid,JSON.stringify({"text":"你们现在可以开始聊天了。"}),"system",cb);
            })
        }
        else if(that.type=="group"){
            that.addMessage(uid,JSON.stringify({"text":(member.name||"有人")+"加入群聊"}),"system",cb);
        }
        else{
            return cb();
        }

    };
    var systemMessage_memberLeave=function(uid,member,cb){
        var that=this;
        uid=uid||"system";
        if(that.type=="group"){
            that.addMessage(uid,JSON.stringify({"text":(member.name||"有人")+"离开了群"}),"system",cb);
        }
        return cb();
    };
                                

附件

消息除文字消息和系统消息,其它消息都需要有附件,图片,文档,音频,视频等。 目前本程序集成一个较小的附件模块,如开发者需要有结合第三方的存储,替换本模块即可,很方便。

附件的存储位置

打开app/service/attachement.js


    service.createPath=function(){
        var d=new Date();
        return "/atts/"+ (1900+d.getYear())+"-"+(d.getMonth()+1)+"/";
    };
                        

默认是./atts/yyyy/mm/更改此地,即可将附件存储到您需要的地方。

上传的附件大小设定

打开/app.js


    app.use(bodyParser.json({defer:true,limit: '50mb'}));
    app.use(bodyParser.urlencoded({limit: '50mb', extended: false }));
                        

目前是限定50mb,您可以根据需要调整。

使用其它存储模块

只需要重写以下两个地方app/router/attaments.js即可,非常简单:

  • 保存文件二进制游
    
        router.post("/attachments",oauth,function(req,res,next){
            //......
            busboy.on('file', function(fieldname, file, filename, encoding, mimetype) {
                fileName=filename;
                size=file.size;
                var saveTo = Path.join(__dirname, ".."+path);
    
                if(!fs.existsSync(saveTo))
                fs.mkdirSync(saveTo);
    
                path += md5;
                saveTo += md5;
    
                console.log(saveTo);
                file.pipe(fs.createWriteStream(saveTo));
            });
            //......
        });
                                    
  • 读取文件二进制游
    
        router.get("/attachments/:attachmentId/content",oauth,function(req,res,next){
            //......
            var filePath=Path.join(__dirname,".."+obj.path);
            res.download(filePath);
            //......
        });
    
                                            

其它地方无需修改即可完成模块替换

RESTful Api

RESTful架构是一种流行的互联网软件架构,它结构清晰,符合标准,易于理解,扩展方便。 本服务器代码便是采用RESTful架构对外提供API, 开发者可以很轻易进行对接。详细请点击: 详细RESTful API

oAuth2.0

此服务端认证体系采用oAuth2.0,是采用oauth2-server搭建的。

配置

打开/app.js


    app.oauth = oauthserver({
        model: require("./app/service/oauth"),
        grants: ['password','authorization_code','refresh_token','client_credentials'],
        debug: false
    });
    app.all('/oauth/token', app.oauth.grant());
    app.get('/oauth/authorize', function (req, res) {

        var scope=req.query.scope;
        var client_id=req.query.client_id;
        var redirect_uri=req.query.redirect_uri;
        var response_type=req.query.response_type;

        var ext=new Date();
        ext.setHours(ext.getHours()+10);
        var code=utils.guid(16);
        app.oauth.model.saveAuthCode(code,client_id,ext,1,function(err){
            redirect_uri+=(redirect_uri.indexOf("?")>0?"&":"?")+"code="+code;
            res.redirect(redirect_uri);
        });
    });
    app.use(app.oauth.errorHandler());

                                        

主要逻辑代码在app/service/oauth.js