免责声明:以下内容描述了如何在基于PHP的Web应用程序上下文中理解类似MVC的模式。内容中使用的所有外部链接都在这里解释术语和概念,而不是暗示我在该主题上的信誉。
我必须清除的第一件事是:模型是一个层。
第二:经典MVC与我们在Web开发中使用的MVC之间存在差异。这是我写的一个较旧的答案,简要说明了它们的不同之处。
什么不是模型:
该模型不是类或任何单个对象。这是一个非常常见的错误(尽管最初的答案是在我开始学习其他方法时写的,但我也这样做了),因为大多数框架都会使这种错误观念永久存在。
它既不是对象关系映射技术(ORM),也不是数据库表的抽象。否则,任何告诉您的人都极有可能试图“出售”另一个全新的ORM或整个框架。
什么是模型:
在适当的MVC改编,M含有所有域业务逻辑和模型层是主要由三种类型的结构制成:
域对象
域对象是纯域信息的逻辑容器。它通常表示问题域空间中的逻辑实体。通常称为业务逻辑。
在这里,您可以定义在发送发票之前如何验证数据或计算订单的总成本。与此同时,域对象完全不知道存储-无论从哪里(SQL数据库,REST API,文本文件等),甚至也不是,如果他们得到保存或检索。
数据映射器
这些对象仅负责存储。如果您将信息存储在数据库中,则SQL便会存在。或者,也许您使用XML文件存储数据,并且数据映射器正在XML文件之间进行解析。
服务
您可以将它们视为“高级域对象”,但服务负责域对象和Mappers之间的交互,而不是业务逻辑。这些结构最终创建了一个“公共”接口,用于与域业务逻辑进行交互。您可以避免使用它们,但是要付出一些将域逻辑泄漏到Controllers中的代价。
ACL实施问题中有一个与此主题相关的答案-可能有用。
模型层和MVC三合一其他部分之间的通信应仅通过Services进行。明确的分离还有一些其他好处:
- 它有助于实施单一责任原则(SRP)
- 提供额外的“摆动空间”,以防逻辑更改
- 使控制器尽可能简单
- 如果您需要外部API,则提供清晰的蓝图
如何与模型互动?
先决条件:观看讲座“全球状态和单例”和“别找东西!” 从“干净代码讨论”中获得。
访问服务实例
对于要访问这些服务的View和Controller实例(可以称为“ UI层”),有两种通用方法:
- 您可以直接在视图和控制器的构造函数中注入所需的服务,最好使用DI容器。
- 使用工厂服务作为所有视图和控制器的强制依赖项。
您可能会怀疑,DI容器是一种更为优雅的解决方案(尽管对于初学者而言这并不是最简单的)。我建议考虑使用此功能的两个库是Syfmony的独立DependencyInjection组件或Auryn。
使用工厂和DI容器的解决方案都可以让您共享要在所选控制器之间共享的各种服务器的实例,并查看给定的请求-响应周期。
模型状态的改变
现在您可以访问控制器中的模型层,您需要开始实际使用它们:
public function postLogin(Request $request)
{
$email = $request->get('email');
$identity = $this->identification->findIdentityByEmailAddress($email);
$this->identification->loginWithPassword(
$identity,
$request->get('password')
);
}
您的控制器有一个非常明确的任务:接受用户输入,并基于此输入更改业务逻辑的当前状态。在此示例中,在“匿名用户”和“已登录用户”之间切换的状态。
Controller不负责验证用户的输入,因为这是业务规则的一部分,并且Controller绝对不调用SQL查询,就像您在此处或此处所看到的(请不要讨厌它们,它们被误导了,不是邪恶的)。
向用户显示状态更改。
好的,用户已登录(或失败)。怎么办?该用户仍未意识到这一点。因此,您需要实际产生响应,这是视图的责任。
public function postLogin()
{
$path = '/login';
if ($this->identification->isUserLoggedIn()) {
$path = '/dashboard';
}
return new RedirectResponse($path);
}
在这种情况下,视图基于模型层的当前状态生成了两个可能的响应之一。对于不同的用例,您将基于“当前选定的文章”之类的视图,选择不同的模板进行呈现。
表示层实际上可以变得非常复杂,如此处所述:了解PHP中的MVC视图。
但是我只是制作一个REST API!
当然,在某些情况下,这是一个过大的杀伤力。
MVC只是关注点分离原则的具体解决方案。MVC将用户界面与业务逻辑分开,并且在UI中将用户输入和表示的处理分开。这很关键。人们通常将其描述为“三合会”,但实际上并非由三个独立的部分组成。结构更像这样:
这意味着,当您的表示层的逻辑几乎不存在时,实用的方法是将它们保持为单层。它还可以大大简化模型层的某些方面。
使用这种方法,可以将登录示例(用于API)编写为:
public function postLogin(Request $request)
{
$email = $request->get('email');
$data = [
'status' => 'ok',
];
try {
$identity = $this->identification->findIdentityByEmailAddress($email);
$token = $this->identification->loginWithPassword(
$identity,
$request->get('password')
);
} catch (FailedIdentification $exception) {
$data = [
'status' => 'error',
'message' => 'Login failed!',
]
}
return new JsonResponse($data);
}
尽管这是不可持续的,但是当您具有用于呈现响应主体的复杂逻辑时,这种简化对于更琐碎的场景非常有用。但请注意,当尝试在具有复杂表示逻辑的大型代码库中使用该方法时,它将成为一场噩梦。
如何建立模型?
由于没有单个“模型”类(如上所述),因此您实际上不会“构建模型”。取而代之的是从制作Services,它们能够执行某些方法。然后实现Domain Objects和Mappers。
服务方法的示例:
在上述两种方法中,都有用于身份验证服务的登录方法。实际会是什么样子。我使用的是我写的.. 库中相同功能的稍作修改的版本,因为我很懒:
public function loginWithPassword(Identity $identity, string $password): string
{
if ($identity->matchPassword($password) === false) {
$this->logWrongPasswordNotice($identity, [
'email' => $identity->getEmailAddress(),
'key' => $password, // this is the wrong password
]);
throw new PasswordMismatch;
}
$identity->setPassword($password);
$this->updateIdentityOnUse($identity);
$cookie = $this->createCookieIdentity($identity);
$this->logger->info('login successful', [
'input' => [
'email' => $identity->getEmailAddress(),
],
'user' => [
'account' => $identity->getAccountId(),
'identity' => $identity->getId(),
],
]);
return $cookie->getToken();
}
如您所见,在此抽象级别上,没有迹象表明从何处获取数据。它可能是数据库,但也可能只是出于测试目的的模拟对象。即使实际使用的数据映射器也被隐藏在private
该服务的方法中。
private function changeIdentityStatus(Entity\Identity $identity, int $status)
{
$identity->setStatus($status);
$identity->setLastUsed(time());
$mapper = $this->mapperFactory->create(Mapper\Identity::class);
$mapper->store($identity);
}
创建映射器的方法
要实现持久性的抽象,最灵活的方法是创建自定义数据映射器。
来自:PoEAA书
实际上,它们是为与特定类或超类进行交互而实现的。可以说您拥有Customer
并Admin
在您的代码中(都继承自User
超类)。由于它们包含不同的字段,因此两者可能最终都将拥有单独的匹配映射器。但是您还将最终获得共享的和常用的操作。例如:更新“最近一次在线查看”时间。而不是使现有的映射器更加复杂,更实用的方法是使用通用的“用户映射器”,该用户映射器仅更新该时间戳。
一些其他评论:
数据库表和模型
尽管有时在大型项目中,数据库表,Domain Object和Mapper之间存在直接的1:1:1关系,但它可能不如您预期的那么普遍:
单个域对象使用的信息可能是从不同的表映射的,而对象本身在数据库中没有持久性。
示例:如果您正在生成每月报告。这将从不同的表中收集信息,但是MonthlyReport
数据库中没有神奇的表。
单个映射器可以影响多个表。
示例:当您存储User
对象中的数据时,该域对象可能包含其他域对象(Group
实例)的集合。如果您更改它们并存储User
,则数据映射器将必须在多个表中更新和/或插入条目。
来自单个域对象的数据存储在多个表中。
例如:在大型系统(例如:中等规模的社交网络)中,将用户身份验证数据和经常访问的数据与较大的内容块分开存储是很实用的,而这很少需要。在那种情况下,您可能仍然只有一个User
类,但是其中包含的信息将取决于是否获取了完整的详细信息。
对于每个域对象,可以有多个映射器
示例:您有一个新闻站点,该站点具有面向公众和管理软件的共享代码。但是,尽管两个接口都使用同一Article
类,但管理人员需要在其中填充更多信息。在这种情况下,您将有两个单独的映射器:“内部”和“外部”。每个执行不同的查询,甚至使用不同的数据库(如在主数据库或从数据库中)。
视图不是模板
MVC中的视图实例(如果您不使用模式的MVP变体)负责表示逻辑。这意味着每个View通常会处理至少几个模板。它从模型层获取数据,然后根据接收到的信息选择模板并设置值。
您从中获得的好处之一就是可重用性。如果您创建一个ListView
类,则可以使用编写良好的代码来使同一类负责在文章下方呈现用户列表和注释。因为它们都有相同的表示逻辑。您只需切换模板。
您可以使用本机PHP模板,也可以使用某些第三方模板引擎。可能还有一些第三方库可以完全替换View实例。
那答案的旧版本呢?
唯一的主要变化是,在旧版本中称为Model的实际上是Service。其余的“图书馆类比”保持良好。
我看到的唯一缺陷是,这将是一个非常奇怪的库,因为它会向您返回书中的信息,但不会让您接触书本身,因为否则抽象将开始“泄漏”。我可能不得不考虑一个更合适的类比。
View和Controller实例之间是什么关系?
MVC结构由两层组成:UI和模型。UI层的主要结构是视图和控制器。
当您处理使用MVC设计模式的网站时,最好的方法是使视图和控制器之间具有1:1的关系。每个视图代表您网站中的整个页面,并且具有专用的控制器来处理该特定视图的所有传入请求。
例如,要表示已打开的文章,您将具有\Application\Controller\Document
和\Application\View\Document
。在处理文章时,这将包含UI层的所有主要功能(当然,您可能有一些与文章不直接相关的XHR组件)。