08 | MVC架构解析:模型(Model)篇

在上一讲中,我们了解了 MVC 这个老而弥坚的架构模式,而从这一讲开始,连同第 09、10 讲共计 3 篇,将分别展开介绍 MVC 三大部分内容。今天我要讲的就是第一部分——模型(Model)。

概念

首先我们要了解的是,我们总在谈“模型”,那到底什么是模型?

简单说来,模型就是当我们使用软件去解决真实世界中各种实际问题的时候,对那些我们关心的实际事物的抽象和简化。比如我们在软件系统中设计“人”这个事物类型的时候,通常只会考虑姓名、性别和年龄等一些系统用得着的必要属性,而不会把性格、血型和生辰八字等我们不关心的东西放进去。

更进一步讲,我们会谈领域模型(Domain Model)。“领域”两个字显然给出了抽象和简化的范围,不同的软件系统所属的领域是不同的,比如金融软件、医疗软件和社交软件等等。如今领域模型的概念包含了比其原本范围定义以外更多的内容,我们会更关注这个领域范围内各个模型实体之间的关系。

MVC 中的“模型”,说的是“模型层”,它正是由上述的领域模型来实现的,可是当我们讲这一层的时候,它包含了模型上承载的实实在在的业务数据,还有不同数据间的关联关系。因此,我们在谈模型层的时候,有时候会更关心领域模型这一抽象概念本身,有时候则会更关心数据本身。

贫血模型和充血模型

第一次听到“贫血模型”“充血模型”这两个词的时候,可能你就会问了,什么玩意儿?领域模型还有贫血和充血之说?

其实,这两兄弟是 Martin Fowler 造出来的概念。要了解它们,得先知道这里讲的“血”是什么。

这里的“血”,就是逻辑。它既包括我们最关心的业务逻辑,也包含非业务逻辑 。因此,贫血模型(Anemic Domain Model),意味着模型实体在设计和实现上,不包含或包含很少的逻辑。通常这种情况下,逻辑是被挪了出去,由其它单独的一层代码(比如这层代码是“Service”)来完成。

严格说起来,贫血模型不是面向对象的,因为对象需要数据和逻辑的结合,这也是贫血模型反对者的重要观点之一。如果主要逻辑在 Service 里面,这一层对外暴露的接口也在 Service 上,那么事实上它就变成了面向“服务”的了,而模型实体,实际只扮演了 Service API 交互入参出参的角色,或者从本质上说,它只是遵循了一定封装规则的容器而已。

这时的模型实体,不包含逻辑,但包含状态,而逻辑被解耦到了无状态 Service 中。既然没有了状态,Service 中的方法,就成为过程式代码的了。请注意,不完全面向对象并不代表它一定“不好”,事实上,在互联网应用设计中,贫血模型和充血模式都有很多成功的使用案例,且非常常见。

比如这样的一个名为 Book 的类:

public class Book {
    private int id;
    private boolean onLoan;
    
    public int getId() {
        return this.id;
    }
    public void setId(int id) {
        this.id = id;
    }
    public boolean isOnLoan() {
        return this.loan;
    }
    public void setOnLoan(boolean onLoan) {
        this.onLoan = onLoan;
    }
}

你可以看到,它并没有任何实质上的逻辑在里面,方法也只有简单的 getters 和 setters 等属性获取和设置方法,它扮演的角色基本只是一个用作封装的容器。

那么真正的逻辑,特别是业务逻辑在哪里呢?有这样一个 Service:

public class BookService {
    public Book lendOut(int bookId, int userId, Date date) { ... }
}

这个 lendOut 方法表示将书从图书馆借出,因此它需要接收图书 id 和 用户 id。在实现中,可能需要校验参数,可能需要查询数据库,可能需要将从数据源获得的原始数据装配到返回对象中,可能需要应用过滤条件,这里的内容,就是逻辑。

