2020游戏开发入门-04(服务端框架实现)
title: 2020游戏开发入门-04(服务端框架实现)
date: 2020-05-31 22:09:24
tags:
- 游戏开发
- Unity3D
- Python
- 服务端
categories: 游戏开发
目录
概述
-
客户端项目地址:DTSGameClient
-
服务端项目地址:DTSGameServer
Unity3D + C# +Python 2.7 。服务端框架都是自己写的。啥第三方库都没有。资源文件太大。客户端项目里面是Assest/script
文件夹下面的代码。完整项目在里面有个云盘链接。
在windows下直接打开客户端。如果有python环境(我测试的时候是py 2.7。理论上3也可以只是我没全面测试)也可以跑起来服务端。然后就可以登入进去玩了。
玩法大概就是登入后在一个匹配房间。点匹配会在服务端的匹配列表里面。人够了就一起丢到一个场景。按吃鸡的规则最后一个活下来的胜利。
ps: 初学者写的框架。python 2.7 写的。性能不好。仅供学习使用。
服务端框架实现
以前看过的参考资料:
sylar框架: [C++高级教程]从零开始开发服务器框架(sylar) https://github.com/sylar-yin/sylar
zinx框架: zinx-Golang轻量级TCP服务器框架(适合Go语言自学-
这个是Moduo库的作者录的网络编程课程 网络编程实战-陈硕
还有一个之前某厂用过的Middle框架。
《APUE》《UNP》《后台开发核心技术与实践》。。。。。。
《Python核心编程》主要看下Python socket接口怎么调用的。剩下看官方文档。
前置技能
-
关于大端小端,网络字节序,主机字节序
-
TCP不保证消息的边界,粘包问题的解决
TCP是可靠的字节流协议,只要发了就一定能收到,当然前提是开发者真的懂得如何使用TCP进行正确的网络编程。TCP不保证消息的边界。当发送字符“hello_server”时, 另一方可能第一次只收到了“hello”。下一次接收才能够收到全部的数据。也有可能两次发送的数据,在同一次接收中全部接收。
造成这种现象的原因有很多。在客户端和服务端中各自维护这一定大小的收发缓冲区。当发送缓冲区满的时候,再向其发送数据就会出现失败。或则是读数据时内核数据没有准备好,发出EAGAIN信号。希望应用层再试一次。在网速较快,连接数较少,缓冲区大小足够且发送数据大小刚好合适的情况下。这样情况发送的概率会小很多。导致不深入研究的人会忽略这一点。
另一个原因是TCP协议的Neagle算法,或者说是TCP_NODELAY选项。这个设计的开发者希望解决的问题是,在某些开发者代码写不好的时候,会发送很多比较小的数据包。大量小数据包每次都需要内核做相应准备再发送会造成大量性能损耗。于是他把小数据包在内核的发送缓冲区等待。等待达到一定规模时再发送,直接造成了两次发送的数据“粘”在了一起。
解决的方法也很简单。只需要指定协议格式对于无边界字符流的解析方法即可。业界有几种实现比较好的方案。一是使用特殊分隔符号作为边界切割。代表性的有Http协议。该协议使用“\r\n”作为分隔符区分消息。二是基于特殊状态机设计的协议格式。具有代表性的如Redis的RESP协议。第三类是使用定长字节描述,消息包的总长度。首先是4字节的消息头,其内容是一个整数表示消息体的长度。接下来4字节表示接口编号。最后是消息体,其长度是消息头的数值。这一我们就对字节流的TCP协议在应用层做了消息的切割。在消息体中也能够使用更加优秀的序列化协议表示行为。在这里使用Json作为序列化协议。
客户端发送消息
消息的格式为:
【4字节】【4字节】【n字节】
前4字节是一个整数。值为n。表示整个消息的长度。
然后4字节是接口编号ServiceID。也是一个整数(前8字节在传的时候要处理字节序)
然后n字节是Json的序列化字符串。
ServiceID决定了服务端具体那个接口会被调用。
客户端NetworkMgr代码只需要封装出这个格式发给服务端即可。
如下代码:参数是一个整数和一个string。客户端会构造完整的消息。然后在头4字节写上消息长度。然后发给服务端。
private byte[] PackageBuffer()
{
byte[] package = new byte[8 + header];
byte[] headerBytes = BitConverter.GetBytes(header);
byte[] handlerBytes = BitConverter.GetBytes(handler);
if (!IsBigEndian())
{
Array.Reverse(headerBytes);
Array.Reverse(handlerBytes);
}
BytesCopy(headerBytes, 0, package, 0, 4);
BytesCopy(handlerBytes, 0, package, 4, 4);
for (int i = 0; i < header; i++)
{
package[i + 8] = byteArray[i];
}
return package;
}
public byte[] PackBuffer(int handler, string message)
{
header = message.Length;
this.handler = handler;
byteArray = new ByteArray();
// string 对于每个字符(unicode char),忽略高8位转 byte[]
bool asciiFlag = true;
for (int i = 0; i < message.Length; i++)
{
byte[] bytes = BitConverter.GetBytes(message[i]);
byteArray.AddOneByte(bytes[0]);
if (bytes[1] != 0)
{
asciiFlag = false;
break;
}
}
// 失败,说明string中有 ascii无法表示的字符,当做非法包
if (asciiFlag == false)
{
return null;
}
messageState = MessageState.PKG_FINISH;
return PackageBuffer();
}
服务端初始化
服务端用的是事件驱动的Reactor模式的写法。
服务端框架的核心代码我都写在server_core里面。
在main.py里面
if __name__ == '__main__':
server = Server("cwl server", "01")
server.start(mode="light")
// ...
// 挂载业务
server.run()
在
server = Server("cwl server", "01")
里面我只是随便初始化一些变量,比如。日志类,和配置文件类。
日志类: 作用基本就是Log().debug() 能在文件写报错信息。
配置文件类: 作用就是能在文本里面加载个 监听端口号之类的参数。
server.start(mode="light")
里面主要是初始化了几个类。比如light模式初始化 LightServer类是网络处理层。其他的还有连接池,工作池
ps: 这三我起的名字。。。
各自调用这几个模块的初始化函数。
比如套接字 bind , listen 一下呀之类的事情。
回到主函数。
然后是挂载service的部分。这部分怎么来的我在上一篇框架使用讲过了。
user_server.UserServer(server)
room_mgr_server.RoomMgrServer(server)
game_mgr_server.GameMgrServer(server)
synchronization_server.SynchronizationServer(server)
这部分的Server协助挂载service。
每一个service可以理解成一个接口,也可以理解是一个服务。都是自动生成的。
每一个service有一个ServiceID。作为身份表示。消息通过这个ID锁定到Service的函数可调用对象。
这部分的底层原理:
这是UserServer类中的一行。他初始化了一个UserLoginService()。里面有代码模板。
然后使用server的add_handler挂载到某个地方。
user_login_service = UserLoginService()
self.server.add_handler(user_login_service.func_handler)
这里的UserLoginService()初始化如下
user_login_service_run,user_login_service_pretreatment,user_login_service_aftertreatment是三个函数。
func_handler是FunctionHandler对象。显然他把一个整数和函数绑定起来了。
class UserLoginService:
def __init__(self):
if not hasattr(config, "USER_LOGIN_SERVICE"):
raise Exception("config file service id not define")
self.handler_id = config.USER_LOGIN_SERVICE
self.func_handler = FunctionHandler(self.handler_id, user_login_service_run)
self.func_handler.pre_handler = user_login_service_pretreatment
self.func_handler.last_handler = user_login_service_aftertreatment
然后
self.server.add_handler
这里的server。通过参数传给各个server类。其实就是主函数一开始初始化的那个Server()
def add_handler(self, handler_func):
try:
self.work_process.add_handler(handler_func.handler_id, handler_func)
self.logger.info("add handler " + str(handler_func.handler_id))
except Exception as e:
self.logger.error("work process not init." + e.message)
Server()类的add_handler又把ServiceID(接口ID)和函数。给了工作池work_process。
work_process是类WorkerPool()的实例。
因为在模块划分上工作池就该拿着 要干活的人的ID(ServiceID活接口ID), 要干活的事情(Service类的函数)
然后在WorkerPool的add_handler就只是对Python的字典看一下这个接口ID有没有被用过了。没被用过就设置一下。
然后主函数开始。到这里吗程序会卡在这里面。
server.run()
里面是比较经典的事件循环。我们以不使用IO复用为例。
run()调用了网络层的run()方法。也就是LightServer类的run
def run(self):
self.state = config.SERVER_RUN
while True:
# time.sleep(0.1)
self.__accept_client()
self.__update_client()
self.workers.update()
服务端在运行时一直处于事件循环状态。周期性的做一系列的事情。
在
self.__accept_client()
里面,服务端会尝试accept客户端(就是socket编程通常说的那个accept调用)。如果有客户端加入。
会用创建一个UUID,和accept出来的socket对象。组装一个Connection 连接对象。然后丢到连接池里面。
我其实就是吧每一个连接设置一个全局唯一的UUID,和套接字。放到一个ConnectionPool里面的列表里面。
顺便控制下连接上限。
self.__update_client()
在底层,遍历每一个连接。尝试读取数据。读的时候可能会发现连接已断开。需要吧连接从连接池去掉。
def update(self):
exit_conn = []
for conn_id, conn in self.connections.items(): # 遍历连接
is_exit = conn.recv_event() # 处理读事件
if is_exit:
exit_conn.append(conn.conn_id)
for conn_id in exit_conn: # 清除断开的客户端
self.__del_conn(conn_id)
其中is_exit = conn.recv_event()
是具体处理读事件的代码。conn是Connection对象。
recv_event函数内容如下。它处理字符流的数据。在最后的self._package_message()
中组装数据包
def recv_event(self):
text = ''
# 客户端退出 not text为 True,这里text上空字符串。
# 如果是客户端暂时没有数据,并不会导致text为空传,导致10035异常,非阻塞模式中
try:
text = self.client_fd.recv(1024)
if not text:
err_code = 10000
self.close_fd()
return True
except socket.error, (code, strerror):
if code not in Connection.err_d:
err_code = code
self.close_fd()
return True
self.recv_buf += text
self._package_message()
return False
消息可能很长。不止一个消息。当时内容都在self.recv_buf里面。下面要做的是解析数据包。
def _package_message(self):
while len(self.recv_buf) != 0:
size = self.msg.recv(self.recv_buf)
self.recv_buf = self.recv_buf[size:]
if self.msg.finish():
self.workers.message_handler(self.conn_id, self.msg)
self.msg.assign()
其中size = self.msg.recv(self.recv_buf)
。msg是Message类的对象。
msg.recv能吧字符流转化为消息.核心代码如下
# 从缓冲区中读取对应字节
# @param 缓冲区,缓冲区大小,需要的字节
def __recv(self, buf, buf_size, need_size):
read_size = min(buf_size, need_size)
self.__buf += buf[0: read_size]
self.__now_size += read_size
return read_size
def recv(self, buf):
buffer_size = len(buf)
read_size = 0
if self.__state == Message.PKG_RECV_HEAD:
read_size = self.__recv(buf, buffer_size, self.__header_size - self.__now_size)
if self.__now_size == self.__header_size:
self.__state = Message.PKG_RECV_HANDLER
self.__header_val = struct.unpack(">i", self.__buf)[0]
if self.__header_val <= 0 or self.__header_val > config.MAX_MESSAGE_SIZE:
self.assign()
self.__reset_buf()
elif self.__state == Message.PKG_RECV_HANDLER:
read_size = self.__recv(buf, buffer_size, self.__handler_size - self.__now_size)
if self.__now_size == self.__handler_size:
self.__state = Message.PKG_RECV_BODY
self.__handler_val = struct.unpack(">i", self.__buf)[0]
self.__reset_buf()
elif self.__state == Message.PKG_RECV_BODY:
read_size = self.__recv(buf, buffer_size, self.__header_val -self.__now_size)
if self.__now_size == self.__header_val:
self.__state = Message.PKG_FINISH
self.__message_val = self.__buf
self.__reset_buf()
return read_size
消息有三个部分。设置一个状态机PKG_RECV_HEAD,PKG_RECV_HANDLER,PKG_RECV_BODY
正在收消息头,正在收消息ID,正在收消息。
不论是收那一部分的数据。我们总能知道现在需要多少字节的数据。
如果缓冲区数据不够。拿消息拼不完。就等下次再拼。
如果消息多了。那就留着不取。把收完的包变成消息。
在内存中能够查出来消息的三个部分 int int string 的数据。
在_package_message()
里面。如果消息收完整了。会带着连接id和消息给工作池。连接id是为了,工作池调用完函数后能知道要会给哪一个客户端。
if self.msg.finish():
self.workers.message_handler(self.conn_id, self.msg)
self.msg.assign()
下面是工作池的message_handler
这里的任务可以开进程池或者线程池处理。message_handler只负责分发任务。然后其他线程去处理。处理完在发给客户端。
但是由于Python的全局解释锁。我觉得多线程不会更有优势。
本来有加个进程池的版本。但是在写游戏逻辑的帧同步的时候发现消息会乱序。刚好最开始写的比较卡。乱序的影响非常大。然后进程池版本就被我删掉了。
现在这个框架是单进程单线程的。
所以在收到消息后我马上调用下那个函数。
如下面message_handler写的。
def message_handler(self, conn_id, msg):
req = Request(conn_id, msg)
res = Response(conn_id)
handler = req.get_handler()
if handler in self.handler_dict.keys(): # 根据 request 哈希到具体函数
self.handler_dict[handler].run(self.common_tools, req, res)
for v in res.msg_queue:
if isinstance(v, Response):
v.pack_buffer(req.msg.get_handler())
v.msg.encryption()
self.response_queue.append(v)
res.msg_queue = []
self.response_queue.append(res)
Request对象的构造需要一个msg参数。Response对象是Request的子类。其实是一模一样的。可以不用。我为了在命名上区分。搞出的Response。
handler = req.get_handler() 会获取消息中的ServiceID。这是这个消息需要调用那个函数的凭证。
self.handler_dict是一个在工作池里面的Python字典。
程序最开始的
user_server.UserServer(server)
room_mgr_server.RoomMgrServer(server)
game_mgr_server.GameMgrServer(server)
synchronization_server.SynchronizationServer(server)
就是给这个字典赋值。key为ID。是整数,也叫ServiceID, 服务端ID,接口ID。
字典的val是FunctionHandler对象。包含生成的业务代码类的几个函数。也可以叫具体的Service。
run方法会去调用这些函数。
在run方法中(这里调用的是FunctionHandler的run)
在run方法中其实是直接调用了call。区别是会在配置文件中找一下debug参数有没有被配置。
如果是运行时项目。某些业务代码的异常会被catch掉,不会影响其他代码的运行
def run(self, controller, req, res):
is_debug = config.ConfigLoader().get("debug")
if isinstance(is_debug, bool) and is_debug:
self.call(controller, req, res)
else:
try:
self.call(controller, req, res)
except Exception as e:
logger = Log()
# log something
call方法包含五个函数。开头字符流要解密。结尾在发向网络前要加密。这里加密我写的比较水。
只是吧消息第三部分的消息体。每个字母x。做了对称加密 x = x + hash(i)。hash是一个哈希函数。i是字符在第几位。因为毕设,所以不能留下明显的漏洞。安全性肯定和ssl不能比的。
然后是5个函数。注释中标好了。其中2,3,4函数是框架生成的Service中三个由用户编写的函数。
1,5函数由框架提供。
def call(self, controller, req, res):
req.msg.un_encryption() # 消息解密
self.system_pretreatment(req, res) # [1]
if callable(self.pre_handler):
self.pre_handler(controller, req, res) # [2]
self.handler(controller, req, res) # [3]
if callable(self.last_handler):
self.last_handler(controller, req, res) # [4]
self.system_aftertreatment(req, res) # [5]
res.msg.encryption() # 消息加密
在system_pretreatment
中
把消息中还是Json字符串的数据。弄成了Python的字典。方便后面取出来处理。
所以在使用框架的人用的时候,网络获取的数据都以及是Python字典了。还可以做参数检测什么的
例如:
def game_mgr_add_hp_service_run(controller, req, res):
if not req.parse_success or not req.content:
Log().warn("service %d req parse err %s" %
(config.GAME_MGR_ADD_HP_SERVICE, req.parse_err))
return
user_id = req.content["user_id"]
hp = req.content["hp"]
为什么我提供有三个函数呢。
我希望第一个函数用户能做参数检查。因为Python没有类型的概念。后面一个一个校验比较麻烦。
类似这样
def game_mgr_add_hp_service_pretreatment(controller, req, res):
req.check_contain_int("user_id")
req.check_contain_int("hp")
我希望json反序列化的东西,hp值已经是整数了。
如果不是,会在第二个函数中直接中断。。。
def game_mgr_add_hp_service_run(controller, req, res):
if not req.parse_success or not req.content:
Log().warn("service %d req parse err %s" %
(config.GAME_MGR_ADD_HP_SERVICE, req.parse_err))
return
第三个函数要干嘛没想好。。。
类似sptring的面向切面编程。PHP CI 框架的钩子函数的概念。
反正在最后面,需要设置res.content为一个python字典。
res.content = {
"ret": 0,
"err_msg": ''
}
系统提供的第五个函数。作用就是把这个字典安装Json序列化,写到消息的包体里面。
然后我们回到run的地方.
def message_handler(self, conn_id, msg):
req = Request(conn_id, msg)
res = Response(conn_id)
handler = req.get_handler()
if handler in self.handler_dict.keys(): # 根据 request 哈希到具体函数
self.handler_dict[handler].run(self.common_tools, req, res)
for v in res.msg_queue:
if isinstance(v, Response):
v.pack_buffer(req.msg.get_handler())
v.msg.encryption()
self.response_queue.append(v)
res.msg_queue = []
self.response_queue.append(res)
在run完之后。会吧Response对象丢到self.response_queue里面。
Response对象里面有一个列表也可以存Response对象。这个是我想加广播事件的时候写的。
这时候我们就完成了一个对服务端的请求到处理的全过程。
回到最开始的事件循环。刚刚的事情只执行到事件循环的
self.__accept_client()
和self.__update_client()
def run(self):
self.state = config.SERVER_RUN
while True:
# time.sleep(0.1)
self.__accept_client()
self.__update_client()
self.workers.update()
最后是workers.update()
def update(self):
self.common_tools.update()
self.message_consumer()
先说第二个函数self.message_consumer()
它把上面存Response对象的列表取出来。然后交给连接池。
因为连接池才是手握客户端socket的人。然后由连接池吧消息发回给客户端。
如果是普通模式就直接sendall发过去就好了。
如果是IO复用机制下。这里底层就是添加一个写事件。等待可写。
写博客的时候,突然发现这里应该改成while好一点!!!
# 消费处理好的 response,发向客户端
def message_consumer(self):
if len(self.response_queue): # while len(self.response_queue):
res = self.response_queue[0]
self.response_queue = self.response_queue[1:]
self.conn_pool.send_handler(res.conn_id, res.msg)
在update函数里面还有一个是self.common_tools.update()
这是我写的一个延时调用函数。
这里的common_tools是一个工具集。就是反复出现在service代码。函数参数第一个的controller。
通过
controller.events.start_delay_event(DelayEvent(
config.SYNCHRONIZATION_HEART_BEAT_SERVICE,
{
"user_id": self.user_id,
"mode": 2,
"time": self.last_tick_time,
},
life_time * 2
))
其实是往框架的延时任务列表里面添加了一个延时事件。他需要什么时候执行。执行的任务和网络事件的接口一样的都是Service。
也就是多少秒后调用那个服务。
然后在事件循环中。轮到延时事件处理是,过期的事件就会被处理。
延时事件的存储是一个基于时间的优先队列。
def update(self):
self.tick = time.time()
while not self.events.empty():
event = self.events.get()
if event.run_tick < self.tick:
controller = Cache().get(config.GLOBAL_TOOLS)
self.handler_dict[event.handler].inline_call(controller, event.req_dict)
else:
self.events.put(event)
break
这里的events是一个Queue.PriorityQueue()
这么写延时事件处理当然不是很及时。我拿来做匹配多扫秒自动取消这种需求。
如果要很准时的延时调用。事件的发起和运行要分开的。显然比较耗性能。
到这里我们处理的网络事件的发起。客户端向服务端发包。服务端的事件循环中的一次处理流程。
实际上事件处理机制需要一直运行的。
这个框架是单线程单进程的。事件驱动。意思就是有的事件。驱动着网络数据调用了某个业务代码。
如果没有Python的全局解释锁。可以尝试进程池,线程池之类的设计。比如在消息包解析完成后丢给线程池。
线程池处理完后在吧结果的Response发回给主线程的队列拿去消费。发回给客户端。
这里的网络事件是通过遍历连接池的所有连接的。
每一次都需要遍历所有连接。但是其实每次只有很少的连接有事件。
网络IO层也可以封装成select, poll, epoll的事件触发。
我的框架也写了,select, epoll lt, epoll et的版本。但是经过测试性能并没有差很多。
应该是我写搓了,或者是我测试服务器的性能本身就不咋地。。。
还有一个原因是我为了复用连接池代码。select epoll做了一个字典,映射文件句柄到连接ID呀。可能这种地方把性能拖慢了。
另外epoll的版本还有点bug以后修。某些连接断开后会意外的在客户端连接池没有删掉。
居然写了这么多。下一篇开始写游戏业务逻辑的一些东西。