游戏服务器的思考之三:谈谈MVC

游戏服务器也是基于MVC架构的吗?是的,全部的应用系统都是基于MVC架构的,这是应用系统的天性。无论是客户端仍是后台,都包含模型、流程、界面这3个基本要素;不一样类型的应用,3要素的“重量”可能各有误差。目前那些声称比MVC更好的架构,在我看来,不过是MVC的在某种场合下的细化。可是,MVC这个概念是比较抽象的,项目中每一个人都有本身的理解,在细节之处你们的实现每每截然不同。像Spring这样的基于MVC的具体框架工具,可以缓解一些混乱局面,可是做为一个很是通用、有弹性的框架,它容许你作任何违反MVC的设计。要获得一份结构清晰、可扩展、质量稳定的项目代码,必须遵循良好的MVC设计理念,这个“理念”既来自软件开发行业的现有知识,也来自项目团队的共识。
 
首先来看看MVC的经典定义:
Model(模型)是应用程序中用于处理应用程序数据逻辑的部分。
  一般模型对象负责在数据库中存取数据。
View(视图)是应用程序中处理数据显示的部分。
  一般视图是依据模型数据建立的。
Controller(控制器)是应用程序中处理用户交互的部分。
一般控制器负责从视图读取数据,控制用户输入,并向模型发送数据。
 
这是来自百度百科的定义,我没有找到更权威的定义。以目前软件系统的复杂度,这个定义显得太简陋了,几乎无法指导实际的工做。
 
这篇文章,我想谈谈对MVC的理解,为了简单起见,拿”救济金“这个游戏里面的极小的模块作示例。这是一个极为简单的例子,可是足以说明个人设计理念。只要设计理念是完备且清晰的,那么其余更复杂的模块彻底能够套用相似的思路。
 
咱们游戏里面”救济金“模块的业务逻辑是这样的:用户在破产时(拥有金币数小于某个值),能够领取必定的的救济金币,天天最多能够领取N次,N取决于用户的VIP等级。
 
一、“模型”是什么
上面的定义说模型是“应用程序数据逻辑的部分”,该怎么理解?首先没有任何疑问的是,软件的核心数据结构是Model的一部分,这个小功能里,有几个数据须要被建模:用户领取救济的最小金币限制、救济金额度、用户能够领取的次数、用户目前领取的次数。
 
1)救济金额度和用户金币限制
这两个数值是一个与具体用户无关的业务配置值,能够实现为常量,也能够写在配置文件里面。参考第二篇的设计思想,咱们应该创建一个json格式的救济金配置文件,内容多是:
{
userCoinLimit:10000;//用户金币要低于1万
reliefCoin:5000; //救济金币5000
}
载入到一个叫作ReliefConfig的数据结构里面:
public class ReliefConfig {
     long userCoinLimit;
     long reliefCoin;
}
 
2) 用户能够领取的次数
这个数值与用户的VIP等级有关,而用户的VIP等级的控制是另外一个模块的事。在这里,模型所要表达的是一个约束关系。这个”约束关系”须要用一条数据记录来表达吗?仍是写死在代码里面就好了。这取决于业务的复杂程度和对灵活性的指望,假如咱们指望在线调整这个约束关系,那么“写死在代码里面“就不行。
 
咱们的游戏里,这个规则相对固定,能够用写死在代码里:
int getUserReliefTimeLimit(int vipLevel)
{
     return reliefConfig.getTime()+vipLevel; //领取次数是一个固定值加上vip等级
}
领取次数的固定部分放在上面的配置类里,增长一个字段以下:
public class ReliefConfig {
     long userCoinLimit;
     long reliefCoin;
     int reliefTime;
}
 
3)用户今天领取的次数
这个数据是用户相关的数据,不断地发生变化,须要使用数据库来记录,并且和日期相关,能够考虑设计成下面这个数据结构:
public class UserReliefTime {
    String date;
    int reliefTime;
}
 
