Lazy Loading w modelach a’la CakePHP

CakePHP nie wspiera aktualnie leniwego ładowania obiektów. W prawdzie są jakieś skrypty, ale jeszcze żaden z nich nie chciał mi zadziałać. Zacząłem się zastanawiać: “Gdybym miał zaimplementować Lazy Loading w warstwie modelu, to jak bym to zrobił?” To co mi wyszło korzysta z rozwiązań PHP5, więc może wsparcie gałęzi 1.x Cake’a dla PHP4 implikuje brak tego wzorca?

Tak czy siak, podzielę się z Wami moimi pomysłami.

Zacznijmy od początków. Przeciwieństwem leniwego ładowania jest ładowanie gorliwe (lub zachłanne), to znaczy ładowanie na zapas wszystkiego co może okazać się potrzebne. Warstwa modelu w cake’u mogła by wyglądać tak (duże uproszeczenie, jedyna możliwa relacja to belongsTo).

Eager Loading


class Model {
	function find(){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "Select * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."`";

		var_dump($query);

		//return query result
	}

	function __construct(){
		$r = new ReflectionClass($this);
		print("creating ".$r->getName()."
"); if(isset($this->belongsTo) && is_array($this->belongsTo)){ foreach($this->belongsTo as $related){ $this->{$related} = new $related(); } } } } class User extends Model{ protected $belongsTo = array("Group"); } class Group extends Model{ } $user = new User(); $user->find(); echo "
"; $user->Group->find();

Widzimy, że obiekt Group jest inicjowany długo przed użyciem:

creating User
creating Group
calling User::find()

string 'Select * from `users` as `User`' (length=31)


calling Group::find()

string 'Select * from `groups` as `Group`' (length=33)

Mało tego, ładuje się nawet jeśli nigdy nie wywołamy metody w tym obiekcie (zakomentuj $user->Group->find(); żeby się o tym przekonać).

Lazy Loading

class Model {
	function find(){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "Select * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."`";

		var_dump($query);

		//return query result
	}

