Статьи

Создание сайта поиска рецептов с Angular и Elasticsearch

Вы когда-нибудь хотели встроить функцию поиска в приложение? В старые времена вы могли бы поссориться с Solr или создать собственную поисковую службу поверх Lucene — если вам повезло. Но с 2010 года появился более простой путь: Elasticsearch .

Elasticsearch — это движок с открытым исходным кодом, построенный на Lucene. Это больше, чем поисковая система; Это настоящее хранилище документов, хотя оно подчеркивает эффективность поиска, а не стабильность и долговечность. Это означает, что для многих приложений вы можете использовать Elasticsearch в качестве всей своей серверной части. Приложения, такие как …

Создание поисковой системы рецептов

В этой статье вы узнаете, как использовать Elasticsearch с AngularJS для создания поисковой системы для рецептов, как на OpenRecipeSearch.com . Почему рецепты?

  1. OpenRecipes существует, что делает нашу работу намного проще.
  2. Почему нет?

OpenRecipes — это проект с открытым исходным кодом, который собирает сайты рецептов для рецептов, а затем предоставляет их для загрузки в удобном формате JSON. Это здорово для нас, потому что Elasticsearch также использует JSON. Тем не менее, мы должны запустить Elasticsearch, прежде чем сможем кормить его всеми этими рецептами.

Скачайте Elasticsearch и разархивируйте его в любой каталог, который вам нравится. Затем откройте терминал, cd в каталог, который вы только что распаковали, и запустите bin/elasticsearch ( bin/elasticsearch.bat в Windows). Та-да! Вы только что запустили свой собственный экземпляр эластичного поиска. Оставьте этот бег, пока вы следуете за ним.

Одна из замечательных особенностей Elasticsearch — это готовый бэкэнд RESTful, который облегчает взаимодействие со многими средами. Мы будем использовать драйвер JavaScript , но вы можете использовать тот, который вам больше нравится ; код будет выглядеть очень похоже в любом случае. Если вам нравится, вы можете обратиться к этой удобной ссылке (отказ от ответственности: написано мной).

