Pomolo Chat

基于Pomelo开发的聊天室应用天生是多进程的,因此可以非常容易地扩展服务器类型和数量。对于多频道聊天室,在Pomelo架构中,前端connector连接服务器专门负责承载连接,后端chat服务器处理具体逻辑。这样扩展的运行架构具有的如下的优势:

  • 负载分离
    架构将承载连接的逻辑和后端业务逻辑完全分离,这样做是非常有必要的,尤其针对广播密集型应用,比如游戏和聊天。密集的广播和网络通讯会占掉大量的资源,经过分离后业务逻辑的处理能里就不能再受广播的影响。
  • 切换简便
    由于前后端分离,用户可以任意切换频道或方便,无需重连前端的websocket。
  • 扩展性好
    用户数量的扩展可以通过连接服务器connector的进程数量来支撑,频道的扩展可以通过哈希分区等算法负载均衡到多态聊天服务器,因此理论上架构可实现频道和用户的无限扩展。
聊天室运行架构

运行架构说明

  • 客户端通过websocket长连接连接到前端服务器群connector
  • 前端服务器connector负责承载连接并将请求转发到后端服务器群
  • 后端服务器群包含按场景分区的场景服务器area、聊天服务器chat、状态服务器status等,不同服务器负责各自的业务逻辑。
  • 后端服务器处理完逻辑后将结果返回给前端服务器connector,在有前端服务器connector广播返回给客户端。
  • master服务器统一管理这些服务器,包括各服务器的启动、监控和关闭。

客户端聊天室的业务逻辑

  • 用户进入聊天室:将用户信息注册到session并让用户加入聊天室的channel
  • 用户发起聊天:用户从客户端发起请求,服务器接收请求等。
  • 广播用户的退出:所有在同一个聊天室的客户端受到请求并显示内容
  • 用户退出:清理session和channel
chat

新增注册登录流程

  1. 客户端输入账户username和密码password,使用HTTP协议POST方法传递数据给登录服务器login server或注册服务器 register server,登录或注册服务器验证成功后返回生成的token令牌和用户的唯一编号uid。
  2. 客户端使用websocket协议向网关服务器gate server发送uid,网关服务器接收到后,根据用户uid进行负载均衡计算后分配一台connector服务器,返回给客户端connector的主机地址和端口。
  3. 客户端向指定IP和端口的connector服务器的路由发送uid和token,connector服务器将用户的token发送给认证服务器auth进行验证,验证成功后生成session,并给客户端返回用户信息。
  4. 客户端收到用户信息后,开始监听各端口数据。

变量解释

  • uid:pomelo中的uid,每个连接会都随机产生一个唯一的uid以保证连接的唯一性,可视为uuid。
  • aid:玩家id由客户端主动传给游戏服务器类似QQ号,正常情况是一个aid对应一个uid,如果业务允许同一账号多地点登录,那么一个aid可能会对应多个uid。
  • rid为roomid即房间号,类似群号。
  • sid表示serverId即当前用户连接的connector服务器的编号,若是私聊则必须知道对方的sid。
  • channel类似房间的概念,channel中的name可认为是房间号rid,全局唯一。

安装配置

$ npm i -g pomelo
$ pomelo init ./chat
$ cd chat
$ npm-install.bat

服务端

Pomelo的App Server规范中指出servers包下每个服务器子包下的每个文件均为一个module模块,module中export方法可被分派请求的method方法。

例如:聊天服务下包含三个文件夹分别是filter过滤器、handler客户端请求处理器、remote远程调用其它内部服务器请求处理器。


聊天服务
文件夹 描述
filter 当前服务器各个module.method执行各个阶段的filter过滤器,分为before和after等。
handler 当前服务器处理客户端请求,客户端通过connector server的socket通道发送过来的request请求,请求的路由格式为serverType.module.method。
remote 非客户端直接请求,由服务器发出的RPC调用处理逻辑。

服务器配置

服务器 名称 类型 对外端口 对内端口
gate 网关服务器 前端服务器 3010 -
connector 连接服务器 前端服务器 3020 3150
gate 聊天服务器 后端服务器 - 3250

服务器结构

  • gate:客户端连接gate网关服务器返回connector连接服务器对外的的host主机地址和port端口。
  • connector:客户端根据获取的host和port连接connector连接服务器,在connector服务器中远程调用auth服务器认证用户。当认证成功后会绑定session,并将用户添加到某个chat聊天场景服务器并返回登录结果。
  • auth:auth认证服务器用于查询数据库创建角色、查询角色等操作。
  • chat:聊天服务器中handler用于提供前端请求接口,remote用于提供后端请求接口。
  • domain:用于处理各种业务逻辑

修改配置文件,添加服务器配置。

$ game-server/config/adminServer.json
[
    {
        "type": "gate",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    },
    {
        "type": "connector",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    },
    {
        "type": "chat",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    }
]
$ vim game-server/config/servers.json
{
  "development":{
    "gate": [
      {"id": "gate-server-1", "host": "127.0.0.1", "clientPort": 3010, "frontend": true},
      {"id": "gate-server-2", "host": "127.0.0.1", "clientPort": 3011, "frontend": true},
      {"id": "gate-server-3", "host": "127.0.0.1", "clientPort": 3012, "frontend": true}
    ],
    "connector": [
      {"id": "connector-server-1", "host": "127.0.0.1", "port": 3150, "clientPort": 3020, "frontend": true},
      {"id": "connector-server-2", "host": "127.0.0.1", "port": 3151,"clientPort": 3021, "frontend": true},
      {"id": "connector-server-3", "host": "127.0.0.1", "port": 3152, "clientPort": 3022, "frontend": true}
    ],
    "chat": [
      {"id": "chat-server-1", "host": "127.0.0.1", "port": 3250},
      {"id": "chat-server-2", "host": "127.0.0.1", "port": 3251},
      {"id": "chat-server-3", "host": "127.0.0.1", "port": 3252}
    ]
  },
  "production":{
    "gate": [
      {"id": "gate-server-1", "host": "127.0.0.1", "clientPort": 3010, "frontend": true},
      {"id": "gate-server-2", "host": "127.0.0.1", "clientPort": 3011, "frontend": true},
      {"id": "gate-server-3", "host": "127.0.0.1", "clientPort": 3012, "frontend": true}
    ],
    "connector": [
      {"id": "connector-server-1", "host": "127.0.0.1", "port": 3150, "clientPort": 3020, "frontend": true},
      {"id": "connector-server-2", "host": "127.0.0.1", "port": 3151, "clientPort": 3021, "frontend": true},
      {"id": "connector-server-3", "host": "127.0.0.1", "port": 3152, "clientPort": 3022, "frontend": true}
    ],
    "chat": [
      {"id": "chat-server-1", "host": "127.0.0.1", "port": 3250},
      {"id": "chat-server-2", "host": "127.0.0.1", "port": 3251},
      {"id": "chat-server-3", "host": "127.0.0.1", "port": 3252}
    ]
  }
}

这里connector服务器和chat服务器具有多态,因此需要考虑对用户请求的服务器进行分配。

配置字段 数据类型 可选 含义
id 字符串 必填 应用服务器的ID
host 字符串 必填 应用服务器的IP或域名
port 数值 必填 RPC请求监听的端口
frontend 布尔 必填 是否是前端服务器,默认为false。
clientPort 数值 选填 前端服务器的客户端请求监听的端口
max-connections 可选 数值 前端服务器最大客户连接数
args 字符串 可选 node v8配置

服务器入口程序

$ vim game-server/app.js
const pomelo = require('pomelo');
const crc = require("crc");

/**
 * 初始化应用
 */
const app = pomelo.createApp();
app.set('name', 'chat');

//应用全局配置 针对所有服务器均有效
app.configure('production|development', function(){

});
//应用配置网关服务器
app.configure('production|development', 'gate', function(){
  app.set('connectorConfig',
    {
        connector : pomelo.connectors.hybridconnector,
        useDict : false,
        useProtobuf : false,
        //心跳监测 断线重连
        //heartbeat : 3,
        heartbeats: true,
        closeTimeout: 60 * 1000,
        heartbeatTimeout: 60 * 1000,
        heartbeatInterval: 25 * 1000
    });
});
//应用配置连接服务器
app.configure('production|development', 'connector', function(){
  app.set('connectorConfig',
    {
      connector : pomelo.connectors.hybridconnector,
      heartbeat : 10,
      useDict : false,
      useProtobuf : false
    });
});
//应用配置聊天服务器
app.configure('production|development', 'chat', function(){
  app.set('connectorConfig',
    {
      connector : pomelo.connectors.hybridconnector,
      heartbeat : 10,
      useDict : false,
      useProtobuf : false
    });
});
//路由设置
app.route("chat", function(session, msg, app, callback){
    //console.log("app.route chat", session, msg);
    //获取所有的服务器
    const servers = app.getServersByType("chat");
    //console.log(servers);
    if(!servers || servers.length===0){
        callback(new Error("chat servers is null"));return;
    }
    //获取聊天室编号
    //console.log(session);
    const id = session.get("rid");// Caught exception: TypeError: session.get is not a function
    //console.log("app.route.chat rid = ", id);
    if(!id){
        callback(new Error("session rid is null"));return;
    }
    //路由匹配
    let index = 0;
    index = Math.abs(crc.crc32(id.toString())) % servers.length;
    const server = servers[index];
    //console.log(server);
    //返回服务器编号
    callback(null, server.id);
});

