Doctrine2 immutable entities and append only data structures

时间:2016-04-15 15:05:39

标签: php mysql symfony doctrine-orm dql

I like the technique described by the Marco Pivetta at PHP UK Conference 2016 (https://youtu.be/rzGeNYC3oz0?t=2011), he recommends to favour immutable entities and instead of changing data structures - appending them. History of changes as a bonus is a nice thing to have for many different reasons, so I would like to apply this approach on my projects. Let's have a look at the following use case:

class Task {
    protected $id;
    /**
     * Status[]
     */
    protected $statusChanges;

    public function __construct()
    {
        $this->id = Uuid::uuid4();
        $this->statusChange = new ArrayCollection();
    }   

    public function changeStatus($status, $user){
        $this->statusChange->add(new Status($status, $user, $this);
    }

    public function getStatus()
    {
        return $this->statusChange->last();
    }
}

class Status {
    protected $id;
    protected $value;
    protected $changedBy;
    protected $created;

    const DONE = 'Done';

    public function __construct($value, User $changedBy, Task $task)
    {
        $this->id = Uuid::uuid4();
        $this->value = $value;
        $this->changedBy = $changedBy;
        $this->task = $task;
        $this->created = new \DateTime();
    }
}

$user = $this->getUser();
$task = new Task();
$task->changeStatus(Status::DONE, $user);
$taskRepository->add($task, $persistChanges = true);

All status changes I'm planning to persist in the MySQL database. So the association will be One(Task)-To-Many(Status).

1) What is the recommended way of gettings tasks by current status? Ie. all currently opened, finished, pending tasks.

$taskRepository->getByStatus(Status::DONE);

2) What is your opinion on this technique, are there some disadvantages which may appear in the future, as the project will grow?

3) Where it is more practical to save status changes (as a serialized array in a Task field, or in a separate table?

Thanks for opinions!

2 个答案:

答案 0 :(得分:2)

我想这将基于意见而被关闭,只是因为你知道。

话虽如此,我对这个想法很感兴趣,但我并没有真正研究它,但这是我的想法......

<强> 1。按状态查找
我认为您需要在联接中执行某种子查询以获取每个任务的最新状态并匹配该任务。 (我想指出,这只是从观察SO而不是实际知识的猜测,所以它可能会很好)。

SELECT t, s
FROM t Task
LEFT JOIN t.status s WITH s.id = (
    SELECT s2.id
    FROM Status s2
    WHERE s2.created = (
        SELECT MAX(s3.created)
        FROM Status s3
        WHERE s3.task = t
    )
)
WHERE s.value = :status

或者只是(假设合并的ID和创建的字段是唯一的)...

SELECT t, s
FROM t Task
LEFT JOIN t.status s WITH s.created = (
    SELECT MAX(s2.created)
    FROM Status s2
    WHERE s2.task = t
)
WHERE s.value = :status

2缺点
我认为必须为每个存储库调用使用上述类型的查询需要更多的工作,因此更容易出错。由于您只是追加到数据库,它只会变得更大,因此存储/缓存空间可能是一个问题,具体取决于您拥有多少数据。

3保存状态的位置
不可变实体的主要好处是它们可以永久缓存,因为它们永远不会改变。如果您在序列化字段中保存了任何状态更改,那么该实体将需要是可变的,这将违背目的。

答案 1 :(得分:1)

这就是我的所作所为:

我的业务涉及的所有类型的表

我将数据库组织在4种类型的表中:

  • Log_xxx
  • Data_xxx
  • Document_xxx
  • Cache_xxx

我存储的任何数据属于这4种类型的表中的一种。

Document_xxxData_xxx仅用于存储二进制文件(如提供商发送给我的关税的PDF),以及静态数据或超慢速数据(如机场或国家或货币)在世界上)。他们没有参与&#34;主要&#34;这个解释但值得一提。

日志表

我所有的&#34;域名活动&#34;以及&#34;应用程序事件&#34;转到Log_xxx表。

日志表是一次写入,永不可删除,我必须对它们进行备份。这就是&#34;业务历史&#34;存储。

例如,对于&#34;任务&#34;你在问题中提到的域对象,说任务可以&#34;创建&#34;然后改变,我使用:

  • Log_Task_CreatedEvents
  • Log_Task_ChangedEvents

此外,我保存了所有&#34;应用程序事件&#34;:每个HTTP请求都包含一些上下文数据。每个命令运行......他们去:

  • Log_Application_Events

除非有更改它的应用程序(命令行,cron,参加HTTP请求的控制器等),否则域永远不会更改。所有&#34;域事件&#34;引用创建它们的应用程序事件。

所有事件,无论是域事件(如TaskChangedEvent)还是应用程序事件都是绝对不可变的,并带有几个标准的东西,如创建时的时间戳。

&#34; Doctrine Entities&#34;有没有setters 所以只能创建和阅读它们。从未改变过。

在数据库中,我只有一个相关字段。它的类型为TEXT,表示JSON中的事件。我有另一个字段:WriteIndex是自动数字,是主键,我的软件永远不会用作密钥。它仅用于备份和数据库控制。有时你需要数据GB,你只需要转储从索引XX和#34开始的事件。

然后为了方便起见,我有一个额外的字段,我称之为&#34; cachedEventId&#34;它包含了相同的&#34; id&#34;事件,对JSON来说是多余的。这就是为什么该字段以&#34;缓存...&#34;命名的原因。因为它不包含原始数据,所以可以从事件字段重建。这只是为了简单起见。

虽然学说称他们为&#34;实体&#34;那些不是域实体,它们是域值对象。

因此,Log表看起来像这样:

