Pomelo MMORPG

环境准备

$ mkdir mmorpg && cd rpg
$ pomelo ini
$ npm-install.bat

服务器类型

游戏采用分布式设计,服务端是由一个服务器集群所组成。

序号 服务器类型 名称 数量
1 网关服务器 gate 1
2 连接服务器 connector 1
3 聊天服务器 chat 1
4 认证服务器 auth 1
5 场景服务器 area n
6 寻路服务器 path 1
7 管理服务器 manager 1
8 登录服务器 login 1
9 日志服务器 log 1

网关服务器gate

  • 网关服务器为用户提供统一的websocket入口
  • 网关服务器会向所有客户端暴露一个固定的websocket接口
  • 网关服务器负责用户验证和连接服务器的分配
  • 网关服务器一般只有一台

当用户登录时会首先连接网关服务器完成验证并获得由网关服务器分配的对应连接服务器的地址,之后客户端会断开与网关服务器的连接,通过获取的地址连接对应的连接服务器以获取对应的服务。

网关服务器用于维持与客户端之间的socket连接,可处理广播、断线重连等逻辑。

连接服务器 connector

与web短连接模式不同的是,网络游戏中客户端和服务器建立的都是长连接,长连接本身需要一定的资源来维持。

本游戏中使用websocket协议在客户端和服务器之间建立连接,连接服务器是用来维护这些连接,并中转客户端和服务器之间的消息。

本游戏中客户端和服务器的连接是通过一个抽象的会话来维护的,会话是一个客户端在服务端的标识,用来维护用户的登录状态、用户的基本信息、用户的websocket连接信息等。

认证服务器auth

  • 认证服务器作为用户验证的统一入口。
  • 认证服务器提供远程调用接口供其它服务器调用来进行用户身份验证
  • 认证服务器的主要作用是屏蔽认证验证的细节为其它服务器提供统一的验证接口

认证服务器用于处理登录、排队等逻辑。

登录服务器login

  • 负责用户注册和验证

聊天服务器 chat

  • 聊天服务器是网游的基本服务之一,通过独立的服务器实现。
  • 聊天服务器维护一份所有在线用户的数据,通过这些数据与连接服务器通讯,实现用户之间的即时通讯。

场景服务器 area

  • 网游中处于性能和负载考量,大的游戏世界会被划分为多个区域即场景。
  • 本游戏中一张地图即一个游戏场景与一台独立的场景服务器对应
  • 场景是构成游戏世界的基本单位,不能进行分隔和合并扩展。
  • 场景服务器负责维护场景中所有实体并驱动实体AI运行游戏逻辑
  • 场景服务器负责处理游戏中几乎所有的逻辑同时为其它服务器提供操纵场景数据的接口
  • 虽然场景本身不可分隔但可通过加入新的场景的方式来分散用户从而提高游戏服务器总体负载
  • 一些与场景相关的服务通过独立运行的方式进行水平扩展

寻路服务器path

  • 寻路服务器是游戏服务器基本服务之一
  • 玩家跑动、怪物移动都需要寻路服务器提供支持
  • 寻路服务器是根据地图的起点和重点得到两点之间的最优路径
  • 由于寻路是典型的无状态、计算密集型服务,因此将寻路与场景逻辑分离,放在单独的服务器中。从而减轻场景服务器的压力。
  • 寻路服务器可根据简单并行进行扩展
  • 寻路算法使用AI实现并提供通用的计算接口,并封装为一个模块。

管理服务器manager

管理服务器是后端服务器集群中负责全局管理副本全生命周期和组队相关操作的功能服务器。

组队功能模块team

组队功能是玩家之间互动的一种方式,玩家可创建队伍并邀请其它玩家加入,其它玩家也可以主动向队长踢出申请加入队伍,队伍的人数上限为3。

teamHandler.js为协议入口模块,负责负责队伍相关操作的前期判断和后期通知。teamHandler完成前期判断后通过一个rpc将操作所需的参数传递给manager服务器。队伍对象的管理工作是由manager服务器所持有一个全局的teamManager.js模块负责的,teamManager中管理所有的team对象的创建、更改、销毁等操作。

队伍id从1开始递增,所有场景中的队伍使用统一的id序列,当manager服务器重启时,队伍id也重新初始化为1.

team.js模块中维护一个队伍对象中的所有成员与成员身份,维护一个队伍频道来通知各个成员队伍相关的消息及进行队伍内的聊天。

同一个队伍中的玩家可以进入同一个组队副本。

副本功能模块instance

副本instance.js本质上是一个临时的场景,并对进入该临时场景的玩家进行限制。instance由模块instancePool.js来统一管理,这与队伍模块的结构是相同的。

