Парадигмы разработки Аспектно-ориентированное и событийно-ориентированное программирование
0%

Аспектно-ориентированное и событийно-ориентированное программирование

Аспектно-ориентированное и событийно-ориентированное программирование

Все парадигмы, разобранные до сих пор, сохраняли одно свойство: глядя на строчку кода, вы понимали, что произойдёт дальше. В императивной — следующий оператор. В ООП — метод, выбранный по динамическому типу, но всё же из известного набора. В функциональной — применение функции к аргументам. Даже декларативная отдаёт управление движку, но движок один и он документирован.

Две парадигмы этой статьи ломают именно это свойство — и делают это осознанно, ради вполне конкретной выгоды.

  • Аспектно-ориентированное программирование (АОП) вмешивается в чужой поток управления «сбоку»: код вызывается в точке, где о нём не написано ни слова.
  • Событийно-ориентированное программирование (СОП) инвертирует поток во времени: отправитель не знает, кто и когда отреагирует, и не ждёт ответа.

Обе — частные случаи архитектурного стиля, который в литературе по архитектуре ПО называется 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), — состоит в том, чтобы добавить вторую ось декомпозиции и склеивать оси отдельным шагом сборки.

Рассеяние и переплетение сквозной функциональности

Модель точек соединения — сердце парадигмы

Аспектный язык определяется тремя вещами. Всё остальное — синтаксис.

  1. Точки соединения (join points) — множество мест в исполнении программы, куда в принципе можно вмешаться. Это не «строки кода», а события исполнения: вызов метода, выполнение метода, чтение поля, запись поля, вызов конструктора, обработка исключения, инициализация класса.
  2. Срезы (pointcuts) — язык запросов, выбирающий подмножество точек соединения. Именно язык, а не список: execution(* com.shop.service.*.*(..)) — это предикат.
  3. Советы (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) — процесс подстановки советов в точки соединения. Момент вплетения определяет всё: производительность, возможности, отлаживаемость.

Самая распространённая ловушка — последняя строчка правого блока. Разберём её отдельно, потому что на ней спотыкаются буквально все.

Ловушка самовызова: почему @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 выполняется синхронно — и всё это молча, без единой ошибки. Варианты лечения:

  1. Вынести аннотированный метод в другой бин — самое честное решение, оно же обычно улучшает дизайн.
  2. Использовать AopContext.currentProxy() — работает, но привязывает код к фреймворку.
  3. Перейти на 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) разделил четыре несовместимых паттерна, которые все зовут одним словом. Это разделение стоит выучить наизусть — половина споров об архитектуре растворяется.

  1. Event Notification — тонкое событие: «заказ 42 размещён», и всё. Подписчик, которому нужны детали, идёт за ними по API. Максимальная развязка данных, но появляется обратный вызов (и обратная зависимость), а поток исполнения становится невидимым: чтобы понять, что произойдёт после заказа, нужно грепать всю кодовую базу по имени события.
  2. Event-Carried State Transfer — толстое событие, несущее данные. Подписчик держит локальную реплику и не ходит к источнику: устойчивость к отказам источника растёт, но появляются дублирование данных и неизбежная устарелость реплики (eventual consistency).
  3. Event Sourcing — журнал событий становится источником истины, состояние есть свёртка журнала. Даёт полный аудит и возможность «переиграть» историю новым кодом.
  4. 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» (доступна бесплатно).

Типичные ошибки СОП

  1. Команда под видом события. Признак: имя в повелительном наклонении, ровно один подписчик, отправитель ждёт результата.
  2. Отсутствие корреляционного идентификатора. Без correlation_id / traceparent в заголовках сообщений отладка распределённого потока невозможна физически. Пробрасывайте контекст трассировки в заголовках по W3C Trace Context — это делается один раз в инфраструктурном коде и окупается на первом же инциденте.
  3. Молчаливое проглатывание ошибок. Обработчик упал, сообщение подтверждено, данные потеряны. Нужны DLQ, алерт на непустой DLQ и процедура повторной обработки.
  4. Бесконечный ретрай ядовитого сообщения. Одно неразбираемое сообщение блокирует раздел навсегда. Нужен ограниченный счётчик попыток и уход в DLQ.
  5. Игнорирование лага. Лаг — это SLO. Алертить нужно не на «лаг > N сообщений», а на возраст самого старого необработанного события: 10000 сообщений могут быть секундой трафика, а могут — сутками.
  6. Неверный ключ раздела. Порядок нарушается там, где он критичен; либо появляется «горячий» раздел, в который идёт 90% трафика.
  7. События как API базы данных. Публикация построчных изменений таблиц (сырой CDC наружу) намертво связывает потребителей со схемой БД. Публикуйте доменные факты, а не диффы строк.
  8. Синхронная цепочка, притворяющаяся асинхронной. Если пользователь ждёт ответа, а внутри пять брокерских хопов, вы получили худшее из двух миров: и задержку, и невозможность вернуть ошибку. Хвостовые задержки в цепочке складываются, и p99 всей цепи хуже, чем p99 худшего звена.
  9. 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: только добавление полей, никогда не переиспользовать номера.
  • Обе парадигмы обязаны компенсироваться наблюдаемостью, восстанавливающей неявную связь: трассировка, каталог событий, метрики лага, инвентарь аспектов.

Источники

Что дальше

Мы разобрали девять парадигм и увидели, что ни одна из них не является «правильной»: каждая покупает одно свойство ценой другого — АОП меняет понятность на эволюционируемость, СОП меняет предсказуемость на независимость компонентов, ФП меняет производительность на изоляцию эффектов, ООП меняет прямолинейность на расширяемость. Реальная система никогда не бывает написана в одной парадигме, и вопрос практика звучит иначе: где провести границы между парадигмами внутри одного продукта, как их сочетать, чтобы швы не рвались, и как объяснить выбор команде. Этим занимается финальная статья трека: Как выбирать и смешивать парадигмы в реальном проекте.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов