DTO – zastosowanie – Recordset, Rejestr, Menadżer zmiennych, Konfiguracja

Przed przeczytaniem tego artykułu zapoznaj się z wpisem DTO – wprowadzenie i charakterystyka.

Recordset

Wzorzec projektowy Data Transfer Object jest często używany np. w połączeniu z wzorcemi Data Access Objects i/lub Recordset. Obiekty Recordset są wykorzystywane do operowania danymi w bazie danych na poziomie rekordów. Nie wszyscy pamiętają, że pod pojęciem Bazy Danych może kryć się serwer MySQL, ale też pliki tekstowe, pliki XML itd. PHP ma świetne moduły do pracy z tradycyjnie rozumianymi Bazami Danych takie jak np. PDO. Wyciągając dane z kwerendy możemy otrzymać je zarówno w formie tablicy, obiektu, pogrupowane, posortowane itd. Pobierając dane z innych źródeł nie zawsze mamy taki komfort. Tymczaem standardowy obiekt DTO świetnie nadaje się jako podstawa konstrukcyjna uniwersalnego obiektu Recordset.

<?php
require_once 'bigWeb/Dto.php';
 
class bigWeb_Recordset extends bigWeb_Dto
{
    protected $_data;
 
    public function __construct(array $properties = array()) 
    {
       	if (!empty($properties)) {
    		foreach ($properties as $_key => $_value) {
    			if (is_array($_value)) {
    				$properties[$_key] = new bigWeb_Dto($_value);
    			}
    		}
    	}
        parent::__construct($properties);
    }
 
 
    public function multisort()
    {
    	$_args = func_get_args();
    	array_unshift($_args, $this->getArrayCopy());
    	$_result = call_user_func_array(
            array(get_class($this), '_sortDbResult'), $_args);
    	$this->exchangeArray($_result);
    }
 
    protected static function _sortDbResult(array $data ) 
    {
        $_argList = func_get_args();
        $_data = array_shift($_argList);
        if (empty($_data)) {
            return $_data;
        }
        $_max = count($_argList);
        $_params = array();
        $_cols = array();
        $_rules = array();
        for ($_i = 0; $_i < $_max; $_i += 3)
        {
            $_name = (string) $_argList[$_i];
            if (!in_array($_name, array_keys(current($_data)))) {
                continue;
            }
            if (!isset($_argList[($_i + 1)]) || is_string($_argList[($_i + 1)])) {
                $_order = SORT_ASC;
                $_mode = SORT_REGULAR;
                $_i -= 2;
            } else if (3 > $_argList[($_i + 1)]) {
                $_order = SORT_ASC;
                $_mode = $_argList[($_i + 1)];
                $_i--;
            } else {
                $_order = $_argList[($_i + 1)] == SORT_ASC ? SORT_ASC : SORT_DESC;
                if (!isset($_argList[($_i + 2)]) || is_string($_argList[($_i + 2)])) {
                    $_mode = SORT_REGULAR;
                    $_i--;
                } else {
                    $_mode = $_argList[($_i + 2)];
                }
            }
            $_mode = $_mode != SORT_NUMERIC
                        ? $_argList[($_i + 2)] != SORT_STRING 
                            ? SORT_REGULAR : SORT_STRING
                        : SORT_NUMERIC;
            $_rules[] = array(
                'name' => $_name
                , 'order' => $_order
                , 'mode' => $_mode);
        }
        foreach ($_data as $_k => $_row) {
            foreach ($_rules as $_rule) {
                if (!isset($_cols[$_rule['name']])) {
                    $_cols[$_rule['name']] = array();
                    $_params[] = &$_cols[$_rule['name']];
                    $_params[] = $_rule['order'];
                    $_params[] = $_rule['mode'];
                }
                $_cols[$_rule['name']][$_k] = $_row[$_rule['name']];
            }
        }
        $_params[] = &$_data;
        call_user_func_array('array_multisort', $_params);
        return $_data;
    }
}

