Александр Карпов
388 subscribers
26 photos
1 video
39 links
Блог PHP разработчика для шаринга личного опыта с друзьями, коллегами и всеми желающими.

Не стесняйтесь задавать вопросы в комментариях к постам, или в личку: @slayervc (UTC +8).
Download Telegram
🎉 Старт нашего LevelUp Club превзошёл все мои ожидания!

Участники не только предложили дополнения к дереву навыков, но и проявили сразу несколько инициатив. 💪 Мне оставалось лишь успевать задавать форму этому потоку.))

В итоге, в Клубе образовалось сразу два пространства.

📚 Библиотека, как более лайтовый формат, где планируется раз в 1-2 недели выбирать навык, по которому я буду готовить небольшое занятие для его прокачки.

⚒️ Кузница, как более вовлекающий и жесткий формат. В Кузнице мы планируем пилить MVP платформы для нашего клуба с аутентификацией и трекингом прогресса по навыкам. Всё, что происходит в Кузнице, тоже строится вокруг обучения и практики.

Двое участников уже заработали первые ачивки Мастера-кузнеца и Вестника-первопроходца. 🙂 А в качестве первого навыка на прокачку Вестник выбрал рефлексию.

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

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

#LevelUp
Please open Telegram to view this post
VIEW IN TELEGRAM
👻3🔥2
Базовые принципы CQRS, как спасение от Legacy

Чем старше кодовая база и чем ниже ее качество, тем дороже становится написание нового функционала. Одним из отличных инструментов преодоления этой проблемы является применение базовых принципов CQRS.

📖 Немного теории

Если совсем упрощенно и кратко: есть такой архитектурный подход - CQRS. Ключевой его идеей является разделение моделей на модели чтения и модели записи.

А связующим звеном между ними выступают проекции. Проекции "понимают", какие изменения тригерят модели записи и заботятся о том, чтобы внести необходимые изменения в модели чтения.

💻 Пример

Представьте, что есть таблица order на 50 полей, которая хранит в себе все возможные детали заказа. Суть в том, что мы не обязаны делать одну модель с мапингом на всю эту огромную таблицу. Мы можем сделать несколько моделей чтения и несколько моделей записи.

✍️ Модели записи будут отвечать за внесение изменений в данные, причем каждая модель может быть как на все 50 полей таблицы, так и только на 2-3 поля. Моделей этих может быть сколько угодно и они могут смотреть на одни и те же столбцы, это допускается.

🧾 Модели чтения - это, по сути, вьюшки, представления. Опять же, их может быть сколько угодно и они могут мапиться хоть на два, хоть на все пятьдесят столбцов, и при этом могут пересекаться между собой по столбцам.

🔧 Как это помогает бороться с Legacy

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

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

☘️ Нет никаких проблем с тем, чтобы создавать новую модель под каждый новый блок функционала. Например, у вас есть legacy Order на 50 полей. Вы можете создать Order на 7 полей для нужд программы лояльности, Order на 23 поля для нужд нового модуля Склада и т. д.

☘️ Вы все равно не будете трогать старые поля вашего legacy-монстра, а значит нет проблем с тем, что дублируются классы, смотрящие на эти поля.

☘️ В новых моделях вы свободны от старого нейминга и неудачных решений. То, что вы используете старые данные, не обязывает вас использовать их по-старому. Поле в таблице и в старой сущности назвали company_order_price_i_like_long_names? Вам никто не мешает в новом классе сделать просто поле price. Цена хранится в float, а вы давно хотите перейти на копейки в int? Назовите поле priceKopecks и делайте (int)ceil($data['company_order_price']) при гидрации.

☘️ Вы можете задеприкейтить старую модель и больше никогда в нее не дописывать код.

☘️ Старая модель как бы навязывает вам необходимость все писать в одном месте из-за своей гипер-ответственности ("Божий класс"). Переход на модели-проекции позволяет вам отвязаться от этой единой точки. Order для нужд склада умеет что-то только про склад, а Order для нужд бонусной системы знает что-то, что нужно только ему. Да, у вас много классов Order, это вам навязал legacy, но теперь вы не обязаны лепить весь код в одно место.
6🔥6👍2
Решил в субботу наделать крафтовых сосисок. Сейчас отварил пару на обед. Жена, увидев сей кулинарно-эротический шедевр, объявила, что не будет это есть. А зря. На вкус как домашние пельмешки без теста, только нежнее. Ням ням.
😁7👏5🥰1
Проработав в десятке команд, я заметил, что многие разработчики плохо понимают разницу между такими вещами, как событие, сообщение и транспорт. Уже много лет хотел написать подобную статью. Очень рекомендую.

https://habr.com/ru/articles/921656/
🔥10👍41
Почему я не люблю DRY

Отвечаю на вопрос подписчика к посту о CQRS "а как же DRY?"

Первое, что нужно понять: не следует понимать все слишком буквально и прямолинейно. Второе: невозможно писать код, соответствующий всем принципам, правилам, парадигмам и best practices.

