Home / Brudnopis / O hermetyzacji i API

O hermetyzacji i API 06.03.2011

tagi: php symfony

Jedną z najbardziej podstawowych zasad programowania zorientowanego na obiekty to hermetyzacja danych. Mechanizmy php dostarczają nam modyfikatory dostępu, które to pomagają nam w hermetyzowaniu danych i implementacji poszczególnych klas, tak abyśmy mieli swobodę we wprowadzania zmian, które nie zmieniają funkcjonalności. API to interfejs, który nie ukrywamy, a udostępniamy. Co więc składa się na ten interfejs?

Na API klasy składają się:
  • składowe i metody publiczne
  • stałe
  • składowe i metody chronione, jeśli klasa jest przeznaczona do dziedziczenia (nie jest klasą finalną)
  • postać serializowana


Dwa pierwsze punkty wydają się oczywiste, API jest interfejsem obiektu, z którego może korzystać klient (czyli obiekt pracujący na innym obiekcie udostępniającym to API). Dwa pozostałe punkty nie wydają się tak oczywiste. Dla wyjaśnienia trzeciego punktu posłużę się przykładem z symfony 1.4.x:

[PHP]
  1. abstract class sfComponent
  2. {
  3. protected
  4. $moduleName = '',
  5. $actionName = '',
  6. $context = null,
  7. $dispatcher = null,
  8. $request = null,
  9. $response = null,
  10. $varHolder = null,
  11. $requestParameterHolder = null;
  12.  
  13. //ciach...
  14. }


Co by się stało gdyby developerzy symfony postanowili zmienić nazwę składowej $varHolder na np. $dataForView lub też typ przechowywany przez tą składową? Wiele projektów, które korzystają bezpośrednio z niej przestała by działać. Ustawiając tą składową jako chronioną sami siebie ograniczyli co do jej typu (klasy dziedziczące po sfComponent i pracujące bezpośrednio na $varHolder oczekują interfejsu jaki udostępnia klasa sfParameterHolder) oraz jej nazwy. Powyższa klasa udostępnia również rozmaite metody ustawiające, które operują na składowej $varHolder (getVar, setVar oraz magiczne metody __get, __set, __isset, __unset), które wyczerpują zastosowanie tej składowej. Klasa ta wcale nie ukrywa danych i swojej implementacji. Dlaczego składowa $varHolder oraz reszta składowych są chronione? Zapewne z przyzwyczajenia programisty lub też konwencji przyjętych przez zespół, dla dużej liczby programistów "podstawowym" modyfikatorem dostępu jest modyfikator chroniony, przez co klasy udostępniają za dużo informacji niż powinny, a co za tym idzie niepotrzebnie udostępniają większe, bardziej skomplikowane i naszpikowane szczegółami API. Ewentualna refaktoryzacja takiej klasy jest ograniczona, gdyż może już istnieć wiele kodu, który korzysta z jej szczegółów implementacyjnych. Wniosek jest taki, że nie należy bezmyślnie ustawiać wszystkim właściwościom modyfikatora chronionego, ale używać tego modyfikatora tam gdzie faktycznie jest on niezbędny. Taka sytuacja może mieć miejsce, gdy składowa jest prywatna i ma np. chronione metody ustawiające i pobierające, przez co sama prosi się o większą widoczność.

Podobna sytuacja ma się z metodami, jednakże modyfikator "chroniony" powinien być bardziej powszechny wśród metod, gdyż dane powinny być chronione w większym stopniu.

Ostatni składnik API, czyli postać serializacji jest pewnie największym zaskoczeniem. Pamiętajmy że dzięki mechanizmie serializacji można ustawić i odczytać prywatną właściwość obiektu. Domyślna postać serializacji udostępnia mnóstwo szczegółów na temat implementacji klasy.

[PHP]
  1. class A
  2. {
  3. private $objectOfB;
  4.  
  5. public function __construct(B $objectOfB)
  6. {
  7. $this->objectOfB = $objectOfB;
  8. }
  9. }
  10.  
  11. class B
  12. {
  13. private $text;
  14.  
  15. public function __construct($text)
  16. {
  17. $this->text = (string) $text;
  18. }
  19. }
  20.  
  21. $a = new A(new B('some text'));
  22.  
  23. echo serialize($a);


Wynik powyższego kodu będzie: O:1:"A":1:{s:12:"?A?objectOfB";O:1:"B":1:{s:7:"?B?text";s:9:"some text";}}

