Дизайн класів: що таке добре?

9 квітня
Денис Циплаков
Дизайн класів: що таке добре?
За роки роботи я виявив, що програмісти досить часто повторюють одні й ті самі помилки. На жаль, книги, присвячені теоретичним аспектам розробки, уникнути їх не допомагають: у книгах зазвичай немає конкретних, практичних порад. І я навіть здогадуюся, чому...

Перша рекомендація, яка спадає на думку, коли йдеться, наприклад, про логування або дизайн класів, дуже проста: “Не робити відвертої нісенітниці”. Але досвід показує, що її недостатньо. Якраз дизайн класів у цьому випадку є хорошим прикладом — вічний головний біль, який виникає через те, що кожен дивиться на це питання по-своєму. Тому я й вирішив зібрати в одній статті базові поради, дотримуючись яких, ви уникнете ряду типових проблем, а головне, позбавите від них колег. Якщо деякі принципи здаватимуться вам банальними (бо вони дійсно є банальними!) — це добре, значить, вони вже засіли у вашій підкірці, і вашу команду можна привітати.

Обмовлюся, насправді, ми зосередимося на класах виключно для простоти. Майже те саме можна сказати про функції або будь-які інші будівельні блоки програми.

Якщо додаток працює та виконує завдання, значить, його дизайн є хорошим. Чи ні? Залежить від цільової функції програми; те, що цілком підходить для мобільного застосування, яке треба один раз показати на виставці, може абсолютно не підійти для трейдингової платформи, яку якийсь банк розвиває роками. В якійсь мірі, відповіддю на поставлене запитання можна назвати принцип SOLID, але він є занадто загальним — хочеться якихось більш конкретних інструкцій, на які можна посилатися у розмові з колегами.

ЦІЛЬОВИЙ ДОДАТОК

Оскільки універсальної відповіді бути не може, пропоную звузити область. Давайте вважати, що ми пишемо стандартний бізнес-додаток, який приймає запити через HTTP або інший інтерфейс, реалізує якусь логіку над ними й далі або робить запит у наступний за ланцюжком сервіс, або десь зберігає отримані дані. Для простоти давайте вважати, що ми використовуємо Spring IoC Framework, благо він зараз є досить поширеним та інші фреймворки на нього неабияк схожі. Що ми можемо сказати про такий додаток?

  • Час, який процесор витрачає на обробку одного запиту, є важливим, але не критичним — прибавка в 0,1% погоди не зробить.
  • У нашому розпорядженні немає терабайтів пам'яті, але якщо додаток займе зайві 50-100 Кбайт, катастрофою це не стане.
  • Звичайно, чим коротшим є час старту, тим краще. Але принципової різниці між 6 сек і 5.9 сек теж немає.

КРИТЕРІЇ ОПТИМІЗАЦІЇ

Що важливо для нас у цьому випадку?

Код проекту, швидше за все, використовуватиметься бізнесом декілька, а може, й більше десяти років.

Код у різний час модифікуватимуть декілька незнайомих один з іншим розробників. Цілком можливо, через декілька років розробники захочуть використовувати нову бібліотеку LibXYZ або фреймворк FrABC.

В якийсь момент частина коду або весь проект можуть бути злиті з кодовою базою іншого проекту.

У середовищі менеджерів прийнято вважати, що такого роду питання вирішуються за допомогою документації. Документація, безумовно, є річчю хорошою і корисною, адже так добре, коли ви починаєте роботу над проектом, на вас висить п'ять відкритих тікетів, проджект-менеджер запитує, як там у вас із прогресом, а вам треба прочитати (й запам'ятати) якихось 150 сторінок тексту, написаних далеко не геніальними літераторами. У вас, звісно, було декілька днів або навіть пара тижнів на вливання у проект, але, якщо використовувати просту арифметику, — з одного боку 5,000,000 байт коду, з іншого, скажімо, 50 робочих годин. Виходить, що в середньому треба було вливати в себе 100 Кб коду щогодини. І тут все дуже сильно залежить від якості коду. Якщо він чистий: легко збирається, є добре структурованим і передбачуваним, то вливання у проект здається помітно менш болючим процесом. Не останню роль у цьому відіграє дизайн класів. Далеко не останню.

