В этом уроке я собираюсь создать небольшое приложение Rails. Я покажу вам, как создать грабли, чтобы импортировать некоторые объекты из Foursquare в нашу базу данных. Тогда мы будем индексировать их на Elasticsearch. Кроме того, местоположение каждого места будет проиндексировано, чтобы мы могли осуществлять поиск по расстоянию.
Задача Rake для импорта площадок Foursquare
Rake-задача — это всего лишь скрипт ruby, который мы можем запустить вручную, или мы можем запускать его периодически, если нам нужно выполнить некоторые фоновые задачи, например, для обслуживания. В нашем случае мы собираемся запустить его вручную. Нам понадобится новое приложение для рельсов и несколько моделей для сохранения в нашей базе данных, местах, которые мы собираемся импортировать из Foursquare. Давайте начнем с создания нового приложения rails, поэтому введите в своей консоли:
$ rails new elasticsearch-rails-geolocation
Я собираюсь создать две модели: место и категорию, используя рельсовые генераторы. Чтобы создать модель Venue, введите в своем терминале:
$ rails g model venue name:string address:string country:string latitude:float longitude:float
Введите следующую команду для создания модели категории:
$ rails g model category name:string venue:references
Отношение от места проведения до категории много ко многим. Например, если мы импортируем итальянский ресторан, он может иметь категории «Итальянский» и «Ресторан», но другие заведения также могут иметь такие же категории. Чтобы определить отношение «многие ко многим» из «Места проведения» и «Категории», мы используем has_and_belongs_to_many
активной записи has_and_belongs_to_many
, поскольку у нас нет других свойств, которые относятся к этому отношению. Наши модели теперь выглядят так:
1
2
3
4
5
6
7
|
class Venue < ActiveRecord::Base
has_and_belongs_to_many :categories
end
class Category < ActiveRecord::Base
has_and_belongs_to_many :venues
end
|
Теперь нам все еще нужно создать таблицу соединений для отношений. Он будет хранить список ‘venue_id, category_id’ для отношений. Чтобы сгенерировать эту таблицу, выполните следующую команду в своем терминале:
$ rails generate migration CreateJoinTableVenueCategory venue category
Если мы посмотрим на сгенерированную миграцию, мы можем убедиться, что создана правильная таблица для отношений:
1
2
3
4
5
6
7
8
|
class 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
|
Чтобы действительно создать таблицу в базе данных, не забудьте запустить миграцию, выполнив команду bin/rake db:migrate
в своем терминале.
Чтобы импортировать объекты из foursquare, нам нужно создать новое задание Rake. В Rails также есть генератор для задач, поэтому просто введите свой терминал:
$ rails g task import venues
Если вы откроете новый файл, созданный в lib / tasks / import.rake , вы увидите, что он содержит задачу без реализации.
1
2
3
4
5
|
namespace :import do
desc «TODO»
task venues: :environment do
end
end
|
Для реализации задачи я собираюсь использовать два драгоценных камня. Драгоценный камень ‘foursquare2’ используется для подключения к foursquare. Второй драгоценный камень — «геокодер», чтобы преобразовать название города, который мы передаем задаче в качестве аргумента для гео-координат. Добавьте эти два камня в ваш Gemfile:
gem 'foursquare2'
gem 'geocoder'
Запустите bundle install
в своем терминале, внутри папки проекта rails, чтобы установить гемы.
Чтобы выполнить задачу, я проверил документацию для foursquare2 , а также официальную документацию Foursquare . Foursquare не принимает анонимные вызовы своего API, поэтому нам нужно создать учетную запись разработчика и зарегистрировать это приложение, чтобы получить ключи client_id и client_secret, которые нам нужно подключить. Для этого примера меня интересует конечная точка API поиска места , поэтому мы можем получить некоторые реальные данные для нашего образца. После получения данных от API мы просто сохраняем их в базе данных. Окончательная реализация выглядит так:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
namespace :import do
desc «Import venues from foursquare»
task :venues, [:near] => :environment do |t, args|
client = Foursquare2::Client.new(
client_id: ‘your_foursquare_client_id’,
client_secret: ‘your_foursquare_client_secret’,
api_version: ‘20160325’)
result = client.search_venues(near: args[:near].to_s, query: ‘restaurants’, intent: ‘browse’)
result.venues.each do |v|
venue_object = Venue.new(name: v.name, address: v.location.address, country: v.location.country, latitude: v.location.lat, longitude: v.location.lng)
v.categories.each do |c|
venue_object.categories << Category.find_or_create_by(name: c.shortName)
end
venue_object.save
puts «‘#{venue_object.name}’ — imported»
end
end
end
|
После того, как вы добавили свои ключи API Foursquare, чтобы импортировать некоторые объекты из «Лондона», выполните эту команду в своем терминале: bin/rake import:venues[london]
Вы можете попробовать с вашим городом, если вы предпочитаете, или вы также можете импортировать данные из нескольких мест. Как вы можете видеть, наша задача с граблями — только отправить это в Foursquare, а затем сохранить результаты в нашей базе данных.
Индексирование мест в Foursquare с использованием Chewy
На данный момент у нас есть наш импортер и модель данных, но нам все еще нужно проиндексировать наши места на Elasticsearch. Затем нам нужно создать вид с формой поиска, которая позволит вам ввести адрес, рядом с которым вы заинтересованы в поиске мест.
Давайте начнем с добавления gem ‘chewy’ в Gemfile и запуска bundle install
.
В соответствии с документацией создайте файл app / chewy / venues_index.rb, чтобы определить, как каждое место будет индексироваться Elasticsearch. Используя chewy, нам не нужно аннотировать наши модели, поэтому индексы Elasticsearch полностью изолированы от моделей.
1
2
3
4
5
6
7
8
9
|
class VenuesIndex < Chewy::Index
define_type Venue do
field :country
field :name
field :address
field :location, type: ‘geo_point’, value: ->{ {lat: latitude, lon: longitude} }
field :categories, value: ->(venue) { venue.categories.map(&:name) } # passing array values to index
end
end
|
Как вы можете видеть, в классе VenuesIndex
я указываю, что хочу индексировать поля страна, имя и адрес в виде строки. Затем, чтобы иметь возможность поиска по географическому местоположению, мне нужно указать, что широта и долгота составляют гео- точку , которая является географическим местоположением в Elasticsearch. Последнее, что мы хотим проиндексировать с каждым местом, это список категорий.
Запустите задачу rake, введя в свой терминал bin/rake chewy:reset
чтобы проиндексировать все места, которые мы имеем в базе данных. Вы можете использовать ту же команду для переиндексации вашей базы данных в Elasticsearch, если вам нужно.
Теперь у нас есть данные в базе данных SQLite и они проиндексированы в Elasticsearch, но мы еще не создали никаких представлений. Давайте сгенерируем наш контроллер Venues только с действием show.
Давайте начнем с изменения нашего файла rout.rb :
1
2
3
4
|
Rails.application.routes.draw do
root ‘venues#show’
get ‘search’, to: ‘venues#show’
end
|
Теперь создайте представление app / views / venues / show.html.erb , где я просто добавляю форму, чтобы указать место, где вы хотите найти места. Я также отображаю список мест, если результат поиска доступен:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
<h1>Search venues</h1>
<% if @total_count %>
<h3><%= @total_count %> venues found near <%= params[:term] %></h3>
<% end %>
<%= form_for :term, url: search_path, method: :get do |form|
<p>
Venues near
<%= text_field_tag :term, params[:term] %>
<%= submit_tag «Search», name: nil %>
</p>
<% end %>
<hr/>
<div id=’search-results’>
<% @venues.each do |venue|
<div>
<h3><%= venue.name %></h3>
<% if venue.address %>
<p>Address: <%= venue.address %></p>
<% end %>
<p>Distance: <%= number_to_human(venue.distance(@location), precision: 2, units: {unit: ‘km’}) %></p>
</div>
<% end %>
</div>
|
Как вы видите, я показываю расстояние от местоположения, указанного в форме поиска, до каждого места. Чтобы рассчитать и отобразить расстояние, добавьте метод «расстояние» в свой класс объекта:
1
2
3
4
5
6
7
|
class Venue < ActiveRecord::Base
has_and_belongs_to_many :categories
def distance location
Geocoder::Calculations.distance_between([latitude, longitude], [location[‘lat’], location[‘lng’]])
end
end
|
Теперь нам нужно сгенерировать VenuesController, поэтому введите в вашем терминале $ rails g controller venues show
. Это полная реализация:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
class 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
|
Как видите, у нас есть только действие «показать». Местоположение поиска сохраняется в params[:term]
, и если это значение доступно, мы конвертируем адрес в географическое местоположение. В методе ‘search_by_location’ я просто запрашиваю Elasticsearch, чтобы он соответствовал любому месту в пределах 2 км от расстояния поиска и порядка ближайшего.
Вы можете подумать: «Почему результат не упорядочен по расстоянию по умолчанию, если мы выполняем гео-поиск?» Elasticsearch рассматривает геолокационный фильтр как один фильтр, вот и все. Вы также можете выполнить поиск по другим полям, чтобы мы могли искать «пиццерия» рядом с локацией. Может быть, есть итальянский ресторан, в меню которого есть четыре пиццы, но есть большая пиццерия чуть дальше. Elasticsearch учитывает актуальность поиска по умолчанию.
Если я выполню поиск, я могу увидеть список мест:
Фильтрация мест по категориям
Мы также храним категорию для каждого места, но в данный момент мы не показываем ее и не фильтруем по категориям, поэтому начнем с ее отображения. Отредактируйте views / venues / show.html.erb и в списке результатов поиска отобразите категорию со ссылкой для фильтрации по этой категории. Нам также нужно передать местоположение, чтобы мы могли искать по местоположению и категории:
1
2
3
4
5
|
<p>Category:
<% venue.categories.each do |c|
<%= link_to c.name, search_path(term: params[:term], category: c.name) %>
<% end %>
</p>
|
Если мы обновим страницу поиска, то увидим, что категории отображаются сейчас:
Теперь нам нужно реализовать контроллер, и у нас есть новый необязательный параметр ‘category’. Кроме того, когда мы запрашиваем индекс, нам нужно проверить, установлен ли параметр ‘category’, а затем отфильтровать по категории после поиска по расстоянию.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
class 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
|
Кроме того, в шапке я добавляю ссылку, чтобы вернуться к «нефильтрованным результатам».
01
02
03
04
05
06
07
08
09
10
|
<h1>Search venues</h1>
<% if @total_count %>
<% if @category %>
<h3><%= «#{@total_count} #{@category} found near #{params[:term]}» %></h3>
<%= link_to ‘All venues’, search_path(term: params[:term]) %>
<% else %>
<h3><%= @total_count %> venues found near <%= params[:term] %></h3>
<% end %>
<% end%>
|
Теперь, если я нажму на категорию после выполнения поиска, вы увидите, что результаты фильтруются по этой категории.
Вывод
Как видите, существуют разные гемы для индексации ваших данных в Elasticsearch и выполнения разных поисковых запросов. В зависимости от ваших потребностей, вы можете предпочесть использовать разные гемы, и, возможно, когда вам нужно выполнить сложные запросы, вам нужно будет узнать об Elasticsearch API и делать запросы на более низком уровне, что допускается большинством гемов. Если вы хотите реализовать полнотекстовый поиск и, возможно, только автозаполнение, вам, вероятно, не нужно много узнавать о деталях Elasticsearch.