由於最近在計划工作的變動,想要好好規划自己的未來,在這段時間內自己會休息一段時間。就在工作交接的空檔,對自己維護的項目以及近年來對工作做一些整理總結,發現了自己的框架在設計中對看門狗有兩種不同的方式,因此把它分享出來,希望對接觸它的人有所幫助,當然其中不乏紕漏,希望大家指正!記憶之中似乎以前也稍微寫過類似的文章,不過沒有這次總結來的完善。(這兩天狗狗幣很瘋狂,手動狗頭)
看門狗
顧名思義,它就和字面意思一樣是用來看家護院的,保護某個東西不被侵犯。在游戲服務器設計中,它就是用來保護游戲服務器不受到攻擊。還記得曾經玩過一個“看門狗”的游戲,對此有過形象的解釋,如果狗狗被帶壞,那么我們所要保護的東西就很危險了。
最近接近無業之時,整理自己過去工作和項目用到的設計,利用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)這種模式,那么跨服也要連接到游戲服所處的看門狗,游戲服和跨服都要經過看門狗處理,但這樣感覺上讓游戲服和跨服做了一次無意義的操作,畢竟它們都在門內,何必使用這個狗再傳遞?(感覺陷入了某個怪圈是不是,狗頭保命)
個人覺得跨服不要直連看門狗要好點,雖然多了一步的轉發操作,但內部轉發應該能反應過來,這樣跨服和游戲服就不用隔着一道牆來交換數據,這樣數據的交換比較便利。
如果想到更好的解決辦法,我將在這篇文章中更新。