在写代码之前,我们要先了解什么是协议,协议就是 “客户端向服务端发起的登录请求”,那么登录请求是什么样子的呢?这得先从TCP数据流说起,客户端发起的请求,就是一些二进制数据。
(1)TCP粘包现象
TCP协议是一种基于数据流的协议,举例来说,如果客户端分两次发送“1234”和“5678”这两条消息。服务端可能一次性接收到“12345678”;也可能先只收到“12”,过一会儿才收到“345678”。
游戏的网络模块需要实现数据切分的功能,具体有三种方法,如下表所示:
方法 | 说明 |
长度信息法 | 每个数据包前面加上长度信息,每次接收到数据后,先读取表示长度的字节,如果缓冲区的数据长度大于要去的字节数,则取出相应的字节,否则等待下一次接收(此为最常用的方法) |
固定长度法 | 每次都以相同的长度发送数据,假设规定每条信息的长度都为10个字符,那么“hello”、“12”这两条信息可以发送成“hello.....”、“12.......”,其中的“.”表示填充字符,只为凑数没有实际意义,接收方每次取10个字符,作为一条消息去处理 |
结束符号法 | 规定一个结束符号,作为消息间的分隔符。假设规定结束符号为“$”,那么“hello”、“12”这两条信息都可以发送成“hello$”、“12$”。接收方不停地读取数据,知道“$”出现为止,并且使用“$”去分割消息。(该方法最简单直观,本项目都使用该方法) |
(2)协议格式
本项目中都会使用字符串协议格式,每条消息由“\r\n”作为结束符,消息的各个参数用英文逗号分隔,如下图所示:
后续会实现编码解码方法,让协议字符串与Lua表互相转换。
gateway需要使用两个列表,一个用于保存客户端连接信息,另一个用于记录已登录的玩家信息。我们之前说的让gateway把客户端和agent关联起来,即是将“连接信息”和“玩家信息”关联起来。
定义了conns和players这两个表,以及conn和gateplayer这两个类,代码如下所示:
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对象)。关联conn和gateplayer,即设置conn对象的playerid。
登录后,gateway可以做到双向查找:
实现gateway处理客户端连接的功能。
(1)初始化监听
在服务启动后,service模块会调用s.init方法,在里面编写功能。代码如下所示:
function s.init()
local node = skynet.getenv("node")
local nodecfg = runconfig[node]
local port = nodecfg.gateway[s.id].port
local 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模块的listen和start方法开启监听。当有客户端连接时,start方法的回调函数connect(稍后实现)会被调用。
(2)客户端连接
当客户端连接上时,gateway创建代表该连接的conn对象,并开启协程recv_loop(稍后实现)专接收该连接的数据。代码如下所示:
--有新连接时
local connect = function(fd, addr)
print("connect from " .. addr .. " " .. fd)
local c = conn()
conns[fd] = c
c.fd = fd
skynet.fork(recv_loop, fd)
end
(3)接收客户端消息
recv_loop负责接收客户端消息。其中参数fd由skynet.fork传入,代表客户端的标识。
--每一条连接接收数据处理
--协议格式 cmd,arg1,arg2,...#
local recv_loop = function(fd)
socket.start(fd)
skynet.error("socket connected " ..fd)
local readbuff = ""
while true do
local recvstr = socket.read(fd)
if recvstr then
readbuff = readbuff..recvstr
readbuff = process_buff(fd, readbuff)
else
skynet.error("socket close " ..fd)
disconnect(fd)
socket.close(fd)
return
end
end
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处理缓冲区里的数据。
根据上面的代码,服务端接收到数据后,就会调用process_buff,并把对应连接的缓冲区传给它,process_buff会实现消息的切分工作。
举例:如果缓冲区readbuff的内容是“login,101,134\r\nwork\r\nwo”,那么process_buff会把它切分成“login,101,123”和“work”这两条消息交由下一阶段的方法去处理,然后返回“wo”,供下一阶段的recv_loop处理。
process_buff的整个处理流程如下图所示:
process_buff方法如下代码所示。由于缓冲区readbuff可能包含多条消息,且process_buff主体是个循环结构,因此每次循环时都会使用string.match匹配一条消息,再调用下一阶段的process_msg(稍后实现)处理它。
local process_buff = function(fd, readbuff)
while true do
local msgstr, rest = string.match( readbuff, "(.-)\r\n(.*)")
if msgstr then
readbuff = rest
process_msg(fd, msgstr)
else
return readbuff
end
end
end
举例:假如readbuff的内容是“login,101,134\r\nwork\r\nwo”,经过string.match语句匹配,msgstr的值为“login,101,134”,rest的值为“work\r\nwo”;如果匹配不到数据,例如readbuff的内容是“wo”,那么经过string.match语句匹配后,msgstr为空值。
完整代码放在下一篇一起提交。