Recherche géospatiale dans Rails avec Elasticsearch

Dans ce tutoriel, je vais créer une petite application Rails. Je vais vous montrer comment créer une tâche de rake pour importer des sites de Foursquare dans notre base de données. Ensuite, nous les indexerons sur Elasticsearch. De plus, l’emplacement de chaque site sera indexé, ce qui nous permettra d’effectuer une recherche par distance..

Rake Task pour importer des lieux Foursquare

Une tâche de rake n'est qu'un script ruby ​​que nous pouvons exécuter manuellement ou régulièrement si nous devons effectuer certaines tâches en arrière-plan, pour la maintenance par exemple. Dans notre cas, nous allons l'exécuter manuellement. Nous allons avoir besoin d’une nouvelle application de rails et de quelques modèles pour enregistrer dans notre base de données, les sites que nous allons importer de Foursquare. Commençons par créer une nouvelle application rails, tapez donc dans votre console:

$ rails new elasticsearch-rails-geolocation

Je vais créer deux modèles: un lieu et une catégorie, en utilisant des générateurs de rails. Pour créer le modèle de site, tapez votre terminal:

$ rails g nom du lieu modèle: chaîne adresse: chaîne pays: chaîne latitude: float longitude: float

Tapez la commande suivante pour générer le modèle Category:

$ rails g nom de la catégorie de modèle: string venue: références

La relation de lieu en catégorie est multiple. Par exemple, si nous importons un restaurant italien, il peut contenir les catégories "Italien" et "Restaurant", mais d'autres sites peuvent également contenir les mêmes catégories. Pour définir les relations nombreuses à multiples des lieux aux catégories, nous utilisons le has_and_belongs_to_many méthode d'enregistrement actif, car nous n'avons pas d'autres propriétés qui appartiennent à la relation. Nos modèles ressemblent à ceci maintenant:

lieu de classe < ActiveRecord::Base has_and_belongs_to_many :categories end class Category < ActiveRecord::Base has_and_belongs_to_many :venues end

Nous devons maintenant créer la table de jointure pour la relation. Il stockera la liste de 'venue_id, category_id' pour les relations. Pour générer cette table, exécutez la commande suivante dans votre terminal:

$ rails génère la migration CreateJoinTableVenueCategory catégorie de lieu

Si nous examinons la migration générée, nous pouvons vérifier que la bonne table pour la relation est créée:

classe CreateJoinTableVenueCategory < ActiveRecord::Migration def change create_join_table :venues, :categories do |t| # t.index [:venue_id, :category_id] # t.index [:category_id, :venue_id] end end end

Pour créer réellement la table dans la base de données, n'oubliez pas d'exécuter la migration en exécutant la commande bin / rake db: migrer dans votre terminal.

Pour importer les sites depuis foursquare, nous devons créer une nouvelle tâche Rake. Rails a également un générateur pour les tâches, il vous suffit donc de taper votre terminal:

$ rails g lieux d'importation de tâches

Si vous ouvrez le nouveau fichier créé dans lib / tasks / import.rake, vous pouvez voir qu'il contient une tâche sans implémentation.

espace de noms: import do desc "TODO" lieux de la tâche:: environment do end end

Pour mettre en œuvre la tâche, je vais utiliser deux joyaux. Le gem 'foursquare2' est utilisé pour se connecter à foursquare. Le second bijou est «géocodeur» pour convertir le nom de la ville que nous transmettons à la tâche en tant qu'argument en géo-coordonnées. Ajoutez ces deux joyaux à votre Gemfile:

gem 'foursquare2'
bijou 'géocodeur'

Courir installation groupée dans votre terminal, dans votre dossier de projet rails, pour installer les gemmes. 

Pour mettre en œuvre la tâche, j'ai vérifié la documentation de foursquare2, ainsi que la documentation officielle de Foursquare. Foursquare n'accepte pas les appels anonymes à son API. Nous devons donc créer un compte de développeur et enregistrer cette application pour obtenir la clés client_id et client_secret que nous devons nous connecter. Pour cet exemple, je m'intéresse au point de terminaison de l'API Venue Search afin que nous puissions disposer de données réelles pour notre échantillon. Après avoir récupéré les données de l’API, nous les sauvegardons dans la base de données. L'implémentation finale ressemble à:

