🛌

Fatigue Driven Development (FDD)

Fatigue с англ. "усталость"
 
Привет, меня зовут 👨🏻 Давид Шекунц 👴🏿 и я устал от ночного дебага, я устал от неподдерживаемого кода, я устал от технологий, которые ломаются. Здесь я собираю best practice, которые помогают мне больше отдыхать. Надеюсь, и вам тоже помогут.
 
notion image
 

📚 Оглавление

 
📚 Оглавление📖 Словарь🍔 App structurei. Монорепозиторийii. Монолитiii. Микросервисыiv. Модульный монолитv. Что выбрать? vi. Как делить микросервисыa. Отдельная командаb. Безопасностьc. Геозависимостьd. Statefule. Отдельный набор фичf. Выделенные ресурсыg. Мешает окружающимh. Независимость деплояi. Независимость бизнес-логикиk. Мини-продуктvii. FALS – лучшая структура проектаviii. Graceful-shutdownix. Separate your Cron🎛️ APIi. RPCii. CQRSiii. (a)Sync Communicationiv. Schemav. Push | Pullvi. Создавайте ID на клиенте👴🏿 Типизируйi. Branded & Nominal Types и валидация на типахii. Algebraic Data Types (ADT) и Инвариантыiii. implements – невероятное злоiv. I префикс – зло🐞 Error Handlingi. Classified Errorsii. Error Dictionaryiii. Return, not throw🧠 Архитектураi. Функционально Ориентированное Программирование (ФОП)ii. Data Oriented Architectureiii. Dirty vs Clean Architectureiv. Vertical Slicesv. Event Driven Architecture (EDA)vi. Say “NO” to master-mastervii. Say “yes” to master-slaveviii. Horizontal scaling💾 Базы Данныхi. ORM or notii. Migration firstiii. Optimistic & Pessimistic Concurrency Controliv. Transactionsv. Distributed Transactionsvi. Drop Relationsvii. Drop Constraintsviii. Как выбрать БДix. Используйте UUIDx. INSERT-s / UPDATE-s / DELETE-s must be batchedxi. “Хранилища, как луковицы”xii. Event busxiii. SQLite🔎 Тестированиеi. Общееii. Юнит, Интеграционные, E2E тестыiii. Интеграционные тесты🌎 Логгирование, метрики, трейсингi. Метрики – ваше паучье чутью, Тресы – ваша карта, Логи – ваши глазаii. Мета-инфа логовiii. Техническое👨‍👧‍👦 Leadingi. Триптих – идеальная структура тех. команды💻 Программированиеi. Абсолютно все конкурентноii. Избегай Mutexiii. Программируй так, как будто все уже сломалосьiv. И многое, многое другое👨🏻 Об авторе
 
💡
Данная страница в активной стадии написания, поэтому подписывайтесь на телеграм канал 🦾 IT-Качалка Давида Шекунца 💪, где будут выходить анонсы новых глав и обновления существующих. Спасибо за внимание и мощной вам прокачки 💪
 

📖 Словарь

Термины, которые будут встречаться в тексте:
 
  • “Источник данных” – база данных, кэш, сторонний API, короче все, откуда мы достаем или куда отправляем данные.
  • Application Interface (API) – способ взаимодействия с запущенным приложением (stdin, stdout, TCP, UDP, )
  • “Ручка” (endpoint) – один из методов API (например HTTP API URL, описание команды в gRPC, мутация / запрос / подписка в GQL, etc.)
  • Message Broker (MB) – возможность публиковать сообщения (чаще всего по “топикам”) и подписаться на их появление
  • Message Queue (MQ) – тоже самое, что и MB, но гарантирует последовательность доставки
  • Event Driven Architecture (EDA) – выкидываем “События” (id, название в прошедшем совершенном времени, данные) и потребляем любым числом сервисов.
  • CI / CD – автоматизированные процессы, требующиеся для сборки и раскрытия сервисов
  • “Инфра” – базы данных, CI/CD, сервера, оркестрация, нетворк и все, все, все
  • Orphan data (данные-сироты) – данные, которые имели смысл только при существовании данных, к которым они были привязаны (условно, любые данные на которые мы хотим сделать ON DELETE CASCADE)
  • “Проекция” (projection) – readonly данные, высчитанные на основе других данных. Например, мы сохраняем все сообщения с устройства в одну таблицу, а потом из них считаем проекции его текущих и исторических показателей температуры / состояния батареи / местоположения / etc.
  • Деградация системы – когда новый функционал ломает старый, считается одним из самых страшных видов ошибок.
  • Фича-флаг – булевое значение (true | false), которое вы пробрасываете в код (через env) и в зависимости от того, включено оно или нет, вы включаете или выключаете функционал
  • Real-time – системы, предполагающие, обработку и ответ в миллисекундах (максимум в 1-3 секунды)
  • Приложение (app) – код, запущенный, как процесс
  • Инстанс (instance) – единица запущенного приложения
  • Транспорт (transport) – любой способ взаимодействия 2-х процессов, предполагающий отправку / получение данных (TCP, HTTP, MQ, 2IP, stdin, etc.)
  • Внутренняя коммуникация – вызов бизнес-логики внутри одного приложения засчет вызова функции изнутри кода
  • Внешняя коммуникация – вызов бизнес-логики из другого приложения с использованием какого-либо Транспорта
 

🍔 App structure

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

i. Монорепозиторий

 
Об этом можно говорить много, но вот эти 3 свойства для меня являются причиной выбирать монорепозиторий в 9 из 10 случаев:
 
  1. Атомарные деплои – на прод выгрузиться сразу и бэк, и фронт, и код инфры
  1. Все в одном месте – даже на 5 репозиториях начинаются проблемы с поиском нужных вещей
  1. Shared код – возможность без публикации использовать локальный код между сервисами
 
Важно, что при наличии монорепозитория надо озаботиться:
 
  1. Development и staging средами, чтобы в первой велась разработка, а во вторую попадали изменения, которые потом пойдут в production
  1. Если вам нужно публиковать библиотеки, то обязательно добавьте в CI/CD stage с ручным управлением для их сборки и публикации (из main выпускается основная версия, из development beta, из других веток alfa)
 

ii. Монолит

 
!ВНИМАНИЕ! дальнейшие “Плюсы” и “Минусы” идут в сравнении между Монолитами, Микросервисами и Модульными монолитами.
 
  1. Одна большая кодовая база
  1. Запускается +- в 1 инстансе
 
Плюсы:
 
  1. Позволяет избежать проблем распределенности (кросс-коммуникация и синхронизации состояния)
  1. Быстрый деплой
  1. Меньше мороки с Инфрой
  1. Легче дебажить
 
Минусы:
 
  1. Все плюсы сверху работают до определенного размера, а после полностью сходят на нет
  1. Сложность горизонтального масштабирования
  1. Единая точка отказа
  1. При отсутствии правил написания кода, превращается в лапшу
 

iii. Микросервисы

 
Суть:
 
  1. Кодовая база отделена друг от друга
  1. В идеале, свои собственные ресурсы (разные сервера, БД, кэши, etc.)
 
Плюсы:
 
  1. Можно выделить кодовую базу в отдельное место (безопасность, управление)
  1. Теоретически максимальное горизонтальное масштабирование
 
Минусы:
 
  1. Ошибка границ ответственности микросервисов намного хуже любого самого жирного монолита
  1. Сложность коммуникации и синхронизации состояния в максимуме
  1. Сложно деплоить
  1. Сложно следить за инфрой
 

iv. Модульный монолит

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

v. Что выбрать?

В рамках одной команды:
 
  1. Начинайте с модульного монолита, стараясь максимально избегать кросс-сервисной коммуникации
  1. Выделяйте что-то в микросервисы по НЕОБХОДИМОСТИ, то есть вы просто увидите, что по-другому вообще никак, тогда выделяете что-то в микросервисы (такой точки может даже не наступить)
 
