Nette + Doctrine + ElasticSearch

Upozornění: Tohle vlákno je hodně staré a informace nemusí být platné pro současné Nette.
jasin755
Člen | 116
+
0
-

Zdravím,

snažím se implementovat ElasticSearch do Doctrine2, které používám na Nette. Prakticky nemůžu najít žádnou pořádnou dokumentaci. Nejdál jsem se dostal s Doctrine\Search.

Zatím testovacím způsobem volám

$entity_manager->persist($entity);
$search_manager->persist($entity)
$entity_manager->flush($entity);
$search_manager->flush($entity);

a vrátí se mi:

exception 'Elastica\Exception\ResponseException' with message 'MapperParsingException[failed to parse, document is empty]' in /home/www-data/kopecky/vendor/ruflin/elastica/lib/Elastica/Transport/Http.php:146
Filip Procházka
Moderator | 4668
+
0
-

Můžeš na vlastní riziko vyzkoušet work-in-progress integraci z Kdyby :)

"require": {
	"kdyby/doctrine": "dev-master",
	"kdyby/doctrine-search": "dev-master",
	"kdyby/elastic-search": "dev-master",
	"doctrine/search": "dev-devel",
	"jms/serializer": "~0.16",
},
"repositories": [
	{ "type": "vcs", "url": "https://github.com/fprochazka/doctrine-search.git" }
}

V configu pak

extensions:
	search: Kdyby\ElasticSearch\DI\SearchExtension
	doctrineSearch: Kdyby\DoctrineSearch\DI\DoctrineSearchExtension

doctrineSearch:
	defaultSerializer: jms
	# jeden ze serializerů https://github.com/doctrine/search/tree/master/lib/Doctrine/Search/Serializer
	# pro jms mám ale vlastní https://github.com/Kdyby/DoctrineSearch/blob/master/src/Kdyby/DoctrineSearch/Serializer/JMSSerializer.php

	serializers:
		Rohlikcz\Store\Grocery: Rohlikcz\Search\GrocerySearchSerializer
		# pokud chceš pro nějakou entitu custom serializer
		# tak tady napíšeš název entity a třídu s tím serializerem

	# a pak si normálně definuješ indexy
	indexes:
		rohlik:
			char_filter:
				punctuation:
					type: pattern_replace
					pattern: '[\:\.\&\+]'
					replacement: ' '


			filters:
				czech_stop:
					type: stop
					stopwords: [_czech_]


			analyzers:
				czech:
					type: custom
					tokenizer: standard
					char_filter: [punctuation]
					filter: [standard, lowercase, ...]

				default_index < czech:

				default_search < czech:

Mám tam i nějaký command pro consoli, třeba elastica:mapping:create, který projde metadata entit a vytvoří indexy a typy. A elastica:pipe-entities slouží pro prvotní naplnění indexu.


Ale jak říkám, byť to používáme na rohlik.cz, je to work in progress! :)

jasin755
Člen | 116
+
+1
-

Už jsem na to tvoje rozšíření díval. Kdyžtak vyzkouším a dám vědět :)

jasin755
Člen | 116
+
0
-

Mám dotaz, Kdyby\Doctrine nepoužívám, takže jsem se rozhodl to naimplementovat sám a mám to prakticky funkční, ale jedna věc se mi nelíbí. V subscriberu, potřebuji dostat instanci search_manageru (z Doctrine\Search), ale nevím jak. Když si ho předám pomocí DI, tak se mi to zacyklí přes entity_manager atd… Neřešil si tam něco podobného?

Filip Procházka
Moderator | 4668
+
+5
-

Prostě použij Kdyby/Doctrine (které nemění chování doctrine, pouze ji integruje do nette) a Kdyby/Events (které vyřeší tvůj problém se subscribery) a Kdyby/Console (umožní ti snadno používat doctrinní consolové commandy) a máš po problému :)

jasin755
Člen | 116
+
0
-

Narazil jsem celkem na problém. Používám PosgreSQL a nevím jak mám implementovat řazení podle score, abych se vyhnul nějakemu CASE v dotazech

Etruska
Člen | 25
+
0
-

Zdravím,
existuje nějaká dokumentace pro Kdyby/ElasticSearch nebo alespoň příklad použití? Odkaz na githubu nefunguje. Díky :)

F.Vesely
Člen | 369
+
0
-