namespace: import do desc "Importer des lieux depuis foursquare" tâche: lieux de rendez-vous, [: near] =>: environment do | t, args | client = Foursquare2 :: Client.new (client_id: 'your_foursquare_client_id', client_secret: 'your_foursquare_client_secret', api_version: '20160325') resultat = client.search_venues (près de: args [: près de. : 'parcourir') result.venues.each do | v | venue_object = Venue.new (nom: v.name, adresse: v.location.address, pays: v.location.country, latitude: v.location.lat, longitude: v.location.lng) v.categories.each do | c | venue_objet.catégories << Category.find_or_create_by(name: c.shortName) end venue_object.save puts "'#venue_object.name' - imported" end end end

Une fois que vous avez ajouté vos clés d'API Foursquare, pour importer des lieux de Londres, exécutez cette commande dans votre terminal: import bin / rake: sites [london]

Vous pouvez essayer avec votre ville si vous préférez, ou vous pouvez également importer des données à partir de plusieurs endroits. Comme vous pouvez le constater, notre tâche de rake n’envoie que cela à Foursquare, puis enregistre les résultats dans notre base de données..

Indexation des lieux dans Foursquare à l'aide de Chewy

À ce stade, nous avons notre importateur et notre modèle de données, mais nous devons encore indexer nos sites sur Elasticsearch. Ensuite, nous devons créer une vue avec un formulaire de recherche vous permettant de saisir une adresse à proximité de laquelle vous souhaitez trouver des sites..

Commençons par ajouter la gemme 'chewy' au Gemfile et en cours d'exécution installation groupée.

Selon la documentation, créez le fichier app / chewy / sites_index.rb définir comment chaque site va être indexé par Elasticsearch. En utilisant Chewy, nous n'avons pas besoin d'annoter nos modèles, les index pour Elasticsearch sont donc complètement isolés des modèles.. 

classe VenuesIndex < Chewy::Index define_type Venue do field :country field :name field :address field :location, type: 'geo_point', value: ->Champ lat: latitude, long: longitude: catégories, valeur: -> (lieu) venue.categories.map (&: name) # valeurs de tableau passées à la fin de l'index

Comme vous pouvez le voir, en classe VenuesIndex, J'indique que je veux indexer les champs pays, nom et adresse sous forme de chaîne. Ensuite, pour pouvoir effectuer une recherche par géolocalisation, je dois indiquer que latitude et longitude faire un geo_point, qui est une géolocalisation sur Elasticsearch. La dernière chose que nous voulons indexer avec chaque lieu est la liste des catégories.

Exécutez la tâche de rake en tapant dans votre terminal bin / rake chewy: réinitialiser d'indexer tous les sites que nous avons dans la base de données. Vous pouvez utiliser la même commande pour réindexer votre base de données dans Elasticsearch si vous en avez besoin..

Nous avons maintenant nos données dans la base de données SQLite et indexées dans Elasticsearch, mais nous n’avons encore créé aucune vue. Générons notre contrôleur de lieux, avec une action 'show' uniquement.

Commençons par modifier notre routes.rb fichier:

Rails.application.routes.draw root 'sites # show' obtenir 'rechercher', à: 'sites # show' end

Maintenant, créez la vue app / views / sites / show.html.erb, où je viens d’ajouter un formulaire pour entrer le lieu où vous voulez trouver des lieux. Je rend également la liste des sites si le résultat de la recherche est disponible:

Recherche de lieux

<% if @total_count %>

<%= @total_count %> lieux trouvés près de <%= params[:term] %>

<% end %> <%= form_for :term, url: search_path, method: :get do |form| %>

Lieux à proximité <%= text_field_tag :term, params[:term] %> <%= submit_tag "Search", name: nil %>

<% end %>
<% @venues.each do |venue| %>

<%= venue.name %>

<% if venue.address %>

Adresse: <%= venue.address %>

<% end %>

Distance: <%= number_to_human(venue.distance(@location), precision: 2, units: unit: 'km') %>

<% end %>

Comme vous pouvez le constater, j’affiche la distance entre le lieu saisi dans le formulaire de recherche et chaque lieu. Pour calculer et afficher la distance, ajoutez la méthode 'distance' à votre classe de site:

lieu de classe < ActiveRecord::Base has_and_belongs_to_many :categories def distance location Geocoder::Calculations.distance_between([latitude, longitude], [location['lat'], location['lng']]) end end

Maintenant, nous devons générer VenuesController, tapez donc dans votre terminal  Spectacles $ rails g contrôleurs. Ceci est la mise en œuvre complète:

Classe VenuesController < ApplicationController def show if params[:term].nil? @venues = [] else @location = address_to_geolocation params[:term] scope = search_by_location @total_count = scope.total_count @venues = scope.load end end private def address_to_geolocation term res = Geocoder.search(term) res.first.geometry['location'] # lat / lng end def search_by_location VenuesIndex .filter match_all .filter(geo_distance:  distance: "2km", location: lat: @location['lat'], lon: @location['lng'] ) .order(_geo_distance:  location: lat: @location['lat'], lon: @location['lng'] ) end end

Comme vous pouvez le constater, nous n’avons que l’action 'show'. Le lieu de recherche est enregistré dans params [: terme], et si cette valeur est disponible, nous convertissons l'adresse en une géolocalisation. Dans la méthode 'search_by_location', je demande simplement à Elasticsearch de faire correspondre tout lieu situé à moins de 2 km de la distance de recherche et de le commander par le plus proche.. 

Vous pensez peut-être: "Pourquoi le résultat n'est-il pas trié par distance par défaut si nous effectuons une géo-recherche?" Elasticsearch considère un filtre de géolocalisation comme un seul filtre, c'est tout. Vous pouvez également effectuer une recherche dans les autres champs afin que nous puissions rechercher un "restaurant de pizzas" à proximité d'un lieu. Peut-être y at-il un restaurant italien qui propose quatre pizzas au menu, mais il y a une grande pizzeria un peu plus éloignée. Elasticsearch prend en compte la pertinence d'une recherche par défaut.

Si je fais une recherche, je peux voir une liste de lieux:

Filtrage des lieux par catégorie

Nous enregistrons également la catégorie pour chaque site, mais nous ne l’afficheons pas et ne les filtrons pas par catégorie pour le moment. Commençons donc par l’afficher. modifier vues / sites / show.html.erb, et dans la liste des résultats de la recherche, affichez la catégorie, avec un lien pour filtrer par cette catégorie. Nous devons également passer le lieu afin que nous puissions effectuer une recherche par lieu et par catégorie:

Catégorie: <% venue.categories.each do |c| %> <%= link_to c.name, search_path(term: params[:term], category: c.name) %> <% end %>

Si nous actualisons la page de recherche, nous pouvons voir les catégories affichées maintenant:

Maintenant, nous devons implémenter le contrôleur, et nous avons un nouveau paramètre optionnel 'category'. En outre, lorsque nous interrogons l'index, nous devons vérifier si le paramètre 'category' est défini, puis filtrer par catégorie après la recherche par distance..

Classe VenuesController < ApplicationController def show if params[:term].nil? @venues = [] else @location = address_to_geolocation params[:term] @category = params[:category] scope = search_by_location @total_count = scope.total_count @venues = scope.load end end private def address_to_geolocation term res = Geocoder.search(term) res.first.geometry['location'] # lat / lng end def search_by_location scope = VenuesIndex .filter match_all .filter(geo_distance:  distance: "2km", location: lat: @location['lat'], lon: @location['lng'] ) .order(_geo_distance:  location: lat: @location['lat'], lon: @location['lng'] ) if @category scope = scope.merge(VenuesIndex.filter(match: categories: @category)) end return scope end end

De plus, dans l'en-tête, j'ajoute un lien pour revenir aux "résultats non filtrés"..

Recherche de lieux

<% if @total_count %> <% if @category %>

<%= "#@total_count #@category found near #params[:term]" %>

<%= link_to 'All venues', search_path(term: params[:term]) %> <% else %>

<%= @total_count %> lieux trouvés près de <%= params[:term] %>

<% end %> <% end%>

Maintenant, si je clique sur une catégorie après une recherche, vous pouvez voir que les résultats sont filtrés par cette catégorie..

Conclusion

Comme vous pouvez le constater, différents gemmes permettent d’indexer vos données dans Elasticsearch et d’effectuer différentes requêtes de recherche. En fonction de vos besoins, vous préférerez peut-être utiliser différentes gemmes. Si vous devez effectuer des requêtes complexes, vous devrez en savoir plus sur l'API Elasticsearch et effectuer des requêtes à un niveau inférieur, comme le permet la plupart des gems. Si vous souhaitez implémenter une recherche en texte intégral et peut-être uniquement une suggestion automatique, vous n'avez probablement pas besoin d'en savoir beaucoup sur les détails d'Elasticsearch..