Úvod
SOLID je mnemonická skratka pre päť princípov objektovo orientovaného návrhu, ktoré predstavil Robert C. Martin, všeobecne známy ako Uncle Bob. Tieto princípy majú pomôcť softvérovým dizajnérom, architektom, inžinierom a vývojárom vytvárať flexibilnejší, udržiavateľnejší a škálovateľnejší softvér. Dodržiavaním týchto princípov môžete navrhovať triedy, ktoré sa ľahšie testujú, refaktorujú, opätovne používajú a rozširujú.
Skratka SOLID znamená:
S – Princíp jednej zodpovednosti
O – Princíp otvorenosti a uzavretosti
L – Liskovovej princíp substitúcie
I – Princíp segregácie rozhraní
D – Princíp inverzie závislostí
V tomto článku si podrobne vysvetlíme každý princíp, aby sme pochopili, ako vám môže pomôcť písať lepší kód. Okrem toho ku každému princípu pridáme ukážky kódu, aby sme vám ukázali, ako ich môžete uplatniť vo svojej programátorskej praxi, a tiež to, čomu by ste sa mali v architektúre čistého kódu vyhnúť. Na demonštráciu týchto konceptov budeme používať programovací jazyk Kotlin vyvíjaný spoločnosťou JetBrains a open-source prispievateľmi.
Princíp jednej zodpovednosti
Princíp jednej zodpovednosti (SRP) je princíp návrhu softvéru, ktorý hovorí, že každá trieda alebo modul v programe by mal mať jedinú, jasne definovanú zodpovednosť. To znamená, že trieda by mala mať iba jeden dôvod na zmenu a mala by byť zodpovedná iba za jedinú časť funkčnosti programu.
Zvážte nasledujúci úryvok kódu ako príklad toho, ako možno tento princíp uplatniť v Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
Kľúčové data slovo v jazyku Kotlin označuje, že táto trieda je dátová trieda, čo znamená, že je určená na uchovávanie dát a nemá žiadne komplexné správanie. Použitím tejto dátovej triedy môžeme vytvoriť UserService triedu na správu používateľov, ako je znázornené nižšie:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // kód na vytvorenie používateľa v databáze } fun deleteUser(user: User) { // kód na vymazanie používateľa z databázy } fun updateUser(user: User) { // kód na aktualizáciu používateľa v databáze } fun getUser(id: Int): User { // kód na získanie používateľa z databázy } } |
V tomto príklade má trieda UserService jedinú zodpovednosť: správu používateľov v databáze. Každý používateľ je reprezentovaný kódom dátovej triedy User zdieľaným skôr. Všetky metódy v triede UserService sú spojené s touto zodpovednosťou, a preto sú kohézne. Vďaka tomu je trieda ľahšie pochopiteľná a udržiavateľná, keďže je jasné, že všetky metódy v triede súvisia s jedinou, jasne definovanou úlohou.
Metóda, ktorá porušuje princíp jednej zodpovednosti (SRP) v triede UserService, by bola taká, ktorá nesúvisí s hlavnou zodpovednosťou triedy, ktorou je správa používateľov v databáze. Zvážte napríklad nasledujúci variant triedy UserService:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // kód na vytvorenie používateľa v databáze } fun deleteUser(user: User) { // kód na vymazanie používateľa z databázy } fun updateUser(user: User) { // kód na aktualizáciu používateľa v databáze } fun getUser(id: Int): User { // kód na získanie používateľa z databázy } fun sendEmail(user: User, subject: String, message: String) { // kód na odoslanie e-mailu používateľovi } } |
V tomto príklade metóda sendEmail nesúvisí s hlavnou zodpovednosťou UserService trieda, ktorá spravuje používateľov v databáze. Táto metóda je zodpovedná za odosielanie e-mailov, čo je iná záležitosť ako správa používateľov. V dôsledku toho táto metóda porušuje SRP, pretože zavádza druhý dôvod na zmenu triedy UserService.
Pre dodržanie princípu jednej zodpovednosti by bolo lepšie oddeliť funkcionalitu odosielania e-mailov do samostatnej triedy, ako je napríklad EmailService trieda. To by umožnilo UserService triede zamerať sa na svoju primárnu zodpovednosť správy používateľov, a EmailService triedu, aby sa sústredila na svoju zodpovednosť za odosielanie e-mailov.
Mali by ste vziať na vedomie, že princíp jednej zodpovednosti nie je o počte metód, ktoré trieda má, ale skôr o súdržnosti metód a jasnom oddelení zodpovedností v rámci triedy.
Princíp otvorenosti a uzavretosti
Princíp otvorenosti a uzavretosti (OCP) je princíp softvérového dizajnu, ktorý hovorí, že softvérové entity (ako napríklad triedy, moduly alebo funkcie) by mali byť otvorené pre rozšírenie, ale uzavreté pre úpravu. To znamená, že by malo byť možné pridať novú funkcionalitu do triedy alebo modulu bez zmeny jeho existujúceho kódu.
Uvažujme o UserService triede z predchádzajúceho príkladu. Predpokladajme, že chceme pridať novú funkciu do UserService triedy, ktorá nám umožňuje vyhľadávať používateľov podľa e-mailovej adresy. Jedným zo spôsobov, ako to urobiť, by bolo pridať novú metódu do UserService triedu, ako je zvýraznené v kóde nižšie:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
trieda UserService { fun createUser(user: User) { // kód na vytvorenie používateľa v databáze } fun deleteUser(user: User) { // kód na vymazanie používateľa z databázy } fun updateUser(user: User) { // kód na aktualizáciu používateľa v databáze } fun getUser(id: Int): User { // kód na získanie používateľa z databázy } fun searchUsersByEmail(email: String): List<User> { // kód na vyhľadávanie používateľov podľa e-mailovej adresy } } |
Tento prístup funguje, ale porušuje Open Closed Principle, keďže sme museli upraviť existujúci UserService triedu, aby sme pridali novú funkciu. Lepším prístupom by bolo použiť dedičnosť alebo kompozíciu na rozšírenie funkčnosti UserService triedy bez úpravy jej kódu.
Aby sme to dosiahli, mohli by sme vytvoriť novú triedu s názvom UserSearchService ktorá rozširuje UserService triedu a pridáva funkciu vyhľadávania podľa e-mailu:
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // kód na vyhľadávanie používateľov podľa e-mailovej adresy } } |
V tomto príklade je UserSearchService trieda otvorená na rozširovanie, keďže poskytuje dodatočnú funkcionalitu nad rámec toho, čo ponúka UserService trieda. Zároveň UserService trieda zostáva zatvorená pre modifikáciu, keďže sme nemuseli upravovať jej kód, aby sme pridali funkciu vyhľadávania podľa e-mailu.
Princíp substitúcie Liskovovej
Liskovovej princíp substitúcie (LSP) je princíp softvérového dizajnu, ktorý hovorí, že objekty nadtriedy by mali byť nahraditeľné objektmi podtriedy bez toho, aby to ovplyvnilo správnosť programu. To znamená, že podtrieda by mala byť platnou náhradou za svoju nadtriedu a mala by sa správať rovnako ako nadtrieda, ak sa použije v rovnakom kontexte.
Budeme pokračovať v ukážke s použitím User a UserService tried z predchádzajúcich príkladov. Aby bolo možné triedu User rozšíriť, použijeme open kľúčové slovo v Kotline:
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Tu je pôvodná UserService trieda, ktorá používa User dátovú triedu vyššie:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // kód na vytvorenie používateľa v databáze } fun deleteUser(user: User) { // kód na vymazanie používateľa z databázy } fun updateUser(user: User) { // kód na aktualizáciu používateľa v databáze } fun getUser(id: Int): User { // kód na získanie používateľa z databázy } } |
Predpokladajme, že chceme vytvoriť podtriedu User nazvanú AdminUser ktorá reprezentuje používateľov s administrátorskými právami. Mohli by sme to urobiť takto:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
V tomto príklade AdminUser trieda je platnou náhradou za User triedu, keďže sa správa rovnako ako User trieda a dá sa použiť všade tam, kde sa očakáva User objekt. Napríklad môžeme použiť AdminUser triedu s UserService triedou takto:
|
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
Tento kód je správny, keďže AdminUser trieda je platnou náhradou za User triedu a môže byť použitá rovnakým spôsobom ako User objekt.
Je dôležité poznamenať, že Liskov Substitution Principle je o niečom viac než len o dedičnosti. Ide o zabezpečenie toho, aby sa objekty podtriedy správali rovnako ako objekty nadtriedy, bez ohľadu na to, ako je podtrieda implementovaná. Napríklad, ak by sa AdminUser trieda správala inak ako User trieda nejakým spôsobom, porušilo by to Liskov Substitution Principle, keďže by nebola platnou náhradou za User triedu.
Princíp segregácie rozhraní
Princíp segregácie rozhraní (ISP) je princíp návrhu softvéru, ktorý hovorí, že klienti by nemali byť nútení závisieť od rozhraní, ktoré nepoužívajú. To znamená, že vo všeobecnosti je dobré vytvárať malé, špecializované rozhrania, ktoré robia jednu vec dobre, namiesto vytvárania veľkých, univerzálnych rozhraní, ktoré sa snažia robiť veľa vecí.
Tu je príklad toho, ako sa tento princíp dá uplatniť prepísaním tried User a UserService z predchádzajúcich príkladov:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // kód na vytvorenie používateľa v databáze } override fun deleteUser(user: User) { // kód na vymazanie používateľa z databázy } override fun updateUser(user: User) { // kód na aktualizáciu používateľa v databáze } override fun getUser(id: Int): User { // kód na získanie používateľa z databázy } } |
V tomto príklade UserService rozhranie definuje štyri metódy, ktoré súvisia so správou používateľov v databáze. Trieda DatabaseUserService implementuje toto rozhranie a poskytuje konkrétne implementácie pre tieto metódy.
Predpokladajme, že chceme pridať novú funkcionalitu do rozhrania UserService , ktoré nám umožňuje vyhľadávať používateľov podľa e-mailovej adresy. Jedným zo spôsobov, ako to urobiť, by bolo pridať novú metódu do rozhrania UserService :
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
Váš kód sa nespustí, ak túto metódu neimplementujete aj vo všetkých triedach, ktoré implementujú UserService rozhranie:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // kód na vytvorenie používateľa v databáze } override fun deleteUser(user: User) { // kód na odstránenie používateľa z databázy } override fun updateUser(user: User) { // kód na aktualizáciu používateľa v databáze } override fun getUser(id: Int): User { // kód na získanie používateľa z databázy } override fun searchUsersByEmail(email: String): List<User> { // kód na vyhľadávanie používateľov podľa e-mailovej adresy } } |
Aj keď tento prístup funguje, porušuje princíp segregácie rozhraní, pretože núti DatabaseUserService triedu implementovať metódu ( searchUsersByEmail ), ktorú nemusí potrebovať alebo používať.
Lepším prístupom by bolo vytvoriť samostatné rozhranie pre funkciu vyhľadávania podľa e-mailu:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Teraz máme samostatné, malé a zamerané rozhrania, t. j. UserService a UserSearchServicektoré majú jedinú zodpovednosť. Trieda vyžadujúca všetky funkčnosti týchto rozhraní ich môže implementovať tak, ako je znázornené v ukážke kódu nižšie:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // kód na vytvorenie používateľa v databáze } override fun deleteUser(user: User) { // kód na odstránenie používateľa z databázy } override fun updateUser(user: User) { // kód na aktualizáciu používateľa v databáze } override fun getUser(id: Int): User { // kód na získanie používateľa z databázy } override fun searchUsersByEmail(email: String): List<User> { // kód na vyhľadávanie používateľov podľa e-mailovej adresy } } |
Toto dodržiava princíp segregácie rozhraní, keďže to zaisťuje, že klienti (ako napríklad DatabaseUserService trieda) nie sú nútení závisieť od rozhraní, ktoré nepoužívajú.
Aby sme tento koncept lepšie pochopili, predpokladajme, že máme ďalšiu triedu s názvom MemoryUserService ktorá implementuje UserService rozhranie, ale nepotrebuje funkciu vyhľadávania e-mailov, môžeme kód napísať takto:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
V tomto príklade trieda MemoryUserService potrebuje iba implementovať metódy definované v rozhraní UserService, a nemusí sa starať o funkciu vyhľadávania e-mailov. To umožňuje MemoryUserService triede zamerať sa na svoju hlavnú zodpovednosť správy používateľov v pamäti, namiesto toho, aby bola nútená implementovať nesúvisiacu funkcionalitu.
Princíp inverzie závislostí
Princíp inverzie závislostí (DIP) je princíp návrhu softvéru, ktorý hovorí, že moduly vyššej úrovne by nemali závisieť od modulov nižšej úrovne, ale obe by mali závisieť od abstrakcií. To znamená, že vo všeobecnosti je dobré navrhnúť softvér tak, aby komponenty vyššej úrovne neboli viazané na konkrétne implementácie komponentov nižšej úrovne, ale namiesto toho záviseli od abstrakcií (ako sú rozhrania alebo abstraktné triedy), ktoré môžu byť implementované rôznymi spôsobmi.
Pozrime sa na príklad toho, ako sa dá tento princíp aplikovať na User a UserService triedy použité v predchádzajúcich fragmentoch kódu:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
rozhranie UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
V tomto príklade trieda UserService závisí od abstrakcie nazývanej UserRepository rozhranie, namiesto toho, aby sme záviseli od konkrétnej implementácie používateľského repozitára. To nám umožňuje implementovať UserRepository rozhranie rôznymi spôsobmi, napríklad pomocou databázy alebo úložiska v pamäti, bez toho, aby to ovplyvnilo UserService triedu.
Napríklad, tu je implementácia UserRepository rozhrania, ktoré používa databázu:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // kód na vytvorenie používateľa v databáze } override fun delete(user: User) { // kód na odstránenie používateľa z databázy } override fun update(user: User) { // kód na aktualizáciu používateľa v databáze } override fun get(id: Int): User { // kód na získanie používateľa z databázy } } |
Tu je ďalšia implementácia UserRepository rozhrania, ktoré používa úložisko v pamäti:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
Vďaka tomu je systém flexibilnejší a udržiavateľnejší, pretože nám to umožňuje zmeniť implementáciu repozitára bez ovplyvnenia zvyšku systému. Taktiež to uľahčuje testovanie triedy UserService, keďže v našich testoch môžeme mockovať závislosť UserRepository.
Záver
V tomto článku sme hovorili o piatich princípoch SOLID kódu a zdieľali sme ukážky kódu vyhovujúce každému princípu. Dodržiavanie týchto princípov vám môže pomôcť navrhnúť softvérové systémy, ktoré sú flexibilnejšie, udržiavateľnejšie a škálovateľnejšie. Je však dôležité mať na pamäti, že tieto princípy sú skôr usmerneniami než pevnými pravidlami a je na vývojárovi, aby sa rozhodol, kedy a ako ich uplatní v kontexte svojho konkrétneho projektu. Pokračujte v učení a pozrite si náš blog, kde nájdete podrobnejšie a aktuálnejšie články a návody o cloud computingu a DevOps, návrh a vývoj softvéru, technologické trendy, ktoré treba sledovať, a mnoho ďalšieho.
Príjemné kódovanie!
Komentáre
Zatiaľ žiadne komentáre. Buďte prvý.