ЧОГО МИ ХОЧЕМО ВІД ДИЗАЙНУ КЛАСІВ

З усього перерахованого можна зробити багато цікавих висновків щодо загальної архітектури, стеку технологій, процесу розробки тощо. Але ми з самого початку вирішили поговорити про дизайн класів, давайте розберемося, що корисного ми можемо витягти зі сказаного раніше стосовно нього.

  • Хочеться, щоб розробник, досконально не знайомий з кодом програми, міг, дивлячись на клас, зрозуміти, що цей клас робить. І навпаки — дивлячись на функціональну чи нефункціональну вимогу, міг би швидко здогадатися, в якому місці програми знаходяться класи, які за неї відповідають. Ну й бажано, щоб реалізація вимог була не “розмазана” по всьому додатку, а зосереджена в одному класі або компактній групі класів. Поясню на прикладі, який саме антипатерн я маю на увазі. Припустимо, нам треба перевіряти, що 10 запитів певного типу можуть виконуватися лише користувачами, які мають на рахунку понад 20 очок (неважливо, що саме це значить). Поганий шлях реалізації такої вимоги — на початку кожного запиту вставити перевірку. Тоді логіка буде розмазана на 10 методів, у різних контролерах. Хороший спосіб — створити фільтр або WebRequestInterceptor і перевіряти все в одному місці.
  • Хочеться, щоб зміни в одному класі, що не зачіпають контракту класу, не зачіпали, ну або (будьмо реалістами!) хоча б не дуже сильно зачіпали й інші класи. Іншими словами, хочеться інкапсуляції реалізації контракту класу.
  • Хочеться, щоб при зміні контракту класу можна було, пройшовши ланцюжком викликів і зробивши find usages, знайти класи, які ця зміна зачіпає. Тобто хочеться, щоб класи не мали непрямих залежностей.
  • За змогою хочеться, щоб процеси обробки запитів, що складаються з декількох однорівневих кроків, не розмазувалися кодом декількох класів, а були описані на одному рівні. Зовсім добре, якщо код, що описує такий процес обробки, вміщується на одному екрані всередині одного методу зі зрозумілою назвою. Наприклад, нам треба в рядку знайти всі слова, для кожного слова зробити виклик у сторонній сервіс, отримати опис слова, застосувати до опису форматування та зберегти результати у БД. Це одна послідовність дій з 4-х кроків. Дуже зручно розбиратись у коді та змінювати його логіку, коли є метод, де ці кроки йдуть один за іншим.
  • Дуже хочеться, щоб однакові речі в коді були реалізовані однаковим чином. Наприклад, якщо ми звертаємося до БД одразу з контролера, краще так робити скрізь (хоча хорошою практикою такий дизайн я б не назвав). А якщо ми вже ввели рівні сервісів і репозитаріїв, то краще безпосередньо з контролера до БД не звертатися.
  • Хочеться, щоб кількість класів/інтерфейсів, що не відповідають безпосередньо за функціональні та нефункціональні вимоги, була не дуже великою. Працювати з проектом, в якому на кожен клас із логікою є два інтерфейси, складна ієрархія наслідування з п'яти класів, фабрика класу та абстрактна фабрика класів, досить важко.

ПРАКТИЧНІ РЕКОМЕНДАЦІЇ

Сформулювавши побажання, ми можемо намітити конкретні кроки, які дозволять нам досягти поставлених цілей.

Статичні методи

Для розминки почну з відносно простого правила. Не варто створювати статичні методи за винятком випадків, коли вони потрібні для роботи однієї з використовуваних бібліотек (наприклад, вам потрібно зробити серіалізатор для типу даних).