目前有两种副本可供玩家进入分别是单人副本、组队副本。当队伍中有两个以上玩家时,队长点击进入组队副本时,队伍中的队员会同时拉入副本中,队伍中的队员也可以单独进入组队副本,但不会触发将队伍中的其它成员拉入副本的操作,其它成员可以分别进入该副本。

玩家进入副本也被视为场景切换,在areaService.js模块中changeArea函数中进行目标场景类型判断,如果是普通场景则正常切换。如果是副本场景则通过一个rpc来创建副本。组队副本创建时的id与队伍id相关。单人副本创建时的id与玩家id相关。组队副本创建成功后,如果是队长触发的操作则向其它队员客户都拿发送进入副本的命令,如果是队员则直接进入副本中。

服务器配置

配置游戏服务器类型

$ vim game-server/config/adminServer.json
[
    {
        "type": "gate",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    },
    {
        "type": "connector",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    },
    {
        "type": "chat",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    },
    {
        "type": "auth",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    },
    {
        "type": "area",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    },
    {
        "type": "path",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    },
    {
        "type": "manager",
        "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
    }
]

配置不同类型服务器的参数

$ vim game-server/config/servers.json
{
  "development":{
    "gate": [
      {
        "id": "gate-server-1",
        "host": "127.0.0.1",
        "clientPort": 3014,
        "frontend": true
      }
    ],
    "connector": [
      {
        "id": "connector-server-1",
        "host": "127.0.0.1",
        "port": 3150,
        "clientPort": 3010,
        "frontend": true
      },
      {
        "id": "connector-server-2",
        "host": "127.0.0.1",
        "port": 3151,
        "clientPort": 3011,
        "frontend": true
      }
    ],
    "area": [
      {
        "id": "area-server-1",
        "host": "127.0.0.1",
        "port": 3250,
        "area": 1
      },
      {
        "id": "area-server-2",
        "host": "127.0.0.1",
        "port": 3251,
        "area": 2
      },
      {
        "id": "area-server-3",
        "host": "127.0.0.1",
        "port": 3252,
        "area": 3
      },
      {
        "id": "instance-server-1",
        "host": "127.0.0.1",
        "port": 3260,
        "instance": true
      },
      {
        "id": "instance-server-2",
        "host": "127.0.0.1",
        "port": 3261,
        "instance": true
      },
      {
        "id": "instance-server-3",
        "host": "127.0.0.1",
        "port": 3262,
        "instance": true
      }
    ],
    "chat": [
      {
        "id": "chat-server-1",
        "host": "127.0.0.1",
        "port": 3450
      }
    ],
    "path": [
      {
        "id": "path-server-1",
        "host": "127.0.0.1",
        "port": 3550
      }
    ],
    "auth": [
      {
        "id": "auth-server-1",
        "host": "127.0.0.1",
        "port": 3650
      }
    ],
    "manager": [
      {
        "id": "manager-server-1",
        "host": "127.0.0.1",
        "port": 3750
      }
    ]
  },
  "production":{}
}
序号 服务器类型 名称 数量 主机地址 rpc port client port
1 网关服务器 gate 1 127.0.0.1 - 3014
2 连接服务器 connector 2 127.0.0.1 315x 301x
3 场景服务器 area 6 127.0.0.1 325x -
4 聊天服务器 chat 1 127.0.0.1 3450 -
5 寻路服务器 path 1 127.0.0.1 3550 -
6 认证服务器 auth 1 127.0.0.1 3650 -
7 管理服务器 manager 1 127.0.0.1 3750 -

其中场景服务器又分为两种类型分别是普通的场景服务器和副本服务器

序号 服务器类型 服务器名称 是否副本
1 场景服务器 area-server-1
2 场景服务器 area-server-2
3 场景服务器 area-server-3
4 副本服务器 instance-server-1
5 副本服务器 instance-server-2
6 副本服务器 instance-server-3

启动流程

app.js是游戏服务器的入口,主要负责所有服务器的配置,以及组件的加载和启动。本项目的启动主要分为两步:先启动pomelo的master服务器,再由pomelo的master服务器分别启动其它服务器。

项目启动采用pomelo的启动方式,即将master作为默认组件,在app.js中调用app.start()方法后加载并启动master服务。master组件会负责启动其它服务,启动过程分为两个阶段:

  • 第一阶段
    master服务启动其它所有服务,在服务器启动完毕后,其中monitor组件会连接到master对应的监听端口上,表明该服务器启动完毕。
  • 第二阶段
    当所有服务器启动完毕后,master会调用所有服务器上的afterStart接口来执行后续处理流程。

组件的加载和配置

本项目使用多个外部组件,这些组件在服务器启动时加载以提供各种服务,诸如数据统计、路由替换、游戏场景初始化等。