//启动应用
app.start();

process.on('uncaughtException', function (err) {
  console.error(' Caught exception: ' + err.stack);
});

注意路由中的会话信息

app.route("chat", function(session, msg, app, cb){});
{
  _events: [Object: null prototype] {},
  _eventsCount: 0,
  _maxListeners: undefined,
  id: 1,
  frontendId: 'connector-server-2',
  uid: null,
  __sessionService__:
   SessionService {
     singleSession: undefined,
     sessions: { '1': [Session] },
     uidMap: { '547160*678975': [Array] } },
  settings: { rid: 547160 },
  __session__:
   Session {
     _events:
      [Object: null prototype] {
        closed: [Function: bound onSessionClose],
        bind: [Function],
        unbind: [Function] },
     _eventsCount: 3,
     _maxListeners: undefined,
     id: 1,
     frontendId: 'connector-server-2',
     uid: '547160*678975',
     settings: { rid: 547160 },
     __socket__:
      Socket {
        _events: [Object],
        _eventsCount: 6,
        _maxListeners: undefined,
        id: 1,
        socket: [WebSocket],
        remoteAddress: [Object],
        state: 2 },
     __sessionService__:
      SessionService {
        singleSession: undefined,
        sessions: [Object],
        uidMap: [Object] },
     __state__: 0 } }

前端服务器

网关服务器gate

网关服务器的作用是做前端的负载均衡不参与RPC,因此servers.json服务器配置信息中不会存在port字段,只有clientPort字段。网关服务器在一般用户量情况下一台机器就可以支撑,但用户量多了就需要扩充服务器。

$ vim game-server/config/adminServer.json
[
    {
        "type": "gate",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    }
]
$ vim game-server/config/servers.json
{
    "gate": [
      {"id": "gate-server-1", "host": "127.0.0.1", "clientPort": 3010, "frontend": true},
      {"id": "gate-server-2", "host": "127.0.0.1", "clientPort": 3011, "frontend": true},
      {"id": "gate-server-3", "host": "127.0.0.1", "clientPort": 3012, "frontend": true}
    ]
}

注意:网关服务器的主机地址配置中只能使用host而不能使用clientHost,由于网关服务器只和客户端交互因此只需要clientPort而无非配置port。

$ vim game-server/app.js
app.configure('production|development', 'gate', function(){
  app.set('connectorConfig',
    {
      connector : pomelo.connectors.hybridconnector,
      heartbeat : 3,
      useDict : false,
      useProtobuf : false
    });
});

网关服务器业务逻辑

客户端向网关服务器发出请求,网关服务器会给客户端分配一个connector服务器的IP和端口。

$ vim web-server/view/index.ejs
<script src="js/lib/build/build.js" type="text/javascript"></script>
<script>
    require('boot');
    const pomelo = window.pomelo;
    //客户端与服务器断开连接,原因直接断开。
    pomelo.on("disconnect", function(data){
        console.log("disconnect", data);
    });
    //客户端与服务器断开连接,原因心跳超时。
    pomelo.on("heartbeat timeout", function(data){
        console.log("heartbeat timeout", data);
    });
    //用户进入房间
    pomelo.on("onJoin", function(data){
        console.log("onJoin", data);
    });
    //用户离开房间
    pomelo.on("onKick", function(data){
        console.log("onKick", data);
    });
    pomelo.on("onChat", function(data){
        console.log("onChat", data);
    });
    function pomelo_init_request(host, port, route, param){
        return new Promise((resolve, reject)=>{
            pomelo.init({host:host, port:port, log:true}, socket=>{
                pomelo.request(route, param, res=>{
                    console.log(res);
                    if(res.code === 200){
                        resolve({error:false, code:res.code, data:res.data});
                    }else{
                        reject({error:true, code:res.code});
                    }
                    //pomelo.disconnect();
                });
            });
        });
    }
    function pomelo_request(route, param){
        return new Promise((resolve, reject)=>{
            pomelo.request(route, param, res=>{
                console.log(res);
                if(res.code === 200){
                    resolve({error:false, code:res.code, data:res.data});
                }else{
                    reject({error:true, code:res.code});
                }
                //pomelo.disconnect();
            });
        });
    }
    function id(min=100000, max=1000000){
        return Math.round(Math.random()*(max - min)) + min;
    }
    const aid = id();
    const rid = 222222;
    //访问网关获取连接服务器地址
    async function main(){
        let result;
        result = await pomelo_init_request("127.0.0.1", 3010, "gate.gateHandler.queryEntry", {aid:aid});
        if(!result.error){
            await pomelo_init_request(result.data.host, result.data.port, "connector.chatHandler.join", {aid:aid, rid:rid});
        }
        await pomelo_request("chat.chatHandler.send", {target:"*", content:"hello world"});
    }
    main();
</script>

这里需要注意登录顶号的问题,由于一个客户端只有一个pomelo实例,当用户登录后不退出而重启客户端,此时服务器检测到玩家已经登录会将之前的登录踢下线,客户端会触发disconnect事件,在disconnect中断开pomelo连接,这样导致当前的连接也被断掉了。解决的方式在disconnect中不断开连接,但问题是当服务器连接不上时会报错。

$ vim game-server/app/servers/gate/handler/gateHandler.js
const crc = require("crc");

module.exports = function(app) {
  return new Module(app);
};

const Module = function(app) {
  this.app = app;
};

/**
 * 网关服务器查询入口
 * 根据负载均衡算法分配connector服务器
 * 返回connector服务器的地址和对外端口
 */
Module.prototype.queryEntry = function(msg, session, next) {
    const app = this.app;
    //获取参数
    const uid = msg.aid;
    if(!uid){
        next(new Error("parameter error"), {code:500});return;
    }
    //获取服务器列表
    const servers = app.getServersByType("connector");
    if(!servers || servers.length===0){
        next(new Error("server list not exists"), {code:500});return;
    }
    console.log(servers);
    //路由匹配算法
    console.log(uid, crc.crc32(uid.toString()), servers.length);
    const index = Math.abs(crc.crc32(uid.toString())) % servers.length;//负载均衡匹配算法
    console.log(index);
    const server = servers[index];
    console.log(server);
    if(!server){
        next(new Error("server not exists"), {code:500});return;
    }
    //返回客户端地址与端口
    next(null, {code: 200, data:{host:server.host, port:server.clientPort}});
};

查看msg参数的结构可发现

console.log(msg);
{ 
  aid: 481942,
  __route__: 'gate.gateHandler.queryEntry' 
}

根据类型获取的目标服务器列表servers格式为

 [ { main: 'D:\\pomelo\\workspace\\chat\\game-server\\app.js',
    env: 'development',
    id: 'connector-server-1',
    host: '127.0.0.1',
    port: 3150,
    clientHost: '127.0.0.1',
    clientPort: 3020,
    frontend: 'true',
    serverType: 'connector',
    pid: 2240 },
  { main: 'D:\\pomelo\\workspace\\chat\\game-server\\app.js',
    env: 'development',
    id: 'connector-server-3',
    host: '127.0.0.1',
    port: 3152,
    clientHost: '127.0.0.1',
    clientPort: 3022,
    frontend: 'true',
    serverType: 'connector',
    pid: 6748 },
  { main: 'D:\\pomelo\\workspace\\chat\\game-server\\app.js',
    env: 'development',
    id: 'connector-server-2',
    host: '127.0.0.1',
    port: 3151,
    clientHost: '127.0.0.1',
    clientPort: 3021,
    frontend: 'true',
    serverType: 'connector',
    pid: 6464 } ]

注意:crc.crc32()方法需要传入参数的格式为字符串,若传入参数格式为数字则无效。

服务器的负载均衡的分配策略是根据客户端ID做哈希运算后再除以当前服务器的个数已获取目标的索引值。

//负载均衡分配目标服务器
console.log(uid, crc.crc32(uid.toString()), servers.length);
const index = Math.abs(crc.crc32(uid.toString())) % servers.length;//负载均衡匹配算法
console.log(index);
const server = servers[index];
console.log(server);
if(!server){
    next(new Error("server not exists"), {code:500});return;
}

目标服务器输出信息格式

{ main: 'D:\\pomelo\\workspace\\chat\\game-server\\app.js',
  env: 'development',
  id: 'connector-server-2',
  host: '127.0.0.1',
  port: 3151,
  clientHost: '127.0.0.1',
  clientPort: 3021,
  frontend: 'true',
  serverType: 'connector',
  pid: 21564 }

服务器代码实现

  1. 添加gate网关服务器配置
{"id": "gate-server-1", "host": "127.0.0.1", "clientPort": 3014, "frontend": true}
  1. 访问网关指定路由获取connector的地址

Pomelo中handler和remote决定了服务器的行为,handler用于接收用户发送过来的send请求,remote由connector RPC发起远程调用时调用。在remote中由于涉及到用户的加入和退出,所有会有对channel的操作。

$ vim game-server/app/servers/game/handler/gateHanlder.js