А вот у каждая отдельная команда делают свой отдельный модульный монолит и микросервисы, потому что работать над одной совместной кодовой базой тяжело, если вы не часть одной команды.
 

vi. Как делить микросервисы

 
Делить микросервисы по "ответственности" – самая большая ошибка.
 
Человек очень плох в классификации и категоризации, и если ваша методолгия требует жесткой группировки (например, ООП или "деление по ответственности"), то вы никогда не сможете сделать это правильно
 
Почему? Потому что даже если вы смогли выделить конкретный набор фич по ответственности и разбить их на микросервисы / классы, которые удовлетворяют бизнес-задачам, мир не стоит на месте, будут постоянно появляться новые требования, которые будут ооочень сильно размывать границы этой "ответственности" и правильная архитектура сегодня, превращается в неправильную завтра и не по вашей вине
 
Вторая проблема: "ответственность" – очень субъективное понятие. Спроси в средней+ системе нескольких человек: "какие ответственности вы бы выделили?" – вариант каждого будет на 50% отличаться от другого. А повышение градуса "субъективности" – путь бесконечных ошибок.
 
Вот эту "придуманную ответственность" разработчиком можно назвать "исcкуственной ответственностью", а нам нужно ориентироваться на "натуральную ответственность" и вот вам чеклист примеров этой "натуральной ответственности":
 

a. Отдельная команда

Если есть две команды людей, который должны решать независимые части системы и не коммуницируют на постоянной основе (weekly, daily, свой менеджмент и т.д.), лучше чтобы каждые делали свой набор микросервисов / модулей и договаривались про API.
 
Даже общие библиотеки это опасно (должен быть 1 конкретный mainteiner этой либы), потому что любое вмешательство в код со стороны разраба из сторонней команды очень плохо контролируется и может деградировать пол системы
 

b. Безопасность

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

c. Геозависимость

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

d. Stateful

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

e. Отдельный набор фич

Вашим партнерам нужна урезанная / другая вариация вашего основного API, тогда вы создаете отдельный модуль, в котором выдаете наружу только то, что нужно + подменяете способ аутентификации (например, на oAuth) и т.д.
 

f. Выделенные ресурсы

Ваше приложение требует большой работы с файловой системы, или, например, это сжатие картинок, или обработка потокового видео, или CPU-intensive рассчет, короче, что-то что требует особого ресурса. Тогда вы выделяете микросервис / модуль и деплоете его на тачках с нужными ресурсами.
 

g. Мешает окружающим

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

h. Независимость деплоя

Когда мы хотим, чтобы разные сервисы деплоились независимо друг от друга (например, чтобы редеплой неафектил приложения, код которых не был изменен в PR)
 
Тогда можно выделить модуль (у модулей свои независимые Docker image, поэтому после сборки можно проверять изменился ли hash чтобы принимать решение о редеплое) или микросервис
 

i. Независимость бизнес-логики

Вот это САМЫЙ сложный аспект, потому что независимость одного микросервиса от другого – это часто временное понятие (помним, что требования всегда меняются), но если один модуль, по-настоящему способен оперировать независимо от другого (например, система рассчета вознаграждений и система аутентификации), тогда это претендент на выделение в микросервис.
 
НО советую это делать вторым шагом, после того, как вы поняли, что эти модули по-настоящему стали независимыми друг от друга и для удобства дальнейшей разработки вы хотите выделить кодовые базы.
 
ЕЩЕ РАЗ, только время покажет реальную независимость кодовых баз
 

k. Мини-продукт

Бывает такое, что надо добавить крупный набор фич, но которые могли бы условно быть даже отдельным "мини-продуктом", который интегрируется с вашим
 
Например, клиенты вашей системы используют ее для своих клиентов и захотели, чтобы вы добавили возможность тарифицировать их клиентов
 
По факту, это не является частью вашей платформы и может быть реализовано через ваш внешний API, как отдельное приложение
 
Но по тем или иным причинам, вы хотите иметь возможность переиспользовать какую-то часть инфры и кодовой базы, поэтому данный мини-продукт имеет смысл делать как микросервис, но в рамках вашего монорепозитория
 
Он может также использовать прямые запросы в основную БД (чтобы не плодить дополнительные ручки), НО только к таблицам, из которыз он читает. Все таблицы, в которые он пишет, должны быть уникальны для этого сервиса.
 
Также, важно, чтобы основная кодовая база никак не менялась под этот сервис, максимум, добавлялись новые фичи, которые могут быть интересны этому мини-продукту
 
То есть, оснвоной продукт не должен зависеть от мини-продукта, но при этом мини-продукт будет зависеть от основного
 
И вот в такой ситуации, выделять его в микросервис, отличная идея, потому что тогда кодовая база основного продукта будет в безопасности + уставшему разработчику будет нарушить эту зависимость при написании кода
 
Можно было бы назвать это "ответственностью", но важнее тут кто от кого зависит и придерживаться этой зависимости
 

vii. FALS – лучшая структура проекта

4 основные папки:
 
  1. Features – код с бизнес-логикой приложения, разделенная по доменам.
  1. Apps – код, запускающий приложение в разных конфигурациях.
  1. Libs – код, который мог бы стать приватными или публичными библиотеками.
  1. Scripts – код, который нам время от времени хочется запускать с локалки из CI/CD.
 
Пример:
 
/features /auth login.ts register.ts /user-management get-user.ts delete-user.ts /apps /main-http /http-api schema.ts config.ts index.ts /cron index.ts /libs /@my-company /specific-lib index.ts /logger index.ts /scripts /inactivate-stale-user index.ts
 
На данный момент данная структура покрывает 100% кейсов, с которыми я сталкивался.
 

viii. Graceful-shutdown

Всегда аккуратно закрывайте свои приложения:
 
  1. Запустите таймер, который убьет приложение, даже если оно не успело завершиться
  1. Закройте API (HTTP, MQ, etc.)
  1. Выключите все кроны и интервалы
  1. Дождитесь окончания текущих процессов бизнес-логики
  1. Закройте внешние соединения (БД, MQ, etc.)
 
Чаще всего стоит реагировать на SIGINT и SIGTERM.
 

ix. Separate your Cron

Всегда создавайте отдельные приложения под кроны, чтобы запускать их отдельно и не мешать горизонтальному масштабированию других приложений.
 
НО не рекомендую делать для этого отдельное приложение в 1 инстанс, потому что тогда нет гарантии исполнения крона.
 
Самый правильный вариант – использовать планировщик кронов, например, планировщик кронов, встроенный в k8s.
 
Так, даже если какая-то тачка недоступна, сам планировщик воспроизведет крон там и тогда, где получится.
 
Также, бывает полезным сделать так, чтобы крон чисто выкидывал event о том, что надо произвести какую-то операцию, а какое-нибудь из уже активных приложений (или несколько) среагировали на него и сделали то, что нужно.
 
Таким образом вы получаете больше контроля: (1) в рамках приложения-исполнителя можете более грамотно распределять нагрузку, (2) легче писать логику распределения рассчета данного крона, (3) среагировать и отдебажить крон, (4) не запустить рассчетов более чем надо.
 

🎛️ API

i. RPC

Remote Producer Call – один из самых удобных способов структурирования API.
 
Во-первых, он хорошо подходит для request-response:
 
// # Request { id: string name: "GetUser", params: { userId: string }, meta: { ts: Date requesterId: UUID traceId: UUID } } // # Success Response { id: string // такойже как и в request result: { case: "success", success: { email: string avatar: string } } } // # Failure Response { id: string // такойже как и в request result: { case: "failure", failure: { code: number message: string } } }
 
Но при этом Request можно использовать и как Event:
 
{ id: string name: "UserCreated", payload: { userId: string }, meta: { ts: Date requesterId: UUID traceId: UUID } }
 