应用入口

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

//创建应用 为客户端初始化应用
const app = pomelo.createApp();
app.set('name', 'mmorpg');

//设置路径
app.set("configPath", path.join(app.getBase(), "config"));
app.set("utilsPath", path.join(app.getBase(), "utils"));
app.set("modelPath", path.join(app.getBase(), "model"));

//加载配置
//应用实例加载错误码配置文件
app.loadConfig("codeConfig",  path.join(app.get("configPath"), "code"));
//获取数据库配置
app.loadConfig("mysqlConfig",path.join(app.get("configPath"), "mysql"));
//获取令牌加密解密
app.loadConfig("tokenConfig", path.join(app.get("configPath"), "token"));
//获取Redis配置
app.loadConfig("redisConfig", path.join(app.get("configPath"), "redis"));

//设置数据库客户端对象
app.set("mysql", require(path.join(app.get("utilsPath"), "mysql"))(app.get("mysqlConfig")));
//设置Redis缓存对象
app.set("redis", require(path.join(app.get("utilsPath"), "redis"))(app.get("redisConfig")));

//应用全局配置 针对所有服务器
app.configure("production|development", function(){
    //开启系统监控 Linux环境有效
    //app.enable("systemMonitor");
    //配置自定义监控:运行自定义统计脚本收集服务器运行数据,为服务器注册自定义监控模块。
    if(typeof app.registerAdmin === "function"){
        //将监控在线用户模块注册给所有服务器
        //app.registerAdmin("online", require(app.get("appModulePath"), "online"), {app:app});
    }
});

//应用配置网关服务器
app.configure('production|development', 'gate', function(){
    app.set('connectorConfig',
        {
            connector : pomelo.connectors.hybridconnector,
            heartbeat : 10,
            useProtobuf : false,
            useDict : false
        });
});
//应用配置连接服务器
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
        });
});

//设置路由
app.route("chat", function(session, msg, app, cb){
    const servers = app.getServersByType("chat");
    const len = servers.length;
    const id = session.get("rid");
    if(!servers || len===0){
        cb(new Error("chat server can not find"));
        return;
    }
    let index = 0;
    if(len > 1){
        if(id){
            index = Math.abs(crc.crc32(id.toString())) % len;
        }
    }
    const server = servers[index];
    //console.log(server);
    cb(null, server.id);
});

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

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

自定义在线统计模块

项目中使用了基于脚本的统计,组件通过运行自定义的脚本,收集服务器运行数据并生成报告。

$ vim game-server/modules/online.js
//自定义监控模块
let Module = function(opts){
    console.log("online modules constructor");
    opts = opts||{};
    //当前监控模块所监测的服务器实例
    this.app = opts.app;
    //获取数据的方式
    // pomelo-admin提供两种方式一种是pull拉取,一种是push推送
    // pull拉取表示master服务器会主动从各个游戏服务器上拉取所需的监控数据
    // push推送表示游戏服务器向master服务器推送监控数据
    this.type = opts.type || "pull";
    //每次push或pull的时间间隔
    this.interval = opts.interval || 5;
};

// 监控模块标识
// 用于唯一标识监控模块,非常重要。
// 如果要向该模块获取和发送数据都需要依靠这个ID作为参数
// 如果需要让外部程序获取该模块的监控数据也必须依靠此参数
Module.moduleId = "online";

// 采用pull拉取 当接收到master主服务器拉通知时回调
// 采用push推送 每次到达interval间隔时间时回调
Module.prototype.monitorHandler = function(agent, msg){
    console.log("online modules handler");
    //获取连接组件服务
    const connectionService = this.app.components.__connection__;
    if(!connectionService){
        console.log("not support connection: %j", agent.id);
        return;
    }
    //代理通知连接统计信息
    const info = connectionService.getStatisticsInfo();
    //通知admin的消息给master服务器
    agent.notify(Module.moduleId, info);
};

// 采用pull拉取 每次到达pull拉取时间间隔时会被调用
// 采用push推送 当接收到游戏服务器push推送数据回调
Module.prototype.masterHandler = function(agent, msg){
    console.log("online master handler", msg, Module.moduleId);
    //若无消息则通知所有的monitor监视器去获取数据
    if(!msg){
        //通知指定类型的监听器获取获取数据
        const type = "connector";
        const list = agent.typeMap[type];
        if(!list || list.length===0){
            agent.notifyByType(type, Module.moduleId);
        }else{
            //通知所有监视器去获取数据
            agent.notifyAll(Module.moduleId);
        }
        return;
    }
    //从monitor监视器中收集数据
    let data = agent.get(Module.moduleId);
    if(!data){
        data = {};
        agent.set(Module.moduleId, data);
    }
    data[msg.serverId] = msg;
};

