Реализация

Заказ доставки

Продолжу рассказ про то как я спроектировал и начал реализовывать MVP сервиса доставки.

Первую часть читайте тут — Как я спроектировал MVP сервиса доставки. Часть 1. Планирование

В первой части я рассказал про выбор технологий и планирование разработки, во второй части расскажу про принципы которые я использую в коде. Некоторые вопросы возможно будут рассмотрены в отдельных статьях.

На скриншоте — вид MVP сейчас.

Особенности кода

У моего кода есть несколько особенностей.

В течение 6 лет я изучаю принципы чистого кода, правильного выстраивания архитектуры. Изучаю теорию и применяю на практике. Что-то получается, что-то нет.

Со временем выработался стиль кода, который позволяет быстро получить результат и при этом смотреть на код без содрогания.

Часть правил которым я следую общеизвестны и взяты из книг, статей и обсуждений, некоторые я вывел сам.

Принципы:

  • SOLID, чистая архитектура (по Роберту Мартину), чистый код
  • Код неважен
  • Самое важное — скорость изменения кода
  • Простое лучше сложного
  • Абстракция важнее деталей
  • Код используемый вместе должен быть рядом
  • Код изменяемый вместе должен быть рядом

На практике:

  • Группирую функциональность в модулях
  • Стараюсь делать меньше методов, классов, абстракций, держать код компактным
  • Инкапсулирую всё что можно
  • Использую VO (Value Object, шаблон проектирования Объект-Значение)
  • Слежу за тем чтобы не объединять то что объединять не следует

Фреймворк не важен

Многие считают, что для веб-разработки очень важен фреймворк. Что фреймворк определяет качество кода в проекте.

Но я пришёл к выводу, что современные фреймворки, к которым отношу в первую очередь Symfony, Yii и Laravel, все поддерживают любой стиль кода. На любом из них можно написать как красивый так и совершенно ужасный код.

Поэтому, я сосредотачиваюсь на том чтобы делать чистым именно свой код. Я описываю абстракцию такой какой я бы хотел её видеть, а фреймворком пользуюсь для решения второстепенных задач — запись в БД, извлечение данных из запроса и т.п.

Фреймворк вторичен и поэтому мой код не опирается на него. Я стараюсь следовать этому правилу везде.

Благодаря тому что я не “склеиваю” приложение и фреймворк, мой код на любом фреймворке выглядит одинаково. При необходимости я легко могу портировать свой код с приложения на одном фреймворке в приложение на другом фреймворке.

В настоящее время для меня удобнее всего фреймворк Laravel, поэтому новые приложения делаю на нём.

Структура приложения

Дерево проекта

Вот так выглядит приложение. Весь код сгруппирован по модулям.

Я не складываю модели в папке app/Models, а распределяю их по соответствующим модулям. Точно так же поступаю с контроллерами и остальными файлами.

В папке Module/Common я поместил файлы, которые используются по всему проекту. В моём случае это хелпер Json и два класса VO: Money для денег и PhoneNumber для номера телефона.

Контроллер заказов

В контроллере держим только общую логику управления.

Все реальные действия выполняются внутри “сущности” заказа, класса Order.

Таким образом в контроллере мы просто командуем сущностями и выдаём информацию клиенту.

class OrderController extends Controller
{
    public function newOrder(Request $request)
    {
        try {
            $order = Order::create(Customer::takeLogined(), $request->post());
            $order->confirmPayment();
            $order->assignCourier();
        } catch (\Throwable $throwable) {
            return $this->failResponse($throwable->getMessage());
        }

        return $this->successResponse();
    }

    public function viewOrder(int $id)
    {
        try {
            $order = Order::findForCustomerById(Customer::takeLogined(), $id);
        } catch (\Throwable $throwable) {
            return $this->failResponse($throwable->getMessage());
        }

        return $this->successResponse(
            $order->serializeInfo()
        );
    }

    public function cancelOrder(int $id)
    {
        try {
            $order = Order::findForCustomerById(Customer::takeLogined(), $id);
            $order->cancel();
        } catch (\Throwable $throwable) {
            return $this->failResponse($throwable->getMessage());
        }

        return $this->successResponse();
    }

    public function updateOrder(Request $request, int $id)
    {
        try {
            $order = Order::findForCustomerById(Customer::takeLogined(), $id);
            $order->updateFields($request->post());
        } catch (\Throwable $throwable) {
            return $this->failResponse($throwable->getMessage());
        }

        return $this->successResponse();
    }