实现gate.gateHandler.queryEntry用于用户连接到gate服务器后返回分配的connector服务器的IP和端口。

连接服务器 connector

游戏服务器中connector的作用

  • connector接收到客户端的连接请求后,创建与客户端的连接,然后维护客户端的会话信息。
  • connector接收客户端对后端服务器的请求,然后按照客户端的路由策略,将客户端请求路由给具体的后端服务器。
  • 当后端服务器处理完请求或需要给客户端推送消息时,connector服务器会扮演一个中间人的角色,完成对客户端的消息转发。

聊天服务器中connector服务器的作用

  • connector服务器接受客户端的请求,并将其路由到chat聊天服务器,同时维护客户端的连接。
  • connector服务器接收客户端对后端服务器的请求,按照用户配置的路由策略,将请求路由给具体的后端服务器。
  • 当后端服务器处理完请求或需要给客户端推送消息时,connector服务器同样会扮演一个中间角色,完成对客户端的消息发送。

connector服务器会同时拥有clientPort和port,其中clientPort端口是用来监听客户端的连接,port端口是用来给后端提供服务。

"connector": [
  {"id": "connector-server-1", "host": "127.0.0.1", "port": 3150, "clientPort": 3020, "frontend": true},
  {"id": "connector-server-2", "host": "127.0.0.1", "port": 3151, "clientPort": 3021, "frontend": true},
  {"id": "connector-server-3", "host": "127.0.0.1", "port": 3152, "clientPort": 3022, "frontend": true}
],

客户端向连接服务器发送请求

    async function main(){
        let result;
        //访问网关获取连接服务器地址
        result = await pomelo_init_request("127.0.0.1", 3010, "gate.gateHandler.queryEntry", {aid:aid});
        if(!result.error){
            //玩家加入房间
            await pomelo_init_request(result.data.host, result.data.port, "connector.chatHandler.join", {aid:aid, rid:rid});
        }
        //向聊天服务器发送消息
        await pomelo_request("chat.chatHandler.send", {target:"*", content:"hello world"});
    }
    main();

连接服务器处理客户端请求

$ vim game-server/servers/connector/handler/chatHandler.js
module.exports = function(app) {
  return new Module(app);
};

let Module = function(app) {
  this.app = app;
};

/**
 * New client entry.
 *
 * @param  {Object}   msg     request message
 * @param  {Object}   session current session object
 * @param  {Function} next    next step callback
 * @return {Void}
 */
Module.prototype.join = function(msg, session, next) {
    //console.log(session);
    const app = this.app;
    const sessionService = app.get("sessionService");
    const serverId = app.get("serverId");
    //接收参数
    const aid = msg.aid;
    const rid = msg.rid;
    if(!aid || !rid){
        next(new Error("parameter error"), {code:500});return;
    }
    //重组形成唯一uid
    const uid = rid+"*"+aid;
    //console.log(uid);//305901*386818
    //判断会话中是否存在uid,用于拒绝重复登录,若需要实现多点登录则需修改。
    const data = sessionService.getByUid(uid);
    console.log("connector.entryHandler.chat sessionService.getByUid(uid) = %j",data);//undefined todo
    if(!!data){
        next(new Error("uid in session duplicate"), {code:500});return;
    }
    //会话绑定uid,一旦绑定则uid为只读不可更改。
    session.bind(uid);
    //前端会话设置rid
    session.set("rid", rid);
    //将会话中设置的rid推送到全局会话中
    session.push("rid", function(error){
        if(error){
            console.error("session push rid error: %j", error);
        }
    });
    //当客户端连接断开时触发 将用户踢出房间
    //session.on("closed", function(session, id){
    //  console.log("session closed:", id);
    //});
    session.on("closed", onKick.bind(null, app));
    //console.log(session, session.uid, session.get("rid"));//null 305901
    // 向后端chat服务器发送RPC,获取房间内的用户列表。
    app.rpc.chat.chatRemote.join(session, uid, serverId, rid, true, function(data){
        next(null, {code:200, data:data});
    });
};

const onKick = function(app, session){
    //console.log(app, session);
    const uid = session.uid;
    const serverId = app.getServerId();
    const rid = session.get("rid");
    console.log("connector.entryHandler.onKick", uid, serverId, rid);
    app.rpc.chat.chatRemote.kick(uid, serverId, rid, function(){

    });
};

查看连接服务器聊天入口中的前端会话的结构

FrontendSession {
  _events: [Object: null prototype] {},
  _eventsCount: 0,
  _maxListeners: undefined,
  id: 1,
  frontendId: 'connector-server-3',
  uid: null,
  __sessionService__:
   SessionService {
     singleSession: undefined,
     sessions: { '1': [Session] },
     uidMap: {} },
  settings: {},
  __session__:
   Session {
     _events:
      [Object: null prototype] {
        closed: [Function: bound onSessionClose],
        bind: [Function],
        unbind: [Function] },
     _eventsCount: 3,
     _maxListeners: undefined,
     id: 1,
     frontendId: 'connector-server-3',
     uid: null,
     settings: {},
     __socket__:
      Socket {
        _events: [Object],
        _eventsCount: 6,
        _maxListeners: undefined,
        id: 1,
        socket: [WebSocket],
        remoteAddress: [Object],
        state: 2 },
     __sessionService__:
      SessionService { singleSession: undefined, sessions: [Object], uidMap: {} },
     __state__: 0 } }

前端会话绑定uid并设置rid后的会话数据结构

//前端会话绑定uid
session.bind(uid);
//前端会话设置rid
session.set("rid", rid);
//将前端会话中设置的rid推送到全局会话中
session.push("rid", function(error){
    if(error){
        console.error("session push rid error: %j", error);
    }
});
console.log(session.uid, session.get("rid"));//null 305901
FrontendSession {
  _events: [Object: null prototype] {},
  _eventsCount: 0,
  _maxListeners: undefined,
  id: 1,
  frontendId: 'connector-server-3',
  uid: null,
  __sessionService__:
   SessionService {
     singleSession: undefined,
     sessions: { '1': [Session] },
     uidMap: { '945275*771977': [Array] } },
  settings: { rid: 945275 },
  __session__:
   Session {
     _events:
      [Object: null prototype] {
        closed: [Function: bound onSessionClose],
        bind: [Function],
        unbind: [Function] },
     _eventsCount: 3,
     _maxListeners: undefined,
     id: 1,
     frontendId: 'connector-server-3',
     uid: '945275*771977',
     settings: { rid: 945275 },
     __socket__:
      Socket {
        _events: [Object],
        _eventsCount: 6,
        _maxListeners: undefined,
        id: 1,
        socket: [WebSocket],
        remoteAddress: [Object],
        state: 2 },
     __sessionService__:
      SessionService {
        singleSession: undefined,
        sessions: [Object],
        uidMap: [Object] },
     __state__: 0 } }

此时发现使用session.bind(uid)后,若直接使用session.uid返回的结果是undefined。在前端服务器的handler中式无法直接获取绑定的uid。

而使用session.set('rid',rid)后可直接使用session.get("rid")获取设置的结果。

这里发现使用sessionService.getByUid(uid)获取的数据若没有加入则返回undefined,否则返回数据的格式为

[ Session {
    _events:
     [Object: null prototype] { closed: [Array], bind: [Function], unbind: [Function] },
    _eventsCount: 3,
    _maxListeners: undefined,
    id: 1,
    frontendId: 'connector-server-2',
    uid: '222222*111111',
    settings: { rid: 222222 },
    __socket__:
     Socket {
       _events: [Object],
       _eventsCount: 6,
       _maxListeners: undefined,
       id: 1,
       socket: [WebSocket],
       remoteAddress: [Object],
       state: 2 },
    __sessionService__:
     SessionService {
       singleSession: undefined,
       sessions: [Object],
       uidMap: [Object] },
    __state__: 0 } ]

用户加入房间

// 向后端chat服务器发送RPC,获取房间内的用户列表。
app.rpc.chat.chatRemote.join(session, uid, serverId, rid, true, function(data){
    next(null, {code:500, data:data});
});

用户踢出房间

//当客户端连接断开时触发 将用户踢出房间
//session.on("closed", function(session, id){
//  console.log("session closed:", id);
//});
session.on("closed", onKick.bind(null, app));

const onKick = function(app, session){
    //console.log(app, session);
    const uid = session.uid;
    const serverId = app.getServerId();
    const rid = session.get("rid");
    console.log("connector.entryHandler.onKick", uid, serverId, rid);
    app.rpc.chatRemote.kick(uid, serverId, rid, function(){

    });
};

后端服务器

后端服务器是用来处理 用户请求的具体业务逻辑的地方,当前端服务器接收到来自客户端的请求时,通过分析请求的路由,并做出简单校验表明路由是合法时,前端服务器就会根据路由策略配置,选择某一台后端服务器,然后发出rpc调用。

后端服务器的所有调用请求均来自前端服务器的rpc调用,即从rpc请求中获取的。也就是说后端服务器的Server组件的请求是MsgRemote派发的。

当后端服务器发起filter-handler链对前端服务器分派过来的请求进行处理时,如果仅仅需要给用户端响应,那通过rpc的回调返回具体响应即可。但很多情况下具体的请求处理逻辑需要给其他用户推送消息。

