关于看门狗的两种模型以及带来的思考

由于最近在计划工作的变动,想要好好规划自己的未来,在这段时间内自己会休息一段时间。就在工作交接的空档,对自己维护的项目以及近年来对工作做一些整理总结,发现了自己的框架在设计中对看门狗有两种不同的方式,因此把它分享出来,希望对接触它的人有所帮助,当然其中不乏纰漏,希望大家指正!记忆之中似乎以前也稍微写过类似的文章,不过没有这次总结来的完善。(这两天狗狗币很疯狂,手动狗头)

看门狗

  顾名思义,它就和字面意思一样是用来看家护院的,保护某个东西不被侵犯。在游戏服务器设计中,它就是用来保护游戏服务器不受到攻击。还记得曾经玩过一个“看门狗”的游戏,对此有过形象的解释,如果狗狗被带坏,那么我们所要保护的东西就很危险了。

  最近接近无业之时,整理自己过去工作和项目用到的设计,利用plain framework和skynet两种框架分别实现两种不同的效果,其各有优劣,因此在这篇文章中做相应的总结分析。

  plain-simple在以下简称PF,skynet-simple简称SS

plain-simple

  简介

  目前有三个示例的应用:看门狗、逻辑服务器、客户端,主要的逻辑放在LUA脚本处理。这个示例并没有完整的给出游戏服务器的具体逻辑,比如玩家模块、地图模块等等。

  其看门狗的实现方式大致图如下:

 

  1、PF中利用了路由和转发的方式来实现看门狗,即只有当看门狗认证成功后数据才能由客户端转发到相应的服务器

  部分实现的代码

  在服务器(logic server)可以使用以下代码进行路由请求:

    pf_net::packet::RoutingRequest routing_request;                                
    routing_request.set_destination(destination);   // 请求路由的目标服务                               
    routing_request.set_aim_name(aim_name);         // 请求路由的连接名                               
    routing_request.set_aim_id(aim_id);             // 请求路由的连接ID                               
    result = connection->send(&routing_request);

  在服务器(logic server)使用连接的路由接口传输数据到客户端(framework/core/src/connection/basic.cc):

bool Basic::routing(const std::string &_name,                                   
                    packet::Interface *packet,                                  
                    const std::string &service) {                               
  if (routing_list_[_name] != 1 || is_null(packet)) return false;               
  packet::Routing routing_packet;                                               
  routing_packet.set_destination(service);             // 需要路由的目标服务                                   
  routing_packet.set_aim_name(_name);                  // 需要路由的目标连接名                         
  routing_packet.set_packet_size(packet->size());      // 路由的网络包大小                         
  return send(&routing_packet) && send(packet);                                 
} 

  在看门狗(gateway)收到客户端发上来的信息调用连接的转发接口(framework/core/src/connection/basic.cc):

bool Basic::forward(packet::Interface *packet) {                                
  if (is_null(packet)) return false;                                            
  std::string aim_name = params_["routing"].data;                               
  if (aim_name == "") return false;                                             
  std::string service = params_["routing_service"].data;                        
  if (service == "") service = "default";                                       
  auto listener = ENGINE_POINTER->get_listener(service);                        
  if (is_null(listener)) return false;                                          
  auto connection = listener->get(aim_name);                                    
  if (is_null(connection) || connection->is_disconnect()) return false;         
  packet::Forward forward_packet;                                               
  forward_packet.set_original(name());                                          
  forward_packet.set_packet_size(packet->size());                               
  return connection->send(&forward_packet) && connection->send(packet);         
}

  2、怎样实现多客户端进入多服务器进行操作(跨服)?直接利用看门狗进行转发,还是透过客户端所在主逻辑服务器再进行分发处理?

  3、丢失连接时的处理:PF中如果路由中的连接一旦发生丢失,这只狗会“汪汪”两声,而这两声分别表现在看门口对自己的提醒和对另一方的提醒。比如客户端这时正通过看门狗路由到服务器上,客户端这时突然消失离开,这时候看门狗能看警觉地对自己叫了一声(如果开启脚本服务,则会调用脚本的链接丢失函数),同时对着服务器叫了一声(如果开启了脚本,则调用丢失函数)。但有个地方不同的是:客户端消失的时候,看门狗和服务器的连接仍然存在,只是清除了相应的路由信息;假如是服务器突然消失,狗也同样对自己和客叫两声,但这时候看门狗由于失去的路由便判定路由失败直接断开自己和客户端的连接(客户端以后可以优化,比如在丢失连接三十秒内看门狗连接服务器)。

  部分实现代码如下(路径在PF项目的framework/core/src/connection/basic.cc):

  连接断线时处理(客户端离开了狗狗,狗狗叫了两声):

