Globális állapot és singletonok
Figyelmeztetés: A következő konstrukciók a rosszul megtervezett kód tünetei:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
vagystatic::$var
Találkozik ilyen konstrukciókkal a kódjában? Ha igen, akkor lehetősége van javítani rajta. Azt gondolhatod, hogy ezek gyakori konstrukciók, amelyeket gyakran láthatsz különböző könyvtárak és keretrendszerek mintamegoldásaiban. Ha ez a helyzet, akkor a kódtervezésük hibás.
Itt most nem valami akadémiai tisztaságról beszélünk. Mindezekben a konstrukciókban egy dolog közös: globális állapotot használnak. Ez pedig romboló hatással van a kód minőségére. Az osztályok megtévesztőek a függőségeiket illetően. A kód kiszámíthatatlanná válik. Ez összezavarja a fejlesztőket és csökkenti a hatékonyságukat.
Ebben a fejezetben elmagyarázzuk, miért van ez így, és hogyan kerülhetjük el a globális állapotot.
Globális összekapcsolás
Egy ideális világban egy objektum csak olyan objektumokkal kommunikálhat, amelyeket közvetlenül átadtak neki. Ha létrehozok két
objektumot A
és B
, és soha nem adok át közöttük hivatkozást, akkor sem a A
, sem a
B
nem férhet hozzá a másik állapotához, illetve nem módosíthatja azt. Ez egy nagyon kívánatos tulajdonsága
a kódnak. Olyan, mintha lenne egy elem és egy izzó; az izzó nem fog világítani, amíg nem csatlakoztatjuk az elemhez egy
vezetékkel.
Ez azonban nem igaz a globális (statikus) változókra vagy a szingletonokra. A A
objektum vezeték
nélkül hozzáférhet a C
objektumhoz, és módosíthatja azt mindenféle referenciaátadás nélkül, a
C::changeSomething()
meghívásával. Ha a B
objektum a globális C
objektumot is
megcsapolja, akkor a A
és a B
objektumok a C
objektumon keresztül befolyásolhatják
egymást.
A globális változók használata a vezeték nélküli csatolás egy új, kívülről nem látható formáját
vezeti be. Ez egy füstfüggönyt hoz létre, amely megnehezíti a kód megértését és használatát. A függőségek valódi
megértéséhez a fejlesztőknek a forráskód minden sorát el kell olvasniuk, ahelyett, hogy csak az osztályok interfészeivel
ismerkednének. Ráadásul ez az összefonódás teljesen felesleges. A globális állapotot azért használjuk, mert bárhonnan
könnyen elérhető, és lehetővé teszi például az adatbázisba való írást egy globális (statikus) metóduson keresztül
DB::insert()
. Azonban, mint látni fogjuk, az általa nyújtott előny minimális, míg a bevezetett bonyodalmak
súlyosak.
A viselkedés szempontjából nincs különbség egy globális és egy statikus változó között. Egyformán károsak.
A kísérteties cselekvés távolról
“Kísérteties hatás a távolban” – így nevezte Albert Einstein 1935-ben a kvantumfizika egyik jelenségét, amelytől kirázta a hideg. Ez a kvantum összefonódás, amelynek sajátossága, hogy amikor egy részecskéről információt mérünk, azonnal hatással vagyunk egy másik részecskére, még akkor is, ha azok több millió fényévre vannak egymástól. ami látszólag sérti a világegyetem alapvető törvényét, miszerint semmi sem haladhat gyorsabban a fénynél.
A szoftverek világában “spooky action at a distance”-nek nevezhetjük azt a helyzetet, amikor lefuttatunk egy folyamatot, amelyről azt gondoljuk, hogy elszigetelt (mert nem adtunk át neki semmilyen hivatkozást), de váratlan kölcsönhatások és állapotváltozások történnek a rendszer távoli pontjain, amelyekről nem szóltunk az objektumnak. Ez csak a globális állapoton keresztül történhet.
Képzeljük el, hogy csatlakozunk egy olyan projektfejlesztő csapathoz, amely nagy, kiforrott kódbázissal rendelkezik. Az új vezetőd megkér egy új funkció megvalósítására, és jó fejlesztőhöz méltóan egy teszt megírásával kezded. De mivel új vagy a projektben, sok feltáró “mi történik, ha meghívom ezt a metódust” típusú tesztet csinálsz. És megpróbálod megírni a következő tesztet:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // az Ön kártyaszámát
$cc->charge(100);
}
Egy idő után észreveszed, hogy a bank értesítést küld a telefonodon, hogy minden egyes futtatáskor 100 dollárral terhelték meg a hitelkártyádat. 🤦♂️
Hogy a fenébe okozhatott a teszt tényleges terhelést? Nem könnyű a hitelkártyával operálni. Egy harmadik fél webes szolgáltatásával kell kapcsolatba lépnie, ismernie kell a webes szolgáltatás URL-jét, be kell jelentkeznie, és így tovább. Ezek közül az információk közül egyik sem szerepel a tesztben. Még rosszabb, hogy azt sem tudod, hol vannak ezek az információk, és ezért nem tudod, hogyan kell a külső függőségeket leutánozni, hogy minden egyes futtatásnál ne kelljen újra 100 dollárt fizetni. És új fejlesztőként honnan kellett volna tudnod, hogy amit most fogsz csinálni, az 100 dollárral szegényebbé tesz téged?
Ez egy kísérteties akció a távolból!
Nincs más választásod, mint rengeteg forráskódban turkálni, megkérdezni idősebb és tapasztaltabb kollégákat, amíg
meg nem érted, hogyan működnek az összefüggések a projektben. Ennek oka, hogy a CreditCard
osztály
interfészét megnézve nem tudod meghatározni az inicializálandó globális állapotot. Még az osztály forráskódjának
megnézése sem árulja el, hogy melyik inicializálási metódust kell meghívni. A legjobb esetben megkereshetjük a globális
változót, amelyhez hozzáférünk, és ebből próbálhatjuk kitalálni, hogyan kell inicializálni.
Egy ilyen projektben az osztályok beteges hazudozók. A fizetési kártya úgy tesz, mintha egyszerűen csak instanciáznád,
és meghívnád a charge()
metódust. Titokban azonban kölcsönhatásba lép egy másik osztállyal, a
PaymentGateway
. Még az interfésze is azt mondja, hogy önállóan inicializálható, de a valóságban valamilyen
konfigurációs fájlból húzza a hitelesítő adatokat, és így tovább. A kódot író fejlesztők számára egyértelmű,
hogy a CreditCard
-nak szüksége van a PaymentGateway
. Így írták meg a kódot. De bárki számára,
aki új a projektben, ez teljes rejtély, és akadályozza a tanulást.
Hogyan lehet kijavítani a helyzetet? Egyszerűen. Hagyjuk, hogy az API deklarálja a függőségeket.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Figyeljük meg, hogy a kódon belüli kapcsolatok hirtelen nyilvánvalóvá válnak. Azzal, hogy deklaráljuk, hogy a
charge()
metódusnak szüksége van a PaymentGateway
címre, senkitől sem kell megkérdeznünk, hogy a
kód hogyan függ egymástól. Tudod, hogy egy példányt kell létrehoznod belőle, és amikor megpróbálod ezt megtenni,
belefutsz abba, hogy hozzáférési paramétereket kell megadnod. Ezek nélkül a kód nem is futna.
És ami a legfontosabb, most már le tudja mockolni a fizetési átjárót, hogy ne kelljen 100 dollárt fizetnie minden egyes teszt futtatásakor.
A globális állapot miatt az objektumaid titokban hozzáférhetnek olyan dolgokhoz, amelyek nincsenek deklarálva az API-jukban, és ennek eredményeképpen az API-id kóros hazudozókká válnak.
Lehet, hogy eddig nem gondoltál rá így, de valahányszor globális állapotot használsz, titkos vezeték nélküli kommunikációs csatornákat hozol létre. A hátborzongató távoli működés arra kényszeríti a fejlesztőket, hogy minden egyes kódsort elolvassanak a lehetséges interakciók megértéséhez, csökkenti a fejlesztők termelékenységét, és összezavarja az új csapattagokat. Ha te vagy az, aki a kódot létrehozta, akkor ismered a valódi függőségeket, de bárki, aki utánad jön, tanácstalan.
Ne írjon olyan kódot, amely globális állapotot használ, inkább adja át a függőségeket. Vagyis a függőségi injektálás.
A globális állam törékenysége
A globális állapotot és singletonokat használó kódban sosem lehetünk biztosak abban, hogy az állapotot mikor és ki változtatta meg. Ez a kockázat már az inicializáláskor fennáll. A következő kódnak egy adatbázis-kapcsolatot kellene létrehoznia és inicializálnia a fizetési átjárót, de folyamatosan kivételt dob, és az okának megtalálása rendkívül fárasztó:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Részletesen át kell nézni a kódot, hogy kiderüljön, hogy a PaymentGateway
objektum vezeték nélkül más
objektumokhoz is hozzáfér, amelyek közül néhányhoz adatbázis-kapcsolat szükséges. Így a PaymentGateway
előtt inicializálni kell az adatbázist. A globális állapot füstfüggönye azonban ezt elrejti Ön elől. Mennyi időt
spórolna meg, ha az egyes osztályok API-ja nem hazudna és nem jelentené be függőségeit?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);
Hasonló probléma merül fel, amikor globális hozzáférést használunk egy adatbázis-kapcsolathoz:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
A save()
metódus meghívásakor nem biztos, hogy az adatbázis-kapcsolat már létrejött-e, és ki a felelős a
létrehozásáért. Ha például menet közben szeretnénk megváltoztatni az adatbázis-kapcsolatot, esetleg tesztelési céllal,
akkor valószínűleg további metódusokat kellene létrehoznunk, például a DB::reconnect(...)
vagy a
DB::reconnectForTest()
metódusokat.
Vegyünk egy példát:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Hol lehetünk biztosak abban, hogy a $article->save()
meghívásakor valóban a tesztadatbázist használjuk ?
Mi van, ha a Foo::doSomething()
módszer megváltoztatta a globális adatbázis-kapcsolatot? Ahhoz, hogy ezt
megtudjuk, meg kellene vizsgálnunk a Foo
osztály és valószínűleg sok más osztály forráskódját. Ez a
megközelítés azonban csak rövid távú választ adna, mivel a jövőben változhat a helyzet.
Mi lenne, ha az adatbázis-kapcsolatot egy statikus változóba helyeznénk át a Article
osztályon belül?
class Article
{
private static DB $db;
public static function setDb(DB $db): void
{
self::$db = $db;
}
public function save(): void
{
self::$db->insert(/* ... */);
}
}
Ez egyáltalán nem változtat semmit. A probléma egy globális állapot, és nem számít, hogy melyik osztályban
rejtőzik. Ebben az esetben, ahogy az előző esetben is, fogalmunk sincs arról, hogy a $article->save()
metódus
meghívásakor milyen adatbázisba íródik. Bárki az alkalmazás távoli végén bármikor megváltoztathatja az adatbázist a
Article::setDb()
segítségével. A mi kezünk alatt.
A globális állapot miatt az alkalmazásunk rendkívül törékennyé válik.
Van azonban egy egyszerű módja ennek a problémának a kezelésére. Csak az API-nak kell deklarálnia a függőségeket a megfelelő funkcionalitás biztosítása érdekében.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Ez a megközelítés kiküszöböli az adatbázis-kapcsolatok rejtett és váratlan változásai miatti aggodalmat. Most már biztosak vagyunk abban, hogy a cikket hol tároljuk, és semmilyen kódmódosítás egy másik, nem kapcsolódó osztályon belül nem változtathatja meg többé a helyzetet. A kód többé nem törékeny, hanem stabil.
Ne írjunk olyan kódot, amely globális állapotot használ, inkább adjuk át a függőségeket. Így a függőségi injektálás.
Singleton
A singleton egy olyan tervezési minta, amely a híres Gang of Four kiadvány definíciója szerint egy osztályt egyetlen példányra korlátoz, és globális hozzáférést biztosít hozzá. Ennek a mintának a megvalósítása általában a következő kódhoz hasonlít:
class Singleton
{
private static self $instance;
public static function getInstance(): self
{
self::$instance ??= new self;
return self::$instance;
}
// és más metódusok, amelyek az osztály funkcióit hajtják végre.
}
Sajnos a singleton globális állapotot vezet be az alkalmazásba. És mint fentebb megmutattuk, a globális állapot nem kívánatos. Ezért tekinthető a singleton antipatternnek.
Ne használjon singletont a kódjában, és helyettesítse más mechanizmusokkal. Tényleg nincs szükséged szingletonokra. Ha
azonban garantálnod kell egy osztály egyetlen példányának létezését az egész alkalmazás számára, akkor hagyd ezt a DI konténerre. Így hozzon létre egy alkalmazás szingletont,
vagy szolgáltatást. Ezáltal az osztály nem fogja biztosítani a saját egyediségét (azaz nem lesz getInstance()
metódusa és statikus változója), és csak a funkcióit fogja végrehajtani. Így megszűnik az egyetlen felelősség elvének
megsértése.
Globális állapot a tesztek ellenében
A tesztek írása során feltételezzük, hogy minden teszt egy izolált egység, és nem kerül bele külső állapot. És semmilyen állapot nem hagyja el a teszteket. Amikor egy teszt befejeződik, a teszthez kapcsolódó állapotot a szemétgyűjtőnek automatikusan el kell távolítania. Ez teszi a teszteket izolálttá. Ezért a teszteket tetszőleges sorrendben futtathatjuk.
Ha azonban globális állapotok/singletonok vannak jelen, akkor mindezek a szép feltételezések összeomlanak. Egy állapot beléphet és kiléphet egy tesztből. Hirtelen a tesztek sorrendje számíthat.
Ahhoz, hogy a szingletonokat egyáltalán tesztelni lehessen, a fejlesztőknek gyakran lazítaniuk kell a tulajdonságaikon,
például úgy, hogy megengedik, hogy egy példányt egy másikra cseréljenek. Az ilyen megoldások a legjobb esetben is hackek,
amelyek nehezen karbantartható és nehezen érthető kódot eredményeznek. Minden olyan tesztnek vagy metódusnak
tearDown()
, amely bármilyen globális állapotot érint, vissza kell vonnia ezeket a változásokat.
A globális állapot a legnagyobb fejfájás az egységtesztelésben!
Hogyan lehet megoldani a helyzetet? Egyszerűen. Ne írj olyan kódot, amely singletonokat használ, inkább add át a függőségeket. Vagyis függőségi injektálással.
Globális konstansok
A globális állapot nem korlátozódik a szingletonok és statikus változók használatára, hanem a globális konstansokra is vonatkozhat.
Azok a konstansok, amelyek értéke nem szolgáltat számunkra új (M_PI
) vagy hasznos
(PREG_BACKTRACK_LIMIT_ERROR
) információt, egyértelműen rendben vannak. Ezzel szemben azok a konstansok, amelyek
arra szolgálnak, hogy vezeték nélkül információt adjunk át a kódon belül, nem többek, mint rejtett függőség.
Mint a LOG_FILE
a következő példában. A FILE_APPEND
konstans használata teljesen helyes.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
Ebben az esetben a paramétert a Foo
osztály konstruktorában kell deklarálnunk, hogy az API részévé
váljon:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Most már átadhatjuk a naplófájl elérési útvonalára vonatkozó információt, és szükség esetén könnyen módosíthatjuk azt, így könnyebben tesztelhetjük és karbantarthatjuk a kódot.
Globális függvények és statikus metódusok
Szeretnénk hangsúlyozni, hogy a statikus metódusok és globális függvények használata önmagában nem problémás. A
DB::insert()
és hasonló módszerek használatának helytelenségét már elmagyaráztuk, de mindig is a statikus
változóban tárolt globális állapotról volt szó. A DB::insert()
metódus megköveteli egy statikus változó
meglétét, mivel az adatbázis-kapcsolatot tárolja. E változó nélkül lehetetlen lenne a módszer végrehajtása.
A determinisztikus statikus módszerek és függvények, mint például a DateTime::createFromFormat()
,
Closure::fromCallable
, strlen()
és sok más, használata tökéletesen összhangban van a függőségi
injektálással. Ezek a függvények mindig ugyanazokat az eredményeket adják vissza ugyanazokból a bemeneti paraméterekből,
ezért kiszámíthatóak. Nem használnak semmilyen globális állapotot.
Vannak azonban a PHP-ben olyan függvények, amelyek nem determinisztikusak. Ezek közé tartozik például a
htmlspecialchars()
függvény. Harmadik paramétere, a $encoding
, ha nincs megadva, alapértelmezés
szerint a ini_get('default_charset')
konfigurációs opció értékét veszi fel. Ezért ajánlott mindig megadni ezt
a paramétert, hogy elkerüljük a függvény esetleges kiszámíthatatlan viselkedését. A Nette következetesen
ezt teszi.
Egyes függvények, mint például a strtolower()
, strtoupper()
és hasonlók, a közelmúltban nem
determinisztikus viselkedést mutattak, és a setlocale()
beállításától függtek. Ez sok bonyodalmat okozott,
leggyakrabban a török nyelvvel való munka során. Ennek oka, hogy a török nyelv különbséget tesz a kis- és nagybetűs
I
között ponttal és pont nélkül. Így a strtolower('I')
a ı
karaktert, a
strtoupper('i')
pedig a İ
karaktert adta vissza , ami az alkalmazásokban számos rejtélyes hibát
okozott. Ezt a problémát azonban a PHP 8.2-es verziójában kijavították, és a függvények többé nem függnek a
nyelvjárástól.
Ez egy szép példa arra, hogy a globális állapot fejlesztők ezreit sújtotta világszerte. A megoldás az volt, hogy függőségi injektálással helyettesítették.
Mikor lehetséges a globális állapot használata?
Vannak bizonyos speciális helyzetek, amikor lehetséges a globális állapot használata. Például kód hibakereséskor, amikor ki kell dobni egy változó értékét, vagy meg kell mérni a program egy adott részének időtartamát. Ilyen esetekben, amelyek olyan ideiglenes műveletekre vonatkoznak, amelyeket később eltávolítunk a kódból, jogos egy globálisan elérhető dumper vagy stopperóra használata. Ezek az eszközök nem részei a kódtervezésnek.
Egy másik példa a preg_*
, a reguláris kifejezésekkel való munkavégzésre szolgáló függvények, amelyek a
lefordított reguláris kifejezéseket belsőleg egy statikus gyorsítótárban tárolják a memóriában. Ha ugyanazt a
reguláris kifejezést a kód különböző részeiben többször hívja meg, akkor csak egyszer fordítja le. A gyorsítótár
teljesítményt takarít meg, és a felhasználó számára is teljesen láthatatlan, így az ilyen használat jogosnak
tekinthető.
Összefoglaló
Megmutattuk, miért van értelme
- Távolítsunk el minden statikus változót a kódból.
- Deklaráljuk a függőségeket
- És használjuk a függőségi injektálást
Amikor a kódtervezésről gondolkodik, tartsa szem előtt, hogy minden egyes static $foo
egy problémát jelent.
Ahhoz, hogy a kódod DI-tisztelő környezet legyen, elengedhetetlen a globális állapot teljes kiirtása és függőségi
injektálással való helyettesítése.
E folyamat során előfordulhat, hogy egy osztályt fel kell osztanod, mert egynél több felelőssége van. Ne aggódjon emiatt; törekedjen az egy felelősség elvére.
Köszönöm Miško Hevery-nek, akinek olyan cikkei, mint a Flaw: Brittle Global State & Singletons (Hiba: Törékeny globális állapot és szingletonok ) képezik e fejezet alapját.