Решение, которое вы строите - это всегда компромисс с учетом реальных условий проекта.


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

🔎 Давайте сначала немножечко о DRY

Этот принцип сформулирован предельно универсально, а значит может быть натянут на любую ситуацию.

Везде ли надо его натягивать?

DRY гласит, что всякое знание в системе должно иметь единственное представление.

Самый часто мне встречавшийся случай гипертрофированного применения DRY – это использование одного класса в качестве риквеста, сущности и DTO. При таком подходе связанность кода становится максимальной, как и число болей по сопровождению проекта.

Да, когда вы выделяете три класса: request, entity и DTO, значительная часть полей в этих классах может совпадать, и изменение entity потянет за собой изменение request и DTO.

Является ли это нарушением DRY?

Мое мнение: вопрос не имеет смысла.

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

Я скажу: request, entity и DTO – три разные единицы, они про разное. И буду прав. Вы скажете: request и DTO – производные от entity, и первоисточник знаний – entity. И будете правы. А потом придет третий чувак и спросит: а как же Single responsibility?

Качество кода в подходе с выделением request, entity и DTO значительно выше, чем при соединении всего в одном классе. И для меня это – ключевой критерий.

📌 Теперь про вопрос с legacy Order и его проекциями

Вопрос подписчика звучал так:

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


Отвечаю по порядку с учетом написанного выше.

🧠 Вы сами решаете, что для вас больнее: продолжать работать с огромным legacy классом или нарушить DRY. И это не ирония и не колкость. Вы принимаете решение, как инженер, с учетом условий вашего проекта.

В заскорузлом legacy идеальных решений нет. Только боль и компромиссы.


🧠 Опция с выносом общих для разных модулей инвариантов в отдельные классы допустима, если вы сумеете красиво ее реализовать. Скорее всего, это будет экзотика и переусложнение кода. Вообще

Дотошное следование DRY почти всегда ведет к переусложнению.


🧠 Надо понимать, что ваш код – это воплощение вашего образа мышления и вашего видения. «Изменять количество можно только пока заказ не оплачен». Это один инвариант, или два? А если завтра бузинес скажет вам: мы хотим иметь возможность сделать частичный возврат средств покупателю если при сборке оплаченного заказа товара на складе оказалось недостаточно. В этой ситуации вам будет удобнее с одним инвариантом или с двумя?

Мысль, которую я пытаюсь донести, следующая:

DRY слишком универсален, чтобы применять его дотошно к любой ситуации. Только вы решаете, где у вас нарушение, а где – нет.


Конечно, если у вас появились большие куски повторяемого кода, которые вы меняете одновременно – это явный повод для рефакторинга. Но в части проектирования системы лучше руководствоваться более тонкими подходами.

#DRY
Please open Telegram to view this post
VIEW IN TELEGRAM
👍6🔥51
🤔 Мысль за обедом

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

Представьте дядю Васю, задумчиво почесывающего щетину на подбородке и дымящего Беломором, которому молодой племянник объявил, что хочет стать мЕнеджером или «айтишником».

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

Прошло 20 лет, и наш воображаемый дядя Вася ни то, что не помер, но, возможно, даже и на пенсию еще не вышел. Жизнь уже научила его, что некоторые мЕнеджеры за год зарабатывают больше, чем он за всю жизнь, но он все равно мыслит с привычной инерцией и верит, что обладание навыком загуглить – уже неплохое достижение в мире Интернета.

А вот теперь представьте, как к этому же дяде подходит внук и объявляет, что хочет стать… Оркестратором смыслов. Потому что в мире AI традиционные профессии просто вымрут. Как вы думаете, дядя Вася застрелится, или просто напьется?

А мысли все эти пошли у меня оттуда, что раньше нам в школе говорили: «мы не учим вас будущей профессии, мы учим вас учиться». Похоже, что современная школа моего ребенка не научит даже этому.
👍61
🔒 Набор завершен.

📯 Объявляется донабор в наш LevelUp Club

Двери открыты для первых пяти желающих.) В Клубе проводятся занятия в двух пространствах: Библиотеки и Кузницы.

📜 В Библиотеке мы работаем над созданием большого дерева навыков PHP-разработчика, по которому видно весь ландшафт: что нужно уметь, куда прокачиваться и как оценивать свои компетенции. Участники сами выбирают навык из дерева, а я провожу по этому навыку занятие.

⚒️ В Кузнице мы планируем совместную разработку платформы Клуба в обучающих целях. На занятиях я планирую делиться своим подходом к разработке.

Первое занятие в Библиотеке уже в это воскресенье.

Подробнее можно почитать в правилах Клуба.

Чтобы присоединиться, просто напишите мне в личку @slayervc со словами "хочу вступить в Клуб". 🙂

Участие бесплатное.)

#LevelUp
Please open Telegram to view this post
VIEW IN TELEGRAM
🕊4👍1👏1
Принципы построения хорошего репозитория

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

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

