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

Leave a Reply

Your email address will not be published. Required fields are marked *