//当第三方程序调用时 获取监控数据接口时回调
Module.prototype.clientHandler = function(agent, msg, cb){
    console.log("online client handler");
  if(!!cb && typeof cb==="function"){
      const param = agent.get(Module.moduleId) || {};
      //处理客户端请求直接返回数据并缓存到master服务器
      cb(null, param);
  }
};

//导出模块
module.exports.moduleId = Module.moduleId;
module.exports = function(opts){
    return new Module(opts);
};

启动脚本中为所有服务器添加在线统计,也可以单独为connector连接服务器添加。

$ game-server/app.js
//应用全局配置 针对所有服务器
app.configure("production|development", function(){
    //开启系统监控 Linux环境有效
    app.enable("systemMonitor");
    //配置自定义监控:运行自定义统计脚本收集服务器运行数据,为服务器注册自定义监控模块。
    if(typeof app.registerAdmin === "function"){
        //将监控在线用户模块注册给所有服务器
        app.registerAdmin("online", require("./app/modules/online"), {app:app});
    }
});

MySQL数据库配置

创建MySQL连接配置

$ vim game-server/config/mysql.json
{
  "development": {
    "debug": true,
    "host": "127.0.0.1",
    "port": 3306,
    "user": "root",
    "password": "root",
    "database": "pomelo",
    "charset": "UTF8_GENERAL_CI"
  },
  "production": {
    "debug": false,
    "host": "127.0.0.1",
    "port": 3306,
    "user": "root",
    "password": "root",
    "database": "pomelo",
    "charset": "UTF8_GENERAL_CI"
  }
}

应用启动脚本加载MySQL配置

$ npm i --save path;

安装MySQL客户端

$ cd game-server
$ npm i --save mysql

封装数据库连接

$ vim game-server/utils/mysql.js
let Module = function(config){
    const mysql = require("mysql");
    this.pool = mysql.createPool(config);
};

Module.prototype.query = function(sql, values){
  return new Promise((resolve, reject)=>{
    this.pool.getConnection((error, connection)=>{
        if(error){
            reject(error);
        }else{
            if(values){
                connection.query(sql, values, (err, res)=>{
                    if(err){
                        reject(err);
                    }else{
                        resolve(JSON.parse(JSON.stringify(res)));
                    }
                });
            }else{
                connection.query(sql, (err, res)=>{
                    if(err){
                        reject(err);
                    }else{
                        resolve(JSON.parse(JSON.stringify(res)));
                    }
                });
            }
            connection.release();
        }
    });
  });
};

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

配置数据库模块

$ vim game-server/app.js
const path = require("path");
app.set("utilsPath", path.join(app.getBase(), "utils"));
//应用全局配置 针对所有服务器
app.configure("production|development", function(){
    //获取数据库配置
    app.loadConfig("mysqlConfig",path.join(app.get("configPath"), "mysql"));
    app.set("mysql", require(path.join(app.get("utilsPath"), "mysql"))(app.get("mysqlConfig")));
});

自定义错误码

创建错误码配置文件

$ vim game-server/config/code.json
{
  "OK": 200,
  "FAIL": 500,
  "PARAMETER_ERROR": 1001,
  "NO_SERVER_AVAILABEL": 1002,
  "LOAD_BALANCE_ERROR": 1003,
  "DUPLICATE_CONNECT":1004,
  "TOKEN_ILLEGAL": 4001,
  "TOKEN_EXPIRE": 4002
}

安装路径组件

$ npm i --save path

应用实例加载错误码配置文件

$ vim game-server/app.js
const path = require("path");
//设置路径
app.set("configPath", path.join(app.getBase(), "config"));
//应用全局配置 针对所有服务器
app.configure("production|development", function(){
    //应用实例加载错误码配置文件
    app.loadConfig("codeConfig",  path.join(app.get("configPath"), "code"));
});

网关服务器

客户端传入用于唯一编号aid,网关服务器接收后经过哈希计算负载均衡,分配某台连接服务器,网关服务器返回连接服务器的对外地址信息。

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

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

/**
 * 网关处理器
 * 功能
 * 1. 接收client查询connector的请求
 * 2. 返回给client一个可用连接的connector的ip和port
 */
let Module = function(app){
    this.app = app;
};

/**
 * 网关服务器查询入口
 * 根据负载均衡算法分配connector服务器
 * 返回connector服务器的地址和对外端口
 */