Во-вторых, он позволяет общаться имея всего 1 двунаправленный канал (например, как в случае с WS), а значит его можно использовать с абсолютно любым протоколом / интерфейсом коммуникации.
 
Знаменитые реализации RPC: gRPC, GraphQL, Pg Wire Protocol, JSON RPC.
 

ii. CQRS

Command Query Responsibility Separation – если максимально упрощать, то это 2 основных правила:
 
  1. Если вы возвращаете данные вы не имеете право менять состояние системы (писать что-либо в БД, кэш, изменять значения переменных)
  1. Если вы изменяете состояние системы, вы можете ответить только “ОК” или “Ошибка”
 
Такой способ построения любых API / Интерфейс гарантирует возможность (1) удобного горизонтального масштабирования API, (2) меньше проблем при написании и поддержании бизнес-логики, (3) возможность использования eventual-consistency.
 
!ВАЖНО! Его невозможно соблюдать в 100% случаев, например, как в случае с JWT аутентификацией: вам скорее всего придется создать токен, сохранить его, а потом вернуть пользователю.
 
Поэтому не стоит втыкать CQRS везде, стоит просто максимизировать его использование.
 
Лайфхак 1: чтобы CQRS заработал на полную, советую сделать все id сущностей в виде UUID, чтобы их мог создавать и передавать клиент.
 
Лайфхак 2: идеально сочетается с RPC.
 

iii. (a)Sync Communication

Синхронная коммуникация (sync) – отправляем запрос в “канал” и блокируем его пока не вернется ответ (HTTP, gRPC).
 
Асинхронная коммуникация (async) – отправляем запрос в “канал”, а ответ получим когда-нибудь потом из него или другого (UDP, WS, Message Queue, Message Broker, etc.)
 
Плюсы sync: безопасность, надежность, простота, скорость
Недостатки sync: ручная маршрутизация, куча блокируемых каналов
 
Плюсы async: eventual consistency, использование 1 канала, возможность использовать с очередями, EDA, хорошо работает с RPC
Недостатки async: более медленный и менее надежный
 

iv. Schema

Всегда начинайте описывать API от схемы, а потом только пишите код реализации.
 
Самые удобные схемы:
 
  1. OpenAPI или GQL для HTTP / MQ / MB / WS
  1. Protobuf для gRPC / MQ / MB / WS
 
Лайфхак 1: чаще используйте Union type, например oneof из gRPC, allOf / oneOf из OpenApi.
 

v. Push | Pull

Push Model – отправляем что-то куда-то.
 
+ realtime
+- умный продюссер
- для контроллирования нагрузки читающий стороны нужен backpressure механизм
 
Pull Model – достаем что-то откуда-то.
 
+ абсолютная управляемость процессом потребления
+- умный потребитель
- полноценный real-time невозможен
- не работает в НЕ потоковых данных (например, HTTP запросы сложно реализовать по Pull модели)
 
Относится как к MQ, так и хранилищам (Prometheus и InfluxDB), так и к реализациям кода (Event Emitter vs Async Iterator).
 
На практике стараюсь чаше использовать Pull модель, а Push только там, где Pull просто не получится.
 

vi. Создавайте ID на клиенте

Идентификатор сущности должен создавать клиент:
 
  1. Это позволяет в случае получения успеха клиенту самостоятельно запросить нужные данные
  1. Легче реализовать потоковые / real-time коммуникацию
  1. Позволяет использовать Eventual Consistency
  1. Уникальный идентификатор будет также выступать в роли ключа идемпонентности
  1. На бэке можно создать сущности, связывать их этим UUID и только после этого записывать в БД (вместо того, чтобы делать запись, получать SERIAL и только после этого создавать следующую связанную сущность)
Используйте для этого UUID v7 или похожий уникальный идентификатор с вшитой в него датой.
 

👴🏿 Типизируй

Продвинутые техники типизации:

i. Branded & Nominal Types и валидация на типах

Эту главу перенес в книгу λ Функционально Ориентированное Программирование:
 
 

ii. Algebraic Data Types (ADT) и Инварианты

Эту главу перенес в книгу λ Функционально Ориентированное Программирование:
 
 

iii. implements – невероятное зло

implements полностью убивает смысл интерфейсов.
 
Суть интерфейса в том, чтобы отделить конкретную реализацию от набора методов нужных какой-то конкретной функции чтобы снизить связанность кода.
 
Если вы хотите явно объявить, что какой-то “класс” должен включать в себя какой-то набор методов, то используйте абстрактные классы, они для этого и созданы.
 
Интерфейс же нужен, чтобы в каком-то месте объявить что вам нужно, в другом месте реализацию, а в третьем прокинуть реализацию в запрос.
 
// business-logic/some-fn.ts interface User { id: string email: string } interface UserDataSource { getUserById(id: string): User } function someLogic(uds: UserDataSource) { // ... } // databases/pgsql.ts interface UserTable = { id: UUID email: string } const UserTableService = (conn: PgConnection) => { return { getUserById(id: string): UserTable => { // ... } } } // app/main.ts const pg = PgConnection() const userTable = UserTableService(pg) someLogic(userTable)
 
Только в такой ситуации вы снижаете связанность кода, а значит верно используете интерфейсы.
 

iv. I префикс – зло

 
Самая важная проблема: используя I префикс, вы неправильно структурируете свой код.
 
  1. Это семантическая ошибка – вы же не именуете все классы с префиксом C или все цифры с постфиксом Int
  1. Таким неймингом вы связываете интерфейс с реализацией, а суть интерфейса именно в том, чтобы отвязать один от другого
 
interface IPaymentService { // ... } function extendSubscription(ps: IPaymentService) { // ... }
 
Теперь нам нужно реализовать Stripe и Tinkoff, как сервисы оплаты. Судя по этому коду, человек назовет их StripePaymentService и TinkoffPaymentService, но вопрос: а что если еще где-то по коду есть ISubscriptionService и наши Stripe и Tinkoff полностью ему отвечают и нужно использовать его и там, нам придется в название еще добавлять StripePaymentSubscriptionService? Нет, это абсолютный бред.
 
Достаточно просто сделать названия, PaymentService и SubscriptionService для интерфейсов и Stripe и Tinkoff для реализаций, тогда все становится абсолютно логичным.
 
А тут подробнее
Для начала перечислю ссылки, в которых это раскрывается супер подробно, а потом расскажу свою точку зрения:
 