void Basic::disconnect() {                                                                                     
  using namespace pf_basic::type;                                                                              
  //Notice routing original.                                                                                   
  std::string aim_name = params_["routing"].data;                             
  if (aim_name != "") {                                                                                        
    std::string service = params_["routing_service"].data;                      
    manager::Listener *listener = ENGINE_POINTER->get_service(service);         
    if (is_null(listener)) return;                                                                             
    auto connection = listener->get(aim_name);                                                                 
    if (!is_null(connection) && name_ != "") {                                                                 
      packet::RoutingLost packet;                                                                              
      packet.set_aim_name(name_);                                                                              
      connection->send(&packet);                                       // 通知服务器                                        
    }                                                                                                          
  }                                                                                                            
  auto script = ENGINE_POINTER->get_script();                                                                  
  if (!is_null(script) && GLOBALS["default.script.netlost"] != "") {            
    auto func = GLOBALS["default.script.netlost"].data;                         
    variable_array_t params;                                                                                   
    params.emplace_back(this->name());                                                                         
    params.emplace_back(this->get_id());                                                                       
    variable_array_t results;                                           // 提醒自己                                       
    script->call(func, params, results);                                                                       
  }                                                                                                            
  clear();                                                                                                     
}

  心跳中定时监测路由的处理(这只狗狗四处张望,看看是不是服务器突然人间蒸发):

bool Basic::heartbeat(uint32_t, uint32_t) {                                     
  using namespace pf_basic;                                                     
  auto now = TIME_MANAGER_POINTER->get_ctime();                                 
  if (is_disconnect()) return false;                                            
  if (safe_encrypt_time_ != 0 && !is_safe_encrypt()) {                          
    now = TIME_MANAGER_POINTER->get_ctime();                                    
    if (safe_encrypt_time_ + NET_ENCRYPT_CONNECTION_TIMEOUT < now) {            
      io_cwarn("[%s] Connection with safe encrypt timeout!",                    
               NET_MODULENAME);                                                 
      return false;                                                             
    }                                                                           
  }                                                                             
  // Routing check also can put it in input.                                    
  std::string aim_name = params_["routing"].data;                               
  if (aim_name != "") {                                                         
    auto last_check = params_["routing_check"].get<uint32_t>();                 
    if (now - last_check >= 3) {                                                
      std::string service = params_["routing_service"].data;                    
      manager::Listener *listener = ENGINE_POINTER->get_service(service);       
      if (is_null(listener)) return false;                                      
      auto connection = listener->get(aim_name);                                
      if (is_null(connection) || connection->is_disconnect()) {                 
        io_cwarn("[%s] routing(%s|%s) lost!",                                   
                 NET_MODULENAME,                                                
                 service.c_str(),                                               
                 aim_name.c_str());                                             
        return false;                                                           
      }                                                                         
      params_["routing_check"] = now;                                           
    }                                                                           
  }                                                                             
  return true;                                                                  
}

  下面由两张图来体现,看门狗在丢失服务器和客户端时的不同做派。

  1)客户端丢失

  2)服务器丢失

  4、PF模式的好处:可以隐藏所有服务器,客户端无法直接连接到服务器上,使得有人想要恶意攻击服务器变得困难。看门狗在一定程度上可以过滤很多垃圾数据,如遭受攻击的防御策略可以放在看门狗身上。守门上看门狗作用不差,就算是被人打趴下也不会让自己保护的东西受到直接攻击,除非攻击者让看门狗疯掉反过来破坏。(就算同一个IP上同时放服务器和看门狗,服务器的端口仍旧可以不用暴露,攻击者自然也无法直接攻击)

  5、PF模式的劣势:由于看门狗和服务器之间只有一条连接,在客户端数量很大的情况下,也许会造成消息堵塞。这要看看门狗和服务器之间的管道有多大,一般情况下是比较足够的,当然这里可以优化,比如增加看门狗到服务器之间的管道数量(增加手下传输狗狗的数量,只要人手够多就不相信忙不过来)。

 