Это не коннект к базе, не запрос, не QueryBuilder и т. п.


Все перечисленное - детали реализации. О репозитории всегда нужно думать как о коллекции объектов. Представляйте, что ваш репозиторий держит в памяти PHP процесса все объекты, которые могут быть из него извлечены. И проектируйте его интерфейс исходя из этого.

🔁 Всегда следуйте принципу инвертирования зависимостей

Какой бы репозиторий или Storage вы не писали, всегда прокладывайте интерфейс между вашим репозиторием и клиентским кодом. Это позволит вам:

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

☘️ Выделить реализацию репы в отдельную задачу и отдать ее коллеге. 😄

☘️ В будущем легко добавлять новые реализации при переходе на другое хранилище (редко, но бывает), фреймворк (актуально в легаси проектах) или микросервисы (вместо базы репа дергает API).

🗃 Используйте классы коллекций вместо plural types

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

🚫 Не держите даже примитивную логику в репозитории

Вот пример нарушения этого принципа:

class UserRepository implements UserRepositoryInterface
{
public function deleteWhoFinishedCourse():void
{
//Удалить всех пользователей, кто привязан к курсу со статусом FINISHED

$userIds = $this->em->createQueryBuilder()->... //Делаем сложный запрос для выборки id
foreach ($userIds as $id) {
$this->em->createQueryBuilder->()... //Делаем запрос на удаление по id
}
}
}


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

Правильнее было бы выделить в репозитории два метода: один для выборки юзеров (универсальный на насколько случаев, или конкретно для этой задачи), второй - для удаления по id. А формирование самих критериев удаления по случаю завершения курса лучше вынести в слой бизнес-логики, или, в крайнем случае, в Application.

✂️ Репозитории можно делить не только по сущностям, но и на Read и Write

Эта идея позаимствована из CQRS. Если ваш репозиторий слишком большой, или если вы явно следуете CQRS, то выделение UserReadRepository и UserWriteRepository будет вам полезно.

🪶 Переход на абстракции не обязывает вас отказываться от ORM и даже ActiveRecord

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

☘️ Сначала вы можете написать InMemoryRepository для тестов.

☘️ Потом вы можете написать ActiveRecordRepository в качестве первой быстрой реализации. Внутри можно прямо дергать методы ActiveRecord, это ничему не мешает, ведь вы отделены от остального кода интерфейсом.

☘️ Позднее вы можете решить притащить Doctrine и написать DoctrineRepository, где спокойно можете использовать EntityManager.

☘️ И, наконец, столкнувшись с высокой нагрузкой вы можете захотеть написать PDORepository или DBALRepository с запросами на нативном SQL и ручной гидрацией.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥9👍3🤩21
Паттерн Стратегия. Пример применения.

Я уже писал как-то о превратном понимании Фабрики. Стратегия, наверное, второй паттерн по исковерканности понимания его разработчиками. Что только не объявляли при мне реализацией Стратегии: и обычный if-оператор и какие-то инфраструктурные моменты, типа возможности десериализовать в различные классы входящий JSON.

Так что же такое Стратегия?

Чтобы понять Стратегию (как и почти любой другой паттрен 4-х), нужно понимать полиморфизм (вот мой пост об этом). На самом деле, это очень простой паттерн. Давайте разберем на примере.

Нам необходимо реализовать программу лояльности (ПЛ), поддерживающую разные "валюты" (рубли, бонусы, мили) и разные способы расчета начисляемой суммы: от всей стоимости заказа, только от части заказа и т. д.

Помимо логики расчета начислений, реализация ПЛ имеет и много другой логики. Возникает вопрос:

Как реализовать различные варианты расчета бонусов?


1️⃣ Первый вариант: писать методы с разной логикой прямо в классе ПЛ и потом обвязывать это все кучей if-ов по всему коду. Ведь нам во многих местах нужно будет понимать как "валюту", в которой считается поощрение клиента, так и способ расчета. При такой реализации код будет запутанным и плохо расширяемым.

2️⃣ Второй вариант: использовать наследование. Мы могли бы сделать несколько классов ПЛ. Общую логику, не касающуюся расчета, можно поместить в базовый класс, а уникальную логику - в потомков. У нас могли бы быть такие классы:

☠️ ПЛ с рублями от всей стоимости заказа.

☠️ ПЛ с рублями только за основные услуги.

☠️ ПЛ с бонусами от всей стоимости заказа.

☠️ И так далее.

Чем больше комбинаций "валюта" <-> способ расчета, тем больше классов. А теперь представьте, что следующей задачей вам нужно добавить в ПЛ функционал массовой раздачи карт клиентам. Раздача карт может быть в зависимости от статуса клиента, или от того, сколько товаров он купил за последний год. То есть, похожая с начислением бонусов задача. Что делать? Плодить еще комбинации наследников?

3️⃣ Третий вариант: пойти по принципу "Предпочитайте композицию наследованию". Вынесем логику расчета бонусов и стартовой раздачи карт в отдельные классы. И вот тут-то и будем плодить наследников. А класс ПЛ будет в себя агрегировать конкретные реализации алгоритмов.

