SOLID як пытанне працоўнай змены: рефактораванне службы Spring Boot
Навучыцеся прыкладваць кожны з прынцыпаў SOLID у бэкендзе Spring Boot, ставячы пытанні пра тое, што можа зменіцца, і як утримацца ад чрэзмернай інжынерызаціі, якую часта спрыяюць прынцыпы SOLID.
Крыніцы: SOLID Principles for Java Developers: What 5 Years of Backend Development Taught Me
Большасць разработчыкаў можа запамятаць, што паводзіцца пад п’мяцю літер SOLID, але толькі некалькі з іх можа поясніць, як гэтыя літеры павінны стварыць рэальную службу Spring Boot з репазітарыямі, прадаўцамі платежаў і каналамі абавесцення. Лячынкі самыя по сабе не дапамагаюць, калі менеджер продукту просіць аб абавесценнях праз WhatsApp у пятнічны час дзённай. У гэтым кялешчыку рассказваецца пра кожны з прынцыпаў на прыкладзе типовага коду бэкенду, паказваецца рефакторынг, які прыпускае кожны з іх, а таксама, што не менш важліва, паказваецца, дзе механічныя застосоўкі прынцыпа пагаршуюць структуру коду.
Адыякавае ўсіх пяці прынцыпаў — не «больш супрацоўнасцей» чы «маленькіх класаў». Це — зберагчы код простым у змене, каб модыфікацыя аднаго якосткавання не паўтарылася ў всім, што з яго асоцыйваецца. Прыклады нижэй даны на Java, але гэтыя меркаванні можна без перашкод прынясіць да службаў на TypeScript у Node.js; якщо вы вярнуліся да такога падходу, існуе адпаведны матэрыял на SOLID чераз практычныя прыклады JavaScript.
Проблема, якую прагне запобiec SOLID
Пачніце з службы ардэру, якая выканае ўсё, што патрэбна для адправкі замовлення, ў аднам методе: яна пераканаець даны, зберагчы іх, надасць электранаўпы, стварыць рахунакі, паведаміць і запісае статыстыку.
public class OrderService {
public void createOrder(Order order) {
validateOrder(order);
saveOrder(order);
sendEmail(order);
generateInvoice(order);
sendNotification(order);
updateAnalytics(order);
}
}
Нічага тут не зламана. Логіка працюе зверху вниз, ёй быстро пасуў аудыт коду, і яна могла б працаваць у продакшэне месцаў. Проблемы пачынаюцца з наступных трэбаванняў. Адні з учаснікаў проекту хочаць, каб паведамленні надходзілі через WhatsApp адносна электранаўпы. Іншы хочаць, каб стварэнне рахунковых фатурак перавялі на заданні, якое выконваецца у фоне. Команда, якая займаецца дадзеннямі, аанунсавае міграцыю базы дадзенняў. Потым аналітыка павінна быть выкладзеная ў Kafka.
Кожны з гэтых запитоў падае ў той самы клас. Код все ўсё працуе; проблема тая, што кожная маленькая зміна вымушвае вас ачыняць, рэдагаваць і ператеставаць елемент, які пераплетаеца з пяцьма іншымі аспектамі. Гэтае пераплетанне, калі адна зміна тягне за сабой багато іншых, самэ ў тым і заключаецца мета прынцыпаў SOLID — контролаваць гэта.
Едынная адпаведнасць: лічыце прычыны для змены
Прынцып аднаея адпаведнасці гласі, што клас павінен мець адную адпаведнасць, што на практычным рэвэле значыць адную прычыну для змены. Ёго часта неправяце адначыта як «адна метода на клас». Такая інтерпрэтация некоректная: клас з двадцятьма метадамі все ўсё можа мець адную адпаведнасць, як толькі ўсе яны служаюць аднаму цылі.
Корыстным пытаннем ня ў тым, насколькі велик клас, а ў тым, што можа змусіць вас яго зменіць. Якщо вы можете перыявіць калькі некалязаных адказоў, клас, верагодна, несе калькі адпаведнасцей.
Выяўленне сумешаных адпаведнасцей
Ёсьць служба рэгістрацыі корыстніка ў стыле, які вы знайдзеце ў багатох базах коду.
public class UserService {
public void registerUser(User user) {
validate(user);
saveToDatabase(user);
sendEmail(user);
generateReport(user);
}
}
Ён выканае чатыры разныя задачы: паверыканне даступу корыстніка, яго збережэнне, адправка электронных пісьмаў і стварэнне атласа. Гэта дае чатыры незалежныя прычыны для змены. Новы прадаўцу электронной пошты значыць неабходнасць рэдагавання UserService. Пераход на іншую базу дадзеных таксама вымагае рэдагавання UserService. Змена формата атласа зноў прыводзіць да неабходнасці рэдагавання UserService. Калі несвязанныя каманды постаўляюць змены ў той самы файл, гэта є сігналам проблем у дизайне.
Дэлегаванне задач саўершальнікам
Працэс рефакторавання заставляе оркестрацыю ў UserService, а спецыялізаваную работу перакладае на спецыяльных саўершальнікаў, якія імпортуюцца через канстрактар.
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public void registerUser(User user) {
validate(user);
userRepository.save(user);
emailService.sendWelcomeEmail(user);
}
}
Структура залежнасцяў, якая выйшла, лёгка ўявіць:
UserService
|
+---- UserRepository
|
+---- EmailService
UserService теперь вяршыць пра тое, што адбываецца пад час рэгістрацыі, тады як UserRepository адпавядае за зберагчыцтва дадзэнняў, а EmailService — за ўсылку паведамленняў. Атрыбутызацыя застаецца ў самай службе, калі ўжо яна ёсць часткай правіла рэгістрацыі, а формаванне звятынаў выходзіць за межы процесу рэгістрацыі.
Звычка, якую трэба выклікваць, — гэта перестаць лічыць методы і замест таго спытацца, скількі разныя прычыны могу змусіць вас зменіць клас. Гэтыя запытанні паказваюць на рэальную зв’язку межу элементамі коду, а не на простае значэнне ў вялічыну.
Open/Closed: дадаць новую функцыональнасць без перзапуску стабільнага коду
Формальны выраз, што програмныя ентытэты должны быць адкрытымі для расширэння, але закрытымі для змены, звучыць абстрактна. У повсякдзенных тэрмінах: вы должны магчымае дадаць новую функцыональнасць без павтаральнага рэдагавання коду, які вже працюе і пераблакаваны.
Развіваючыяся умовы
Обробка платежаў — это класычны прыклад. Першая версія часта выкарыстоўвае стрынг для цэй мэты.
public class PaymentService {
public void pay(String type) {
if (type.equals("CARD")) {
// card payment
}
if (type.equals("UPI")) {
// UPI payment
}
if (type.equals("NET_BANKING")) {
// net banking
}
}
}
Это падходяжна для трох методаў. Але потым бізнес прасіць аб падтрымцы кошэлькаў, PayPal, Apple Pay і банкавых пераказаў. Кожны новы элемент значыць прабачанне PaymentService, расшырэнне умов і перапранацаванне кожнага ветка, які ўжо існаваў. Клас паступова ператвараецца на дужы ланцуг if/else.
Замена веткаў поліморфізамам
Падход «Адкрыты/Закрыты» вводзіць абстракцыю, якая описвае, што павінен робіць кожны спосаб платежа.
public interface PaymentProcessor {
void process(Payment payment);
}
Кожны спосаб платежа становіцца самастоятельной рэалізацыяй. Процесар картак:
public class CardPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// card payment
}
}
А процесар UPI:
public class UpiPaymentProcessor
implements PaymentProcessor {
@Override
public void process(Payment payment) {
// UPI payment
}
}
Дадзенне новага прадасталера зараз значыць напісанне новай класа, а не рэдагаванне існуючых. Прасэсары, якія вже выкарыстоўваюцца ў працэсе, застаюцца недастрымленымі, што зменшвае адзінак парадксу кожных змян. У застосоўцы Spring зазвычай кожны прасэсар рэгіструецца як bean, а правы выбіраецца за дапамою карточкі, ключаванай па типе адплатаў, таму нават логіка выбору не патрабуе стварэння новай галіны.
Калі інтэфейс нічога вам не дае
Нічыга з гэтага не значыць, што кожны клас патрабуе інтэфейса. Чыста распашчастая модель у проектах Spring — это одна-до-однай пара інтэфейса і яго ўзеўдоўж адмацавання:
public interface UserService {
void createUser();
}
з аднам класам пазаду яго:
public class UserServiceImpl implements UserService {
}
Якщо няма рэальных шансаў на другую рэалізацыю, і нічога не выгадваецца ад замены ўсё таго ж, гэты інтэфейс — проста дадатковы файл, які трэба перарашчаваць. Сучасныя бібліятэкі для мокінгу можаюць імітаваць конкрэтныя класы, таму сама можлівасць тэставання рэдкая калі ёсць сильным аргументам. PRINЦЫП SOLID стосуецца проектавання для змян, якія вы чакаеце, а не прымусовага пакрыць кожны клас абстракцыяй.
Замена на прынцыпе Ліскова: паважайце кантракт, а не толькі сігнатуру
Прынцып замены на прынцыпе Ліскова ставяе, што вы должны магчымаць викорыстоўваць будзь-які падтып там, дзе чакаецца яго родны тип, не паслабляючы правільнасці програмы. З пяці прынцыпаў гэты часта здаецца найбольш акадэмічным, таму дапамагае практыка з прыкладам платежаў.
Абстракцыя застаецца тая ж, як і раней:
public interface PaymentProcessor {
void process(Payment payment);
}
Рэалізацыя карткі выплняе тое, што обяцае інтэфейс:
public class CardPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
// process payment
}
}
Паказалася добра ситуацыя. Але потым хтось дадзеў працоўнік для грошовых платэжаў, які на самай працы не можа нічога обрабатваць у сети:
public class CashPaymentProcessor
implements PaymentProcessor {
public void process(Payment payment) {
throw new UnsupportedOperationException();
}
}
Кампайлер задоволены, таму што падпис метода збігаецца. Аднак такое прыемнае паведанне нарабляе проблем. Кожны, хто выкарыстоўвае PaymentProcessor, правядома вырашае, што вызов process будзе абрабатваць платэж. З такой рэалізацыяй, цілкам ненапастны цикл па всіх працоўніках можа раптам выклікваць крызыс у часе выканання, і користувачы змушаны дадаваць пераказы instanceof чы ў блокі try/catch, каб захавацца, што яўляеся явным знакам таго, што абстракцыя выйшла за межы.
Калі пішлося працаваць над рэалізацыяй, неабходна запытка: чы можа яна практычна працаваць так, як обявляе абстракцыя. Якшо часовая адказва — ні, то зазвычай трэба змяніць абстракцыю, напрыклад, шляхом окремага моделювання платэжаў без інтэрнету або раздзелення можлівасцяў на болей вузкі інтэрфейс. Прынцып Ліскова стосуецца правільнасці працы, а не таго, чы слова-кліянты implements компілююцца.
Раздзеленне інтэрфейсаў: не дазволяйце корыстувачам завісіць ад таго, чаго яны ігнаруюць
Принцып раздзелення інтэрфейсаў гласі, што жаданы кліент не павінен быць прымушаны завісіць ад методаў, якія ён не выкарыстоўвае. Вялікія, універсальныя інтэрфейсы — гэта там, дзе зазвычай прояўляецца гэты принцып.
Інтэрфейс, які паслужыць усім
Разглянем інтэрфейс, які аб’еднвае кераванне працоўнікамі, расчытку зароблака, апраўленне выпускных дняў і стварэнне звястак:
public interface EmployeeOperations {
void createEmployee();
void updateEmployee();
void deleteEmployee();
void generateSalary();
void approveLeave();
void generateReport();
}
Компанента для стварэння адзортаванняў патрабуе толькі адна операцыя:
generateReport();
Але, завязаная на EmployeeOperations, яна ўзаўсёды будзе асоціяваная з логікай стварэння, выдалення, расчытку заробкі і відпачынку, якую ніколі не будзе выкананаць.
Раздзелэнне па потребах корыстувачаў
Лепшая структура выклікае адзеленыя, спецыялізаваныя кантракты. Одны для керування дакументамі працавнікаў:
public interface EmployeeManagement {
void createEmployee();
void updateEmployee();
void deleteEmployee();
}
І другі для стварэння адзортаванняў:
public interface EmployeeReporting {
void generateReport();
}
Кожны корыстувач тепер завісіць толькі ад тых можнасцей, якія ён выкарыстоўвае. Адна класа можа яшчэ рэалізаваць обе; раздзелэнне стосуецца таго, што бачаюць кліенты.
Чаму это важна, калі система расте
У маленькім прыемніце занадта большы інтэрфейс ёст кароткі недагодзін. У вялікім прыемніце ён стае сэтком нехтачных звязкаў, калі змяна для аднаго корыстніка паўтараецца на багато некалявых. Меньшыя інтэрфейсы чыста паказваюць межы залежнасцяў і спрыяюць лепшаму ўсвядомленню іх.
Адзінакавасць тут такая ж, як і у прыемпрые Open/Closed. Не трэба дзеліць інтэрфейсы на фрагменты проста каб запалаваць пункт у списку. Дзеліце іх тады, калі розныя кліенты даскледзе разныя падскупы функцыйнасці.
Інверсія залежнасцяў: нехай бізнес-логіка залежыць ад абстракцый
Інверсія залежнасцяў заклёкае, каб код бізнес-рэгламентаў і код інфраструктуры абоўваюцься на спаканых абстракцый, а не каб шар правілаў сылаваўся на конкрэтныя класы нізкага рангу. У прыемніцах Spring Boot гэты прынцып, можна сказаць, ёсць найзначнейшым з пяці.
Інфраструктура, прыўязаная жорстка
Хутчэй, служба замовлень стварыць свой сабе рэпазітары:
public class OrderService {
private final MySqlOrderRepository repository;
public OrderService() {
this.repository = new MySqlOrderRepository();
}
}
Тепер логіка бізнесу точна ведае, яка тэхналогія базы дадзеных зберагае яе данні. Чырванне MySQL або замена штучнага рэпазітарыя ў тэсте на единайцу вымагае рэдагавання самога OrderService. Высокія прынцыпы керавання прыўязаны да нізкіх деталяў.
Інверсія залежнасцей
Спачатку задаць, калі на самай працэ логіка бізнесу патрэбны данні, незалежна ад тэхналогіі зберагання:
public interface OrderRepository {
void save(Order order);
}
Потым клас, прызначаны для MySQL, стае аднай з рэалізацый гэтага кантракту:
@Repository
public class MySqlOrderRepository
implements OrderRepository {
@Override
public void save(Order order) {
// save order
}
}
У падсумку служба атрымлеяе абстракцыю чераз свой канструктар:
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
OrderService большэй не пакіраецца тым, дзе будуць знаходзіцца замовленні. Імплементацыя можа быць MySQL, PostgreSQL, MongoDB, вызовам іншага сервісу або мак-об’ектам у памяці, які викорыстоўваюцца пад час тэстаў. Напрамак перадачы знанняў змяніўся: інфраструктура прыстаёмлівае да інтэрфейсу, які належыць бізнес-шару, а не бізнес-шар прыстаёмлівае да інфраструктуры.
Як вставка залежнасцяў у Spring падтрымлівае гэтыя прынцыпы
Spring робіць інверсію залежнасцяў прыродной, таму што яго контейнер адпаведна налажвае ўсе зв’язкі. Калі сервіс задаёць своіх спавярнікаў у канстрактары, як паказана нижэй, Spring знаходзіць бін, які падходзіць па типу OrderRepository, і передае яго.
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
Клас ніколі не стварае своіх залежнасцяў сам. Поручыце гэта з прымітным стварэнням экземпляра ў самам коде бізнесу:
OrderRepository repository =
new MySqlOrderRepository();
Эта адначыя лінія запоўнюе вызывача конкрэтным класам і не дазволяе зьдзейсніць замену без яго рэедагавання. Ін’екцыя канстрактара падтрымвае зв’язкі на рэўлеўне абстракцыі і робіць залежнасці виднымі ў падписе класа, і самэ гэта яшчэ больш часта ўзято за прывілей у пораўнаньы з ін’екцыяй поль. Памяркуйце, што ін’екцыя залежнасцей — цэла механізм, а інверсія залежнасцей — прынцып проектавання: кантэйнер памагае вам запоўніць абстракцыямі, але вы все рава должны выбраць, якія абстракцыі визначаць.
SOLID не значыць больш класоў
Команды, якія толькі пазналі SOLID, інодзе перэйджаюць у крайносці. Адна функцыя для корыстніка ператвараецца на дзесяткі типоў:
UserValidator
UserCreator
UserUpdater
UserRepository
UserMapper
UserConverter
UserEmailService
UserNotificationService
UserFactory
UserBuilder
UserManager
UserProcessor
Якща така практыка будзе застосавана ў всім кодбэсе, то выйдзе чатырнацца класоў, калі ж восьмы моглі б яшчэ чытрае выразіць той жа дизайн. Гэта ўзбочаная інжынерыя, а не хорашая архітектура. Принцыпы існуюць для зменшэння складнасі; якщо ўжыцеўванне аднаго з іх робіць код важкім для разумення, значыць вы, верагатна, працуеце за шаблонамі, а не у вяданні практычных прычын змян.
Пачніце з таго, што можа змяніцца
У працоўніках бэкенду з дазналым досвядам замест таго, каб спытацца, дзе можна застосаваць принцыпы SOLID, яны зазвычай пачынаюць з іншага пытання: што ў гэтым компоненте можа змяніцца? Адпаведны адказ прыкладна паўтарае тэзы принцыпаў:
- База дадзеных можа змяніцца: неабходна ввести абстракцыю репазітарыя.
- Прадаўцы платэжных аперацый можа змяніцца: прадаўцы трэба розместіць за абстракцыяю платэжаў.
Такое падача прымічае прынцыпы да рэальнасці. Адносіце абстракцыі там, дзе ёсць рэальная нестабільнасць, а стабільны код залейвайце простым.
Застосаванне таго ж падходу да меж сэрвісаў
Гэтыя меркаванні распрастраняюцца за межы адзіных класаў. Уявіце сабе адны сэрвіс, які можа быць запускаемым і адпавядае за ўсі этыя домены:
User
Payment
Notification
Reporting
Inventory
Shipping
Ён можа працаваць абсалютна гэтачынка, але корыстувачы, платежы, прыемныя, аналіз даных, запасы і доставка зменяюцца з розных прычын і частаў у разныя трымкі, якімі керуюць розныя команды. Логіка прынцыпа адной адпаведнасці можа памагчы вам знайсці месца, дзе знаходзяцца природныя межы.
Гэта не значыць, што кожная доменна концэпцыя заслуговае на свой мікросервіс. SOLID — гэта не прыказ падмагчы больш меркава; гэта напамінак пра неабходнасць правильнага раздзелення адпаведнасцяў і залежнасцей, незалежна ад таго, чыя межы будуць выражаныя як модулі, пакеты чы сервісы.
Спіс пераканальнай перагляду для компонентаў бэкенду
Перш чым сцягнуцца, што значны компонент бэкенду завершаны, разгляньце адна запитанне па прынцыпу:
- Адна адпаведнасць: чы ў гэтым класе є калькі нясувязаных прычын для змены?
Чыстасныя адказы на гэтыя запитанні ценюцца больш, чым ўмэнне запамятаць ўзначэнні.
Ключовыя выводы
- Мета ўсіх пяці прынцыпаў адна: зробіць наступныя змены дышэўнымі і контрольванымі, так што замена базы дадзеных, адключэнне прадаўца платежаў чы змена канала абавесцяў будзе выглядаць як звычайная процедура, а не рызыкаваная.
- Чытайце кожны прынцып як запит. Большая служба ставіць запыт «Што тут зменяецца?», інтерфейс на дзесяць методаў — «Кому патрэбны ўсе гэтыя методы?», а
new SomeImplementation()у логіцы бізнесу — «Чы рэкламаецца, каб гэты клас ведаў пра гэта?». - Вводзіце абстракцыі там, дзе змены ўразумелы, і адстаіцеся ад яных там, дзе гэта немагчыма; інтерфейс з адной стацыонарнаяя рэалізацыяй або дзесяткі класоў для адной функцыялкі падвышаюць вартасць без адпаведнага захавання.
- Паўшчэнні прынцыпа Ліскова зазвычай сигналізуюць пра неправильную абстракцыю, таму лепш вылагодзіце саму модэль, чым проста латаваце тых, хто яю выкарыстоўвае.
- Пішыце код, які будзе працаваць сёння, і структуруйце яго так, каб маніпуляцыі завтра не былі складнейшымі, чым сённяшнія.