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

游戏服务器也是基于MVC架构的吗?是的,所有的应用系统都是基于MVC架构的,这是应用系统的天性。不管是客户端还是后台,都包含模型、流程、界面这3个基本要素;不同类型的应用,3要素的“重量”可能各有偏差。目前那些声称比MVC更好的架构,在我看来,不过是MVC的在某种场合下的细化。但是,MVC这个概念是比较抽象的,项目中每个人都有自己的理解,在细节之处大家的实现往往大相径庭。像Spring这样的基于MVC的具体框架工具,能够缓解一些混乱局面,但是作为一个非常通用、有弹性的框架,它允许你做任何违反MVC的设计。要得到一份结构清晰、可扩展、质量稳定的项目代码,必须遵循良好的MVC设计理念,这个“理念”既来自软件开发行业的现有知识,也来自项目团队的共识。
 
首先来看看MVC的经典定义:
Model(模型)是应用程序中用于处理应用程序数据逻辑的部分。
  通常模型对象负责在数据库中存取数据。
View(视图)是应用程序中处理数据显示的部分。
  通常视图是依据模型数据创建的。
Controller(控制器)是应用程序中处理用户交互的部分。
通常控制器负责从视图读取数据,控制用户输入,并向模型发送数据。
 
这是来自百度百科的定义,我没有找到更权威的定义。以目前软件系统的复杂度,这个定义显得太简陋了,几乎没法指导实际的工作。
 
这篇文章,我想谈谈对MVC的理解,为了简单起见,拿”救济金“这个游戏里面的极小的模块做示例。这是一个极为简单的例子,但是足以说明我的设计理念。只要设计理念是完备且清晰的,那么其他更复杂的模块完全可以套用类似的思路。
 
我们游戏里面”救济金“模块的业务逻辑是这样的:用户在破产时(拥有金币数小于某个值),可以领取一定的的救济金币,每天最多可以领取N次,N取决于用户的VIP等级。
 
1、“模型”是什么
上面的定义说模型是“应用程序数据逻辑的部分”,该怎么理解?首先没有任何疑问的是,软件的核心数据结构是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,推荐看看《领域驱动设计.软件核心复杂性应对之道》。
 
2、控制层
控制层解析来自客户端的输入,调用一个或多个模块的模型层完成业务功能,然后将结果输出给客户端。
控制层提供的接口基本对应客户端需要调用的接口,在救济金这个业务里,我们需要两个接口:一个查询救济金额度和剩余的领取次数;一个领取接口;于是对应的类设计可能如下:
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”反而更好。
 
3、这里有视图吗
和其他APP一样,游戏的视图是通过游戏客户端来呈现,后台仅仅提供数据给客户端而已,看起来这里没有视图这个元素。MVC软件架构最初来源于单机软件,这个类型的软件里面,数据处理、控制逻辑、视图输出全部在一个进程里面。现在流行的互联网应用,前端重视图,后台重数据,似乎都不足以构成完整的MVC结构。假设我们把视图换个说法叫做“输出”,那么就形成了两个MVC结构。后端的View是输出的接口数据,这些数据在前端反序列化以后,又承担了M的角色。
 
因此游戏服务器的视图层比较简单,本质上就是通讯协议的定义,以及消息接受和发送的逻辑。比起web系统,游戏系统在通讯协议的设计上追求更加简洁和高效,后台往往直接将来自模型层的数据结构直接传递给客户端,客户端和服务端在数据模型上保持了非常高的一致性。为什么可以这么做?这是由游戏系统的封闭性决定了的(参考第一篇)。
 
4、业务逻辑在哪里?
有人说:在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获取用户注册天数的方法调用。不得不感叹:不管说起来有多简单,改需求从来不是一件简单的事,这就是技术和产品常常撕逼的原因了。 在上面这个设计原则之下,新的需求该怎么满足至少能够一目了然。修改完了之后,每个部分仍然各司其职,保持了不错的内聚性。不管我们的设计算不算行业先进水平,在快速的迭代过程中,保持可持续和一致性才是最重要的。
 
5、膨胀的控制层
如果有一个设计良好的模型层,那么在产品的迭代过程中最有可能出问题的就是控制层。在移动应用的开发里面有经典的Massive Controller问题,由此诞生了很多MVC的衍生架构,比如MVP,MVVP等。后台代码也一样,控制层既要处理来自前端的输入,又要粘合模型层的接口来实现功能;然后诸如”流程分支“、”特殊处理“这些放哪都不合适的代码,最终也落在了这一层。
 
首先控制层的代码膨胀往往有其他的原因:
1)重复代码太多,比如对输入输出处理没有良好的封装
2)内聚性差,在Spring这种框架的帮助下,要添一个接口处理方法是实在太简单了,导致开发者决策的随意性
3)  在需求更改的时候,没有辨别出归属于模型层的变化,直接在控制层完成所有的事。
 
第三点是比较容易重复犯的一个错误,即使是经验丰富的程序员。譬如上面第一部分救济金系统的的例子,有不少人会直接在controller层加一个分支逻辑就完事了。如果要快速上线,这是可能是最简单的办法,但长此以往,代码变得混乱不堪。
 
如果有设计良好的模型层,再加上一点点开发规范,后台控制层的一般代码不会膨胀很厉害(这一点和移动应用不一样,后者很大程度是受系统的UI framework拖累)。有些开发者为了拆分控制层,容易做出两个错误的决策:1)增加一个Service类来辅助Controller,这个Service不伦不类,不知道属于Controller层,还是模型层;2)部分逻辑入侵到模型层,破坏了模型层的内聚性。
 
总结:
应用软件应当使用MVC的架构模式,这是毋庸置疑的(至少目前没有更好的选择)。MVC三层之中,首先要设计模型层,也就是对业务建立模型,模型层的核心使命是表达业务规则(通过数据结构和服务接口);控制层是一组实现对应需求用例的方法集合,它接受输入,调用模型层完成功能,并返回结果;视图对App或游戏后台,可对应为输入输出处理层。
 
MVC这种架构模式并没有具体的规范,在细节之处要靠自己去把握。本文对MVC的理解可以算作一种mvc的架构风格,它能指导开发者如何把功能模块划分到各个层次,以及在产品迭代过程中维护好各个层次(尤其是模型层)的内聚性。这种架构风格是否正确,是否足够优秀,并不是特别重要的事,”有风格“本身才是最重要的。
 
 
 

猜你喜欢

转载自www.cnblogs.com/longhuihu/p/10423774.html