Чтобы это работало совсем удобно и красиво, мы объявим базовый интерфейс для класса, рассчитывающего сумму бонусов, и еще один - для класса, раздающего карты. Такой подход позволит нам полиморфно подменять алгоритмы расчета и алгоритмы раздачи карт в нашей ПЛ. Причем, благодаря полиморфизму, мы сможем это делать даже в рантайме.

Вот мы и реализовали паттерн Стратегия. У нас появилась стратегия расчета бонусов и стратегия стартовой выдачи карт. Вот пример кода, а пояснения к нему я дам в следующем посте. ⬇️

class LoyaltyProgram
{
private string $id;
//enum {KOPECKS, MILES, BONUSES}
private LoyaltyCurrency $currency;
private AccumulationStrategy $accumulationStrategy;
private InitialCardIssuing $initialCardIssuing;

//Начислить бонусы на карту
public function accrue(Order $order, LoyaltyCard $card): void
{
$amount = $this->accumulationStrategy->calculate($order);
$card->accrue($amount);
//...
}
}

abstract class AccumulationStrategy
{
public const ?LoyaltyCurrency CURRENCY = null;

public function __construct()
{
if (null === static::CURRENCY) {
throw new \LogicException();
}
}

/**
* @return int сумма начислений на карту ПЛ в валюте ПЛ
*/
abstract public function calculate(Order $order): int;
}

class BaseServicesKopecksAccumulationStrategy extends AccumulationStrategy
{
public const ?LoyaltyCurrency CURRENCY = LoyaltyCurrency::KOPECKS;

public function calculate(Order $order): int
{
//...
}
}

class MilesAccumulationStrategy extends AccumulationStrategy
{
//...
}
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥6👍4
Паттерн Стратегия. Продолжение.

В предыдущем посте ⬆️ я показал, как использовать паттерн Стратегия на примере программы лояльности. Давайте теперь поясню этот пример чуть подробнее. Благодаря такой реализации пользователи могут создавать свои программы лояльности с нужной им "валютой" и способом расчета поощрений.

class LoyaltyProgram
{
//...

public function changeAccumulationStrategy(AccumulationStrategy $strategy): void
{
$this->accumulationStrategy = $strategy;
$this->currency = $strategy::CURRENCY;
//...
}
}


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

Что дает нам такое решение:

🍀 Можно писать неограниченное количество стратегий, не меняя код LoyaltyProgram.

🍀 Можно спокойно расширять список "валют" (рубли, бонусы, мили, что угодно).

🍀 LoyaltyProgram знает о том, в какой валюте хранится баланс на ее картах, но ничего не знает о том, как этот баланс рассчитывается. То есть агрегат LoyaltyProgram разгружается от части логики, делегируя ее в свои вложенные структуры.

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

Вообще Стратегия - один из самых простых и понятных способов создать точку расширения в вашем коде, избегая кучи if-ов и запутывания логики.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍51
Давно хотел поделиться своим неоднозначным опытом применения паттерна Спецификация. Вообще, все, что касается сложных выборок данных, неоднозначно. 😄

Построить аккуратный и легкочитаемый код в ситуациях, когда требуется реализовать сложные критерии отбора объектов, почти невозможно. Спецификация, по крайней мере, позволяет спустить весь треш вниз, в инфраструктурный слой.

https://habr.com/ru/articles/929848/
Please open Telegram to view this post
VIEW IN TELEGRAM
👍4🔥3
Сколько лет с этим сталкиваюсь, а все не могу привыкнуть. Ты такой весь в задаче, напряжен и собран. И тут тебе шторм выделяет русское слово красным. Ты перечитываешь его трижды и не видишь ошибки.

Раздраженный тем, что уже выронил из головы контекст задачи и переключился на орфографию, ты наводишь мышь на слово: "ну давай, научи меня русскому языку!" И видишь вот это. В лесу родилась елочка. Даже музыка заиграла в голове.
😁10
Про оптимистичную конкуренцию на простом примере

Недавно у нас в команде разработчик решал такую задачу:

💳 Реализовать генератор номеров пластиковых карточек. Номер состоит из двух частей: id компании и порядковый номер карты внутри компании.

1️⃣ В качестве первого решения разработчик подключил в проект Redis. Мотивация такая: редис умеет делать атомарный инкремент с отдачей нового значения, что снимает проблему конкуренции за один и тот же номер карты. То есть, исключается ситуация, когда два PHP процесса попробуют сохранить в базу карту с одинаковым номером.

☠️ Услышав о необходимости поднять на проде Redis, техлид нервно подпрыгнул и в удовольствии разработчику отказал. Оно и понятно: добавлять в систему ради такой мелкой задачи новый компонент, который будет являться еще одной потенциальной точкой отказа, не согласится ни один человек, отвечающий за стабильность прода, будь то техлид, девопс или СТО.