  1. https://softwareengineering.stackexchange.com/questions/117348/should-interface-names-begin-with-an-i-prefix
  1. https://stackoverflow.com/questions/5816951/prefixing-interfaces-with-i
  1. https://developer.okta.com/blog/2019/06/25/iinterface-considered-harmful
 
А теперь что я про это думаю:
 
Во-первых, потому что это чистейший пример Hungarian Notation, если уж вы именуете interface с префиксом I, то почему вы не именуете class с префиксом C или string с префиксом S.
 
Логично то, что не стоит добавлять префикс типа к его названию.
 
Во-вторых, потрясающее объяснение дается в первой ссылке:
 
If you stop to think about it, you'll see that an interface really isn't semantically much different from an abstract class:
  • Both have methods and/or properties (behaviour);
  • Neither should have non-private fields (data);
  • Neither can be instantiated directly;
  • Deriving from one means implementing any abstract methods it has, unless the derived type is also abstract.
In fact, the most important distinctions between classes and interfaces are:
  • Interfaces cannot have private data;
  • Interface members cannot have access modifiers (all members are "public");
  • A class can implement multiple interfaces (as opposed to generally being able to inherit from only one base class).
Since the only particularly meaningful distinctions between classes and interfaces revolve around (a) private data and (b) type hierarchy - neither of which make the slightest bit of difference to a caller - it's generally not necessary to know if a type is an interface or a class. You certainly don't need the visual indication.
 
Раскрою немного по-другому этуже мысль:
 
class User { constructor( public id: string, public email: string ) {} } // Когда вы используете класс User в типизации, на самом деле, вы используете // его интерфейс const someFn = (user: User) => { // ... } // То есть, на деле, класс User состоит из 2-х частей: // 1. Тип класса interface User { id: string email: string } // 2. Рантайм класса(условное) const User = { new(id: string, email: string): User => { return {id, email } } }
 
Единственное важное отличие, что у класса в его интерфейс попадает еще и приватные свойства НО разве с точки зрения функции someFn важно какие у него приватные свойства? Нет, потому что эта функция сможет вызвать только его публичные свойства.
 
Соответственно, мы так и так всегда используем interface , даже когда в типе прописываем класс, так зачем тогда при написании своего interface мы должны знать что это интерфейс, добавляя префикс I?
 
В-третьих, гораздо лучше, если ваш интерфейс будет назван без префикса, а вот его реализации будут иметь постфикс:
 
interface UserRepository = { insert(user: User) => void } // Реализация на PSQL class UserRepositoryPostgreSQL { insert(user: User): void {} } // Реализация на Mongo class UserRepositoryMongoDB { insert(user: User): void {} }
 
Потому что реализация – это доуточнение интерфейса, что должно отражаться и в именовании.
 
В-пятых, это, конечно, сайд-эффект, но я видел очень много раз, как разработчики, не понимающие смысла интефрейсов, к каждому классу реализации создавали рядом интерфейс с I префиксом…
 
Ребята, эти интерфейсы нужны только для (1) возможности подмены реализации (то есть, у вас должно хотябы 2 класса, отвечающие одному и тому же интерфейсу) или (2) для уменьшения связанности кода (но тогда мы должны описывать интерфейс не рядом с реализацией, а там, где это развязывание происходит).
 

🐞 Error Handling

i. Classified Errors

Создавайте слоистую классификацию ошибок, чтобы легче проверять их типы:
 
// . Сначала базовую ошибку type BaseError = { _isBaseError: true // для простой проверки statusCode: number // всегда используйте HTTP Status Code type: string // уникальные ключи словаря ошибок (об этом ниже) message: string // внутренее описание ошибки data: JSON // доп данные } // . Первый слой обычно про то доступны ли они внутри или снаружи type InternalError = BaseError & { _isInternalError: true } type PublicError = BaseError & { _isPublicError: true publicMessage: string // вот эту ошибку будем показывать пользователям } // . Далее стандартные типы type ForbiddenError = PublicError & { _isForbiddenError: true statusCode: 403 } type NotFoundError = PublicError & { _isNotFoundError: true statusCode: 404 } // и так далее // . Потом ваши специфичные type SubscriptionEndedError = ForbiddenError & { _isSubscriptionEndedError: true publicMessage: "Your susbscription ended" } type PostsNotFoundError = NotFoundError & { _isPostsNotFoundError: true publicMessage: "No posts" }
 
Тем самым в любом месте программы вы можете полученную ошибку проверить на нужный тип.
 
Например, если ошибка дошла до самого верхнего уровня (например, http API пользователей) вы можете проверить и отдать только то, что нужно:
 
const errorHandler = (request: Request, error: Error) => { if (error._isBaseError) { if (error._isPublicError) { return request.status(error.statusCode).message(error.publicMessage) } else { return request.status(500)message("Internal error") } } }
 

ii. Error Dictionary

Выделяйте отдельное поле и добавляйте туда уникальное значение поля, например:
 
// errors.json { // ключ – уникальный, а значение любая +- понятная строка "user_email_validation_error": "Email is incorrect", "value_to_long": "Value too long: ", "you_are_not_owner": "You are not owner" }
 
Уникальные ключи ошибок загоняйте в систему локализации (например, Lokalize, i18n, etc.) и тем самым вы как на бэке, так и на фронте во-первых, будете лучше понимать что за ошибка была отправлена, во-вторых, сможете автоматически их переводить на нужные языки.
 
Если нужны особые значения, отправляйте их в виде объекта или добавляйте всегда в конец строки.
 

iii. Return, not throw

Практически всегда я предпочту возврат ошибки, вместо ее выкидывания. Можно возвращать или саму ошибку, или сущность, которая может содержать ответ или ошибку (монада Either).
 
  1. Очевидность программы увеличивается в 10-ки раз
  1. Можно типизировать возвращающиеся ошибки
  1. Упрощается дебагинг
  1. Код при обработки ошибок выглядит чище
  1. Сложнее забыть обработать все нужные кейсы
 
Go, Rust, Zig – cовременые языки, для которых возврат ошибок – норма.
 
💡
Подписывайтесь на телеграм канал 🦾 IT-Качалка Давида Шекунца 💪, где будут выходить анонсы новых глав и обновления существующих. Спасибо за внимание и мощной вам прокачки 💪

🧠 Архитектура

i. Функционально Ориентированное Программирование (ФОП)

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

ii. Data Oriented Architecture

Эту главу я перенес в книгу λ Функционально Ориентированное Программирование:
 
 

iii. Dirty vs Clean Architecture

Clean / Hexagonal / Onion Architecture пропагандируют отделение бизнес сущностей (User, Order, Product, etc.) от инфраструктуры (БД, контроллеров, etc.).
 
Это хороший подход, НО ТОЛЬКО В ОДНОМ КОНКРЕТНОМ СЛУЧАЕ, а именно, когда инфраструктура ДОЛЖНА быть подменяйемой (например, у вас технология, которая предполагает, что кто-то другой может развернуть ее с БД по вкусу). Люди же пытаются запихнуть его абсолютно везде.
 
Я на 1000% могу вам сказать, что чем более прямолинейно я действую (чуть ли не SQL запросы в http controller) тем более надежные, масштабируемые, оптимизированные и гибкие программы у меня получаются.
 
Я называю это Dirty Architecture.
 
Готов поспорить, что словлю за этот посыл 10 000 хуев, но ставлю жопу, потому что я на собственной шкуре в продакшене использовал Clean Architecture и DDD и только после этого на практике осознал насколько несбыточные обещания они дают.
 
Если вкратце:
 
  1. Любыми абстракциями бизнес-логики вы увеличиваете Искусственную сложность (ссылочка), а в CA и DDD это возведено в максимум.
  1. Код – это слуга данных (БД), а не наоборот, поэтому чем меньше абстракций над данными в вашем коде, тем легче вам будет с ним работать (ссылочка)
  1. Если вы создаете программу, так, чтобы можно было поменять условно PG на MongoDB, то ваша программа будет работать хуево, как с одной, так и со второй базой
  1. Абстракция над БД сразу предполагает то, что (1) вы не сможете использовать важные фичи конкретной БД, (2) вы не сможете оптимизировать под конкретную БД, (3) вы будете работать неоптимизировано и очень быстро достигнете потолка нужных вам ресурсов
  1. Чем больше переиспользуемого кода (например слой Entities) тем больше опасности при добавлении новой фичи / изменении старой, сломать что-то работающее (со временем эта вероятность достигает 100%)
 

iv. Vertical Slices

В контекст Dirty Architecture еще хорошо вписывается понятие: Vertical Slices.
 
Утрируя, многие разбивают свое приложение по папкам: use-cases, db-queries, models
 
Когда приходит запрос он сначала обрабатывается кодом use-cases, который вызывает database, которая вызывает models.
 
Получаются, что 1 фича (обработка запроса) разбросана между множеством папок, где лежит код другиз фич. Такое построение называется Horizontal Slices.
 
Идея Vertical slices, в том, чтобы иметь по папке на каждую фичу и в каждой папке будет как controller, так и логика use-case, так и описание запросов к БД, так и код моделей, которые используются в этой фиче.
 
При этом одна фича не имеет право использовать код другой, только вызывать ее controller / use case.
 
