2020游戏开发入门-03(服务端框架使用)

更好的阅读体验


title: 2020游戏开发入门-03(服务端框架使用)
date: 2020-05-31 21:09:24
tags:
- 游戏开发
- Unity3D
- Python
categories: 游戏开发

目录

概述

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。

写到这里才把我框架的大致使用写完。下一章开始我写框架的底层原理。。。怎么感觉是大工程。

猜你喜欢

转载自www.cnblogs.com/Q1143316492/p/13179690.html