2️⃣ Тогда разработчик реализовал второе решение: хранение последнего порядкового номера карты last_sequence_id в MySQL с накладыванием блокировок через SELECT FOR UPDATE. То есть, первый PHP процесс, создающий новую карту, берет из базы последний порядковый номер. И пока этот PHP процесс не завершил создание карты, сохранение ее в базу и не инкрементнул last_sequence_id, остальные процессы будут дожидаться снятия блокировки.

Лично у меня на этом моменте возникло два вопроса:

1. Насколько удачно такое решение, и
2. а нужны ли здесь вообще блокировки?

Что касается ответа на первый вопрос, то при больших нагрузках такие блокировки превращаются в bottleneck и создают проблемы для масштабирования. Но на этом проекте нагрузки небольшие, и в пределах одной компании создается не так много пластиковых карт. И тогда мы переходим ко второму вопросу.

🤔 Когда мы рассматриваем ситуацию с конкуренцией, надо всегда помнить, что она делится на оптимистичную и пессимистичную. При оптимистичной конкуренции мы предполагаем, что конфликты редки, а значит физические блокировки не требуются.

Давайте посмотрим: у каждой компании свой last_sequence_id. При генерации карты он инкрементится на 1. Карта создается либо при регистрации нового клиента, либо сотрудником компании вручную. Компании имеют тысячи клиентов. Итого: вероятность ситуации, что в один и тот же момент два PHP процесса попытаются одновременно создать карту, крайне мала. Это и есть пример оптимистичной конкуренции.

А дальше возникает вопрос: а что с этим делать? При оптимистичной конкуренции применяются семантические блокировки, но это больше для распределенных систем (микросервисы). Здесь не требуется и этого. Достаточно создать уникальный индекс в таблице пластиковых карт по полю number. Это гарантирует нас от сохранения двух карт с одинаковым номером.

Следующий вопрос: а что будет, если конфликт все-таки возникнет. Ответ прост: один из PHP процессов упадет с SQL ошибкой Integrity constraint violation. И здесь вы уже сами решаете, устраивает вас это, или нет. Если ваше приложение юзер-френдли, то на фронте пользователь увидит что-то вроде "Что-то пошло не так, попробуйте еще раз."

Если такое увидит сотрудник компании при попытке завести пластиковую карту с вероятностью в сотые или тысячные процента, то надо ли заморачиваться с блокировками? Я считаю, что нет. Чем проще ваша система и ваш код, тем легче живется как вам, так и вашим коллегам. 🙂

В общем, не в том сила, чтобы чуть что, тащить в проект редис, рабита и монгу, а в том, чтобы адекватно оценивать как риски, так и стоимость разрабатываемого решения. 😄
Please open Telegram to view this post
VIEW IN TELEGRAM
👍123🔥1
🐸 Дождь бывает не только из лягушек

Посетила меня сегодня идея сходить в бассейн попозже вечерком. Гипотеза состояла в том, что спорт-клуб пятничным вечером будет полупуст, и я смогу по такому поводу насладиться плаванием на персональной дорожке 🦊. Без толкучки, так сказать.

Поначалу все шло неплохо. Персональной дорожки мне не досталось, но народу было немного, и плавалось комфортно. Ровно до тех пор, пока не пошел... Дождь из голых мужиков! 🤯

Пока я восстанавливал дыхание у бортика возле стартовой тумбы (такая штука, с которой в воду прыгают), на меня в буквальном смысле повалилась толпа голых дяденек. Спортивные, обычные, жирные и ультражирные лоснящиеся туши непрерывно обрушивались в воду, поднимая тучи брызг. Это было похоже на ковровую бомбардировку. 🛩

Кто-то прыгал с тех самых стартовых вышек прямо через мою голову, кто-то с борта, кто-то чинно спускался по лестнице. Но их было реально МНОГО. В мгновение ока идиллическая картина тихого полупустого бассейна с приятными размеренно покачивающимися волнами, превратилась в бурлящий водопой из какого-нибудь "Мадагаскара" или "Ледникового периода". 🤪

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

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

Дальше по курсу шли жирные, обрюзгшие мужики. Опершись на борт и разделительный канат как на барную стойку, они, перекрывая дорожку почти полностью, громко обсуждали свои похождения по бабам. Причем, послушаешь их, так у каждого не только член 30 сантиметров, но и такой список отполированных девок, что Джейсон Стэйтем сдох бы от зависти. 💪

Наконец, миновав все преграды, я достиг противоположного борта, возле которого большой дядя в ярко-желтой шапочке играл желтым же корабликом 🚤 (неподалеку стоял ящик с игрушками для мамочек, приходящих с детьми в релакс-зону). Дядя то поднимал кораблик над водой, то снова бросал его на поверхность, изображая губами то ли работу двигателей то ли пердеж матросов, переевших просроченных пайков.

