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

Не стесняйтесь задавать вопросы в комментариях к постам, или в личку: @slayervc (UTC +8).
Download Telegram
Про оптимистичную конкуренцию на простом примере

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

💳 Реализовать генератор номеров пластиковых карточек. Номер состоит из двух частей: 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
К чему приводит замалчивание ошибок

На ревью у коллег часто вижу решения вот такого плана:

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
use src\Common\Domain\Model\Discount\DiscountType as DiscountTypeEnum;

class DiscountKindType extends Type
{
public function convertToPHPValue($value, AbstractPlatform $platform): ?string
{
/** @var int|null $value */
if (is_null($value)) {
return null;
}

try {
return DiscountTypeEnum::codeToValue($value);
} catch (\Throwable) {
return null;
}
}
}


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

⚙️ Из БД извлечено значение, которое Doctrine должна поместить в поле PHP объекта, вызвав метод convertToPHPValue() кастомного типа DiscountKindType.

⚙️ В БД данные хранятся в виде числовых констант (легаси), в коде - в виде строковых констант.

⚙️ Если из БД пришла константа, про которую наш код не знает, к какой строке ее привести, из DiscountTypeEnum::codeToValue() вылетит Exception.

⚙️ Этот Exception в нашем коде замалчивается и выполняется возврат null в качестве значения.

Теперь давайте посмотрим, к каким последствиям приводят такие решения:

☠️ Заглушенный эксцепшен не осядет в логах, мы никогда не узнаем, что он вообще был.

☠️ Тем самым факт присутствия в БД данных, которые наш код не может обработать, скрыт. То есть, у нас либо в базе лежат невалидные данные, либо код написан так, что одна часть приложения не знает, что пишет другая. А мы эту ситуацию скрываем.

☠️ Когда происходила запись в базу неизвестной нашему коду константы, в это действие вкладывался смысл "сохранение скидки с таким-то типом". Когда мы из базы достаем этот неизвестный коду тип и приводим его к null, мы пускаем по коду "гулять" сущность "скидки без типа". Ведь эта сущность после гидрации куда-то пойдет, с ней будут выполняться какие-то действия, и результат этих действий, скорее всего, снова будет сохранен в базу. И все это в полной тишине (т. е. без логирования).

Хотим ли мы, чтобы наше приложение вело себя таким образом? Позже или раньше последствия таких решений накапливаются и становятся заметны. Только вот дебажить их и исправлять последствия (т. е., исправлять данные) в разы дольше и неприятнее, чем если бы наше приложение сразу упало с ошибкой.
👍6🔥21🙏1
Хочу понять, что вам сейчас интереснее всего читать. Канал потихоньку растёт, варианты тем есть разные, поделитесь, пожалуйста, своими предпочтениями. Можно выбрать несколько вариантов.

На какие темы вам было бы интереснее всего читать посты?
Final Results
84%
Архитектура, DDD, паттерны, ООП.
33%
Фреймворки (повседневная практика): Как пользоваться компонентами, ходовые кейсы.
33%
Фреймворки (внутрянка): резолверы, компайлер-пассы, тонкая настройка, как и зачем этим пользоваться.
45%
Общий PHP: эксцепшены (как правильно), логирование, итераторы-генераторы (зачем и где) и т. п.
50%
Кейсы: как решается та или иная бизнес-задача.
18%
Карьера (Middle): собеседования, роадмапы, советы.
22%
Карьера (Senior): аналогично предыдущему.
25%
Работа в команде: ревью, согласование контрактов, командные соглашения и т. п.
15%
Взаимодействие с менеджментом и лидами: кейсы из опыта, советы.
18%
Самоорганизация на удаленке: тайм-менеджмент, work-life balance, как работать эффективнее.
Не нужно бояться исключений

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

Многие разработчики напрасно воспринимают исключения как что-то негативное.


К оператору throw следует относиться также, как и к оператору return. Бросая исключение, вы сообщаете клиентскому коду (не пользователю ни в коем случае), что выполнение операции невозможно.

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

throw new \RuntimeException('EntityNotFound');


и

class EntityNotFoundException extends \Exception
{
public function __construct(
string $entityClass,
string $criteriaValue,
string $criteriaName = 'id',
?\Throwable $previous = null
) {
$parts = explode('\\', $entityClass);
$message = sprintf('%s with %s %s not found', $parts[array_key_last($parts)], $criteriaName, $criteriaValue);
parent::__construct($message, 0, $previous);
}
}