Module.prototype.queryEntry = function(msg, session, next){
    const app = this.app;
    const codeConfig = app.get("codeConfig");
    let code = codeConfig.OK;
    let message = "success";
    //获取客户端传递过来的用户ID
    let uid = msg.aid;
    if(!uid){
        code = codeConfig.PARAMETER_ERROR;
        message = "parameter error";
        next(new Error(message), {code, message});return;
    }
    //获取所有的connector服务器
    let servers = app.getServersByType("connector");
    if(!servers || servers.length===0){
        code = codeConfig.NO_SERVER_AVAILABEL;
        message = "no server availabel";
        next(new Error(message), {code, message});return;
    }
    //根据用户ID参与计算获得目标connector服务器
    let index = Math.abs(crc.crc32(uid.toString())) % servers.length;//负载均衡匹配算法
    let server = servers[index];
    if(!server){
        code = codeConfig.LOAD_BALANCE_ERROR;
        message = "load balance error";
        next(new Error(message), {code, message});return;
    }
    //返回目标connector服务器的主机地址和对外端口
    const data = {host:server.host, port:server.clientPort};
    next(null, {code, message, data});
};

客户端测试代码

<script src="/js/lib/build/build.js"></script>
<script>
    require('boot');
    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, data:res.data, message:res.message});
                    }else{
                        reject({error:true, message:res.message});
                    }
                   //pomelo.disconnect();
               });
           });
        });
    }

    async function main(){
        let result;
        result = await pomelo_init_request("127.0.0.1", 3014, "gate.gateHandler.queryEntry", {aid:1000});
    }
    main();
</script>

连接服务器

加入聊天频道与踢出

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

let Module = function(app){
    this.app = app;
};
/**
 * 进入游戏
 * */
Module.prototype.join = function(msg, session ,next){
    const app = this.app;
    const sessionService = app.get("sessionService");
    const serverId = app.get("serverId");
    const codeConfig = app.get("codeConfig");
    let code = codeConfig.OK;
    let message = "success";
    //获取参数
    const aid = msg.aid;
    const rid = msg.rid;
    if(!aid || !rid){
        code = codeConfig.PARAMETER_ERROR;
        message = "parameter error";
        next(new Error(message), {code, message});return;
    }

    //生成房间用户唯一编号
    const uid = [rid, aid].join("*");
    //console.log(uid);
    //判断用户前端会话是否存在
    let result = sessionService.getByUid(uid);
    //若用户前端会话存在则说明是重复连接
    if(!!result){
        code = codeConfig.DUPLICATE_CONNECT;
        message = "duplicate connect";
        next(new Error(message), {code, message});return;
    }

    //首次连接用户绑定前端会话
    session.bind(uid);
    //设置房间编号并推送到全局会话
    session.set("rid", rid);
    session.push("rid", function(error){
        if(error){
            console.error("push rid to session failed: %j", error);
        }
    });

    //前端会话监听用户连接断开事件
    session.on("closed", onSessionClosed.bind(null, app));

    //向后端chat服务器发送添加用户进入房间的rpc请求
    app.rpc.chat.chatRemote.join(session, uid, serverId, rid, true, function(data){
        next(null, {code, message, data});
    });
};

const onSessionClosed = function(app, session){
    if(!session || !session.uid){
        return;
    }
    //向后端chat服务器发送用户踢下线的rpc请求
    const uid = session.uid;
    const serverId = app.get("serverId");
    const rid = session.get("rid");
    app.rpc.chat.chatRemote.kick(session, uid, serverId, rid, function(uid){
        console.log(uid);
    });
};

聊天服务器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;
};
/**
 * 玩家加入聊天室
 * 每个频道对应一间房
 * 若用户没有加入频道则先加入
 * 加入成功后向频道中所有客户端推送消息
 * 返回频道中所有玩家
 * */
Module.prototype.join = function(uid, serverId, channelName, channelCreate, callback){
    console.log("chat.chatRemote.join", uid, serverId, channelName, channelCreate);
    const app = this.app;
    const channelService = app.get("channelService");
    //获取频道
    const channel = channelService.getChannel(channelName, channelCreate);
    //判断用户是否在频道中
    const user = channel.getMember(uid);
    console.log(user);
    if(!user){
        channel.add(uid, serverId);
    }
    //向频道中用户推送消息,推送消息到客户端
    channel.pushMessage("onJoin", uid);
    //返回频道中的所有用户
    callback(channel.getMembers());
};
/**
 * 从频道中踢出用户 todo
 * */