Bohuzel neexistuje, taky jsem se tim prokousaval pred par tydny. Nicmene, stahni si to podle Filipova prikladu nahore pres composer, pote zaregistruj a nastav v cofigu. Akorat sekci indexes musis presunout do vlastnich configu, coz jsem tak nejak pochopil z kodu.

Ja to mam nasledovne:
confing.neon

extensions:
	search: Kdyby\ElasticSearch\DI\SearchExtension
	doctrineSearch: Kdyby\DoctrineSearch\DI\DoctrineSearchExtension

doctrineSearch:
    defaultSerializer: jms
	metadata:
		Project: %appDir%/libs/Project/elasticsearch

ve slozce %appDir%/libs/Project/elasticsearch pak mam
project.index.neon

charFilter:
    punctuation:
        type: pattern_replace
        pattern: '[\:\.\&\+]'
        replacement: ' '
filter:
    czech_stop:
        type: stop
        stopwords: [_czech_]
    czech_stemmer:
        type: czech_stem
analyzer:
    default:
        type: custom
        tokenizer: standard
        char_filter: [punctuation]
        filter: [standard, lowercase, czech_stop, asciifolding, czech_stem]
    name_czech:
        type: custom
        tokenizer: standard
        char_filter: [punctuation]
        filter: [standard, lowercase, asciifolding, czech_stem]
    default_index < default:
    default_search < default:

a

entity.type.neon

class: Project\Entities\Entity
index: project
properties:
    id:
        type: integer
        includeInAll: false
    name:
        type: string
        includeInAll: true
        boost: 3.0
        analyzer: name_czech
    content:
        type: string
        includeInAll: true

Snad ti to pomuze, mozna jsem delal jeste nejake upravy, ale uz si nepamatuji. :)

Etruska
Člen | 25
+
0
-

Díky moc, určitě to pomůže :) Ale myslíš, že bys mi mohl poslat i nástin použití? Nějak mi zatím není jasné, jak funguje indexace a co přesně injektnout…

Filip Procházka
Moderator | 4668
+
+6
-

Samotnej Kdyby/Elasticsearch se používá úplně maximálně primitivně

extensions:
    search: Kdyby\ElasticSearch\DI\SearchExtension

A máš vyděláno :) Pak si jenom kdekoliv v aplikaci vyžádáš Kdyby\ElasticSearch\Client (nebo Elastica\Client, Kdyby od toho dědí a přidává eventy pro panel) a funguješ…

Pokud bys potřeboval něco extra fancy, tak můžeš změnit nastavení připojení

search:
	host: 127.0.0.1
	# ostatní volby najdeš ve třídě SearchExtension

Kdyby/DoctrineSearch je pořád mezi alpha a beta… máme to na produkci ale negarantuju zpětnou kompatibilitu.

Co se týče integrace s Doctrine, zjistili jsme, že nám dost nevyhovuje používat na všechno ten JMS serializer. Je to dobrý když prototypuješ, ale na serióznější použití je lepší napsat si vlastní… A maličko jsem se v tom rejpal, takže aktualizovaná verze použití:

composer.json

{
	"require": {
		"kdyby/doctrine": "~3.0@dev",
		"kdyby/doctrine-search": "dev-master",
		"kdyby/elastic-search": "dev-master",
		"doctrine/search": "dev-devel",
		"jms/serializer": "~0.16"
	},
	"repositories": [
		{ "type": "vcs", "url": "https://github.com/fprochazka/doctrine-search.git" }
	]

Je to trochu oser to nainstalovat… je to kvůli tomu že jsme rychle potřebovali funkční verzi a neměl jsem čas hrát si s detaily. V současnosti se rozhoduju jestli se snažit protlačit ty změny do doctrine/search (což bych asi měl) nebo to forknout.

config:

	doctrineSearch:
		defaultSerializer: jms
		serializers:
			Rohlikcz\Products\Product: Rohlikcz\Products\Search\ProductSerializer
		metadata:
			Rohlikcz\Products: %libsDir%/Rohlikcz/Products/Search/Resources

Nastavení indexů a typů se strašně snadno roztáhne, přistoup jsem tedy na Doctrine-way variantu kdy všechno je hezky rozdělené do několika souborů

Rohlikcz/Products/Search/Resources/rohlik.index.neon

number_of_shards: 1
number_of_replicas: 1

charFilter:
	punctuation:
		type: pattern_replace
		pattern: '[\:\.\&\+]'
		replacement: ' '


filter:
	cs_CZ:
		type: hunspell
		locale: cs_CZ

	# ...


analyzer:
	czech:
		type: custom
		tokenizer: standard
		char_filter: [punctuation, doubled_letters]
		filter: [standard, lowercase, synonym, czech_stop, cs_CZ, asciifolding]

	default_index < czech: # ano, umí to dědit nastavení, přece to nebudu kopírovat!

	default_search < czech:

Rohlikcz/Products/Search/Resources/product.type.neon

index: rohlik
class: Rohlikcz\Products\Product
source: true
properties:
	id:
		type: integer
		includeInAll: true

	name:
		type: string
		analyzer: standard
		includeInAll: true
		boost: 15

	# ...

parameters:

Na psaní vlastního serializeru je nejlepší, že si v něm můžu položit velice efektivní query místo počítání nad kolekcemi a tím pádem si můžu například k produktu uložit kolikrát si ho někdo koupil, něco s hodnocením atd. A taky je to o něco pohodlnější než „programovat v annotacích“ :)