  1. Мы получаем настоящую независимость кодовых баз, а шансов изменениями в фиче задеть что-то стороннее стремится к нулю
  1. Логика, относящаяся к конкретной фиче не загрязняет общую кодовую базу, а находится там, где нужна, а это невероятно помогает в осознании кодовой базы
  1. Тестировать такой код намного проще
  1. Мы можем легко включать / выключать / переносить фичи в любое место
  1. Каждая фича может использовать свои технологии (можно даже поменять Query builder, БД, библиотеку, etc.)
 
Сюда важно добавить еще пару условий:
 
  1. Держать 1000 фич в одной папке неудобно, поэтому папки с фичами стоит распределять по папкам доменой области (auth, shop, reports, blog-system, etc.)
  1. В рамках доменной области можно иметь переиспользуемые в этой доменной области код
  1. А общий переиспользуймый код (например, схема БД) всегда можно оформить как “библиотеку” / SDK, которую будут использовать отдельные фичи
 

v. Event Driven Architecture (EDA)

Честно говоря, не представляю практически ни одного backend приложения без вариации EDA.
 
Суть EDA в том, чтобы дать нам механизм при котором мы можем сообщить о каком-то Событии, чтобы на это запустились какие-то другие процессы, о которых мы не должны знать в месте публикации.
 
Из полезного:
 
  1. Event состоит из
    1. id: uuid
    2. name: string – в прошедшей совершенной форме (UserRegistered , PostCreated, etc.)
    3. payload: JSON – содержимое
    4. timestamp: UnixTimestamp – время создания
    5. traceId: uuid – идентификатор проходящий с самого первого вызова и через все вызовы и события, чтобы собрать картину целиком
  1. У событий есть размер
    1. S – название + id сущности
    2. M – название + полезные данные
    3. L – название + все данные
    4. XL – название + новые и старые данные
  1. В каждом отдельном случае походит свой “размер”, эксперементируйте
  1. Если какие-то данные, влияющие на событие или обработчики могут измениться со временем, то засовывайте их в event
  1. Если есть возможность воспроизвести какую-то логику, используя ивенты (то есть, когда не нужно ждать ответа), то лучше так и делайте
  1. Используйте системы очередей с гарантией доставки и репликацией (например, Kafka)
  1. Лучше, если ивенты персистятся (например, как в Kafka), чтобы их можно было перечитать
  1. Выучите паттерн Transaction Outbox для очень сложных и опасных операций
  1. Для синхронизации состояния используйте Saga или Оркестратор на событиях
  1. Никогда не рассчитывайте что событие исполнится в константное время
 

vi. Say “NO” to master-master

Не используйте master-master технологии.
 
Кажется, что это “серебряная пуля”, но на деле это технологии, к которым стоит прибегать, когда вы перепробовали все другие варианты:
 
  1. Данных слишком много?
    1. Попробуйте выделить cold и hot данные, где cold вы архивируете и скидываете на будущее
    2. Если данные нужны только для проекции, то скиньте в горизонтально масштабирующиеся OLAP БД (Clickhouse) и удалите из основной БД
  1. Не хватает скорости INSERT? Используйте слои батчинга, например, Kafka, в которую скидываете все записи, которые потом будут забатчены и засунуты в итоговые хранилища
  1. Слишком долгие UPDATE? Часто можно изменить UPDATE на INSERT или же воспользоваться Event Sourcing / EDA – сохраняем события об изменении, с другого конца аггрегируем и считаем проекции
  1. Слишком долгие DELETE? Помечайте данные для удаления (deletedAt) и чистите их 1 раз по крону
 
Если вам нужно m-m кластеризация, значит у вас сложная задача, если у вас сложная задача, значит в ней сложно будет поддерживать надежность и еще сложнее дебажить.
 
m-m кластеризация – это сильное снижение надежности и скорости, при увеличении сложности инфры и дебагинга.
 
Если хотите использовать master-master, то в идеале ваши данные не должны иметь Constraint любого вида (и минимизировать UNIQUE), вы должны использовать только INSERT и SELECT (то есть, по факту это time-series, event sourcing или OLAP данные).
 
Если вам все-таки нужен m-m, то используйте технологии, которые основаны (а лучше не могут работать без) m-m.
 
То есть, какой-нибудь Redis / MySQL имеет опциональную m-m кластеризацию, поэтому их использовать точно не стоит.
 
А вот etcd / cockroach / clickhouse – изначально предполагают работу в кластере, а значит им можно доверять. Но при переходе на них вы все равно заплатите цену, поэтому вы должны быть уверены в своем решении.
 

vii. Say “yes” to master-slave

Только кэшу я даю право не иметь slave репликации, но лишь потому что к кэшу нужно относится как к данным, которые имеют право в любой момент исчезнуть.
 
Во всех остальных случаях, я всегда выбираю технологии и настраиваю их так, чтобы был slave, который будет (1) read репликой, (2) фолбэком в случае падения мастера, (3) backup узлом.
 
Если вам нужна жесткая синхронизация slave, тогда берите технологии с RAFT.
 
Когда нужно расти, то раскрывайте N master-slave узлов и управляйте данными между ними на уровне логики приложения (микросервисы, логические шарды, акторы, домены, etc.)
 

viii. Horizontal scaling

 
Пиши горизонтально-масштабирующиеся приложения.
 
Во-первых, абсолютно любой код всегда конкурентный, даже в рамках самого однопоточного языка. Когда пишешь с учетом горизонтального масштабирования намного чаще это вспоминаешь и реже допускаешь ошибки рейсинга.
 
Во-вторых, в современных реалиях очень легко упереться в потолок приложения в 1 инстанс и переходить от вертикального масштбарирования к горизонтального невероятно сложно, в тот момент как в обратную сторону проблем никаких не возникнет.
 
При горизонтальном масштабировании вам нужно учитывать:
 
  1. Потребуется внешнее хранилище для синхронизации состояний (Redis-like)
  1. Абсолютно все процессы становятся конкурентными, а значит нужно или уметь распределять (пример, round-robin на очередях) или уметь лочить (Redis-like / etcd-like)
  1. Проблема могут возникать только на части интсансов, поэтому в мониторинге ресурсов надо разделять каждый отдельный инстанс
 
!ВАЖНО! не путайте горизонтальную масштабируемость с “микросервисами”, горизонтально масштабировать можно и монолит (особенно, распределенный).
 

💾 Базы Данных

i. ORM or not

Если вы пишите библиотеку / сервис, который можно использовать с разными БД, тогда можете использовать ORM.
 
В остальных случаях (то есть, практически всегда) используйте библиотеки максимально близкие к языку запросов, то есть или чистый SQL / CQL / Dynamo API, или Query Builder.
 

ii. Migration first

В 90% случаев это намного более удобный и надежный путь:
 
  1. Написали миграции
  1. Накатили на БД
  1. Сделали интроспекцию – выгрузка схемы таблиц в типизацию вашего языка и констант (название таблиц, название колонок, etc.)
 

iii. Optimistic & Pessimistic Concurrency Control

Pessimistic Concurrency Control (PCC) – блокируем при доставании данные, производим изменения, записываем обратно, снимаем блокировку (по факту Mutex).
 
+ операции надежные
- медленно и есть шансы дедлоков
 
Optimistic Concurrency Control (OCC) – достали данные, изменили, когда пытаемся записать обратно проверяем, что никто другой не поменял их до нас (например, при записи проверяем что остался тот же updated_at или version).
 
+ быстро, просто
- чем больше конкуренции, тем медленее будет работать система или вообще не работать
 
Сразу можно заключить, что если ваш алгоритм предполагает конкуренцию (множеством процессов должны поочередно делать UPDATE техже данных), то OCC точно не подходит. А вот если у вас есть только вероятность конкуренции (два процесса решили записать в 1 и туже сущность) OCC может малыми затратами сильно ускорить систему.
 
(O|P)CC применимы как к абсолютно любым источникам данных, так и к построению логики кода.
 

iv. Transactions

Если вы никогда не использовали разные уровни изоляции транзакций значит вы никогда не писали приложения даже среднего уровня или делали это неправильно.
 
Среднестатически будет так:
 