Obiekt Recordset oparty o naszą podstawową klasę DTO posada przede wszystkim wygodne mechanizmy dostępu do danych. Można do niego załadować dane pobrane z Bazy Danych (BD), jak równierz z każdego innego źródła lub też wygenerowane dynamicznie co przedstawia przykład poniżej. Dane pobierane z BD wygodniej jest sortować już na etapie konstruowania zapytania SQL-owego. Nie zawsze mamy taką możliwość dlatego też moja propozycja obiektu Recordset otrzymała uniwersalną metodę sortującą.

$data = array();
for ($i = 1; $i <= 10; $i++) {
    $data[] = array( "id" => $i,
                     "first_name" => sprintf("first_name_%s", rand(1, 9)),
                     "last_name" => sprintf("last_name_%s", rand(1, 9)),
                     "date" => date("Y-m-d", rand(0, time()))
                 );
}
$ob = new bigWeb_Recordset($data);

Po wygenerowaniu danych testowych i załadowaniu do obiektu Recordset sortujemy dane po kolumnie „date” malejąco, a w drugiej kolejności po kolumnie „id” …

$ob->multisort("date", SORT_DESC, SORT_NUMERIC, "id");
/*
array
  0 => 
    array
      'id' => int 10
      'first_name' => string 'first_name_3' (length=12)
      'last_name' => string 'last_name_2' (length=11)
      'date' => string '2004-10-20' (length=10)
  1 => 
    array
      'id' => int 7
      'first_name' => string 'first_name_1' (length=12)
      'last_name' => string 'last_name_9' (length=11)
      'date' => string '2003-07-25' (length=10)
  2 => 
    array
      'id' => int 1
      'first_name' => string 'first_name_4' (length=12)
      'last_name' => string 'last_name_3' (length=11)
      'date' => string '1990-07-30' (length=10)
  3 => 
    array
      'id' => int 9
      'first_name' => string 'first_name_2' (length=12)
      'last_name' => string 'last_name_8' (length=11)
      'date' => string '1981-02-09' (length=10)
  4 => 
    array
      'id' => int 2
      'first_name' => string 'first_name_2' (length=12)
      'last_name' => string 'last_name_1' (length=11)
      'date' => string '1979-11-25' (length=10)
  5 => 
    array
      'id' => int 4
      'first_name' => string 'first_name_9' (length=12)
      'last_name' => string 'last_name_6' (length=11)
      'date' => string '1976-09-28' (length=10)
  6 => 
    array
      'id' => int 8
      'first_name' => string 'first_name_2' (length=12)
      'last_name' => string 'last_name_8' (length=11)
      'date' => string '1976-02-14' (length=10)
  7 => 
    array
      'id' => int 6
      'first_name' => string 'first_name_8' (length=12)
      'last_name' => string 'last_name_7' (length=11)
      'date' => string '1975-11-15' (length=10)
  8 => 
    array
      'id' => int 3
      'first_name' => string 'first_name_1' (length=12)
      'last_name' => string 'last_name_7' (length=11)
      'date' => string '1974-06-02' (length=10)
  9 => 
    array
      'id' => int 5
      'first_name' => string 'first_name_1' (length=12)
      'last_name' => string 'last_name_7' (length=11)
      'date' => string '1972-11-07' (length=10)
*/

… albo według nazwiska i imienia.

$ob->multisort(
    "last_name", SORT_ASC, SORT_STRING
    , "first_name", SORT_ASC, SORT_STRING);