Теперь вам понадобится копия базы данных OpenRecipes . Это просто большой файл, полный документов JSON, поэтому написать быстрый скрипт Node.js, чтобы получить их там, просто. Для этого вам понадобится библиотека JavaScript Elasticsearch, поэтому запустите npm install elasticsearch . Затем создайте файл с именем load_recipes.js и добавьте следующий код.

 var fs = require('fs'); var es = require('elasticsearch'); var client = new es.Client({ host: 'localhost:9200' }); fs.readFile('recipeitems-latest.json', {encoding: 'utf-8'}, function(err, data) { if (err) { throw err; } // Build up a giant bulk request for elasticsearch. bulk_request = data.split('\n').reduce(function(bulk_request, line) { var obj, recipe; try { obj = JSON.parse(line); } catch(e) { console.log('Done reading'); return bulk_request; } // Rework the data slightly recipe = { id: obj._id.$oid, // Was originally a mongodb entry name: obj.name, source: obj.source, url: obj.url, recipeYield: obj.recipeYield, ingredients: obj.ingredients.split('\n'), prepTime: obj.prepTime, cookTime: obj.cookTime, datePublished: obj.datePublished, description: obj.description }; bulk_request.push({index: {_index: 'recipes', _type: 'recipe', _id: recipe.id}}); bulk_request.push(recipe); return bulk_request; }, []); // A little voodoo to simulate synchronous insert var busy = false; var callback = function(err, resp) { if (err) { console.log(err); } busy = false; }; // Recursively whittle away at bulk_request, 1000 at a time. var perhaps_insert = function(){ if (!busy) { busy = true; client.bulk({ body: bulk_request.slice(0, 1000) }, callback); bulk_request = bulk_request.slice(1000); console.log(bulk_request.length); } if (bulk_request.length > 0) { setTimeout(perhaps_insert, 10); } else { console.log('Inserted all records.'); } }; perhaps_insert(); }); 

Затем запустите скрипт, используя командный node load_recipes.js . Спустя 160 000 записей у нас есть полная база данных готовых рецептов. Проверьте это с помощью curl если вам это удобно:

 $ curl -XPOST http://localhost:9200/recipes/recipe/_search -d '{"query": {"match": {"_all": "cake"}}}' 

Теперь вы можете использовать curl для поиска рецептов, но если миру понравится ваш поиск рецептов, вам нужно …

Создание интерфейса поиска по рецепту

Вот тут-то и вступает Angular. Я выбрал Angular по двум причинам: потому что хотел, и потому что библиотека JavaScript Elasticsearch поставляется с экспериментальным адаптером Angular. Я оставлю дизайн в качестве упражнения для читателя, но я покажу вам важные части HTML.

Получите ваши руки на Angular и Elasticsearch сейчас. Я рекомендую Bower , но вы также можете просто скачать их. Откройте файл index.html и вставьте его туда, куда вы обычно помещаете свой JavaScript (я предпочитаю непосредственно перед закрывающим тегом body , но это совершенно другой аргумент):

 <script src="path/to/angular/angular.js"></script> <script src="path/to/elasticsearch/elasticsearch.angular.js"></script> 

Теперь давайте перестанем думать о том, как будет работать наше приложение:

  1. Пользователь вводит запрос.
  2. Мы отправляем запрос как поиск в Elasticsearch.
  3. Мы получаем результаты.
  4. Мы предоставляем результаты для пользователя.

В следующем примере кода показан ключ HTML для нашей поисковой системы с указанием угловых директив. Если вы никогда не использовали Angular, это нормально. Вам нужно знать только несколько вещей, чтобы получить этот пример:

  1. HTML-атрибуты, начинающиеся с ng являются угловыми директивами.
  2. Динамические части ваших приложений Angular заключены в ng-app ng-controller и ng-app ng-controller . ng-app и ng-controller не обязательно должны быть в одном элементе, но они могут быть.
  3. Все остальные ссылки на переменные в HTML ссылаются на свойства объекта $scope которые мы встретим в JavaScript.
  4. Части, заключенные в {{}} являются переменными шаблона, как в шаблонах Django / Jinja2 / Liquid / Mustache.
 <div ng-app="myOpenRecipes" ng-controller="recipeCtrl"> <!-- The search box puts the term into $scope.searchTerm and calls $scope.search() on submit --> <section class="searchField"> <form ng-submit="search()"> <input type="text" ng-model="searchTerm"> <input type="submit" value="Search for recipes"> </form> </section> <!-- In results, we show a message if there are no results, and a list of results otherwise. --> <section class="results"> <div class="no-recipes" ng-hide="recipes.length">No results</div> <!-- We show one of these elements for each recipe in $scope.recipes. The ng-cloak directive prevents our templates from showing on load. --> <article class="recipe" ng-repeat="recipe in recipes" ng-cloak> <h2> <a ng-href="{{recipe.url}}">{{recipe.name}}</a> </h2> <ul> <li ng-repeat="ingredient in recipe.ingredients">{{ ingredient }}</li> </ul> <p> {{recipe.description}} <a ng-href="{{recipe.url}}">... more at {{recipe.source}}</a> </p> </article> <!-- We put a link that calls $scope.loadMore to load more recipes and append them to the results.--> <div class="load-more" ng-hide="allResults" ng-cloak> <a ng-click="loadMore()">More...</a> </div> </section> 

Теперь мы можем начать писать наш JavaScript. Начнем с модуля, который, как мы решили выше, будет называться myOpenRecipes (через атрибут ng-app ).

 /** * Create the module. Set it up to use html5 mode. */ window.MyOpenRecipes = angular.module('myOpenRecipes', ['elasticsearch'], ['$locationProvider', function($locationProvider) { $locationProvider.html5Mode(true); }] ); 

Для новичков в Angular бизнес- ['$locationProvider', function($locationProvider) {...}] — это наш способ сказать Angular, что мы бы хотели, чтобы он передавал $locationProvider в нашу функцию-обработчик, чтобы мы могли использовать его , Эта система внедрения зависимостей избавляет нас от необходимости полагаться на глобальные переменные (кроме глобального angular и MyOpenRecipes мы только что создали).

Далее мы напишем контроллер с именем recipeCtrl . Мы должны обязательно инициализировать переменные recipes , allResults и searchTerm используемые в шаблоне, а также предоставить search() и loadMore() качестве действий.

 /** * Create a controller to interact with the UI. */ MyOpenRecipes.controller('recipeCtrl', ['recipeService', '$scope', '$location', function(recipes, $scope, $location) { // Provide some nice initial choices var initChoices = [ "rendang", "nasi goreng", "pad thai", "pizza", "lasagne", "ice cream", "schnitzel", "hummous" ]; var idx = Math.floor(Math.random() * initChoices.length); // Initialize the scope defaults. $scope.recipes = []; // An array of recipe results to display $scope.page = 0; // A counter to keep track of our current page $scope.allResults = false; // Whether or not all results have been found. // And, a random search term to start if none was present on page load. $scope.searchTerm = $location.search().q || initChoices[idx]; /** * A fresh search. Reset the scope variables to their defaults, set * the q query parameter, and load more results. */ $scope.search = function() { $scope.page = 0; $scope.recipes = []; $scope.allResults = false; $location.search({'q': $scope.searchTerm}); $scope.loadMore(); }; /** * Load the next page of results, incrementing the page counter. * When query is finished, push results onto $scope.recipes and decide * whether all results have been returned (ie were 10 results returned?) */ $scope.loadMore = function() { recipes.search($scope.searchTerm, $scope.page++).then(function(results) { if (results.length !== 10) { $scope.allResults = true; } var ii = 0; for (; ii < results.length; ii++) { $scope.recipes.push(results[ii]); } }); }; // Load results on first run $scope.loadMore(); }]); 

Вы должны узнать все об объекте $scope из HTML. Обратите внимание, что наш реальный поисковый запрос опирается на загадочный объект recipeService . Сервис — это способ Angular предоставлять повторно используемые утилиты для таких вещей, как общение с внешними ресурсами. К сожалению, Angular не предоставляет recipeService , поэтому нам придется написать его самостоятельно. Вот как это выглядит:

 MyOpenRecipes.factory('recipeService', ['$q', 'esFactory', '$location', function($q, elasticsearch, $location) { var client = elasticsearch({ host: $location.host() + ':9200' }); /** * Given a term and an offset, load another round of 10 recipes. * * Returns a promise. */ var search = function(term, offset) { var deferred = $q.defer(); var query = { match: { _all: term } }; client.search({ index: 'recipes', type: 'recipe', body: { size: 10, from: (offset || 0) * 10, query: query } }).then(function(result) { var ii = 0, hits_in, hits_out = []; hits_in = (result.hits || {}).hits || []; for(; ii < hits_in.length; ii++) { hits_out.push(hits_in[ii]._source); } deferred.resolve(hits_out); }, deferred.reject); return deferred.promise; }; // Since this is a factory method, we return an object representing the actual service. return { search: search }; }]); 

Наш сервис довольно скромный. Он предоставляет единственный метод search() , который позволяет нам отправлять запрос в Elasticsearch, выполняя поиск по всем полям по заданному термину. Вы можете видеть это в query переданном в теле запроса для search : {"match": {"_all": term}} . _all — это специальное ключевое слово, которое позволяет нам искать все поля. Если бы вместо этого наш запрос был {"match": {"title": term}} , мы бы увидели только рецепты, которые содержали поисковый термин в заголовке.

Результаты возвращаются в порядке уменьшения «балла», что является предположением Elasticsearch относительно релевантности документа на основе частоты ключевых слов и места размещения. Для более сложного поиска мы могли бы настроить относительный вес оценки (т. Е. Попадание в заголовок стоит больше, чем в описании), но по умолчанию, кажется, довольно хорошо без него.

Вы также заметите, что поиск принимает аргумент offset . Поскольку результаты упорядочены, мы можем использовать это для получения большего количества результатов, если потребуется, указав Elasticsearch пропустить первые n результатов.

Некоторые заметки о развертывании

Развертывание немного выходит за рамки этой статьи, но если вы хотите начать поиск по рецепту, вам нужно быть осторожным. Elasticsearch не имеет понятия пользователей или разрешений. Если вы хотите запретить кому-либо добавлять или удалять рецепты, вам нужно найти способ запретить доступ к этим конечным точкам REST в вашем экземпляре Elasticsearch. Например, OpenRecipeSearch.com использует nginx в качестве прокси-сервера перед Elasticsearch для предотвращения внешнего доступа ко всем конечным точкам, кроме recipes/recipe/_search .

Поздравляем, вы сделали поиск рецептов

Теперь, если вы откроете index.html в браузере, вы должны увидеть список рецептов без стилей, поскольку наш контроллер выбирает некоторые для вас случайным образом при загрузке страницы. Если вы введете новый поиск, вы получите 10 результатов, относящихся к тому, что вы искали, и если вы нажмете «Еще…» в нижней части страницы, должны появиться еще несколько рецептов (если действительно есть больше рецептов для извлечения) ,

Это все, что нужно сделать! Вы можете найти все необходимые файлы для запуска этого проекта на GitHub .