Úvod
SOLID je mnemotechnická zkratka pro pět principů objektově orientovaného návrhu, které představil Robert C. Martin, populárně označovaný jako Uncle Bob. Tyto principy mají pomoci softwarovým návrhářům, architektům, inženýrům a vývojářům vytvářet flexibilnější, udržovatelnější a škálovatelnější software. Dodržováním těchto principů můžete navrhovat třídy, které se snáze testují, refaktorují, opakovaně používají a rozšiřují.
Zkratka SOLID znamená:
S – Princip jedné odpovědnosti
O – Princip otevřenosti a uzavřenosti
L – Liskovové princip zastoupení
I – Princip segregace rozhraní
D – Princip inverze závislostí
V tomto článku si vysvětlíme každý princip zvlášť, abychom pochopili, jak vám může pomoci psát lepší kód. Navíc ke každému principu přidáme ukázky kódu, abychom vám ukázali, jak je můžete uplatnit při svém programování, a také čemu byste se měli v architektuře čistého kódu vyhnout. K demonstraci těchto konceptů budeme používat programovací jazyk Kotlin vyvíjený společností JetBrains a přispěvateli open-source.
Princip jedné odpovědnosti
Princip jedné odpovědnosti (SRP) je princip softwarového návrhu, který říká, že každá třída nebo modul v programu by měl mít jedinou, jasně definovanou odpovědnost. To znamená, že třída by měla mít pouze jeden důvod ke změně a měla by být zodpovědná pouze za jedinou část funkcionality programu.
Zvažte následující ukázku kódu jako příklad toho, jak lze tento princip aplikovat v jazyce Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
Klíčové slovo data v jazyce Kotlin značí, že tato třída je datová třída (data class), což znamená, že je určena k uchovávání dat a nemá žádné složité chování. Pomocí této datové třídy můžeme vytvořit UserService třídu pro správu uživatelů, jak je znázorněno níže:
|
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) { // code to create a user in the database } fun deleteUser(user: User) { // code to delete a user from the database } fun updateUser(user: User) { // code to update a user in the database } fun getUser(id: Int): User { // code to retrieve a user from the database } } |
V tomto příkladu má třída UserService jedinou odpovědnost: správu uživatelů v databázi. Každý uživatel je reprezentován data class User kódem sdíleným dříve. Všechny metody ve třídě UserService souvisejí s touto odpovědností, a jsou proto kohezní. Díky tomu je třída snáze srozumitelná a udržovatelná, protože je jasné, že všechny metody v ní se vztahují k jedinému, jasně definovanému úkolu.
Metoda, která porušuje princip jedné odpovědnosti (SRP) ve třídě UserService , by byla taková, která nesouvisí s hlavní odpovědností třídy, což je správa uživatelů v databázi. Zvažte například následující variantu třídy 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 pro vytvoření uživatele v databázi } fun deleteUser(user: User) { // kód pro smazání uživatele z databáze } fun updateUser(user: User) { // kód pro aktualizaci uživatele v databázi } fun getUser(id: Int): User { // kód pro načtení uživatele z databáze } fun sendEmail(user: User, subject: String, message: String) { // kód pro odeslání e-mailu uživateli } } |
V tomto příkladu metoda sendEmail nesouvisí s hlavní odpovědností třídy UserService , kterou je správa uživatelů v databázi. Tato metoda je zodpovědná za odesílání e-mailů, což je záležitost oddělená od správy uživatelů. V důsledku toho tato metoda porušuje SRP, protože zavádí druhý důvod pro změnu třídy UserService.
Pro dodržení principu jedné odpovědnosti (Single Responsibility Principle) by bylo lepší oddělit funkcionalitu odesílání e-mailů do samostatné třídy, jako je například EmailService . To by umožnilo třídě UserService soustředit se na svou hlavní odpovědnost, kterou je správa uživatelů, a třídě EmailService soustředit se na svou odpovědnost za odesílání e-mailů.
Měli byste vzít na vědomí, že princip jedné odpovědnosti není o počtu metod, které třída má, ale spíše o soudržnosti metod a jasném oddělení odpovědností v rámci třídy.
Princip otevřenosti/uzavřenosti (Open-Closed Principle)
Princip otevřenosti/uzavřenosti (OCP) je princip softwarového návrhu, který říká, že softwarové entity (jako jsou třídy, moduly nebo funkce) by měly být otevřené pro rozšíření, ale uzavřené pro změny. To znamená, že by mělo být možné přidat novou funkcionalitu do třídy nebo modulu bez změny jejího stávajícího kódu.
Podívejme se na třídu UserService z předchozího příkladu. Předpokládejme, že chceme do třídy UserService přidat novou funkci, která nám umožní vyhledávat uživatele podle e-mailové adresy. Jedním ze způsobů, jak toho dosáhnout, by bylo přidat novou metodu do třídy UserService , jak je zvýrazněno v kódu níže:
|
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 pro vytvoření uživatele v databázi } fun deleteUser(user: User) { // kód pro smazání uživatele z databáze } fun updateUser(user: User) { // kód pro aktualizaci uživatele v databázi } fun getUser(id: Int): User { // kód pro načtení uživatele z databáze } fun searchUsersByEmail(email: String): List<User> { // kód pro vyhledání uživatelů podle e-mailové adresy } } |
Tento přístup funguje, ale porušuje princip otevřenosti/uzavřenosti, protože jsme museli upravit stávající třídu UserService , abychom přidali novou funkci. Lepším přístupem by bylo použít dědičnost nebo kompozici k rozšíření funkcionality třídy UserService bez úpravy jejího kódu.
Abychom toho dosáhli, mohli bychom vytvořit novou třídu s názvem UserSearchService , která rozšiřuje třídu UserService a přidává funkci vyhledávání podle 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 pro vyhledání uživatelů podle e-mailové adresy } } |
V tomto příkladu je třída UserSearchService otevřená pro rozšíření, protože poskytuje další funkcionalitu nad rámec toho, co nabízí třída UserService třída. Zároveň UserService třída zůstává uzavřena pro úpravy, protože jsme nemuseli měnit její kód, abychom přidali funkci vyhledávání podle e-mailu.
Liskovové princip substituce
Liskovové princip substituce (LSP) je princip návrhu softwaru, který říká, že objekty nadtřídy by měly být nahraditelné objekty podtřídy, aniž by to ovlivnilo správnost programu. To znamená, že podtřída by měla být platnou náhradou za svou nadtřídu a měla by se ve stejném kontextu chovat stejně jako nadtřída.
V ukázce budeme pokračovat s využitím User a UserService tříd z předchozích příkladů. Abychom umožnili rozšíření třídy User, použijeme open klíčové slovo v Kotlinu:
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Zde je původní UserService třída, která používá User datovou třídu výše:
|
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 pro vytvoření uživatele v databázi } fun deleteUser(user: User) { // kód pro smazání uživatele z databáze } fun updateUser(user: User) { // kód pro aktualizaci uživatele v databázi } fun getUser(id: Int): User { // kód pro načtení uživatele z databáze } } |
Předpokládejme, že chceme vytvořit podtřídu třídy User s názvem AdminUser, která představuje uživatele s administrátorskými právy. Mohli bychom to udělat takto:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
V tomto příkladu je třída AdminUser platnou náhradou za třídu User, protože se chová stejně jako třída User a lze ji použít všude tam, kde se očekává objekt User. Třídu AdminUser můžeme například použít s třídou UserService 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ávný, protože třída AdminUser je platnou náhradou za třídu User a lze ji použít stejným způsobem jako objekt User.
Je důležité si uvědomit, že Liskovové princip substituce je o něčem víc než jen o dědičnosti. Jde o zajištění toho, aby se objekty podtřídy chovaly stejně jako objekty nadtřídy, bez ohledu na to, jak je podtřída implementována. Pokud by se například třída AdminUser měla v nějakém ohledu chovat jinak než třída User, porušilo by to Liskovové princip substituce, protože by nebyla platnou náhradou za třídu User.
Princip oddělení rozhraní
Princip oddělení rozhraní (ISP) je princip návrhu softwaru, který říká, že klienti by neměli být nuceni záviset na rozhraních, která nepoužívají. To znamená, že je obecně dobré vytvářet malá, úzce zaměřená rozhraní, která dělají jednu věc dobře, namísto vytváření velkých, univerzálních rozhraní, která se snaží dělat mnoho věcí.
Zde je příklad, jak lze tento princip aplikovat přepsáním tříd User a UserService z předchozích příkladů:
|
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 pro vytvoření uživatele v databázi } override fun deleteUser(user: User) { // kód pro smazání uživatele z databáze } override fun updateUser(user: User) { // kód pro aktualizaci uživatele v databázi } override fun getUser(id: Int): User { // kód pro načtení uživatele z databáze } } |
V tomto příkladu rozhraní UserService definuje čtyři metody, které souvisejí se správou uživatelů v databázi. Třída DatabaseUserService toto rozhraní implementuje a poskytuje konkrétní implementace těchto metod.
Předpokládejme, že chceme přidat novou funkci do rozhraní UserService, které nám umožní vyhledávat uživatele podle e-mailové adresy. Jedním ze způsobů, jak toho docílit, by bylo přidání nové metody do rozhraní UserService rozhraní:
|
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 se nespustí, pokud tuto metodu neimplementujete také ve všech třídách, které implementují rozhraní UserService rozhraní:
|
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 pro vytvoření uživatele v databázi } override fun deleteUser(user: User) { // kód pro smazání uživatele z databáze } override fun updateUser(user: User) { // kód pro aktualizaci uživatele v databázi } override fun getUser(id: Int): User { // kód pro načtení uživatele z databáze } override fun searchUsersByEmail(email: String): List<User> { // kód pro vyhledávání uživatelů podle e-mailové adresy } } |
Ačkoliv tento přístup funguje, porušuje princip segregace rozhraní (Interface Segregation Principle), protože nutí třídu DatabaseUserService implementovat metodu ( searchUsersByEmail ), kterou nemusí potřebovat ani využívat.
Lepším přístupem by bylo vytvořit samostatné rozhraní pro vyhledávání podle e-mailu:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Nyní máme samostatná, malá a úzce zaměřená rozhraní, tj. UserService a UserSearchServicekteré mají jedinou odpovědnost. Třída vyžadující všechny funkce těchto rozhraní je může implementovat tak, jak je znázorněno v ukázce kódu níže:
|
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 pro vytvoření uživatele v databázi } override fun deleteUser(user: User) { // kód pro smazání uživatele z databáze } override fun updateUser(user: User) { // kód pro aktualizaci uživatele v databázi } override fun getUser(id: Int): User { // kód pro získání uživatele z databáze } override fun searchUsersByEmail(email: String): List<User> { // kód pro vyhledávání uživatelů podle e-mailové adresy } } |
Toto je v souladu s principem segregace rozhraní, protože to zajišťuje, že klienti (jako například DatabaseUserService třída) nejsou nuceni záviset na rozhraních, která nepoužívají.
Pro lepší pochopení tohoto konceptu předpokládejme, že máme další třídu s názvem MemoryUserService která implementuje rozhraní UserService , ale nepotřebuje funkci vyhledávání podle e-mailu, můžeme kód napsat 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 příkladu třída MemoryUserService musí implementovat pouze metody definované v rozhraní UserService a nemusí se starat o funkci vyhledávání podle e-mailu. To umožňuje třídě MemoryUserService zaměřit se na svou primární odpovědnost, kterou je správa uživatelů v paměti, namísto toho, aby byla nucena implementovat nesouvisející funkce.
Princip inverze závislostí
Princip inverze závislostí (DIP) je princip návrhu softwaru, který říká, že moduly vyšší úrovně by neměly záviset na modulech nižší úrovně, ale oboje by mělo záviset na abstrakcích. To znamená, že je obecně dobré navrhnout software tak, aby komponenty vyšší úrovně nebyly vázány na konkrétní implementace komponent nižší úrovně, ale spíše závisely na abstrakcích (jako jsou rozhraní nebo abstraktní třídy), které lze implementovat různými způsoby.
Podívejme se na příklad, jak lze tento princip aplikovat na třídy User a UserService použité v předchozích ukázkách 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 |
interface 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 příkladu třída UserService závisí na abstrakci zvané rozhraní UserRepository, namísto toho, aby závisela na konkrétní implementaci uživatelského repozitáře. To nám umožňuje implementovat rozhraní UserRepository různými způsoby, například pomocí databáze nebo paměťového úložiště, bez ovlivnění třídy UserService.
Zde je například implementace rozhraní UserRepository, které využívá databázi:
|
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 pro vytvoření uživatele v databázi } override fun delete(user: User) { // kód pro smazání uživatele z databáze } override fun update(user: User) { // kód pro aktualizaci uživatele v databázi } override fun get(id: Int): User { // kód pro získání uživatele z databáze } } |
Zde je další implementace rozhraní UserRepository, které využívá paměťové úložiště:
|
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] } } |
Díky tomu je systém flexibilnější a snáze udržovatelný, protože nám umožňuje změnit implementaci repozitáře bez ovlivnění zbytku systému. Usnadňuje to také testování třídy UserService, protože v našich testech můžeme závislost na UserRepository mockovat.
Závěr
V tomto článku jsme mluvili o pěti principech SOLID kód a sdílené ukázky kódu splňující jednotlivé principy. Dodržování těchto principů vám může pomoci navrhovat softwarové systémy, které jsou flexibilnější, udržovatelnější a škálovatelnější. Je však důležité mít na paměti, že tyto principy jsou spíše vodítky než pevnými pravidly, a je na vývojáři, aby se rozhodl, kdy a jak je v kontextu svého konkrétního projektu uplatní. Pokračujte v učení tím, že navštívíte náš blog, kde najdete podrobnější a aktuálnější články a tutoriály o cloud computingu a DevOps, návrhu a vývoji softwaru, technologických trendech, které se vyplatí sledovat, a dalších tématech.
Příjemné programování!
Komentáře
Zatím žádné komentáře. Buďte první.