    private function successResponse(?array $data = null): JsonResponse
    {
        $result = [
            'result' => 'success',
        ];

        if ($data !== null) {
            $result['data'] = $data;
        }

        return response()->json($result);
    }

    private function failResponse(string $error): JsonResponse
    {
        return response()
            ->json([
                       'result' => 'fail',
                       'message' => $error,
                   ], 500);
    }
}

Сущность SmsRequest

Так как код сущности заказа слишком объёмный, чтобы приводить его в статье, рассмотрим пример сущности попроще.

Сущность SmsRequest отвечает за учёт запросов на отправку SMS.

class SmsRequest
{
    private const STATUS_DRAFT = 'draft';
    private const STATUS_SENT = 'sent';
    private const STATUS_FAILED = 'failed';

    private $smsRequest;

    private function __construct(
        SmsRequestModel $smsRequest
    ) {
        $this->smsRequest = $smsRequest;
    }

    public static function create(PhoneNumber $phoneNumber, string $message): self
    {
        $smsRequest = new SmsRequestModel(
            [
                'message' => $message,
                'phone_number' => $phoneNumber->asDbValue(),
                'status' => self::STATUS_DRAFT,
                'error' => '',
            ]
        );

        $smsRequest->saveOrFail();

        return new self($smsRequest);
    }

    public function markAsSent(): void
    {
        $this->setStatus(self::STATUS_SENT);
    }

    public function markAsFailed(string $error): void
    {
        $this->setStatus(self::STATUS_FAILED);
        $this->smsRequest->error = $error;
        $this->smsRequest->saveOrFail();
    }

    private function setStatus(string $status): void
    {
        $this->smsRequest->status = $status;
        $this->smsRequest->saveOrFail();
    }

    public function getModelId(): int
    {
        return $this->smsRequest->id;
    }
}

Сущности

Сущность (Entity) в моём коде — это класс, который описывает некоторую часть данных и логику связанную непосредственно с этими данными.

Выше приведён пример кода такой сущности в классе SmsRequest. Она поддерживает создание записей, смену статуса, запись сообщения об ошибке.

Полагаю что мои “сущности” не совпадают с определениями сущностей из DDD, но лучшего названия я ещё не придумал, поэтому называю так.

С помощью сущностей я управляю данными: создаю, меняю, удаляю, ищу в БД, форматирую и извлекаю данные.

Благодаря тому что весь код по конкретному набору данных сосредоточен в сущности, мне не требуется создавать ни DTO, ни сервисы, ни репозитории. Весь код сосредоточен в одном классе.

Модель SmsRequest

К каждой сущности в моём коде прилагается модель, таким образом сущность реального мира описывается двумя классами.

Я приводил пример сущности SmsRequest, а теперь посмотрим как выглядит соответствующая модель SmsRequest.

Мы наследуем модель от Eloquent Model и перечисляем поля таблицы БД. Всё необходимое и ничего лишнего.

Имея доступ к классу модели, наша сущность может делать любые нужные ей операции с БД.

/**
 * @property int    $id
 * @property string $message
 * @property string $phone_number
 * @property string $status
 * @property string $error
 */
class SmsRequest extends Model
{
    use HasFactory;

    protected $fillable = [
        'message',
        'phone_number',
        'status',
        'error',
    ];
}

Такое описание сущностей через два класса (сущность и модель) не является типичным для проектов на Laravel, поэтому расскажу подробнее как я к этому пришёл.

Модели ORM

Конечно, коду нашего приложения нужно как-то обращаться к данным БД. Фреймворки предоставляют отличные решения для этого: ORM.

В Yii есть ORM “Active Record”, в Laravel есть очень похожая ORM “Eloquent”. В проектах Symfony часто используется ORM “Doctrine”.

Ну хорошо, нужна БД — используй ORM.

Можно настругать табличек в БД, прицепить к ним “модели” Eloquent, использовать их везде в коде, так?

Всё будет работать.

Проблема с моделями ORM

Но тут есть незаметный и подлый подвох.

Если в класс такой “модели” запихнуть всю логику приложения, относящуюся к этим данным, то мы свяжем себя по рукам и ногам.

У нас получится, что таблица в БД жёстко связана с кодом приложения. Мы не сможем свободно менять таблицы без опасений что-то сломать в коде. А значит структура БД станет сопротивляться изменениям и в ней будет накапливаться легаси. Код будет подстраиваться под легаси в БД и со временем будет всё более запутанным, сам превратится в легаси.

Я встречал это много раз: в проекте нельзя прикоснуться ни к одной действительно важной модели, так как они проникли повсюду и не поддаются рефакторингу. Трогать такие “закостеневшие” модели очень опасно.