class ProductSerializer extends Nette\Object implements SerializerInterface
{
	private $em;

	public function __construct(\Kdyby\Doctrine\EntityManager $em)
	{
		$this->em = $em;
	}

	public function serialize($object)
	{
		if (!$object instanceof Product) {
			throw new \InvalidArgumentException;
		}

		$data = [
			'id' => $object->getId(),
			'name' => (string) $object->getName(),
			# ...
		];

		# ...

		return Json::encode($data);
	}

	public function deserialize($entityName, $data)
	{
		if (!isset($data['id'])) {
			throw new \InvalidArgumentException;
		}

		return $this->em->getReference(Product::class, $data['id']);
	}

}

A když pak pokládám dotaz, tak to vypadá nějak takto

$search = (new SearchQuery($userQuery))
	->setPaginator($paginator)
	->createQuery($this->search, $this->entityManager);

try {
	$this->template->products = $search->getResult();
	$paginator->setItemCount($search->count());

} catch (Elastica\Exception\ResponseException $e) {
	$this->template->products = [];

	if (preg_match('~Failed to execute phase~i', $e->getMessage())) {
		$this->logger->addNotice(sprintf('Invalid query: "%s"', $userQuery), ['channel' => 'search']);

	} else {
		$this->logger->addWarning($e->getMessage(), ['channel' => 'search']);
	}
}
use Elastica\Filter;
use Elastica\Query;
use Elastica\Util;

class SearchQuery
{

	private $query;

	private $paginator = NULL;

	public function __construct($query)
	{
		$this->query = Util::escapeTerm($query);
	}

	public function setPaginator(Paginator $paginator)
	{
		$this->paginator = $paginator;
		return $this;
	}

	public function createQuery(SearchManager $searchManager, EntityManager $entityManager)
	{
		$filtered = new Query\Filtered($this->createSearchQuery(), $this->createSearchFilter());

		$searchWith = (new Query($filtered))
			->addSort(['available' => ['order' => 'desc']])
			->addSort(['_score' => ['order' => 'desc']])
			->setParam('_source', FALSE);

		$hydrateWith = $this->createHydratingQuery($entityManager);

		$query = $searchManager->createQuery()
			->searchWith($searchWith)
			->hydrateWith($hydrateWith);

		if ($this->paginator !== NULL) {
			$query->setMaxResults($this->paginator->getLength());
			$query->setFirstResult($this->paginator->getOffset());
		}

		return $query;
	}

	private function createSearchQuery()
	{
		$search = (new Query\QueryString($this->query))
			->setDefaultOperator('AND')
			->setBoost(3);

		$name = (new Query\Match())
			->setFieldQuery('name', $this->query)
			->setFieldOperator('name', 'AND')
			->setFieldBoost('name', 10);

		return (new Query\Bool())
			->addShould($search)
			->addShould($name);
	}

	private function createSearchFilter()
	{
		$filter = (new Filter\Bool())
			->addMust(new Filter\Term(['enable' => TRUE]));

		return $filter;
	}

	private function createHydratingQuery(EntityManager $entityManager)
	{
		$products = $entityManager->getRepository(Product::class);

		$qb = $products->createQueryBuilder('p')
			->addSelect('FIELD(p.id, :ids) as HIDDEN relevance')
			->andWhere('p.id IN (:ids)');

		return $qb;
	}
}
Etruska
Člen | 25
+
0
-

Děkuji :)