Globális állapot és singletonok
Figyelmeztetés: A következő konstrukciók rosszul megtervezett kód jelei:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
vagystatic::$var
Előfordulnak ezek a konstrukciók a kódjában? Akkor itt a lehetőség a javításra. Talán azt gondolja, hogy ezek általános konstrukciók, amelyeket akár különböző könyvtárak és keretrendszerek példamegoldásaiban is lát. Ha ez így van, akkor a kódjuk tervezése nem jó.
Most biztosan nem valamilyen akadémiai tisztaságról beszélünk. Minden ilyen konstrukciónak egy közös vonása van: globális állapotot használnak. És ennek romboló hatása van a kód minőségére. Az osztályok hazudnak a függőségeikről. A kód kiszámíthatatlanná válik. Megzavarja a programozókat és csökkenti hatékonyságukat.
Ebben a fejezetben elmagyarázzuk, miért van ez így, és hogyan kerüljük el a globális állapotot.
Globális összekapcsolás
Egy ideális világban egy objektumnak csak azokkal az objektumokkal kellene tudnia kommunikálni, amelyeket közvetlenül átadva kapott. Ha létrehozok két
A
és B
objektumot, és soha nem adok át referenciát közöttük, akkor sem A
, sem
B
nem férhet hozzá a másik objektumhoz, vagy nem változtathatja meg annak állapotát. Ez a kód egy nagyon
kívánatos tulajdonsága. Hasonló ahhoz, mint amikor van egy elem és egy izzó; az izzó nem fog világítani, amíg nem köti
össze az elemmel egy dróttal.
Ez azonban nem igaz a globális (statikus) változókra vagy singletonokra. Az A
objektum vezeték
nélkül hozzáférhetne a C
objektumhoz, és módosíthatná azt anélkül, hogy bármilyen referenciát
átadna, azáltal, hogy meghívja a C::changeSomething()
-t. Ha a B
objektum is megragadja a globális
C
-t, akkor A
és B
kölcsönösen befolyásolhatják egymást a C
-n
keresztül.
A globális változók használata a vezeték nélküli összekapcsolás új formáját vezeti be a rendszerbe, amely
kívülről nem látható. Füstfüggönyt hoz létre, amely bonyolítja a kód megértését és használatát. Ahhoz, hogy a
fejlesztők valóban megértsék a függőségeket, el kell olvasniuk a forráskód minden sorát. Ahelyett, hogy egyszerűen
megismerkednének az osztályok interfészével. Ráadásul ez egy teljesen felesleges összekapcsolás. A globális állapotot
azért használják, mert könnyen hozzáférhető bárhonnan, és lehetővé teszi például az adatbázisba írást a globális
(statikus) DB::insert()
metóduson keresztül. De ahogy megmutatjuk, az ebből származó előny elenyésző, míg a
okozott komplikációk végzetesek.
Viselkedés szempontjából nincs különbség a globális és a statikus változó között. Ugyanolyan károsak.
Kísérteties távolhatás
“Kísérteties távolhatás” – így nevezte el híresen 1935-ben Albert Einstein a kvantumfizika egy jelenségét, amelytől libabőrös lett. Ez egy kvantum-összefonódás, amelynek különlegessége, hogy ha megmérjük az információt az egyik részecskéről, azonnal befolyásoljuk a másik részecskét is, még akkor is, ha millió fényév távolságra vannak egymástól. Ami látszólag megsérti az univerzum alapvető törvényét, hogy semmi sem terjedhet gyorsabban a fénynél.
A szoftver világában “kísérteties távolhatásnak” nevezhetjük azt a helyzetet, amikor elindítunk egy folyamatot, amelyről azt gondoljuk, hogy izolált (mert nem adtunk át neki semmilyen referenciát), de a rendszer távoli pontjain váratlan interakciók és állapotváltozások következnek be, amelyekről nem volt tudomásunk. Ez csak globális állapoton keresztül történhet meg.
Képzelje el, hogy csatlakozik egy projekt fejlesztői csapatához, amelynek kiterjedt, kiforrott kódbázisa van. Az új vezetője megkéri Önt egy új funkció implementálására, és Ön, mint jó fejlesztő, a teszt írásával kezdi. Mivel azonban új a projektben, sok feltáró tesztet végez, mint például “mi történik, ha meghívom ezt a metódust”. És megpróbálja megírni a következő tesztet:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // az Ön kártyaszáma
$cc->charge(100);
}
Futtatja a kódot, talán többször is, és egy idő után észreveszi a mobilján a banki értesítéseket, hogy minden futtatáskor 100 dollárt vontak le a bankkártyájáról 🤦♂️
Hogy a fenébe okozhatta a teszt a valódi pénzlevonást? A bankkártyával való művelet nem egyszerű. Kommunikálnia kell egy harmadik fél webszolgáltatásával, ismernie kell ennek a webszolgáltatásnak az 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. Sőt, még azt sem tudja, hol vannak ezek az információk, és így azt sem, hogyan mockolja az externális függőségeket, hogy minden futtatás ne vezessen újabb 100 dollár levonásához. És honnan kellett volna tudnia új fejlesztőként, hogy amit tenni készül, az 100 dollárral szegényebbé teszi?
Ez a kísérteties távolhatás!
Nem marad más hátra, mint hosszan turkálni a rengeteg forráskódban, kérdezgetni az idősebb és tapasztaltabb
kollégákat, amíg meg nem érti, hogyan működnek a kapcsolatok a projektben. Ez azért van, mert a CreditCard
osztály interfészének megtekintésekor nem lehet megállapítani a globális állapotot, amelyet inicializálni kell. Még az
osztály forráskódjának megtekintése sem árulja el, melyik inicializációs metódust kell meghívnia. Legjobb esetben
találhat egy globális változót, amelyhez hozzáférnek, és abból megpróbálhatja kitalálni, hogyan inicializálja.
Az ilyen projekt osztályai patologikus hazudozók. A bankkártya úgy tesz, mintha elég lenne példányosítani és
meghívni a charge()
metódust. Titokban azonban együttműködik egy másik PaymentGateway
osztállyal,
amely a fizetési kaput képviseli. Annak interfésze is azt mondja, hogy önállóan inicializálható, de valójában kihúzza a
hitelesítő adatokat valamilyen konfigurációs fájlból és így tovább. A fejlesztőknek, akik ezt a kódot írták,
világos, hogy a CreditCard
-nak szüksége van a PaymentGateway
-re. Így írták a kódot. De bárki
számára, aki új a projektben, ez teljes rejtély, és akadályozza a tanulást.
Hogyan javítsuk a helyzetet? Könnyen. Hagyja, 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);
}
Figyelje meg, hogyan válnak hirtelen nyilvánvalóvá a kódon belüli kapcsolatok. Azzal, hogy a charge()
metódus deklarálja, hogy szüksége van a PaymentGateway
-re, nem kell senkitől megkérdeznie, hogyan van
összekapcsolva a kód. Tudja, hogy létre kell hoznia annak példányát, és amikor megpróbálja, rájön, hogy meg kell adnia
a hozzáférési paramétereket. Nélkülük a kód el sem indulna.
És ami a legfontosabb, most már mockolhatja a fizetési kaput, így nem vonnak le 100 dollárt minden tesztfuttatáskor.
A globális állapot miatt az objektumai titokban hozzáférhetnek olyan dolgokhoz, amelyek nincsenek deklarálva az API-jukban, és ennek következtében az API-jai patologikus hazudozókká válnak.
Talán korábban nem gondolt rá így, de minden alkalommal, amikor globális állapotot használ, titkos vezeték nélküli kommunikációs csatornákat hoz létre. A kísérteties távolhatás arra kényszeríti a fejlesztőket, hogy minden kódsort elolvassanak a potenciális interakciók megértéséhez, csökkenti a fejlesztők termelékenységét és megzavarja az új csapattagokat. Ha Ön hozta létre a kódot, ismeri a valódi függőségeket, de bárki, aki Ön után jön, tanácstalan.
Ne írjon olyan kódot, amely globális állapotot használ, részesítse előnyben a függőségek átadását. Tehát a dependency injection-t.
Globális állapot törékenysége
A globális állapotot és singletonokat használó kódban soha nem biztos, hogy mikor és ki változtatta meg ezt az állapotot. Ez a kockázat már az inicializáláskor megjelenik. A következő kódnak adatbázis-kapcsolatot kellene létrehoznia és inicializálnia a fizetési kaput, azonban folyamatosan kivételt dob, és az ok keresése rendkívül hosszadalmas:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Részletesen át kell néznie a kódot, hogy rájöjjön, a PaymentGateway
objektum vezeték nélkül hozzáfér
más objektumokhoz, amelyek közül néhány adatbázis-kapcsolatot igényel. Tehát az adatbázist korábban kell inicializálni,
mint a PaymentGateway
-t. Azonban a globális állapot füstfüggönye ezt elrejti Ön elől. Mennyi időt
takaríthatna meg, ha az egyes osztályok API-ja nem hazudna, és deklarálná a függőségeit?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);
Hasonló probléma merül fel az adatbázis-kapcsolat globális elérésének használatakor is:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
A save()
metódus hívásakor nem biztos, hogy már létrejött-e az adatbázis-kapcsolat, és ki felelős annak
létrehozásáért. Ha például futás közben szeretnénk megváltoztatni az adatbázis-kapcsolatot, például tesztek miatt,
valószínűleg további metódusokat kellene létrehoznunk, mint például DB::reconnect(...)
vagy
DB::reconnectForTest()
.
Vegyünk egy példát:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Hol van a biztosíték arra, hogy a $article->save()
hívásakor valóban a tesztadatbázist használjuk? Mi
van, ha a Foo::doSomething()
metódus megváltoztatta a globális adatbázis-kapcsolatot? Ennek kiderítéséhez meg
kellene vizsgálnunk a Foo
osztály forráskódját, és valószínűleg sok más osztályét is. Ez a
megközelítés azonban csak rövid távú választ adna, mivel a helyzet a jövőben megváltozhat.
És mi van, ha az adatbázis-kapcsolatot egy statikus változóba helyezzük az 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(/* ... */);
}
}
Ezzel egyáltalán semmi sem változott. A probléma a globális állapot, és teljesen mindegy, melyik osztályban rejtőzik.
Ebben az esetben, akárcsak az előzőben, a $article->save()
metódus hívásakor nincs semmilyen támpontunk
arra vonatkozóan, hogy melyik adatbázisba íródik. Bárki az alkalmazás másik végén bármikor megváltoztathatta az
adatbázist az Article::setDb()
segítségével. A kezünk alatt.
A globális állapot rendkívül törékennyé teszi az alkalmazásunkat.
Van azonban egy egyszerű módja ennek a problémának a kezelésére. Csak hagyni kell, hogy az API deklarálja a függőségeket, ami biztosítja a helyes működést.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Ennek a megközelítésnek köszönhetően megszűnik az aggodalom a rejtett és váratlan adatbázis-kapcsolat változások miatt. Most már biztosak lehetünk benne, hova mentődik a cikk, és semmilyen kódmódosítás egy másik, nem kapcsolódó osztályon belül már nem változtathat a helyzeten. A kód már nem törékeny, hanem stabil.
Ne írjon olyan kódot, amely globális állapotot használ, részesítse előnyben a függőségek átadását. Tehát a dependency injection-t.
Singleton
A Singleton egy tervezési minta, amely a híres Gang of Four kiadvány Singleton_pattern szerint egy osztályt egyetlen példányra korlátoz, és globális hozzáférést kínál hozzá. Ennek a mintának az implementációja á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 további metódusok, amelyek az adott osztály funkcióit töltik be
}
Sajnos a singleton globális állapotot vezet be az alkalmazásba. És ahogy fentebb megmutattuk, a globális állapot nemkívánatos. Ezért a singletont antipattern-nek tekintik.
Ne használjon singletonokat a kódjában, és helyettesítse őket más mechanizmusokkal. Valóban nincs szüksége
singletonokra. Ha azonban garantálnia kell egy osztály egyetlen példányának létezését az egész alkalmazás számára,
bízza azt a DI konténerre. Hozzon létre így egy
alkalmazás szintű singletont, azaz egy szolgáltatást. Ezzel az osztály megszűnik foglalkozni saját egyediségének
biztosításával (azaz nem lesz getInstance()
metódusa és statikus változója), és csak a funkcióit fogja
ellátni. Így megszűnik megsérteni az egyetlen felelősség elvét.
Globális állapot versus tesztek
Tesztek írásakor feltételezzük, hogy minden teszt egy izolált egység, és hogy semmilyen külső állapot nem lép be. És semmilyen állapot nem hagyja el a teszteket. A teszt befejezése után minden, a teszthez kapcsolódó állapotot automatikusan el kell távolítania a garbage collectornak. Ennek köszönhetően a tesztek izoláltak. Ezért futtathatjuk a teszteket tetszőleges sorrendben.
Ha azonban globális állapotok/singletonok vannak jelen, mindezek a kellemes feltételezések összeomlanak. Az állapot beléphet a tesztbe és kiléphet belőle. Hirtelen számíthat a tesztek sorrendje.
Ahhoz, hogy egyáltalán tesztelni tudjuk a singletonokat, a fejlesztők gyakran kénytelenek lazítani a tulajdonságaikat,
például azáltal, hogy megengedik a példány cseréjét egy másikkal. Az ilyen megoldások legjobb esetben is hackek, amelyek
nehezen karbantartható és érthető kódot hoznak létre. Minden tesztnek vagy tearDown()
metódusnak, amely
bármilyen globális állapotot befolyásol, vissza kell állítania ezeket a változtatásokat.
A globális állapot a legnagyobb fejfájás az unit tesztelés során!
Hogyan javítsuk a helyzetet? Könnyen. Ne írjon olyan kódot, amely singletonokat használ, részesítse előnyben a függőségek átadását. Tehát a dependency injection-t.
Globális konstansok
A globális állapot nem korlátozódik csak a singletonok és statikus változók használatára, hanem globális konstansokra is vonatkozhat.
Azok a konstansok, amelyek értéke nem hoz számunkra semmilyen új (M_PI
) vagy hasznos
(PREG_BACKTRACK_LIMIT_ERROR
) információt, egyértelműen rendben vannak. Ellenben azok a konstansok, amelyek arra
szolgálnak, hogy vezeték nélkül információt adjanak át a kódba, nem mások, mint rejtett függőségek. Mint
például a LOG_FILE
a következő példában. A FILE_APPEND
konstans használata teljesen korrekt.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
Ebben az esetben deklarálnunk kellene egy paramétert a Foo
osztály konstruktorában, 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 az információt a naplófájl elérési útjáról, és szükség szerint könnyen megváltoztathatjuk, ami megkönnyíti a kód tesztelését és karbantartását.
Globális függvények és statikus metódusok
Szeretnénk hangsúlyozni, hogy maguk a statikus metódusok és globális függvények használata nem problematikus.
Elmagyaráztuk, miért nem megfelelő a DB::insert()
és hasonló metódusok használata, de ez mindig csak a
globális állapot kérdése volt, amely valamilyen statikus változóban van tárolva. A DB::insert()
metódus
megköveteli egy statikus változó létezését, mert abban van tárolva az adatbázis-kapcsolat. E változó nélkül lehetetlen
lenne a metódust implementálni.
Determinisztikus statikus metódusok és függvények használata, mint például a DateTime::createFromFormat()
,
Closure::fromCallable
, strlen()
és sok más, teljes mértékben összhangban van a dependency
injection-nel. Ezek a függvények mindig ugyanazokat az eredményeket adják vissza ugyanazokból a bemeneti paraméterekből,
és ezért előrejelezhetők. Nem használnak semmilyen globális állapotot.
Léteznek azonban olyan függvények is PHP-ban, amelyek nem determinisztikusak. Ezek közé tartozik például a
htmlspecialchars()
függvény. Annak harmadik paramétere, a $encoding
, ha nincs megadva,
alapértelmezett értékként a ini_get('default_charset')
konfigurációs opció értékét veszi fel. Ezért
ajánlott ezt a paramétert mindig megadni, hogy elkerüljük a függvény esetleges kiszámíthatatlan viselkedését. A Nette
ezt következetesen megteszi.
Néhány függvény, mint például a strtolower()
, strtoupper()
és hasonlók, a közelmúltban nem
determinisztikusan viselkedtek, és a setlocale()
beállítástól függtek. Ez sok komplikációt okozott,
leggyakrabban a török nyelvvel való munka során. Az ugyanis megkülönbözteti a kis- és nagybetűs I
-t ponttal
és pont nélkül is. Így a strtolower('I')
az ı
karaktert adta vissza, a strtoupper('i')
pedig az İ
karaktert, ami ahhoz vezetett, hogy az alkalmazások számos rejtélyes hibát kezdtek okozni. Ezt a
problémát azonban a PHP 8.2-es verziójában orvosolták, és a függvények már nem függnek a locale-tól.
Ez egy szép példa arra, hogyan okozott fejfájást a globális állapot több ezer fejlesztőnek világszerte. A megoldás az volt, hogy dependency injection-nel helyettesítették.
Mikor lehet globális állapotot használni?
Léteznek bizonyos specifikus helyzetek, amikor lehet globális állapotot használni. Például a kód debuggolásakor, amikor ki kell íratni egy változó értékét, vagy meg kell mérni egy programrész futási idejét. Ilyen esetekben, amelyek ideiglenes műveletekre vonatkoznak, amelyeket később eltávolítanak a kódból, legitim egy globálisan elérhető dumper vagy stopperóra használata. Ezek az eszközök ugyanis nem részei a kód tervezésének.
Egy másik példa a reguláris kifejezésekkel dolgozó preg_*
függvények, amelyek belsőleg statikus cache-ben
tárolják a lefordított reguláris kifejezéseket a memóriában. Tehát ha ugyanazt a reguláris kifejezést többször hívja
meg a kód különböző pontjain, csak egyszer fordítódik le. A cache teljesítményt takarít meg, és ugyanakkor a
felhasználó számára teljesen láthatatlan, ezért az ilyen használat legitimnek tekinthető.
Összegzés
Megbeszéltük, miért van értelme:
- Eltávolítani minden statikus változót a kódból
- Deklarálni a függőségeket
- És használni a dependency injection-t
Amikor a kód tervezésén gondolkodik, gondoljon arra, hogy minden static $foo
problémát jelent. Ahhoz, hogy a
kódja DI-t tiszteletben tartó környezet legyen, elengedhetetlen a globális állapot teljes kiirtása és dependency
injection-nel való helyettesítése.
E folyamat során talán rájön, hogy egy osztályt fel kell osztani, mert több felelőssége van. Ne féljen ettől; törekedjen az egyetlen felelősség elvére.
Szeretnék köszönetet mondani Miško Hevery-nek, akinek cikkei, mint például a Flaw: Brittle Global State & Singletons, képezik ennek a fejezetnek az alapját.