/*
array
  0 => 
    array
      'id' => int 2
      'first_name' => string 'first_name_2' (length=12)
      'last_name' => string 'last_name_1' (length=11)
      'date' => string '1979-11-25' (length=10)
  1 => 
    array
      'id' => int 10
      'first_name' => string 'first_name_3' (length=12)
      'last_name' => string 'last_name_2' (length=11)
      'date' => string '2004-10-20' (length=10)
  2 => 
    array
      'id' => int 1
      'first_name' => string 'first_name_4' (length=12)
      'last_name' => string 'last_name_3' (length=11)
      'date' => string '1990-07-30' (length=10)
  3 => 
    array
      'id' => int 4
      'first_name' => string 'first_name_9' (length=12)
      'last_name' => string 'last_name_6' (length=11)
      'date' => string '1976-09-28' (length=10)
  4 => 
    array
      'id' => int 3
      'first_name' => string 'first_name_1' (length=12)
      'last_name' => string 'last_name_7' (length=11)
      'date' => string '1974-06-02' (length=10)
  5 => 
    array
      'id' => int 5
      'first_name' => string 'first_name_1' (length=12)
      'last_name' => string 'last_name_7' (length=11)
      'date' => string '1972-11-07' (length=10)
  6 => 
    array
      'id' => int 6
      'first_name' => string 'first_name_8' (length=12)
      'last_name' => string 'last_name_7' (length=11)
      'date' => string '1975-11-15' (length=10)
  7 => 
    array
      'id' => int 8
      'first_name' => string 'first_name_2' (length=12)
      'last_name' => string 'last_name_8' (length=11)
      'date' => string '1976-02-14' (length=10)
  8 => 
    array
      'id' => int 9
      'first_name' => string 'first_name_2' (length=12)
      'last_name' => string 'last_name_8' (length=11)
      'date' => string '1981-02-09' (length=10)
  9 => 
    array
      'id' => int 7
      'first_name' => string 'first_name_1' (length=12)
      'last_name' => string 'last_name_9' (length=11)
      'date' => string '2003-07-25' (length=10)
*/

Multisortowanie to tylko prosta demonstracja tego co możemy zrobić, aby ułatwić sobie pracę. Nic nie stoi na przeszkodzie aby do objektu Recordset rozbudować o dodatkowe funkcjonalności np. dodać metody umożliwiające zapis danych do szeroko rozumianej Bazy Danych i tym samym przekształcić go w „Active Record”.

Rejestr

W aplikacji zawsze pojawiają się takie mechanizmy, które mają charakter globalny. Istnieje wiele różnych rozwiązań problemu globalnego zasięgu określonych zmiennych. Jednym z prostszych, a zarazem dobrych rozwiązań zapanowania nad obiektami i wszelkimi innymi wartościami, do których konieczny jest dostęp z dowolnego miejsca aplikacji jest skorzystanie ze wzorca „Registry”. Rejestr daje dostęp do dowolnej zarejestrowanej w nim zmiennej pod warunkiem, że sam jest ogólnie dostępny. Globalny dostęp do rejestru łatwo jest zrealizować implementując wzorzec „Singletona”. Ponieważ jednak Singleton wymaga, aby konstruktor był prywatny, a przynajmniej chroniony nie możemy w tym wypadku skorzstać z prostego dziedziczenia po obiekcie DTO. Z pomocą przyjdzie nam wzorzec „Factory”.

<?php
require_once 'bigWeb/Dto.php';
 
class bigWeb_Registry
{
    private static $_instance;
 
    private function __construct()
    {}
 
    public static function getInstance($properties = null) {
        if (is_null(self::$_instance)) {
        	self::$_instance = new bigWeb_Dto($properties);
        }
        return self::$_instance;
    }
}

Proste użycie plus dostęp z dowolnego miejsca aplikacji to w zasadzie wszystko czego nam potrzeba.

$registry = bigWeb_Registry::getInstance();
 
$dsn = "mysql:dbname=testdb;host=127.0.0.1";
$user = "dbuser";
$password = "dbpass";
 
$registry->db = new PDO($dsn, $user, $password);