  1. Read uncommited – читаем данные, которые еще незакомичены (максимальная скорость, минимальная надежность, подходит когда данные которые мы читаем не могут не записаться, например, поскольку у них нет никаких Contraints)
  1. Read commited – читаем только закомиченные данные (надежно, просто, работает)
  1. Repeatable read – в рамках транзакции при чтении всегда получим теже данные, как если бы читали из snapshot (сложнее, но частых кейс, как например при использовании sub query)
  1. Serializable – выстраиваем все транзакции в очередь (минимальная скорость, максимальная надежность, готовимся к дедлокам)
 
Также обязательно изучите что и в какой момент блокируется (текущие / связанные строчки, таблица, схема или база) и умейте контролировать уровне блокировки (условно, SELECT FOR UPDATE SHARE / SKIP)
 
Советы:
 
  1. Стройте архитектуру так, чтобы минимизировать или не использовать транзакции (а если и использовать то не выше Repeatable read). И да, архитектурные решения способны позволить работать без транзакций.
  1. Никогда не задерживайте транзакции – замедление 1 транзакции может привести к каскадному росту и ошибкам во всей системе.
  1. Если вам пришлось использовать Serializable, то скорее всего, вы просто поленились / не имеете времени сделать по-другому.
 

v. Distributed Transactions

Никогда не задерживайте транзакцию: не делайте таймеров, не делайте внешних вызовов.
 
Задержка транзакции геометрически увеличивает время работы абсолютно всей системы.
 
А что если приходится, например, зависим от стороннего API?
 
Меняйте логику приложения так, чтобы она работала без транзакций.
 
Если именно так подойти к решению проблемы, вы обнаружите, что существуют пути решения вопроса.
А в тех местах, где вам нужно знать закончилась ли и в каком состоянии набор действий, создавайте стейт машины, например, “Джобы” (N-фазные коммиты) или более надежный, но сложные “Саги”.
 

vi. Drop Relations

Скорее всего, вам не нужны реляции.
 
Это поразительно и контринтуитивно, но на определенном объеме работы вы начнете сами это замечать:
 
  1. ON DELETE CASCADE невероятно опасная конструкция, которая (1) может удалить нужные данные, (2) очень сильно тормозит БД, (3) очень сложно контроллируется и дебажиться. Его можно использовать только при one-to-one, в остальных случаях лучше удалять orphan данные кроном в моменты пониженной нагрузки.
  1. Еще чаще вы заметите, что при удалении данных вам не деле не нужно и наоборот вредно удалять связанные с ними данные, тогда ON DELETE CASCADE вообще теряет смысл.
  1. ON DELETE / UPDATE … является переносом бизнес-логики на БД, что черевато тонной дебагинга и непонимания “почему это не работает”.
  1. FOREIGN KEY для проверки наличия сущности очень часто бесмысленный – если вы получили какой-то id, то чаще всего вам или придется заранее проверить его существование или он точно существует. А даже если нет, то крон из первого пункта в последствии удалит эти данные.
  1. Orphan данные чаще всего не будут мешать в ваших выборках, потому что если мы удалили связующие данные, то в стандартных выборках они просто перестанут попадаться (возможно только коллизии в OLAP выборках, но там так и так надо следить за множеством аспектов)
  1. Рано или поздно вам придется хранить часть данных в одной БД, а часть в другой и в этот момент вы уже потеряете реляции. А если вы в первую очередь откажитесь от них, то сможете использовать сколько угодно разных баз и в 100 раз проще горизонтально масштабировать свои хранилища.
 

vii. Drop Constraints

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

viii. Как выбрать БД

Ну, кроме статей, комьюнити, продающее опыта и т.д. важно ещё проверить:
 
  1. Выдержит ли нужный QPS
  1. Посмотрите нужные уровни транзакций и атомарности
  1. Поверь кол-во доступных коннектов
  1. Поверь наличие репликации
  1. Проверь подходит ли она для множества маленьких или только большие Insert
  1. Есть ли Update
  1. Есть ли Upsert
  1. Есть ли методы массовой загрузки (например COPY в PG)
  1. Как устроен MVCC и GC (чтобы понимать сложность Insert / Update / Delete, а также причины пауз)
  1. Какие механизмы дают Optimitic / Pessimistic Concurrency Control
  1. Проверь типы чисел, дат, массивов и наличие JSON / неструктурированных типов, а также разные методы работы с ними
  1. Поверь наличие нормальных библиотек: адаптер, query builder, мигратор и интроспекция
  1. Строковая или колоночная
  1. OLAP или OLTP
  1. Есть ли CDC
  1. Eventual или Strong consistency
  1. Если master-master то какой алгоритм консенсуса
  1. Требуется ли что-то дополнительное для кластеризация (например, zookeeper, etcd)
 
(скоро здесь появится таблица сравнения PostgreSQL, MySQL, MongoDB, Clickhouse, CockroachDB, TimescaleDB, etc.)
 

ix. Используйте UUID

Если нет тонких специфик, как primary key используйте UUID:
 
  1. Можно подготавливать множество сущностей в коде, которые связаны друг с другом по id и разом вставлять их в БД (в случае serial, вам пришлось бы вставлять, получать id и только после этого вставлять следующую сущность)
  1. Позволяет клиенту отправлять id сущности и после успеха операции запрашивать его отдельными ручками / механизмами (например, получать его из WS)
  1. Позволяет использовать отложенную вставку (например, если вы хотите батчить сущности и вставлять их потом, но связанные с ними сущности уже могут появится в БД)
  1. Если просуммировать 3 верхних пункта: позволяет строить Eventual Consistency системы
  1. В экстренных ситуациях позволяет чуть ли не пройтись по всем таблицам, чтобы найти к какой сущности этот id принадлежит
 
НО используйте UUID, у которых началом является timestamp (прим. UUID v7), это повышает скорость вставки в разы.
 

x. INSERT-s / UPDATE-s / DELETE-s must be batched

Большинство БД не любят единичные insert. Скорее всего, БД с одинаковой скорость прососет 1 или сразу 1000 записей (а некоторые, как Clickhouse, настаивают на 100 000 и 1 000 000 000 записей)
 
Если вы создаете нагруженную систему, проектируйте архитектуру с учетом, что для оптимизации вам придется батчить.
 
Основные сайд-эффекты батчинга:
 
  1. Если не персисть сообщения в сторонней системе (например, Kafka) их можно потерять
  1. Забатченные сообщения не будут доступны в момент их реального появления
 

xi. “Хранилища, как луковицы”

Я попытался абстрагировать процессы, происходящие в куче разных хранилищах, среди которых, PostgreSQL, TimescaleDB, MySQL, MongoDB, Redis, Clickhouse, CockroachDB, YDB, Amazon Aurora, TiDB, RMQ, Kafka, RedPanda.
 
Несомненно, я многое забыл / не знаю, поэтому нужны ваши комментарии.
 