Widać więc, że klasa A ma właściwość prywatną o nazwie "objectOfB", której wartością powinien być obiekt klasy B. Mozna zmodyfikować tą postać serializowaną, przez co jawnie ustawimy wartość prywatnej składowej deserializowanego obiektu: O:1:"A":1:{s:12:"?A?objectOfB";s:9:"some text"}. Teraz gdy to zdeserailizujemy, w składowej "objectOfB" będzie przechowywany ciąg znaków - zostanie złamany niezmiennik obiektu, który zakłada (za pomocą konstruktora) że przechowuje ona obiekt typu B.

Możliwość zmiany wartości prywatnych właściwości obiektu to jedno z niebezpieczeństw - przez to mogą zostać naruszone niezmienniki obiektu, co jest równoważne z jego uszkodzeniem. Drugim niebezpieczeństwem jest udostępnianie wewnętrznej (prywatnej) struktury. Jeśli obiekty naszej klasy są serializowane (np. są przechowywane w cache, bazie danych, w sesji, czy przesyłane w postaci serializowanej) mogą wystąpić problemy w czasie dezerializacji gdy nastąpiła (nawet drobna) zmiana implementacji. W najgorszym przypadku obiekty będą wyglądały tak jakby wszystko było z nimi w porządku, a skukti niekompletnej deserializacji będą ujawniały się w dziwnych miejscach kodu i będą powodowały niepoprawne, trudne do wykrycia działanie.

Jaka jest więc ochrona przed utratą hermetyzacji przy mechanizmie serializacji? W PHP 5.1.0 wprowadzono interfejs Serializable, który właśnie służy do oddzielenia struktury klasy od struktury serializacji. Interfejs ten posiada dwie metody:

  • serialize() - z założenia zwraca zserializowaną wewnętrzną strukturę obiektu
  • unserialize($serialized) - odtwarza wewnętrzny stan obiektu na podstawie przekazanych zaserializowanych danych (wartość zwracana przez metodę serialize)


Na pierwszy rzut oka interfejs ten wydaje się podobny do magicznych metod __sleep oraz __wakeup. Na szczęście interfejs ten daje nam znacznie większe możliwości i służy do osiągnięcia innych celów.

Poprzedni przykład można zapisać tak:

[PHP]
  1. class A implements Serializable
  2. {
  3. private $objectOfB;
  4.  
  5. public function __construct(B $objectOfB)
  6. {
  7. $this->objectOfB = $objectOfB;
  8. }
  9.  
  10. public function serialize()
  11. {
  12. return serialize($this->objectOfB);
  13. }
  14.  
  15. public function unserialize($serialized)
  16. {
  17. $this->objectOfB = unserialize($serialized);
  18. }
  19. }
  20.  
  21. class B
  22. {
  23. private $text;
  24.  
  25. public function __construct($text)
  26. {
  27. $this->text = (string) $text;
  28. }
  29.  
  30. public function serialize()
  31. {
  32. return serialize($this->text);
  33. }
  34.  
  35. public function unserialize($serialized)
  36. {
  37. $this->text = unserialize($serialized);
  38. }
  39. }
  40.  
  41. $a = new A(new B('some text'));
  42.  
  43. echo serialize($a);


Wynik działania powyższego kodu daje w rezultacie C:1:"A":42:{O:1:"B":1:{s:7:"?B?text";s:9:"some text";}}. Zaserializowany obiekt jest znacznie krótszy, na pierwszy rzut oka nie zawiera szczegółów na temat wewnętrznej budowy, jednakże taki sposób serializacji ogranicza nas do serializacji tylko jednej składowej. Aby dać sobie furtkę do serializacji większej liczby składowych, powinniśmy w metodzie serialize serializować tablicę.

[PHP]
  1. //ciach..
  2. //w klasie A
  3. public function serialize()
  4. {
  5. return serialize(array(
  6. 'b' => $this->objectOfB,
  7. ));
  8. }
  9.  
  10. public function unserialize($serialized)
  11. {
  12. $data = unserialize($serialized);
  13. $this->objectOfB = $data['b'];
  14. }
  15.  
  16. //w klasie B
  17. public function serialize()
  18. {
  19. return serialize(array(
  20. 'text' => $this->text,
  21. ));
  22. }
  23.  
  24. public function unserialize($serialized)
  25. {
  26. $data = unserialize($serialized);
  27. $this->text = $data['text'];
  28. }


Postać serializowana nie zależy już od tego jak nazywają się składowe, ani też od tego ile ich jest. Możemy bez problemu zmienić nazwę składowej objectOfB, jednakże nadal jest problem jeśli będziemy chcieli zmienić jej typ. Aby ukryć typ obiektu przechowywanego w składowej objectOfB nie możemy go bezpośrednio serializować. Należy serializować dane, dzięki którym będziemy mogli odtworzyć ten obiekt (np. argumenty konstruktora).