好了,咱们已经为这个小系统创建了数据模型:两个类加一个函数,顺便制定了数据的存储方案:配置文件+数据库表。在一个业务系统实现以前,哪怕逻辑再简单,也要对业务创建模型,以”郑重而清晰”地表达业务规则。
 
模型是否只包含这些?固然不止,这只是静态的数据模型,按照上面的定义:“应用程序数据逻辑的部分”,模型至少还要对外提供数据操做的接口。
在提供什么样的接口以前,咱们要作一个决策:“用户领取救济“的逻辑是否属于模型的一部分,换句话说,在模型层是否要提供一个”领取救济金“的接口。
 
要决策这个问题,要考虑一个背景:修改用户的金币在游戏里面是一个特别重要的行为,有一个单独的模块(暂且叫作用户属性管理模块)来负责,若是救济金模块的模型部分要实现这个逻辑,那么就会依赖于用户属性管理模块。就整个救济金模块来讲,对户属性管理模块造成依赖是必然的,但在模型层造成这种依赖,我以为不恰当,由于破坏了模型层的内聚性。
 
最终救济金模型的操做接口被设计成这样,实现部分就略过了:
public interface ReliefService
{
int getReliefTime(String userId); //获取用户今天的领取次数
int addReliefTime(String userId);//增长用户今天领取次数
int getReliefTimeLeft(String userId,int vipLevel); //获取用户今天剩余的领取次数
long gerReliefCoin(); //获取救济金额度
}
 
4)模型层的代码清单
一个配置读取类ReliefConfig,一个数据库ORM对象类UserReliefTime,一个Service类ReliefService。
我认为Service类是属于模型层的,这块可能有一些争议,缘由在于Service从名字上含义就模糊,
 
模型层用来作什么?简单来讲,就是表达规则以及业务相关核心数据的”增删改查“,这里的”增删改查“是高于底层数据存储层的,是饱含业务语义的,在修改数据的过程当中维护着数据的业务一致性。对于救济金这个模块来讲,核心数据是:用户领取救济的最小金币限制、救济金额度、用户能够领取的次数、用户目前领取的次数;模型层的使命是:
1)屏蔽这些数据的底层存储细节;
咱们有两种存储方式:文件和数据库,模型层封装了这些细节;
2)维护这些数据之间的一致性;
所谓一致性,就是数据在变化过程当中符合业务规则约束,用户领取了一次救济金,那么剩余的次数必然减小,除非这个过程当中vip等级提高;
3)提供这些数据增删改查接口。
模型层提供的接口通常不会是getter&setter这样的简单接口,而是饱含业务语义的接口。一个新来的团队成员,一看这一组接口,基本就能明白这个模块的基础功能。
 
如今再考虑”给用户发放救济金”这个动做,它并非救济金这个模块的模型层的使命,后者只关注用户领取的次数,并不关注金币是怎么发放到用户手上的。划清这个界限是颇有必要的,随着业务变得愈来愈复杂,救济金模块未来还能够依赖其余模块,有时候开发者会有一种冲动,在模型层直接访问其余子模块的接口,以减小模型接口的参数,让模型层看起来功能更强大。可是实际上,这样作是在破坏模型的内聚性,让它变得不稳定。
 
模型层特别强调内聚性,尽可能不要对其余模块造成较强的依赖(个人习惯是,能够引用来自其余模块的数据结构,但避免使用其余模块的Service),模型层的功能要恰到好处,既不能退化成数据层(只包含数据库访问的逻辑以及相关的PO对象),也不能混入非核心的逻辑;我比较推崇DDD(模型驱动设计)的模型设计方法,若是不知道DDD,推荐看看《领域驱动设计.软件核心复杂性应对之道》。
 