比如聊天应用中当有一个用户发起聊天请求时,其他聊天的所有内容都需要推送给同一房间的其他用户。当然消息推送逻辑并不仅仅在后端服务器中使用,前端服务器中也可能有类似的场景。

BackendSession组件和Channel组件一般是用于后端服务器中完成给特定用户推送消息,BackendSession可以看作前端原始session在后端服务器的一个代理,BackendSession包装的BackendSessionService是用来创建并管理后端的BackendSession,可以通过bind和push调用给前端原始的session绑定uid并设置属性。

Channel组件包装的ChannelService中维护了频道的信息,每个Channel可看作是一系列绑定用户uid的集合。通过Channel的调用即可向客户端推送消息。

聊天服务器 chat

服务器配置

$ vim game-server/config/adminServer.json
{
    "type": "chat",
    "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
}
$ vim game-server/config/servers.json
"chat": [
  {"id": "chat-server-1", "host": "127.0.0.1", "port": 3250, "clientPort": 3030, "frontend": true},
  {"id": "chat-server-2", "host": "127.0.0.1", "port": 3251, "clientPort": 3031, "frontend": true},
  {"id": "chat-server-3", "host": "127.0.0.1", "port": 3252, "clientPort": 3032, "frontend": true}
]

连接配置

$ vim game-server/app.js
app.configure('production|development', 'chat', function(){
  app.set('connectorConfig',
    {
      connector : pomelo.connectors.hybridconnector,
      heartbeat : 10,
      useDict : false,
      useProtobuf : false
    });
});

路由配置

$ vim game-server/app.js
const crc = require("crc");
//路由设置
app.route("chat", function(session, msg, app, callback){
    //获取所有的服务器
    const servers = app.getServersByType("chat");
    //console.log(servers);
    if(!servers || servers.length===0){
        callback(new Error("chat servers is null"));return;
    }
    //获取聊天室编号
    const id = session.get("rid");
    //console.log(id);
    if(!id){
        callback(new Error("session rid is null"));return;
    }
    //路由匹配
    const index = Math.abs(crc.crc32(id.toString())) % servers.length;
    const server = servers[index];
    //console.log(server);
    //返回服务器编号
    callback(null, server.id);
});

路由配置中注意返回的格式为callback(null, server.id)

配置RPC

$ vim game-server/app/servers/chat/remote/chatRemote.js
module.exports = function(app){
    return new Module(app);
};
let Module = function(app){
    this.app = app;
};

/**
 * 向频道添加用户
 * uid 用户UID
 * serverid 用户所在connector服务器的ID
 * channelName 频道名称
 * channelCreate 是否创建频道
 * callback 添加成功后回调函数
 * */
Module.prototype.join = function(uid, serverId, channelName, channelCreate, callback){
    console.log("chatRemote.join", uid, serverId, channelName, channelCreate);
    const app = this.app;
    const channelService = app.get("channelService");
    //获取房间对应的频道
    const channel = channelService.getChannel(channelName, channelCreate);
    //console.log(channel);
};

/**
 * 从频道中踢出用户
 * uid 用户UID
 * serverId 用户连接的connector服务器的ID
 * channelName 频道名称
 * callback 踢出用户后的回调
 * */
Module.prototype.kick = function(uid, serverId, channelName, callback){
    console.log("chatRemote.kick", uid, serverId, channelName);
    const app = this.app;
    const channelService = app.get("channelService");
    //获取房间对应的频道
    const channel = channelService.getChannel(channelName, false);
};

chat聊天服务器接收connector连接服务器发送过来的rpc调用分别是

  • app.rpc.chat.chatRemote.join聊天服务器添加用户进入指定频道
  • app.rpc.chat.chatRemote.kick聊天服务器从指定频道删除用户
$ vim game-server/servers/chat/remote/chatRemote.js
module.exports = function(app){
    return new Module(app);
};
let Module = function(app){
    this.app = app;
};

/**
 * 向频道添加用户
 * uid 用户UID
 * serverid 用户所在connector服务器的ID
 * channelName 频道名称
 * channelCreate 是否创建频道
 * callback 添加成功后回调函数
 * */
Module.prototype.join = function(uid, serverId, channelName, channelCreate, callback){
    console.log("chatRemote.join", uid, serverId, channelName, channelCreate);
    const app = this.app;
    const channelService = app.get("channelService");

    //获取房间对应的频道
    const channel = channelService.getChannel(channelName, channelCreate);
    console.log(channel);

    //向客户端推送消息
    channel.pushMessage("onJoin", uid);

    //将用户添加到频道
    channel.add(uid, serverId);

    //获取评到中所有成员
    const members = channel.getMembers();
    //console.log("members", members);

    callback(members);
};

/**
 * 从频道中踢出用户
 * uid 用户UID
 * serverId 用户连接的connector服务器的ID
 * channelName 频道名称
 * callback 踢出用户后的回调
 * */
Module.prototype.kick = function(uid, serverId, channelName, callback){
    console.log("chatRemote.kick", uid, serverId, channelName);
    const app = this.app;
    const channelService = app.get("channelService");

    //获取房间对应的频道
    const channel = channelService.getChannel(channelName, false);

    //从频道中删除用户
    channel.leave(uid, serverId);

    //向客户端推送消息
    channel.pushMessage("onKick", uid);

    callback(null);
};

查看频道的数据结构

Channel {
  name: 222222,
  groups:
   { 'connector-server-3': [ '222222*203562', '222222*857012' ] },
  records:
   { '222222*203562': { sid: 'connector-server-3', uid: '222222*203562' },
     '222222*857012': { sid: 'connector-server-3', uid: '222222*857012' } },
  __channelService__:
   ChannelService {
     app:
      { init: [Function],
        getBase: [Function],
        require: [Function],
        configureLogger: [Function],
        filter: [Function],
        before: [Function],
        after: [Function],
        globalFilter: [Function],
        globalBefore: [Function],
        globalAfter: [Function],
        rpcBefore: [Function],
        rpcAfter: [Function],
        rpcFilter: [Function],
        load: [Function],
        loadConfigBaseApp: [Function],
        loadConfig: [Function],
        route: [Function],
        beforeStopHook: [Function],
        start: [Function],
        afterStart: [Function],
        stop: [Function],
        set: [Function],
        get: [Function],
        enabled: [Function],
        disabled: [Function],
        enable: [Function],
        disable: [Function],
        configure: [Function],
        registerAdmin: [Function],
        use: [Function],
        transaction: [Function],
        getMaster: [Function],
        getCurServer: [Function],
        getServerId: [Function],
        getServerType: [Function],
        getServers: [Function],
        getServersFromConfig: [Function],
        getServerTypes: [Function],
        getServerById: [Function],
        getServerFromConfig: [Function],
        getServersByType: [Function],
        isFrontend: [Function],
        isBackend: [Function],
        isMaster: [Function],
        addServers: [Function],
        removeServers: [Function],
        replaceServers: [Function],
        addCrons: [Function],
        removeCrons: [Function],
        loaded: [Array],
        components: [Object],
        settings: [Object],
        base: 'D:\\pomelo\\workspace\\chat\\game-server',
        event: [EventEmitter],
        serverId: 'chat-server-3',
        serverType: 'chat',
        curServer: [Object],
        startTime: 1576044791937,
        master: [Object],
        servers: [Object],
        serverTypeMaps: [Object],
        serverTypes: [Array],
        lifecycleCbs: {},
        clusterSeq: {},
        env: 'development',
        main: 'D:\\pomelo\\workspace\\chat\\game-server\\app.js',
        mode: 'clusters',
        type: 'all',
        state: 3,
        sessionService: [Component],
        backendSessionService: [BackendSessionService],
        localSessionService: [BackendSessionService],
        channelService: [Circular],
        rpc: [Getter],
        sysrpc: [Getter],
        rpcInvoke: [Function: bound ] },
     channels: { '222222': [Circular] },
     prefix: undefined,
     store: undefined,
     broadcastFilter: undefined,
     channelRemote: Remote { app: [Object] },
     name: '__channel__' },
  state: 0,
  userAmount: 2 
}

通过 channel.getMembers() 方法或获取使用channel.add(uid, serverId) 添加进入的用户uid数组列表,类似结构 [ '222222*830354', '222222*254368', '222222*863192' ]

此时客户端需需要监听自定义事件onJoinonKick

//用户进入房间
pomelo.on("onJoin", function(data){
    console.log("onJoin", data);
});
//用户离开房间
pomelo.on("onKick", function(data){
    console.log("onKick", data);
});

当使用不同客户端进入同一间房间时,客户端配置。

const aid = id();
const rid = 222222;
//访问网关获取连接服务器地址
async function main(){
    let result;
    result = await pomelo_request("127.0.0.1", 3010, "gate.gateHandler.queryEntry", {aid:aid});
    if(!result.error){
        await pomelo_request(result.data.host, result.data.port, "connector.entryHandler.chat", {aid:aid, rid:rid});
    }
}
main();

客户端发送消息

当客户端在输入框内输入消息后点击发送可以向全频道所有用户或指定当前群组中的某个用户进行发送信息,此时需要客户端直接和聊天服务器进行交互。