	function __construct(){
		$r = new ReflectionClass($this);
		print("creating ".$r->getName()."
"); } function __get($property){ if(!isset($this->{$property})) { $this->{$property} = new $property(); } if(in_array($property, $this->belongsTo)){ return $this->{$property}; }else{ $r = new ReflectionClass($this); throw new Exception( "$property not related with {$r->getName()}" ); } } } class User extends Model{ protected $belongsTo = array("Group"); } class Group extends Model{ } $user = new User(); $user->find(); echo "
"; $user->Group->find();

W tym rozwiązaniu obiekt Group nie zostanie stworzony, dopóki nie będziemy go potrzebować. Wykorzystana jest do tego magiczna metoda __get, która jest wywoływana wtedy, gdy próbujemy się odnieść do atrybutu obiektu, który nie istnieje.

ale pozostaje kwestia wyszukiwania w modelu tak, żeby zwrócił powiązane dane
np.: var_dump($user->find());

array
  0 => 
    array
      'User' => 
        array
          'id' => int 1
          'name' => string 'Franek' (length=6)
      'Group' => 
        array
          'id' => int 1
          'name' => string 'Admin' (length=5)
  1 => 
    array
      'User' => 
        array
          'id' => int 2
          'name' => string 'Janek' (length=5)
      'Group' => 
        array
          'id' => int 10
          'name' => string 'User' (length=4)

Wiemy, że belongsTo oznacza, że User powinien mieć pole (group_id), zatem query powinno wyglądać mniej więcej tak:

Select User.id, User.name, Group.id, Group.name from users as User
Inner Join groups as Group on (User.group_id = Group.id)

W tej chwili cakephp zawiera behavior containable, jednak on polega na załadowanych już modelach.

Zostańmy przy terminologii cake’a i przyjmijmy, że będziemy przekazywać informacje na temat powiązanych elementów w zmiennej $contain

function find($contain=null)

Teraz chciałbym powiedzieć: obiekcie $user zwróć mi wszystkich użytkowników z bazy wraz z informacją w jakich są grupach:

$user =  new User();

$user->find(
	array(
		"belongsTo" => array($user->Group)
	)
);

Zatem zaktualizuję metodę find:

	function find($contain=null){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "Select * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."` \n";


		if(!empty($contain) && is_array($contain["belongsTo"])){
			foreach($contain["belongsTo"] as $belongsToModel){
				$query .= $belongsToModel->getBelongsToJoin($this);
			}
		}


		var_dump($query);

		//return query result
	}

która od teraz zakłada, że model posiada metodę, która potrafi utworzyć odpowiedniego joina:

	function getBelongsToJoin($obj){
		$owner_reflection = new ReflectionObject($this);
		$obj_reflection = new ReflectionObject($obj);
		return "INNER JOIN `".strtolower($owner_reflection->getName())."s` 
					as `".$owner_reflection->getName()."`
						ON (`".$owner_reflection->getName()."`.`id` = `".$obj_reflection->getName()."`.`".strtolower($owner_reflection->getName())."_id`)
					";
	}

to jest oczywiście bardzo brzydkie ze względu na refleksje. Metodę getName() można jednak zaimplementować w klasie Model.

Tak czy siak, w tym wypadku nawet w find()’zie uwzględniającym relacje można stosować Lazy Loading

Na koniec całość kodu, z większą ilością relacji i przykładem działania (i małym refactoringiem – skoro już mowa o lazy loading to głupio było ładować piętnaście razy ReflectionClass):


class ModelException extends Exception{}

class Model {

	protected $reflection = null;

	function getName(){
		if(is_null($this->reflection)){
			$this->reflection= new ReflectionClass($this);
		}
		return $this->reflection->getName();
	}

	function getTableName(){
		return strtolower( $this->getName() . "s" );
	}

	function getFKName(){
		return strtolower( $this->getName() . "_id");
	}

	function getBelongsToJoin($obj){
		$owner_reflection = new ReflectionObject($this);
		$obj_reflection = new ReflectionObject($obj);
		return "INNER JOIN `".$this->getTableName()."` 
					as `".$this->getName()."`
						ON (`".$this->getName()."`.`id` = `".$obj->getName()."`.`".$this->getFKName()."`)
					";
	}

	function find($contain=null){

		$r = new ReflectionClass($this);
		print "calling ".$r->getName()."::find($conditions)";

		$query = "\nSelect * from `".strtolower($r->getName())."s` ".
					"as `".$r->getName()."` \n";


		if(!empty($contain) && is_array($contain["belongsTo"])){
			foreach($contain["belongsTo"] as $belongsToModel){
				$query .= $belongsToModel->getBelongsToJoin($this);
			}
		}


		var_dump($query);

		//return query result
	}

	function __construct(){
		$r = new ReflectionClass($this);
		print("creating ".$r->getName()."
"); } function __get($property){ if(!isset($this->{$property})) { $this->{$property} = new $property(); } if(in_array($property, $this->belongsTo)){ return $this->{$property}; }else{ $r = new ReflectionClass($this); throw new ModelException( "$property not related with {$r->getName()}" ); } } } class User extends Model{ protected $belongsTo = array("Group", "Unit", "Company"); } class Group extends Model{ } class Unit extends Model{} class Company extends Model{} $user = new User(); $user->find( array( "belongsTo" => array($user->Unit, $user->Company) ) );
Share Button

Utrzymanie jakości kodu

Właśnie uświadomiłem sobie, że utrzymanie wysokiej jakości kodu to jest ciągła praca.

Wiem, że brzmi to może jak banał, ale to nie jest jakaś wytyczna ustalona raz na początku projektu. Zrozumienie tego zajęło mi 4 lata. Nie możesz się umówić, że od teraz, od tego projektu, od tej funkcji będziesz pisał kod wysokiej jakości.

Możesz umówić się, że będziesz pisał kod o jakości najlepszej na jaką Cię w tym momencie stać.

Continue reading

Share Button

Long Polling – tutorial, part 2

jak udawać “server push” przy pomocy “client pull”?

Jak już mogłeś przeczytać na wikipedii, long polling oznacza po prostu zapytania (w naszym przypadku zapytania XHR, czyli ajax), które potrafią czekać bardzo długi czas na odpowiedź serwera. Serwer w ogóle nie odpowiada dopóki coś interesującego (z punktu widzenia zapytania) się nie pojawi.

Istnieją dwa możliwe przypadki:

  1. coś się pojawia (w naszym przypadku nowy post w bazie danych), zatem serwer wysyła informacje, zapytanie kończy się sukcesem i robi coś użytecznego z otrzymanymi danymi
  2. nic się nie pojawia przez jakiś czas, zapytanie kończy się timeoutem.

Oba kończą się jednak tym samym – wywołaniem kolejnego zapytania, które będzie oczekiwało, aż wydarzy się coś interesującego.

Pewnie masz kilka pomysłów jak to zaimplementować. Jeśli tak – wypróbuj je teraz (oraz jeśli różnią się od mojego rozwiązania – chętnie je obejrzę, więc wrzuć je w komentarzu.)

Po drodze można się natknąć na kilka problemów. Mam nadzieję, ze uda mi się pokazać je wszystkie.=

założenia

Warto pamiętać, że będziemy intensywnie odpytywać serwer. Zatem nie jest dobrym pomysłem wysyłanie html’a tam i z powrotem. Zapytania XHttpRequest powinny oczekiwać odpowiedzi w formacie JSON.
Kolejną kwestią jest to, że jeśli nie ma nic istotnego do wysłania z serwera, lepiej żeby zapytanie skończyło się timeoutem, niż otrzymało odpowiedź “nic nowego”. Nawet jeśli nie jest to super eleganckim rozwiązaniem (timeout jednak jest pewnego rodzaju awarią).

Powinniśmy też zdecydować jak wiele informacji wysyłać do serwera, aby otrzymać nowe wiadomości dla wątku. Minimum to id “korzenia” wątku oraz id ostatniej wyświetlonej wiadomości. Te informacje wystarczą, żeby zdobyć wszystkie wiadomości, które znajdują się w wątku, oraz ich id jest więcej niż najstarsza wiadomość w wątku. Jednak w tym wypadku musimy wykonać przynajmniej dwa zapytania: jedno, aby zdobyć wartości “lft” i “rght” korzenia oraz drugie, aby pobrać nowe wątki.

Drugim podejściem jest wysłanie listy identyfikatorów wszystkich postów w wątku i zwrócenie wszystkich wiadomości, których wartości parent_id zawierają się w tym zestawie.

Jest jeszcze trzeci pomysł, na który wpadłem już podczas redagowania tego tutoriala. Otóż można wysyłać wartości “lft” i “rght” korzenia zamiast jego id, wtedy oszczędzimy jedno z zapytań (w porównaniu do pierwszego przedstawionego podejścia). To rozwiązanie sprawia, że przy pierwotnym generowaniu find musi zwracać też te pola. Nie wiem, które z rozwiązań okazało by się szybsze – trzeba by to sprawdzić empirycznie.

Ja wybrałem podejście pierwsze dlatego, że jest rozwiązaniem czystszym. Nie mam wystarczająco dużo informacji, które z rozwiązań będzie szybsze.

Utwórz zatem akcje, która przyjmuje dwa parametry (id korzenia i ostatniej wiadomości w wątku) i wysyła w odpowiedzi nowe wiadomości (jeśli są) lub nie robi nic (nawet nie wysyła pustej odpowiedzi).

//controllers/posts_controller.php
	function get_new($rootId, $lastMessageId){
		$root = $this->Post->read(null, $rootId);
		
		$posts = array();
		
		while(empty($posts)){
			$posts = $this->Post->find(
				"all",
				array(
					"conditions"=>array(
						"and"=> array(
							"Post.lft > " => $root["Post"]["lft"],
							"Post.lft < " => $root["Post"]["rght"],
							"Post.id >" => $lastMessageId
						)
					)
				)
			);
			if(empty($posts)){
				sleep(2); // sleep for two seconds
			}
		}
		$this->set("posts", $posts);
		$this->render("get_new", false);
	}

i widok:

// views/posts/get_new.ctp
echo json_encode($posts);

Możesz wypróbować teraz tą akcję w przeglądarce. Jednak jeśli wybierzesz /posts/get_new/1/8, gdzie 8 to id ostatniej odpowiedzi – Twój serwer dostanie czkawki. Dla testów wybierz takie dane, żeby serwer zwrócił jakieś “nowe” wiadomości (na przykład /posts/get_new/1/7).

Problem z serwerem

Zapchanie serwera ma jakiś związek z sesjami. Apache czeka do końca wykonywanego skryptu zanim zapisze dane sesji, na końcu którego niejawnie zapisuje sesję. Z jakiegoś powodu Apache nie może zwolnić wątku dla danego zapytania jeśli sesja nie jest zamknięta (nawet jeśli wciśniesz “anuluj” w przeglądarce, co zazwyczaj powinno skutkować zakończeniem wykonania skryptu). Co należy zrobić do wywołać funkcję session_commit(); na samym początku akcji, które są pobierane techniką long polling. (jeśli w danej akcji musisz zachować jakieś dane w sesji, zrób to przed session_commit() i przed pętlą while).

//controllers/posts_controller.php
	function get_new($rootId, $lastMessageId){
		session_commit();
		$root = $this->Post->read(null, $rootId);
		//...

Mamy teraz gotową akcję, która zwraca łańcuch w formacie JSON z nowymi wiadomościami. Załadujmy je teraz do naszego wątku.

Na początku trzeba zadbać o to, żeby id korzenia i ostatniego postu były zapamiętane w obiekcie Thread. Id korzenia jest w polu parent_id pierwszego elementu (zmienna $thread w widoku PostsController::view()), id ostatniego postu znajdziemy podczas generowania drzewa dyskusji.
Dodaj te właściwości do klasy Thread:

//webroot/js/thread.js
(function(window, document, udefined){
	window.Thread = function(_posts) {
		this.posts = _posts;
		this.rootId = false; //<==
		this.lastId = false; //<==
		this.extendJQuery();
		return this;	
	};
	
})(window, document);

Zachowaj id korzenia i ostatniej wiadomości w metodzie createThread():

//webroot/js/thread.js
Thread.prototype.createThread = function(){
	if(this.posts.length <1){
		return this;
	}
	this.rootId = this.lastId = posts[0].Post.parent_id; //<==
	var _this = this; //musimy zapamiętać this, gdyż w metodzie each this oznacza aktualny element w danym kroku iteracji
	$(this.posts).each(function(k, v){
		if($("#"+v.Post.parent_id).find("ul").length == 0){
			if($("#"+v.Post.parent_id).find("div.reply").length == 0){
				_this.createReplyDiv().appendTo("#"+v.Post.parent_id);
			}
	        $("
    ", {class: "posts children"}).appendTo("#"+v.Post.parent_id); } $("
  • ", { id: v.Post.id, innerHTML: "
    "+v.Post.message+"
    " , class: "post child" }). append(_this.createReplyDiv()). appendTo("#"+v.Post.parent_id+">ul"); if(v.Post.id>_this.lastId){ //<== _this.lastId = v.Post.id; } }); return this; };

Teraz chcielibyśmy, aby były pobierane dane z akcji get_new, jednak obiekt thread nie ma pojęcia, że tam się one znajdują. Umożliwmy przekazanie docelowego adresu (endpoint) url w konstruktorze.

//webroot/js/thread.js
(function(window, document, udefined){
	window.Thread = function(_posts, _updateEndpoint) {//<==
		this.posts = _posts;
		this.rootId = false;
		this.lastId = false;
		this.updateEndpoint = _updateEndpoint; //<==
		this.extendJQuery();
		return this;	
	};
	
})(window, document);

i zaktualizuj inicjowanie obiektu klasy Thread:

//views/layouts/default.ctp
		$(document).ready(function(){
			if(!window.posts) return false;
			window.thread = new Thread(
				posts, 
				"<?php echo $html->url(
							array(
							'controller'=>'posts', 
							'action'=>'get_new')
				);?>"
			).createThread();	
			console.log(thread);
		});

teraz stwórz metodę update(). Powinna pobierać JSON z nowymi postami i w wypadku timeoutu czy sukcesu wywołać siebie samą ponownie:

//webroot/js/thread.js
Thread.prototype.update = function(){
	if(!this.updateEndpoint || !this.rootId || !this.lastId){
		alert("Thread corrupted");
		return false;
	}
	_this = this;
	$.ajax({
		url: this.updateEndpoint +"/"+ this.rootId+ "/" + this.lastId,
		dataType: 'json',
		timeout: 5000,
		success: function(){
			_this.update();
		},
		error: function(){
			_this.update();
		}
	});
	return true;
}

bug #1

Przy okazji natknąłem się na błąd wprowadzony przez moją nieuwagę. Możesz go zauważyć, kiedy w bazie pojawi się element o id równym 10 i większym. Okazuje się, że obiekt Thread zachowuje największe id, ale porównuje je w kolejności słownikowej (sprawdź w konsoli firebug: "10">"9" zwraca false). Proszę, popraw instrukcję "if" na samym końcu w ten sposób:

//...
		if(parseInt(v.Post.id)>_this.lastId){
			_this.lastId = parseInt(v.Post.id);
		}
}

Wywołaj teraz nowo utworzoną metodę w Thread.createThread():

Thread.prototype.createThread = function(){
	var _this = this;
	if(this.posts.length <1){
		return this;
	}
	this.rootId = this.lastId = posts[0].Post.parent_id;
	$(this.posts).each(function(k, v){
		if($("#"+v.Post.parent_id).find("ul").length == 0){
			if($("#"+v.Post.parent_id).find("div.reply").length == 0){
				_this.createReplyDiv().appendTo("#"+v.Post.parent_id);
			}
        	$("
    ", {class: "posts children"}).appendTo("#"+v.Post.parent_id); } $("
  • ", { id: v.Post.id, innerHTML: "
    "+v.Post.message+"
    ", class: "post child" }). append(_this.createReplyDiv()). appendTo("#"+v.Post.parent_id+">ul"); if(parseInt(v.Post.id)>_this.lastId){ _this.lastId = parseInt(v.Post.id); } }); this.update(); //<== return this; }

Możesz teraz sprawdzić w konsoli, czy raz za razem próbuje pobrać nowe dane z serwera. Dodaj post w wątku przy pomocy innej przeglądarki i sprawdź co dzieje się z zapytaniem...
Jak widzisz mamy pozytywną odpowiedź, ale od tego momentu każdy kolejny request dostaje tą samą odpowiedź a drzewo wątku nie jest aktualizowane. Poprawimy to teraz. Po pierwsze wyodrębnij jako metodę fragment kodu (z metody createThread), która tworzy kolejne elementy z wypowiedziami - możemy ją ponownie użyć...

Thread.createThread should look like that:

Thread.prototype.createThread = function(){
	if(this.posts.length <1){
		return this;
	}
	this.rootId = this.lastId = posts[0].Post.parent_id;
	var _this = this;
	$(this.posts).each(function(k, v){
		_this.addPost(v.Post.id, v.Post.parent_id, v.Post.message); //<==
	});
	this.update();
	return this;
};

oraz nasza nowa metoda Thread.addPost:

Thread.prototype.addPost = function(id, parentId, message){
	if($("#"+parentId).find("ul").length == 0){
		if($("#"+parentId).find("div.reply").length == 0){
			this.createReplyDiv().appendTo("#"+parentId);
		}
        $("
    ", {class: "posts children"}).appendTo("#"+parentId); } $("
  • ", { id: id, innerHTML: "
    "+message+"
    " , class: "post child" }). append(this.createReplyDiv()). appendTo("#"+parentId+">ul"); if(id>this.lastId){ this.lastId = id; } }

bug #2

Trafiłem na jeszcze bardziej zakamuflowany błąd - skrypt powoduje wyciek pamięci na serwerze (wystarczy poczekać odpowiednio długo, gdy "lecą" kolejne requesty) - po timoucie xhr'a skrypt po stronie serwera najwyraźniej się nie kończy.
Dodałem prosty licznik do metody get_new:

//controllers/posts.php
	function get_new($rootId, $lastMessageId){
		//...
		$counter = 2; //<==
		while(empty($posts)){
			$posts = $this->Post->find(
				"all",
				array(/*...*/)
			);
			if($counter>5){ //<==
				break; //<==
			}else{ //<==
				$counter += 2; //<==
			} //<==
			if(empty($posts)){
				sleep(2); // sleep for two seconds
			}
		}
		$this->set("posts", $posts);
		$this->render("get_new", '');
	}

Dlaczego inkrementuję o 2? Dlatego, że w pętli jest 2 sekundowy sleep(), zatem sprawdzam, czy skrypt "przespał" przynajmniej 5 sekund (co ma związek z 5 sekundowym timeoutem w żądaniu ajax).

Aby zamknąć sprawę - wywołaj wyodrębnioną metodę w zdarzeniu success:

//webroot/js/thread.js
Thread.prototype.update = function(){
	if(!this.updateEndpoint || !this.rootId || !this.lastId){
		alert("Thread corrupted");
		return false;
	}
	_this = this;
	$.ajax({
		url: this.updateEndpoint +"/"+ this.rootId+ "/" + this.lastId,
		dataType: 'json',
		timeout: 5000,
		success: function(data){ //<==
			$(data).each(function(k,v){
				_this.addPost(v.Post.id, v.Post.parent_id, v.Post.message); //<==
			});
			_this.update();
		},
		error: function(){
			_this.update();
		}
	});
	return true;
}

To tyle. Oczywiście przy pomocy long polling (i niedługo web sockets) możesz zaimplementować więcej fajnych funkcjonalności. Możesz naprzykład wyświetlać informację, że ktoś zaczął odpowiadać na konkretną wypowiedź w wątku.

Jest jeszcze kilka usprawnień, które warto w takim systemie dodać, jak choćby podświetlenie nowo dodanych odpowiedzi, ale wykracza to już poza tematykę tego tutoriala.

Mam nadzieję, że ten tutorial się wam spodobał, nie wahajcie się mi napisać o tym co o nim myślicie.

Share Button