> 文章列表 > 【从零开始学Skynet】实战篇《球球大作战》(五):gateway代码设计(上)

【从零开始学Skynet】实战篇《球球大作战》(五):gateway代码设计(上)

【从零开始学Skynet】实战篇《球球大作战》(五):gateway代码设计(上)

   

1、协议格式

       在写代码之前,我们要先了解什么是协议,协议就是 “客户端向服务端发起的登录请求”,那么登录请求是什么样子的呢?这得先从TCP数据流说起,客户端发起的请求,就是一些二进制数据。

(1)TCP粘包现象

        TCP协议是一种基于数据流的协议,举例来说,如果客户端分两次发送“1234”和“5678”这两条消息。服务端可能一次性接收到“12345678”;也可能先只收到“12”,过一会儿才收到“345678”。

游戏的网络模块需要实现数据切分的功能,具体有三种方法,如下表所示:

方法 说明
长度信息法 每个数据包前面加上长度信息,每次接收到数据后,先读取表示长度的字节,如果缓冲区的数据长度大于要去的字节数,则取出相应的字节,否则等待下一次接收(此为最常用的方法)
固定长度法 每次都以相同的长度发送数据,假设规定每条信息的长度都为10个字符,那么“hello”、“12”这两条信息可以发送成“hello.....”、“12.......”,其中的“.”表示填充字符,只为凑数没有实际意义,接收方每次取10个字符,作为一条消息去处理
结束符号法 规定一个结束符号,作为消息间的分隔符。假设规定结束符号为“$”,那么“hello”、“12”这两条信息都可以发送成“hello$”、“12$”。接收方不停地读取数据,知道“$”出现为止,并且使用“$”去分割消息。(该方法最简单直观,本项目都使用该方法)

(2)协议格式 

        本项目中都会使用字符串协议格式,每条消息由“\\r\\n”作为结束符,消息的各个参数用英文逗号分隔,如下图所示:

  • 参数 login:登录协议
  • 参数101:要登录的玩家id
  • 参数134:密码

后续会实现编码解码方法,让协议字符串与Lua表互相转换。

 2、连接类和玩家类

        gateway需要使用两个列表,一个用于保存客户端连接信息,另一个用于记录已登录的玩家信息。我们之前说的让gateway客户端agent关联起来,即是将“连接信息”“玩家信息”关联起来。

定义了connsplayers这两个表,以及conngateplayer这两个类,代码如下所示:

conns = {}   --[fd] = conn
players = {} --[playerid] = gateplayer--连接类
function conn()local m = {fd = nil,playerid = nil,}return m
end--玩家类
function gateplayer()local m = {playerid = nil,agent = nil,conn = nil,}return m
end

        在客户端进行连接后,程序会创建一个conn对象(稍后实现),gateway会以fd为索引把它存进conns表中。conn对象会保存连接的fd标识,但playerid属性为空。此时gateway可以通过conn对象找到连接标识fd,给客户端发送消息。如下图所示:

         当玩家成功登录时,程序会创建一个gateplayer对象,gateway会以玩家id为索引,将它存入players表中。gateplayer对象会保存playerid(玩家id)、agent(对应的代理服务id)和conn(对应的conn对象)。关联conngateplayer,即设置conn对象的playerid

 登录后,gateway可以做到双向查找:   

  • 若客户端发送了消息,可由底层Socket获取连接标识fdgateway则由fd索引到conn对象,再由playerid属性找到player对象,进而知道它的代理服务(agent)在哪里,并将消息转发给agent;
  • agent发来消息,只要附带着玩家idgateway即可由playerid索引到gateplayer对象,进而通过conn属性找到对应的连接及其fd,向对应客户端发送消息。

3、接收客户端连接

实现gateway处理客户端连接的功能。

(1)初始化监听

        在服务启动后,service模块会调用s.init方法,在里面编写功能。代码如下所示:

function s.init()local node = skynet.getenv("node")local nodecfg = runconfig[node]local port = nodecfg.gateway[s.id].portlocal listenfd = socket.listen("0.0.0.0", port)skynet.error("Listen socket :", "0.0.0.0", port)socket.start(listenfd , connect)
end

        先开启Socket监听,程序读取了我们之前编写的配置文件runconfig,找到该gateway的监听端口port,然后使用skynet.socket模块的listenstart方法开启监听。当有客户端连接时,start方法的回调函数connect(稍后实现)会被调用。

