我知道这已被一遍又一遍地问过,我读了主题,但它总是专注于特定的情况,我通常试图理解为什么在实体中使用服务不是最好的做法。
给定一个非常简单的服务:
Class Age
{
private $date1;
private $date2;
private $format;
const ym = "%y years and %m month"
const ...
// some DateTime()->diff() methods, checking, formating the entry formats, returning different period formats for eg.
}
和一个简单的实体:
Class People
{
private $firstname;
private $lastname;
private $birthday;
}
从控制器,我想做:
$som1 = new People('Paul', 'Smith', '1970-01-01');
$som1->getAge();
当然,我可以重写我的实体内部的getAge()
函数,它不长,但我非常懒,因为我已经写了所有可能的datetime-> diff()我需要在上面的服务,我不明白为什么我不应该使用“嗯...
注意:我的问题不是关于如何在我的实体中注入容器,我可以理解为什么这没有意义,但更多的是避免在不同实体中重写相同功能的最佳实践。
继承似乎是一个糟糕的“好主意”,因为我可以在类BlogArticle中使用getAge(),我怀疑这个BlogArticle类应该继承与People类相同的类...
希望我很清楚,但不确定......
许多程序员的一个主要困惑是认为学说实体“是”模型。那是一个错误。
向您的学说实体注入服务是“尝试做更多的事情而不是存储数据”的症状。当您看到“反模式”时,您很可能违反了SOLID编程中的“单一责任”原则。
Symfony不是MVC
框架,它只是一个VC
框架。缺乏M
部分。 Doctrine实体(我将从现在开始称它们为实体,最后看澄清)是“数据持久层”,而不是“模型层”。 SF有许多视图,Web控制器,命令控制器......但对域建模(http://en.wikipedia.org/wiki/Domain_model)没有帮助 - 甚至持久层都是Doctrine,而不是Symfony。
克服SF2中的问题
当您在数据层中“需要”服务时,触发反模式警报。存储应该只是“放在这里 - 从那里开始”系统。没有其他的。
要解决此问题,您应该将服务注入“逻辑层”(Model)并将其与“纯存储”(数据持久层)分开。遵循单一责任原则,将逻辑放在一边,将getter和setter放在另一边的mysql中。
解决方案是创建缺少的Model
层,在Symfony2中不存在,并使其给出域对象的“逻辑”,完全分离并与数据持久性层解耦,后者知道“如何存储”模型到一个带有doctrine的mysql数据库,或者一个redis,或者只是一个文本文件。
所有这些存储系统应该是可以互换的,你的Model
应该仍然暴露相同的公共方法,而消费者绝对没有变化。
这是你如何做到的:
第1步:将模型与数据持久性分开
为此,在您的包中,您可以在束根级别创建另一个名为Model
的目录(除了tests
,DependencyInjection
等),就像在这个游戏示例中一样。
Model
这个名字不是强制性的,Symfony对此没有任何说明。你可以选择你想要的任何东西。ModelBundle
,提供逻辑概念,如Board
,Piece
或Tile
以及许多其他逻辑概念,目录中的结构是为了清晰起见。特别是对你的问题
在您的示例中,您可以:
Entity/People.php
Model/People.php
Entity/People.php
里面 - 例如:假设您想要将生日期存储在日期时间字段中,以及三个冗余字段中:年,月,日,因为与搜索相关的任何棘手的事情或索引,与域无关(即与某人的“逻辑”无关)。Model/People.php
- 例如:如果一个人刚刚过了大多数年龄,如果某个出生日期和他所居住的国家(这将决定最低年龄),如何计算。如您所见,这与持久性无关。第2步:使用工厂
然后,您必须记住模型的使用者永远不应该使用“new”创建模型对象。他们应该使用工厂,这将正确设置模型对象(将绑定到正确的数据存储层)。唯一的例外是单元测试(我们稍后会看到)。但是除了单一测试之外,用大脑中的火来抓住它,并用视网膜中的激光对其进行纹身:永远不要在控制器或命令中做“新”。改为使用工厂;)
为此,您需要创建一个充当模型“getter”的服务。您可以将getter创建为可通过服务访问的工厂。看图像:
你可以在那里看到BoardManager.php。这是工厂。它是与电路板相关的主要吸气剂。在这种情况下,BoardManager具有以下方法:
public function createBoardFromScratch( $width, $height )
public function loadBoardFromJson( $document )
public function loadBoardFromTemplate( $boardTemplate )
public function cloneBoard( $referenceBoard )
ObjectStorageManager
注入BoardManager
。对于此示例,ObjectStorageManager
能够存储和加载来自数据库或文件的对象;而BoardManager
是存储不可知的。ObjectStorageManager
,然后注入@doctrine以便能够访问mysql
。new
的地方。从不在控制器或命令中。特别是对你的问题
在您的示例中,您将在模型中拥有PeopleManager
,能够根据需要获取人物对象。
同样在模型中,您应该使用正确的单数复数名称,因为它与数据持久层分离。似乎您当前正在使用People
来表示单个Person
- 这可能是因为您当前(错误地)将模型与数据库表名称匹配。
因此,涉及的模型类将是:
PeopleManager -> the factory
People -> A collection of persons.
Person -> A single person.
例如(伪代码!使用C ++表示法来指示返回类型):
PeopleManager
{
// Examples of getting single objects:
Person getPersonById( $personId ); -> Load it from somewhere (mysql, redis, mongo, file...)
Person ClonePerson( $referencePerson ); -> Maybe you need or not, depending on the nature the your problem that your program solves.
Person CreatePersonFromScratch( $name, $lastName, $birthDate ); -> returns a properly initialized person.
// Examples of getting collections of objects:
People getPeopleByTown( $townId ); -> returns a collection of people that lives in the given town.
}
People implements ArrayObject
{
// You could overload assignment, so you can throw an exception if any non-person object is added, so you can always rely on that People contains only Person objects.
}
Person
{
private $firstname;
private $lastname;
private $birthday;
}
所以,继续你的例子,当你做...
// **Never ever** do a new from a controller!!!
$som1 = new People('Paul', 'Smith', '1970-01-01');
$som1->getAge();
...你现在可以改变为:
// Use factory services instead:
$peopleManager = $this->get( 'myproject.people.manager' );
$som1 = $peopleManager->createPersonFromScratch( 'Paul', 'Smith', '1970-01-01' );
$som1->getAge();
PeopleManager将为你做new
for。
此时,由工厂创建的$som1
类型的变量Person
可以预先填充必要的机制来存储并保存到持久层。
myproject.people.manager
将在您的services.yml中定义,并且可以通过'myproject.persistence.manager`层或其他方式直接访问该学说。
注意:通过管理器注入持久层会产生一些副作用,这些副作用会从“如何使模型访问服务”中产生影响。为此,请参见步骤4和5。
第3步:通过工厂注入您需要的服务。
现在,您可以将所需的任何服务注入people.manager
您,如果您的模型对象需要访问该服务,您现在有两个选择:
在此示例中,我们为PeopleManager提供了模型要使用的服务。当人员经理被请求一个新的模型对象时,它会在new
语句中注入它所需的服务,因此模型对象可以直接访问外部服务。
// Example of injecting the low-level service.
class PeopleManager
{
private $externalService = null;
class PeopleManager( ServiceType $externalService )
{
$this->externalService = $externalService;
}
public function CreatePersonFromScratch()
{
$externalService = $this->externalService;
$p = new Person( $externalService );
}
}
class Person
{
private $externalService = null;
class Person( ServiceType $externalService )
{
$this->externalService = $externalService;
}
public function ConsumeTheService()
{
$this->externalService->nativeCall(); // Use the external API.
}
}
// Using it.
$peopleManager = $this->get( 'myproject.people.manager' );
$person = $peopleManager->createPersonFromScratch();
$person->consumeTheService()
在此示例中,我们为PeopleManager提供了模型要使用的服务。然而,当人员管理器被请求一个新的模型对象时,它会将自己注入到创建的对象中,因此模型对象可以通过管理器访问外部服务,然后管理器隐藏API,因此如果外部服务更改了API,经理可以为模型中的所有消费者进行适当的转换。
// Second example. Using the manager as a proxy.
class PeopleManager
{
private $externalService = null;
class PeopleManager( ServiceType $externalService )
{
$this->externalService = $externalService;
}
public function createPersonFromScratch()
{
$externalService = $this->externalService;
$p = new Person( $externalService);
}
public function wrapperCall()
{
return $this->externalService->nativeCall();
}
}
class Person
{
private $peopleManager = null;
class Person( PeopleManager $peopleManager )
{
$this->peopleManager = $peopleManager ;
}
public function ConsumeTheService()
{
$this->peopleManager->wrapperCall(); // Use the manager to call the external API.
}
}
// Using it.
$peopleManager = $this->get( 'myproject.people.manager' );
$person = $peopleManager->createPersonFromScratch();
$person->ConsumeTheService()
第4步:为所有事情投掷事件
此时,您可以在任何模型中使用任何服务。似乎一切都已完成。
然而,当你实现它时,如果你想要一个真正的SOLID模式,你会发现将模型与实体分离的问题。这也适用于将此模型与模型的其他部分分离。
问题显然出现在诸如“何时进行刷新()”或“何时决定是否必须保存某些内容或稍后保存”(特别是在长期存在的PHP过程中)以及有问题的变化中。如果该学说改变了它的API和类似的东西。
但是当你想要测试一个人而不测试它的House时也是如此,但是House必须“监视”Person是否更改其名称以更改邮箱中的名称。这是特别尝试长寿命的过程。
对此的解决方案是使用观察者模式(http://en.wikipedia.org/wiki/Observer_pattern),因此模型对象几乎可以为任何事物抛出事件,并且观察者决定将数据缓存到RAM,填充数据或将数据存储到磁盘。
这极大地增强了固/闭原理。如果您更改的内容与域无关,则永远不应更改模型。例如,添加一种新的存储方式到新类型的数据库,应该要求在模型类上进行零版本。
您可以在下图中看到此示例。在其中,我突出了一个名为“TurnBasedBundle”的捆绑包,就像每个基于回合制的游戏的核心功能一样,尽管它是否有一块板。您可以看到捆绑包只有模型和测试。
每个游戏都有一个规则集,玩家,在游戏过程中,玩家表达他们想要做的事情的愿望。
在Game
对象中,实例化器将添加规则集(扑克?国际象棋?tic-tac-toe?)。警告:如果我想加载的规则集不存在怎么办?
初始化时,某人(可能是/ start控制器)将添加玩家。注意:如果游戏是双人游戏而我加三个游戏怎么办?
并且在游戏期间,接收玩家移动的控制器将增加欲望(例如,如果下棋,“玩家想要将女王移动到该区块” - 这可能是有效的,或者不是 - 。
在图片中,您可以通过事件看到这3个操作受到控制。
PRE
和POST
。看看它怎么运作:
有人调用$ game-> addPlayer($ player)方法。
一旦我们进入addPlayer()函数,就会引发PRE
事件。
然后观察者可以捕捉此事件以决定是否可以添加玩家。
所有PRE
事件都应该通过引用取消。因此,如果有人认为这是一个2位玩家的游戏,并且您尝试添加第3个游戏,则$ cancel将设置为true。
然后你再次进入addPlayer函数。您可以检查是否有人想取消该操作。
如果允许则执行操作(即:改变$ this-> state)。
状态更改后,引发POST
事件以指示观察者操作已完成。在图片中你看到三个,但当然它还有更多。根据经验,每个setter有近2个事件,每个方法有2个事件可以修改模型的状态,每个“不可避免”的动作有1个事件。因此,如果您在类上运行10个方法,则可能会有大约15或20个事件。
您可以在任何操作系统的任何graphyc库的典型简单文本框中轻松看到这一点:典型事件将是:gotFocus,lostFocus,keyPress,keyDown,keyUp,mouseDown,mouseMove等...
特别是在你的例子中
Person将具有类似preChangeAge,postChangeAge,preChangeName,postChangeName,preChangeLastName,postChangeLastName的内容,以防每个人都有setter。
对于像“人,走路10秒”这样的长寿行为,你可能有3:preStartWalking,postStartWalking,postStopWalking(如果不能以编程方式阻止10秒的停止)。
如果你想简化,你可以有两个单一的preChanged( $what, & $cancel )
和postChanged( $what )
事件。
如果您永远不会阻止您的更改发生,您甚至可以只为一个事件设置changed()
以及对模型进行任何更改。然后,您的实体将在每次更改时“复制”实体属性中的模型属性。对于简单的类和项目,或者您不打算为第三方使用者发布的结构,这是可以的,并节省了一些编码。如果模型类成为项目的核心类,花一些时间添加所有事件列表将节省您将来的时间。
第5步:从数据层中捕获事件。
正是在这一点上,您的数据层捆绑包进入了行动!
使您的数据层成为模型的观察者。当模型更改其内部状态时,然后使您的实体将该状态“复制”到实体状态。
在这种情况下,MVC按预期运行:Controller,对模型进行操作。这样做的后果仍然是从控制器隐藏的(因为控制器不应该访问Doctrine)。该模型“广播”所做的操作,所以任何感兴趣的人都知道,这反过来触发数据层知道模型的变化。
特别是在你的项目中
Model/Person
对象将由PeopleManager
创建。在创建它时,PeopleManager
是一种服务,因此可以注入其他服务,可以使用ObjectStorageManager
子系统。因此,PeopleManager
可以获得您在问题中引用的Entity/People
,并将Entity/People
作为观察者添加到Model/Person
。
在Entity/People
中,你主要用事件捕手取代所有的二传手。
您可以这样读取代码:当Model/Person
更改其LastName时,将通知Entity/People
并将数据复制到其内部结构中。
最有可能的是,您很想将实体注入模型中,因此您可以调用实体的setter来代替抛出事件。
但是通过这种方法,你可以打破“开放 - 封闭”原则。因此,如果您想要迁移到MongoDb的任何特定点,您需要在模型中“更改”您的“实体”中的“文档”。使用观察者模式,这种变化发生在模型之外,他们永远不会知道观察者的本质,因为它是PersonObserver。
第6步:单元测试一切
最后,您需要对软件进行单元测试。正如我所解释的这种模式克服了您发现的反模式,您可以(并且您应该)单独测试模型的逻辑,而不管存储的方式如何。
遵循这种模式,可以帮助您实现SOLID原则,因此每个“代码单元”都独立于其他单元。这将允许您创建单元测试,它将测试Model
的“逻辑”而无需写入数据库,因为它会将伪数据存储层注入test-double。
让我再次使用游戏示例。我在图像中向您展示游戏测试。假设所有游戏可以持续数天,并且起始日期时间存储在数据库中。我们在示例中仅当getStartDate()返回dateTime对象时才进行测试。
其中有一些箭头代表流动。
在这个例子中,从我告诉你的两个注入策略中,我选择第一个:向Game
模型对象注入它需要的服务(在这种情况下是BoardManager
,PieceManager
和ObjectStorageManager
)而不是注入GameManager
本身。
ObjectStorageManager
的两倍。new
命令创建$ sut(被测系统)变量,而不是通过管理器创建。你还记得我说测试是一个例外吗?如果你在这里使用经理(你仍然可以)它不是单元测试,它是一个集成测试,因为测试两个类:经理和游戏。在new
命令中,我们伪造了模型所具有的所有依赖关系(就像一个董事会经理,就像一个片断管理器)。我在这里硬编码GameId = 1。这与数据持久性有关,见下文。Game
模型对象)来测试它的内部结构。我在new
中硬编码“Game id = 1”。在这种情况下,我们只测试返回的类型是DateTime对象。但是如果我们想要测试它得到的日期是正确的,我们可以“调整”ObjectStorageManager(数据持久层)模拟以返回我们想要的内部调用,所以我们可以测试一下例如当我向游戏的数据层请求日期= 1时,日期是2014年1月1日,对于游戏= 2,日期是2014年2月2日。然后在testGetStartDate中,我将创建2个新实例,其中ID为1和2,并检查结果的内容。
特别是在你的项目中
您将有一个Test/Model/PersonTest
单元测试,可以使用该人的逻辑,如果需要数据库中的人,您将通过模拟假冒它。
如果您想测试将人员存储到数据库中,那么无论是谁监听,都可以对事件进行单元测试。您可以创建一个假侦听器,附加到事件,并且当postChangeAge
发生时标记一个标志并且什么都不做(没有真正的数据库存储)。然后断言该标志已设置。
简而言之:
Model
,并将所有逻辑放入其中。new
从任何消费者那里获取您的模型。请改用工厂服务。特别注意避免控制器和命令中的新闻。例外:单元测试是唯一可以使用new
的消费者。似乎做了很多工作。但事实并非如此。这是一个习惯它的问题。只需考虑您需要的“对象”,创建它们并使数据层成为对象的“监视器”。然后你的对象可以自由运行,解耦。如果从工厂创建模型,请将模型中的任何所需服务注入模型,并保留数据。
在这个答案中所有出现的单词实体都指的是“学说实体”,这是造成大多数编码人员混淆的原因,在模型层和持久层之间应该总是不同。
DDD
积木越来越受欢迎,因此需要澄清更多我的答案,因为DDD
在模型中也使用了Entity
这个词。Domain entities
(不是Doctrine entities
)与我在Domain objects
的答案中提到的相似。Domain objects
:
Domain entities
(与Doctrine entites
不同)。
Domain value objects
(可以被认为类似于基本类型,逻辑)。
Domain events
(也不同于那些Symfony events
,也不同于Doctrine events
)。
Domain commands
(不同于那些Symfony command line
控制器般的助手)。
Domain services
(与Symfony framework services
不同)。
等等古人已经使用持久历史方法来记录事物(例如在石头上放置标记以记录交易)。
十年来,编程中的CQRS + ES方法(Command Query Responsability Segregation + Event Sourcing)越来越受欢迎,将“历史不可改变”的概念带入我们编写的程序中,而今天许多编码员都想到将命令分离方与查询方面。如果你不知道我在说什么,不用担心,只需跳过下一段。
CQRS + ES在过去3年或4年的日益普及让我想到这里考虑一下评论,以及它与我5年前在这里回答的内容有什么关系:
这个答案被认为是单一模型,而不是写模型和读模型。但我很高兴看到许多重叠的想法。
把我在这里提到的PRE事件想象成“命令和写模型”。将POST事件视为“事件采购部分走向读取模型”。
在CQRS中,您可以根据内部状态轻松找到“可以接受或不接受命令”。通常一个实现它们抛出异常但是还有其他替代方法,比如回答命令是否被接受。
例如,在“火车”中,我可以“将其设置为X速度”。但如果状态是火车在轨道上不能再进一步达到80公里/小时,则应该拒绝将其设置为200。
这与通过引用传递的cancel
布尔值相似,其中实体可以在其状态改变之前“拒绝”某些东西。
相反,POST事件不会带有“取消”事件,并在状态发生变化后抛出。这就是你无法取消它们的原因:他们谈论“实际发生的状态变化”因此无法取消:它发生了。
所以...
在我2014年的回答中,“pre”事件与CQRS + ES系统的“命令接受”匹配(命令可以被接受或拒绝),“post”事件与CQRS +的“Domain events”匹配ES系统(它只是告知实际已经发生的变化,用这些信息做任何你想做的事情)。
希望能有所帮助。
哈维。
你已经提到了一个很好的观点。类Person
的实例不是唯一可以有年龄的东西。 BlogArticle
s也可以与许多其他类型一起衰老。如果你使用PHP 5.4+,你可以利用特征来添加一小部分功能,而不是从容器中获得服务对象(或者你可以将它们组合起来)。
这是一个快速的模型,你可以做些什么来使它非常灵活。这是基本的想法:
Aging
)$birthdate
,$createdDate
,...)trait Aging {
public function getAge() {
return $this->calculate($this->start());
}
public function calculate($startDate) { ... }
}
trait AgingPerson {
use Aging;
public function start() {
return $this->birthDate;
}
}
class Person {
use AgingPerson;
private $birthDate = '1999-01-01';
}
// Use for articles, pages, news items, ...
trait AgingContent {
use Aging;
public function start() {
return $this->createdDate;
}
}
class BlogArticle {
use AgingContent;
private $createDate = '2014-01-01';
}
现在您可以询问上述类的任何实例的年龄。
echo (new Person())->getAge();
echo (new BlogArticle())->getAge();
如果你需要类型提示特征不会对你有任何好处。在这种情况下,您将需要提供一个接口,并让每个使用该特征的类实现它(实际的实现是特征,但接口启用了类型提示)。
interface Ageable {
public function getAge();
}
class Person implements Ageable { ... }
class BlogArticle implements Ageable { ... }
function doSomethingWithAgeable(Ageable $object) { ... }
这实际上看起来很麻烦,实际上维护和扩展这种方式要容易得多。
很大一部分是在使用数据库时没有简单的方法来注入依赖项。
$person = $personRepository->find(1); // How to get the age service injected?
一种解决方案可能是将年龄服务作为参数传递。
$ageCalculator = $container('age_service');
$person = $personRepository->find(1);
$age = $person->calcAge($ageCalculator);
但实际上,您可能最好只将年龄添加到Person类中。更容易测试和所有这些。
听起来你可能会有一些输出格式化?那种事情应该在树枝上完成。 getAge应该只返回一个数字。
同样,您的出生日期确实应该是日期对象而不是字符串。
你是对的,一般都气馁。但是,有几种方法可以扩展实体的功能,超出数据容器的目的。当然,所有这些都可以被认为(或多或少)糟糕的做法......但不知怎的,你必须做好这项工作,对吗?
AbstractEntity
超类,所有其他实体都可以从中继承。此AbstractEntity将包含其他实体可能需要的辅助方法。如前所述,所有这些都有其优点和缺点。选择你的毒药。