[PHP]
  1. //ciach..
  2. //w klasie A
  3. public function serialize()
  4. {
  5. return serialize(array(
  6. 'b' => $this->objectOfB->getText(),
  7. ));
  8. }
  9.  
  10. public function unserialize($serialized)
  11. {
  12. $data = unserialize($serialized);
  13. $this->objectOfB = new B($data['b']);
  14. }


Dzięki takiemu rozwiązaniu całkowicie ukrywamy wewnętrzną implementację klasy A oraz przy okazji chronimy się przed przypadkowym naruszeniem niezmienników obiektu, gdyż metoda deserializująca sama jawnie tworzy oczekiwany obiekt dla swojej składowej.

Należy pamiętać, że metoda unserialize pełni funkcję konstruktora, którego argumentem jest postać serializowana. Konstruktor powinien ustawić poprawne wartości dla składowych, a więc nie należy bezgranicznie ufać zdeserializowanym danym. Należy je rzutować do odpowiedniego typu lub też sprawdzić czy są poprawne. Inny przykład takiego sprawdzenia przedstawia poniższy kod:

[PHP]
  1. class A
  2. {
  3. private $objectOfB;
  4.  
  5. public function __construct(B $objectOfB)
  6. {
  7. $this->setObjectOfB($objectOfB);
  8. }
  9.  
  10. private setObjectOfB(B $objectOfB)
  11. {
  12. $this->objectOfB = $objectOfB;
  13. }
  14.  
  15. public function serialize()
  16. {
  17. return serialize(array(
  18. 'b' => $this->objectOfB
  19. ));
  20. }
  21.  
  22. public function unserialize($serialized)
  23. {
  24. $data = unserialize($serialized);
  25.  
  26. if(!isset($data['b']))
  27. {
  28. throw new Exception(...);
  29. }
  30. $this->setObjectOfB($data['b']);//metoda setObjectOfB pełni rolę weryfikacji danych
  31. }
  32. }


Klasy, które przeznaczamy do serializacji jawnie oznaczajmy interfejsem Serializable. Daje to sygnał dla innych, że obiekty tej klasy można bez obaw serializować, gdyż jej autor świadomie zaimplementował strukturę postaci serializowanej. Serializowanie obiektów, które nie implementują tego interfejsu lub źle go używają jest w pewnym stopniu ryzykowne, to już od nas zależy czy na takie ryzyko i ograniczenia możemy sobie pozwolić.

Powinniśmy świadomie używać modyfikatorów dostępu, wykorzystywać modyfikator chroniony (w przypadku składowych) i publiczny (w przypadku metod) tylko w dobrze przemyślanych sytuacjach i gdy faktycznie zajdzie taka potrzeba. Użycie modyfikatora chronionego dla składowej z powodu "pewnie kiedyś w klasie dziedziczącej będę chciał odczytać wartość tej zmiennej" nie jest zbyt fortunne. W takim przypadku można zmienną ustawić jako prywatną i napisać chronioną metodę pobierającą wartość (bez metody ustawiającej). Postać serializowana również jest ważna, zwłaszcza jeśli wiemy że nasze obiekty będą przechowywane przez długi czas w tej postaci w jakimś źródle danych. Brak poświęcenia uwagi za wczasu na postać serializowaną, w przyszłości może prowadzić do bardzo dużych ograniczeń, a nawet uniemożliwienia refaktoryzowania kodu.

Komentarze (4)

#1 sokzzuka (www) , 06.03.2011 20:27

Dobry opis interfejsu serializable. Masz może jakieś przykłady złego jego użycia w popularnych frameworkach ? ZF/Symfony ?

#2 Piotr (www) , 08.03.2011 18:56

Nie szukałem ani dobrych, ani złych przykładów użycia tego interfejsu. Zazwyczaj obiekty są serializowane na krótki okres czasu, w takim przypadku można sobie pozwolić na jakieś odstępstwa (tak jest zazwyczaj we frameworkach). Ale problem pojawia się gdy obiekt w postaci serializowanej przebywa dosyć długo, wtedy trzeba sobie zadać więcej trudu i w taki sposób zaimplementować ten interfejs, aby przy ewentualnej zmianie implementacji, "stary" obiekt się poprawnie odserializował i był poprawny.

#3 Tomasz Kowalczyk (www) , 08.03.2011 23:15

Mi także podoba się opis interfejsu, gratulacje dla autora za czytelny przykład. Jeśli chodzi o serializację, polecam pakowanie danych klasy w pewne kontenery, wtedy problem sprowadza się np. do odserializowania arraya, w którym może siedzieć dowolna liczba danych.

#4 Rumcajs z Jcina, 27.03.2011 18:20

Bardzo fajny artykuł, gratuluję! Na początku wydawało mi się, że nie pokumam tematu, ale w miarę czytania wszystko stawało się jasne :)

Dodaj komentarz