Module.prototype.kick = function(uid, serverId, channelName, callback){
    console.log("chat.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(uid);
};

客户端测试

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", 3014, "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/app/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){
    console.log(msg);
    const app = this.app;
    const channelService = app.get("channelService");
    const codeConfig = app.get("codeConfig");

    let code = codeConfig.OK;
    let message = "success";

    const uid = session.uid;
    const rid = session.get("rid");
    const channel = channelService.getChannel(rid, false);

    const target = msg.target;

    let param = {};
    param.from = uid;
    param.content = "hello";

    const method = "onChat";
    if(msg.target === "*"){
        channel.pushMessage(method, param);
    }else{
        const member = channel.getMember(target);
        const serverId = member.sid;
        channelService.pushMessageByUids(method, param, [{uid:target, sid:serverId}]);
    }

    next(null, {code, message});
};

客户端测试

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", 3014, "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();

认证服务器

安装并封装加密解密方法

$ npm i -S crypto
$ vim game-server/utils/cypher.js
//检查项目中是否包含crypto模块
let crypto;
try{
    crypto = require("crypto");
}catch(e){
    console.error("crypto support is disabled");
}
/**
 * 哈希加密
 * @param value mixed 需要加密的数据,默认为UTF-8的字符串或Buffer
 * @param type string 哈希类型可以为 md5/sha1/sha256/sha512
 * @return string 十六进制哈希值
 * */
exports.hash = (value, type="md5")=>{
    const hash = crypto.createHash(type);
    //可多次调用update(),update()方法默认字符串编码格式为UTF-8也可以传入Buffer
    hash.update(value);
    return hash.digest("hex");
};
/**
 * 随机数增强哈希加密
 * 利用MD5或SHA1等哈希算法进行加密,不同之处在于需传入密钥。
 * 只要密钥变化,同样输入的数据会得到不同的签名,可认为hmac是使用随机数增强的哈希算法。
 * @param value mixed 需要加密的数据,默认为UTF-8的字符串或Buffer
 * @param secret string 密钥
 * @param type string 哈希类型 md5/sha1/sha256/sha512
 * @return string 十六进制哈希值
 * */
exports.hmac = (value, secret, type="sha1")=>{
    const hmac = crypto.createHmac(type, secret);
    hmac.update(value);
    return hmac.digest("hex");
};
/**
 * AES对称加密
 * AES是常用的对称加密算法,加密解析都使用同一个密钥。
 * @param value 待加密数据
 * @param secret string 密钥
 * @param type string 对称加密算法类型,支持aes192/aes-128-ebc/aes-256-cbc等
 * */
exports.aesEncrypt = (value, secret, type="aes192")=>{
    const cipher = crypto.createCipher(type, secret);
    let crypted = cipher.update(value, "utf8", "hex");
    crypted += cipher.final("hex");
    return crypted;
};
/**
 * AES对称解密
 * */
exports.aesDecrypt = (crypted, secret)=>{
    const decipher = crypto.createDecipher("aes192", secret);
    let decrypted = decipher.update(crypted, "hex", "utf8");
    decrypted += decipher.final("utf8");
    return decrypted;
};

创建令牌配置文件

$ vim game-server/config/token.json
{
  "development": {
    "secret": "token_secret",
    "expire": 3600000
  },
  "production": {
    "secret": "token_secret",
    "expire": 3600000
  }
}

令牌配置文件中的secret会分发给客户端和服务器,用于加密增强使用。expire为过期时间,令牌加密时会传入timestamp当前时间戳,当令牌到达客户端或服务器时会验证令牌是否过期。

创建定义加密解密规则用于生成和解析令牌

$ vim game-server/utils/token.js
const cypher = require("./cypher");

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

let Module = function(config){
    this.secret = config.secret;
    this.expire = config.expire;
};
/**
 * 创建令牌
 * @param val
 * @param secret string 密钥
 * @return string
 * */
Module.prototype.create = function(val){
    const timestamp = new Date().getTime();
    if(Array.isArray(val)){
        val.push(timestamp);
    }else{
        val = [val, timestamp];
    }
    return cypher.aesDecrypt(val.join("*"), this.secret);
};
/**
 * 解析令牌
 * @param token string 令牌字符串
 * @param secret string 密钥
 * @return string
 * */
Module.prototype.parse = function(token){
    const result = cypher.aesDecrypt(token, this.secret);
    const arr = result.split("*");
    if(arr.length <=0 ){
        return null;
    }
    return arr;
};
/**
 * 是否过期
 * @param timestamp int 时间戳
 * @param expire int 过期时间
 * @return bool true已过期 false未过期
 * */
Module.prototype.isExpire = function(timestamp){
  //console.log(Date.now(), timestamp, this.expire, Date.now() - parseInt(timestamp), );
  return Date.now() - parseInt(timestamp) > parseInt(this.expire);
};

令牌加密解密规则说明:令牌使用id * timestamp格式,再配合secret进行AES192进行对称加密和解密。客户端和服务端,同时拿到secret。加密中的timestamp用于验证令牌是否过期。

备注:加密解析为服务端和客户端通用,可放置到项目下的shared文件夹下。

