JavaRush /Курси /Spring Core /Технічна обвʼязка в сервісах

Технічна обвʼязка в сервісах

Spring Core
Рівень 22 , Лекція 0
Відкрита

1. Коли сервісний шар починає «шуміти»

Майже в кожного початківця буває такий момент: ви нарешті написали охайний сервіс, він робить одну зрозумілу річ, код читається, залежності видно в конструкторі — краса. А потім хтось — інколи ви самі — каже: «А давайте вимірювати час виконання», «Давайте логувати вхід/вихід», «А можна метрику на кожен метод?». І сервіс раптово стає схожим на бутерброд із технічних прошарків, де бізнес-логіка — тонкий шматочок у центрі.

Давайте подивимося на це на дуже маленькому прикладі. Уявімо, що в нашому ContextFlow ми захотіли вимірювати час виконання ключових методів сценарію використання. Найпростіший шлях — просто вставити вимірювання часу прямо в метод.

import org.springframework.stereotype.Service;

@Service // Spring створить бін сервісу та керуватиме його життєвим циклом
public class OrderPlacementService {

    public void placeOrder() {
        // Засікаємо старт: nanoTime підходить саме для вимірювання тривалості, а не для "поточного часу"
        long start = System.nanoTime();
        try {
            // Тут має бути бізнес-логіка сценарію розміщення замовлення
            System.out.println("розміщуємо замовлення"); // розміщуємо замовлення
        } finally {
            // finally потрібен, щоб метрика або лог спрацювали навіть під час винятку в бізнес-логіці
            long duration = System.nanoTime() - start;
            System.out.println("placeOrder зайняв " + duration + " ns"); // placeOrder зайняв 123456 нс
        }
    }
}

На одному методі це виглядає терпимо. Навіть трохи «приємно»: є відчуття контролю, усе поруч. Але тепер уявіть, що в нас таких методів не один, а хоча б три–пʼять: створення замовлення, скасування замовлення, генерація звіту, перерахунок ціни, якась перевірка на здоровий глузд. І ви акуратно, чесно копіюєте цей шаблон у кожен метод.

import org.springframework.stereotype.Service;

@Service // Ще один Spring-сервіс: та сама "обвʼязка", але вже в іншому класі
public class ReportingService {

    public void generateDailyReport() {
        // Технічна частина (таймер) починає розмножуватися в сервісному шарі
        long start = System.nanoTime();
        try {
            // Тут має бути логіка формування звіту
            System.out.println("формуємо звіт"); // формуємо звіт
        } finally {
            // Гарантуємо фіксацію тривалості незалежно від результату виконання
            long duration = System.nanoTime() - start;
            System.out.println("generateDailyReport зайняв " + duration + " ns");
        }
    }
}

У цей момент сервісний шар починає шуміти. Ви відкриваєте метод і замість того, щоб бачити сценарій — «сформувати звіт → вивести → готово» — бачите технічний каркас, який повторюється слово в слово. А якщо потім ви захочете змінити формат виводу, наприклад додати імʼя класу, вивести мілісекунди або писати в інший канал, — ви змінюватимете це в десяти місцях.

Щоб не склалося хибне враження, ніби ми сваримо вимірювання часу, наголошу: воно корисне. Проблема не в тому, що ми вимірюємо. Проблема в тому, де живе цей код і як він розмножується.

2. Межа: обвʼязка і бізнес-логіка

Коли ми кажемо «винести повторюване в окремий механізм», легко випадково почати виносити взагалі все підряд: і технічне, і доменне. Зазвичай це закінчується тим, що логіка розповзається по непередбачуваних місцях, а початківець потім не розуміє, чому «воно працює», але знайти джерело поведінки неможливо. Тому нам потрібна проста й чесна межа: що вважаємо технічною обвʼязкою, а що має залишатися прямо в сервісному сценарії.

У ContextFlow у нас уже є гарний приклад цієї межі: аудит (AuditWriter) у нас — частина бізнес-сценарію. Це не «лог для розробника», а доменний запис: «замовлення створено», «замовлення скасовано», «хтось виконав дію». Це важливо для предметної області, і тому аудит у нас винесено в окремий доменний/прикладний компонент (через події та слухачів), але він залишається саме доменною відповідальністю застосунку.

А от «виміряти час виконання методу» — це зовсім інше. З погляду домену замовлень, і навіть навчального домену, бізнесу зазвичай не важливо, скільки наносекунд виконувався метод generateDailyReport(). Це важливо нам, розробникам: щоб побачити, чи щось гальмує, щоб діагностувати, а потім, у реальному житті, будувати метрики та моніторинг. Це і є типова технічна обвʼязка.

Якщо коротко, тримайте в голові таку таблицю. Вона не ідеальна для всіх проєктів світу, але для нашого курсу — дуже корисна:

Питання Якщо відповідь «так» — радше доменне Якщо відповідь «так» — радше технічне
Це вимога бізнесу/предметної області? «Потрібно зберігати аудит дій» «Потрібно заміряти час методів»
Користувач/замовник побачить це як частину продукту? «Звіт має бути сформовано і збережено» «У логах є тривалість кожного методу»
Це має бути однаковим майже для всіх сервісів? Іноді Майже завжди
Це заважає читанню сценарію? Якщо так — ви, імовірно, змішали рівні Так, майже завжди

