Bevezetés
A SOLID egy mozaikszó, amely az objektumorientált tervezés öt alapelvét jelöli, amelyeket Robert C. Martin vezetett be, akit a köznyelvben csak úgy hívnak: Uncle Bob. Ezeknek az alapelveknek az a célja, hogy segítsék a szoftvertervezőket, architektusokat, mérnököket és fejlesztőket rugalmasabb, karbantarthatóbb és skálázhatóbb szoftverek létrehozásában. Ezen elvek követésével olyan osztályokat tervezhet, amelyeket könnyebb tesztelni, refaktorálni, újrafelhasználni és kibővíteni.
A SOLID mozaikszó a következőket jelenti:
S – Egyszeres felelősség elve
O – Nyílt-zárt elv
L – Liskov-helyettesítési elv
I – Interfész-szegregációs elv
D – Függőség-megfordítási elv
Ebben a cikkben mindegyik elvet egyenként magyarázzuk el, hogy megértsük, hogyan segíthetnek jobb kódok írásában. Emellett mindegyik elvhez kód részleteket is mellékelünk, hogy megmutassuk, hogyan alkalmazhatja őket a kódolás során, valamint azt is, hogy mit érdemes elkerülni a tiszta kód architektúrában. A fogalmak bemutatásához a Kotlin programozási nyelvet fogjuk használni, amelyet a JetBrains és nyílt forráskódú közreműködők fejlesztettek.
Egyszeres felelősség elve
Az egyszeres felelősség elve (SRP) egy szoftvertervezési elv, amely kimondja, hogy egy program minden osztályának vagy moduljának egyetlen, jól meghatározott felelősséggel kell rendelkeznie. Ez azt jelenti, hogy egy osztálynak csak egyetlen oka lehet a változásra, és a program működésének csak egyetlen részéért kell felelősnek lennie.
Tekintse meg a következő kód részletet példaként arra, hogyan alkalmazható ez az elv a Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
A data kulcsszó azt jelzi, hogy ez az osztály egy adatosztály, ami azt jelenti, hogy adatok tárolására szolgál, és nem rendelkezik összetett viselkedéssel. Ezt az adatosztályt használva létrehozhatunk egy UserService osztályt a felhasználók kezelésére, az alábbiak szerint:
|
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 a felhasználó adatbázisban történő létrehozásához } fun deleteUser(user: User) { // kód a felhasználó adatbázisból történő törléséhez } fun updateUser(user: User) { // kód a felhasználó adatbázisban történő frissítéséhez } fun getUser(id: Int): User { // kód a felhasználó adatbázisból történő lekéréséhez } } |
Ebben a példában a UserService osztálynak egyetlen felelőssége van: a felhasználók kezelése az adatbázisban. Minden felhasználót a data class User korábban megosztott kód képvisel. A UserService osztály összes metódusa kapcsolódik ehhez a felelősséghez, és ezért kohéziósak. Ez megkönnyíti az osztály megértését és karbantartását, mivel egyértelmű, hogy az osztály összes metódusa egyetlen, jól meghatározott feladathoz kapcsolódik.
Egy olyan metódus, amely sérti az egyszeres felelősség elvét (SRP) a UserService osztályban, az lenne, amely nem kapcsolódik az osztály elsődleges felelősségéhez, azaz a felhasználók adatbázisban történő kezeléséhez. Példaként tekintsük a UserService osztály következő változatá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 |
class UserService { fun createUser(user: User) { // kód a felhasználó létrehozásához az adatbázisban } fun deleteUser(user: User) { // kód a felhasználó törléséhez az adatbázisból } fun updateUser(user: User) { // kód a felhasználó frissítéséhez az adatbázisban } fun getUser(id: Int): User { // kód a felhasználó lekéréséhez az adatbázisból } fun sendEmail(user: User, subject: String, message: String) { // kód az e-mail küldéséhez a felhasználónak } } |
Ebben a példában a sendEmail metódus nem kapcsolódik az elsődleges felelősségéhez a UserService osztálynak, amely a felhasználók kezelése az adatbázisban. Ez a metódus az e-mailek küldéséért felelős, ami egy különálló feladatkör a felhasználók kezelésétől. Ennek eredményeként ez a metódus sérti az SRP-t, mivel egy második okot vezet be a UserService osztály módosítására.
Az egyszeres felelősség elvének való megfelelés érdekében jobb lenne az e-mail küldési funkciót egy külön osztályba választani, például egy EmailService osztályba. Ez lehetővé tenné, hogy a UserService osztály az elsődleges felelősségére, a felhasználók kezelésére összpontosítson, az EmailService osztály pedig az e-mailek küldésével kapcsolatos felelősségére.
Érdemes megjegyezni, hogy az egyszeres felelősség elve nem az osztály metódusainak számáról szól, hanem sokkal inkább a metódusok kohéziójáról és a felelősségi körök egyértelmű elkülönítéséről egy osztályon belül.
Nyitott-zárt elv (Open-Closed Principle)
A nyitott-zárt elv (Open-Closed Principle - OCP) egy olyan szoftvertervezési elv, amely kimondja, hogy a szoftverentitásoknak (például osztályoknak, moduloknak vagy függvényeknek) nyitottnak kell lenniük a kiterjesztésre, de zártnak a módosításra. Ez azt jelenti, hogy lehetővé kell tenni új funkciók hozzáadását egy osztályhoz vagy modulhoz a meglévő kód megváltoztatása nélkül.
Vegyük a UserService osztályt az előző példából. Tegyük fel, hogy egy új funkciót szeretnénk hozzáadni a UserService osztályhoz, amely lehetővé teszi a felhasználók e-mail-cím alapján történő keresését. Ennek egyik módja az lenne, ha egy új metódust adnánk a UserService osztályhoz, az alábbi kódban kiemeltek szerint:
|
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 a felhasználó létrehozásához az adatbázisban } fun deleteUser(user: User) { // kód a felhasználó törléséhez az adatbázisból } fun updateUser(user: User) { // kód a felhasználó frissítéséhez az adatbázisban } fun getUser(id: Int): User { // kód a felhasználó lekéréséhez az adatbázisból } fun searchUsersByEmail(email: String): List<User> { // kód a felhasználók e-mail-cím alapján történő kereséséhez } } |
Ez a megközelítés működik, de sérti a nyitott-zárt elvet, mivel módosítanunk kellett a meglévő UserService osztályt az új funkció hozzáadásához. Egy jobb megközelítés az lenne, ha öröklődést vagy kompozíciót használnánk a UserService osztály funkcionalitásának kiterjesztésére, a kódjának módosítása nélkül.
Ennek eléréséhez létrehozhatnánk egy új osztályt UserSearchService néven, amely kiterjeszti a UserService osztályt, és hozzáadja az e-mail alapú keresési funkciót:
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // kód a felhasználók e-mail-cím alapján történő kereséséhez } } |
Ebben a példában a UserSearchService osztály nyitott a kiterjesztésre, mivel további funkciókat biztosít azon túl, amit a UserService osztály. Ezzel egy időben a UserService osztály zárva marad a módosítások előtt, mivel nem kellett módosítanunk a kódját az e-mail alapú keresés funkció hozzáadásához.
Liskov-féle behelyettesítési elv
A Liskov-féle behelyettesítési elv (LSP) egy olyan szoftvertervezési elv, amely kimondja, hogy egy szülőosztály objektumainak helyettesíthetőnek kell lenniük egy alosztály objektumaival anélkül, hogy ez befolyásolná a program helyességét. Ez azt jelenti, hogy egy alosztálynak a szülőosztály érvényes helyettesítőjének kell lennie, és ugyanúgy kell viselkednie, mint a szülőosztálynak, ha ugyanabban a kontextusban használják.
A bemutatót a User és a UserService osztályokkal folytatjuk az előző példákból. Ahhoz, hogy a User osztály kiterjeszthető legyen, az open kulcsszót használjuk Kotlinban:
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Íme az eredeti UserService osztály, amely a fenti User data osztályt használja:
|
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 a felhasználó létrehozásához az adatbázisban } fun deleteUser(user: User) { // kód a felhasználó törléséhez az adatbázisból } fun updateUser(user: User) { // kód a felhasználó frissítéséhez az adatbázisban } fun getUser(id: Int): User { // kód a felhasználó lekéréséhez az adatbázisból } } |
Tegyük fel, hogy szeretnénk létrehozni a User egy alosztályát, amelynek neve AdminUser, és amely az adminisztrátori jogosultságokkal rendelkező felhasználókat képviseli. Ezt a következőképpen tehetjük meg:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
Ebben a példában az AdminUser osztály a User osztály érvényes helyettesítője, mivel ugyanúgy viselkedik, mint a User osztály, és bárhol használható, ahol User objektum elvárt. Például használhatjuk az AdminUser osztályt a UserService osztállyal a következőképpen:
|
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) } |
Ez a kód helyes, mivel az AdminUser osztály a User osztály érvényes helyettesítője, és ugyanúgy használható, mint egy User objektum.
Fontos megjegyezni, hogy a Liskov-féle behelyettesítési elv többről szól, mint csupán az öröklődésről. Arról szól, hogy biztosítsuk: egy alosztály objektumai ugyanúgy viselkedjenek, mint a szülőosztály objektumai, függetlenül attól, hogyan van megvalósítva az alosztály. Például, ha az AdminUser osztály valamilyen módon eltérően viselkedne, mint a User osztály, az sértené a Liskov-féle behelyettesítési elvet, mivel nem lenne a User osztály érvényes helyettesítője.
Interface Segregation Principle
Az interfész-szegregációs elv (ISP) egy olyan szoftvertervezési elv, amely kimondja, hogy a klienseket nem szabad olyan interfészektől való függésre kényszeríteni, amelyeket nem használnak. Ez azt jelenti, hogy általában jó ötlet kicsi, fókuszált interfészeket létrehozni, amelyek egy dolgot jól csinálnak, ahelyett, hogy nagy, általános célú interfészeket hoznánk létre, amelyek sok mindent próbálnak megtenni.
Íme egy példa arra, hogyan alkalmazható ez az elv az előző példákban szereplő User és UserService osztályok újraírásával:
|
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 a felhasználó létrehozásához az adatbázisban } override fun deleteUser(user: User) { // kód a felhasználó törléséhez az adatbázisból } override fun updateUser(user: User) { // kód a felhasználó frissítéséhez az adatbázisban } override fun getUser(id: Int): User { // kód a felhasználó lekéréséhez az adatbázisból } } |
Ebben a példában a(z) UserService interfész négy olyan metódust definiál, amelyek a felhasználók adatbázisban történő kezeléséhez kapcsolódnak. A(z) DatabaseUserService osztály megvalósítja ezt az interfészt, és konkrét implementációkat biztosít ezekhez a metódusokhoz.
Tegyük fel, hogy egy új funkciót szeretnénk hozzáadni a(z) UserService interfészhez, amely lehetővé teszi a felhasználók e-mail-cím alapján történő keresését. Ennek egyik módja az lenne, ha egy új metódust adnánk a(z) UserService interfészhez:
|
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> } |
A kódod nem fog futni, hacsak nem implementálod ezt a metódust az összes olyan osztályban is, amely megvalósítja a(z) UserService interfészt:
|
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 a felhasználó létrehozásához az adatbázisban } override fun deleteUser(user: User) { // kód a felhasználó törléséhez az adatbázisból } override fun updateUser(user: User) { // kód a felhasználó frissítéséhez az adatbázisban } override fun getUser(id: Int): User { // kód a felhasználó lekéréséhez az adatbázisból } override fun searchUsersByEmail(email: String): List<User> { // kód a felhasználók e-mail-cím alapján történő kereséséhez } } |
Bár ez a megközelítés működik, sérti az interfész-szegregáció elvét (Interface Segregation Principle), mivel arra kényszeríti a(z) DatabaseUserService osztályt, hogy megvalósítson egy olyan metódust ( searchUsersByEmail ), amelyre esetleg nincs szüksége, vagy amelyet nem használ.
Jobb megközelítés lenne egy külön interfész létrehozása az e-mail alapú keresési funkcióhoz:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Most már különálló, kicsi és fókuszált interfészeink vannak, azaz a UserService és a UserSearchServiceamelyek egyetlen felelősséggel bírnak. Az ezen interfészek összes funkcióját igénylő osztály implementálhatja őket az alábbi kódrészletben látható módon:
|
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 egy felhasználó létrehozásához az adatbázisban } override fun deleteUser(user: User) { // kód egy felhasználó törléséhez az adatbázisból } override fun updateUser(user: User) { // kód egy felhasználó frissítéséhez az adatbázisban } override fun getUser(id: Int): User { // kód egy felhasználó lekéréséhez az adatbázisból } override fun searchUsersByEmail(email: String): List<User> { // kód a felhasználók e-mail-cím alapján történő kereséséhez } } |
Ez megfelel az interfész-elkülönítési elvnek, mivel biztosítja, hogy a kliensek (például a DatabaseUserService osztály) ne kényszerüljenek olyan interfészektől függeni, amelyeket nem használnak.
A koncepció jobb megértéséhez tegyük fel, hogy van egy másik osztályunk, amelynek neve MemoryUserService amely implementálja a UserService interfészt, de nincs szüksége az e-mail alapú keresési funkcióra. A kódot így írhatjuk meg:
|
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] } } |
Ebben a példában a MemoryUserService osztálynak csak a UserService interfészben definiált metódusokat kell implementálnia, és nem kell törődnie az e-mail alapú keresési funkcióval. Ez lehetővé teszi a MemoryUserService osztály számára, hogy a memóriában lévő felhasználók kezelésének elsődleges feladatára összpontosítson, ahelyett, hogy nem kapcsolódó funkciók implementálására kényszerülne.
Függőség megfordításának elve
A függőség megfordításának elve (Dependency Inversion Principle - DIP) egy olyan szoftvertervezési elv, amely kimondja, hogy a magas szintű modulok nem függhetnek az alacsony szintű moduloktól, hanem mindkettőnek absztrakcióktól kell függnie. Ez azt jelenti, hogy általában jó ötlet úgy tervezni a szoftvert, hogy a magas szintű komponensek ne kötődjenek az alacsony szintű komponensek konkrét implementációihoz, hanem inkább olyan absztrakcióktól (például interfészektől vagy absztrakt osztályoktól) függjenek, amelyek különböző módon implementálhatók.
Nézzünk meg egy példát arra, hogyan alkalmazható ez az elv a User és UserService osztályokra a korábbi kódrészletekből:
|
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) } } |
Ebben a példában a UserService osztály egy absztrakciótól függ, amelyet UserRepository interfésznek nevezünk, ahelyett, hogy a felhasználói adattár egy konkrét implementációjától függene. Ez lehetővé teszi számunkva, hogy a UserRepository interfészt különböző módon implementáljuk, például adatbázissal vagy memóriában történő tárolással, anélkül, hogy ez befolyásolná a UserService osztályt.
Például itt látható a UserRepository interfész egy olyan implementációja, amely adatbázist használ:
|
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 a felhasználó létrehozásához az adatbázisban } override fun delete(user: User) { // kód a felhasználó törléséhez az adatbázisból } override fun update(user: User) { // kód a felhasználó frissítéséhez az adatbázisban } override fun get(id: Int): User { // kód a felhasználó lekéréséhez az adatbázisból } } |
Íme a UserRepository interfész egy másik implementációja, amely memóriában történő tárolást használ:
|
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] } } |
Ez rugalmasabbá és karbantarthatóbbá teszi a rendszert, mivel lehetővé teszi az adattár implementációjának módosítását anélkül, hogy az befolyásolná a rendszer többi részét. Emellett megkönnyíti a UserService osztály tesztelését is, mivel a tesztjeinkben mockolhatjuk a UserRepository függőséget.
Összegzés
Ebben a cikkben a SOLID öt alapelvéről beszéltünk. kódokat és megosztott kódrészleteket, amelyek megfelelnek az egyes elveknek. Ezen elvek betartása segíthet olyan szoftverrendszerek tervezésében, amelyek rugalmasabbak, karbantarthatóbbak és skálázhatóbbak. Azonban fontos észben tartani, hogy ezek az elvek inkább irányelvek, mintsem kőbe vésett szabályok, és a fejlesztőn múlik, hogy eldöntse, mikor és hogyan alkalmazza őket a konkrét projektje kontextusában. Folytassa a tanulást, és látogasson el blogunkra további részletes és naprakész cikkekért és oktatóanyagokért a felhőalapú számítástechnika és a DevOps, a szoftvertervezés és -fejlesztés, a legújabb technológiai trendek és sok más egyéb terén.
Kellemes kódolást!
Hozzászólások
Még nincsenek hozzászólások. Legyen Ön az első.