Nette + Doctrine + ElasticSearch
- jasin755
- Člen | 116
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
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
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
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 :)
- F.Vesely
- Člen | 369
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. :)
- Filip Procházka
- Moderator | 4668
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;
}
}