На этот раз речь пойдет о бизнес-логике модуля. Это, пожалуй, самая важная часть. Это то, без чего смысла в модуле вообще никакого нет. Бизнес-логика должна решать реальную задачу, что в свою очередь нацелено на извлечение прибыли. Ей и займемся, но сначала ….
Инсталлятор
Инсталлятор, черт побери! Я уже писал ранее, что к нему скорее всего придется возвращаться время от времени. И вот время снова пришло 🙂 Я тут в процессе изысканий обнаружил очередное неочевидное поведение в ядре (почему меня это не удивляет?). При описании ORM сущности мы должны создать метод getMap, который должен вернуть массив с описанием всех полей таблицы сущности. В моем модуле используется две сущности — DiscountCoupon и Process. Последняя сущность содержит поля с датами, одно из которых, в соответствии с описанием, должно по дефолту иметь значение null. Когда я задаю сущности дефолтное значение null, я ожидаю, что ORM сгенерирует мне таблицу с колонкой, в которую можно записать NULL’able значения. И каково было моё удивление, когда я попытался создать с помощью ORM запись в этой таблице со значением NULL для этого поля, но ORM вывалила мне Exception.
А дело вот в чем. Для создания таблицы в d7 используется метод \Bitrix\Main\DB\Connection::createTable. Этот метод на основании массива полей сущности создает таблицу. Если заглянуть в исходники, то можно увидеть, что там захардкодили запрет на создание NULL’able значений для всех полей. Возможно в будущем это изменится, поэтому вставлю скрином:
Если кто-то знает причины, которыми руководствовались разработчики при написании этого кода — подскажите. Причем тут этот комментарий в коде — я тоже не понял. На звание гуру в теории реляционных СУБД я ну никак не претендую, да и с mysql изнутри тоже знаком не особо. Чем тут может помешать наличие NULL значений? Не понятно ..
Если посмотреть тот же метод в реализации для других СУБД, то для MSSQL наблюдается аналогичная картина, а вот для Oracle выбор между NULL и NOT NULL осуществляется в зависимости от того, является ли поле первичным ключом. В общем — мне не понятно. Если кто-то может истолковать — будьте добры, в комментах.
В связи с этой ерундистикой пришлось изящный createTable заменить на прямой запрос CREATE TABLE … Ну да ладно …
Коллекции
Для того, чтобы генерировать какие-то последовательности символов, нам нужно определить наборы символов, которые будут использоваться в будущем для генерации. На этапе создания ТЗ я описал несколько разных классов последовательностей. Был определен базовый интерфейс SymbolsCollectionInterface, реализовать который должны все коллекции. Есть еще дополнительный интерфейс LettersCollectionInterface, который описывает коллекцию букв, необходимость которого заключается в том, чтобы отличать буквы от других последовательностей, а также иметь возможность работать с регистром букв. В итоге были реализованы коллекции для чисел, букв русского и английского алфавита, спец-символов, а также специальная коллекция, в которую можно собрать несколько других коллекций. Вся иерархия реализованных коллекций может быть представлена на диаграмме в следующем виде:
Реализация этих классов довольно тривиальна.
Шаблон последовательности
Помимо коллекций символов нам понадобится шаблон. Можно было бы пойти по более простому пути — указать в настройках количество купонов, количество символов в каждом купоне и формировать все купоны на основании этой настройки, но эта штука не является достаточно гибкой. Поэтому я решил сразу ввести понятие шаблона. В будущем эту концепцию можно будет развить (и у меня уже есть планы), но на старте этого будет более чем достаточно.
Шаблон купона будет представлять из себя набор символов, где часть символов является статическими, а часть — генерируемыми. Если в шаблоне будет присутствовать какой-то заранее предопределенный символ, являющийся генерируемым, то он будет заменен на рандомный символ из ассоциированной этому символу коллекции. В остальном же это обычная строка.
Класс для составления шаблона принимает в конструкторе строку, а также предоставляет интерфейс, который позволяет добавить к шаблону соответствие между символом и коллекцией. Это позволит составлять шаблоны программно на лету.
Структура классов и реализацию можно посмотреть в неймспейсе \Maximaster\Coupanda\Generator\Template
Генератор последовательностей
Задача генератора сводится к тому, чтобы сформировать одну или несколько последовательностей на основании настроек шаблона. При генерации необходимо предусмотреть обработку уникальных и не уникальных значений. Если шаблон содержит всего 3 генерируемых цифры, то количество последовательностей не будет превышать 1000 штук (основная формула комбинаторики — 10*10*10). И если генератору дать задание генерить более 1000 значений, генератор должен адекватно реагировать на это.
Но тут встает другая проблема — если ввести достаточно длинный шаблон, то количество комбинаций запросто перевалит за PHP_INT_MAX. К счастью, php легко справится с достаточно большими числами, просто переведя количество во float. Но генератор должен давать возможность генерировать уникальные значения в рамках одного хита, а поэтому все сгенерированные купоны придется хранить в оперативке в виде хеш таблицы (читай — массива). Но на этапе запуска нам не нужно особо думать о том, чтобы давать возможность в рамках одного хита формировать огромные количества купонов. Поэтому просто ограничим максимальное количество последовательностей, сгенерированных за одно обращение к генератору каким-то достаточно большим числом.
На самом деле, проблем тут куда больше, и если копнуть достаточно глубоко, то можно ооочень сильно и ооочень надолго закопаться. Но мы просто не будем об этом думать. Вероятность, что мы наткнемся на проблемы — слишком мала (хоть по закону Мёрфи, это когда-нибудь и случится). Но я пока решил этим всем пренебречь и просто ввести ограничения.
Генератор купонов
На основании генератора последовательностей уже можно сделать и генератор купонов. Задача генератора купонов — обратиться к генератору последовательностей за набором последовательностей и добавить их в БД, а также проконтролировать уникальность добавляемых в БД записей. Все это нужно делать на основании настроек, которые указал пользователь в форме. Класс совсем простой и маленький получается.
Для общения с таблицей купонов у нас есть ORM сущность DiscountCoupon, которая наследуется от битриксовой одноименной сущности.
Процесс
На прошлых этапах я выделил 3 сущности, которые будут описывать сам процесс и все вспомогательные для него данные — ProcessSettings, ProcessProgress и ProcessReport. Начав реализовывать первый концепт, я сделал работу с ProcessProgress с помощью сессии, а ProcessSettings являлся одним из полей ProcessProgress. Однако немного поработав с этим стало понятно, что будет удобнее в качестве хранилища для прогресса использовать СУБД, а сама сущность Process, которую я уже заранее определил, по сути и является этим самым хранилищем для прогресса. В связи с этим я просто избавился от ProcessProgress в пользу класса Process, о котором чуть подробнее.
Процесс в понятиях моего модуля — это как некая книга учета событий. Если нам нужно запланировать какое-то событие, то мы создаем запись в календаре, что нам нужно начать его 15го числа такого-то месяца в 12 часов дня. Событию сопутствуют некоторые параметры — в моем случае это параметры генерации купона, но могут быть и другие параметры. Кроме этого событие имеет в себе флаг состояния (в моем случае — количество обработанных процессом купонов).
Каждый новый процесс создается в тот момент, когда администратор модуля нажимает кнопку «Начать генерацию». В БД заводится запись о новом процессе, которая хранит в себе все заданные пользователем настройки. А компонент, который работает с этим процессом, обращается к БД и обновляет процесс в БД по мере его выполнения.
Сам по себе процесс и является неким отчетом — он содержит данные о начале и окончании процесса, а также о количестве обработанных купонов.
Для удобства работы с процессом нужно представить его в виде класса. Однако класс этот связан с БД напрямую, значит я должен использовать ORM сущность. Но использовать ORM сущности неудобно в битриксе для хранения бизнес-логики из-за их статической природы и связи с БД, поэтому я решил создать прослойку, которая позволит более-менее удобно обращаться к данным из БД.
Репозиторий процессов
Смысл в том, что работать с массивами менее удобно, чем с объектами. Поэтому нужен какой-то слой, который будет преобразовывать массивы с описанием процессов в объекты процессов.
Кроме этого, перед преобразованием в объекты, данные нужно выбрать из БД. Возложим это все на наш «репозиторий», который будет являться реализацией паттерна DataMapper.
Задачи репозитория процессов сводятся к тому, чтобы:
- искать сущности в БД
- преобразовывать ORM сущности в объекты процессов (и отдавать их после поиска), и наоборот
- сохранять объекты процессов в БД
Класс репозитория тоже получается довольно простым:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
<?php namespace Maximaster\Coupanda\Process; use Bitrix\Main\DB\DbException; use Bitrix\Main\Entity\Query; use Maximaster\Coupanda\Orm\ProcessTable; class ProcessRepository { /** * @param Query $query * @return Process[] */ public static function find(Query $query) { $result = $query->exec(); $processList = []; while ($process = $result->fetch()) { $processList[] = Process::fromState($process); } return $processList; } /** * @param Query $query * @return Process */ public static function findOne(Query $query) { $query->setLimit(1); return static::find($query)[0]; } public static function findById($id) { return static::find(static::getByIdQuery($id)); } public static function findOneById($id) { return static::findOne(static::getByIdQuery($id)); } protected static function getByIdQuery($id) { return ProcessTable::query()->addFilter('ID', $id)->setSelect(['*']); } public static function save(Process $process) { $processData = Process::toState($process); $result = $process->getId() > 0 ? ProcessTable::update($process->getId(), $processData) : ProcessTable::add($processData); if (!$result->isSuccess()) { throw new DbException(implode('. ', $result->getErrorMessages())); } if (!$process->getId()) { $process->setId($result->getId()); } return $result; } } |
Класс процесса при этом будет содержать геттеры и сеттеры для каждого из полей ORM сущности и представляет собой простейший объект, который знать ничего не знает о СУБД и просто хранит свои поля в оперативке без обратной связи, а когда нам нужно сохранить данные этого объекта в постоянное хранилище, то просто отдаем его репозиторию, он сделает сам всё, что нужно. Класс процесса тоже очень простой — набор геттеров и сеттеров плюс пара методов для отслеживания прогресса выполнения процесса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
<?php namespace Maximaster\Coupanda\Process; use Bitrix\Main\Type\DateTime; class Process { /** @var int */ protected $id; /** @var DateTime */ protected $startedAt; /** @var DateTime */ protected $finishedAt; /** @var int */ protected $processedCount = 0; /** @var ProcessSettings */ protected $settings; public static function fromState(array $state) { $instance = new static(); isset($state['ID']) && $instance->setId($state['ID']); isset($state['STARTED_AT']) && $instance->setStartedAt($state['STARTED_AT']); isset($state['FINISHED_AT']) && $instance->setFinishedAt($state['FINISHED_AT']); isset($state['PROCESSED_COUNT']) && $instance->setProcessedCount($state['PROCESSED_COUNT']); isset($state['SETTINGS']) && $instance->setSettings($state['SETTINGS']); return $instance; } public static function toState(Process $process) { return [ 'ID' => $process->getId(), 'STARTED_AT' => $process->getStartedAt(), 'FINISHED_AT' => $process->getFinishedAt(), 'PROCESSED_COUNT' => $process->getProcessedCount(), 'SETTINGS' => $process->getSettings(), ]; } /** * @return int */ public function getId() { return $this->id; } /** * @param int $id */ public function setId($id) { $this->id = (int)$id; } /** * @return DateTime */ public function getStartedAt() { return $this->startedAt; } /** * @param DateTime $startedAt */ public function setStartedAt(DateTime $startedAt) { $this->startedAt = $startedAt; } /** * @return DateTime */ public function getFinishedAt() { return $this->finishedAt; } /** * @param DateTime $finishedAt */ public function setFinishedAt(DateTime $finishedAt = null) { $this->finishedAt = $finishedAt; } /** * @return int */ public function getProcessedCount() { return $this->processedCount; } /** * @param int $processedCount */ public function setProcessedCount($processedCount) { $this->processedCount = (int)$processedCount; } /** * @return ProcessSettings */ public function getSettings() { return $this->settings; } /** * @param ProcessSettings $settings */ public function setSettings(ProcessSettings $settings) { $this->settings = $settings; } public function getProgressPercentage() { $percentage = $this->getProcessedCount() * 100 / $this->getSettings()->getCount(); return $percentage; } public function isInProgress() { return $this->getSettings()->getCount() > $this->getProcessedCount(); } public function incrementProcessedCount($count = 1) { $this->setProcessedCount( $this->getProcessedCount() + $count ); } } |
Настройки процесса — это дополнительный, пока не особо структурированный объект, поэтому он хранится в БД в виде сериализованной строки (если у вас нет на то реальных причин — не делайте так в РСУБД!).
Ну и класс ProcessReport я удалил пока за ненадобностью. Возможно в будущем еще вернусь к нему, если мне понадобятся расширенные отчеты.
Таким образом, удалось уменьшить количество кода и разделить обязанности между классами, увеличив читабельность и удобство в поддержке.
Компонент
Для того, чтобы воплотить все наработки в жизнь, нужно лишь в компонент генератора добавить обращение к разработанным классам. В обработчике действия generation_start — создаем новый процесс, сохраняем туда дату начала и настройки.
В generation_process готовим инстанс шаблона, созданный из настроек, готовим генератор последовательностей на основе этого шаблона и достаем инстанс процесса, который был создан на предыдущем шаге. Все это скармливаем в CouponGenerator и в цикле генерируем по несколько купонов до достижения лимита.
В generation_finish сохраняем процесс в БД с датой окончания.
Естественно все это сопровождается валидацией и обработкой ошибок.
На фронтенде теперь появляется необходимость хранить идентификатор процесса, в связи с чем появляются накладные расходы на его обработку и передачу в ajax запросы, но это все мелочи.
Превью
Ну и осталось сделать только функцию превью. Тут достаточно обратиться к генератору последовательностей и сформировать несколько последовательностей (можно даже за уникальностью не следить) на основании заданного шаблона купона. Кода вообще минимум. Результат генерации выводится на фронтенде в отдельное всплывающее окно.
Выводы
На данный момент у нас уже есть полностью работоспособная версия модуля, которая уже может быть установлена и использована на проектах с кодировкой utf-8. На 100% уверен, что данная версия не является стабильной, и содержит набор багов. Но это еще совсем не конец разработки, еще много работы — это и управление правами доступа, логирование, локализация, документация, тестирование и исправление косяков. Кроме этого нужно еще разработать какой-то инструментарий для автоматической сборки обновлений в маркетплейс и запуска автотестов, сборки документации. А еще надо логотип нарисовать:) Так что впереди еще много интересного, следите за обновлениями в RSS и подписывайтесь на email рассылку! Исходники, как обычно, на гитхабе.