Elasticsearch fournit une interface HTTP puissante et RESTful pour l'indexation et l'interrogation des données, construite au-dessus du Apache Lucene bibliothèque. Dès la sortie de la boîte, il fournit une recherche évolutive, efficace et robuste, avec prise en charge UTF-8. C'est un outil puissant pour indexer et interroger d'énormes quantités de données structurées et, ici à ApeeScape , il alimente notre recherche de plate-forme et sera bientôt également utilisé pour la saisie semi-automatique. Nous sommes de grands fans.
Chewy étend le client Elasticsearch-Ruby, le rendant plus puissant et offrant une intégration plus étroite avec Rails.Puisque notre plateforme est construite en utilisant Rubis sur rails , notre intégration d'Elasticsearch tire parti de la elasticsearch-rubis projet (un cadre d'intégration Ruby pour Elasticsearch qui fournit un client pour se connecter à un cluster Elasticsearch, une API Ruby pour l'API REST d'Elasticsearch et diverses extensions et utilitaires). Sur cette base, nous avons développé et publié notre propre amélioration (et simplification) de l'architecture de recherche d'applications Elasticsearch, présentée sous la forme d'un joyau Ruby que nous avons nommé Moelleux (avec un exemple d'application disponible Ici ).
Chewy étend le client Elasticsearch-Ruby, le rendant plus puissant et offrant une intégration plus étroite avec Rails. Dans ce guide Elasticsearch, j'explique (à travers des exemples d'utilisation) comment nous y sommes parvenus, y compris les obstacles techniques apparus lors de la mise en œuvre.
Quelques remarques rapides avant de passer au guide:
Malgré l'évolutivité et l'efficacité d'Elasticsearch, son intégration à Rails ne s'est pas avérée aussi simple que prévu. Chez ApeeScape, nous avons dû augmenter considérablement le client Elasticsearch-Ruby de base pour le rendre plus performant et prendre en charge des opérations supplémentaires.
Et ainsi, le joyau Chewy est né.
Quelques caractéristiques particulièrement remarquables de Chewy incluent:
Chaque indice est observable par tous les modèles associés.
La plupart des modèles indexés sont liés les uns aux autres. Et parfois, il est nécessaire de dénormaliser ces données associées et de les lier au même objet (par exemple, si vous souhaitez indexer un tableau de balises avec leur article associé). Chewy vous permet de spécifier un index modifiable pour chaque modèle, de sorte que les articles correspondants seront réindexés chaque fois qu'une balise pertinente est mise à jour.
Les classes d'index sont indépendantes des modèles ORM / ODM.
Avec cette amélioration, la mise en œuvre de la saisie semi-automatique inter-modèles, par exemple, est beaucoup plus facile. Vous pouvez simplement définir un index et travailler avec lui de manière orientée objet. Contrairement à d'autres clients, la gemme Chewy supprime le besoin d'implémenter manuellement les classes d'index, les rappels d'importation de données et d'autres composants.
L'importation groupée est partout .
Chewy utilise l'API Elasticsearch en masse pour une réindexation complète et des mises à jour d'index. Il utilise également le concept de mises à jour atomiques, collectant les objets modifiés dans un bloc atomique et les mettant à jour tous en même temps.
Chewy fournit un DSL de requête de style AR.
En étant chaînable, fusionnable et paresseux, cette amélioration permet aux requêtes d'être produites de manière plus efficace.
OK, voyons comment tout cela se joue dans la gemme…
comment trouver un développeur de logiciel
Elasticsearch a plusieurs concepts liés aux documents. Le premier est celui d'un index
(l'analogue de a database
dans SGBDR ), qui consiste en un ensemble de documents
, qui peut être de plusieurs types
(où a type
est une sorte de table SGBDR).
Chaque document a un ensemble de fields
. Chaque champ est analysé indépendamment et ses options d'analyse sont stockées dans le répertoire mapping
pour son type. Chewy utilise cette structure «telle quelle» dans son modèle d'objet:
class EntertainmentIndex { author.name } field :author_id, type: 'integer' field :description field :tags, index: 'not_analyzed', value: ->{ tags.map(&:name) } end {movie: Video.movies, cartoon: Video.cartoons}.each do |type_name, scope| define_type scope.includes(:director, :tags), name: type_name do field :title, analyzer: 'title' field :year, type: 'integer' field :author, value: ->{ director.name } field :author_id, type: 'integer', value: ->{ director_id } field :description field :tags, index: 'not_analyzed', value: ->{ tags.map(&:name) } end end end
Ci-dessus, nous avons défini un index Elasticsearch appelé entertainment
avec trois types: book
, movie
et cartoon
. Pour chaque type, nous avons défini des mappages de champs et un hachage des paramètres pour tout l'index.
Donc, nous avons défini le EntertainmentIndex
et nous voulons exécuter certaines requêtes. Dans un premier temps, nous devons créer l'index et importer nos données:
EntertainmentIndex.create! EntertainmentIndex.import # EntertainmentIndex.reset! (which includes deletion, # creation, and import) could be used instead
Le .import
La méthode est consciente des données importées car nous avons passé des étendues lorsque nous avons défini nos types; ainsi, il importera tous les livres, films et dessins animés stockés dans le stockage persistant.
Cela fait, nous pouvons effectuer quelques requêtes:
EntertainmentIndex.query(match: {author: 'Tarantino'}).filter{ year > 1990 } EntertainmentIndex.query(match: {title: 'Shawshank'}).types(:movie) EntertainmentIndex.query(match: {author: 'Tarantino'}).only(:id).limit(10).load # the last one loads ActiveRecord objects for documents found
Maintenant, notre index est presque prêt à être utilisé dans notre implémentation de recherche.
Pour l'intégration avec Rails, la première chose dont nous avons besoin est de pouvoir réagir aux changements d'objet du SGBDR. Chewy prend en charge ce comportement via des callbacks définis dans le update_index
méthode de classe. update_index
prend deux arguments:
'index_name#type_name'
formatNous devons définir ces rappels pour chaque modèle dépendant:
class Book Étant donné que les balises sont également indexées, nous devons ensuite effectuer un patch de certains modèles externes afin qu'ils réagissent aux changements:
ActsAsTaggableOn::Tag.class_eval do has_many :books, through: :taggings, source: :taggable, source_type: 'Book' has_many :videos, through: :taggings, source: :taggable, source_type: 'Video' # Updating all tag-related objects update_index 'entertainment#book', :books update_index('entertainment#movie') { videos.movies } update_index('entertainment#cartoon') { videos.cartoons } end ActsAsTaggableOn::Tagging.class_eval do # Same goes for the intermediate model update_index('entertainment#book') { taggable if taggable_type == 'Book' } update_index('entertainment#movie') { taggable if taggable_type == 'Video' && taggable.movie? } update_index('entertainment#cartoon') { taggable if taggable_type == 'Video' && taggable.cartoon? } end
À ce stade, chaque objet enregistrer ou détruire mettra à jour le type d'index Elasticsearch correspondant.
Atomicité
Nous avons encore un problème persistant. Si nous faisons quelque chose comme books.map(&:save)
pour enregistrer plusieurs livres, nous demanderons une mise à jour de entertainment
indice chaque fois qu'un livre individuel est enregistré . Ainsi, si nous sauvegardons cinq livres, nous mettrons à jour l’index Chewy cinq fois. Ce comportement est acceptable pour REPL , mais certainement pas acceptable pour les actions du contrôleur dans lesquelles les performances sont essentielles.
Nous abordons ce problème avec le Chewy.atomic
bloquer:
class ApplicationController En bref, Chewy.atomic
regroupe ces mises à jour comme suit:
- Désactive le
after_save
rappeler. - Collecte les ID des livres enregistrés.
- À la fin de
Chewy.atomic
block, utilise les ID collectés pour effectuer une seule demande de mise à jour d'index Elasticsearch.
Recherche
Nous sommes maintenant prêts à mettre en œuvre une interface de recherche. Puisque notre interface utilisateur est un formulaire, la meilleure façon de la construire est, bien sûr, avec FormBuilder et ActiveModel . (Chez ApeeScape, nous utilisons ActiveData pour implémenter les interfaces ActiveModel, mais n'hésitez pas à utiliser votre gemme préférée.)
class EntertainmentSearch include ActiveData::Model attribute :query, type: String attribute :author_id, type: Integer attribute :min_year, type: Integer attribute :max_year, type: Integer attribute :tags, mode: :arrayed, type: String, normalize: ->(value) { value.reject(&:blank?) } # This accessor is for the form. It will have a single text field # for comma-separated tag inputs. def tag_list= value self.tags = value.split(',').map(&:strip) end def tag_list self.tags.join(', ') end end
Didacticiel sur les requêtes et les filtres
Maintenant que nous avons un objet de type ActiveModel qui peut accepter et transposer des attributs, implémentons la recherche:
class EntertainmentSearch ... def index EntertainmentIndex end def search # We can merge multiple scopes [query_string, author_id_filter, year_filter, tags_filter].compact.reduce(:merge) end # Using query_string advanced query for the main query input def query_string index.query(query_string: {fields: [:title, :author, :description], query: query, default_operator: 'and'}) if query? end # Simple term filter for author id. `:author_id` is already # typecasted to integer and ignored if empty. def author_id_filter index.filter(term: {author_id: author_id}) if author_id? end # For filtering on years, we will use range filter. # Returns nil if both min_year and max_year are not passed to the model. def year_filter body = {}.tap do |body| body.merge!(gte: min_year) if min_year? body.merge!(lte: max_year) if max_year? end index.filter(range: {year: body}) if body.present? end # Same goes for `author_id_filter`, but `terms` filter used. # Returns nil if no tags passed in. def tags_filter index.filter(terms: {tags: tags}) if tags? end end
Contrôleurs et vues
À ce stade, notre modèle peut effectuer des requêtes de recherche avec des attributs passés. L'utilisation ressemblera à quelque chose comme:
EntertainmentSearch.new(query: 'Tarantino', min_year: 1990).search
Notez que dans le contrôleur, nous voulons charger des objets ActiveRecord exacts au lieu de Moelleux wrappers de documents:
class EntertainmentController Maintenant, il est temps d’en rédiger HAML à entertainment/index.html.haml
:
= form_for @search, as: :search, url: entertainment_index_path, method: :get do |f| = f.text_field :query = f.select :author_id, Dude.all.map d, include_blank: true = f.text_field :min_year = f.text_field :max_year = f.text_field :tag_list = f.submit - if @entertainments.any? %dl - @entertainments.each do |entertainment| %dt %h1= entertainment.title %strong= entertainment.class %dd %p= entertainment.year %p= entertainment.description %p= entertainment.tag_list = paginate @entertainments - else Nothing to see here
Tri
En prime, nous ajouterons également le tri à notre fonctionnalité de recherche.
Supposons que nous ayons besoin de trier sur les champs de titre et d'année, ainsi que par pertinence. Malheureusement, le titre One Flew Over the Cuckoo's Nest
sera divisé en termes individuels, donc le tri selon ces termes disparates sera trop aléatoire; à la place, nous aimerions trier par le titre entier.
La solution est d'utiliser un champ de titre spécial et d'appliquer son propre analyseur:
class EntertainmentIndex En outre, nous allons ajouter ces nouveaux attributs et l'étape de traitement du tri à notre modèle de recherche:
class EntertainmentSearch # we are going to use `title.sorted` field for sort SORT = {title: {'title.sorted' => :asc}, year: {year: :desc}, relevance: :_score} ... attribute :sort, type: String, enum: %w(title year relevance), default_blank: 'relevance' ... def search # we have added `sorting` scope to merge list [query_string, author_id_filter, year_filter, tags_filter, sorting].compact.reduce(:merge) end def sorting # We have one of the 3 possible values in `sort` attribute # and `SORT` mapping returns actual sorting expression index.order(SORT[sort.to_sym]) end end
Enfin, nous allons modifier notre formulaire en ajoutant la boîte de sélection des options de tri:
= form_for @search, as: :search, url: entertainment_index_path, method: :get do |f| ... / `EntertainmentSearch.sort_values` will just return / enum option content from the sort attribute definition. = f.select :sort, EntertainmentSearch.sort_values ...
La gestion des erreurs
Si vos utilisateurs effectuent des requêtes incorrectes telles que (
ou AND
, le client Elasticsearch générera une erreur. Pour gérer cela, apportons quelques modifications à notre contrôleur:
class EntertainmentController e @entertainments = [] @error = e.message.match(/QueryParsingException[([^;]+)]/).try(:[], 1) end end
De plus, nous devons rendre l'erreur dans la vue:
... - if @entertainments.any? ... - else - if @error = @error - else Nothing to see here
Test des requêtes Elasticsearch
La configuration de base du test est la suivante:
- Démarrez le serveur Elasticsearch.
- Nettoyez et créez nos index.
- Importez nos données.
- Exécutez notre requête.
- Croisez le résultat avec nos attentes.
Pour l'étape 1, il est pratique d'utiliser le cluster de test défini dans le elasticsearch-extensions gemme. Ajoutez simplement la ligne suivante à votre projet Rakefile
installation post-gemme:
require 'elasticsearch/extensions/test/cluster/tasks'
Ensuite, vous obtiendrez ce qui suit Râteau Tâches:
$ rake -T elasticsearch rake elasticsearch:start # Start Elasticsearch cluster for tests rake elasticsearch:stop # Stop Elasticsearch cluster for tests
Elasticsearch et Rspec
Tout d'abord, nous devons nous assurer que notre index est mis à jour pour être synchronisé avec nos modifications de données. Heureusement, le joyau Chewy est livré avec le update_index
rspec allumettes:
describe EntertainmentIndex do # No need to cleanup Elasticsearch as requests are # stubbed in case of `update_index` matcher usage. describe 'Tag' do # We create several books with the same tag let(:books) { create_list :book, 2, tag_list: 'tag1' } specify do # We expect that after modifying the tag name... expect do ActsAsTaggableOn::Tag.where(name: 'tag1').update_attributes(name: 'tag2') # ... the corresponding type will be updated with previously-created books. end.to update_index('entertainment#book').and_reindex(books, with: {tags: ['tag2']}) end end end
Ensuite, nous devons tester que les requêtes de recherche réelles sont correctement effectuées et qu'elles renvoient les résultats attendus:
describe EntertainmentSearch do # Just defining helpers for simplifying testing def search attributes = {} EntertainmentSearch.new(attributes).search end # Import helper as well def import *args # We are using `import!` here to be sure all the objects are imported # correctly before examples run. EntertainmentIndex.import! *args end # Deletes and recreates index before every example before { EntertainmentIndex.purge! } describe '#min_year, #max_year' do let(:book) { create(:book, year: 1925) } let(:movie) { create(:movie, year: 1970) } let(:cartoon) { create(:cartoon, year: 1995) } before { import book: book, movie: movie, cartoon: cartoon } # NOTE: The sample code below provides a clear usage example but is not # optimized code. Something along the following lines would perform better: # `specify { search(min_year: 1970).map(&:id).map(&:to_i) # .should =~ [movie, cartoon].map(&:id) }` specify { search(min_year: 1970).load.should =~ [movie, cartoon] } specify { search(max_year: 1980).load.should =~ [book, movie] } specify { search(min_year: 1970, max_year: 1980).load.should == [movie] } specify { search(min_year: 1980, max_year: 1970).should == [] } end end
Dépannage du cluster de test
Enfin, voici un guide de dépannage de votre cluster de test:
-
Pour commencer, utilisez un cluster à un nœud en mémoire. Ce sera beaucoup plus rapide pour les spécifications. Dans notre cas: TEST_CLUSTER_NODES=1 rake elasticsearch:start
-
Il existe des problèmes avec le elasticsearch-extensions
tester la mise en œuvre du cluster elle-même liée à la vérification de l'état du cluster à un nœud (elle est jaune dans certains cas et ne sera jamais verte, de sorte que la vérification de démarrage du cluster de statut vert échoue à chaque fois). Le problème a été résolu dans un fork, mais j'espère qu'il sera bientôt résolu dans le repo principal.
conduite pour uber vs lyft 2016
-
Pour chaque ensemble de données, regroupez votre demande dans les spécifications (c'est-à-dire importez vos données une fois, puis effectuez plusieurs demandes). Elasticsearch se réchauffe pendant longtemps et utilise beaucoup de mémoire lors de l'importation des données, alors n'en faites pas trop, surtout si vous avez un tas de spécifications.
-
Assurez-vous que votre machine dispose de suffisamment de mémoire ou Elasticsearch gèlera (nous avons requis environ 5 Go pour chaque machine virtuelle de test et environ 1 Go pour Elasticsearch lui-même).
Emballer
Elasticsearch se décrit comme «un moteur de recherche et d'analyse open source, distribué et en temps réel flexible et puissant». C'est la référence en matière de technologies de recherche.
Avec Chewy, notre développeurs de rails ont regroupé ces avantages sous la forme d'un joyau Ruby open source simple, facile à utiliser, de qualité production, qui offre une intégration étroite avec Rails. Elasticsearch et Rails - quelle combinaison géniale!
Elasticsearch et Rails - quelle combinaison géniale! Tweet
Annexe: Internes Elasticsearch
Voici un très brève introduction à Elasticsearch «sous le capot»…
Elasticsearch repose sur Lucène , qui lui-même utilise indices inversés comme structure de données principale. Par exemple, si nous avons les chaînes «les chiens sautent haut», «sautent par-dessus la clôture» et «la clôture était trop haute», nous obtenons la structure suivante:
'the' [0, 0], [1, 2], [2, 0] 'dogs' [0, 1] 'jump' [0, 2], [1, 0] 'high' [0, 3], [2, 4] 'over' [1, 1] 'fence' [1, 3], [2, 1] 'was' [2, 2] 'too' [2, 3]
Ainsi, chaque terme contient à la fois des références et des positions dans le texte. De plus, nous choisissons de modifier nos conditions (par exemple, en supprimant des mots vides comme «le») et d'appliquer hachage phonétique à chaque terme (pouvez-vous deviner l'algorithme ?):
'DAG' [0, 1] 'JANP' [0, 2], [1, 0] 'HAG' [0, 3], [2, 4] 'OVAR' [1, 1] 'FANC' [1, 3], [2, 1] 'W' [2, 2] 'T' [2, 3]
Si nous recherchons ensuite «le chien saute», il est analysé de la même manière que le texte source, devenant «DAG JANP» après le hachage («chien» a le même hachage que «chiens», comme c'est le cas avec «sauts» et 'sauter').
Nous ajoutons également une certaine logique entre les mots individuels de la chaîne (en fonction des paramètres de configuration), en choisissant entre («DAG» ET «JANP») ou («DAG» OU «JANP»). Le premier renvoie l'intersection de [0] & [0, 1]
(c'est-à-dire le document 0) et ce dernier, [0] | [0, 1]
(c'est-à-dire les documents 0 et 1). Les positions dans le texte peuvent être utilisées pour évaluer les résultats et les requêtes dépendant de la position.