现在,我们再来了解一下充血模型(Rich Domain Model)。在充血模型的设计中,领域模型实体就是有血有肉的了,既包含数据,也包含逻辑,具备了更高程度的完备性和自恰性,并且,充血模型的设计才是真正面向对象的。在这种设计下,我们看不到 XXXService 这样的类了,而是通过操纵有状态的模型实体类,就可以达到数据变更的目的。

public class Book {
    private int id;
    private boolean onLoan;
    public void lendOut(User user, Date date) { ... }
    ... // 省略属性的获取和设置方法
}

在这种方式下,lendOut 方法不再需要传入 bookId,因为它就在 Book 对象里面存着呢;也不再需要传出 Book 对象作为返回值,因为状态的改变直接反映在 Book 对象内部了,即 onLoan 会变成 true。

也就是说,Book 的行为和数据完完全全被封装的方法控制起来了,中间不会存在不应该出现的不一致状态,因为任何改变状态的行为只能通过 Book 的特定方法来进行,而它是可以被设计者严格把控的。

而在贫血模型中就做不到这一点,一是因为数据和行为分散在两处,二是为了在 Service 中能组装模型,模型实体中本不该对用户开放的接口会被迫暴露出来,于是整个过程中就会存在状态不一致的可能。

但是请注意,无论是充血模型还是贫血模型,它和 Model 层做到何种程度的解耦往往没有太大关系。比如说这个 lendOut 方法,在某些设计中,它可以拆分出去。对于贫血模型来说,它并非完全属于 BookService,可以拿到新建立的“借书关系”的服务中去,比如:

public class LoanService {
    public Loan add(int bookId, int userId, Date date) { ... }
}

这样一来,借书的关系就可以单独维护了,借书行为发生的时候,Book 和 User 两个实体对应的数据都不需要发生变化,只需要改变这个借书关系的数据就可以了。对于充血模型来说,一样可以做类似拆分。

内部层次划分

软件的耦合和复杂性问题往往都可以通过分层解决,模型层内部也一样,但是我们需要把握其中的度。层次划分过多、过细,并不利于开发人员严格遵从和保持层次的清晰,也容易导致产生过多的无用样板代码,从而降低开发效率。下面是一种比较常见的 Model 层,它是基于贫血模型的分层方式。

在这种划分方式下,每一层都可以调用自身所属层上的其它类,也可以调用自己下方一层的类,但是不允许往上调用,即依赖关系总是“靠上面的层”依赖着“靠下面的层”。最上面三层是和业务模型实体相关的,而最下面一层是基础设施服务,和业务无关。从上到下,把各层依次简单介绍一下:

第一层 Facade,提供粗粒度的接口,逻辑上是对 Service 功能的组合。有时候由于事务需要跨多个领域模型的实体控制,那就适合放在这里。举例来说,创建用户的时候,我们同时免费赠送一本电子书给用户,我们既要调用 UserService 去创建用户对象,也要调用 SubscriptionService 去添加一条订购(赠送)记录,而这两个属于不同 Service 的行为需要放到一处 Facade 类里面做统一事务控制。在某些较小系统的设计里面,Service 和 Facade 这两层是糅合在一起的。

第二层 Service,前面已经介绍了,通常会存放仅属于单个领域模型实体的操作。

第三层数据访问层,在某些类型的数据访问中需要,比如关系型数据库,这里存放数据库字段和模型对象之间的 ORM(Object-Relational Mapping,对象关系映射)关系。

第四层基础设施层,这一层的通用性最好,必须和业务无关。某些框架会把基础设施的工作给做了,但有时候也需要我们自己实现。比如 S3Service,存放数据到亚马逊的分布式文件系统。

CQRS 模式

你也许听说过数据库的读写分离,其实,在模型的设计中,也有类似读写分离的机制,其中最常见的一种就叫做 CQRS(Command Query Responsibility Segregation,命令查询职责分离)。