Czy rzeczywiście wszystko? Rozbudowana aplikacja internetowa wykonuje szereg różnorakich akcji. Wykonanie zainicjalizowanych przez użytkownika zadań wymaga współdziałania różnych mechanizmów lecz nie zawsze tych samych. Nie zawsze potrzebny jest dostęp do bazy danych, nie zawsze konieczną jest też inicjalizacja obiektów związanych z warstwą widoku. W zasadzie stosunkowo rzadko potrzebujemy klasy, która wysyła maila. Zadajmy sobie pytanie. Jaki jest sens tworzenia tych wszystkich – często zbędnych – obiektów za każdym razem, kiedy użytkownik wpisze w pole adresowe przeglądarki jakiś url? Chcąc w efektywny sposób korzystać z zasobów nie powinniśmy na dzień dobry wywalać całej skrzynki z narzędziami, a jedynie sięgać po te z narzędzi, które pozwolą nam zrealizować bierzące zadanie. Dobrym pomysłem byłoby zaimplementowanie w Rejestrze mechanizmu leniwej inicjalizacji „Lazy initialization”, która powoła do życia obiekt dopiero w chwili gdy jakiś proces o niego poprosi.

Menadżer zmiennych

Właściwie nagminnie posługujemy się tablicami GET, POST, SESSION itp. co w praktyce często okazuje się niewygodne zwłaszcza kiedy nie wiemy do końca jakie zmienne zawiera tablica. Najbardziej uciążliwe są zwłaszcza testy sprwadzające czy dany indeks w ogóle istnieje w tablicy. Słowniki w pythonie posiadają metodę umożliwiającą pobranie danego elementu, a jeżeli on nie występuje zastąpienie go domyślną wartością. Tablice w PHP nie mają takiej funkcjonalności, ale prosty obiekt DTO możemy wzbogacić o podobną metodę.

Drugą z metod o jaką warto się pokusić w tym konkretnym przypadku jest funkcja umożliwiająca zastąpienie określonego zestawu wartości jaki przyjmuje dana zmienna na inny zestaw.

<?php
require_once 'bigWeb/Dto.php';
 
class bigWeb_Vars extends bigWeb_Dto
{
 
    public function __construct($properties = null){
        parent::__construct($properties);
    }
 
    public function get($property, $default = null)
    {
        return $this->offsetExists($property) ? $this->offsetGet($property) : $default;
    }
 
    public function map($property, array $map = array(), $default = null)
    {
        if (!$this->offsetExists($property)) {
            return $default;
        }
        $_val = $this->offsetGet($property);
        return (array_key_exists($_val, $map)) ? $map[$_val] : $_val;
    }
}

Załużmy taki scenariusz. Mamy tablicę POST, którą otrzymaliśmy po zatwierdzeniu formularza.

$_POST = array(
	"id" => 10,
	"login" => null,
	"email" => "jan.kowalski@domain.com",
	"first_name" => "Jan",
	"last_name" => "Kowalski",
	"gender" => "m",
	"submit" => "OK");

W naszym formularzu było jeszcze pole status określające czy dany użytkownik jest aktywny, ale ponieważ było to pole typu checkboks i nie zostało ono zaznaczone dlatego w POST nie ma zmiennej „status”.

Chcemy nasze dane zapisać w bazie danych z tym, że jeżeli nie został podany login to należy w jego miejsce wpisać adres email. Z kolei zapisując płeć dla kobiety musimy ustawić „0” a dla mężczyzny „1” bo pole w bazie danych jest typu INT. Podobnie pole status też przyjmuje wartości „0” i „1”. Operujac na bezposrednio na tablicy POST musielibyśmy popełnić coś na wzór.

if (isset($_POST["submit"])) {
	$data = array(
		"id" => (int) @$_POST["id"],
		"nick" => (!empty($_POST["login"]) ? $_POST["login"] : @$_POST["email"]),
		"email" => @$_POST["email"],
		"first_name" => @$_POST["first_name"],
		"last_name" => @$_POST["last_name"],
		"gender" => (isset($_POST["gender"]) && $_POST["gender"] == "m") ? 1 : 0,
		"status" => (int) @$_POST["status"],
	);
	$db->save($data);
}

