Jak se praví v dokumentaci, Zend_Search_Lucene je obecný textový vyhledávací engine napsaný pouze v PHP5. Praktické využití pro většinu webů – fulltextové vyhledávání. Osobně jsem si zvykl spíš používat Google a jeho site:domain.tld. Ale mít vlastní fulltextové vyhledávání je lepší, jelikož Google mě nemusí zas tak rychle zaindexovat a uživatelé jsou na zvyklí.
Zend je celkem moloch, takže nedoporučuji stahovat celý archiv, ale namísto toho využít jen SVN repozitář a provést checkout pouze potřebných adresářů a souborů:
$ mkdir -p lib/Zend
$ svn checkout \
http://framework.zend.com/svn/framework/standard/trunk/library/Zend/Search \
lib/Zend/Search
$ svn cat \
http://framework.zend.com/svn/framework/standard/trunk/library/Zend/Exception.php \
> lib/Zend/Exception.php
$ find lib/ -name ".svn" | xargs rm -rf
Zend potřebuje, aby byla správně nastavena include_path. Je-li framework umístěn v podadresáři lib/, pak je potřeba include_path nastavit nějak takto:
set_include_path(dirname(__FILE__) . DIRECTORY_SEPARATOR . 'lib' .
PATH_SEPARATOR . get_include_path());
Ještě než se člověk pustí do práce s indexem, je potřeba nastavit locale. Tohle mě docela potrápilo. Na localhostu všechno fungovalo, jak by mělo, ale na produkčním serveru se výsledky hledání nějak podivně omezily jen na pár věcí – jako by se nezaindexovalo posledních pár slov. Zkusil jsem vše přeindexovat asi 3krát, ale pořád to samé. Kdyby mě rovnou napadlo se podívat do chybového logu, mohl jsem serveru ušetřit hodně procesorového času. Nejdříve jsem si logu vůbec nevšímal, ale jak dosáhl velikosti někde kolem 3 MiB, přilákal tím tak trochu mou pozornost. Problém byl v jiném nastavení locale u mě doma a na produkčním serveru, takže iconv() skončilo s Notice o nepovoleném znaku, čímž se zpracovala jen část řetězce. Doma jsem měl nastaveno UTF-8, na produkčním je asi ISO-8859-2, takže setlocale():
setlocale('cs_CZ.UTF-8');
Fulltext jsem potřeboval pro jednu instanci Shopaholic. Nakonec jsem se rozhodl, že indexovat se budou akorát produkty – u e-shopu lidi stejně nic jiného nezajímá (alespoň doufám). (Jelikož jsem na této instanci provedl ještě nějaké úpravy, které by nebylo dobré zahrnout do hlavní větve, bude chvilku trvat, než se fulltext dostane do repozitáře na GitHubu.)
Celé indexování se Zend_Search_Lucene probíhá ve vytváření dokumentů a jejich ukládání do indexu. Každý dokument obsahuje různá pole, pomocí kterých se dá vyhledávat. Jedno pole je pro vyhledávání výchozí, a tak se při psaní dotazu nemusí uvádět jeho jméno.
Pole jsou různých typů a mají různé vlastnosti. Pro aplikace, kde slouží jako persistentní úložiště databáze, je podle mě nejlepší zvolit pro všechna pole typ UnStored – bylo by zbytečné data z databáze ještě replikovat v indexu. Jediné, co Shopaholic ukládá do indexu, je ID produktu, podle kterého se poté při vyhledávání podnikne dotaz na databázi a vyberou skutečná data.
Když se na to podíváme z toho pohledu, že data jsou v databázi, pak možnosti indexování jednotlivých polí ztrácí význam. Řekněme, že název produktu je ukládán do pole name výrobce do manufacturer a popis produktu do description. Jako výchozí se zvolí pole description. Ale teď chce zákazník vyhledat všechny produkty od výrobce foobar. Aby tak mohl učinit, musel by do vyhledávacího políčka zadat manufacturer:foobar. Ale kdo tohle udělá? Zákazník chce prostě zadat foobar a mít výrobky od společnosti foobar.
Takže všechny informace, které by mohl chtít zákazník hledat, je nakonec potřeba dát do jednoho pole a to nastavit jako výchozí. Zvažuji, jestli do hlavní větve ponechat i další pole (kvůli pokročilejším dotazům), nebo se jich úplně zbavit a nechat jen jedno jediné? Spíš zbavit, Shopaholic má směřovat k co největší jednoduchosti – easy yet powerful, a ne powerful yet easy (protože to stejně nikdy pak easy není).
Potom tu vyvstává problém, jak aktualizovat index, když se některý z produktů změní. Jsou dvě možnosti, každá má svá pro a proti:
-
aktualizovat index hned při jakékoli změně produktu
-
vyčlenit změny zvlášť
První možnost přidá, a to docela dost, na čase všech operací, takže práce s produkty je pomalejší. Ale zase zachovává index neustále aktuální a zákazník vždycky najde to, co je právě teď v databázi. U druhého řešení je to naopak – práce s produkty je plynulejší, ale zákazník nemusí vždy najít to, co vlastně chtěl, protože se to mezitím mohlo změnit. A taktéž přidává nutnost do rozhraní nějak tu volbu k aktualizaci fulltextu zapracovat. Vzhledem k importu nakonec vyhrála druhá možnost.
Jak ukládat informace o tom, že produkt změnil? Databázové schéma postrádá informace o tom, kdy byl produkt vložen, či upraven (jaké to špatné mé návrhové rozhodnutí!), takže tudy cesta nevede. I když teď mě napadá, že by se to dalo sledovat pomocí změn cen – při každém ukládání produktu je zároveň zapsána cena, s kterou je v ten okamžik ukládán. Proč mě to nenapadlo dříve?
Vyřešil jsem to tedy jinak – bitmapou (a teď myslím tu bitmapu, tu strukturu, ne obrázek) ukládanou do souboru. Kód na konci článku.
Zend_Search_Lucene mě potěšilo – jak svou jednoduchostí, tak rychlostí. Originál Lucene je asi rychlejší, ale na PHP to podle mě vůbec není špatné. Taky mě to utvrdilo v tom, že Zend má své dobré části – Zend_Search_Lucene, různá API –, ale celkově je to moloch a jako základ k psaní aplikací bych ho nepoužil.
function bitmap($filename)
{
// get all true
if (func_num_args() === 1) {
if (($data = file_get_contents($filename)) === FALSE) return NULL;
$bits = array();
for ($i = 0, $len = strlen($data); $i < $len; ++$i) {
$byte = ord($data{$i});
for ($j = 0; $j < 8; $j++)
if ($byte & (1 << (7 - $j))) $bits[] = $i * 8 + $j;
}
return $bits;
// get/set bit
} else {
$bit = intval(func_get_arg(1));
$set = NULL;
if (func_num_args() > 2) $set = func_get_arg(2);
// open file
if (!($handle = @fopen($filename, 'r+b'))) return NULL;
if (!flock($handle, $set === NULL ? LOCK_SH : LOCK_EX)) {
fclose($handle);
return NULL;
}
// read byte with needed bit
$offset = intval(floor($bit / 8));
$byte_offset = 7 - ($bit % 8);
if (fseek($handle, $offset, SEEK_SET) === -1) {
fclose($handle);
return NULL;
}
$byte = ord(fread($handle, 1));
// get
if ($set === NULL) {
fclose($handle);
return (bool) ($byte & (1 << $byte_offset));
}
// set
$byte = ($a = ($byte >> ($byte_offset + 1) << ($byte_offset + 1))) |
$byte & ~($a + (1 << $byte_offset)) |
intval((bool) $set) << $byte_offset;
if (fseek($handle, $offset, SEEK_SET) === -1) {
fclose($handle);
return NULL;
}
if (fwrite($handle, chr($byte), 1) !== 1) {
fclose($handle);
return NULL;
}
fflush($handle);
fclose($handle);
return TRUE;
}
}