(2)客户端连接

        当客户端连接上时,gateway创建代表该连接的conn对象,并开启协程recv_loop(稍后实现)专接收该连接的数据。代码如下所示:

--有新连接时
local connect = function(fd, addr)print("connect from " .. addr .. " " .. fd)local c = conn()conns[fd] = cc.fd = fdskynet.fork(recv_loop, fd)
end
  • 参数fd:客户端连接的标识,这些参数是socket.start规定好的;
  • 参数addr:客户端连接的地址,如“127.0.0.1:60000”;
  • c:新创建的conn对象。

(3)接收客户端消息

        recv_loop负责接收客户端消息。其中参数fdskynet.fork传入,代表客户端的标识。

--每一条连接接收数据处理
--协议格式 cmd,arg1,arg2,...#
local recv_loop = function(fd)socket.start(fd)skynet.error("socket connected " ..fd)local readbuff = ""while true dolocal recvstr = socket.read(fd)if recvstr thenreadbuff = readbuff..recvstrreadbuff = process_buff(fd, readbuff)elseskynet.error("socket close " ..fd)disconnect(fd)socket.close(fd)returnendend
end

这段代码可以分成四个部分:

1)初始化:使用socket.start开启连接,定义字符串缓冲区readbuff。为了处理TCP数据的粘包现象,我们把接收到的数据全部存入readbuff中。

2)循环:通过while true do ...end实现循环,该协程会一直循环。每次循环开始,就会由socket.read阻塞的读取连接数据。

 3)若有数据:若接收到数据,程序将数据拼接到readbuff后面,再调用process_buff(稍后实现)处理数据。process_buff会返回尚未处理的剩余数据。

4)若断开连接:若客户端断开连接,调用disconnect(稍后实现)处理断开事务,再调用socket.close关闭连接。

说明:通过拼接Lua字符串实现缓冲区是一种简单的做法,它可能带来GC(垃圾回收)的负担,后面我们会介绍更高效的方法。

        下图对前面写的代码做了一个总结:当客户端连接时,程序通过skynet.fork发起协程,协程recv_loop是个循环,每个协程都记录着连接fd和缓冲区readbuff。收到数据后,程序会调用process_buff处理缓冲区里的数据。

 

 4、处理客户端协议

        根据上面的代码,服务端接收到数据后,就会调用process_buff,并把对应连接的缓冲区传给它,process_buff会实现消息的切分工作。

举例:如果缓冲区readbuff的内容是“login,101,134\\r\\nwork\\r\\nwo”,那么process_buff会把它切分成“login,101,123”“work”这两条消息交由下一阶段的方法去处理,然后返回“wo”,供下一阶段的recv_loop处理。

process_buff的整个处理流程如下图所示:

  •  它先接收缓冲区数据(阶段①);
  • 然后按照分隔符\\r\\n切分数据,并将切分好的数据交由process_msg方法处理(②阶段)
  • 最后返回尚未处理的数据“wo”(阶段④,返回值会重新赋给readbuff)。
  • process_msg会解码协议,并将字符串转为Lua表(如把字符串“login,101,123”转成图中的msg表,阶段③)。

        process_buff方法如下代码所示。由于缓冲区readbuff可能包含多条消息,且process_buff主体是个循环结构,因此每次循环时都会使用string.match匹配一条消息,再调用下一阶段的process_msg(稍后实现)处理它。

local process_buff = function(fd, readbuff)while true dolocal msgstr, rest = string.match( readbuff, "(.-)\\r\\n(.*)")if msgstr thenreadbuff = restprocess_msg(fd, msgstr)elsereturn readbuffendend
end
  • fd:客户端连接的标识;
  • readbuff:接收数据的缓冲区;
  • msgstr和rest:根据正则表达式“(.-)\\r\\n(.*)”的规则,它们分别代表取出的第一条消息和剩余的部分。

举例:假如readbuff的内容是“login,101,134\\r\\nwork\\r\\nwo”,经过string.match语句匹配,msgstr的值为“login,101,134”rest的值为“work\\r\\nwo”;如果匹配不到数据,例如readbuff的内容是“wo”,那么经过string.match语句匹配后,msgstr为空值。

完整代码放在下一篇一起提交。