Остановившись возле увлеченного морской игрой "малыша" я всерьез задумался: стоит ли оставаться посреди этого театра абсурда, или пора домой. Решение помог принять мужик на соседней дорожке, решивший покинуть бассейн, не пользуясь лестницей.

По всей видимости, он рассчитывал, оперевшись руками на борт, выскочить из воды подобно мускулистому герою какого-нибудь рекламного ролика, но... Не рассчитал свои кондиции и физические возможности и, выбравшись из воды на край бассейна, был нагнут силой земного притяжения в позу рака, от чего его задница, обтянутая трусами в горошек (ну кто в бассейн такое одевает, фу), оказалась аккурат возле моего лица.

После такого чудного зрелища я решил, что лучше будет продолжить тренировку завтра, благо до клуба от дома рукой подать. Ну а единственное объяснение произошедшему я нашел такое: к вечеру позаканчивались групповые программы, всякие там файтинги, кросс-фиты и пилатесы, и народ после тренировки побрел в бассейн, дабы поймать релакс и освежиться.

Ну а сокрушаться о том, что народ у нас что на парковке, что в очереди, что в бассейне вести себя не умеет, не буду, пост и так вышел язвительным. Зато жизненным. 😄
Please open Telegram to view this post
VIEW IN TELEGRAM
😁14🌚2🤬1💯1
Параметрирование через конструктор vs параметрирование через аргументы метода

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


class CreateLoyaltyCard
{
public function __construct(
private readonly GuestRepositoryInterface $guestRepository,
private readonly LoyaltyProgramRepositoryInterface $programRepository,
//...
) {
}

public function execute(CreateLoyaltyCardParams $params): void
{
$guest = $this->guestRepository->getById($params->guestId);
$lp = $this->programRepository->getByHotelId($guest->getHotelId());

//выдача карты гостю
}


Метод getByHotelId(), как и любой метод get, выбрасывает EntityNotFoundException. Необходимо доработать эту команду так, чтобы была возможность в клиентском коде выбирать, будет она падать с исключением при несуществующей LoyaltyProgram или тихо завершаться.

Решение состоит в том, чтобы добавить параметр bool $strictLoyaltyProgramExists и через него управлять поведением команды. Я предложил добавить этот параметр в конструктор, а коллега - в аргумент метода execute(), в CreateLoyaltyCardParams.

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

1️⃣ Первое отличие: аргументы конструктора (и они же поля класса в данном случае) - это состояние класса, влияющее на его поведение на всем сроке жизни. Аргумент метода - это входные параметры для конкретной операции, выполняемой разово.

2️⃣ Второе отличие: Аргумент метода execute() - это часть интерфейса класса, контракта, показывающего, какие есть операции в классе, и что они делают. Аргумент конструктора - это настройка на уровне самого класса.

Чтобы понять, какой вариант предпочтительнее, можно взглянуть на проектируемый класс через клиентский код. Допустим, мы хотим взять пачку из 10 гостей, пройти по ней форычем и для каждого выполнить команду выдачи карты.

Хотим ли мы для каждого гостя вместе с его id указывать, должно поведение команды быть строгим или мягким? Или нам удобнее будет один раз проинстанцировать команду, указать ей параметр строгости и дальше уже пользоваться сконфигурированной командой?

Другой пример: экземпляр CreateLoyaltyCardParams, передаваемый аргументом в метод execute(), как это почти всегда бывает с Application командами, будет создаваться из HTTP риквеста. Что-то вроде:

class CreateLoyaltyCardRequest
{
#[Assert\NotBlank]
public int $guestId;

public function toParams(): CreateLoyaltyCardParams
{
//...
}
}


Насколько уместно в таком случае будет смотреться флаг $strictLoyaltyProgramExists в CreateLoyaltyCardParams?

Конечно, для данного конкретного случая разница не столь принципиальна. Но мне этот случай показался хорошим примером для демонстрации того, как вообще можно мыслить о классах, которые мы пишем. И что вообще-то, когда мы пишем классы, нужно мыслить. 😄
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥72👍2🦄2
Границы агрегата в DDD: теория и практика (часть 1)

Этот пост перерос в статью на Хабре, если тема интересна, лучше сразу переходите к статье.)

При разработке по DDD новый функционал пишется с доменного слоя, без оглядки на то, как будут храниться данные в базе. Это очень удобно и позволяет писать любой код компактно и читаемо, удерживая высокий cohesion.

Одним из самых полезных в этом плане понятий в DDD является агрегат. Напомню кратко: доменный агрегат обязан реализовывать внутри себя все бизнес-правила (инварианты), всегда держать все свои данные консистентными, и нам запрещено обращаться к составным частям агрегата в обход его корня.

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

Давайте рассмотрим на примере программы лояльности (ПЛ). Есть корневой класс LoyaltyProgram, который держит в себе настройки ПЛ для конкретной организации, и различную бизнес-логику.

Следующий класс, который нам понадобится - это LoyaltyCard, инкапсулирующий баланс, связку с клиентом и бизнес-логику работы с начислениями и списаниями. В идеале класс LoyaltyCard должен быть частью агрегата LoyaltyProgram, то есть LoyaltyProgram должен держать в себе (читай, в памяти PHP процесса) все карты.