二、控制层
控制层解析来自客户端的输入,调用一个或多个模块的模型层完成业务功能,而后将结果输出给客户端。
控制层提供的接口基本对应客户端须要调用的接口,在救济金这个业务里,咱们须要两个接口:一个查询救济金额度和剩余的领取次数;一个领取接口;因而对应的类设计可能以下:
public class ReliefController()
{
//查询
public output handleReliefCheck(input)
{
     String userId = input.userId
     long reliefCoin = reliefService.getReliefCoin(userId,);
     int vipLevel = userService.getVipLevel(userId);
     int  leftTime = reliefService.getReliefTimeLeft(userId,vipLevel);
     return {“reliefCoin":reliefCoin,”leftTime”:leftTime}
}
//领取
public output handleDrawRelief(input)
{
     String userId = input.userId
     long reliefCoin = reliefService.getReliefCoin(userId,);
     int vipLevel = userService.getVipLevel(userId);
     int  leftTime = reliefService.getReliefTimeLeft(userId,vipLevel);
     if (leftTime<=0) {
            return {“error”:...}
     }
     userService.addCoin(userId,reliefCoin);
     leftTime = reliefService.addReliefTime(userId);
     return {“reliefCoin":reliefCoin,”leftTime”:leftTime}
}
}
 
能够看到,一旦模型层确立,控制层该怎么编写变成一件很天然的事。在上面这个简单的控制器里面,咱们作了如下几件事:
1)解析来自客户端数据
2)构建返回给客户端数据
3)调用救济金模块的模型层接口以及用户属性管理模块的模型层接口完成了救济金领取的业务功能;
上面这几件事是控制层的职责,千万不要浸染到模型层。
 
一个模块的控制层每每就是一个类,一个public方法对应一个客户端接口。控制层充当了两个角色:
1)直接实现需求用例,因此它的public方法列表和相关需求用例呈现一对一的映射关系;
2)像粘合剂同样,调用相关模块的模型层接口以最终实现需求用例;
 
相比模型层,控制层的代码是比较廉价的,常常须要修改。说实话,我不建议在这一层去发挥面向对象设计技巧,保持“Easy To Delete”反而更好。
 
三、这里有视图吗
和其余APP同样,游戏的视图是经过游戏客户端来呈现,后台仅仅提供数据给客户端而已,看起来这里没有视图这个元素。MVC软件架构最初来源于单机软件,这个类型的软件里面,数据处理、控制逻辑、视图输出所有在一个进程里面。如今流行的互联网应用,前端重视图,后台重数据,彷佛都不足以构成完整的MVC结构。假设咱们把视图换个说法叫作“输出”,那么就造成了两个MVC结构。后端的View是输出的接口数据,这些数据在前端反序列化之后,又承担了M的角色。
 
所以游戏服务器的视图层比较简单,本质上就是通信协议的定义,以及消息接受和发送的逻辑。比起web系统,游戏系统在通信协议的设计上追求更加简洁和高效,后台每每直接未来自模型层的数据结构直接传递给客户端,客户端和服务端在数据模型上保持了很是高的一致性。为何能够这么作?这是由游戏系统的封闭性决定了的(参考第一篇)。
 
四、业务逻辑在哪里?
有人说:在MVC架构下,业务逻辑在模型层,控制层只是映射输入到模型层而已。上面的设计明显已经和这个说法背道而驰:业务逻辑一部分在控制器,一部分在模型。说实话,我从未在实际项目中,见过符合前面说法的设计,反而这种说法致使了混乱,致使非核心逻辑入侵了业务模型。
 
“业务逻辑“是一种很模糊的说法,有一本书(忘了是哪本书了)说了一句很正确的话:在一个软件产品里面,”业务逻辑“是最没逻辑的部分。由于它受到太多因素的影响:平台、用户、设计潮流、以及产品经理的我的喜爱。业务逻辑中仍然包含”颇有逻辑“的一部分,这部分就是”业务模型“,成功抽取出这个部分加以精心实现是获得一个优秀设计的关键;“没逻辑”的部分咱们就放到控制层。
 
以上面救济金的子系统为例,假设有一天策划提出这样一个需求:咱们但愿注册时间超过5天的用户获得更多的救济金。在上面这个设计里面, 相关修改多是这样的:
public class ReliefConfig {
     long reliefCoin;
     long reliefCoinDay5; //增长一个配置数据
     int reliefTime;
}
 
