SOLID
Щоб бути SOLIDним розробником, необхідно знати (а краще ще і використовувати в роботі) так звані принципи SOLID.
Спочатку трохи історії. Роберт Мартін, відомий автор книжок про обʼєктно-орієнтоване програмування, все своє життя збирав (і сам виводив) деякі принципи, що використовуючи їх у розробці програмного забезпечення, можна було зробити його краще з точки зору коду і архітектури. А потім просто деякий Michael Feathers помітив, що назви "загальних" принципів Мартіна складаються в акронім - SOLID. Так вони і стали відомими.
Тож, що ж це за "загальні" принципи:
- S - SRP - Single Responsibility Principle
- O - OCP - Open-Closed Principle
- L - LSP - Liskov Substitution Principle
- I - ISP - Interface Segregation Principle
- D - DIP - Dependency Inversion Principle
Single Responsibility Principle
На мою думку, найпростіший принцип серед усіх. Ми так чи інакше майже завжди його використовуємо на підсвідомому рівні.
Кожний обʼєкт повинен мати одну відповідальність і ця відповідальність повинна бути повністю інкапсульована у класі.
Щоб зрозуміти цей принцип, треба зайти з іншого боку. У будь-якого програмного забезпечення є така штука, як вісь змін. Це той набір класів, методів/функцій, полів, які постійно змінюються. Ну, тобто, щось таке:
Часто кажуть "вісь змін проходить через ось ці класи".
Звісно, осей змін може бути кілька у великій системі. Наприклад: одна вісь змін - бізнес логіка, а друга - робота з базою даних.
Так ось за принципом SRP через ваш клас має проходити лише одна вісь змін, тобто ваш клас повинен змінюватись лише з однієї причини, що можливо тільки тоді, коли він виконує одну функцію - відповідальний тільки за одну річ.
Також, якщо ви, наприклад, за бажанням клієнта змінили в коді одну маленьку штуку, а потім виявилось, що це вплинуло на якийсь взагалі лівий модуль в додатку, і все зламалось - це і є порушення SRP.
Приклад
Припустимо, ви пишете софт для компанії, яка робить побутову техніку, і ваш перший продукт - духова шафа. Перша задача - навчити духову шафу тримати температуру якийсь заданий час. У вас є годинник і термометр - треба для них код написати, щоб їх використовувати в програмах для приготування їжі. І ви такий "напишу я клас DateTimeTemperature, який буде все робити". Написали величенький клас, який увійшов до кодової бази компанії, і в основу духової шафи. А потом пішли на іншу роботу, бо там більшу зарплату запропонували.
Духова шафа продається успішно, компанія почала розширятися. Вирішили зробити два нових продукти: витяжку і електрочайник. Виділили Васю и Петю і сказали Васі писати код під витяжку, а Петі - під чайник. Витяжці треба час вимірювати, щоб поставити її на півгодини, наприклад, запахи висмоктувати. Вася покопався в репозиторіях и знайшов клас DateTimeTemperature. Температура йому взагалі не треба, тому просто взяв з класу час і вставив собі. Випустили витяжку. Петя тим часом думав, як би швидше код для чайника написати. Знайшов той же клас і думає "мені ж тільки температура треба". Взяв та й зкопіював собі шматок класу. Випустили чайник.
Все йде добре, але раптом у коді духових шаф знайшли баги. Запросили команду тестувальників, як Atlassian задумались раптом про доступність. Протестували клас DateTimeTemperature - купу багів знайшли. Пофіксили. Але ж у чайниках і витяжках той самий код, та от лежить він хрін знає де. Ось так і вийшло, що в одному і тому ж коді купа багів, але в одному місці їх пофіксили, а в іншому - ні.
Висновок: треба було зробити два окремих класи - DateTime і Temperature. Вони б увійшли до єдиної стандартної бібліотеки компанії і їх би всі використовували. А ще кожен з цих класів мав би одну відповідальність.
Цікаві факти
Є такий патерн - Active Record. Це коли у класі окрім бізнес-логіки лежить ще і код для роботи з базою даних - записати себе, знайти себе і т.д. Хоч цей патерн і повністю порушує SRP, його часто використовують. Наприклад, ruby on rails повністю побудований на Active Record. Laravel і Yii - PHP фреймворки - теж його використовують. C# так само колись був побудований на Active Record, але скоро все ж перейшов на ORM.
Open-Closed Principle
Принцип трохи складніший за попередній. Як ми знаємо, розробка програмного продукту може тривати десятиліттями. А все тому, що постійно змінюються і доповнюються вимоги бізнесу до продукту, тобто постійно потрібно вносити зміни. А основна причина, через яку це буває важко чи дорого - коли маленька зміна в одній частині коду спричиняє лавину переробок у всіх інших.
Формулювання (не Роберта Мартіна, а декого Бертрана Меєра):
Програмні сутності - класи, функції, модулі і т.д. - повинні бути відкриті для розширення і закриті для змін.
Почнемо з того, як це можливо взагалі? А все дуже просто. Якщо є нормальний програмний продукт, який протестований, якщо він нормально спроектований – додавання нової функціональності не повинно валити існуючі тести та додавати нові баги (це ж треба буде повне регресійне тестування проводити, якщо так).
Як Бертран Мейер пропонує це реалізовувати? Припустимо ми написали клас. Коли закінчили, вішаємо на нього умовний "замочок", який забороняє нам змінювати його (звісно ж крім багфіксів). І все. Якщо хочемо розширити клас, додати нову функціональність – використовуємо наслідування. При цьому дозволяється у новому класі змінювати попередній інтерфейс.
Остання штука звучить тупо, адже викликає сайд-ефекти у клієнтських класів - їх також доведеться переписувати. Саме тому вже Роберт Мартін запропонував "поліморфний" Open-Closed принцип.
Поліморфний Open-Closed принцип забороняє змінювати інтерфейс. Тобто клієнтський код повинен спілкуватися з реалізацією лише через інтерфейс (який незмінний), а цей інтерфейс вже повинен реалізовуватись старою та новою реалізаціями. При цьому нова реалізація може або делегувати старій виконання якихось шматків коду, або наслідуватись від неї.
Оскільки поліморфний принцип більш логічний, у сучасній розробці використовують саме його.
На завершення скажу, що цей принцип у сучасних реаліях перетинається з Dependency Inversion, тому що найчастіше передбачає використання класів через відповідні інтерфейси, оскільки за допомогою цього досить просто розширювати функціональність класів, до прикладу, завдяки патернам Decorator або Proxy. Візьмемо, наприклад, клієнт і сервер. Додавши серверу інтерфейс (або абстракцію), до якого звертатиметься клієнт, ми спокійно між інтерфейсом та класом можемо додати аутентифікацію, авторизацію, логування, кешування тощо.
Використання у фронтенді
Далеко не у кожному проекті є тайпскрипт, проте цей принцип має застосування і у звичайному JS. Припустимо, ми робимо компонент кнопки - проста така кнопка з іконкою всередині. Без питань зробили, юзаємо. Через деякий час треба робити нові кнопки вже з іконкою і текстом. Ну не проблема - допишемо вже існуючий компонент і додамо необовʼязковий текст. Ще через місяць прилітає таска: зробити кнопки з бейджом непрочитаних повідомлень (як на айфонах). Та все просто - додамо бейдж до вже існуючої кнопки. І так можна продовжувати ще довго.
Уважний розробник (а тестувальник тим більше) скаже, що після кожної такої зміни компоненту у ньому можуть зʼявитись баги, і необхідно його (і всі місця, де він використовується) перетестовувати. А якщо, припустимо, ми передамо у цю кнопку лише текст з бейджом, то вийде взагалі якась фігня. Саме тому перед кожною такою зміною варто подумати, чи не краще було б створити новий компонент. Так нам не треба буде їх постійно перетестовувати, та й імовірність зловити нові баги значно зменшиться. Звісно, назви компонентів будуть трохи довші (як-от IconButtonWithTextAndBadge
), але на мою думку це інколи навіть краще.
Приклад від себе
Ось я працюю над accessibility фіксом для одного маленького дропдауну в Джирі на одній сторінці. Я не можу просто зробити по-нормальному те що мені треба, бо:
- якщо впали тести, значить зламалась поточна поведінка;
- якщо зламалась поточна поведінка, то є висока імовірність, що зʼявились нові баги.
А тепер уявіть, що цей дропдаун використовується ще в якійсь жопі, про яку ми і гадки не маємо. А якщо у компонента немає тестів/сторібуку, то ми про це взагалі ніколи не дізнаємось! (ну, хіба що лише тоді, коли нам прилетить по дупі за наші "фікси"). Саме тому працюючи над кожним таким фіксом, моя задача - зробити його максимально сумісним зі старою поведінкою так, щоб впала мінімальна кількість тестів. Тож я намагаюсь реалізувати цей фікс максимально не змінюючи попередній код, а лише дописуючи нове (за можливості). Звісно, ідеальним рішенням було б зробити новий дропдаун на основі старого, вже з фіксами, і використати саме його у тому місці, яке зарепорчено в тасці - так ми і інтерфейс зберігаємо, і зводимо імовірність появи багів у інших місцях до мінімуму.
Liskov Substitution Principle
Насправді досить простий принцип. Взятий у Барбари Лісков, але формулювання використовують Мартіна (а все тому, що Лісков - математикиня).
Функції, що використовують базовий тип, повинні мати можливість використання підтипів базового типу, не знаючи про це.
Тобто, поведінка базового класу не повинна протиречити поведінці нащадків.
Приклад
Стандартний приклад - класи Rectangle і Square:
class Rectangle { width: number height: number constructor(width: number, height: number) { this.width = width this.height = height } setWidth(width: number) { this.width = width } setHeight(height: number) { this.height = height } areaOf(): number { return this.width * this.height } }
class Square extends Rectangle { width: number height: number constructor(size: number) { super(size, size) } setWidth(width: number) { this.width = width this.height = width } setHeight(height: number) { this.width = height this.height = height } }
const square: Square square.setWidth(20) // змінює ширину і висоту, все правильно square.setHeight(40) // теж змінює ширину і высоту, ок
Але якщо ми, наприклад, почнемо писати тести...
function testShapeSize(figure: Rectangle) { figure.setWidth(10) figure.setHeight(20) assert(figure.areaOf() === 200) // умова не зпрацює, якщо figure — екземпляр класу Square }
Математично, так, квадрат - це прямоукутник, але він поводить себе інакше!
Таким чином, Liskov Substitution Principle пропонує використання композиції замість наслідування. А якщо і наслідування, то класи-нащадки не мають протиречити базовому класу, наприклад - надавати інтерфейс вужче базового.
Ще один приклад: класи DB і MockDB. При реалізації моку для тестів, імовірно, клас буде наслідуватись від DB і реалізовувати тільки методи, необхідні для тестів, а інші - кидати NotImplementedException. Якщо прийде інший розробник і вирішить написати свої тести з використанням цього ж моку, він з великою імовірністю отримає цей NotImplementedException. Таким чином, тут клас-нащадок надає інтерфейс вужче базового.
Ще одне формулювання від розумних дядьків:
Підклас не має вимагати від викликаючого його коду більше, ніж базовий клас, і надавати менше, ніж базовий клас.
В даному випадку "вимагати більше" - це наприклад перед викликом методів робити якусь ініціалізацію. А "надавати менше" - замість очікуваного обʼєкту повертати null.
До речі, правильну роботу цього принципу можна побачити на прикладі попереднього: якщо зробити кілька компонентів, які під собою використовують попредні - підставивши на сторінку замість базового компоненту один з нащадків ми не маємо побачити жодних змін.
Interface Segregation Principle
Доволі простий принцип, якщо ви працюєте зі строго-типізованою мовою.
Клієнти не мають залежати від методів, які вони не використовують.
Це означає, що якщо у нас є інтерфейс, і його зміна призводить до змін у тих клієнтах, які щойно змінені методи навіть не використовують - принцип порушений. Суть у тому, щоб зробити кілька різних інтерфейсів, щоб будь-яка зміна у будь-якому з них не призводила до випадків, описаних вище.
Приклад
Спочатку - стандартний приклад. Є у нас сайт з оренди нерухомості, фронт на тайпскрипті, і ми пишемо типи юзерів. У нас будуть:
- Звичайні юзери: можуть оформлювати оренду, ставити рейтинг орендодавцю і писати коменти;
- Орендодавці: можуть робити все те ж, що і звичайні юзери, плюс додавати нові оголошення;
- Адміни: можуть робити все вищезазначене, а також видаляти коменти і банити інших юзерів.
Припустимо, що буде у нас масив з юзерами усіх видів. І ми такі бахаємо великий інтерфейс:
interface User { id: number, role: Role, nickname: string; rentApartment(apartmentId: number): void, setRating(apartmentId: number, rating: number): void, addComment(apartmentId, number, comment: string): void, addApartment(title: string, description: string, photo: string): void, removeApartment(apartmentId: number): void, removeComment(commentId: number): void, banUser(userId: number): void }
- Нашо всім юзерам метод бану, якщо ніхто, крім адміна, цього зробити не може?
- Як тоді визначати такі методи? Опшинал?
і так далі. Правильніше зробити три інтерфейси: IUser, ILandlord і IAdmin, при цьому IUser навіть можна зробити базовим - і всі питання одразу ж відпадуть.
Приклад №2
Більш показовим прикладом є фасад класу для роботи з БДшкою. У ньому зазвичай є абсолютно всі методи: від пошуку до операцій CRUDу. Ну ми ж гарні розробники, думаємо "забахаємо інтерфейс до цього фасаду, треба ж, щоб все через інтерфейси спілкувалось" і бахнули один інтерфейс для всіх методів фасаду. Настав час писати клієнтів: один використовує тільки один метод пошуку, інший використовує якийсь складніший пошук, а третій взагалі лише операції CRUDу. І всі працюють через наш інтерфейс.
Припустимо, що інтерфейс трішки змінився, сигнатуру якогось там 340го методу змінили. І що? Правильно, після цього треба перекомпільовувати весь проект, хоча жоден з клієнтів навіть не використовує цей метод.
Звісно, найгірше стається тоді, коли цей фасад БДшки змінюється на щось інше, наприклад - два менші фасади по 4 методи. Якби для кожного клієнта під його потреби був маленький інтерфейс - ми б швидко все поправили. А так, з одним інтерфейсом - переписуй все.
Приклад №3
Цей приклад більше про фронтенд. От ми юзаємо якусь бібліотеку для графіків (нехай буде HighCharts). Використали з неї лише PieChart та і все. Через пару місяців вирішили оновити депенденсі на проекти, перед цим почитавши чейнджлог (у якому не знайшли речей, що стосуються PieChart'ів). Оновили - і все впало.
У цьому випадку інтерфейсом ми вважаємо те, як бібліотека експортує свої складові і розділяє їх. Припустимо, HighCharts ми в проект включаємо повністю, використовуючи лише PieChart - ось і проблема.
Щоб пофіксити це, гарно було б розділити бібліотеку на різні модулі (плагіни): Highcharts (загальний), TableChart, PieChart, DataParsing etc. Таким чином, при оновленні лише PieChart'у, ми б майже гарантовано не отримали жодних проблем.
Гарно реалізована з цієї точки зору бібліотека i18next. Вона поділена на плагіни, і кожний може підключатись до проекту окремо:
import i18next from 'i18next'; import Backend from 'i18next-http-backend'; import Cache from 'i18next-localstorage-cache'; import PostProcessor from 'i18next-sprintf-postprocessor'; import LanguageDetector from 'i18next-browser-languagedetector'; i18next .use(Backend) .use(Cache) .use(LanguageDetector) .use(PostProcessor);
- Окремо тестувати модулі розробникам бібліотеки;
- Не включати до проекту зайвого і економити розмір бандлу;
- Винести розробникам ці модулі в різні репозиторії і працювати над ними окремо.
Всі ми знаємо приклад, що суперечить принципу розділення інтерфейсів - бібліотека moment.js. Вона настільки розрослась, що використовувати і сапортити її стало проблематично, тож багато проектів перейшло на аналоги, а її розробники оголосили про закінчення підтримки.
Примітка
Варто зазначити, що інтерфейси практично завжди потрібні клієнтам, а не тому, хто надає сервіс - саме клієнти найкраще знають, що їм потрібно. Тому і створюючи інтерфейс, треба думати як клієнт, або навіть краще спитати, що клієнту треба - як у випадку з апішкою між фронтендом і бекендом.
Dependency Inversion Principle
Найскладніший особисто для мене принцип.
Модулі вищого рівня не повинні залежати від модулів нижчого рівня. Всі модулі повинні залежати від абстракцій.
Абстракції не повинні залежати від деталей реалізації. Деталі реалізації повинні залежати від абстракцій.
Деякі розробники просто описують цей принцип як "використовуйте всі класи через інтерфейси", щоб уникнути купи проблем у майбутньому, як ось тут:
interface ILogger { write(message: string): void; } class FileLogger implements ILogger { write(message: string): void { // write to file } } class StandardOutLogger implements ILogger { write(message: string): void { // write to standard out } } function doStuff(logger: ILogger): void { logger.write("some message") }
Проте це не зовсім повне трактування, і далі я хотів би його розширити.
По-перше: що таке модулі вищого і нижчого рівня? Нижчий рівень - це ті загальні допоміжні модулі, які використовуються у більш конкретному коді. Як приклад можна навести будь-яку зовнішню бібліотеку, власний сервіс/модуль для роботи з чимось, типу локал стореджа, або навіть якийсь сервіс, що ми використовуємо через його АПІшку. Модулі вищого рівня мають цей код як свою депенденсі. Надалі будемо розглядати саме приклад для роботи зі стореджом.
Для чого потрібний цей принцип? Ну використовуємо ми, наприклад, локал сторедж по всьому коду, то й що? Ну, наприклад, даних стане настільки багато, що ми впремося у 5 мегабайт. Або треба зберігати картинки. Або взагалі сказали перевикористати код в нативній аплікушці, де локал стореджу просто не існує. Та це ще дрібниці - що як треба зробити мок цієї депенденсі для тестів? Всі такі зміни виливаються у купу змін в коді, через що він гарантовано десь зламається, та і ми задовбемось все переписувати.
Але ж як зробити так, щоб не наш код залежав від бібліотеки, а бібліотека залежала від нашого коду? Ну, повернемося до другої частини першого твердження: всі модулі мають залежати від абстракцій. Окей, зробимо абстракцію над функціями роботи з локал стореджем (або бібліотекою), і будемо залежати вже від неї. Це вже покращить наше становище - АПІшка локал стореджу буде використовуватись лише в абстракції, і при її зміні не потрібно буде переписувати півпроекту. Також це запобігає сильному звʼязуванню модулів - high coupling. Проте, саме на цьому багато розробників і зупиняються - робота зроблена - і таке рішення справді підходить для маленьких проектів.
Але ж поки ми виконали лише першу частину першого твердження - абстракція все ще залежить від деталей реалізації і локал сторедж аж ніяк не залежить від абстракції. Тут маємо до абстракції додати клієнтський інтерфейс і адаптер до нього. Наприклад, будемо використовувати сторедж для роботи з АРІ токеном:
interface TokenStorage { setToken(token: string): Promise<void>; getToken(): Promise<string>; }
let storage; async function setItem(name, value) { if (typeof storage.setItem !== "function") { throw "Storage should implement setItem method"; } storage.setItem(name, value); } async function getItem(name) { if (typeof storage.getItem !== "function") { throw "Storage must implement getItem method"; } return storage.getItem(name); } function setStorage(instance) { storage = instance; } export default { setStorage, getItem, setItem };
import storage from "../storage"; async function setToken(token: string): Promise<void> { storage.setItem("token", token); } async function getToken(): Promise<string> { return storage.getItem("token"); } export default { setToken, getToken };
Отже, маємо локал сторедж, який вже тепер залежить від створеної абстракції, і надає для неї адаптер. А клієнт має інтерфейс і також залежить від абстракції.
Перевага цього підходу полягає у тому, що тепер після оновлення бібліотеки для роботи зі стореджом (або її заміни на іншу), клієнтський код і абстракція взагалі цього не відчують, а от якщо зміниться код в клієнті, то достатньо буде змінити його інтерфейс і відповідний адаптер.
Ось тут добре написано про ці адаптери: https://bespoyasov.ru/blog/clean-architecture-on-frontend
Висновки
S - Single Responsibility Principle: програмні сутності мають мати лише одну відповідальність і ця відповідальність має бути інкапсульована в них. Знижує імовірність дублювання коду. Запобігає слабкому звʼязуванню сутностей всередині модулю (low cohesion).
O - Open-Closed Principle: програмні сутності мають бути закриті для змін і відкриті для розширення. В рази зменшує імовірність виникнення нових багів.
L - Liskov Substitution Principle: методи/функції, що використовують базовий тип, повинні мати можливість використання підтипів базового типу, не знаючи про це. Тобто підклас не має вимагати від викликаючого його коду більше, ніж базовий клас, і надавати менше, ніж базовий клас. Позбавляє розробників від неочікуваних результатів роботи.
I - Interface Segregation Principle: клієнти не мають залежати від методів, які вони не використовують. Тобто якщо один інтерфейс реалізують два клієнти, але деякі його методи не використовуються в них, треба розбити інтерфейс на два.
D - Depencency Inversion Principle: модулі вищого рівня не повинні залежати від модулів нижчого рівня - всі модулі повинні залежати від абстракцій;
Абстракції не повинні залежати від деталей реалізації - деталі реалізації повинні залежати від абстракцій. Тобто перед використанням якоїсь залежності нижчого рівня варто принаймні зробити інтерфейс/абстракцію над нею, а краще - ще й адаптер. Запобігає сильному звʼязуванню (high coupling) модулів.
Джерела
SOLID by Sergey Nemchinskiy: https://www.youtube.com/watch?v=O4uhPCEDzSo&list=PLmqFxxywkatQNWLG1IZYUhKoQrnuZHqaK
SOLID in FrontEnd by IT Sin9k: https://www.youtube.com/watch?v=WMO9aarO390&list=PLz_dGYmQRrr8rWKkoB3BtxF7JpCzUKny_
DIP by Web Dev Simplified: https://www.youtube.com/watch?v=9oHY5TllWaU
SOLID by Ostap Chervak & Andrii Zhydkov: https://uncomment.fm/episodes/03_solid
SOLID by StackOverflow: https://stackoverflow.blog/2021/11/01/why-solid-principles-are-still-the-foundation-for-modern-software-architecture/
SOLID book: https://ota-solid.vercel.app/