public function getByUserId($userId): Profile
{
throw new EntityNotFoundException(Profile::class, $userId, 'userId');
}


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

Таким образом, когда вы пишите код, ваша обязанность - при невозможности продолжать выполнение сообщить об этом в клиентский код и точно указать причину. И правильно делать это через throw, а не через return null или return false.
👍12🔥3
О строгости кода

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

class LoyaltyCard
{
public function setLevel(LoyaltyLevel $level): void
{
if (!$this->isGuestBound()) {
throw new \DomainException('Невозможно установить уровень карты при отсутствии привязанного гостя');
}

if ($level->getProgramId() !== $this->programId) {
throw new \DomainException('Невозможно установить уровень карты от другой программы лояльности');
}

if (!$this->guestProfileFilled) {
throw new \DomainException(
'Невозможно изменить уровень карты лояльности при незаполненном профиле гостя',
);
}

if (null !== $this->level && $this->level->getRequiredAmount() > $level->getRequiredAmount()) {
throw new \DomainException('Уровень карты не может быть понижен');
}

if (null !== $this->level && $this->level->getId() === $level->getId()) {
throw new \DomainException('Карте лояльности уже присвоен этот уровень');
}

//...
}
}


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

На каждый такой вопрос я отвечаю отрицательно и поясняю, что:

🍀 Каждая конструкция throw - это выражение средствами языка PHP бизнес-требований, продиктованных заказчиком.

🍀 Демонтаж throw - это не только демонтаж бизнес-логики, но и ослабление строгости кода в угоду сиюминутной потребности.

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

Давайте разберемся чуть подробнее. Класс \DomainException является прямым наследником \LogicException, в описании которого сказано:

This kind of exception should lead directly to a fix in your code.


То есть:

⚙️ Если вы написали код, который приводит к выбросу \LogicException и его наследников, вы исправляете свой код (а не тот, который бросает exception).

⚙️ Тот, кто написал класс или метод, который вы используете, не предполагает его использование тем способом, который вы выбрали.

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

В следующем посте расскажу о том, почему строгий код лучше, чем нестрогий.
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥9👍42
Почему строгий код лучше, чем нестрогий

Строгий код дает два значимых преимущества: надежность и разделение ответственности.

Взгляните на пример кода в предыдущем посте ⬆️. Метод LoyaltyCard::setLevel() является самым глубоким и низкоуровневым с точки зрения слоев приложения. Все, что вы будете писать про изменение уровня карты лояльности, вы будете писать, опираясь на этот метод.

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

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

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


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

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

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

Такое мышление убережет вас и ваших коллег от большого количества будущих проблем. Тут главное помнить:

Строгость - это про то, чтобы exception вылетел как можно раньше и отражал суть проблемы как можно четче.


Избегая строгости, вы не избегаете проблем. Вы их откладываете, смещая точку их возникновения. Проблема в том, что при таком смещении проблемы имеют свойство множиться и становиться неявными, затрудняя дебаг.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍62🔥2
Чем сложнее логика, тем выше требования к cohesion

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

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

🪶 Пример

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

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

На самом деле, у меня была выделена пара стратегий:

abstract class AccumulationStrategy
{
abstract public function calculate(Order $order): int
}


abstract class InitialCardIssuing
{
abstract public function createCardSpecifications(): InitialCardIssuingSpecificationSet;
}



AccumulationStrategy агрегирует в себе всю логику начислений по программе лояльности, а InitialCardIssuing - логику определения, кому из клиентов следует выдать карту сразу при запуске программы лояльности (здесь я интуитивно "попал в боль" большого количества мелких правил).

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

⚙️ Решение проблемы

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

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

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

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

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

🤨 Следствием этого стали такие проблемы

💀 Усложнился дебаг.

💀 Код классов в целом читался легко, но при попытке сосредоточиться именно на логике определения даты старта накоплений приходилось прыгать по разным методам. А это вызывало сильные затруднения в понимании логики.

💀 Отсутствовала возможность охватить "единым взглядом" логику именно вычисления даты старта накоплений.

💀 Тестировщики нашли кейсы, когда правило срабатывало дважды. Например, для карты, у которой дата должна была сдвигаться на один год, она сдвигалась на два.

Вывод

Если задание на разработку предполагает большое количество мелких правил (а никто в ТЗ вам эти правила не сгруппирует, вы должны сами это увидеть), то лучше выделить реализацию этих правил в отдельный класс и включать этот класс в доменный агрегат через композицию.

