1. Когда сервисный слой начинает «шуметь»
Почти у каждого начинающего разработчика есть такой момент: вы наконец-то написали аккуратный сервис, он делает одну понятную вещь, код читается, зависимости видны в конструкторе — красота. А потом кто-то (иногда вы сами) говорит: «А давайте измерять время выполнения», «Давайте логировать вход/выход», «А можно метрику на каждый метод?». И сервис внезапно становится похож на бутерброд из технических прослоек, где бизнес-логика — тонкий ломтик в центре.
Давайте увидим эту боль на очень маленьком примере. Представим, что в нашем ContextFlow мы захотели измерять время выполнения ключевых use-case методов. Самый «прямолинейный» путь — просто написать измерение времени прямо внутри метода.
import org.springframework.stereotype.Service;
@Service // Spring создаст бин сервиса и будет управлять его жизненным циклом
public class OrderPlacementService {
public void placeOrder() {
// Засекаем время старта: nanoTime подходит именно для измерения длительности, а не для "текущего времени"
long start = System.nanoTime();
try {
// Здесь должна быть бизнес-логика сценария размещения заказа
System.out.println("placing order"); // placing order
} finally {
// finally нужен, чтобы метрика/лог сработали даже при исключении в бизнес-логике
long duration = System.nanoTime() - start;
System.out.println("placeOrder took " + duration + " ns"); // placeOrder took 123456 ns
}
}
}
На одном методе это выглядит терпимо. Даже немного «приятно»: есть чувство контроля, всё рядом. Но теперь представьте, что у нас таких методов не один, а хотя бы три–пять: создание заказа, отмена заказа, генерация отчёта, пересчёт цены, какой-нибудь sanity-check. И вы аккуратно, честно копируете этот шаблон в каждый метод.
import org.springframework.stereotype.Service;
@Service // Ещё один Spring-сервис: та же "обвязка", но уже в другом классе
public class ReportingService {
public void generateDailyReport() {
// Техническая часть (таймер) начинает размножаться по сервисному слою
long start = System.nanoTime();
try {
// Здесь должна быть логика формирования отчёта
System.out.println("generating report"); // generating report
} finally {
// Гарантируем фиксацию длительности независимо от исхода выполнения
long duration = System.nanoTime() - start;
System.out.println("generateDailyReport took " + duration + " ns");
}
}
}
В этот момент сервисный слой начинает шуметь. Вы открываете метод и вместо того, чтобы видеть сценарий («сформировать отчёт → вывести → готово»), вы видите технический каркас, который повторяется слово в слово. А если потом вы захотите поменять формат вывода (например, добавить имя класса, или вывести миллисекунды, или писать в другой канал) — вы будете менять это в десятке мест.
Чтобы не создавать ложное ощущение «мы ругаем измерение времени», подчеркну: измерение времени — полезно. Проблема не в том, что мы измеряем. Проблема в том, где живёт этот код и как он размножается.
2. Граница: обвязка и бизнес-логика
Когда мы говорим «вынести повторяющееся в отдельный механизм», легко случайно начать выносить вообще всё подряд: и техническое, и доменное. Это обычно заканчивается тем, что логика расползается по непредсказуемым местам, а новичок потом не понимает, почему «оно работает», но найти источник поведения невозможно. Поэтому нам нужна простая и честная граница: что считаем технической обвязкой, а что обязано оставаться прямо в сервисном сценарии.
В ContextFlow у нас уже есть хороший пример этой границы: аудит (AuditWriter) у нас — часть бизнес-сценария. Это не «лог для разработчика», а доменная запись: «заказ создан», «заказ отменён», «кто-то сделал действие». Это важно для предметной области, и поэтому аудит у нас вынесен в отдельный доменный/прикладной компонент (через события и listeners), но он остаётся именно доменной ответственностью приложения.
А вот «померить время выполнения метода» — это совсем другое. С точки зрения домена заказов (и даже учебного домена) бизнесу обычно не важно, сколько наносекунд выполнялся метод generateDailyReport(). Это важно нам, разработчикам: чтобы увидеть «тормозит/не тормозит», чтобы диагностировать, чтобы потом (в реальной жизни) строить метрики и мониторинг. Это и есть типичная техническая обвязка.
Если коротко, можно держать в голове такую таблицу (она не идеальна для всех проектов мира, но для нашего курса — очень полезна):
| Вопрос | Если ответ «да» — скорее доменное | Если ответ «да» — скорее техническое |
|---|---|---|
| Это требование бизнеса/предметной области? | «Нужно сохранять аудит действий» | «Нужно замерять время методов» |
| Пользователь/заказчик увидит это как часть продукта? | «Отчёт должен быть сформирован и сохранён» | «В логах есть duration каждого метода» |
| Это должно быть одинаковым почти для всех сервисов? | Иногда | Почти всегда |
| Это мешает чтению сценария? | Если да — вы, вероятно, смешали уровни | Да, почти всегда |
Теперь важная ремарка, чтобы вы не начали бороться с «любым println как с врагом». В учебных примерах мы печатаем в консоль, потому что так проще увидеть эффект. В реальной разработке вместо System.out.println у вас будет нормальный логгер, метрики, трассировка, контекст запроса. Но механика проблемы та же: повторяющаяся техническая обвязка «налипает» на методы, и сервисы перестают быть аккуратными носителями бизнес-сценариев.
3. Cross-cutting concern
Термин cross-cutting concern звучит так, будто его придумали, чтобы отпугивать людей от программирования (вместе со словами «полиморфизм» и «дифференциальное уравнение»). На самом деле смысл простой: это забота/задача, которая пересекает (cut across) несколько частей системы. Не «живёт в одном модуле», а повторяется похожим образом в разных местах. Сервисам нужны их сценарии, но сверху на них хочется положить одинаковую техническую «упаковку».
Здесь достаточно максимально приземлённых примеров: замер времени выполнения (timing), техническое логирование входа/выхода, простые метрики «сколько раз вызвали метод». Дальше в жизни сюда же относятся безопасность (проверить права), транзакции (обеспечить границу атомарности), трассировка запросов (trace/span), обработка ошибок и единый формат исключений. Заметьте, это только примеры. Нам важно увидеть сам паттерн повторяемости, без ухода в соседние механики.
Очень удобно представить cross-cutting concern как прозрачную плёнку, которую вы наклеиваете на разные методы. Методы остаются собой (делают то, что должны), но каждый вызов проходит через одинаковый «слой наблюдения».
Вот простая схема того, что мы сейчас делаем вручную, копируя код в каждый метод:
flowchart TD
A[Вызов метода сервиса] --> B["Start timer / технический лог"]
B --> C[Бизнес-логика метода]
C --> D["Stop timer / технический лог"]
А теперь ключевая мысль лекции: проблема не в том, что нам нужен блок Start/Stop. Проблема в том, что этот блок мы вынуждены писать внутри каждого метода, как будто Java не умеет «оборачивать» вызовы.
И вот здесь мы мягко подходим к AOP, но пока без терминов и аннотаций: нам нужен способ сказать системе «оборачивай вот эти методы вот таким техническим поведением», не переписывая сами методы.
4. Копипаст обвязки ломает качество
Очень легко недооценить вред от повторяющегося технического кода. Кажется: «Ну подумаешь, 5 строк с таймером. Я же не 500 строк копирую». На практике эти 5 строк начинают вести себя как капля масла на кухне: вроде маленькая, а потом почему-то жирно везде — и на столе, и на ручке холодильника, и на коте. (Кота лучше не смазывать, это плохая метрика.)
Во-первых, повторяющийся каркас снижает читаемость. Когда вы открываете placeOrder(), вам хочется увидеть сценарий: «создать заказ → сохранить → опубликовать событие». Если половина метода занята nanoTime/try/finally/println, вы видите техническую рамку, а не смысл.
Во-вторых, повторяющаяся обвязка почти гарантирует рассинхронизацию. Сегодня в одном месте вы вывели took 123 ns, завтра в другом месте забыли вывести единицы измерения, послезавтра в третьем методе забыли finally, и у вас при исключении таймер не печатается (а именно в исключениях чаще всего и хочется видеть диагностику). Чем больше копипасты, тем больше «косметических» различий, которые потом превращаются в реальную боль.
В-третьих, изменения становятся дорогими. Представьте, что вы решили поменять формат: вместо placeOrder took X ns писать OrderPlacementService.placeOrder took X ms. Это вроде мелочь, но теперь это работа «пройдись по всем сервисам и не ошибись». И вы точно один раз ошибётесь. Не потому что вы плохой, а потому что человеческая память не предназначена быть системой сборки и контроля версий одновременно.
Давайте сравним два фрагмента. Сначала — метод с «обвязкой»:
public void cancelOrder() {
// Техническая часть влезает прямо в бизнес-метод
long start = System.nanoTime();
try {
// Здесь должна быть доменная логика отмены заказа
System.out.println("cancel order"); // cancel order
} finally {
// Важно: finally, иначе при исключении мы потеряем измерение времени
System.out.println("cancelOrder took " + (System.nanoTime() - start));
}
}
А теперь — тот же метод, если представить, что обвязка исчезла (оставим только сценарий):
public void cancelOrder() {
// Так читается сценарий: без технического каркаса вокруг
System.out.println("cancel order"); // cancel order
}
Второй вариант не «лучше потому что короче». Он лучше, потому что у него есть шанс оставаться читаемым, когда сценарий вырастет до настоящих шагов: проверить статус заказа, обновить store, опубликовать OrderCancelledEvent, сформировать понятный результат. Техническая обвязка должна быть где-то рядом, но не внутри бизнес-метода как обязательный «ритуал».
И вот тут появляется правильный инженерный вопрос: «Как оставить бизнес-методы чистыми, но всё равно получать одинаковую техническую обвязку вокруг вызовов?»
5. Псевдорешения без AOP
Очень соблазнительно решить проблему «по-простому»: вынести таймер в утилиту или сделать общий базовый класс. Эти подходы действительно уменьшают копипасту, и для новичка они даже могут показаться «тем самым правильным ответом». Но у них есть потолок — и полезно увидеть его заранее, чтобы потом не удивляться, почему Spring вообще придумал AOP и прокси, вместо того чтобы «просто написать TimingUtils».
Первый очевидный шаг — сделать утилиту, которая принимает Runnable (или Supplier<T>) и оборачивает выполнение. Код становится короче, но сервис всё равно должен явно вызывать эту утилиту в каждом методе.
public class TimingUtils {
public static void runTimed(String name, Runnable action) {
// name — как мы подпишем измерение (обычно имя метода/операции)
// action — что именно мы измеряем
long start = System.nanoTime();
try {
// Выполняем переданную бизнес-операцию
action.run();
} finally {
// Технический вывод: делаем в finally, чтобы не потерять замер при исключениях
System.out.println(name + " took " + (System.nanoTime() - start));
}
}
}
И тогда сервис выглядит так:
public void placeOrder() {
// Бизнес-метод теперь "знает" про техническую утилиту и обязан вызывать её вручную
TimingUtils.runTimed("placeOrder", () -> {
// Это тело лямбды — реальная работа сценария
System.out.println("placing order"); // placing order
});
}
Плюс: меньше копипасты, единый формат вывода. Минус: бизнес-метод всё равно «знает» про тайминг и содержит техническую конструкцию runTimed(...). Ещё минус: очень легко забыть обернуть новый метод (особенно когда сроки горят, а кофе закончился). И самый неприятный минус: если вам нужно оборачивать много методов автоматически, этот подход не помогает — он остаётся ручной дисциплиной.
Второй популярный путь — базовый класс, от которого наследуются сервисы, чтобы получить protected-метод runTimed. Для учебного проекта это можно показать, но как общий стиль это плохо масштабируется: вы жёстко связываете сервисы наследованием ради технической детали, а у Java, как вы помните, наследование одно. Завтра вам понадобится другое базовое поведение — и внезапно окажется, что «техническая обвязка» забрала у вас архитектурную свободу.
Третий путь — «честный» паттерн Decorator: мы создаём обёртку-сервис, который делегирует вызовы в настоящий сервис, но добавляет поведение вокруг. Это уже ближе к тому, что делает AOP, и полезно как мост понимания.
public class TimedReportingService {
// target — "настоящий" сервис с бизнес-логикой, который мы оборачиваем
private final ReportingService target;
public TimedReportingService(ReportingService target) {
// Внедряем зависимость: декоратор делегирует работу внутрь target
this.target = target;
}
public void generateDailyReport() {
// Техническая обвязка живёт снаружи, бизнес-логика остаётся в target
long start = System.nanoTime();
try {
// Делегируем выполнение реальному сервису
target.generateDailyReport();
} finally {
// Фиксируем длительность вызова
System.out.println("generateDailyReport took " + (System.nanoTime() - start));
}
}
}
Плюс: бизнес-логика остаётся в ReportingService, а обвязка — в TimedReportingService. Минус: теперь вам нужно создать такие декораторы для каждого сервиса, аккуратно зарегистрировать их в контейнере, убедиться, что все остальные бины внедряют именно декоратор, а не target, и не создать при этом циклы зависимостей. На 2–3 сервисах это ещё можно пережить. На 20 — вы начнёте ненавидеть себя, Spring и весь мир (включая принтер в офисе, который ни при чём, но он всегда страдает первым).
И вот здесь мы приходим к выводу, который важен именно как инженерная мотивация: нам нужен способ получать «декоратор вокруг методов» автоматически, без ручного написания декораторов на каждый сервис. И этот способ в Spring существует — и основан на proxy-модели, которую мы уже поняли вчера.
6. Когда AOP уместен
Очень легко влюбиться в идею «оборачивать методы автоматически» и начать применять её везде. Это типичная ловушка: кажется, что AOP — это магическая палочка, которой можно заменить дизайн. На практике AOP полезен тогда, когда выполняются сразу несколько условий: повторяемая логика действительно техническая, она одинаковая по смыслу, она нужна на множестве методов, и вы хотите держать её централизованно, чтобы не зависеть от ручной дисциплины «не забудь вставить 5 строк в каждый метод».
В ContextFlow это выглядит естественно. У нас есть несколько сервисов в пакете com.example.contextflow.application.service, и мы хотим, чтобы каждый вызов их методов (или хотя бы часть) логировался и измерялся единым способом. При этом мы не хотим засорять сервисы техническим каркасом, потому что сервисы у нас — носители сценариев. Мы уже сделали большой шаг к читаемости, когда вынесли побочные эффекты в event listeners; теперь хочется сделать следующий шаг и вынести техническое наблюдение (timing/logging) из методов.
И есть ещё один важный критерий, связанный с самой proxy-моделью: раз AOP в Spring строится на proxy, он хорошо ложится именно на container-managed beans. То есть на то, что создаёт Spring, а не на то, что вы сделали new-ом где-то в стороне. Это как раз совпадает и с архитектурой ContextFlow: у нас весь сервисный слой — это beans контейнера, и мы осознанно строим приложение вокруг ApplicationContext.
Сейчас достаточно удержать две мысли: повторяющаяся техническая обвязка — это отдельная инженерная проблема, и подключать её хочется не ручной дисциплиной, а централизованным механизмом вокруг вызова метода. Тогда «ну я просто скопирую таймер» уже выглядит не решением, а временной отсрочкой.
7. Типичные ошибки при работе с AOP
В этой теме ошибки обычно не «синтаксические», а инженерные: код компилируется и даже работает, но архитектурно вы себе подкладываете мину, которая взорвётся позже. Поэтому лучше заранее проговорить самые частые грабли — без паники, но честно.
Ошибка №1: выносить в техническую обвязку бизнес-сценарий.
Когда разработчик впервые видит идею «вынести повторяющееся», появляется соблазн: «О, тогда и аудит туда же, и отправку уведомлений, и вообще “создание заказа” можно сделать аспектом». Это почти всегда плохая идея для читаемости. Бизнес-сценарий должен оставаться в сервисе явным шаг за шагом. Обвязка — это то, что помогает наблюдать и обслуживать сценарий, но не заменяет его.
Ошибка №2: объявлять cross-cutting concern любую повторяемость.
Повторяемость бывает разная. Если у вас повторяется кусок доменной логики — это повод подумать про выделение метода/компонента, но не обязательно повод лезть в AOP. AOP хорошо работает именно для технических слоёв: timing, технические логи, метрики. Если вы пытаетесь AOP-ом лечить плохую доменную модель, вы просто переносите хаос в другое место.
Ошибка №3: смешивать техническое логирование и бизнес-аудит.
В нашем проекте AuditWriter — часть бизнес-требования. А System.out.println("... took ...") — это техническая диагностика. Если смешать это в одну кашу, вы получите логи, в которых непонятно, что важно для бизнеса, а что важно для разработчика. В результате вы либо утопите бизнес-информацию в шуме, либо начнёте принимать технические детали за доменные события.
Ошибка №4: лечить проблему “копипасты таймера” ещё большим копипастом.
Иногда делают «шаблонный метод» и вставляют его везде, думая: «Ну раз у меня есть утилита, значит всё решено». Но если каждый метод обязан вручную оборачиваться в TimingUtils.runTimed(...), вы просто заменили копипасту на ритуал. Как только команда станет больше одного человека (или вы устанете), ритуал начнут забывать, и единообразие исчезнет.
Ошибка №5: применять AOP ради одного-единственного метода.
Если у вас реально один метод, который нужно замерять, проще честно измерить его в самом методе или в тесте/диагностике. AOP — это инфраструктурный механизм. Он окупается, когда помогает держать единый подход на множестве методов и когда он уменьшает шум в сервисах. Иначе вы получите сложность «ради галочки» и будете втайне скучать по временам, когда всё было в одном try/finally.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