skynet-simple

  简介

  最近两年的项目中都是使用skynet作为开发,源于云风前辈的多年经验和无私奉献,相信很多做游戏的对这个框架并不模式,其服务模式的优点在这里就不仔细探讨了。由于大部分逻辑在LUA层,因此开发很容易上手。

  在这个示例中,汇聚了经手的几个上线项目的一些核心实现(小型测试客户端、登录服务器、游戏世界服),几乎不用怎么修改就能够直接用于正式开发(用于商业风险自负,除非你自己经过很多测试,再次狗头!)

  它的配置由lumen-api和vue-admin共同实现,我这里不描述这两个工具(相当于平台前后端),其配置文件为json格式。

  实现结构图如下:

 

  1、SS模式中的看门狗(这里就是登陆服务器):就真的只起到看门的作用,客户端需要先让看门狗进行验证,然后直接连接到服务器上。服务器收到客户端连接请求,拿着客户端认证的看门狗ID,让对应的看门狗进行辨认,如果确认无误则客户端和服务器可以正常连接(也可以视为可以进入服务器,如游戏里就表现为可以进入游戏世界)。但看门狗认证的凭证是有时效的,就像现在许多验证码一样(1秒钟足够么,手动狗头!)。

  如下代码中,SS模式就定时检测时效的凭证并进行清除(service/login/auth_mgr.lua):

local function clear_timeout()                                                     
  local now = skynet.now()                                                         
  local clears = {}                                                                
  for k, v in pairs(tokens) do                                                     
    if v.time + 30000 < now then -- 5min clear                                     
      table.insert(clears, k)                                                      
    end                                                                            
  end                                                                              
  local _ = next(clears) and log:dump(clears, 'clear_timeout')                     
  for _, key in ipairs(clears) do                                                  
    tokens[key] = nil                                                              
  end                                                                              
end

  2、SS模式得利于设计中的集群模式(cluster),这种模式可以灵活的让自己成为一个节点进行服务。

  下面看看SS中节点的配置:

{
    "login_1": "xx.101.1xx.1xx:10001"
}

  3、SS模式丢失连接:skynet中连接丢失可以自身做一些封装处理,自身框架中并没有统一的处理,它似乎并没有像PF模式那样汪汪两声。

  下图为plain-simple三个部分测试:登录服务器(看门狗)、游戏服务器、机器人(迷你客户端)

 

  4、SS模式的优势:连接比较清晰,我就是要直连!

  直连处理时,客户端清楚自己连接的是谁,服务器也同时也知道谁连到了自己,并且这些连接都可以直接进行处理(可以直接明白的断开各自的连接,数据也是没被转发的,因此不会有转发导致数据丢失的风险)。

  客户端->(认证)看门狗

  客户端->(拿认证)服务器

  客户端登录到登录服务器(看门狗)代码(lualib/robot/init.lua):

-- Auto signup and signin(connect login server).                                
function login_account(self)                                                    
                                                                                
  skynet.sleep(100)                                                             
                                                                                
  local cfg = setting.get('login')                                              
  local host = cfg.ip                                                           
  local port = cfg.port                                                         
  local account = self.account                                                  
  local uid = account.uid                                                       
  local fd = socket.open(host, port)                                            
  log:debug('login account: %s %d', host, port)                                 
  if not fd then                                                                
    return self:login_account() -- loop                                         
  end                                                                           
  log:info('login_account open fd[%d]', fd)                                     
                                                                                
  self.fd = fd                                                                  
                                                                                
  -- Dispatch.                                                                  
  skynet.fork(function()                                                        
    local _ <close> = self:ref_guard()                                          
    local ok, err = xpcall(client.dispatch, trace.traceback, self)              
    if not ok then                                                              
      log:warn(err)                                                             
    end                                                                         
  end)                                                                          
                                                                                
  -- Try signin.                                                                
  local r = self:signin()                                                       
  if not r then                                                                 
    if not self:signup() then                                                   
      socket.close(fd)                                                          
      return self:login_account()                                               
    end                                                                         
    -- Try signin again.                                                        
    if not self:signin() then                                                   
      socket.close(fd)                                                          
      return self:login_account()                                               
    end                                                                         
  end                                                                           
                                                                                
  socket.close(fd)                                                              
                                                                                
  self.logined = true
  return true                                                                   
end  

  客户端登录到游戏服务器(看门狗)代码(lualib/robot/init.lua):

-- Auth game and enter(connect game server).                                    
-- @return bool                                                                 
function login_game(self)                                                       
  skynet.sleep(100)                                                             
  local pid = self.pid                                                          
  local sid = self.sid                                                          
  local cfg = setting.get('world')                                              
  local game_host = cfg.ip                                                      
  local game_port = cfg.port                                                    
  local fd = socket.open(game_host, game_port)                                  
  if not fd then return false end                                               
  log:info('login game open fd: %d', fd)                                        
  self.fd = fd                                                                  
  skynet.fork(function()                                                        
    local _ <close> = self:ref_guard()                                          
    local ok, err = pcall(client.dispatch, self)                                
    if not ok then                                                              
      log:warn('login game dispatch error %s', err or -1)                       
    end                                                                         
  end)                                                                          
  local r = self:auth_game()      -- 验证                                              
  if not r then                                                                 
    return self:login_game()      -- 进入游戏世界                                              
  end                                                                           
  return r                                                                      
end

  5、SS模式的劣势:暴露了服务器,容易造成直接的攻击(当然你的服务器要有价值,否则攻击者不会花费精力来做这个事)。当前可以利用其它方式来弥补这个劣势,则服务器端自身需要有防御策略(家里得放只看家狗)。

思考

  服务器集群的模式在erlang中便利、容错、性能等方面的优势,使得国内服务器开发还是有不少使用这个语言。skynet的模式差不多也是参考了 erlang的模式,使得我们可以比较容易使用高性能并发编程。在PF的设计中,我也曾经考虑过使用该模式来进行设计,对于解决跨服的数据交换问题可以提供一个不错的方案。

  1、账号验证该放在何处?

  一般来说直接将账号的验证放在看门狗处,客户端可以根据不同SDK到平台验证。客户端发送到看门狗这边的参数一致,毕竟账号验证一般都只需要token以及账号就行,看门狗可以连接到自己的平台(或者别的平台提供的统一GET/POST接口进行验证)。当看门狗验证成功后,进行到逻辑服的处理。

  2、跨服(组)应该如何实现?

  集群的方式使用起来比较方便,在skynet中可以使用集群服务器(cluster.send/call)接口轻松跨节点。

  那么PF中的跨服,应当如何实现?

  现阶段可以使用PF框架中的路由来实现跨服处理,跨服的核心也就是数据的转发。但这里存在一个问题,当玩家从自己的服务器进入一个公共跨服服务器时,这里的数据应当如何转发?由于这里有看门狗,如果玩家的路由始终不变,也就是看门狗并没有连接跨服的情况下,那么数据流的形式会是下图中的(1、2、3),如果跨服直连看门狗的话数据流的形式就是下图的(1、4):

  

  我们可以看到如果看门狗是PF这种设计的话,在跨服的时候未免就会多进行一次转发,这次转发带来的不确定性也是PF看门狗模式的劣势之一。因此我想到了(1、4)这种模式,那么跨服也要连接到游戏服所处的看门狗,游戏服和跨服都要经过看门狗处理,但这样感觉上让游戏服和跨服做了一次无意义的操作,毕竟它们都在门内,何必使用这个狗再传递?(感觉陷入了某个怪圈是不是,狗头保命)

   个人觉得跨服不要直连看门狗要好点,虽然多了一步的转发操作,但内部转发应该能反应过来,这样跨服和游戏服就不用隔着一道墙来交换数据,这样数据的交换比较便利。

  如果想到更好的解决办法,我将在这篇文章中更新。

更多

  PF的看门狗://github.com/viticm/plain-simple

  SS的看门狗://github.com/viticm/skynet-simple