INT      writeIndex;    // Never used by my program.
TEXT     event;         // Stores te event as Json
CHAR(40) cachedEventId; // Unique key, it acts really as the primary key from the point of view of my program. Rebuildable from the event field.

有时我选择加入更多缓存字段,例如创建时间戳。所有这些都不是必需的,只是为了方便起见。所有这些都应该从事件中提取出来。

缓存表

  • 然后在Cache_xxx我有&#34;积累的数据&#34;可以重建的数据&#34;来自日志。

例如,如果我有一个&#34;任务&#34;具有&#34;标题&#34;的域对象字段,&#34;创建者&#34;和&#34;截止日期&#34;,根据定义,创建者不能被覆盖,标题和截止日期可以重新设置...然后我有一张表格如下:

Cache_Tasks
    * CHAR(40) taskId
    * VARCHAR(255) title
    * VARCHAR(255) creatorName
    * DATE dueDate

编写模型

然后,当我创建一个任务时,它会写入2个表:

* Log_Task_CreatedEvents // Store the creation event here as JSON
* Cache_Tasks // Store the creation event as one field per column

然后,当我修改任务时,它还会写入2个表:

* Log_Task_ChangedEvents // Store the event of change here as JSON
* Cache_Tasks // Read the entity, change its property, flush.

阅读模型

要阅读任务,请始终使用Cache_Tasks。

他们总是代表最新状态&#34;对象。

Deleteability

所有Cache_xxx表都是可删除的,不需要备份。只需按日期顺序重播事件,您就可以再次获得缓存的实体。

示例代码

我把这个答案写成&#34;任务&#34;因为这是一个问题,但是今天我已经在分配一个&#34;州&#34;客户的表格提交。客户只是通过网络提问,现在我希望能够#34;标记&#34;这个请求为&#34; new&#34;或&#34;已处理&#34;或者&#34;回答&#34;或者&#34; mailValidated&#34;等...

我刚刚为change()创建了一个新的FormSubmissionManager方法。它看起来像这样:

public function change( Id $formSubmissionId, array $arrayOfPropertiesToSet ) : ChangedEvent
{
    $eventId = $this->idGenerator->generateNewId();
    $applicationExecutionId = $this->application->getExecutionId();
    $timeStamp = $this->systemClock->getNow();

    $changedEvent = new ChangedEvent( $eventId, $applicationExecutionId, $timeStamp, $formSubmissionId, $arrayOfPropertiesToSet );

    $this->entityManager->persist( $changedEvent );
    $this->entityManager->flush();

    $this->cacheManager->applyEventToCachedEntity( $changedEvent );
    $this->entityManager->flush();

    return $changedEvent;
}

请注意,我会进行2次刷新。这是故意的。在&#34;写入缓存&#34;失败我不想失去改变的事件。

所以我&#34;存储&#34;事件,然后我将它缓存到实体。

Log_FormSubmission_ChangeEvent.event字段如下所示:

{
    "id":"5093ecd53d5cca81d477c845973add91e31a1dd9",
    "type":"hellotrip.formSubmission.change",
    "applicationExecutionId":"ff7ad4bd5ec6cebacc048650c866812ac0127ac2",
    "timeStamp":"2018-04-04T02:03:11.637266Z",
    "formSubmissionId":"758d3b3cf864d711d330c4e0d5c679cbf9370d9e",
    "set":
    {
        "state":"quotationSent"
    }
}

在&#34;行&#34;我将使用&#34; quotationSent&#34;在state列中,即使不需要任何加入,也可以从Doctrine正常查询。

Sample of cached FormSubmission

我出售旅行。您可以看到来自多个来源的许多非标准化数据,例如成人,儿童和婴儿旅行的数量(来自创建表单提交本身),他请求的旅行的名称(来自存储库)旅行)和其他人。

您还可以看到最新添加的字段&#34;州&#34;在图像的右侧。缓存行中可能有20个解映射字段。

您的问题的答案

Q1)按当前状态获取任务的推荐方法是什么? IE浏览器。所有当前打开,完成,待处理的任务。

查询缓存表。

Q2)随着项目的发展,是否会出现一些可能出现的缺点?

当项目增长时,在写入时重建缓存,chich可能很慢,你设置了一个队列系统(例如RabbitMq或AWS-SNS),你只需向队列发送&的信号#34;嘿,这个实体需要重新缓存&#34;。然后,您可以非常快速地返回,因为保存JSON并向队列发送信号是徒劳的。

然后队列的监听器将处理您所做的所有更改,如果重新缓存很慢,那么您就无所谓了。

Q3)保存状态更改更为实际(作为任务字段中的序列化数组,还是单独的表中?

单独的表格:&#34;状态变化的表格&#34; (= log = events = value_objects,而不是实体),以及&#34;任务&#34;的另一个表。 (= cache = domain_entities)。

进行备份时,请将超级安全放置在日志的备份中。

发生严重故障后,请恢复logs = events并重播它们以重新构建缓存。

在symfony中,我用来创建一个hellotrip:cache:rebuild命令,它接受我需要重构的缓存作为参数。它会截断表(删除该表的所有缓存数据)并重新构建它。

这很昂贵,所以你只需要重建所有&#34;必要时。在正常情况下,当有新事件时,您的应用应该负责让缓存保持最新状态。

文件和数据

一开始我提到了文档和数据表。

现在的时间,现在:您可以在重建缓存时使用该信息。例如,您可以&#34; de-map&#34;机场名称进入缓存实体,而在活动中您可能只有机场代码。

您可以安全地更改缓存格式,因为您的业务具有更复杂的查询,并具有预先计算的数据。只需更改架构,删除它,重新构建缓存。

相反,更改事件将保持&#34;完全相同&#34;所以获取数据并保存事件的代码没有改变,降低了回归错误的风险。

希望能帮到你!