public interface ReliefService
{
     long getReliefCoin(int days); //获取救济金额度方法要加一个参数:注册天数
}
 
public class ReliefController()
{
//领取
public output handleDrawRelief(input)
{
     String userId = input.userId
     int vipLevel = userService.getVipLevel(userId);
     int  leftTime = reliefService.getReliefTimeLeft(userId,vipLevel);
     if (leftTime<=0) {
            return {“error”:...}
     }
     int days = accountService.getRegisterDays(userId); //控制层要从帐号服务里面拉取注册天数
     long reliefCoin = reliefService.getReliefCoin(days);
     userService.addCoin(userId,reliefCoin);
     leftTime = reliefService.addReliefTime(userId);
     return {“reliefCoin":reliefCoin,”leftTime”:leftTime}
}
}
 
上面有3处修改:模型层的配置文件加了一个字段,ReliefService的getReliefCoin接口加了一个参数,控制层的领取方法,增长了从accountService获取用户注册天数的方法调用。不得不感叹:无论提及来有多简单,改需求历来不是一件简单的事,这就是技术和产品经常撕逼的缘由了。 在上面这个设计原则之下,新的需求该怎么知足至少可以一目了然。修改完了以后,每一个部分仍然各司其职,保持了不错的内聚性。无论咱们的设计算不算行业先进水平,在快速的迭代过程当中,保持可持续和一致性才是最重要的。
 
五、膨胀的控制层
若是有一个设计良好的模型层,那么在产品的迭代过程当中最有可能出问题的就是控制层。在移动应用的开发里面有经典的Massive Controller问题,由此诞生了不少MVC的衍生架构,好比MVP,MVVP等。后台代码也同样,控制层既要处理来自前端的输入,又要粘合模型层的接口来实现功能;而后诸如”流程分支“、”特殊处理“这些放哪都不合适的代码,最终也落在了这一层。
 
首先控制层的代码膨胀每每有其余的缘由:
1)重复代码太多,好比对输入输出处理没有良好的封装
2)内聚性差,在Spring这种框架的帮助下,要添一个接口处理方法是实在太简单了,致使开发者决策的随意性
3)  在需求更改的时候,没有辨别出归属于模型层的变化,直接在控制层完成全部的事。
 
第三点是比较容易重复犯的一个错误,即便是经验丰富的程序员。譬如上面第一部分救济金系统的的例子,有很多人会直接在controller层加一个分支逻辑就完事了。若是要快速上线,这是多是最简单的办法,但久而久之,代码变得混乱不堪。
 
若是有设计良好的模型层,再加上一点点开发规范,后台控制层的通常代码不会膨胀很厉害(这一点和移动应用不同,后者很大程度是受系统的UI framework拖累)。有些开发者为了拆分控制层,容易作出两个错误的决策:1)增长一个Service类来辅助Controller,这个Service不三不四,不知道属于Controller层,仍是模型层;2)部分逻辑入侵到模型层,破坏了模型层的内聚性。
 
总结:
应用软件应当使用MVC的架构模式,这是毋庸置疑的(至少目前没有更好的选择)。MVC三层之中,首先要设计模型层,也就是对业务创建模型,模型层的核心使命是表达业务规则(经过数据结构和服务接口);控制层是一组实现对应需求用例的方法集合,它接受输入,调用模型层完成功能,并返回结果;视图对App或游戏后台,可对应为输入输出处理层。
 
MVC这种架构模式并无具体的规范,在细节之处要靠本身去把握。本文对MVC的理解能够算做一种mvc的架构风格,它能指导开发者如何把功能模块划分到各个层次,以及在产品迭代过程当中维护好各个层次(尤为是模型层)的内聚性。这种架构风格是否正确,是否足够优秀,并非特别重要的事,”有风格“自己才是最重要的。
相关文章
相关标签/搜索