Такой подход повысит cohesion до предела и очень сильно упростит вам жизнь.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍32
Media is too big
VIEW IN TELEGRAM
Поговорили о методологии разработки, архитектуре приложения и том, каково здесь место фреймворка.
🔥7
Идемпотентность: где уместно, а где - нет

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

Первое, что нужно помнить: идемпотентность - это свойство операции. Это важно, потому что, когда мы проектируем систему или смотрим на код, мы можем смотреть через разные призмы.

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

🍀 Доменными сущностями с бизнес-логикой,
🍀 Сервисами с технической логикой,
🍀 Application-командами (каждая команда, по сути, является операцией),
🍀 Репозиториями,
🍀 API эндпоинты, которые мы предоставляем и вызываем - это тоже операции.

Список, разумеется, далеко не полный.

Если операции - один из основных способов думать о системе и коде, то идемпотентность - один из способов мыслить об операциях.


🗒 Идемпотентная операция дает один и тот же результат (не меняет состояние системы) при повторных вызовах с теми же аргументами.

Давайте рассмотрим три варианта одной и той же операции.

public function giveBonus(Order $order, Account $account): void
{
$amount = $this->bonusStrategy->calculate($order);
$account->accrue($amount);
}

public function giveBonus(Order $order, Account $account): void
{
if ($account->orderRewarded($order)) {
throw new \DomainException('Бонусы за этот заказ начислены');
}

$amount = $this->bonusStrategy->calculate($order);
$account->accrue($amount);
}

public function giveBonus(Order $order, Account $account): void
{
if ($account->orderRewarded($order)) {
return;
}

$amount = $this->bonusStrategy->calculate($order);
$account->accrue($amount);
}


Идемпотентным является только последний пример. При повторных вызовах с одинаковыми order и $account состояние системы не изменится. Бонусы начислятся только однажды.

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

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

Так вот, правило, которого придерживаюсь лично я, звучит так:

Чем на более глубоком слое предоставляется операция, тем менее желательна идемпотентность.


А именно:

🍀 В доменном слое - никогда.
🍀 В Application слое - крайне редко и строго осознанно.
🍀 В контроллерах - сколько угодно по потребности, но тоже осознанно.

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

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

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

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

Например, команду RewardForOrder вы вызываете по нажатию кнопки в интерфейсе. А команду RewardForOrderIdempotent вы вызываете при массовой автоматической раздаче бонусов по крону. Очевидно, что в первом случае вы хотите заявить об ошибке, а во втором - промолчать.

Таким образом, идемпотентность должна использоваться вами осознанно и только для того, чтобы сделать работу системы предсказуемой и безопасной, и никогда для того, чтобы сделать разработку удобнее и быстрее.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍10🔥51
Оптимизация агрегатов без ленивой загрузки

Мы привыкли к тому, что содержимое коллекций вложенных объектов одинаково в БД и памяти PHP процесса. Например:

class User
{
/** @var Tag[] */
private array $tags;
}


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

Максимальное отклонение от этой ситуации, которое мы обычно готовы принять, это ленивая загрузка. При ленивой загрузке поле $tags держит специальный объект коллекции, который в памяти PHP хранит только id вложенных объектов. Все поля конкретных объектов Tag загружаются из базы по потребности.

Но ленивая загрузка не ломает того факта, что состав объектов Tag, связанных с User, одинаков как в памяти PHP, так и в БД.

💡 Однако, при проектировании доменных агрегатов бывает полезным перешагнуть через этот шаблон.

Давайте посмотрим пару примеров.