В принципі, нічого поганого у використанні статичних методів немає. Якщо поведінка методу повністю залежить від його параметрів, чому би справді не зробити його статичним. Але потрібно врахувати той факт, що ми використовуємо Spring IoC, який служить для зв'язування компонентів нашого застосування. Spring IoC оперує поняттями бінів (Beans) та їхніх областей застосовності (Scope). Цей підхід можна змішувати зі статичними методами, згрупованими у класи, але розбиратись у такому додатку й тим більше щось у ньому змінювати (якщо, наприклад, знадобиться передати у метод або клас якийсь глобальний параметр) може бути досить важко.

При цьому статичні методи у порівнянні з IoC-бінами дають дуже незначну перевагу у швидкості виклику методу. Причому на цьому, мабуть, переваги й закінчуються.

Якщо ви не будуєте бізнес-функцію, яка вимагає великої кількості надшвидких викликів між різними класами, краще статичні методи не використовувати.

Тут читач може запитати: “А як же класи StringUtils та IOUtils?”. Дійсно, у Java-світі склалася традиція — допоміжні функції роботи з рядками та потоками введення-виведення виносити у статичні методи та збирати під парасолькою SomethingUtils-класів. Але мені така традиція здається досить замшілою. Якщо ви будете дотримуватись її, великої шкоди, звісно, не очікується — всі Java-програмісти до цього звикли. Але й сенсу в такій ритуальній дії немає. З одного боку, чому б не зробити бін StringUtils, з іншого — якщо не робити бін, а всі допоміжні методи зробити статичними, давайте вже робити статичні зонтичні класи StockTradingUtils і BlockChainUtils. Почавши виносити логіку у статичні методи, провести кордон і зупинитися складно. Я раджу не починати.

Нарешті, не варто забувати, що до Java 11 багато допоміжних методів, що десятиліттями кочували за розробниками з проекту у проект, стали частиною стандартної бібліотеки або об'єднались у бібліотеки, наприклад, у Google Guava.

Атомарний, компактний контракт класу

