Аспектно-ориентированное и событийно-ориентированное программирование
Все парадигмы, разобранные до сих пор, сохраняли одно свойство: глядя на строчку кода, вы понимали, что произойдёт дальше. В императивной — следующий оператор. В ООП — метод, выбранный по динамическому типу, но всё же из известного набора. В функциональной — применение функции к аргументам. Даже декларативная отдаёт управление движку, но движок один и он документирован.
Две парадигмы этой статьи ломают именно это свойство — и делают это осознанно, ради вполне конкретной выгоды.
- Аспектно-ориентированное программирование (АОП) вмешивается в чужой поток управления «сбоку»: код вызывается в точке, где о нём не написано ни слова.
- Событийно-ориентированное программирование (СОП) инвертирует поток во времени: отправитель не знает, кто и когда отреагирует, и не ждёт ответа.
Обе — частные случаи архитектурного стиля, который в литературе по архитектуре ПО называется implicit invocation (неявный вызов): связь между вызывающим и вызываемым устанавливается не в точке вызова, а где-то ещё — в конфигурации, в срезе, в подписке. Классическое описание — у Гарлана и Шоу в «An Introduction to Software Architecture» (CMU, 1994).
Держите эту рамку в голове всю статью. Она объясняет и общий выигрыш (ортогональность, эволюционируемость), и общую беду: вы теряете способность рассуждать о поведении локально, читая один файл.
Часть I. Аспектно-ориентированное программирование
Проблема: тирания доминирующей декомпозиции
Любой язык навязывает одну главную ось разбиения: процедуры, классы, модули, функции. Разбиение по этой оси хорошо работает для функциональности, которая ложится в ось. И совершенно не работает для всего остального.
Возьмите четыре сервиса и три «сквозные» задачи: трассировка, транзакции, авторизация. Ни одна из трёх не является отдельным модулем — каждая размазана по всем четырём. Возникает два симптома:
- Рассеяние (scattering) — одна задача реализована во многих модулях. Поменялся формат логов — правки в сорока файлах.
- Переплетение (tangling) — один модуль содержит куски многих задач. Метод на 12 строк, из которых 4 про бизнес и 8 про логи, метрики и повторные попытки.
Эту ситуацию Тарр, Оссер, Харрисон и Саттон назвали тиранией доминирующей декомпозиции в работе «N Degrees of Separation: Multi-Dimensional Separation of Concerns» (ICSE 1999). Идея АОП, предложенная группой Грегора Кичалеса в Xerox PARC — «Aspect-Oriented Programming» (ECOOP 1997), — состоит в том, чтобы добавить вторую ось декомпозиции и склеивать оси отдельным шагом сборки.
Модель точек соединения — сердце парадигмы
Аспектный язык определяется тремя вещами. Всё остальное — синтаксис.
- Точки соединения (join points) — множество мест в исполнении программы, куда в принципе можно вмешаться. Это не «строки кода», а события исполнения: вызов метода, выполнение метода, чтение поля, запись поля, вызов конструктора, обработка исключения, инициализация класса.
- Срезы (pointcuts) — язык запросов, выбирающий подмножество точек соединения. Именно язык, а не список:
execution(* com.shop.service.*.*(..))— это предикат. - Советы (advice) — код, который выполняется относительно выбранных точек:
before,after returning,after throwing,after(finally),around.
Дополнительно почти все реализации дают межтиповые объявления (inter-type declarations): добавить полю/методу/интерфейсу класс извне, не трогая его исходник.
Ключевое наблюдение: выразительность аспектного языка равна выразительности его модели точек соединения. Spring AOP умеет только execution публичных методов Spring-бинов — этого хватает для транзакций и кэша, но не для «залогировать любое присваивание полю balance». AspectJ умеет и это.
Around-совет и proceed: почему это не просто «хук»
around — единственный вид совета, который получает управление целиком и решает, звать ли оригинальный код и сколько раз. Это делает его эквивалентом декоратора, но применяемым по срезу, а не поимённо.
// AspectJ: повторные попытки для всех методов, помеченных @Retryable.
// Заметьте: срез привязан к АННОТАЦИИ, а не к имени метода — это принципиально.
@Aspect
public class RetryAspect {
@Pointcut("@annotation(retryable)")
public void retryPoint(Retryable retryable) {}
@Around("retryPoint(retryable)")
public Object aroundRetry(ProceedingJoinPoint pjp, Retryable retryable) throws Throwable {
int attempts = 0;
long backoffMs = retryable.initialBackoffMs();
while (true) {
try {
return pjp.proceed(); // вызов оригинального метода
} catch (TransientException e) {
attempts++;
if (attempts >= retryable.maxAttempts()) throw e;
// экспоненциальная задержка с джиттером: без джиттера получите
// синхронный «шторм» повторов от всех клиентов сразу
long jitter = ThreadLocalRandom.current().nextLong(backoffMs / 2 + 1);
Thread.sleep(backoffMs + jitter);
backoffMs = Math.min(backoffMs * 2, retryable.maxBackoffMs());
}
}
}
}
Сложность здесь важна не алгоритмическая, а операционная: around с повторами умножает время худшего случая на число попыток. Если такой аспект висит на трёх уровнях вложенных вызовов, число попыток перемножается: 3 уровня по 3 попытки — это 27 обращений к внешней системе. Это самая частая производственная авария, связанная с АОП: ретраи, размноженные вложенностью. Правило: повторные попытки живут ровно на одном слое, обычно самом внешнем к сети.
Вплетение: четыре момента и их цена
Вплетение (weaving) — процесс подстановки советов в точки соединения. Момент вплетения определяет всё: производительность, возможности, отлаживаемость.
ajc: байт-код уже содержит советы"] C1 -->|"После компиляции"| BIN["Бинарное вплетение
в готовые .jar без исходников"] C1 -->|"Загрузка класса"| LTW["Java-агент, javaagent + ClassFileTransformer
OpenTelemetry, ByteBuddy, APM"] C1 -->|"Выполнение"| RT["Динамические прокси
Spring AOP, Castle DynamicProxy, wrapt"] CT --> P1["Полная модель точек соединения
Нулевой накладной расход на диспетчеризацию
Нужен свой компилятор в сборке"] BIN --> P2["Инструментируются чужие библиотеки
Сложно сопоставлять со стек-трейсами"] LTW --> P3["Ничего не меняем в сборке приложения
Работает с любым кодом на classpath
Замедляет старт, ломается при смене версий JDK"] RT --> P4["Просто и прозрачно
Только вызовы через прокси
Самовызовы НЕ перехватываются"] P1 --> OUT["Итоговое поведение системы"] P2 --> OUT P3 --> OUT P4 --> OUT
Самая распространённая ловушка — последняя строчка правого блока. Разберём её отдельно, потому что на ней спотыкаются буквально все.
Ловушка самовызова: почему @Transactional «не сработал»
Spring AOP реализован проксированием: контейнер отдаёт вам не сам бин, а обёртку (JDK-прокси по интерфейсу или CGLIB-подкласс). Перехватываются только вызовы, прошедшие через ссылку на прокси. Вызов this.method() внутри объекта идёт мимо.
@Service
public class OrderService {
public void placeOrder(OrderRequest req) {
validate(req);
saveOrder(req); // <-- ВНУТРЕННИЙ вызов: прокси НЕ участвует,
} // аннотация @Transactional будет проигнорирована
@Transactional
public void saveOrder(OrderRequest req) { /* ... */ }
}
Симптом в проде: транзакция не откатывается, кэш не работает, @Async выполняется синхронно — и всё это молча, без единой ошибки. Варианты лечения:
- Вынести аннотированный метод в другой бин — самое честное решение, оно же обычно улучшает дизайн.
- Использовать
AopContext.currentProxy()— работает, но привязывает код к фреймворку. - Перейти на AspectJ с load-time weaving (
@EnableTransactionManagement(mode = AdviceMode.ASPECTJ)) — тогда точка соединенияexecutionловится независимо от того, кто вызвал.
Официальное предупреждение об этом есть прямо в документации: Spring Framework, «Understanding AOP Proxies». Это идеальная иллюстрация тезиса: аспект работает не потому, что вы его написали, а потому, что механизм вплетения увидел точку соединения.
Последовательность прохождения советов
Когда на один метод навешано пять аспектов, порядок перестаёт быть деталью.
Два вывода, которые стоят производственных инцидентов:
- Трассировка должна быть снаружи транзакции, иначе span закроется до
commit()и вы никогда не увидите время фиксации — а именно там обычно и прячется хвостовая задержка. - Авторизация должна быть снаружи транзакции, иначе отказ в доступе откроет и откатит пустую транзакцию; при большом потоке отказов это заметная нагрузка на пул соединений.
В Spring порядок задаётся @Order или Ordered; в AspectJ — declare precedence. Если порядок не задан явно, он не определён — и может измениться при обновлении версии фреймворка.
То же самое на других языках
АОП как отдельное слово почти вышло из моды, но механизм жив везде — под другими именами.
# Python: декоратор + wrapt. Обычный functools.wraps ломает интроспекцию
# и не работает корректно для методов и дескрипторов; wrapt делает прозрачный прокси.
import time
import wrapt
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
@wrapt.decorator
def traced(wrapped, instance, args, kwargs):
"""Around-совет: имя точки соединения берём из самой функции."""
name = f"{wrapped.__module__}.{wrapped.__qualname__}"
with tracer.start_as_current_span(name) as span:
started = time.perf_counter()
try:
return wrapped(*args, **kwargs)
except Exception as exc: # after throwing
span.record_exception(exc)
span.set_status(trace.Status(trace.StatusCode.ERROR))
raise
finally: # after (finally)
span.set_attribute("duration_ms", (time.perf_counter() - started) * 1000)
class OrderService:
@traced
def place_order(self, req): ...
// Go сознательно отказался от неявного вплетения. Вместо этого — явные цепочки
// middleware: тот же around-совет, но связь видна в точке сборки приложения.
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
// применяем в обратном порядке, чтобы mws[0] оказался самым внешним
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func WithTimeout(d time.Duration) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx)) // proceed()
})
}
}
// Сборка: srv := Chain(mux, Recover, Trace, Auth, WithTimeout(3*time.Second))
// Порядок виден глазами. Это главное отличие от аспектов.
Философия Go здесь принципиальна и хорошо оттеняет АОП: язык предпочитает явную связь ценой многословия — подробнее о его подходе в обзорной статье трека Go. В C# работают DispatchProxy, Castle DynamicProxy, генераторы исходного кода и появившиеся в C# 12 interceptors; в ASP.NET Core — та же цепочка middleware. В Elixir сквозная функциональность обычно решается не перехватом, а :telemetry — публикацией событий из библиотек и подпиской снаружи, то есть АОП, переписанным как СОП; это отличная подсказка о родстве двух парадигм.
Проблема хрупких срезов и как её лечить
Главный технический дефект АОП описан в литературе как fragile pointcut problem: срез — это запрос к структуре программы, а структура меняется независимо от аспекта.
// Хрупкий срез: держится на соглашении об именах
@Around("execution(* com.shop..*Service.save*(..))")
Достаточно переименовать saveOrder в persistOrder — и транзакции тихо исчезнут. Компилятор промолчит, тесты, если они не покрывают транзакционность, тоже. Систематический разбор — у Кёппена и Штёрцера, «PCDiff: Attacking the Fragile Pointcut Problem» (EIWAS, 2004), и у Келленса и др., «Managing the Evolution of Aspect-Oriented Software with Model-based Pointcuts» (ECOOP 2006).
Практическое лечение одно и оно работает:
- Привязывайте срезы к аннотациям, а не к именам.
@annotation(Transactional)— это явный контракт, который видно в коде класса. Аннотация — компромисс: связь становится частично явной («на этом методе что-то висит»), сохраняя вынесенную реализацию. - Если аннотации нельзя (чужая библиотека) — привязывайтесь к типам и интерфейсам, они стабильнее имён методов.
- Держите тест на сам срез: тест, который проверяет, что множество перехваченных методов равно ожидаемому. В Spring это делается через
AopUtils/AopProxyUtils, в AspectJ — черезdeclare warning, который превращает несовпадение среза в предупреждение компилятора.
Когда АОП оправдано, а когда нет
| Свойство задачи | Аспект уместен | Аспект вреден |
|---|---|---|
| Ортогональность бизнес-логике | полная: логи, метрики, трассировка, кэш, ретраи, транзакции, аудит, права | частичная: правило «для VIP-клиентов скидка» |
| Наблюдаемость эффекта | эффект виден снаружи (span, запись в лог, откат) | эффект меняет возвращаемое значение молча |
| Количество точек | десятки и сотни однотипных мест | 2–3 места — просто напишите вызов |
| Стабильность структуры | срез по аннотации/интерфейсу | срез по маске имён в живо меняющемся коде |
| Читатель кода | ожидает магию (фреймворк, известный всем) | ожидает прямой поток управления |
Формулировка, которой стоит пользоваться на код-ревью: аспект допустим, если удаление аспекта не ломает бизнес-корректность программы, а лишь ухудшает её эксплуатационные свойства. Логи, метрики, кэш — удаляются безопасно. «Аспект, который начисляет бонусы» — нет; это спрятанная бизнес-логика, и её нужно вернуть в модуль.
Где АОП живёт в проде сегодня
- Spring:
@Transactional,@Cacheable,@Async,@PreAuthorize,@Retryable. Самая массовая АОП-система в мире — просто её так не называют. - Автоинструментирование APM: Java-агент OpenTelemetry вплетает трассировку в сотни библиотек через ByteBuddy, не трогая ваш код. Модель точек соединения — «точки входа известных библиотек». Аналогично работают агенты Datadog, New Relic, Elastic APM.
- eBPF: перехват на уровне ядра (
kprobe,uprobe,tracepoint) — буквально модель точек соединения для ОС. Cilium и Pixie — АОП для сети и системных вызовов. - Service mesh: sidecar Envoy перехватывает сетевые вызовы и добавляет ретраи, mTLS, таймауты, трассировку. Это АОП, у которого точки соединения — не методы, а соединения TCP. Связь с архитектурой обсуждается в статье о выборе и смешении парадигм.
- Метапрограммирование: макросы Rust (
#[instrument]изtracing), макросы Elixir, кодогенерация — статические альтернативы вплетению, без рантайм-накладных расходов. Их механика разобрана в статье об обобщённом программировании и метапрограммировании.
Часть II. Событийно-ориентированное программирование
Инверсия управления во времени
АОП инвертирует место вызова. СОП инвертирует время: вместо «вызвать и ждать» — «сообщить и забыть». Голливудский принцип: «не звоните нам, мы позвоним вам».
Разница между вызовом и событием не техническая, а смысловая:
| Команда / вызов | Событие | |
|---|---|---|
| Смысл | «сделай это» | «это произошло» |
| Время | будущее, повелительное наклонение | прошедшее, факт |
| Адресат | ровно один, известен | неизвестен, может не быть вовсе |
| Отказ | возможен, отправитель узнает | невозможен: факт нельзя отклонить |
| Именование | ReserveInventory |
OrderPlaced |
| Владелец схемы | получатель | отправитель |
Самая частая ошибка в СОП-системах — команда, замаскированная под событие: SendEmailEvent, CreateInvoiceEvent. Отправитель по-прежнему знает, что должно произойти, и знает получателя; вы получили всю сложность асинхронности и ни капли развязки. Это прямая дорога к «распределённому монолиту». Проверка простая: если у события ровно один законный подписчик и отправителю не всё равно, обработал ли он его, — это команда, и её честнее послать напрямую или через очередь команд.
Четыре разных вещи, которые называют «событийным»
Мартин Фаулер в докладе «What do you mean by ‘Event-Driven’?» (2017) разделил четыре несовместимых паттерна, которые все зовут одним словом. Это разделение стоит выучить наизусть — половина споров об архитектуре растворяется.
- Event Notification — тонкое событие: «заказ 42 размещён», и всё. Подписчик, которому нужны детали, идёт за ними по API. Максимальная развязка данных, но появляется обратный вызов (и обратная зависимость), а поток исполнения становится невидимым: чтобы понять, что произойдёт после заказа, нужно грепать всю кодовую базу по имени события.
- Event-Carried State Transfer — толстое событие, несущее данные. Подписчик держит локальную реплику и не ходит к источнику: устойчивость к отказам источника растёт, но появляются дублирование данных и неизбежная устарелость реплики (eventual consistency).
- Event Sourcing — журнал событий становится источником истины, состояние есть свёртка журнала. Даёт полный аудит и возможность «переиграть» историю новым кодом.
- CQRS — разделение модели записи и модели чтения. Часто идёт с event sourcing, но независимо от него.
Практический совет: 1 и 2 берите свободно. 3 и 4 — только там, где история операций сама по себе является требованием (финансы, склад, комплаенс, коллаборативные редакторы). Event sourcing «для гибкости» — самая дорогая архитектурная ошибка последнего десятилетия.
Журнал как структура данных
Ключевая интуиция распределённого СОП: брокер вроде Kafka — это не очередь, а разделяемый упорядоченный журнал с курсорами читателей. Отсюда следуют все его свойства.
- Запись — O(1) амортизированно, последовательная запись на диск; именно поэтому пропускная способность так высока.
- Чтение не удаляет запись: N групп потребителей читают независимо, стоимость записи не растёт с N.
- Порядок гарантирован только внутри раздела (partition). Ключ разделения — это ваша граница упорядоченности и, фактически, граница консистентности. Ключ
order_idдаёт порядок событий одного заказа; ключuser_id— порядок событий пользователя; случайный ключ не даёт ничего. - Отставание (lag) — главная метрика здоровья.
lag < retention— инвариант выживания: отставший больше окна хранения потребитель теряет данные безвозвратно.
Каноническое объяснение — статья Джея Крепса «The Log: What every software engineer should know about real-time data’s unifying abstraction» и книга Клеппмана «Designing Data-Intensive Applications», глава 11.
Семантика доставки: почему exactly-once — это не про доставку
Три уровня, и только один из них правда:
- At-most-once — подтверждаем до обработки. Потерь допустимо (метрики, телеметрия). Дёшево.
- At-least-once — подтверждаем после обработки. Дубликаты неизбежны: падение между обработкой и подтверждением приводит к повтору. Это режим по умолчанию для любой честной системы.
- Exactly-once как свойство доставки — недостижимо в общем случае; это следствие невозможности консенсуса в асинхронной сети с отказами (Фишер, Линч, Патерсон, FLP, 1985) и задачи двух генералов.
То, что достижимо и что вам на самом деле нужно, — effectively-once: at-least-once доставка плюс идемпотентный потребитель. Транзакции Kafka дают атомарность связки «прочитал — обработал — записал» внутри Kafka; как только вы делаете побочный эффект наружу (списание денег, письмо, HTTP-запрос), гарантия заканчивается и работает только идемпотентность.
-- Идемпотентный потребитель: дедупликация по идентификатору сообщения.
-- Ключевая деталь — вставка в processed_messages и бизнес-эффект
-- ОДНОЙ транзакцией. Иначе останется окно на падение между ними.
CREATE TABLE processed_messages (
message_id UUID PRIMARY KEY,
consumer TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Чистка старых записей обязательна, иначе таблица растёт без границ:
CREATE INDEX ON processed_messages (processed_at);
BEGIN;
INSERT INTO processed_messages (message_id, consumer)
VALUES ($1, 'billing')
ON CONFLICT (message_id) DO NOTHING;
-- 0 строк => это дубликат, бизнес-эффект пропускаем и коммитим
UPDATE accounts SET balance = balance - $2 WHERE id = $3;
COMMIT;
Альтернатива дедупликации — естественная идемпотентность: операция, которую можно применить дважды без вреда. SET status = 'paid' идемпотентна, balance = balance - 100 — нет. Проектируйте события так, чтобы обработчики были идемпотентны по построению; это дешевле любой таблицы дедупликации.
Проблема двойной записи и transactional outbox
Самая недооценённая проблема СОП. Обработчик должен сделать два действия: записать в БД и опубликовать событие. Это две разные системы, общей транзакции нет.
CREATE TABLE outbox (
id UUID PRIMARY KEY, -- он же ключ дедупликации у потребителя
aggregate_id UUID NOT NULL, -- он же ключ раздела: сохраняет порядок
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ -- NULL = ещё не отправлено
);
-- Частичный индекс: relay сканирует только неотправленные, размер индекса ~ размеру очереди,
-- а не таблицы. Без него запрос деградирует по мере роста outbox.
CREATE INDEX outbox_unpublished_idx ON outbox (created_at) WHERE published_at IS NULL;
Ретранслятор реализуется двумя способами: polling publisher (простой SELECT ... WHERE published_at IS NULL ORDER BY created_at LIMIT n FOR UPDATE SKIP LOCKED) или change data capture через чтение WAL — Debezium делает это готовым коннектором. CDC не нагружает БД опросом и не отстаёт, но добавляет ещё один компонент в эксплуатацию. Каталог паттерна — у Криса Ричардсона: Transactional Outbox.
Event sourcing: состояние как свёртка
"""Event sourcing на минимальном примере. Состояние агрегата не хранится —
оно вычисляется свёрткой журнала. Хранится журнал."""
from dataclasses import dataclass, replace
from decimal import Decimal
from functools import reduce
from typing import Iterable
@dataclass(frozen=True)
class OrderPlaced: order_id: str; total: Decimal
@dataclass(frozen=True)
class ItemAdded: order_id: str; sku: str; price: Decimal
@dataclass(frozen=True)
class OrderPaid: order_id: str; amount: Decimal
@dataclass(frozen=True)
class OrderCancelled: order_id: str; reason: str
@dataclass(frozen=True)
class Order:
order_id: str = ""
total: Decimal = Decimal(0)
paid: Decimal = Decimal(0)
status: str = "new"
def apply(state: Order, event) -> Order:
"""Чистая функция перехода. Ни ввода-вывода, ни валидации, ни исключений:
события — это факты, они уже случились и не могут быть отвергнуты."""
match event:
case OrderPlaced(order_id, total):
return replace(state, order_id=order_id, total=total, status="placed")
case ItemAdded(_, _, price):
return replace(state, total=state.total + price)
case OrderPaid(_, amount):
paid = state.paid + amount
return replace(state, paid=paid,
status="paid" if paid >= state.total else state.status)
case OrderCancelled():
return replace(state, status="cancelled")
case _:
return state # неизвестное событие игнорируем: forward compatibility
def rehydrate(events: Iterable, snapshot: Order | None = None) -> Order:
"""O(k) по числу событий после снимка. Без снимков — O(n) от начала времён,
и через год у горячего агрегата это станет проблемой."""
return reduce(apply, events, snapshot or Order())
def decide(state: Order, command) -> list:
"""А вот ЗДЕСЬ живёт валидация: команда может быть отвергнута, событие — нет.
Это единственное место, где решается, что имеет право произойти."""
if state.status == "cancelled":
raise ValueError("заказ отменён, операции запрещены")
...
return []
Обратите внимание на структуру: apply — чистая функция без побочных эффектов, decide — чистая функция принятия решения, а весь ввод-вывод остаётся в тонкой оболочке вокруг. Это ровно «функциональное ядро — императивная оболочка» из статьи о функциональном программировании. Event sourcing и ФП сходятся не случайно: неизменяемый журнал и свёртка — это foldl над историей.
Анализ сложности и эксплуатационные последствия:
| Операция | Сложность | Что это значит на практике |
|---|---|---|
| Запись события | O(1) | быстрее, чем UPDATE: только append |
| Восстановление без снимков | O(n) по журналу агрегата | «горячий» агрегат с 200k событий загружается секундами |
| Восстановление со снимками каждые k | O(k) | k подбирают по замерам, обычно 50–500 |
| Запрос «все заказы со статусом X» | невозможен по журналу | нужна отдельная проекция — отсюда и растёт CQRS |
| Хранилище | O(число событий × размер) | растёт монотонно; удаление невозможно by design |
Последняя строка — источник самой болезненной практической проблемы: GDPR и право на забвение против неизменяемого журнала. Рабочие решения: крипто-шрёддинг (персональные данные шифруются ключом на субъекта, ключ удаляется — данные становятся мусором) либо вынос персональных данных из событий в отдельное изменяемое хранилище, а в событиях — только ссылки.
Жизненный цикл агрегата: события как переходы
Эта диаграмма содержит главный тезис распределённого СОП: отката не существует, существует компенсация. Отсюда — саги.
Саги: хореография против оркестрации
Бизнес-операция, растянутая на несколько сервисов, не может быть одной ACID-транзакцией. Сага — последовательность локальных транзакций, каждая со своей компенсирующей операцией. Исходная работа: Гарсиа-Молина и Салем, «Sagas» (SIGMOD 1987).
Два способа связать шаги:
Хореография — каждый сервис слушает события других и публикует свои. Нет центра, минимум связности… и никто в компании не может ответить на вопрос «что происходит после оплаты». Поток существует, но не записан нигде — он эмерджентен. Отладка сводится к чтению логов пяти сервисов в надежде найти корреляционный идентификатор.
Оркестрация — явный координатор (машина состояний), который рассылает команды и получает ответы. Появляется центральная зависимость, зато поток записан в одном месте, его можно нарисовать, протестировать и показать аналитику.
Практическое правило: до 3 шагов — хореография, от 4 — оркестрация. Инструменты оркестрации: Temporal, Camunda/Zeebe, AWS Step Functions, Cadence. Temporal особенно интересен тем, что превращает сагу обратно в обычный последовательный код, сохраняя устойчивость к падениям через детерминированное воспроизведение истории — по сути, event sourcing, спрятанный под императивным фасадом.
Эволюция схемы: то, из-за чего системы умирают через два года
Событие, лежащее в журнале, переживёт три поколения кода. Совместимость схем — не деталь, а вопрос выживания.
- Backward compatible — новый потребитель читает старые события. Обязательно для event sourcing (журнал вечен).
- Forward compatible — старый потребитель читает новые события. Нужно, чтобы деплоить продюсера и потребителя независимо.
- Full — оба сразу. Практическое требование в проде.
Правила, дающие полную совместимость: только добавление полей, только с значениями по умолчанию; никогда не переиспользовать номера полей (protobuf) и не менять типы; никогда не переименовывать; удаление — только через долгий цикл «пометить deprecated, дождаться нулевого трафика, удалить». Инфраструктура: Confluent Schema Registry с включённой проверкой совместимости в CI, Buf для protobuf. Проверка совместимости должна ломать сборку, а не выдавать предупреждение.
Для event sourcing добавляется версионирование событий и апкастинг: OrderPlacedV1 -> OrderPlacedV2 через чистую функцию преобразования при чтении журнала. Каноническое руководство — Greg Young, «Versioning in an Event Sourced System» (доступна бесплатно).
Типичные ошибки СОП
- Команда под видом события. Признак: имя в повелительном наклонении, ровно один подписчик, отправитель ждёт результата.
- Отсутствие корреляционного идентификатора. Без
correlation_id/traceparentв заголовках сообщений отладка распределённого потока невозможна физически. Пробрасывайте контекст трассировки в заголовках по W3C Trace Context — это делается один раз в инфраструктурном коде и окупается на первом же инциденте. - Молчаливое проглатывание ошибок. Обработчик упал, сообщение подтверждено, данные потеряны. Нужны DLQ, алерт на непустой DLQ и процедура повторной обработки.
- Бесконечный ретрай ядовитого сообщения. Одно неразбираемое сообщение блокирует раздел навсегда. Нужен ограниченный счётчик попыток и уход в DLQ.
- Игнорирование лага. Лаг — это SLO. Алертить нужно не на «лаг > N сообщений», а на возраст самого старого необработанного события: 10000 сообщений могут быть секундой трафика, а могут — сутками.
- Неверный ключ раздела. Порядок нарушается там, где он критичен; либо появляется «горячий» раздел, в который идёт 90% трафика.
- События как API базы данных. Публикация построчных изменений таблиц (сырой CDC наружу) намертво связывает потребителей со схемой БД. Публикуйте доменные факты, а не диффы строк.
- Синхронная цепочка, притворяющаяся асинхронной. Если пользователь ждёт ответа, а внутри пять брокерских хопов, вы получили худшее из двух миров: и задержку, и невозможность вернуть ошибку. Хвостовые задержки в цепочке складываются, и p99 всей цепи хуже, чем p99 худшего звена.
- Event sourcing там, где хватило бы таблицы аудита. Спросите: нужно ли переигрывать историю новым кодом? Если нет — обычная таблица
audit_logдаст 80% ценности за 5% сложности.
Наблюдаемость как обязательное условие
Обе парадигмы этой статьи разрушают локальное рассуждение — и обе компенсируются одним и тем же: инструментами, которые реконструируют неявную связь во время работы.
- Для АОП: список активных аспектов и перехваченных методов на старте приложения (Spring умеет отдавать это через Actuator), логирование порядка советов, тест на срез.
- Для СОП: сквозная трассировка через брокер, каталог событий с их схемами и подписчиками, метрики лага и возраста, мониторинг DLQ, дашборд «кто на что подписан».
Хороший индикатор зрелости системы: существует ли автоматически сгенерированная карта того, что на что реагирует. Если такой карты нет и она поддерживается в вики руками — она уже неверна.
Часть III. Общее: что вы отдаёте за неявный вызов
| Аспект сравнения | Явный вызов | АОП | СОП |
|---|---|---|---|
| Кто определяет связь | автор вызывающего кода | автор среза | подписчик |
| Когда связывается | компиляция | вплетение | подписка в рантайме |
| Видно в тексте вызывающего | да | нет (частично — по аннотации) | нет |
| Статический граф вызовов | полный | неполный | отсутствует |
| Стек-трейс осмыслен | да | замусорен, но связен | обрывается на границе брокера |
| Добавить поведение, не трогая код | нет | да | да |
| Синхронность | синхронно | синхронно | асинхронно |
| Отказ виден отправителю | да | да | нет |
| Тестируется | модульно | нужен интеграционный тест на вплетение | нужны контрактные тесты и тесты на дубликаты |
Общая формула компромисса: неявный вызов покупает эволюционируемость ценой понятности. Он оправдан, когда число мест связи велико, а сама связь однотипна и ортогональна смыслу. И вреден, когда связь уникальна и содержательна — тогда её нужно писать явно, буквами, в том месте, где её будут искать.
Отсюда простое эвристическое правило, которое стоит закрепить на уровне командного соглашения:
Неявным можно делать то, удаление чего не меняет смысл программы. Всё, что меняет смысл, должно быть написано явно.
Родство парадигм видно и технически: реактивное программирование — это СОП, которому добавили композицию и явное управление потоком (backpressure, операторы, определённая семантика ошибок); акторы — СОП, где подписчик единственный и владеет своим состоянием; :telemetry в Elixir — АОП, реализованное как СОП. Общая карта этих связей — в обзорной статье трека.
Мини-итог
- АОП и СОП — два вида неявного вызова: первый инвертирует место вызова, второй — время.
- Аспектный язык определяется моделью точек соединения: сами точки, язык срезов, виды советов. Всё остальное — синтаксис.
- Момент вплетения (компиляция / бинарное / загрузка класса / прокси) определяет и возможности, и ловушки. Самая известная ловушка — самовызов мимо прокси в Spring.
- Хрупкие срезы лечатся привязкой к аннотациям и типам, а не к маскам имён, плюс тестом на состав перехваченных методов.
- Аспект допустим, если его удаление не ломает бизнес-корректность. Бизнес-логика в аспекте — всегда ошибка.
- АОП жив в проде под именами:
@Transactional, Java-агенты APM, eBPF, service mesh, макросы. - Событие — факт в прошедшем времени без адресата. Команда под видом события даёт распределённый монолит.
- Четыре разных паттерна: notification, carried state transfer, event sourcing, CQRS. Первые два — недорого; последние два — только под реальное требование к истории.
- Exactly-once доставки не существует; существует at-least-once + идемпотентный потребитель = effectively-once.
- Двойная запись лечится transactional outbox (+ CDC), а не «сначала коммит, потом publish».
- Порядок гарантирован только внутри раздела; ключ раздела — это граница консистентности.
- Отката нет — есть компенсация; связка шагов — сага; больше трёх шагов — берите оркестрацию.
- Совместимость схем событий проверяйте в CI: только добавление полей, никогда не переиспользовать номера.
- Обе парадигмы обязаны компенсироваться наблюдаемостью, восстанавливающей неявную связь: трассировка, каталог событий, метрики лага, инвентарь аспектов.
Источники
- Gregor Kiczales и др. Aspect-Oriented Programming (ECOOP 1997) — статья, с которой всё началось.
- Peri Tarr, Harold Ossher, William Harrison, Stanley Sutton. N Degrees of Separation: Multi-Dimensional Separation of Concerns (ICSE 1999) — тирания доминирующей декомпозиции.
- Ramnivas Laddad. «AspectJ in Action», 2-е изд. — лучшая практическая книга по АОП.
- Документация AspectJ — язык срезов и виды вплетения из первых рук.
- Spring Framework: AOP и понимание прокси — обязательное чтение перед первым
@Transactional. - ByteBuddy и OpenTelemetry Java Agent — АОП промышленного масштаба.
- David Garlan, Mary Shaw. An Introduction to Software Architecture (CMU, 1994) — стиль implicit invocation.
- Martin Fowler. What do you mean by «Event-Driven»? — разделение четырёх паттернов.
- Martin Kleppmann. Designing Data-Intensive Applications — главы 11 и 12 о потоках и журналах; обязательная книга.
- Jay Kreps. The Log — журнал как универсальная абстракция.
- Hector Garcia-Molina, Kenneth Salem. Sagas (SIGMOD 1987).
- Chris Richardson. microservices.io: Transactional Outbox, Saga, Idempotent Consumer.
- Greg Young. Versioning in an Event Sourced System — бесплатно; про эволюцию схем.
- Michael Fisher, Nancy Lynch, Michael Paterson. Impossibility of Distributed Consensus with One Faulty Process (JACM 1985) — почему exactly-once недостижим.
- Debezium Outbox Event Router — CDC-реализация outbox.
- W3C Trace Context — стандарт проброса контекста трассировки через брокеры.
- Confluent Schema Registry: совместимость схем и Buf breaking change detection.
- Temporal — оркестрация саг с детерминированным воспроизведением.
Что дальше
Мы разобрали девять парадигм и увидели, что ни одна из них не является «правильной»: каждая покупает одно свойство ценой другого — АОП меняет понятность на эволюционируемость, СОП меняет предсказуемость на независимость компонентов, ФП меняет производительность на изоляцию эффектов, ООП меняет прямолинейность на расширяемость. Реальная система никогда не бывает написана в одной парадигме, и вопрос практика звучит иначе: где провести границы между парадигмами внутри одного продукта, как их сочетать, чтобы швы не рвались, и как объяснить выбор команде. Этим занимается финальная статья трека: Как выбирать и смешивать парадигмы в реальном проекте.