Как агрегация решает проблемы перерасчёта полей в Битрикс24
Всем привет! На связи Владимир Колеснев из ИТ-команды подразделения ДОМ.РФ Земли. Мы занимаемся автоматизацией вовлечения в оборот неиспользуемых или используемых не по назначению федеральных земельных участков и объектов недвижимости. Разрабатываемый нами продукт – это система на базе Битрикс24, в которой земельные участки проходят долгий и всегда разный путь от появления в системе до реализации на торгах и последующего мониторинга.
В процессе этого пути чего только ни происходит: начиная с того, что появляется множество различных сущностей, и заканчивая тем, что свойства земельных участков, да и других сущностей могут меняться при перерасчёте из-за зависимостей друг от друга. Про перерасчёт свойств (полей) мы и хотим поговорить.
В этой статье мы расскажем простую историю о механизме агрегаций. Так мы назвали механизм, который отслеживает триггеры на новый расчёт полей. Расскажем, какие проблемы были, и как мы эти проблемы решали и решили.
Если вы строите какую‑либо систему, которая работает с множеством сущностей с кучей бизнес‑процессов, ворочащими эти сущности в разных направлениях с разными вводными и бесконечно растущей бизнес‑логикой, то рано или поздно вы столкнётесь с проблемами того, что свойства сущностей каким‑то образом должны менять свои значения в зависимости от других свойств.
Проблемы
Однажды перед нами встала задача реализации автоматического заполнения свойств инфоблока земельных участков. Свойства в расчётах должны были зависеть от других полей и других связанных сущностей.
Изначально мы реализовали это просто - через события: при добавлении/изменении элемента или свойств мы запускали перерасчёт. Довольно быстро свойств стало гораздо больше, бизнес логика - сложнее, а объемы данных значительно увеличились. И в результате этого мы столкнулись со следующими проблемами:
1. Права доступа (у пользователя нет прав на редактирование полей, но он значится в истории)
Первая проблема возникла с быстрым ростом бизнес-логики расчётов. При изменении даже одного свойства в сущности срабатывала куча триггеров. Например, пользователь поменял название, и в моменте запускается перерасчёт «миллиона» полей. В истории изменений остаётся ложная информация о том, что этот пользователь изменил все эти поля.
2. Производительность
На такие операции со временем стала тратиться уйма ресурсов, ведь изначально поля были простые, их перерасчёт был нетрудозатратен, но в будущем появились поля со сложной логикой, которые усложнили процесс.
3. Консистентность
Ещё одной проблемой стало то, что поля не пересчитывались при изменении поля у связанной сущности, потому что не было механизма для возможности связать сущности в классах.
4. Разные типы сущностей (инфоблоки и CRM)
Еще одна проблема появилась из того, что после реализации перерасчётов одной сущности аналогичное потребовалось и для других. Но из-за того, что земельные участки у нас - это инфоблок, а другие сущности могли быть CRM (сделки, лиды, контакты, компании), события и реализации триггеров отличались бы от реализаций прошлых, что привело бы к необоснованному разрастанию кода и ещё большей путанице.
Пришло время кодить, и было принято решение о создании чего-то универсального: удобного механизма, способного разрешить появившиеся проблемы.
Идея
Идея была в том, что новый механизм при изменении сущности будет отслеживать, что изменилось и, исходя из этого, будет знать, что нужно агрегировать.
Реализация
Сейчас мы опишем реализацию в целом, а после уточним некоторые моменты.
Мы создали AggregationManager – класс-менеджер, синглтон, который управляет агрегациями, AggregatableEntity – абстрактный класс для агрегируемых сущностей, AggregatableEntityField – абстрактный класс для полей агрегируемых сущностей. Также мы создали отдельную очередь с воркером.
Внутри менеджера мы подписываемся на события изменения/добавления/удаления элементов и свойств, как и раньше, но теперь мы используем события «до изменения элемента» и «события после изменения элемента» в паре.
В методе, который обрабатывает событие «до изменения элемента» мы получаем тип (например, инфоблок или сделка crm) и идентификатор элемента, производим сохранение в статику исходных данных элемента (если их там ещё нет) и осуществляем поиск всех связанных сущностей. Поиск связанных сущностей до изменения нужен, например, если у земельного участка есть свойство «Документ» и если меняется значение этого свойства, то триггер в последующем нужно будет вызвать как и для старого значения (документа), так и для нового.
В методе, который обрабатывает событие «после изменения элемента», мы получаем список изменённых полей и их новые значения, также записываем их в статику и повторно осуществляем поиск связанных сущностей, так как при изменении поля могли и измениться сущности.
Далее в деструкторе класса менеджера мы вызываем метод, который проверяет действительно ли изменились поля, сравнивая данные элемента до и после. Деструктор класса используются с той целью, что в момент его вызова все события в системе уже точно отработают.
Получив эти данные и зная тип и идентификатор сущности, мы создаем новую очередь. В массив данных для неё мы добавляем такие поля, как
entity – тип связанной сущности
entity_id – идентификатор связанной
data – это поля до/после изменения.
На скриншоте (рис.1) выше видно, что сообщение в очереди агрегаций создаётся для каждого элемента связанной сущностей. В конкретном примере мы изменили поле «Приоритет» у сущности сделки (тип CRM_DEAL).
При разборе очереди из entity_type и entity_id мы получаем класс сущности, наследуемый от AggregatableEntity. Далее получаем поля сущности, которые могут агрегироваться и помещаем их в коллекцию (рис.2). Агрегируемые поля мы наследуем от AggregatableEntityField и кладём в определённые namespace (рис.3).
/**
* @return AggregatableEntityFieldCollection
*/
public function getFieldCollection(): AggregatableEntityFieldCollection
{
if ($this->fieldCollection === null) {
$this->fieldCollection = new AggregatableEntityFieldCollection();
$baseClass = new \ReflectionClass(static::class);
$filePath = dirname($baseClass->getFileName()) . "/Field/" . str_replace(".php", "", basename($baseClass->getFileName()));
$namespace = $baseClass->getNamespaceName() . "\\Field\\" . str_replace(".php", "", basename($baseClass->getFileName()));
$arFieldFiles = scandir($filePath);
foreach ($arFieldFiles as $fieldFile) {
if (strpos($fieldFile, ".php") !== false) {
$className = $namespace . "\\" . str_replace(".php", "", $fieldFile);
if (class_exists($className)) {
$object = new \ReflectionClass($className);
if(!$object->isSubclassOf("\Local\Portal\Business\Aggregation\AggregatableEntityField")){
continue;
}
/** @var $obField \Local\Portal\Business\Aggregation\AggregatableEntityField */
$obField = new $className();
$obField->setEntity($this);
$this->fieldCollection->add($obField);
}
}
}
}
return $this->fieldCollection;
}
рис. 2. Получение агрегируемых полей
Далее мы проходим по коллекции полей и выясняем, нужно ли обновление текущего поля путем того, что заходим в метод getAggregationCondition() (рис.4) и проверяем, есть ли в триггерах наше изменённое поле.
/**
* @inheritDoc
*/
public function getAggregationCondition(): \Local\Portal\Business\Aggregation\ConditionCollection
{
return new \Local\Portal\Business\Aggregation\ConditionCollection([
new \Local\Portal\Business\Aggregation\Condition("CRM_DEAL", "UF_CADASTER_FACT"),
new \Local\Portal\Business\Aggregation\Condition("CRM_DEAL", "PRODUCT_ROWS"),
new \Local\Portal\Business\Aggregation\Condition(EventObjectsRealtyTable::getIblockId(), "ACTIVE"),
new \Local\Portal\Business\Aggregation\Condition(EventObjectsRealtyTable::getIblockId(), "PROPERTY_PRODUCT"),
new \Local\Portal\Business\Aggregation\Condition(EventObjectsRealtyTable::getIblockId(), "ACTIVE_FROM"),
new \Local\Portal\Business\Aggregation\Condition(EventObjectsRealtyTable::getIblockId(), "PROPERTY_EVENT_TYPE"),
]);
}
рис. 4. Получение коллекции триггеров
После этого, если поле должно измениться, получаем новое значение из метода getAggregationValue() и переписываем данные в базе.
Как работают триггеры, и как происходит рекурсия их добавления
Коллекция триггеров создаётся внутри каждого класса агрегируемого поля, наследуемого от AggregatableEntityField. Внутри менеджера при проверке реальных изменений полей в деструкторе (это описано выше) и регистрации этих изменений у сущности мы получаем триггеры для каждого поля связанной сущности.
Цепочки триггеров рекурсивные, потому что при срабатывании триггера для свойства в случае, если свойство переписывается, это же свойство само становится триггером. Рекурсия на рис. 5.
/**
* Регистрация изменения связанной сущности
*
* @param $entityTypeId
* @param $arFields
* @param bool $recursive
*/
public function registerReferenceChange($entityTypeId, $arFields, $recursive = true)
{
foreach ($arFields as $fieldCode => $value) {
if ($fieldCode == "PROPERTY_VALUES") {
foreach ($value as $propertyCode => $propertyValue) {
$fieldCodeProperty = "PROPERTY_{$propertyCode}";
if (!$this->hasReferenceChange(new Condition($entityTypeId, $fieldCodeProperty))) {
$this->registeredChanges["{$entityTypeId}#" . ToUpper($fieldCodeProperty)] = $propertyValue;
}
}
}
else {
$this->registeredChanges["{$entityTypeId}#" . ToUpper($fieldCode)] = $value;
}
}
if ($recursive) {
do {
$foundNew = false;
foreach ($this->values as $obField) {
/** @var $obField AggregatableEntityField */
if (!$this->hasReferenceChange(new Condition($obField->getEntity()::getEntityType(), $obField->getCode()))) {
// TODO getAggregatedValue найти другой способ, чтобы минимизировать вызов этого метода
if ($obField->isNeedUpdateAggregation() && $obField->getAggregatedValue() !== null) {
$foundNew = true;
$this->registeredChanges["{$obField->getEntity()::getEntityType()}#" . ToUpper($obField->getCode())] = null;
}
}
}
}
while ($foundNew == true);
}
}
рис. 5. Рекурсия
Как ищутся связанные сущности
Внутри менеджера в обработчиках событий before/after (рис. 6) при поиске связанных сущностей мы проходим по всем агрегируемым сущностям. Агрегируемые сущности мы получаем из метода getAggregatableEntities(), из которого возвращаем массив всех сущностей, которые могут участвовать в агрегации (рис. 7).
В каждой агрегируемой сущности заходим в метод findEntityByReference() (рис. 8), в который передаём тип и идентификатор изначально изменяемой сущности.
Внутри менеджера в обработчиках событий before/after (рис. 6) при поиске связанных сущностей мы проходим по всем агрегируемым сущностям. Агрегируемые сущности мы получаем из метода getAggregatableEntities(), из которого возвращаем массив всех сущностей, которые могут участвовать в агрегации (рис. 7).
В каждой агрегируемой сущности заходим в метод findEntityByReference() (рис. 8), в который передаём тип и идентификатор изначально изменяемой сущности.
public static function OnIBlockElementSetPropertyValuesEx($elementId, $iblockId, $arPropertyValues, $arFlags = [])
{
self::getInstance()->incrementEventDepth($iblockId, $elementId);
self::getInstance()->findAggregatableObject($iblockId, $elementId);
}
public static function OnAfterIBlockElementSetPropertyValuesEx($elementId, $iblockId, $arPropertyValues, $arFlags = [])
{
self::getInstance()->decrementEventDepth($iblockId, $elementId);
self::getInstance()->findAggregatableObject($iblockId, $elementId, true);
self::getInstance()->storageReferenceDataAfterSave($iblockId, $elementId, ["PROPERTY_VALUES" => $arPropertyValues], self::OPERATION_TYPE_UPDATE);
}
рис. 6. Обработчики событий
/**
* Правила аггреации сущеностей
* @return string[\Local\Portal\Business\Aggregation\AggregatableEntity]
*/
public static function getAggregatableEntities(): array
{
return [
\Local\Portal\Business\Aggregation\Crm\Deal::class,
\Local\Portal\Business\Aggregation\Crm\Lead::class,
\Local\Portal\Business\Aggregation\Crm\Company::class,
\Local\Portal\Business\Aggregation\Iblock\Contract::class,
\Local\Portal\Business\Aggregation\Iblock\Security::class,
\Local\Portal\Business\Aggregation\Iblock\AprovedPP::class,
рис. 7. Агрегируемые сущности
/**
* Определяет список объектов, которые необходимо будет агрегировать и сохраняет текущее состояние (поля)
*
* @param string|int $entityType Код сущности
* @param int $entityId Ид сущности
* @param bool $force Принудительно ещё раз определеить
*/
protected function findAggregatableObject($entityType, int $entityId, $force = false): void
{
if (!self::isEnable()) {
return;
}
$referenceEntity = $this->referenceEntities->find($entityType, $entityId);
if ($referenceEntity === null || $force) {
foreach (self::getAggregatableEntities() as $entity) {
if (is_subclass_of($entity, AggregatableEntity::class)) {
$foundAggregatableEntities = $entity::findEntityByReference($entityType, $entityId);
if(!self::isWorker() && $entityType == $entity::getEntityType()){
$foundAggregatableEntities->add((new $entity())->setEntityId($entityId));
}
if ($foundAggregatableEntities->count() > 0) {
if ($referenceEntity === null) {
$referenceEntity = (new ReferenceEntity)
->setEntityType($entityType)
->setEntityId($entityId)
->storageSourceData();
}
foreach ($foundAggregatableEntities as $aggregatableEntity) {
$referenceEntity->getAggregatableEntities()->add($aggregatableEntity);
}
$this->referenceEntities->add($referenceEntity);
$this->currentStack++;
}
}
}
}
}
рис. 8. Поиск связанных сущностей
В этом методе в зависимости от того, какой тип сущности меняется, получаем связанные сущности, добавляем их в коллекцию и возвращаем (рис. 9).
/**
* @inheritDoc
*/
public static function findEntityByReference($entityType, $entityId): AggregatableEntityCollection
{
$result = new AggregatableEntityCollection();
if ($entityType == \Local\Portal\Business\Iblock\Element\Contract::getIblockId()) {
$rsItems = OksTable::getList([
"filter" => [
"ACTIVE" => "Y",
"PROPERTY_CONTRACT.VALUE" => $entityId,
]
]);
while ($arItem = $rsItems->fetch()) {
$result->add((new self())->setEntityId($arItem["ID"]));
}
}
elseif ($entityType == \Local\Portal\Business\Iblock\Element\InputHouse::getIblockId()) {
$rsItems = OksTable::getList([
"filter" => [
"ACTIVE" => "Y",
"PROPERTY_APPROVEMENT.VALUE" => $entityId,
]
]);
while ($arItem = $rsItems->fetch()) {
$result->add((new self())->setEntityId($arItem["ID"]));
}
}
elseif ...
рис. 9. Поиск связанных сущностей
Зачем в очередь агрегации добавляются PREVIOUS и CURRENT значения
Такой подход решает сразу две проблемы:
Агрегация выполняется не в моменте, и в очереди могут быть две записи для одного и того же элемента. Для того чтобы сохранить последовательность, используются эти значения.
К тому же благодаря этому в агрегациях мы можем получать текущие и предыдущие значения полей. Например, если при переходе лота из одного направления в другое нужно выполнить какую-то бизнес-логику. В таком случае мы знаем, из какого направления переносится лот.
Как можно делать перерасчеты полей
Внутри каждого класса агрегируемого поля, наследуемого от AggregatableEntityField есть метод, который возвращает новое значение поля. Внутри него мы можем:
Получать значения любых полей данной сущности через $this->getEntity()->getCurrentValue(“PROPERTY_...”) и после применять их в расчётах свойства. Если поле агрегационное, то вернется уже актуальное пересчитанное значение.
Получать значения любых полей до изменений через $this->getEntity()->getPreviousValue(“PROPERTY_...”)
Исключения из правил при расчете
В процессе эксплуатации мы поняли, что в расчете полей иногда могут быть исключения и бизнесу необходимо скорректировать значения, сформированные автоматически.
Для этого мы реализовали возможность задания значения поля вручную. Для этого в класс поля имплементируем интерфейс ManualFixableField. Здесь нет ничего сложного, проверяем класс на этот интерфейс для того, чтобы не перезаписывать значение поля агрегирующим значением, если было ручное изменение. Для хранения ручной фиксации агрегации мы создали таблицу с полями id, entity (сущность), entity_id (id сущности), field (поле).
Пример практического применения агрегации
Например, в каком-то случайном инфоблоке контрактов есть поле NAME, в котором должно быть выводимое название контракта. Причём это поле должно состоять из номера документа (свойство DOCUMENT_NUMBER) и даты контракта (свойство CONTRACT_DATE), но к тому же должно иметь возможность заполняться вручную.
Такая задача легко решится агрегацией. Создаём класс поля Name внутри namespace …/Aggregation/Iblock/Field/Contract, наследуем класс от AggregatableEntityField, реализуем три метода и… всё готово!
<?php
namespace Local\Portal\Business\Aggregation\Iblock\Field\Contract;
use Local\Portal\Business\Aggregation\ManualFixableField;
use Local\Portal\Business\Iblock\Element\Internal\ContractTable;
/**
* Название
* Class Name
* @package Local\Portal\Business\Aggregation\Iblock\Field\Contract
*/
class Name extends \Local\Portal\Business\Aggregation\AggregatableEntityField
implements ManualFixableField
{
/**
* @inheritDoc
*/
public function getCode(): string
{
return "NAME";
}
/**
* @inheritDoc
*/
public function getAggregationCondition(): \Local\Portal\Business\Aggregation\ConditionCollection
{
return new \Local\Portal\Business\Aggregation\ConditionCollection([
new \Local\Portal\Business\Aggregation\Condition(ContractTable::getIblockId(), "NAME"),
new \Local\Portal\Business\Aggregation\Condition(ContractTable::getIblockId(), "PROPERTY_DOCUMENT_NUMBER"),
new \Local\Portal\Business\Aggregation\Condition(ContractTable::getIblockId(), "PROPERTY_CONTRACT_DATE"),
]);
}
/**
* Автоматическое. ({Номер документа-основания} от {Дата документа-основания})
*/
public function getAggregatedValue()
{
$number = $this->getEntity()->getCurrentValue("PROPERTY_DOCUMENT_NUMBER");
$date = $this->getEntity()->getCurrentValue("PROPERTY_CONTRACT_DATE");
if (!empty($date) && !$date instanceof \Bitrix\Main\Type\Date) {
$date = \Bitrix\Main\Type\Date::createFromTimestamp(strtotime($date));
}
if(empty($date))
{
return "";
}
$date = $date->format("d.m.Y");
return "{$number} от {$date}";
}
}
рис. 10. Пример агрегации поля NAME
Итоги
В итоге мы реализовали механизм, который решил все наши проблемы, связанные с автоматическим обновлением полей в сущностях. Теперь, когда в разработку приходит задача на новую бизнес-логику на значения полей, которые вычисляются из значений других полей, нам достаточно создать класс поля, определить триггеры перерасчёта и реализовать сам расчёт значения.
Такой подход сэкономил нам время и нервы, код стал организованнее. Хотим заметить, что решение можно использовать не только для агрегаций, но и для системы нотификаций.
В следующий раз мы расскажем о том, как мы пошли дальше и создали отдельного наблюдателя за изменением свойств - Observer. Это, можно сказать, отдельная низкоуровневая штука, в которой мы отслеживаем все события до/после и всегда знаем, что было до и что стало после вне зависимости от того, сколько раз мы меняем значения внутри множества обработчиков событий. Но это уже совсем другая история.