• 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。

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

  • 相关阅读:
    Go语言入门系列(三)之数组和切片
    详解Java的对象创建
    Go语言入门系列(二)之基础语法总结
    Go语言入门系列(一)之Go的安装和使用
    SpringCloud--Ribbon--配置详解
    自己动手作图深入理解二叉树、满二叉树及完全二叉树
    自已动手作图搞清楚AVL树
    《RabbitMQ》什么是死信队列
    《RabbitMQ》如何保证消息不被重复消费
    《RabbitMQ》如何保证消息的可靠性
  • 原文地址:https://www.cnblogs.com/Q1143316492/p/13179690.html
Copyright © 2020-2023  润新知