С точки зрения написания доменного слоя карта лояльности зависит от ПЛ в каждом своем методе, поскольку ПЛ держит в себе настройки, определяющие специфику работы алгоритмов начисления и списания бонусов.

Давайте взглянем на код:

class LoyaltyCard
{
public function accrue(int $amount): void
{
$this->balance += $amount;
}
}


Это все, на что способен класс LoyaltyCard, и проблема здесь в том, что этот код не реализует все бизнес-требования, а именно:

🍀 Сумма начисления должна вычисляться исходя из настроек ПЛ.

🍀 При достижении определенного баланса, уровень карты должен быть повышен. А знания об уровнях ПЛ, разумеется, сосредоточены в самой ПЛ.

Поэтому, полный код начисления на карту может быть помещен только в LoyaltyProgram:

class LoyaltyProgram
{
private LoyaltyCardCollection $cards;

public function accrue(Order $order): void
{
$clientId = $order->getClientId();
$card = $this->cards->getByClientId($clientId);
$amount = $this->accumulationStrategy->calculate($order);
$card->accrue($amount);
$newLevel = $this->levels->getLevelByAmount($card->getBalance());
if (null !== $newLevel) {
$card->tryToUpLevel($newLevel);
}
}
}


Такой код обладает максимальным cohesion и полностью реализует всю необходимую бизнес-логику. Все проверки, такие как статус ПЛ, карты и заказа, наличие карты по $clientId и т. д., в примере опущены.

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

Как я уже говорил, при таком подходе писать (а позднее и дорабатывать) бизнес-логику становится очень легко, а сам код становится компактным и читаемым. Размещение всей логики внутри агрегата делает код максимально сбитым (высокий cohesion) и не позволяет обойти бизнес-правила в клиентском коде.

Однако, применение такого подхода порождает проблемы цены и определения границ агрегата. Но об этом я расскажу в следующем посте. 🙂
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥7👍41
Границы агрегата в DDD: теория и практика (часть 2)

Этот пост перерос в статью на Хабре, если тема интересна, лучше сразу переходите к статье.)

В предыдущем посте ⬆️ я привел пример доменного агрегата и кратко описал плюсы от их применения.

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

class LoyaltyProgram
{
private LoyaltyCardCollection $cards;

public function accrue(Order $order): void
{
$clientId = $order->getClientId();
$card = $this->cards->getByClientId($clientId);
$amount = $this->accumulationStrategy->calculate($order);
$card->accrue($amount);
$newLevel = $this->levels->getLevelByAmount($card->getBalance());
if (null !== $newLevel) {
$card->tryToUpLevel($newLevel);
}
}
}

class LoyaltyCard
{
public function accrue(int $amount): void
{
$this->balance += $amount;
}
}


Программа лояльности (ПЛ) держит в себе (то есть, в памяти PHP процесса) все карты, каковых могут быть десятки тысяч. Уже накладно. А теперь давайте представим, что карта лояльности держит в себе историю всех событий: списаний, начислений, активации и т. д.

class LoyaltyCardChangeLog
{
private string $cardId;

/** @var LoyaltyCardChangeLogEntry[] */
private array $entries = [];

public function accrue(/*...*/): void
{
$this->entries[] = new LoyaltyCardChangeLogEntry(/*...*/);
}
}

class LoyaltyCard
{
private LoyaltyCardChangeLog $changeLog;

public function accrue(int $amount): void
{
$this->balance += $amount;
$this->changeLog->accrue(/*...*/);
}
}


Понятно, что долгоживущие карты могут накопить сотни событий в истории. Но с точки зрения кода это опять очень удобно и надежно: мы будем добавлять события в историю прямо внутри методов LoyaltyCard, что оставит клиентский код чистым, а также не позволит "забыть" добавить запись в историю, т. к. запись происходит на самом низовом уровне - в самой карте лояльности.

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

Вторая проблема - это проблема границ. Продолжим наш пример: карта лояльности предоставляет скидку, зависящую от уровня карты ("бронза", "серебро", "золото"). Скидка - отдельная сущность со своей бизнес-логикой, видами скидок и стратегиями их применения к заказу (на весь заказ, на определенные товары и т. д.)

С точки зрения удобства в коде нам конечно было бы лучше всего сделать так:

class LoyaltyCard
{
private ?Discount $discount

public function getDiscount(): ?Discount
{
if (!$this->isActive()) {
//Инвариант: нельзя получить скидку по неактивной карте
return null;
}

return $this->discount;
}
}


Но тут возникает закономерный вопрос: как не дойти до того, что весь проект окажется в одном агрегате?

Итого, две самые часто встречающиеся проблемы при написании доменных агрегатов - это проблема цены (загрузка из хранилища, гидрация, удержание в памяти) и проблема границ. В следующем посте (или постах, как пойдет) поговорим об их решении. 😄
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥4👍31
Пытаясь осветить проблему границ доменного агрегата, я все-таки устал биться в границах размера телеграм-поста и решил дописать эту тему в статье на Хабре.