  1. Установка connection – устанавливаем соединение с клиентом
  1. Выделение процессора – выделяем некую единицу, которая будет обрабатывать запрос (процесс в PG, thread в Mysql, goroutine в CockroachDB, thread per core в RedPanda).
  1. Процессинг запросов – разобрать присланное сообщение на логические шаги (например, разобрать SQL)
  1. Валидация по схеме – проверить, что присланные данные соотносятся со схемой
  1. Планирование исполнения – можно ли, в какой последовательности, откуда доставать данные
  1. Индексы
    1. Проверка – не нарушаем ли мы текущие индексы
    2. Создание – создаем новые (особенно стоит отметить атомарность UNIQUE индексов)
  1. Конкурентный доступ – например MVCC (создание новых версий записей или rollback журнала)
  1. Транзакции – управление транзакциями
  1. Commit – утверждение, что операция пройдет успешно вне зависимости от обстоятельств (например, WAL запись или RAFT commit)
  1. Ответить мета-данными – иногда требуется заранее ответить клиенту мета-данными, например, о типе возвращаемых данных
  1. Персистенция
    1. Коммуникация со слоем хранения – иногда это часть инстанса процессинга (PG), а иногда существует отдельно (YDB, TiDB)
    2. Компрессия – сжимаем данные
    3. Оптимизация хранения – позволяет
    4. Батчинг – агрегация данных в памяти, для дальнейшего flush
    5. Flush – выгрузка из памяти на диск
  1. Чистка – в большинстве систем хранения будет тот или иной механизм очистки, например, VACUUM в PG или удаление секторов в Kafka
  1. Кластеризация
    1. Membership – обнаружение и присоеденение к кластеру
    2. Leadership – определиние лидеров
    3. Health-checks – проверка доступности компонент кластера
    4. Anti split-brain – предотвращение Split-brain
    5. Recovery – восстановление после отделения от кластера
    6. Rehydration – восстановление данных до нужного уровня
    7. Синхронизация конфигурации – синхронизация итогового состояния конфигурации кластера и отдельных узлов
    8. Синхронизация индексов  синхронизируем индексы
    9. Синхронизация данных – синхронизируем сами данные
    10. Master-Slave репликация – реплицируем данные для дальнейшего чтения
  1. Партицирование
    1. Локальное – разделяем большие master таблицы на более маленькие по ключу в рамках 1 инстанса для оптимизации работы с IO
    2. Шардирование – разделение и хранение master таблиц на разных инстансах
  1. Бэкапы – некоторые хранилища умеют в автоматический бэкап горячих / холодных данных, например, в S3 (TimescaleDB, Redpanda)

xii. Event bus

Тут есть 2 варианта:
 
  1. Практически всегда в первую очередь вам нужно будет персистентное хранилище наподобие Kafka:
    1. Это дает возможность обрабатывать сообщения батчами
    2. Перечитывать сообщения при необходимости инвалидации
    3. Писать за среднестатистически константное время
    4. Иметь горизонтальное масштабирование
  1. Если же вы готовы пожертвовать надежностью доставки и персистенцией во имя скорости, то используйте Message Broker (NATS.io / EMQX)
 
Ну и никто не мешает вам миксовать оба подхода.

xiii. SQLite

 
SQLite по факту это спецификация библиотеки, которая позволяет оперировать над файлами базы данных, напрямую из языка программирования
 
Из преимуществ
 
  • Благодаря тому что скорость работы SQLite зависит от скорости языка + мощности процессора + пропускной способности IO, теоритически это одна из самых быстрых БД, как минимум, потому что в ней полностью отсутствует вся сетевая сложность стандартных баз
  • Если другие встроенные хранилища это просто key-value (rocksdb, leveldb, badger), или NoSQL со своим уникальным SDK (couchdb-like), то SQLite это полноценная SQL база, сравнивая по возможностям SQL с PostgreSQL: схема, индексы, транзакции, локи, джоины, констрайнты, все есть короче
  • Соответственно и опыт на любой другой БД, будет релевантен, что делает SQLite очень привлекательной для разработчиков
  • И из этого же вытекает то, что можно практически безболезненно пересесть со SQLite на PostgreSQL / MySQL, когда / если придет время
  • Из-за своей простоты SQLite или уже интегрирован (барузер, мобилки, нативки) или легко добавляется (видел железки с минимальным Linux на борту, на которых приложения пользуют SQLite)
  • Можно работать с одним инстансом сразу из нескольких процессов
  • Для бэкапа БД, нужно просто закинуть файлы на S3
  • Суперпростое интеграционное тестирование, потому что вы можете хоть на каждый тест свой sqlite инстанс (например, inmemory)
  • Если вы создадите SaaS без multitenancy и будете распространять его как коробочное решение, то без проблем сможете открывать по инстансу на клиента
  • Настолько бесплатно, насколько возможно
 
Из недостатков
 
  • Классические сетевые файловые системы не позволиляют с нескольких серверов / контейнеров работать с одной и той же SQLite базой (обнаружил только информацию про VFS, но пока не понимаю насколько это рабочий вариант)
  • Если что-то произойдет с файловой системой при записи данных, есть шанс поломаться так, что просто не восстановитесь
Кейсы применения
  • Локальная БД для приложения с фронтом (веб, мобилки, десктопы)
  • Локальная БД для удаленных агентов (приложение, которое собирает и отправляет данные в клауд с устройств, с сервера на складе)
  • Кэш для одного инстанса (например, для акторов)
  • База данных для стартапа / проекта, требующего работу с большим объемом данных, но при этом нет желания платить много денег за инстанс PostgreSQL / MySQL
Решение недостатков
Нам нужны 2 вещи:
 
  • Sync Read реплики, чтобы можно было переключиться при падении master
  • WAL Streaming Backup для надежных бэкапов
 
Опционально, async read реплики, для чтения с задержкой или вообще CRDT для превращения ее в distributed multi-master p2p базу
 
И в идеале все это встраиваемое в саму sqlite, то есть встроиваемое в язык, или как sidecar процесс. Иначе, мне кажется, использование SQLite просто теряет смысл
 
Интересные проекты
 