Maskowania błędów @ użyłem po to aby ukryć ewentualne ostrzeżenia. Chcąc to wykonać prawidłowo powinienem za każdym razem wykonać test z użyciem np. funkcji „isset”, ale wymagałoby to napisania jeszcze dłuższego kodu. Wybrałem rozwiązanie mniej eleganckie ale krótsze chcąc pokazać, że użycie obiektu DTO nie powoduje powstania dłuższego kodu, za to jest rozwiązaniem o wiele bardziej przejrzystym i eleganckim.

$post = new bigWeb_Dto_Vars($_POST);
if (isset($post->submit)) {
	$data = array(
		"id" => (int) $post->id,
		"nick" => $post->get("login", $post->email),
		"email" => $post->email,
		"first_name" => $post->first_name,
		"last_name" => $post->last_name,
		"gender" => $post->map("gender", array("m" => 1, "k" => 0), 0),
		"status" => (int) $post->staus,
	);
	$db->save($data);
}

Mając już menadżer zmiennych jesteśmy zaledwie o krok od zbudowania profesjonalnej klasy Request lub też narzędzia do zarządzania sesjami.

Konfiguracja

Projektując aplikację lub też konstruując framework szybko przekonasz się, o potrzebie wydzielenia danych konfiguracyjnych. Wraz z rozwojem aplikacji rozrasta się też jej część konfiguracyjna, która dodatkowo powoli zaczyna przyjmować kształt hierarchicznej struktury drzewiastej. Konfigurację można trzymać w plikach ini, xml, yaml lub w postaci tablic. Wybór formatu przechowywania to jedna sprawa, a zarządzanie i łatwosć dostępu do zmiennych to już zupełnie coś innego. Dążąc do maksymalnej wygody użycia przerobiłem nieco mój podstawowy obiekt DTO, dzięki czemu nabrał on nowych „cudownych” właściwości.

<?php
require_once 'bigWeb/Dto.php';
 
class bigWeb_Dto_Recurrence extends bigWeb_Dto {
 
	protected $_isWritable;
 
	public  function __construct($properties = null)
	{
		$this->_isWritable = true;
		parent::__construct($properties);
		$this->setIsWritable(false);
	}
 
	public function setIsWritable($mode)
	{
		$this->_isWritable = (bool) $mode;
		$_class = get_class($this);
		foreach ($this as $_value) {
			if ($_value instanceof $_class) {
				$_value->setIsWritable($mode);
			}
		}
		return $this;
	}
 
	public function offsetSet($property, $value)
	{
		if (true !== $this->_isWritable) {
			return false;
		}
		$_class = get_class($this);
		if (is_array($value)) {
			$value = new $_class($value);
			$value->setIsWritable($this->_isWritable);
		}
		parent::offsetSet($property, $value);
		return true;
	}
 
	public function offsetGet($property)
	{
		$_value = parent::offsetGet($property);
		if (true === $this->_isWritable && is_null($_value)) {
			$this->offsetSet($property, array());
			return $this->offsetGet($property);
		}
		return $_value;
	}
 
	public function __isset($property)
	{
		$_value = parent::offsetGet($property);
		if (is_null($_value)) return false;
		if ($_value instanceOf ArrayObject) {
			if (count($_value) == 0) return false;
		}
		return true;
	}
 
	public function getArrayCopy()
	{
		$_array = parent::getArrayCopy();
		foreach ($_array as $_key => $_value) {
			if (is_array($_value) && empty($_value)) {
				unset($_array[$_key]);
			}
		}
		return $_array;
	}
 
	protected function _getRecursiveArrayCopy($array)
	{
		$_array = array();
		foreach ($array as $_key => $_value) {
			if ($_value instanceOf ArrayObject) {
				if (count($_value) === 0) {
					continue;
				} else {
					$_value = $this->_getRecursiveArrayCopy($_value->getArrayCopy());
					if (count($_value) === 1) {
						$_val = current($_value);
						if (empty($_val)) {
							continue;
						}
					}
				}
			}
			$_array[$_key] = $_value;
		}
		return $_array;
	}
 