Тепер важлива ремарка, щоб ви не почали боротися з будь-яким println як із ворогом. У навчальних прикладах ми друкуємо в консоль, тому що так простіше побачити результат. У реальній розробці замість System.out.println у вас буде нормальний логер, метрики, трасування, контекст запиту. Але механіка проблеми та сама: повторювана технічна обвʼязка «налипає» на методи, і сервіси перестають бути охайними носіями бізнес-сценаріїв.

3. Cross-cutting concern

Термін cross-cutting concern звучить так, ніби його вигадали, щоб відлякувати людей від програмування, разом зі словами «поліморфізм» і «диференціальне рівняння». Насправді сенс простий: це турбота або задача, яка перетинає (cut across) кілька частин системи. Вона не «живе в одному модулі», а повторюється подібним чином у різних місцях. Сервісам потрібні їхні сценарії, але зверху на них хочеться покласти однакову технічну «упаковку».

Тут достатньо максимально приземлених прикладів: замір часу виконання (timing), технічне логування входу та виходу, прості метрики на кшталт «скільки разів викликали метод». Далі в житті сюди ж належать безпека (перевірити права), транзакції (забезпечити межу атомарності), трасування запитів (trace/span), обробка помилок і єдиний формат винятків. Зверніть увагу: це лише приклади. Нам важливо побачити сам патерн повторюваності, без переходу до сусідніх механізмів.

Дуже зручно уявити cross-cutting concern як прозору плівку, яку ви накладаєте на різні методи. Методи залишаються собою, роблять те, що мають, але кожен виклик проходить через однаковий «шар спостереження».

Ось проста схема того, що ми зараз робимо вручну, копіюючи код у кожен метод:

flowchart TD
    A[Виклик методу сервісу] --> B["Запуск таймера / технічний лог"]
    B --> C[Бізнес-логіка методу]
    C --> D["Зупинка таймера / технічний лог"]

А тепер ключова думка лекції: проблема не в тому, що нам потрібен блок 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("скасовуємо замовлення"); // скасовуємо замовлення
    } finally {
        // Важливо: finally, інакше під час винятку ми втратимо вимірювання часу
        System.out.println("cancelOrder зайняв " + (System.nanoTime() - start));
    }
}

А тепер — той самий метод, якщо уявити, що обвʼязка зникла. Залишимо тільки сценарій:

public void cancelOrder() {
    // Так читається сценарій: без технічного каркаса навколо
    System.out.println("скасовуємо замовлення"); // скасовуємо замовлення
}

Другий варіант не «кращий, бо коротший». Він кращий, тому що в нього є шанс залишатися читабельним, коли сценарій виросте до справжніх кроків: перевірити статус замовлення, оновити 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 + " зайняв " + (System.nanoTime() - start));
        }
    }
}

І тоді сервіс виглядає так:

public void placeOrder() {
    // Бізнес-метод тепер "знає" про технічну утиліту і має викликати її вручну
    TimingUtils.runTimed("placeOrder", () -> {
        // Це тіло лямбди — реальна робота сценарію
        System.out.println("розміщуємо замовлення"); // розміщуємо замовлення
    });
}

Плюс: менше копіпасти, єдиний формат виводу. Мінус: бізнес-метод усе одно «знає» про таймінг і містить технічну конструкцію 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 зайняв " + (System.nanoTime() - start));
        }
    }
}

Плюс: бізнес-логіка залишається в ReportingService, а обвʼязка — в TimedReportingService. Мінус: тепер вам потрібно створити такі декоратори для кожного сервісу, акуратно зареєструвати їх у контейнері, переконатися, що всі інші біни впроваджують саме декоратор, а не target, і не створити при цьому цикли залежностей. На 2–3 сервісах це ще можна пережити. На 20 — ви почнете ненавидіти себе, Spring і весь світ, включно з принтером в офісі, який ні до чого, але завжди страждає першим.

І ось тут ми приходимо до висновку, який важливий саме як інженерна мотивація: нам потрібен спосіб отримувати «декоратор навколо методів» автоматично, без ручного написання декораторів на кожен сервіс. І цей спосіб у Spring існує — і ґрунтується на proxy-моделі, яку ми вже бачили вище.

6. Коли AOP доречний

Дуже легко закохатися в ідею «обгортати методи автоматично» і почати застосовувати її всюди. Це типова пастка: здається, що AOP — це чарівна паличка, якою можна замінити дизайн. На практиці AOP корисний тоді, коли виконуються одразу кілька умов: повторювана логіка справді технічна, вона однакова за змістом, вона потрібна на багатьох методах, і ви хочете тримати її централізовано, щоб не залежати від ручної дисципліни «не забудь вставити 5 рядків у кожен метод».

У ContextFlow це виглядає природно. У нас є кілька сервісів у пакеті com.example.contextflow.application.service, і ми хочемо, щоб кожен виклик їхніх методів, або хоча б частини з них, логувався та вимірювався однаково. При цьому ми не хочемо засмічувати сервіси технічним каркасом, тому що сервіси в нас — носії сценаріїв. Ми вже зробили великий крок до читабельності, коли винесли побічні ефекти в слухачів подій; тепер хочеться зробити наступний крок і винести технічне спостереження (таймінг/логування) з методів.

І є ще один важливий критерій, повʼязаний із самою proxy-моделлю: оскільки AOP у Spring будується на proxy, він добре лягає саме на біни, керовані контейнером. Тобто на те, що створює Spring, а не на те, що ви зробили new-ом десь осторонь. Це якраз збігається і з архітектурою ContextFlow: у нас увесь сервісний шар — це біни контейнера, і ми свідомо будуємо застосунок навколо 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.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