一般我们设计的业务模型,会同时被用作读(查询模式)和写(命令模式),但是,实际上这两者是有明显区别的,在一些业务场景中,我们希望这两者被分别对待处理,那么这种情况下,CQRS 就是一个值得考虑的选项。

为什么要把命令和查询分离?举个例子来说明吧,比如这样的贫血模型:

class Book {
    private long id;
    private String name;
    private Date publicationDate;
    private Date creationDate;
    ... // 省略其它属性和 getter/setter 方法
}

那么,相应地,就有这样一个 BookService:

class BookService {
    public Book add(Book book);
    public Pagination<Book> query(Book book);
}

这个接口提供了两个方法:

一个叫做 add 方法,接收一个 book 对象,这个对象的 name 和 publicationDate 属性会被当做实际值写入数据库,但是 id 会被忽略,因为 id 是数据库自动生成的,具备唯一性,creationDate 也会被忽略,因为它也是由数据库自动生成的,表示数据条目的创建时间。写入数据库完成后,返回一个能够反映实际写入库中数据的 Book 对象,它的 id 和 creationDate 都填上了数据库生成的实际值。

你看,这个方法,实际做了两件事,一件是插入数据,即写操作;另一件是返回数据库写入的实际对象,即读操作。

另一个方法是 query 方法,用于查询,这个 Book 入参,被用来承载查询参数了 。比方说,如果它的 author 值为“Jim”,表示查询作者名字为“Jim”的图书,返回一个分页对象,内含分页后的结果列表。

但这个方法,其实有着明显的问题。这个问题就是,查询条件的表达,并不能用简单的业务模型很好地表达。换言之,这个模型 Book,能用来表示写入,却不适合用来表示查询。为什么呢?

比方说,你要查询出版日期从 2018 年到 2019 年之间的图书,你该怎么构造这个 Book 对象?很难办对吧,因为 Book 只能包含一个 publicationDate 参数,这种“难办”的本质原因,是模型的不匹配,即这个 Book 对象根本就不适合用来做查询调用的模型。

在清楚了问题以后,解决方法 CQRS 就自然而然产生了。简单来说,CQRS 模式下,模型层的接口分为且只分为两种:

命令(Command),它不返回任何结果,但会改变数据的状态。

查询(Query),它返回结果,但是不会改变数据的状态。

也就是说,它把命令和查询的模型彻底分开了。上面的例子 ,使用 CQRS 的方式来改写一下,会变成这样:

class BookService {
    public void add(Book book);
    public Pagination<Book> query(Query bookQuery);
}

你看,在 add 操作的时候,不再有返回值;而在 query 操作的时候,入参变成了一个 Query 对象,这是一个专门的“查询对象”,查询对象里面可以放置多个查询条件,比如:

Query bookQuery = new Query(Book.class);
query.addCriteria(Criteria.greaterThan("publicationDate", date_2018));
query.addCriteria(Criteria.lessThan("publicationDate", date_2019));

读到这里,不知道你有没有联想到这样两个知识点:

第一个知识点,在 [第 04 讲] 我们学习 REST 风格的时候,我们把 HTTP 的请求从两个维度进行划分,是否幂等,以及是否安全。按照这个角度来考量,CQRS 中的命令,可能是幂等的(例如对象更新),也可能不是幂等的(例如对象创建),但一定是不安全的;CQRS 中的查询,一定是幂等的,且一定是安全的。

第二个知识点,在 [第 07 讲] 我们学习 MVC 的一般化,其中的“第二种”典型情况时,Controller 会调用 Model 层的,执行写入操作;而 View 层会调用 Model 层,执行只读操作——看起来这不就是最适合 CQRS 的一种应用场景吗?

所以说,技术确实都是相通的。

总结思考

今天我们详细剖析了 MVC 架构中 Model 层的方方面面,并结合例子理解了贫血模型和充血模型的概念和特点,还介绍了一种典型的模型层的内部层次划分方法,接着介绍了 CQRS 这种将命令和查询行为解耦开的模型层设计方式。