Разделение логики и БД

Как этого избежать? Мы не должны связывать жёстко таблицы в БД и логику сущностей. Для этого я разделил их на две части: сущность и “модель”.

В сущности (Entity) всё что относится к приложению:

  • Валидация
  • Форматирование при выводе
  • Константы статусов
  • Любая внутренняя логика
  • CRUD операции — создание, чтение, изменение, удаление

В “модели” (Eloquent Model), как в примере выше:

  • только отображение полей таблицы БД

Сущность и модель

Плюсы разделения

1. Легче менять БД и код

Коду приложения не требуется обращаться напрямую к модели Eloquent.

Все обращения в контроллерах и других классах идут сразу к сущности, дальше сущность сама взаимодействует с моделью.

Контроллер, сущность, модель, база данных

Мы изолировали БД от логики приложения.

Следовательно, мы можем менять код не оглядываясь на БД и можем менять БД не оглядываясь на код.

Риск всё сломать уменьшается.

2. Можем поменять хранилище

Так как код приложения изолирован от кода взаимодействуюшего с БД, то мы можем при необходимости поменять хранилище.

Мы получили независимость от хранилища.

Завтра мы для ускорения пожелаем переехать на Redis, мы можем легко это сделать.

Все изменения будут сосредоточены только в сущности, остальной код приложения останется нетронутым.

3. Можем свободно удалять и добавлять таблицы

Благодаря разделению у нас нет жёсткой привязки к таблицам.

Как только нам стала мешать какая-то таблица, мы можем удалить её или разделить на несколько.

При завязке моделей на таблицы сделать это очень тяжело, так как придётся переносить код из модели.

Если же код модели содержит только описание полей, то мы можем очень быстро всё поменять.

4. Более логичное отображение данных

Есть такие таблицы, которые содержат вторичные данные.

Пример — у заказа доставки есть статус заказа и данные статуса для точек маршрута. Посетил курьер эту точку или нет и так далее.

Всё это я описал в виде одного класса сущности OrderStatus и двух классов моделей: OrderStatus и OrderStatusPlace.

При вызове метода сущности OrderStatus::create() создаётся одна запись в таблицу order_statuses и несколько записей в таблицу order_status_places.

Получается, что сущности более точно отражают суть наших данных чем модели.

Почему не наследование

Иногда, осознав что пихать весь код в модель не стоит, разработчики применяют наследование.

Класс с логикой наследуется от класса модели.

/* Модель */
class OrderModel extends Model {...}

/* Сущность */
class OrderEntity extends OrderModel {...}

Это не сильно нас спасёт, так как все публичные свойства и методы родителя доступны в наследнике.

Стоит лишь где-нибудь вызвать “родительский метод”, например $orderEntity->save() и прощай, инкапсуляция.

Получаем обратно все проблемы с повсеместным использованием модели.

Поэтому наследование я не применяю.

Почему не репозиторий

Если мы спросим фаната шаблонов проектирования, он предложит применить какой-нибудь шаблон.

Что здесь подходит? Репозиторий!

Действительно, репозиторий служит для отделения кода приложения от хранилища данных. Его действительно можно здесь применить.

Но есть и минус: применение репозитория добавит ещё одну прослойку и усложнит код.

Поэтому, я считаю что репозиторий лучше не вводить до тех пор, пока не появится необходимость использовать одновременно несколько реализаций хранилища данных.

В моём коде хранилище данных только одно, поэтому я не использую репозиторий.

YAGNI, KISS.

Почему не Doctrine

Doctrine ORM популярен среди поклонников DDD и часто используется в проектах на Symfony. Пакет Doctrine можно подключить в любой фреймворк, в том числе и Laravel.

Сущности Doctrine очень гибки и позволяют очень тонко настроить отображение данных на БД.

Можно было бы привязать классы сущностей через Doctrine и не пришлось бы создавать классы моделей. И кода стало бы меньше.

Но всё же я не стал использовать Doctrine для разделения кода, а обошёлся обычным Eloquent в сочетании со своими классами.

Причины:

  • Сущности Doctrine должны следовать правилам, а мне не хочется ограничивать свой код
  • Зависимость от Doctrine может доставить проблем в будущем
  • Я не смогу отказаться от БД в пользу других хранилищ, например Redis или файлов

Поэтому решил не завязываться на Doctrine, а просто сделать свои собственные классы и внутри подключить Eloquent.

Отказаться от Eloquent я могу в любой момент, так как “снаружи” при этом вообще ничего не поменяется.