На этот раз речь пойдет о бизнес-логике модуля. Это, пожалуй, самая важная часть. Это то, без чего смысла в модуле вообще никакого нет. Бизнес-логика должна решать реальную задачу, что в свою очередь нацелено на извлечение прибыли. Ей и займемся, но сначала ….

Инсталлятор

Инсталлятор, черт побери! Я уже писал ранее, что к нему скорее всего придется возвращаться время от времени. И вот время снова пришло 🙂 Я тут в процессе изысканий обнаружил очередное неочевидное поведение в ядре (почему меня это не удивляет?). При описании 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 сущности в объекты процессов (и отдавать их после поиска), и наоборот
  • сохранять объекты процессов в БД

Класс репозитория тоже получается довольно простым:

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 сущности и представляет собой простейший объект, который знать ничего не знает о СУБД и просто хранит свои поля в оперативке без обратной связи, а когда нам нужно сохранить данные этого объекта в постоянное хранилище, то просто отдаем его репозиторию, он сделает сам всё, что нужно. Класс процесса тоже очень простой - набор геттеров и сеттеров плюс пара методов для отслеживания прогресса выполнения процесса:

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 рассылку! Исходники, как обычно, на гитхабе.

К следующей статье цикла.