其中,贫血模型和充血模型的理解是这一讲的重点,这里我再强调一下:

贫血模型,逻辑从模型实体中剥离出去,并被放到了无状态的 Service 层中,于是状态和逻辑就被解耦开了;

充血模型,它既包含数据,也包含逻辑,具备了更高程度的完备性和自恰性。

最后,来提两个问题,一同检验下今天的学习成果吧:

请回想一下,在你经历过的项目中,使用过什么 MVC 框架,Model 层的代码是遵循贫血模型还是充血模型设计的?

在文中应用 CQRS 模式的时候,add 方法不再返回 Book 对象,这样一来,方法调用者就无法知道实际插入的 Book 对象的 id 是什么,就无法在下一步根据 id 去数据库查询出这个 Book 对象了。那么,这个问题该怎么解决呢?

今天的内容就到这里,有余力还可以了解下扩展阅读的内容。有关今天的知识点,如果你在实际的工作经历中遇到过,其实是非常适合拿来一起比较的。可以谈谈你在这方面的经验,也可以分享你的不同理解,期待你的想法!

扩展阅读

AnemicDomainModel,Martin Fowler 写的批评贫血模型的文章,他自己提出了贫血和充血的概念,因此我们可以到概念的源头去,看看他做出批评的理由是什么。

【基础】在模型层我们经常会和数据库打交道,SQL 是这部分的基础,如果你完全不了解 SQL,可以阅读 W3school 上的 SQL 基础教程(左侧目录中的基础教程,内容简短)。

