2020游戏开发入门-03(服务端框架使用)
title: 2020游戏开发入门-03(服务端框架使用)
date: 2020-05-31 21:09:24
tags:
- 游戏开发
- Unity3D
- Python
categories: 游戏开发
目录
概述
-
客户端项目地址:DTSGameClient
-
服务端项目地址:DTSGameServer
Unity3D + C# +Python 2.7 。服务端框架都是自己写的。啥第三方库都没有。资源文件太大。客户端项目里面是Assest/script
文件夹下面的代码。完整项目在里面有个云盘链接。
在windows下直接打开客户端。如果有python环境(我测试的时候是py 2.7。理论上3也可以只是我没全面测试)也可以跑起来服务端。然后就可以登入进去玩了。
玩法大概就是登入后在一个匹配房间。点匹配会在服务端的匹配列表里面。人够了就一起丢到一个场景。按吃鸡的规则最后一个活下来的胜利。
服务端框架使用
一个服务端框架需要什么?
如果我们需要一个 请求,响应模式的框架。
客户端开发视角:
我希望,客户端有一个函数,我调用一下,能把参数带给服务端。
服务端获得参数后,知道你调用的函数希望服务端计算什么。计算完把结果给你。
服务端开发视角:
客户端给我发了一个数据包。我能获得数据包里面的具体参数。然后处理。最后发一个包给客户端。
类似我写了一个grpc+protobuf的过程。就是性能没它那么好。。。
在客户端:
比如我要添加一个注册服务:
在确定一个接口前。客户端和服务端会确定参数作用和类型。会制定如下表格。
用户注册时,客户端要告诉服务端注册的用户名,密码
服务端注册,判断能不能注册。然后返回注册是否成功。
还要确定一个接口ID,比如注册ID我就用1002
假设接口文档如下
1.2 用户注册[user_register_service] [1002]
Request:
属性名 | 类型 | 备注 |
---|---|---|
username | string | 用户名 |
password | string | 密码 |
Response
属性名 | 类型 | 备注 |
---|---|---|
ret | int | 标注请求结果 |
register_success | bool | 是否允许注册 |
err_msg | string |
在服务端的util文件夹下我提供了两个工具。用于生成代码模板。
client_creator.py 代码使用。需要填个表。然后跑一下生成客户端代码。
cc = ClientNetworkCreator()
cc.load_conf({
"class_name": "user",
"struct_name": "register"
})
cc.load_request({
"username": "string",
"password": "string"
})
cc.load_response({
"ret": "int",
"err_msg": "string",
"register_success": "bool"
})
cc.create()
其中 class_name 和 struct_name 必须给出。
类名后缀Router。控制下大小写生成C#的类。同时还有可序列化的类。
参数名和类型在 python 脚本中和 c#中是一样的。访问属性全公开。
还顺带生成了两个函数。如下。
需要把里面注释部分换成一个整数表示接口ID。一般写成一个const int。客户端服务端同一接口ID具有绑定关系
public class UserRouter // 类名由 class_name 的值决定
{
[Serializable]
public class RegisterRequest // 类名由 struct_name 的值决定
{
public string username;
public string password;
}
[Serializable]
public class RegisterResponse // 类名由 struct_name 的值决定
{
public string err_msg;
public int ret;
public bool register_success;
}
public static void RegisterRequestCall(string username, string password)
{
RegisterRequest request = new RegisterRequest
{
username = username,
password = password,
};
Message message = new Message();
message.PackBuffer(/*TODO write down service ID *//*ServiceID.XXX*/,
JsonTools.SerializeToString(request));
NetworkMgr.Instance.Send(message);
}
public static RegisterResponse RegisterRequestCallback(Message message)
{
try
{
RegisterResponse response = null;
response = JsonTools.
UnSerializeFromString<RegisterResponse>(message.GetMessageBuffer());
return response;
}
catch (Exception ex)
{
Debug.Log("RegisterRequestCallback parse error. " + ex.ToString());
return null;
}
}
}
然后是RegisterRequestCall, RegisterRequestCallback 这两个函数名也是由工具类填的参数名决定的。
在客户端。用户只需要调用:
UserRouter.RegisterRequestCall(username, password);
就可以往服务端发一个包。参数格式是类似RegisterRequest的Json序列化结果。服务端客户端端接口有一个ServiceID 作为ID标识。上诉生成模板函数中唯一需要填写的地方。他决定了服务端的那个接口会接受你发的参数。
数据包中会带着这个接口ID。服务端有一个 接口ID 到 函数 的字典Map。服务端会知道你的包由那个函数处理。然后在带着ID的数据和处理结果。丢回给客户端。
在服务端把结果处理好后,会往客户端发一个结果包。如果这个接口的ID有注册相关回调函数(客户端也有一个ID到委托函数的字典)。那么回调函数会被调用。注册回调函数代码如下。
NetworkMgr.Instance.AddMsgListener(ID, XXXCallback);
回调函数的写法大致如下。由于AddMsgListener的第二个参数 是一个c#委托。所以参数必须是Message。
客户端在收到服务端的包后,会根据接口ID找到对应回调函数调用。推荐使用RegisterRequestCallback 进行解析获取结果类。当然如果不在乎请求结果。直接写RegisterRequestCallback 这个回调不监听也是可以的。
void XXXCallback(Message msg)
{
UserRouter.RegisterResponse res = UserRouter.RegisterRequestCallback(msg);
if (res.ret == 0)
{
// do something
}
}
最终
用户通过UserRouter.RegisterRequestCall(username, password);
对服务端的请求。结果放回了NetworkMgr.Instance.AddMsgListener(ID, XXXCallback);
注册的回调函数中。
在客户端视角。他只关心需要告诉服务端什么数据,服务端需要给他返回什么数据
服务端工具类:
把一类代码称为Server。里面的具体接口称为Service。每一个ID对应一个接口。
比如用户服务下面,登入,注册,修改密码。。。下面只填了一个注册的接口
sc = ServerCreator()
#
sc.load_config({
"server_name": "user",
"service_list": [
{"service_name": "register"},
]
})
sc.create()
服务端需要填的数据只有Server名字和Service名字。用于生成框架代码。
生成的代码是一个完整的代码目录。其中有一个文件最后一个单词是server。其余的都是service。
ser_server (master)
$ ls
__init__.py user_register_service.py
user_change_password_service.py user_server.py
user_login_service.py user_user_level_service.py
user_network_test_service.py
下面是主函数。
if __name__ == '__main__':
server = Server("cwl server", "01")
server.start(mode="light")
# 加载 service
user_server.UserServer(server)
room_mgr_server.RoomMgrServer(server)
game_mgr_server.GameMgrServer(server)
synchronization_server.SynchronizationServer(server)
# 事件循环
server.run()
加载 service部分。构造若干类(这些类由server_creator脚本生成)构造的参数给的是Server。
他的作用是会吧业务代码作为事件挂载到服务上。
你需要把刚刚生成的文件夹里面的XXXServer挂在上去
在Server文件中, 需要把生成的Service文件按照下面的方式挂载上去。分层Server, Service一方面也是为了接口分类。
# coding=utf-8
from user_login_service import UserLoginService
from user_register_service import UserRegisterService
from server_impl.user_server.user_change_password_service import UserChangePasswordService
from server_impl.user_server.user_network_test_service import UserNetworkTestService
from server_impl.user_server.user_user_level_service import UserUserLevelService
class UserServer:
def __init__(self, server):
self.server = server
self.load_service()
def load_service(self):
// ...
user_register_service = UserRegisterService()
self.server.add_handler(user_register_service.func_handler)
// ...
下面是具体的Service。大部分都由框架自动生成。
包括一个类 XXXService。这个类在文件夹下的Server类中需要手动加载。
还需要填写 config.USER_REGISTER_SERVICE。config是一个python文件。里面有一个常量USER_REGISTER_SERVICE。表示接口ID。在__init__
函数中被设置。这个Service会以这个ID。作为哈希表中的Key。网络的数据包也因为这个Key而找到了这堆代码。然后调用
除此之外还有三个函数后缀pretreatment
, 后缀run
, 后缀aftertreatment
.框架会按顺序调用这三个函数。
最基础的使用是在 req.content 里面获取参数。在res.content里面设置要返回给客户端的参数。在他们中间写业务代码。这两个东西都是Python字典dict。对应客户端C#那边自动生成的XXXRequest, XXXResponse 的类。
# coding=utf-8
from server_core.function_handler import FunctionHandler
from server_core.log import Log
from server_core import config
from server_impl.base.orm.user import User
def user_register_service_pretreatment(controller, req, res):
req.check_contain_string("username")
req.check_contain_string("password")
def user_register_service_run(controller, req, res):
if not req.parse_success or not req.content:
Log().warn("service %d req parse err" % config.USER_LOGIN_SERVICE)
return
username = req.content["username"]
password = req.content["password"]
// 在这里编写业务代码
res.content = {
"ret": ret,
"register_success": register_success
}
def user_register_service_aftertreatment(controller, req, res):
pass
class UserRegisterService:
def __init__(self):
if not hasattr(config, "USER_REGISTER_SERVICE"):
raise Exception("config file service id not define")
self.handler_id = config.USER_REGISTER_SERVICE
self.func_handler = FunctionHandler(self.handler_id, user_register_service_run)
self.func_handler.pre_handler = user_register_service_pretreatment
self.func_handler.last_handler = user_register_service_aftertreatment
我的框架还提供了很多功能,比如:
参数检测
在按顺序调用的三个函数中。第一个函数是拿来写参数检测的。如果在数据包中检测不到类型。或者检测参数类型不对。直接结束调用。
def user_register_service_pretreatment(controller, req, res):
req.check_contain_string("username")
req.check_contain_string("password")
提供了三种类型检查。可以在req里面点出来
def check_contain_string(self, key):
pass
def check_contain_float(self, key):
pass
def check_contain_int(self, key, min_val=None, max_val=None):
pass
如果参数检测不过会在。run调用的开头就被中断
def user_register_service_run(controller, req, res):
if not req.parse_success or not req.content:
Log().warn("service %d req parse err" % config.USER_LOGIN_SERVICE)
return
获取和返回参数
还是客户端的时候参数是这样的。
[Serializable]
public class RegisterRequest
{
public string username;
public string password;
}
被客户端XXXCall调用后。这个类会被序列化。在网络中传输。再被服务端框架解析成字典。通过下面调用可以获得参数。(如果你的class里面是嵌套结构也是可以的,本质就是一个C#类,Json格式,Python字典在不同时期的的互相装换)
username = req.content["username"]
password = req.content["password"]
获取参数后就可以写业务代码了。
然后在run的最后。你需要设置一下res.content
res.content = {
"ret": ret,
"register_success": register_success
}
它最终会被框架整理成数据包,发回给客户端。最后被解析成客户端的一个c#类。
还是那句话。本质就是一个C#类,Json格式,Python字典的互相转化。只不过框架把底层网络的模块都写好了。用的话只需要填参数就行了。
[Serializable]
public class RegisterResponse
{
public string err_msg;
public int ret;
public bool register_success;
}
日志的使用
从框架引入日志包
from server_core.log import Log
Log()类是一个单例。打印的日志会在项目 logs文件夹下按日期归类好。默认日志级别写在代码中了。
Log().debug("一个字符串")
Log().info("一个字符串")
Log().warn("一个字符串")
Log().error("一个字符串")
共享内存使用
在每个生成的三个函数里面都有一个参数controller。里面包含了一些工具。
例如可以使用:
val = controller.mem_cache.get(key)
controller.mem_cache.set(key, val)
controller.mem_cache.remove(key)
mem_cache是一个python字典。在服务器初始化时候存在。可用于共享数据。存储一些运行时数据。
Service直接的调用
服务器框架是事件驱动的写法。某个网络事件的发送,导致了某个Service被调用。但是有时候我们也需要服务Service直接的互相调用接口
调用方法如下:
res_dict =
controller.handler_dict[config.USER_REGISTER_SERVICE].inline_call(controller, {
"username": "cwl",
"password": "123456"
})
controller.handler_dict 就是服务端运行时 ServiceID 到 Service的哈希表。config.USER_REGISTER_SERVICE是一个整数值。
它的inline_call是我对内部调用的封装。参数需要给一个Python字典。表示服务Service的参数。返回的结果res_dict也是一字典。
本质还是 C# , Json , Python 字典的互相装换。
延时调用
突然有这么一个需求。我发起某个调用。我希望在两秒后在此调用某一个服务。然后我就封装了这么一个东西。
还是从controller里面取东西用。
controller.events.start_delay_event(DelayEvent(
config.GAME_MGR_PLAY_WITH_OTHERS_SERVICE,
{
"user_id": user_id,
"matching_time": matching_time,
"mode": mode
},
2 # 单位 秒 浮点数
))
参数给的是一个DelayEvent类。构造函数给了三个东西。ServiceID. Python字典表示输入参数。一张整数表示延迟调用的时间。
注意这个延时调用不是很准时。延时两秒可能由于服务器任务比较多。2.5秒后才给你处理。
广播事件
本来我觉得写游戏按照 Request, Response的写法没什么问题的。知道有一个地方,我发现弄出个广播事件可能会更方便。
如game_mgr_aoe_freeze_service的封装。我有件事情需要广播所有在某个房间的客户端
for user_id in room_runtime.user_id_list:
conn_id = controller.mem_cache.get(ckv.get_ckv_user_to_conn(user_id))
if not conn_id:
continue
r = Response()
r.conn_id = conn_id
r.content = {
"ret": 0,
"err_msg": '',
"pos": pos
}
res.msg_queue.append(r)
大致意思是遍历了房间下所有的用户。获得连接ID。conn_id。
构造Response()对象。设置conn_id表示它要发给谁。然后r.content设置Python字典。发给他的数据。到客户端会被绑定的ServiceID的回调函数收到。
然后最后res.msg_queue.append(r)
把Response丢到函数框架提供的res对象里面。框架会处理好所有事情。
conn_id是客户端连接后,我用UUID生成的表示。然后在心跳包里面把 user_id 和 conn_id 使用框架 的共享内存绑定了起来。
心跳包:客户端隔一段时间ping一下服务端,告诉它自己活着。顺别把user_id和conn_id绑定。不如框架跑的时候是没有业务中 user_id 的概念的。
在心跳包代码里面有这样一行。
controller.mem_cache.set(ckv.get_ckv_user_to_conn(user_id), req.conn_id)
ckv.get_ckv_user_to_conn(user_id)
只是吧user_id和其他字符串拼出了一个key。
在函数的参数req中直接req.conn_id就可以获得用户的连接ID。
写到这里才把我框架的大致使用写完。下一章开始我写框架的底层原理。。。怎么感觉是大工程。