Існує просте правило, яке застосовується до розробки будь-якої програмної системи. Дивлячись на будь-який клас, ви повинні бути здатні швидко й компактно, не вдаючись до довгих розкопок, пояснити, що цей клас робить. Якщо вмістити пояснення в один пункт (необов'язково, втім, виражений одним реченням) не виходить, можливо, варто поміркувати та розбити цей клас на декілька атомарних класів. Наприклад, клас “Шукає текстові файли на диску та рахує кількість букв Z у кожному з них” є хорошим кандидатом на декомпозицію "шукає на диску" + "рахує кількість букв".

З іншого боку, не варто робити занадто дрібних класів, кожен з яких розрахований на одну дію. Але якого ж розміру тоді повинен бути клас? Базові правила такі:

  • Ідеально, коли контракт класу збігається з описом бізнес-функції (або підфункції, дивлячись на те, як у нас влаштовані вимоги). Це не завжди можливо: якщо спроба дотриматися цього правила веде до створення громіздкого, неочевидного коду, клас краще розбити на дрібніші частини.
  • Хороша метрика для оцінки якості контракту класу — відношення його внутрішньої складності до складності контракту. Наприклад дуже хороший (нехай і фантастичний) контракт класу може виглядати так: “Клас має один метод, який отримує на вході рядок з описом тематики українською мовою і в результаті складає якісну розповідь або навіть повість на задану тему”. Тут контракт є простим і загалом зрозумілим. Його реалізація є вкрай складною, але складність прихована всередині класу.

Чому це правило є важливим?

  • По-перше, вміння чітко пояснити самому собі, що робить кожен із класів, завжди корисне. На жаль, далеко не в кожному проекті розробники можуть таке зробити. Часто можна почути щось на кшталт: “Ну, це така обгортка над класом Path, яку ми чомусь зробили й іноді використовуємо замість Path. Вона ще має метод, який вміє подвоювати у шляху всі File.separator — нам цей метод потрібний при збереженні звітів у хмару, і він чомусь опинився в класі Path”.
  • Людський мозок здатний одноразово оперувати не більш ніж п'ятьма-десятьма об'єктами. У більшості людей — не більше семи. Відповідно, якщо для вирішення завдання розробнику потрібно оперувати більш ніж сімома об'єктами, він або щось упустить, або буде змушений упакувати декілька об'єктів під одну логічну “парасольку”. І якщо упаковувати все одно доведеться, чому б не зробити це відразу, усвідомлено, і не дати цій парасольці осмислену назву та чіткий контракт.

Як перевірити, що у вас все є досить гранулярним? Попросіть колегу приділити вам 5 хвилин. Візьміть частину програми, над створенням якої ви зараз працюєте. Для кожного з класів поясніть колезі, що саме цей клас робить. Якщо ви не вкладаєтесь у 5 хвилин, або колега не може зрозуміти, навіщо той чи інший клас потрібен — можливо, вам варто щось змінити. Або не змінювати та провести досвід ще раз, вже з іншим колегою.

Залежності між класами

Припустимо, нам потрібно виділити незв'язані частини тексту довше 100 байт для PDF-файлу, упакованого в ZIP-архів, і зберегти їх у базу даних. Популярний антипатерн у таких випадках виглядає так:

  • Є клас, який розкриває ZIP-архів, шукає в ньому PDF-файл і повертає його у вигляді InputStream.
  • Цей клас має посилання на клас, який шукає у PDF абзаци тексту.
  • Клас, який працює з PDF, у свою чергу має посилання на клас, який зберігає дані у БД.ласс, работающий с PDF, в свою очередь имеет ссылку на класс, сохраняющий данные в БД.

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

 

Як робити не треба: 

Що тут не так? Клас, який працює з ZIP-файлами, передає дані класу, обробляє PDF, а той, у свою чергу, — класу, що працює з БД. Значить, клас, який працює з ZIP, у результаті чогось залежить від класів, які працюють із БД. Крім того, логіка обробки розмазана по трьох класах, і щоб її зрозуміти, треба пробігтися всіма трьома класами. Що робити, якщо вам знадобиться передати абзаци тексту, отримані з PDF, сторонньому сервісу через REST-виклик? Вам треба буде міняти клас, який працює з PDF, і втягувати в нього ще й роботу з REST.

Як треба робити:

Тут у нас є чотири класи:

  • Клас, який працює тільки з ZIP-архівом і повертає список PDF-файлів (тут можна заперечити — повертати файли погано — вони великі та зламають додаток. Але давайте в цьому випадку розуміти слово “повертає” у широкому сенсі. Наприклад, повертає Stream з InputStream).
  • Другий клас відповідає за роботу з PDF.
  • Третій клас нічого не знає і не вміє, крім збереження параграфів у БД.
  • І четвертий клас, що складається буквально з кількох рядків коду, містить всю бізнес-логіку, яка вміщується на одному екрані.

Ще раз підкреслюю, у 2019 році в Java є як мінімум два хороші (і декілька менш хороших) способи не передавати файли і повний список всіх параграфів як об'єкти в пам'яті. Це:

Ще раз підкреслюю, у 2019 році в Java є як мінімум два хороші (і декілька менш хороших) способи не передавати файли і повний список всіх параграфів як об'єкти в пам'яті. Це:

  1. Java Stream API.
  2. Callbacks. Тобто клас із бізнес-функцією не передає дані безпосередньо, а говорить ZIP Extractor: ось тобі callback, шукай у ZIP-файлі PDF-файли, для кожного файлу створюй InputStream і викликай з ним переданий callback.

Неявна поведінка

Коли ми не намагаємося вирішити зовсім нову, раніше ніким не вирішену задачу, а навпаки, робимо щось, що інші розробники вже робили кілька сотень (або сотень тисяч) разів, у всіх членів команди є якісь очікування щодо цикломатичної складності та ресурсоємності рішення . Наприклад, якщо нам треба у файлі знайти всі слова, що починаються з літери z, це послідовне, одноразове читання файлу блоками з диска. Тобто якщо орієнтуватися на https://gist.github.com/jboner/2841832 — така операція займе декілька мікросекунд на 1 Мб, ну, може, в залежності від середовища програмування і завантаженості системи, кілька десятків або навіть сотню мікросекунд, але ніяк не секунду. Пам'яті на це буде потрібно кілька десятків кілобайт (залишаємо за дужками питання, що ми робимо з результатами, це турбота іншого класу), і код, швидше за все, займе приблизно один екран. При цьому ми очікуємо, що ніяких інших ресурсів системи використано не буде. Тобто код не буде створювати нитки, писати дані на диск, посилати пакети мережею та зберігати дані у БД.

Це звичайні очікування від виклику метода:

zWordFinder.findZWords(inputStream). ...

Якщо код вашого класу не задовольняє ці вимоги з якоїсь розумної причини, наприклад, для класифікації слова на z і не z вам потрібно щоразу викликати REST-метод (не знаю, навіщо це може бути потрібно, але давайте уявимо таке), це треба дуже ретельно прописати в контракті класу, і зовсім добре, якщо в імені методу буде вказівка ​​на те, що метод кудись бігає радитись.

Якщо ви не маєте ніякої розумної причини для неявної поведінки — перепишіть клас.

Як зрозуміти очікування від складності та ресурсоємності методу? Потрібно вдатися до одного з цих простих способів:

  1. З досвідом придбати досить широкий кругозір.
  2. Запитати в колеги — це завжди можна зробити.
  3. Перед стартом розробки проговорити з членами команди план реалізації.
  4. Задати собі питання: “А чи не використовую я в цьому методі _занадто_ багато надлишкових ресурсів?” Зазвичай цього буває достатньо.

Занадто захоплюватись оптимізацією теж не варто — економія 100 байтів при використовуваних класом 100,000 не має особливого сенсу для більшості додатків.

Це правило відкриває нам вікно в багатий світ оверінженірінга, що приховує відповіді на запитання типу “чому не варто витрачати місяць, щоб заощадити 10 байт пам'яті в додатку, який потребує для роботи 10 Гбайт”. Але цю тему тут я розвивати не стану. Вона гідна окремої статті.

Неявні імена методів

В Java-програмуванні на поточний момент склалося декілька неявних угод з приводу імен класів та їхньої поведінки. Їх не так багато, але краще їх не порушувати. Спробую перерахувати ті з них, які спадають мені на думку:

  • Конструктор — створює екземпляр класу, може створювати якісь досить розгалужені структури даних, але при цьому не працює з БД, не пише на диск, не посилає дані мережею (обмовлюся, все це може робити вбудований логер, але це окрема історія і в будь-якому випадку лежить вона на совісті конфігуратора логування).
  • Getter — getSomething() — повертає якусь структуру пам'яті з глибин об'єкту. Знову ж таки не пише на диск, не робить складних обчислень, не посилає даних мережею, не працює з БД (за винятком випадку, коли це lazy поле ORM, і саме це є однією з причин, чому lazy поля варто використовувати з великою обережністю).
  • Setter — setSomething (Something something) — встановлює значення структури даних, не робить складних обчислень, не посилає даних мережею, не працює з БД. Зазвичай від сетера взагалі не очікується неявної поведінки або споживання більш-менш значних обчислювальних ресурсів.
  • equals() і hashcode() — не очікується взагалі нічого, крім простих обчислень і порівнянь у кількості, лінійно залежній від розміру структури даних. Тобто якщо ми викликаємо hashcode для об'єкта з трьох примітивних полів, очікується, що буде виконано N*3 простих обчислювальних інструкцій.
  • toSomething() — також очікується, що це метод, який перетворює один тип даних на інший і для перетворення потребує лише кількості пам'яті, зіставної з розмірами структур, і процесорного часу, лінійно залежного від розміру структур. Тут треба зауважити, що не завжди перетворення типів можна зробити лінійно, скажімо, перетворення піксельної картинки на SVG-формат може бути вельми нетривіальною дією, але в такому випадку краще назвати метод по-іншому. Наприклад, назва computeAndConvertToSVG ​() виглядає дещо незграбною, зате відразу наводить на думку, що там всередині відбуваються якісь значні обчислення.

Наведу приклад. Нещодавно я робив аудит додатку. За логікою роботи я знаю, що додаток десь у коді підписується на RabbitMQ-чергу. Іду за кодом згори вниз — не можу знайти це місце. Шукаю безпосередньо звернення до rabbit, починаю підійматися вгору, доходжу до місця у business flow, де підписка власне відбувається — починаю лаятись. Як це виглядає в коді:

  1. Викликається метод service.getQueueListener(tickerName) — результат, що повертається, ігнорується. Це могло б насторожити, але такий фрагмент коду, де ігноруються результати роботи методу, в додатку не єдиний.
  2. Усередині tickerName перевіряється на null і викликається інший метод getQueueListenerByName(tickerName).
  3. Усередині нього з хешу за ім'ям тікера береться екземпляр класу QueueListener (якщо його немає, він створюється), й у нього викликається метод getSubscription().
  4. А ось вже всередині методу getSubscription() власне й відбувається підписка. Причому відбувається вона десь у самій середині методу розміром у три екрани.

Скажу прямо — не пробігши всього ланцюжка й не прочитавши уважно десяток екранів коду, здогадатися, де ж відбувається підписка, було нереально. Якби метод називався subscribeToQueueByTicker(tickerName), це заощадило б мені чимало часу.

Утилітарні класи

Є чудова книга Design Patterns: Elements of Reusable Object-Oriented Software (1994), її часто називають GOF (Gang of Four, за кількістю авторів). Користь цієї книги перш за все в тому, що вона дала розробникам із різних країн єдину мову для опису шаблонів дизайну класів. Тепер замість “клас, що гарантовано існує тільки в одному екземплярі та має статичну точку доступу” можна сказати “сінглтон”. Ця ж книга завдала помітної шкоди незміцнілим умам. Шкоду цю добре описує цитата з одного з форумів: “Колеги, мені треба зробити веб-магазин, скажіть, із використання яких шаблонів мені треба почати”. Іншими словами, деякі програмісти схильні зловживати шаблонами проектування, і там, де можна було б обійтися одним класом, іноді створюють одразу п'ять або шість — про всяк випадок, “для більшої гнучкості”.

Як вирішити, чи потрібна вам абстрактна фабрика класів (або інший патерн, складніший за інтерфейс)? Є декілька простих міркувань:

  1. Якщо ви пишете прикладний додаток на Spring, у 99% випадків не потрібна. Spring пропонує вам більш високорівневі будівельні блоки, використовуйте їх. Максимум, що вам може знадобитися, це абстрактний клас.
  2. Якщо пункт 1 все ж не дав вам чіткої відповіді — пам'ятайте, що кожен шаблон — це +1000 очок до складності програми. Ретельно проаналізуйте, чи переважить користь від використання шаблону шкоду від нього ж. Звертаючись до метафори, пам'ятайте, кожен препарат не лише лікує, а й трішечки шкодить. Не треба пити все таблетки відразу.

Хороший приклад того, як робити не треба, можете подивитися тут.

ВИСНОВОК

Підсумовуючи, хочу зауважити, що перерахував найбазовіші рекомендації. Я б узагалі не став оформляти їх у вигляді статті — настільки вони очевидні. Але за минулий рік мені дуже часто траплялися додатки, в яких багато які з цих рекомендацій порушувалися. Давайте писати простий код, який легко читати й легко підтримувати.