文中提到了查询对象(Query Object),这其实是一种常见的设计模式,文中举例说明了是怎么使用的,但是,如果你想知道它是怎么实现的,可以阅读 Query Object Pattern 这篇文章,上面有很好的例子。

  • 21
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
222.jpg 北京时间2016年11月16日,国内领先的WEB与移动内核软件研发厂商-Zoomla!逐浪CMS团队发布其年度最后一个大作,也是目前国内首个基于MVC架构的厂商级dotNET框架CMS- Zoomla!逐浪2 x3.8 众所周知,目前面向云与大数据是今天互联网的大势所趋,而MVC框架则是目前最流行的开发框架之一。 ASP.NET 是一个使用 HTML、CSS、JavaScript 和服务器脚本创建网页和网站的开发框架。 ASP.NET 支持三种不同的开发模式: Web Pages(Web 页面)、MVCModel View Controller 模型-视图-控制器)、Web Forms(Web 窗体) MVC 编程模式 MVC 是三种 ASP.NET 编程模式中的一种。 MVC 是一种使用 MVCModel View Controller 模型-视图-控制器)设计创建 Web 应用程序的模式: Model模型)表示应用程序核心(比如数据库记录列表)。 View(视图)显示数据(数据库记录)。 Controller(控制器)处理输入(写入数据库记录)。 MVC 模式同时提供了对 HTML、CSS 和 JavaScript 的完全控制。 MVC 模式定义 Web 应用程序 带有三个逻辑层: 27 (1).jpg 业务层(模型逻辑) 显示层(视图逻辑) 输入控制(控制器逻辑) Model模型)是应用程序中用于处理应用程序数据逻辑的部分。 通常模型对象负责在数据库中存取数据。 View(视图)是应用程序中处理数据显示的部分。 通常视图是依据模型数据创建的。 Controller(控制器)是应用程序中处理用户交互的部分。 通常控制器负责从视图读取数据,控制用户输入,并向模型发送数据。 MVC 分层有助于管理复杂的应用程序,因为您可以在一个时间内专门关注一个方面。例如,您可以在不依赖业务逻辑的情况下专注于视图设计。同时也让应用程序的测试更加容易。 MVC 分层同时也简化了分组开发。不同的开发人员可同时开发视图、控制器逻辑和业务逻辑 未标题-1.jpg 基于这一思维开发的产品,具有更易维护、更加简洁目录,同时加上全新的逐浪CMS架构和自主表现引擎,整体效率与运行脚本也更具上乘。 Zoomla!逐浪CMS2 x3.8系统是逐浪软件团队年度大作,也是有史以来最大的一次加构更新,我们不仅重写了全局代码、后台引擎,同时就整个底层架构进行优化,整体效率提升了三倍以上。 同时融入了新的办公系统、移动功能、H5模块、移动开发引擎,具备良好的扩展性。 主要更新有: 全新后台表现体系,完美支持移动设置和Surface book、ipad等触控设备应用 全新智能模板引擎,引入我们为猪八戒网等平台提供的模板引擎,从而有更好的设计体验 新增FTP管理模板,可以更好的管理云主机 HTML5表单问券系统 场景复制功能 全新会员特许商品功能 全新会员层级邀请码,通过层级进行B2B分销推荐 增加:插件式开发方式,用于在发布后的项目增加新的mvc页面 增加:加固的安全防护,config下数据库链接不再明文,而是加密,并可通过官方Help.z01.com工具进行解密,从而提升平台的安全性。 修复:用户云备用功能。 增加:扩展商品支持最大购买数、最小购买数、购买倍数 改进:验证码改为点击后会自动更新防抓图破解 增加:增加:微信公众号子商户支付功能(wxpay_submp) 扩展:/Tools/ 维护工具,增加对加密文本的维护功能 增加:内容管理、商品、商城新增二维码,后台一键分享更方便 修改:修改CMS密钥机制 修改:webup多文件上传组件,增加图片压缩功能 场景:增加相册功能,并扩展支持图片压缩 增加:提现申请支持费率(需要在系统-配置-商城参数中设置费率) 修改:场景--相册,增加新建场景提示,微信分享图片默认为第一张图 扩展:ueditor已升级为1.4.3.3,解决一个安全溢出缺陷 增加:微信红包功能(/User/Money/RedPacket) *管理员在后台--微信--生成红包 *用户通过红包码在前台领取红包(用户必须关注公众号才可发送红包) 扩展:场景增加访问密码功能 *如设置,则非创建者访问需要密码 *输入一次密码后,只要不关浏览器,即可直接访问 扩展:资金赠送可根据用户名或ID选择赠送人 扩展:订单管理新增导出Excel 新增:全新订单管理样式 新增:快递打单功能,支持顺风、EMS、中通、安能物流等快递直接打出订单并匹配快递订单 扩展:内容支持中文URL,示例:/Item/标题,从而提升SEO的效率 *需要在后台--节点--栏目选项--中文URL *IE下需要对中文编码,否则无法解析标题 扩展:在线设计增加了对标签的支持,提现支持手续费率 扩展:相册模式增加新的模板,修复PPT模式 相册Bug 修复:OA模型添加无效Bug,用户检测Bug,专题路径无效Bug,OA事务模板Bug *编辑器模板:后台--办公--套红管理--模板类型=事务 修复:重新校验了手机注册流程,将注册流程修复 可视设计:增加记录复制 可视设计:增加标签复制,仅首行显示添加按钮 修复:后台--办公--OA,选择用户无效Bug(OA统一使用组织结构) 优化:开发中心Runsql增加快捷键,并优化关键词显示颜色 优化:后台商城-推广中心-用户明细列表显示用户ID、推荐人,新增按ID、真实姓名搜索, 并可逐级查看推广用户 增加:会员展示和会员详情新样式,更加简洁明亮 移除:移除后台SNS好友管理、虚拟商品等无用页面 修复:邀请码生成功能,并检验邀请码逻辑 修复:微博绑定Bug,修改密码链接Bug 修复:用户中心广告申请、节日提醒功能 增加:MVC页面索引功能,用户中心已可搜索页面 增加:会员支付二级密码功能

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值