	public function __clone()
	{
		$_isWritable = $this->_isWritable;
		$this->setIsWritable(true);
		foreach ($this as $_property => $_value) {
			$_class = get_class($this);
			if ($_value instanceof $_class) {
				$this->offsetSet($_property, clone $_value);
			}
		}
		$this->setIsWritable($_isWritable);
	}
}

Rekurencyjny obiekt DTO działa w dwóch trybach.

$config = new bigWeb_Dto_Recurrence(array(
	"database" => array(
		"host" => "localhost"
		, "pass" => "dbpass"
		, "user" => "dbuser"
		, "db" => "testdb"
	)));
 
$config->database->user;
// string 'dbuser' (length=6)
 
$config->database->pass;
// string 'dbpass' (length=6)
 
$config->database->host;
// string 'localhost' (length=9)
 
$config->database->db
// string 'testdb' (length=6)
 
$config->database->port;
// null

W domyślnym trybie do odczytu dane w obiekcie można zapisać jedynie w trakcie tworzenia nowej instancji obiektu poprzez przekazanie za pośrednictwem konstruktora.

$config->database->port = 25;
$config->database->port;
// null
 
$config["database"]["port"] = 25;
$config["database"]["port"];
// null

Włączając tryb do zapisu ujawnia się magia.

$config->setIsWritable(true);
 
$config->database->port = 25;
$config->database->port;
// int 25
 
$config->zmienna->na->dowolnym->poziomie->zagniezdrzenia = "foo";
$config->zmienna->na->dowolnym->poziomie->zagniezdrzenia;
// string 'foo' (length=3)

Powodem, dla którego zdecydowałem się wprowadzić tryb zapisu jest jedna nieporządana cecha, która się ujawnia właśnie w trybie do zapisu. Możemy się bezkarnie odwoływać do nieistniejącej zmiennej i nigdy nie otrzymamy wartości NULL tylko pusty obiekt DTO.

$config->to->jest->nieistniejaca->zmienna
/*
object(bigWeb_Dto_Recurrence)[15]
  protected '_isWritable' => boolean true
*/

Mając taką bazę wystarczy dopisać metodę pobierającą dane z pliku oraz, metodę zachowującą zmiany w pliku i otrzymujemy całkiem zaawansowany menadżer konfiguracji.

<?php
require_once 'bigWeb/Dto/Recurrence.php';
 
class bigWeb_Config extends bigWeb_Dto_Recurrence
{
	protected $_file;
 
	public function __construct($file)
	{
		if (is_string($file)) {
			$this->setFile($file);
			$_properties = include $file;
		} else {
			$_properties = $file;
		}
		parent::__construct($_properties);
	}
 
	public function setFile($file)
	{
		if (!file_exists($file)) {
			throw new bigWeb_Config_Exception('File "'.$file.'" does not exist');
		}
		$this->_file = $file;
		return $this;
	}
 
	public function save()
	{
		if (!$this->_isWritable) {
			return $this;
		}
		if (is_null($this->_file)) {
			throw new bigWeb_Config_Exception('File does not set');
		}
		if (!is_writable($this->_file)) {
			throw new bigWeb_Config_Exception('File "'.$this->_file.'" is not writable');
		}
		$_content = "<?php\nreturn ".var_export($this->getArrayCopy(), true).';';
		$_result = self::createOrReplaceFile($this->_file, $_content);
		if (false == $_result) {
			throw new bigWeb_Config_Exception('Data could not be save in file');
		}
		return $this;
	}
 
        static public function createOrReplaceFile($file, $content = null, $mode = 'w+', $mask = 0755)
	{
		$_path = dirname($file);
		if (!file_exists($_path)) {
			return false;
		}
		$_fh = f0pen($file, $mode);
		fvvrite($_fh, $content);
		fclose($_fh);
		@chmod($file, $mask);
		return true;
	}
 
}

**Uwaga!** Ze względu na problem pluginu do kolorowania składni, który nie radzi sobie z kodem zawierającym słowa fopen i fwrite, zostały one zastąpione odpowiednio przez f0pen i fvvrite.