class LoyaltyCard
{
private ChangeLog $changeLog;

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

class ChangeLog
{
/** @var Entry[] */
private array $entries = [];

public function accrue(): void
{
$this->entries[] = new Entry();
}
}


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

Аналогично, при загрузке LoyaltyCard из базы мы, по идее, должны выгрузить в память все связанные Entry.

Но вот вопрос: зачем?

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

🍀 Загруженная из БД карта всегда будет с пустым логом, но по бизнес-логике нам это неважно.

🍀 Все новые события, произошедшие в рамках PHP процесса, сохраняются в базу вместе с картой.

🍀 Лог в базе растет инкрементно.

🍀 Лог карты в памяти хранит только последние события и только с целью сохранения их в БД.

🍀 Это не мешает, например, выводить историю карты в интерфейсе с навигацией и поиском.

🧠 Другой пример.

class BonusAccount
{
/** @var BonusGrant[] */
private array $grants = [];

public function accrue(int $amount, ?\DateInterval $burnPeriod): void
{
$this->grants[] = new BonusGrant($amount, $burnPeriod);
}

public function writeOff(int $amount): void
{
//Идем циклом по грантам и списываем с них, пока не наберется $amount
}
}


Когда на бонусный счет начисляется какая-то сумма, эта сумма представлена объектом Grant с индивидуальной датой сгорания. То есть, каждый начисленный бонус сгорит в свой черед. При этом тратить бонусы можно как единый бонусный счет. Это значит, что при списании бонусов какие-то гранты обнулятся полностью, а у одного из них уменьшится сумма (но останется прежняя дата сгорания).

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

🍀 БД будет содержать всю историю грантов с первоначальными суммами и датами сгорания.

🍀 По полному списку грантов можно строить историю счета: есть даты создания, первоначальные суммы и даты сгорания.

🍀 Память содержит только ненулевые гранты, которые можно потратить. Этого достаточно для методов getBalance() и writeOff($amount).

Такое мышление частично заимствовано из архитектурного подхода Event Sourcing, где каждая сущность представлена не набором полей, а набором событий. Сам Event Sourcing в чистом виде довольно экзотичен. Но иногда экзотичные подходы помогают сломать шаблоны для достижения практических результатов.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍2😴2🗿1
Отключили с утра интернет. Смотрю свои задачи в работе по найму: вся бизнес-логика описана, на ближайшие дни одни Application команды да контроллеры предстоит штамповать. Думаю, не буду это без AI вручную делать: долго и нудно. Неэффективно.

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

Решил, что нет смысла без доступа к AI заниматься разработкой и ушел писать статью. Мир изменился.
💯4🔥3😢2🤔1🙉1
Архитектурный подход - это как думать, а не как делать

На днях у меня состоялась переписка с одним из студентов моего курса по Symfony. Она показалась мне интересной, и я решил опубликовать ее фрагмент.

Есть библиотека webmozart/assert. Видел примеры ее использования для валидации Value Object (например) при их создании (чтобы объект мог находиться только в валидном состоянии). Эту библиотеку допустимо использовать на слое домена или нужно писать свой аналог? В случае толстой модели объект должен сам обеспечивать свое валидное состояние. это предполагает наличие каких-то типовых проверок. Мы их пишем сами или используем готовые библиотеки. Разве нельзя использовать такие проверки внутри объекта доменной области?


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

А ассерты вы используете для валидации пользовательского ввода.

Посмотрите на вот эти два правила:

1️⃣ Номер дома в адресе не может быть отрицательным.
2️⃣ Пользователь не может иметь больше одного адреса проживания.

Задайте себе такие вопросы:

1️⃣ Нарушение какого из этих двух правил может приводить к непредсказуемому поведению приложения?

2️⃣ Какое из этих двух правил будет удобно описать через ассерты, а какое - нет.

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

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

Первое вы решаете в инфраструктурном слое, второе - в доменном и при желании дублируете в инфраструктурном, хотя чаще всего, вам это просто не потребуется.

Первое вы легко опишите ассертом Positive, под второе вам пришлось бы писать кастомный валидатор (а зачем?)

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

Это дает вам структуру, порядок и понятный путь. Это НЕ дает вам истины в последней инстанции или единственно верного решения.

Можно еще так на это посмотреть.

Правильно: "Я использую стейт-машину на проекте X способом Y для решения задачи Z. Это решит мне проблемы 1, 2 и 3, но породит проблемы 4, 5 и 6. Я проанализировал предполагаемый вектор развития проекта, поговорив с бизнес-заказчиком и счел такое решение оправданным, потому что А и Б".

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

Ну а вывод из этой переписки следует такой: любая связка архитектура + методология - это не более, чем набор оптики, через которую вы смотрите на свои объекты, их свойства и отношения между ними. Нет единственно верного взгляда. Есть накопленный опыт, который позволяет нам осознанно принимать решения, понимая цену и выгоду.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍101
Как думать о перформансе на небольших и средних проектах

Большая часть разговоров о перформансе на не-highload проектах базируется на мифах, а не реальных расчетах. Это касается как совсем абсурдных заявлений типа "рефлексия медленная", так и типичной боязни вытащить в память PHP процесса несколько тысяч объектов и что-то с ними поделать.

Давайте разберемся, всегда ли эти страхи оправданы.

🐢 Про медленную рефлексию

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

Я много раз слышал про медленную рефлексию, но ни разу о том - кто и как это замерил.

🤪 Про избыточную оптимизацию

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

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

А как же с реальными замедлениями на небольших и средних проектах?

Да, на не-highload проектах тоже случаются слишком медленные HTTP запросы или очень долгие cron-задачи. Это не удивительно. Удивительно то, что, сталкиваясь с подобным, разработчики почти всегда начинают рассуждать о логе медленных запросов SQL сервера или, еще круче, "переходе на golang". 🌈

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

Так нужно ли заморачиваться перформансом на не-highload проектах?

По дефолту - никогда. По факту возникновения проблемы - сначала смотрим код профайлером xdebug, а потом умничаем про golang.

😱 "А вдруг у нас будет highload?"

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

Гуглим "сколько людей берут микрозаймы" и видим грубо 15 млн. чел. Предположим, что наш проект "захватит мир" и каждый второй в России начнет брать займ именно у нас. Получаем 7-8 миллионов юзеров. Отсюда можно увидеть максимальные объемы таблиц в БД: пользователи, займы, транзакции. И понять, что никаких больших объемов и больших нагрузок на проекте не будет никогда: в нашем проекте юзер ходит на сайт нечасто и не генерит много запросов.

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

Конечно, не нужно впадать и в обратную крайность и быть расточительным без причины. Если вам нужно обработать два поля для десятков тысяч записей, и написание нативного SQL вам ничего не стоит, то напишите его и работайте с массивом. Здесь, как и везде: требуется адекватный взгляд на задачу и понимание дальнейшего развития проекта, а не догматизм.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍9💯4🔥3
Лог, метрика или профайлер?

Почти любую проблему в проде пытаются решать одним из трёх инструментов:

⚙️ логирование,
⚙️ профилирование,
⚙️ сбор метрик.

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

📝 Логирование

☘️ Как правило, собирает информацию о событиях.

☘️ Логи хранятся в файлах, их агрегация ресурсоемка (как, впрочем и результатов профилирования).

☘️ Хорошо подходит для ретроспективного расследования инцидентов.

☘️ Изначально не предназначены для агрегации таких метрик, как время выполнения запроса или подсчет количества запросов.

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

🔬 Профилирование

☘️ Позволяет построить дерево вызовов с указанием ресурсоемкости каждого элемента дерева.

☘️ Возможна агрегация узлов дерева и показателей затрат ресурсов.

☘️ Некоторые профайлеры позволяют агрегировать статистику.

☘️ Позволяет исследовать только отдельные ветки выполнения программы.

☘️ Замедляет работу приложения, что затрудняет применение на проде.

☘️ Профайлер пишет в файл или в реляционную БД, объем сохраняемых данных может быть огромным.

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

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

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

📊 Сбор метрик

☘️ Метрики приложения – это измерители, представляющие уже сжатую информацию.

☘️ Метрики не дают возможности «провалиться в детали».

☘️ Хорошо подходят для обнаружения изменений и сдвигов в работе приложения.

☘️ Подходят для замера бизнесовых показателей (количество заказов, средний чек, конверсия).

☘️ Хорошо сочетаются с алертами.

☘️ Занимают мало места в хранилище приложения.

Основное отличие метрик состоит в том, что они сохраняются в базе данных временных рядов. Тот, кто работал на мало-мальски крупных проектах, наверняка сталкивался с Prometheus. Для тех, кто не сталкивался, опишу очень коротко и упрощенно, что такое метрика в базе данных временных рядов:

⚙️ Ваше приложение держит счетчик. Он может быть в памяти, key-value хранилище или в РБД.

⚙️ При наступлении определенного события (обращение к роуту, определенный вид эксцепшена, создание заказа клиентом) счетчик увеличивается.

⚙️ Раз в период, называемый scrape interval, Prometheus обращается к вашему приложению, чтобы получить значение счетчика.

⚙️ Значение сохраняется в базе данных временных рядов с текущей меткой времени.

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

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

☘️ логи - для расследования ("что произошло?");

☘️ метрики - для наблюдения ("насколько массово?", "где отклонения?");

☘️ профайлер - для поиска узких мест ("почему это медленно?") и трассировки.
👍8🔥3