$ game-server/servers/chat/handler/chatHandler.js
module.exports = function(app){
    return new Module(app);
};
let Module = function(app){
    this.app = app;
};
Module.prototype.send = function(msg, session, next){
    const app = this.app;
    const channelService = app.get("channelService");
    console.log("chat.chatHandler.send", msg);
    //获取参数
    const channelName = session.get("rid");
    const from = session.uid;
    const target = msg.target;//发送目标用户
    const content = msg.content;
    //根据频道名称获取频道
    const channel = channelService.getChannel(channelName, false);
    //根据目标用户类型进行发送消息
    const route = "onChat";
    const param = {from:from, target:target, content:content};
    if(target === "*"){
        //向频道内所有用户推送消息
        channel.pushMessage(route, param);
    }else{
        //获取目标用户
        const member = channel.getMember(target);
        //获取目标用户所在服务器ID
        let uids = [{uid:target, sid:member.sid}];
        channelService.pushMessageByUids(route, param, uids);
    }
    next(null, {code:200});
};

此时若使用客户端向服务器发送消息,直接使用pomelo.request请求即可,无需在经过pomelo.init初始化后再request。

async function main(){
    let result;
    //访问网关获取连接服务器地址
    result = await pomelo_init_request("127.0.0.1", 3010, "gate.gateHandler.queryEntry", {aid:aid});
    if(!result.error){
        //玩家加入房间
        await pomelo_init_request(result.data.host, result.data.port, "connector.chatHandler.join", {aid:aid, rid:rid});
    }
    //向聊天服务器发送消息
    await pomelo_request("chat.chatHandler.send", {target:"*", content:"hello world"});
}
main();

客户端

查看Express版本并更新到最新版本

$ npm view express versions
$ npm i -S express@latest

安装必备组件

$ vim /web-server/package.json
{
  "name": "test",
  "version": "0.0.1",
  "private": false,
  "scripts": {
    "start": "node ./bin/www"
  },
  "dependencies": {
    "cookie-parser": "~1.4.4",
    "debug": "~4.1.1",
    "ejs": "^2.7.2",
    "errorhandler": "~1.5.1",
    "express": "~4.17.1",
    "express-session": "~1.17.0",
    "file-stream-rotator": "^0.5.5",
    "fs": "~0.0.1-security",
    "http-errors": "~1.7.3",
    "method-override": "~3.0.0",
    "morgan": "~1.9.1",
    "multer": "~1.4.2",
    "mysql": "^2.17.1",
    "path": "~0.12.7",
    "redis": "^2.8.0",
    "serve-favicon": "~2.5.0",
    "serve-index": "~1.9.1",
    "serve-static": "~1.14.1",
    "uuid": "~3.3.3"
  }
}

$ cd /web-server
$ npm update

COOKIE解析

安装配置cookie-parser组件

$ npm i cookie-parse --save

使用方式

$ vim app.js
const express = require("express");
const cookieParser = require("cookie-parser");

const app = express();
//定义COOKIE解析器
app.use(cookieParser());//不适用签订
app.use(cookieParser("secret"));//使用签名指定加密字符串

Cookie创建

function(req, res, next){
  //创建cookie,Express将其填入响应头(Response Header)的Set-Cookie中,达到在浏览器中设置cookie的作用。
  res.cookie(name, value [, options] );
}
参数 类型 描述
name String 键名
value String/Object 键值
option Object 选项

当键值valueObject对象类型时,对象会在cookie.serialize()序列化前自动调用JSON.stringify()对其进行序列化处理。

选项 类型 描述
domain String Cookie在上面域名下有效,默认为网站域名。
expires Date Cookie过期时间,若未设置或为0,则Cookie只在当前Session有效,关闭浏览器后会被删除。
httpOnly Boolean 是否只能被WebServer访问
maxAge String 实现expires的功能,设置Cookie过期的时间,指明从现在开始多少毫秒后Cookie到期。
path String Cookie在什么路径下有效,默认为"/"。
secure Boolean 是否只能被HTTPS协议使用,默认false.
signed Boolan 是否使用签名,默认false。

Cookie删除

function(req, res, next){
  res.clearCookie(name [, options]);
}

file-stream-rotator

安装

$ npm i -save file-stream-rotator

日志记录 morgan

安装HTTP请求日志记录中间件

$ npm i --save mogran

使用方式

morgan(format, options)
参数 类型 描述
format string/function 打印方式,预定义打印方法名称,或格式化字符串,或格式化入口的回调方法。
options object 日志打印参数
打印方式 描述
combined 标准Apache组合日志输出
common 标准Apache公共日志输出
dev 根据返回的状态码彩色输出日志
short 简洁输出,带响应时间
tiny 控制台输出
token 自定义格式输出
日志参数 描述
immediate 请求到达时打印
skip 忽略打印的日志
stream 输出流,默认控制台输出
predefined formats 预定义打印格式

入口文件添加并配置

$ vim app.js
const express = require("express");
const app = express();
const morgan = require("morgan");
//日志记录默认打印到控制台命令行
app.use(morgan("short"));

日志记录打印到文件

const express = require("express");
const app = express();
const morgan = require("morgan");
const fs = require("fs");
const path = require("path");