Кто читал предыдущие два поста, можете сразу скроллить к разделу "Решение проблемы границ".

https://habr.com/ru/articles/954688/
👍8🔥42
Паттерн спецификация: практический пример применения

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

🧾 Бизнес-правило

В проекте с программой лояльности нужно реализовать такое правило:

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


🧨 Проблема

Знание о том, какой профиль клиента считается заполненным, растеклось по коду. До применения спецификации это выглядело так:

class LoyaltyCard
{
//Условие из этого метода также фигурирует в
//DoctrineInitialCardIssuingStatisticsRepository::guestProfileFilledCondition()
public function checkGuestProfileFilled(Guest $guest): void
{
//...

$this->guestProfileFilled = !empty($guest->getPhone())
&& !empty($guest->getFirstname()) && !empty($guest->getLastname());

//...
}
}


А вот код репозитория, про который говорится в комментарии к предыдущему методу.

class DoctrineInitialCardIssuingStatisticsRepository
{
private function guestProfileFilledCondition(string $alias): string
{
$sql = <<<SQL
(TRIM(%1\$s.phone) <> '' AND %1\$s.phone IS NOT NULL
AND TRIM(%1\$s.firstname_plain) <> '' AND %1\$s.firstname_plain IS NOT NULL
AND TRIM(%1\$s.lastname_plain) <> '' AND %1\$s.lastname_plain IS NOT NULL)
SQL;

return sprintf($sql, $alias);
}
}


Проблема обострилась тогда, когда SQL для выборки гостей, чей профиль считается заполненным, понадобилось добавить в еще один репозиторий. То есть, бизнес-правило было бы прописано уже в трех местах. Пойди уследи в будущем, чтоб все три места менялись одновременно. 🧟

Продолжение в следующем посте. ⬇️
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥4👍21
Паттерн спецификация: практический пример применения. Часть 2.

Продолжаем предыдущий пост. ⬆️

🛠 Решение: используем Спецификацию


Для начала создадим Спецификацию для выражения требования к гостю:

class GuestWithFilledProfileSpecification
{
private array $notEmptyFields;

public function isSatisfiedBy(Guest $guest): bool
{
foreach ($this->notEmptyFields as $field) {
$getter = 'get' . ucfirst($field);
$ref = new \ReflectionClass($guest);
if (method_exists($guest, $getter)) {
$value = $guest->$getter();
} elseif ($ref->hasProperty($field) && $ref->getProperty($field)->isPublic()) {
$value = $guest->$field;
} else {
throw new \LogicException();
}

if (empty($value)) {
return false;
}
}

return true;
}

//getters and setters
}


Знание о том, какие именно требования предъявляются к заполнению профиля гостя, оставим в LoyaltyCard:

class LoyaltyCard
{
public function checkGuestProfileFilled(Guest $guest): void
{
//...

$this->guestProfileFilled = self::getGuestProfileFilledSpecification()->isSatisfiedBy($guest);

//...
}

public static function getGuestProfileFilledSpecification(): GuestWithFilledProfileSpecification
{
return new GuestWithFilledProfileSpecification(['phone', 'firstname', 'lastname']);
}
}


Теперь всякий, кто хочет знать, каковы требования к гостю для выдачи ему карты лояльности, может обратиться к методу LoyaltyCard::getGuestProfileFilledSpecification().

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

trait GuestProfileFilledSpecificationTrait
{
protected readonly EntityManagerInterface $em;

private function guestProfileFilledCondition(string $alias, GuestWithFilledProfileSpecification $spec): string
{
$meta = $this->em->getClassMetadata(Guest::class);
$conditions = [];
foreach ($spec->getNotEmptyFields() as $field) {
if (!$meta->hasField($field)) {
throw new \LogicException(sprintf("Field %s not found in %s entity mapping", $field, Guest::class));
}

$column = $meta->getColumnName($field);
$conditions[] = sprintf(
"TRIM(%s.%s) <> '' AND %s.%s IS NOT NULL",
$alias,
$column,
$alias,
$column
);
}

return '(' . implode(' AND ', $conditions) . ')';
}
}


Обратите внимание, что трейт обращается к доктриновскому мапингу, чтобы получить знание о том, на какие столбцы БД мапятся поля класса Guest. Это позволит нам просто добавлять новые поля в методе LoyaltyCard::getGuestProfileFilledSpecification(), не думая о том, как отработает SQL запрос. Он отработает как надо. 💪

Теперь мы можем использовать этот трейт в нужных нам репозиториях для построения запросов, которые требуют выборки гостей, удовлетворяющих требованиям для выдачи карты. Если требования изменятся, мы просто изменим метод LoyaltyCard::getGuestProfileFilledSpecification(), а весь остальной код останется без изменений.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥6👍32🤔1