  • Pocketbase (https://pocketbase.io/) – админка, Firebaselike HTTP API, email, auth, file storage, logs и много чего другого прямо из коробки
  • Electric (https://electric-sql.com/) – на стороне клиента SQLite, который синхронизируется с PostgreSQL, используя CRDT, превращающие SQLite в multi-master edge базу данных
  • libSQL (https://github.com/tursodatabase/libsql/) – форк SQLite, на котором построен Turso, с возможностью разворачивать сервер, реплики, автосинк WAL в S3 и так далее
  • rqlite (https://github.com/rqlite/rqlite) – превращение SQLite в полноценную БД с read репликами, написанная на Go
 
Какое будущее у SQLite
 
  • Во-первых, видно, что SQLite превращается в БД для Edge среды, потому что она позволяет оперировать на минимальных ресурсах
  • Также, ей точно уготовано стать p2p базой (в стиле couchbase), потому что она уже проинтегрирована / просто интегрируется в любого клиента
  • При добавлении встроенных Sync Read реплик позволит с большей вероятностью использовать ее в продакшен приложениях
 
Еще мысли
 
  • Если мне придется сейчас разрабатывать стандартное веб-приложение для бизнеса, я бы не стал заморчиваться и воспользовался бы PostgreSQL
  • Для своих проектов с удовольствием ей воспользуюсь
  • Если сработает LiteFS или libSQL, то я бы серьезнее рассмотрел ее использование в рамках продакшен приложений
 

🔎 Тестирование

i. Общее

 
  1. Бизнес-логику тестируем абсолютно всегда, все остальное по желанию
  1. Если функция не использует инфру, то пишем юнит тесты
  1. Во всех остальных случаях всегда пишем интеграционные тесты
  1. Тесты – это просто (надо только научиться их разворачивать)
  1. Тесты – это дополнительный час кодинга, сохранивший тебе 10 часов сна
  1. Сколько тестов нужно написать на функцию? Один, дальше по желанию
  1. Когда нашли баг, напиши сначала тест, чтобы его воспроизвести, потом решай баг
 

ii. Юнит, Интеграционные, E2E тесты

 
  1. Юнит тесты – тестируем без окружения (БД, кэшей, API, etc.), то есть все интеграции заменяем на моки, тесты являются частью кода.
  1. Интеграционные тесты – раскрываем интеграции (БД, кэшей, API, etc.) под тесты, тесты являются частью кода.
  1. E2E – раскрываем полное приложение со всеми интеграциями и дергаем его API, тесты являются отдельной кодовой базой.

iii. Интеграционные тесты

 
  1. Скрипт запуска можно написать в Makefile
  1. Перед запуском тестов поднимите локальное окружение (лучше через docker-compose)
  1. Дождитесь запуска
  1. Накатите миграции и фикстуры
  1. Перед каждым тестом делайте снепшот БД
  1. На каждый тест делайте отдельное соединение
  1. После каждого удаляйте снепшот и закрывайте соединение
  1. В конце тестов НЕ закрывайте окружение
 

🌎 Логгирование, метрики, трейсинг

i. Метрики – ваше паучье чутью, Тресы – ваша карта, Логи – ваши глаза

 
  1. На большом объеме единственный способ видеть, что все (не)ок – только метрики
  1. Второстепенным инструментом является трейсинг
  1. И только в последнюю очередь для уточнения деталей мы используем логгирование
 
Соответственно, по приоритету мы должны первостепенно добавлять метрики, второстепенно трейсинг и третьестепенно логи.
 
Если же у вас небольшой проект, то приоритет идет в обратную сторону.

ii. Мета-инфа логов

 
  1. Добавляйте commit hash
  1. Level
  1. Название сервиса
  1. Уникальный id
  1. Время старта сервиса
  1. Стэк вызова функций
  1. ID запроса
  1. Trace ID
 

iii. Техническое

 
  1. Используете логгеры с нулевыми аллокациями
  1. Пишите в stdout
  1. Проверьте что логгер пишет асснихронно
 

👨‍👧‍👦 Leading

 

i. Триптих – идеальная структура тех. команды

 
В очередной раз прихожу к тому, что технические команды должны состоять из Team Lead + Senior + набор Middle разрабов и никак иначе.
 
  • Team lead (уши и рот) – единая входная точка для бизнеса, которая отвечает за сроки поставки решений и технический бэклог и состояние команды, создает условия для работы Senior и Middle
  • Senior (мозг) – отвечает за качество и работоспособность всей системы, а значит: принимает технические решения, имеет право вето, тушит пожары, создает технические условия для работы Middle
  • Middle (руки) – отвественный за работоспособность, написанного им кода: согласует решения с Senior, пилит фичи и проверяет что они работают
 
Примечания
 
  • Я назвал их "Team lead", "Senior" и "Middle", потому что нет более подходящих слов (кроме тех, что в скобочках), на самом деле на роли "Middle" может быть человек уровня "Senior"
  • Это РОЛИ, а значит их может в себе совмещать в себе один и тот же человек
  • При этом Team Lead и Senior должно быть максимум по одному, а вот Middle может быть много, но в идеале в пределах 10-ти
  • Team Lead может не иметь технических навыков (таких я назыаю PM, но это не снимает с них ответственности, описанной выше)
  • Я намеренно не включал QA, DevOps, CTO, Архитектор, etc. потому что это или может переиспользовать написанное выше, или более гипербализированная версия (например, CTO это Team Lead, только который еще и за ФОТ отвечает)
 
Почему именно такая команда
 
  • Решения ставновятся (не)правильными только после того, как вы их применили, поэтому кто-то должен просто брать на себя ответственность за то, чтобы выбрать какой-то путь и ждать результата. Если такое решение будут пытаться утвердить несколько человек, то у них возникнут большие проблемы, соответственно, капитан (Senior) должен быть всего один.
  • Коммуникация с бизнесом – боль. Ее бывает невероятно много. Причем как и на вход в команду, так и на выход. Одновременно с этим, бизнес очень часто не умеет общаться с разрабами и наоборот, поэтому пускать их друг к другу на постоянной основе точно не стоит. Их можно знакомить и оставлять на какое-то время (создание фичи), но вся коммуникация и ответственность должна быть в одних руках (Team Lead)
  • Senior дополняют Middle, а Middle дополняют Senior: Middle может получить все нужные ему знания, Senior может реализовать себя как "сенсей" и при этом сам научиться, структурировав свои знания во время передачи их Middle разработчику. Такой Уроборос позволяют им обоим расти и получать от этого удовольтвие. Два Senior разработчика могут (для начала, разосраться, но это я выше писал, поэтому в данной ситуации) задавать друг другу слишом мало вопросов (например, из-за стеснения) и пилить какую-нибудь невероятную херь просто потому что не решились ее заранее обсудить (это в целом про зрелось, но это отдельный топик)
 
Как такую создать
 
  • Открыто и явно проговорить кто какую роль на себя берет и чтобы Senior явно передали права на решения по бизнес-части Team Lead, а Middle явно передали права за технические решения Senior
  • Установить хорошую, открытую и постоянную коммуникацию между всеми этими звеньями
  • Дать возможность Senior делать технические решения и нанимать людей в соответствии с ними
 
В чем опасность
 
  • Если у Team Lead плохо прокачаны Soft skills и нет "стальных яиц" с умением говорить "нет" как разрабам, так и бизнесу, все будут выгорать, будет страдать бизнес и начнется текучка
  • Если выбрать плохого Senior, то он поведет всю команду в бездну, при этом если не дать Senior право принимать опасные решения, то хороший Senior тоже ничего не способен будет сделать
  • Если Senior не готовы слушать Middle-ов или Middle-ы соглашаться с итоговыми решениями Senior схема не сработает, поэтому именно Senior должен собирать команду
 

💻 Программирование

i. Абсолютно все конкурентно

Даже в рамках однопоточных языков как только вы создаете долгоживущие объекты (например, in-memory cache) ВСЕГДА относитесь к ним, как к сущностям, которые могут быть изменены с разных точек программы в конкурентном режиме.
 
И для этого:

ii. Избегай Mutex

Mutex позволяют нам синхронизироваться в конкурентной среде, НО mutex всегда имеет огромный шанс застрять навсегда или же превратиться в каскад взаимосвязанных mutex.
 
Практически всегда я предпочту построение операций в последовательность, чтобы избежать Mutex и вам тоже советую.
 
Примеры паттернов с последовательной обработкой: Actor Model, Serializable Transactions, RAFT.
 

iii. Программируй так, как будто все уже сломалось

Приложения падают. Причем делают это в самых неприятных и опасных местах.
 
Чаще всего, по вашей вине, но в 10% случае из-за внешних обстоятельств.
 
Всегда программируйте так, как будто приложение упадет в любой момент времени:
 
  1. Если у вас есть state-machine, не забудьте написать крон, который будет доводить подвисшие state-machine до какого-либо состояния.
  1. Если есть набор данных, которые должны быть записаны вместе или не записаны вообще, то используйте (1) заранее проведите все операции над сущностями и в конце запишите одной атомарной операцией, (2) объедините эти данные в одну таблицу / сообщение в очередь (и если надо будет их разъеденять, делайте это отдельным процессом), (3) создайте и запустить state-machine (рано или поздно она сделает то, что нужно), (4) используйте транзакции.
  1. Если вам нужны данные в моменте времени, то пишите их как только смогли собрать воедино, НО озаботьтесь, чтобы запись была идемпонентна (то есть, если в этуже логику кода попадут теже самые входные данные, она не сделала двойной записи)
  1. Вам может повезти и приложение будет падать с SIGINT или SIGTERM, поэтому озаботьтесь написанием graceful-shutdown.
 

iv. И многое, многое другое

Вообще, все философские основы по которых я стараюсь придерживаться я описал в главе “Столпы” книги ФОП, поэтому оставлю здесь на нее ссылку:
 
 
💡
Подписывайтесь на телеграм канал 🦾 IT-Качалка Давида Шекунца 💪, где будут выходить анонсы новых глав и обновления существующих. Спасибо за внимание и мощной вам прокачки 💪

👨🏻 Об авторе

 
notion image
 
Привет! Меня зовут Давид Шекунц и я Full-Stack Tech Lead на JS & Go (рассказываю про разработку в 🦾 IT-Качалке 💪)
Канал: t.me/it_kachalka
 
Всем мощной прокачки 💪