添加认证服务器配置信息

$ vim game-server/config/adminServer.json
{
    "type": "auth",
    "token": "agarxhqb98rpajloaxn34ga8xrunpagkjwlaw3ruxnpaagl29w4rxn"
}
$ vim game-server/config/servers.json
"auth": [
  {
    "id": "auth-server-1",
    "host": "127.0.0.1",
    "port": 3350
  },
  {
    "id": "auth-server-2",
    "host": "127.0.0.1",
    "port": 3351
  },
  {
    "id": "auth-server-3",
    "host": "127.0.0.1",
    "port": 3352
  }
]  

应用上下文设置令牌对象

$ vim game-server/app.js
//获取令牌加密解密
app.loadConfig("tokenConfig", path.join(app.get("configPath"), "token"));
//设置令牌对象
app.set("token", require(path.join(app.get("utilsPath"), "token"))(app.get("tokenConfig")));

应用上下文配置认证服务器并设置令牌路由

$ vim game-server/app.js
//应用配置认证服务器
app.configure('production|development', 'auth', function(){
    app.set('connectorConfig',
        {
            connector : pomelo.connectors.hybridconnector,
            heartbeat : 10,
            useDict : false
        });
});
//路由负载均衡获取目标服务器ID
function loadBalance(app, session, serverType, idName){
    const servers = app.getServersByType(serverType);
    const len = servers.length;
    const id = session.get(idName);
    if(!servers || len===0){
        return false;
    }
    let index = 0;
    if(len > 1){
        if(id){
            index = Math.abs(crc.crc32(id.toString())) % len;
        }
    }
    const server = servers[index];
    return server.id;
}
app.route("auth", function(session, msg, app, cb){
    const serverId = loadBalance(app, session, "auth", "uid");
    if(!serverId){
        cb(new Error("server not exists"));
        return;
    }
    cb(null, serverId);
});

错误代码配置文件添加选项

$ vim game-server/config/code.json
{
  "OK": 200,
  "FAIL": 500,
  "PARAMETER_ERROR": 1001,
  "NO_SERVER_AVAILABLE": 1002,
  "LOAD_BALANCE_ERROR": 1003,
  "DUPLICATE_CONNECT":1004,
  "USER_NOT_EXIST": 1005,
  "DUPLICATE_LOGIN": 1006,

  "TOKEN_ILLEGAL": 2001,
  "TOKEN_EXPIRED": 2002,
  "TOKEN_INVALID": 2003,
  "TOKEN_NOT_EXIST": 2004,
  "TOKEN_PARSE_ERROR": 2005,

  "SESSION_NOT_EXIST": 4005,

  "CHANNEL_DESTROYED": 4006,
  "CHANNEL_DUPLICATE": 4007,
  "CHANNEL_NOT_EXIST": 4008
}

连接服务器添加认证处理程序

$ vim game-server/app/servers/connector/handler/authHandler.js
module.exports = function(app){
    return new Module(app);
};
let Module = function(app){
    this.app = app;
};
Module.prototype.auth = function(msg, session, next){
    const app = this.app;
    const codeConfig = app.get("codeConfig");
    let code = codeConfig.OK;
    let message = "success";
    const tokenService = app.get("token");
    const sessionService = app.get("sessionService");
    //获取参数
    const token = msg.token;
    //console.log(token);
    if(!token){
        code = codeConfig.TOKEN_NOT_EXIST;
        message = "token not exist";
        next(new Error(message), {code, message});return;
    }
    //令牌解析判断
    let result = tokenService.parse(token);
    //console.log(result);
    if(!result || result.length!==2){
        code = codeConfig.TOKEN_PARSE_ERROR;
        message = "token parse error";
        next(new Error(message), {code, message});return;
    }
    //令牌过期判断
    let [id, timestamp] = result;
    //console.log(id, timestamp);
    if(tokenService.isExpire(timestamp)){
        code = codeConfig.TOKEN_EXPIRED;
        message = "token expired";
        next(new Error(message), {code, message});return;
    }
    //设置全局唯一用户编号
    const uid = id;
    //判断用户连接会话是否已存在 重复登录判断
    if(!!sessionService.getByUid(uid)){
        code = codeConfig.DUPLICATE_LOGIN;
        message = "duplicate login";
        next(new Error(message), {code, message});return;
    }
    //会话设置
    session.uid = uid;
    //向认证服务器发送认证
    app.rpc.auth.authRemote.auth(session, uid, function(error, result){
        next(error, result);
    });
};

认证服务器添加RPC处理程序