const logdir = path.join(__dirname, "logs");
fs.existsSync(logdir ) || fs.mkdirSync(logdir );
const stream = fs.createWriteStream(path.join(__dirname, "access.log"), {flags:"a"});
app.use(morgan("short", {stream:stream});

日志记录到文件

const express = require('express');
const app = express();
const fs = require("fs");
const path = require("path");
const FileStreamRotator = require("file-stream-rotator");//文件流旋转器
const morgan = require("morgan");//日志记录中间件
//定义日志和输出级别
const logdir = path.join(__dirname, "logs");
fs.existsSync(logdir) || fs.mkdirSync(logdir);
const stream = FileStreamRotator.getStream({
  date_format:"YYYYMMDD",
  filename:path.join(logdir, "%DATE%.log"),
  frequency:"daily",
  verbose:false
});
app.use(morgan('combined', {stream:stream}));

进程监控 supervisor

安装Supervisor后台监控,避免每次修改后重启Web服务器。

$ npm i -g supervisor
$ supervisor app.js

windows10环境下关闭已经被占用的端口

$ netstat -aon|findstr "3001"
  TCP    127.0.0.1:3001         0.0.0.0:0              LISTENING       24000

$ tasklist|findstr "24000"
node.exe                     24000 Console                    1     33,248 K

$ taskkill /f /t /im node.exe

$ taskkill /pid 24000 /F

项目文件结构

$ ll /web-server
项目文件夹 描述
/bin/ 应用脚本文件夹
/public/ 静态文件夹
/utils/ 公共函数与类库文件夹
/config/ 配置文件夹
/routes/ 路由文件夹
/views/ 视图模板文件夹

编辑项目应用入口

$ vim /web-server/app.js
//创建Express应用
const express = require('express');
const app = express();

//第三方插件
const favicon = require("serve-favicon");
const asset = require("serve-static");
const bodyParser = require("body-parser");
const cookieParser = require("cookie-parser");
const session = require("express-session");
const uuid = require("uuid");
const errorHandler = require("errorhandler");
const ejs = require("ejs");

const multer = require("multer");//文件上传
const errors = require("http-errors");
const methodOverride = require("method-override");
const directory = require("serve-index");

const fs = require("fs");
const path = require("path");
const FileStreamRotator = require("file-stream-rotator");//文件流旋转器
const morgan = require("morgan");//日志记录中间件

//本地配置
let config;

//环境配置
const env = app.get("env") || "development";
if(env === "development"){
  //载入配置
  config = require("./config/development");
  //定义静态请求处理文件夹
  app.use(asset(path.join(__dirname, 'public')));
  //定义错误处理器
  app.use(errorHandler({ dumpExceptions: true, showStack: true }));
}else if(env === "production"){
  //载入配置
  config = require("./config/production");
  //定义静态文件目录
  app.use(asset(path.join(__dirname, 'public'), { maxAge: 31557600000 }));//最大过期时间 一年
  //定义错误处理器
  app.use(errorHandler());
}

//定义日志和输出级别 dev 在控制台打印出所有的请求
const logdir = path.join(__dirname, "logs");
fs.existsSync(logdir) || fs.mkdirSync(logdir);
const stream = FileStreamRotator.getStream({
  date_format:"YYYYMMDD",
  filename:path.join(logdir, "%DATE%.log"),
  frequency:"daily",
  verbose:false
});
app.use(morgan('combined', {stream:stream}));

//定义方法重写
app.use(methodOverride());

//定义服务器ICON图标
app.use(favicon(path.join(__dirname, "public/favicon.ico")));

//定义数据解析器 解析HTTP请求体 获取表单POST的数据
app.use(bodyParser.urlencoded({limit:"5000mb", extended:true}));
app.use(bodyParser.json({limit:"5000mb"}));

//定义COOKIE解析器
app.use(cookieParser());

//定义SESSION解析器
app.use(session({
  genid:(req)=>uuid.v1(),
  cookie:{
    maxAge:1000*60*3//设置session有效事件单位毫秒
  },
  secret:"secret",//对session id相关的cookie进行签名
  rolling:true,
  resave:true,
  saveUninitialized:true//是否保存未初始化的会话
}));

//路径配置
app.set('basepath', path.join(__dirname, 'public'));

//设置视图引擎
app.set("views", path.join(__dirname, "views"));//设置视图模板文件放置的位置
app.engine("html", ejs.renderFile);
app.set('views engine', "html");//定义启动视图引擎 指定模板文件的文件类型

//定义路由
app.use("/", require("./routes/index"));
app.use("/admin", require("./routes/admin"));
app.use("/api", require("./routes/api"));
app.use("/mobile", require("./routes/mobile"));

//监听端口
app.listen(config.client.port, ()=>{
  console.log(`Web server has started.\nPlease log on http://${config.client.host}:${config.client.port}/index.html`);
});

编辑配置文件

$ vim web-server/config/development.js
module.exports = {
    client:{
        host:"127.0.0.1",
        port:3001,
    },
    gate:{
        host:"127.0.0.1",
        port:3014,
    },
    mysql:{
        host:"127.0.0.1",
        port:3306,
        username:"root",
        password:"root",
        dbname:"pomelo"
    },
    redis:{
        host:"127.0.0.1",
        port:6379
    }
};

编辑路由文件

$ vim web-server/routes/index.js
const router = require("express").Router();
const uuid = require("uuid");
const crypto = require("crypto");
const mysql = require("../utils/mysql");
const redisClient = require("../utils/redis");
const utils = require("../utils/utils");

router.get('/index',function(req, res, next){
     let json = {};
     json.title = "default index page";
     json.message = "hello world";
/*
     for(let i=0; i<9999; i++){
          const id = utils.randomNumber(100000, 1000000);
          redisClient.sadd("aid_set", id, function(error, result){
               console.log(error, result);//null 2
          });
     }
*/

     res.render("index/index.html", json);
});

router.get("/login", function(req, res, next){

     let json = {};
     json.title = "登录";

     const csrctoken = uuid.v1();
     json.csrctoken = csrctoken;
     req.session.csrctoken = csrctoken;//将token存入session

     res.render("index/login.html", json);
});
router.post("/login", function(req, res, next){
     const username = req.body.username;
     let password =req.body.password;
     const csrctoken =req.body.csrctoken;
     console.log(req.session.csrctoken, csrctoken);

     //必填参数判断
     if(!username || !password || !csrctoken){
          res.json({error:true, code:500, message:"参数缺失"});
          return;
     }

     //令牌比对
     if(csrctoken!==req.session.csrctoken){
          res.json({error:true, code:500, message:"令牌失效,请刷新后重试!"});
          return;
     }

     password = crypto.createHash("md5").update(password).digest("hex");

     //MySQL:根据账户获取用户记录
     let sql = `SELECT * FROM game_user WHERE 1=1 AND username='${username}' LIMIT 1`;
     mysql.select(sql, function(err, msg){
          console.log(sql, err, msg);
          if(err){
               res.json({error:true, code:500, message:"账户不存在!"});
               return;
          }

          const id = msg.id;
          const aid = msg.aid;
          const salt = msg.salt;

          //验证密码
          password = password + salt;
          password = crypto.createHash("md5").update(password).digest("hex");
          if(msg.password !== password){
               res.json({error:true, code:500, message:"密码错误!"});
               return;
          }

          //更新数据
          const timestamp = utils.timestamp();
          sql = "UPDATE game_user SET ? WHERE 1=1 AND id=?";
          let set = {last_login_time:timestamp};
          mysql.update(sql, [set, id], function(err, msg){
               console.log(sql, err, msg);
               if(err){
                    res.json({error:true, code:500, message:"更新失败!"});
                    return;
               }
          });

          //Redis:将用户信息放入哈希表user_hash:123456
          let key = `user_hash:${aid}`;
          redisClient.hlen(key, function(err, len){
               if(err){

               }
               if(len === 0){
                    let user = {};
                    user.aid = aid;
                    user.username = username;
                    user.nickname = msg.nickname;
                    user.avatar = msg.avatar;
                    user.active = 1;
                    user.logintime = timestamp;
                    //批量写入
                    redisClient.hmset(key, user, function(err, ret){
                         console.log(key, user, err, ret);
                    });
               }else if(len > 0){
                    //更新字段
                    redisClient.hgetall(key, function(err, row){
                         if(err){

                         }
                         row.active = 1;
                         row.logintime = timestamp;
                         redisClient.hmset(key, row, function(err, ret){
                         });
                    });
               }
          });

          //SESSION
          req.session.user = {aid:aid, username:username, nickname:msg.nickname, avatar:msg.avatar};
          req.session.token = uuid.v1();//生成用于验证的TOKEN

          //登录成功
          res.json({error:false, code:200, message:"登录成功", url:"/room"});
          return;
     });

     //res.redirect("/index");
});
router.get("/logout", function(req, res, next){
     //清理Redis
     const aid = req.session.user.aid;
     let key = `user_hash:${aid}`;
     redisClient.hgetall(key, function(err, row){
          row.active = 0;
          row.logouttime = utils.timestamp();
          redisClient.hmset(key, row, function(err, ret){
               console.log(err, ret);
          });
     });
     //清理SESSION
     req.session = null;
     //执行跳转
     res.redirect("/login");
});

router.get("/room", function(req, res, next){
     const user = req.session.user;
     const token = req.session.token;
     if(!user || !token){
          res.redirect("/logout");
          return;
     }

     let json = {};
     json.title = "room";
     json.user = user;
     json.token = token;

     //获取房间列表
     let sql = "SELECT * FROM game_room WHERE 1=1";
     mysql.select(sql, function(err, items){
          if(err){
               json.error = "暂无数据";
          }else{
               json.items = items;
          }
          res.render("index/room.html", json);
     });
});


router.post('/chat',function(req, res, next){
     req.session.rid = req.body.rid;
     res.json({code:200, message:"登录成功", url:"/chat"});
});

router.get("/chat", function(req, res, next){
     let json = {};
     json.title = "chat";
     json.message = "hello";

     json.aid = req.session.user.aid;
     json.rid = req.session.rid;
     json.token = req.session.token;

     res.render("index/chat.html", json);
});

router.get("/member", function(req, res, next){
     let json = {};
     json.title = "member";

     json.rid = req.session.rid;

     res.render("index/member.html", json);
});

router.get("/createroom", function(req, res, next){
     let json = {};
     json.title = "createroom";
     json.aid = req.session.user.aid;
     res.render("index/createroom.html", json);
});

module.exports = router;

MySQL数据库封装

$ vim web-server/utils/mysql.js
const mysql = require("mysql");
const config = {
    host:"127.0.0.1",
    port:3306,
    user:"root",
    password:"root",
    database:"pomelo"
};

let db = {};

//创建连接池
const pool = mysql.createPool(config);

//执行查询
db.query = (sql, callback)=>{
    pool.getConnection((error, connection)=>{
        if(error){
            callback(error, null);
        }else{
            connection.query(sql, (err, result)=>{
                connection.release();//释放连接
                if(err){
                    callback(err, null);
                }else{
                    callback(null, result);
                }
            });
        }
    });
};

//查询操作
db.select = (sql, callback)=>{
    pool.getConnection((error, connection)=>{
        if(error){
            callback(err, null);
        }else{
            connection.query(sql, (err, result)=>{
                connection.release();//释放连接
                //回调处理
                if(err){
                    callback(err.message, null);
                }else{
                    if(result.length === 1){
                        result = result[0];
                    }
                    callback(null, result);
                }
            });
        }
    });
};

//插入数据
db.insert = (sql, values, callback)=>{
    pool.getConnection((error, connection)=>{
        if(error){
            callback(error, null);
        }else{
            connection.query(sql, values, (err, result)=>{
                connection.release();
                if(err){
                    callback(err.message, null);
                }else{
                    callback(null, result.insertId);
                }
            });
        }
    });
};

//更新数据
db.update = (sql, values, callback)=>{
    pool.getConnection((error, connection)=>{
        if(error){
            callback(error, null);
        }else{
            connection.query(sql, values, (err, result)=>{
                connection.release();
                if(err){
                    callback(err.message, null);
                }else{
                    callback(null, result.affectedRows);
                }
            });
        }
    });
};

//删除数据
db.delete = (sql, callback)=>{
    pool.getConnection((error, connection)=>{
        if(error){
            callback(error, null);
        }else{
            connection.query(sql, (err, result)=>{
                connection.release();
                if(err){
                    callback(err.message, null);
                }else{
                    callback(null, result.affectedRows);
                }
            });
        }
    });
};


module.exports = db;

Redis封装类库

$ vim web-server/utils/redis.js
const redis = require("redis");

const config = {};
config.host = "127.0.0.1";
config.port = 6379;

const redisClient = redis.createClient(config.port, config.host);

redisClient.on("connect", function(opts){
    console.log("redis client connect");
});

redisClient.on("error", function(err){
    console.error("redis client on error: %s", err);
});


//monitor事件可以监听到redis接收到的所有客户端命令
redisClient.monitor(function(err, res){
    console.log("redis client monitor: ", res);//ok
});

redisClient.on("monitor", function(time, args){
   console.log("redis client on monitor:", time, args);
});

module.exports = redisClient;

公共函数类库

$ vim web-server/utils/utils.js
let Utils = {};

Utils.randomString = function(len){
    let result = "";

    len = len || 32;
    const chars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678";//默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1
    for(let i=0; i<len; i++){
        result += chars.charAt(Math.floor(Math.random() * chars.length));
    }

    return result;
};

Utils.randomNumber = function(min, max){
  min = min || 100000;
  max = max || 1000000;

  return min + Math.round((max - min) * Math.random());
};

Utils.timestamp = function(isSecond){
    isSecond = isSecond || true;
    if(isSecond){
        return Date.parse(new Date())/1000;
    }else{
        return new Date().getTime();//13位毫秒时间戳
    }
};

module.exports = Utils;

编辑视图文件

$ vim web-server/views/index/index.html
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title><%=title%></title>
</head>
<body>
    <div><%=message%></div>
</body>
</html>

浏览器访问:http://127.0.0.1:3001/index

界面设计

登录界面

$ vim web-server/views/index/login.html
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title><%=title%></title>
    <link href="https://cdn.bootcss.com/font-awesome/5.11.2/css/all.min.css" rel="stylesheet">
    <link href="https://cdn.bootcss.com/twitter-bootstrap/4.3.1/css/bootstrap.css" rel="stylesheet">
</head>
<body class="modal-open">
<div class="container-fluid">
    <div class="container" id="login">
        <div class="form-group text-center">
            <img src="/image/logo.png" />
        </div>
        <div class="form-group">
            <div class="input-group">
                <div class="input-group-prepend">
                    <div class="input-group-text"><i class="fa fa-user"></i></div>
                </div>
                <input type="text" class="form-control" id="username" name="username" aria-describedby="username" placeholder="请输入账号" maxlength="20"/>
            </div>
            <small id="help" class="form-text text-muted"></small>
        </div>
        <div class="form-group">
            <div class="input-group">
                <div class="input-group-prepend">
                    <div class="input-group-text"><i class="fa fa-lock"></i></div>
                </div>
                <input type="password" class="form-control" id="password" name="password" aria-describedby="password" placeholder="请输入密码" maxlength="20"/>
            </div>
        </div>
        <div class="form-group">
            <input type="hidden" id="csrctoken" value="<%=csrctoken%>" />
            <button class="btn btn-success btn-block" type="button" id="submit">登录</button>
        </div>
        <div class="alert alert-dark" role="alert" style="display: none;"></div>
    </div>
</div>
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
<script src="https://cdn.bootcss.com/popper.js/1.15.0/umd/popper.min.js"></script>
<script src="https://cdn.bootcss.com/twitter-bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="https://cdn.bootcss.com/vue/2.6.10/vue.min.js"></script>
<script>
    $(function(){
        //垂直居中
        const targetEl = $("#login");
        const docHeight = $(document).height();
        const targetHeight = targetEl.height();
        const marginTop = (docHeight - targetHeight)/3;
        targetEl.css("marginTop", marginTop+"px");
        //提交表单
        $("#submit").on("click", function(){
            const username = $("#username").val();
            const password =$("#password").val();
            const csrctoken = $("#csrctoken").val();

            let data = {};
            data.username = username;
            data.password = password;
            data.csrctoken = csrctoken;

            const url = window.location.href;
            $.ajax({
                type:"post",
                url:url,
                data:data,
                dataType:"json",
                success:function(res){
                    console.log(res);
                    if(res.code!==200){
                        alert(res.message);
                    }else{
                        window.location.href = res.url;
                    }
                },
                error:function(err){
                    console.error(err);
                }
            });
        });
    });
</script>
</body>
</html>

房间界面

$ vim web-server/views/index/room.html
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title><%=title%></title>
    <link href="https://cdn.bootcss.com/font-awesome/5.11.2/css/all.min.css" rel="stylesheet">
    <link href="https://cdn.bootcss.com/twitter-bootstrap/4.3.1/css/bootstrap.css" rel="stylesheet">
    <style>
        body{
            background-color:#fafafa;
        }
        .wrapper{
            background-color:#f8f8f8;
            padding:0;
        }
        .header{
            position: fixed;
            width: 100%;
            top: 0;
            background: #eee;
        }
        .main{
            padding:54px 0;
        }
        .media{
            background: #fff;
            border-bottom: 1px solid #eee;
            border-top: 1px solid #eee;
            color: #888;
            margin:5px auto;
        }
        .content{
            background: #fff;
            border-top: 1px solid #eee;
            border-bottom: 1px solid #eee;
            margin: 5px auto;
        }
        .block-title{
            color: #444;
            text-indent: 1rem;
            font-weight: normal;
        }
        .figure {
            display: inline-block;
            text-align: center;
            color: #fff;
            background:#eee;
        }
    </style>
</head>
<body class="modal-open">
    <div class="container-fluid wrapper">
        <div class="header">
            <div class="d-flex bd-heighlight">
                <div class="flex-fill bd-highlight p-2 text-left">
                    <button type="button" class="btn btn-default header-btn" data-toggle="modal" data-target="#win">
                        <i class="fa fa-user-circle"></i>
                    </button>
                </div>
                <div class="flex-fill bd-highlight p-2 text-center">
                    <span class="header-title">
                        选择房间
                    </span>
                </div>
                <div class="flex-fill bd-highlight p-2 text-right">
                    <button type="button" class="btn btn-default header-btn" id="setting">
                        <a href="/logout">
                            <i class="fa fa-door-closed"></i>
                        </a>
                    </button>
                </div>
            </div>
        </div>
        <div class="main">
            <div class="d-flex flex-row media">
                <div class="p-2">
                    <img src="<%=user.avatar%>" style="height:48px"/>
                </div>
                <div class="p-2 flex-fill">
                    <div class="title"><%=user.nickname%></div>
                    <div class="title-sub">ID:<strong><%=user.aid%></strong></div>
                </div>
                <div class="p-2">
                    <a href="/createroom" class="btn btn-primary">创建</a>
                    <a href="/joinroom" class="btn btn-primary">加入</a>
                </div>
            </div>
            <div class="block-title">所有/列表</div>
            <div class="d-flex content">
                <% items.forEach(function(item){ %>
                <div class="flex-fill bd-highlight p-2 room" rid="<%=item.room_id%>">
                    <figure class="figure">
                        <button class="btn btn-default btn-block"><%=item.room_id%></button>
                        <img src="/image/room.png" class="figure-img img-fluid rounded" />
                        <button class="btn btn-success btn-block"><%=item.room_name%></button>
                    </figure>
                </div>
                <%})%>
            </div>
        </div>
    </div>
    <input type="hidden" id="token" value="<%=token%>">
    <input type="hidden" id="aid" value="<%=user.aid%>">
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
    <script src="https://cdn.bootcss.com/popper.js/1.15.0/umd/popper.min.js"></script>
    <script src="https://cdn.bootcss.com/twitter-bootstrap/4.3.1/js/bootstrap.min.js"></script>
    <script src="https://cdn.bootcss.com/vue/2.6.10/vue.min.js"></script>
    <script src="/js/lib/build/build.js"></script>
    <script>
        $(function(){
            require('boot');
            //获取参数
            const aid = $("#aid").val();
            const token = $("#token").val();
            $(".room").on("click", function(){
                const rid = $(this).attr("rid");

                let data = {};
                data.aid = aid;
                data.rid = rid;
                data.token = token;

                const url = "/chat";
                $.ajax({
                    type:"post",
                    url:url,
                    data:data,
                    dataType:"json",
                    success:function(res){
                        console.log(res);
                        if(res.code!==200){
                            $.alert(res.message);
                        }else{
                            window.location.href = res.url;
                        }
                    },
                    error:function(err){
                        console.error(err);
                    }
                });
            });
        });
    </script>
</body>
</html>

聊天界面

$ vim web-server/view/index/chat.html
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title><%=title%></title>
    <link href="https://cdn.bootcss.com/font-awesome/5.11.2/css/all.min.css" rel="stylesheet">
    <link href="https://cdn.bootcss.com/twitter-bootstrap/4.3.1/css/bootstrap.css" rel="stylesheet">
    <style>
        .wrapper{
            background-color:#f8f8f8;
            padding:0;
        }
        .header{
            position: fixed;
            width: 100%;
            top: 0;
            background: #eee;
        }
        .header-btn{

        }
        .header-title{

        }
        .main{
            background-color:#fff;
            padding:54px 10px;
        }
        .footer{
            position: fixed;
            width: 100%;
            bottom: 0;
            background: #eee;
        }
    </style>
</head>
<body class="modal-open">
    <div class="container-flaid wrapper">
        <div class="header">
            <div class="d-flex bd-heighlight">
                <div class="flex-fill bd-highlight p-2 text-left">
                    <button type="button" class="btn btn-default header-btn" data-toggle="modal" data-target="#win">
                        <i class="fa fa-angle-left"></i>
                    </button>
                </div>
                <div class="flex-fill bd-highlight p-2 text-center">
                    <span class="header-title">
                        俱乐部
                    </span>
                </div>
                <div class="flex-fill bd-highlight p-2 text-right">
                    <button type="button" class="btn btn-default header-btn" id="setting">
                        <a href="/member">
                        <i class="fa fa-users"></i>
                        </a>
                    </button>
                </div>
            </div>
        </div>
        <div class="userlist">
            <ul>
                <li>
                </li>
            </ul>
        </div>
        <div class="main">

        </div>
        <div class="footer">
            <div class="d-flex bd-highlight">
                <div class="p-2 bd-highlight">
                    <button class="btn btn-primary">
                        <i class="fa fa-volume-up"></i>
                    </button>
                </div>
                <div class="p-2 flex-grow-1 bd-highlight">
                    <input type="text" class="form-control" id="content">
                </div>
                <div class="p-2 bd-highlight">
                    <input type="hidden" name="userlist" id="userlist" />
                    <button class="btn btn-primary" id="send">发送</button>
                </div>
            </div>
        </div>
    </div>
    <div class="modal-backdrop fade show"></div>
    <div class="modal fade show" role="dialog" tabindex="-1" id="win" aria-labelledby="win" aria-hidden="true" style="display:block">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title" id="modal-title">欢迎进入</h5>
                </div>
                <div class="modal-body">
                    <div class="form-group">
                        <div class="input-group">
                            <div class="input-group-prepend">
                                <div class="input-group-text"><i class="fa fa-home"></i></div>
                            </div>
                            <input type="number" class="form-control" id="rid" name="rid" aria-describedby="rid" placeholder="房号" maxlength="20" value="<%=rid%>" />
                        </div>
                    </div>
                    <div class="form-group">
                        <div class="input-group">
                            <div class="input-group-prepend">
                                <div class="input-group-text"><i class="fa fa-user"></i></div>
                            </div>
                            <input type="number" class="form-control" id="aid" name="aid" aria-describedby="aid" placeholder="账号" maxlength="20" value="<%=aid%>"/>
                        </div>
                        <small id="help" class="form-text text-muted"></small>
                    </div>
                    <div class="alert alert-dark" role="alert" style="display: none;"></div>
                </div>
                <div class="modal-footer">
                    <a href="/room" class="btn btn-light">返回</a>
                    <input type="hidden" name="token" id="token" value="<%=token%>"/>
                    <button type="button" class="btn btn-primary" id="confirm">确定</button>
                </div>
            </div>
        </div>
    </div>
    <div aria-live="polite" aria-atomic="true" class="d-flex justify-content-center align-items-center">
        <div class="toast" role="alert" aria-live="assertive" aria-atomic="true" data-autohide="false">
            <div class="toast-header">
                <strong class="mr-auto">温馨提示</strong>
                <button class="ml-2 mb-1 close" type="button" data-dismiss="toast" aria-label="close">
                    <span aria-hidden="true">&times;</span>
                </button>
            </div>
            <div class="toast-body">
                提示信息
            </div>
        </div>
    </div>
    <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.slim.min.js"></script>
    <script src="https://cdn.bootcss.com/popper.js/1.15.0/umd/popper.min.js"></script>
    <script src="https://cdn.bootcss.com/twitter-bootstrap/4.3.1/js/bootstrap.min.js"></script>
    <script src="https://cdn.bootcss.com/vue/2.6.10/vue.min.js"></script>
    <script src="/js/lib/build/build.js"></script>
    <script>
        $(function(){
            require('boot');

            //toast
            $('.toast').toast({animation:true, autohide:true, delay:3000});
            //$('.toast').toast('show');

            //modal垂直居中
            const dialog = $(".modal-dialog");
            const top = ($(window).height() - dialog.height())/2;
            dialog.css({"margin-top":top+"px"});


            const aid = $.trim($("#aid").val());
            const rid = $.trim($("#rid").val());
            const userlist = $.trim($("#userlist").val());

            //进入确认
            $("#confirm").on("click", function(){
                if(!aid){
                    $("#aid").val("").focus();
                    $('.alert').html("请输入账号!").show();
                    return;
                }
                if(aid.length < 6 || aid.length > 20){
                    $("#aid").val("").focus();
                    $('.alert').html("账号长度输入有误!").show();
                    return;
                }
                if(!/^[0-9a-zA-Z]+$/.test(aid)){
                    $("#aid").val("").focus();
                    $('.alert').html("账号必须由数字或数字组合!").show();
                    return;
                }
                if(!rid){
                    $("#rid").val("").focus();
                    $('.alert').html("请输入房号!").show();
                    return;
                }
                if(rid.length < 6 || rid.length > 20){
                    $("#rid").val("").focus();
                    $('.alert').html("房号长度输入有误!").show();
                    return;
                }
                if(!/^[0-9a-zA-Z]+$/.test(rid)){
                    $("#rid").val("").focus();
                    $('.alert').html("房号必须由数字或数字组合!").show();
                    return;
                }
                const token = $("#token").val();

                getConnectorEntryFromGate({host:"127.0.0.1", port:3014, log:true}, {aid:aid, token:token}, function(message){
                    if(message.code === 500){
                        $('.alert').html(message.message).show();
                    }else if(message.code === 200){
                        addUserToRoom({host:message.host, port:message.port}, {aid:aid, rid:rid, token:token}, function(message){
                           if(message.code === 200){
                               $(".modal").hide();
                               $(".modal-backdrop").hide();
                               if(message.data){
                                   //$("#userlist").val(message.data[0]);
                               }
                           }
                        });
                    }
                });
            });

            //发送消息
            $("#send").on("click", function(){
               const content = $("#content").val();

               const route = "chat.chatHandler.send";
               let param = {};
               param.rid = rid;
               param.from = aid;
               param.content = content;
               param.target = $("#userlist").val();
               console.log("send", param);
               pomelo.request(route, param, function(data){
                    console.log(route, data);
               });
            });


            //客户端与服务器断开连接,原因直接断开。
            pomelo.on("disconnect", function(data){
                console.log("disconnect", data);
            });
            //客户端与服务器断开连接,原因心跳超时。
            pomelo.on("heartbeat timeout", function(data){
               console.log("heartbeat timeout", data);
            });
            //用户进入房间
            pomelo.on("onAdd", function(data){
                console.log("onAdd", data);
            });
            //用户离开房间
            pomelo.on("onKick", function(data){
                console.log("onKick", data);
            });
            pomelo.on("onChat", function(data){
                console.log("onChat", data);
            });
        });

        /**
         * 用户进入房间
         * */
        function addUserToRoom(config, param, callback){
            pomelo.init(config, function(socket){
                console.log("entry", socket);
                const route = "connector.entryHandler.enter";
                pomelo.request(route, param, function(data){
                   console.log(route, data);
                    //pomelo.disconnect();
                   callback(data);
                });
            });
        }
        /**
         * 连接gate服务器 获取connector入口
         * 客户端首先要给gate服务器查询一个connector服务器,gate给其回复一个connector的地址及端口号
         * */
        function getConnectorEntryFromGate(config, param, callback){
            pomelo.init(config, function(socket){
                console.log("queryEntry", socket);
                const route = "gate.gateHandler.queryEntry";
                pomelo.request(route, param, function(data){
                    console.log(route, data);
                    pomelo.disconnect();//主动关闭连接
                    if(!data){
                        data = {error:true, code:500, message:"error"};
                    }
                    callback(data);
                });
            });
        }
    </script>
</body>
</html>

浏览器访问:http://127.0.0.1:3001/chat

数值说明

  • 用户在登录界面输入的账户uid,也就是gateHandler网关处理器中的uid,即用户的唯一编号。
  • 用户在登录界面输入的房间编号rid,也就是connector连接处理器中entryHandler中接收的rid,根据rid可以获取到对应的频道channel进行消息推送,在这里会将rid的值保存到session会话中。
  • connector中entryHandler入口处理器中接收用户唯一编号uid和房间唯一编号rid后会合成新的房间用户唯一编号 uid = uid * rid,使用房间用户唯一编号uid与session会话绑定,在同一连接中一旦绑定,则uid在session中是只读的。在这一步中,根据用户的uid以及当前连接到的connector的serverId即sid,在根据channel.add(uid, sid)方法添加到channel中,因而推送消息时可以根据uid获取对应的sid,进而进行消息的推送。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

  • ORA-00001: 违反唯一约束条件 (.) 错误说明:当在唯一索引所对应的列上键入重复值时,会触发此异常。 O...
    我想起个好名字阅读 5,494评论 0 9
  • 背景在HTTP协议的定义中,采用了一种机制来记录客户端和服务器端交互的信息,这种机制被称为cookie,cooki...
    时芥蓝阅读 2,401评论 1 17
  • 网络 理论模型,分为七层物理层数据链路层传输层会话层表示层应用层 实际应用,分为四层链路层网络层传输层应用层 IP...
    FlyingLittlePG阅读 869评论 0 0
  • 快速开始 在安装Sanic之前,让我们一起来看看Python在支持异步的过程中,都经历了哪些比较重大的更新。 首先...
    hugoren阅读 19,796评论 0 23
  • 我先暂且停下了回忆,转身看了看又刚刚睡着的妻子。她还是那个样子,睡觉时总带着一副笑模样,看了让人舒服。我多...
    陶元凯阅读 134评论 0 0