$ vim game-server/app/servers/auth/remote/authRemote.js
const path = require("path");
module.exports = function(app){
    return new Module(app);
};
let Module = function(app){
    this.app = app;
    this.userModel = require(path.join(app.get("modelPath"), "user"))(app);
};
/**
 * 身份认证
 * */
Module.prototype.auth = async function(id, next){
    const app = this.app;
    const userModel = this.userModel;
    const codeConfig  = app.get("codeConfig");
    let code = codeConfig.OK;
    let message = "success";

    let result = await userModel.isExistUser(id);
    if(!result){
        message = "user not exist";
        code = codeConfig.USER_NOT_EXIST;
        next(new Error(message), {code, message});
        return;
    }

    next(null, {code, message});
};

添加用户模型方法

用户模型中使用isExistUser用于判断客户端传递过来的令牌经过解密后先到Redis中查询是否存在,若不存在则向MySQL数据库中查询是否存在字段对应的记录。

$ vim game-server/app/model/user.js
module.exports = function(app){
    return new Module(app);
};

let Module = function(app){
  this.app = app;
  this.mysql = app.get("mysql");
  this.redis = app.get("redis");
};
/**
 * 判断用户是否存在
 * */
Module.prototype.isExistUser = async function(id){
    const redis = this.redis;
    const mysql = this.mysql;
    //判断缓存中是否存在指定的键
    let key = `user_hash:${id}`;
    let result = await redis.exists(key);
    if(!result){
        //判断数据库中是否存在用户信息
        let sql = "SELECT COUNT(1) AS count FROM game_user WHERE 1=1 AND aid = ?";
        let count = await mysql.count(sql, [id]);
        if(count === 0){
            return false;
        }
    }
    return true;
};

添加Redis工具类库对应方法

$ vim game-server/app/utils/redis.js
module.exports = function(config){
    return new Module(config);
};
let Module = function(config){
    const redis = require("redis");
    //创建Redis客户端
    const redisClient = redis.createClient(config);
    redisClient.on("connect", function(){
       //console.log("redis connect");
    });
    redisClient.on("error", function(error){
       //console.error("redis error: %j", error);
    });
    redisClient.on("monitor", function(time, args){
       //console.log("redis monitor: %j %j", time, args);
    });
    this.redisClient = redisClient;
};
/**
 * 判断指定的键是否存在
 * */
Module.prototype.exists = function(key){
    const redisClient = this.redisClient;
    return new Promise((resolve, reject)=>{
       redisClient.exists(key, (error, result)=>{
         console.log("redis", error, result);
         if(error){
             reject(result===0);
         }else{
             resolve(result===1);
         }
       });
    });
};
/**
 * 判断哈希表中指定字段是否存在
 * */
Module.prototype.hexists = function(key, field){
    const redisClient = this.redisClient;
    return new Promise((resolve, reject)=>{
        redisClient.hexists(key, field, (error, result)=>{
           console.log("redis", error, result);
           if(error){
               reject(result===0);
           }else{
               resolve(result===1);
           }
        });
    });
};

MySQL工具类库中添加对应方法

$ vim game-server/app/utils/mysql.js
let Module = function(config){
    const mysql = require("mysql");
    this.pool = mysql.createPool(config);
};

Module.prototype.query = function(sql, values){
  return new Promise((resolve, reject)=>{
    this.pool.getConnection((error, connection)=>{
        if(error){
            reject(error);
        }else{
            if(values){
                connection.query(sql, values, (err, res)=>{
                    console.log("mysql", err, res);
                    if(err){
                        reject(err);
                    }else{
                        resolve(JSON.parse(JSON.stringify(res)));
                    }
                });
            }else{
                connection.query(sql, (err, res)=>{
                    if(err){
                        reject(err);
                    }else{
                        resolve(JSON.parse(JSON.stringify(res)));
                    }
                });
            }
            connection.release();
        }
    });
  });
};

Module.prototype.count = function(sql, values){
  return new Promise((resolve, reject)=>{
    this.pool.getConnection((error, connection)=>{
        if(error){
            reject(error);
        }else{
            if(values){
                connection.query(sql, values, (err, res)=>{
                    console.log("mysql", err, res, JSON.parse(JSON.stringify(res)));
                    if(err){
                        reject(err);
                    }else{
                        resolve(res[0].count);
                    }
                });
            }else{
                connection.query(sql, (err, res)=>{
                    if(err){
                        reject(err);
                    }else{
                        resolve(res[0].count);
                    }
                });
            }
            connection.release();
        }
    });
  });
};

module.exports = function(config){
    return new Module(config);
};
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 210,978评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,954评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,623评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,324评论 1 282
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,390评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,741评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,892评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,655评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,104评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,569评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,254评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,834评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,725评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,950评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,260评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,446评论 2 348

推荐阅读更多精彩内容