Поиск по сайту с использованием Sphinx в Yesod

31 июля 2012

Признайтесь, вы было решили, что работы над русским переводом прекрасного фолианта «Developing Web Applications with Haskell and Yesod» внезапно остановились? А вот как бы не так! Сегодня мне хотелось бы представить на ваш суд черновой вариант перевода 21-ой главы, посвященной реализации поиска по сайту с помощью Sphinx.

Полнотекстовый поиск с использованием Sphinx

Sphinx — это сервер полнотекстового поиска, благодаря которому реализован поиск на многих сайтах, включая сайт самого Yesod. Код, необходимый для интеграции Sphinx и Yesod, является довольно коротким. Тем не менее, он содержит несколько непростых моментов, а потому является прекрасной иллюстрацией к использованию некоторых деталей внутреннего устройства Yesod.

По существу нам предстоит реализовать три вещи:

  • Сохранение данных, которые мы хотели бы искать. В действительности, тут непосредственно используется Persistent-код, и мы не будем подробно останавливаться на этом моменте.
  • Доступ из Yesod к результатам поиска Sphinx. На самом деле, благодаря пакету sphinx, это довольно просто.
  • Предоставление содержимого документов серверу Sphinx. Вот, где происходят интересные вещи! Вы узнаете, как напрямую преобразовывать поточный контент из базы данных в XML, который затем передается клиенту.

Установка Sphinx

В отличие от ранее приводимых примеров здесь для начала нам потребуется настроить и запустить сервер Sphinx. Я не собираюсь затрагивать все особенности Sphinx, отчасти, потому что они не относятся к делу, но в основном — потому что я далеко не эксперт в Sphinx.

Sphinx предоставляет три основных программы. Демон searchd непосредственно принимает запросы от клиента (в данном случае — нашего веб-приложения) и возвращает результат поиска. Программа indexer обрабатывает документы и создает индекс поиска. Утилита search предназначена для отладки, она посылает простые поисковые запросы серверу Sphinx.

Настройки Sphinx содержат два важных параметра — источник (source) и индекс (index). Параметр source говорит Sphinx, откуда ему следует считывать информацию о документах. Поддерживается как прямое чтение из MySQL и PostgreSQL, так и использование XML-формата, известного, как xmlpipe2. Им мы и воспользуемся. Это не только даст гибкость в плане выбора Persistent-бэкендов, но и продемонстрирует некоторые мощные концепции Yesod.

Второй важный параметр — это index. Sphinx может поддерживать несколько индексов одновременно, что позволяет организовать поиск для нескольких служб с помощью одного сервера. Каждому параметру index соответствует параметр source, определяющий, откуда брать данные.

Наше приложение будет предоставлять специальный URL (/search/xmlpipe), с помощью которого XML-файл, требуемый Sphinx, будет передаваться в indexer. В конфигурационный файл Sphinx следует прописать следующее:

source searcher_src
{
    type = xmlpipe2
    xmlpipe_command = curl http://localhost:3000/search/xmlpipe
}

index searcher
{
    source = searcher_src
    path = /var/data/searcher
    docinfo = extern
    charset_type = utf-8
}

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

Базовая настройка Yesod

Создадим новое Yesod-приложение. Нам понадобится одна таблица в базе данных для хранения документов, которые будут состоять из заголовка и текста. Эту таблицу мы будем хранить в SQLite. Создадим маршруты для поиска, добавления и просмотра документов, а также для генерации xmlpipe-файла для Sphinx:

mkYesod "Searcher" [parseRoutes|
/ RootR GET
/doc/#DocId DocR GET
/add-doc AddDocR POST
/search SearchR GET
/search/xmlpipe XmlpipeR GET
|]

К счастью, на данный момент все это кажется уже знакомым. Далее мы определим две формы — одну для создания документа и одну для поиска:

addDocForm :: Html -> MForm Searcher Searcher (FormResult Doc, Widget)
addDocForm = renderTable $ Doc
    <$> areq textField "Title" Nothing
    <*> areq textareaField "Contents" Nothing

searchForm :: Html -> MForm Searcher Searcher (FormResult Text, Widget)
searchForm = renderDivs $ areq (searchField True) "Query" Nothing

Передача параметра True в функцию searchField обеспечивает автоматическую установку фокуса на поле ввода при загрузке страницы. Наконец, объявляем обработчики для главной страницы (на ней отображаются формы добавления и поиска документов), а также отображения и добавления документа:

getRootR :: Handler RepHtml
getRootR = do
    docCount <- runDB $ count ([] :: [Filter Doc])
    ((_, docWidget), _) <- runFormPost addDocForm
    ((_, searchWidget), _) <- runFormGet searchForm
    let docs = if docCount == 1
                then "There is currently 1 document."
                else "There are currently " ++ show docCount ++
                       " documents."
    defaultLayout [whamlet|
<p>Welcome to the search application. #{docs}
<form method=post action=@{AddDocR}>
    <table>
        ^{docWidget}
        <tr>
            <td colspan=3>
                <input type=submit value="Add document">
<form method=get action=@{SearchR}>
    ^{searchWidget}
    <input type=submit value=Search>
|]

postAddDocR :: Handler RepHtml
postAddDocR = do
    ((res, docWidget), _) <- runFormPost addDocForm
    case res of
        FormSuccess doc -> do
            docid <- runDB $ insert doc
            setMessage "Document added"
            redirect $ DocR docid
        _ -> defaultLayout [whamlet|
<form method=post action=@{AddDocR}>
    <table>
        ^{docWidget}
        <tr>
            <td colspan=3>
                <input type=submit value="Add document">
|]

getDocR :: DocId -> Handler RepHtml
getDocR docid = do
    doc <- runDB $ get404 docid
    defaultLayout $
        [whamlet|
<h1>#{docTitle doc}
<div .content>#{docContent doc}
|]
data Result = Result
    { resultId :: DocId
    , resultTitle :: Text
    , resultExcerpt :: Html
    }

Поиск

Теперь, когда мы разобрались со всякой рутиной, займемся непосредственно поиском. В результатах поиска нам понадобится отображать три вещи — идентификатор документа, его заголовок, а также выдержки. Выдержки представляют собой выделенные отрывки документа, содержащие искомое выражение.

Результат поиска

Давайте начнем с определения типа данных Result:

data Result = Result
    { resultId :: DocId
    , resultTitle :: Text
    , resultExcerpt :: Html
    }

Теперь взглянем на обработчик поискового запроса:

getSearchR :: Handler RepHtml
getSearchR = do
    ((formRes, searchWidget), _) <- runFormGet searchForm
    searchResults <-
        case formRes of
            FormSuccess qstring -> getResults qstring
            _ -> return []
    defaultLayout $ do
        toWidget [lucius|
.excerpt {
    color: green; font-style: italic
}
.match {
    background-color: yellow;
}
|]
        [whamlet|
<form method=get action=@{SearchR}>
    ^{searchWidget}
    <input type=submit value=Search>
$if not $ null searchResults
    <h1>Results
    $forall result <- searchResults
        <div .result>
            <a href=@{DocR $ resultId result}>#{resultTitle result}
            <div .excerpt>#{resultExcerpt result}
|]

Ничего волшебного здесь не происходит, мы просто полагаемся на функции searchForm, объявленную выше, и еще не объявленную getResults. Эта функция просто принимает строку с поисковым запросом и возвращает список результатов. Здесь мы впервые взаимодействуем с API сервера Sphinx. Мы будем использовать две функции — query будет возвращать список совпадений, а buildExcerpts — выделенные отрывки. Взглянем на функцию query:

getResults :: Text -> Handler [Result]
getResults qstring = do
    sphinxRes' <- liftIO $ S.query config "searcher" (unpack qstring)
    case sphinxRes' of
        ST.Ok sphinxRes -> do
            let docids = map (Key . PersistInt64 . ST.documentId) $
                           ST.matches sphinxRes
            fmap catMaybes $ runDB $ forM docids $ \docid -> do
                mdoc <- get docid
                case mdoc of
                    Nothing -> return Nothing
                    Just doc -> liftIO $ Just <$>
                                  getResult docid doc qstring
        _ -> error $ show sphinxRes'
  where
    config = S.defaultConfig
        { S.port = 9312
        , S.mode = ST.Any
        }

Она принимает три аргумента — параметры конфигурации, строку с именем индекса, по которому будет производиться поиск, и поисковый запрос. Функция возвращает список идентификаторов документов, содержащих поисковый запрос. Здесь есть небольшая хитрость, связанная с тем, что возвращаемые идентификаторы имеют тип Int64, когда нам нужен DocId. Благодаря тому, что SQL-бэкенды Persistent используют конструктор PersistInt64 для этих идентификаторов, мы сможем получить требуемые значения.

Если вы работаете с бэкендом, использующим нечисловые идентификаторы, например MongoDB, вам придется разработать какой-нибудь более изощренный способ.

Затем мы проходим в цикле по этим идентификаторам, получая список [Maybe Result], после чего используем функцию catMaybes для преобразования этого списка в [Result]. В where-клозе мы определяем локальные настройки, которые замещают номер порта, используемый по умолчанию, а также изменяют режим поиска на такой, в котором возвращаются все документы, содержащие хотя бы одно слово из поискового запроса.

Наконец, взглянем на функцию getResult:

getResult :: DocId -> Doc -> Text -> IO Result
getResult docid doc qstring = do
    excerpt' <- S.buildExcerpts
        excerptConfig
        [T.unpack $ escape $ docContent doc]
        "searcher"
        (unpack qstring)
    let excerpt =
            case excerpt' of
                ST.Ok bss -> preEscapedToMarkup $
                               decodeUtf8With ignore $ L.concat bss
                _ -> ""
    return Result
        { resultId = docid
        , resultTitle = docTitle doc
        , resultExcerpt = excerpt
        }
  where
    excerptConfig = E.altConfig { E.port = 9312 }

escape :: Textarea -> Text
escape =
    T.concatMap escapeChar . unTextarea
  where
    escapeChar '<' = "&lt;"
    escapeChar '>' = "&gt;"
    escapeChar '&' = "&amp;"
    escapeChar c   = T.singleton c

Функция buildExcerpts принимает четыре аргумента — параметры конфигурации, содержимое документа, строку с именем индекса и поисковый запрос. Обратите внимание на экранирование специальных символов в документе. Sphinx его не производит, поэтому нам приходится заниматься этим самим.

Результат поиска, который мы получаем от Sphinx, представляет собой список ленивых ByteString’ов. Но мы, само собой разумеется, предпочли бы получить Html. Поэтому мы конкатенируем элементы списка в одну ленивую ByteString, декодируем ее в ленивый текст (игнорируя некорректные последовательности символов UTF-8), а затем используем функцию preEscapedToMarkup для того, чтобы тэги, используемые для выделения найденных совпадений, не экранировались. Вот пример полученного в итоге HTML-кода:

&#8230; Departments.  The President shall have <span
class='match'>Power</span> to fill up all Vacancies
&#8230;  people. Amendment 11 The Judicial <span
class='match'>power</span> of the United States shall
&#8230; jurisdiction. 2. Congress shall have <span
class='match'>power</span> to enforce this article by
&#8230; 5. The Congress shall have <span
class='match'>power</span> to enforce, by appropriate legislation
&#8230;

Генерируем xmlpipe

Самое интересное мы приберегли напоследок. Для большинства обработчиков в Yesod рекомендуемый подход заключается в загрузке данных из базы в оперативную память и генерации вывода, основанного на этих данных. Так проще, но намного важнее то, что такой подход более устойчив к ошибкам. Если возникнет проблема во время загрузки данных из базы, пользователь получит должный код ответа 500.

Что я имею в виду, говоря «должный код ответа 500»? Если вы начнете передавать данные клиенту и в середине процесса наткнетесь на исключение, у вас не будет возможности изменить код ответа. Пользователь получит ответ с кодом 200, просто передача данных прервется посередине. Помимо того, что частично сгенерированная страница сама по себе сбивает с толку, такое поведение еще и не соответствует спецификации протокола HTTP.

Генерация xmlpipe-вывода является прекрасным примером, где следует использовать альтернативный подход. У нас может быть огромное количество документов (код сайта yesodweb.com оперирует десятками тысяч документов), а документы могут иметь размер порядка нескольких сотен килобайт. Если мы не воспользуемся поточным подходом, это может привести к большому времени ответа и использованию огромного количества оперативной памяти.

Так каким именно образом мы можем сгенерировать поточный ответ (streaming response)? Как мы узнаем из главы, посвященной WAI, у нас есть конструктор ResponseSource который использует поток элементов типа Builder из библиотеки blaze-builder. Со стороны Yesod вместо того, чтобы генерировать ответ, как обычно, мы можем послать WAI-ответ напрямую с помощью функции sendWaiResponse. Таким образом имеется по крайней мере две части головоломки.

Теперь нам известно, что из неких XML-данных требуется создать поток Builder’ов. К счастью, пакет xml-conduit предоставляет непосредственный интерфейс для этого. Вообще пакет xml-conduit предоставляет высокоуровневый интерфейс для работы с документами в целом, однако в нашем случае понадобится низкоуровневый интерфейс Event, дабы обеспечить минимальное использование памяти. Вот функция, которая нам нужна:

renderBuilder :: Resource m =>
                   RenderSettings -> Conduit Event m Builder b

В переводе на русский язык это означает, что renderBuilder принимает некие настройки (мы воспользуемся настройками по умолчанию), и преобразует поток Event’ов в поток Builder’ов. Выглядит неплохо. Все, что нам теперь нужно — это поток Event’ов.

Кстати говоря, а как должен выглядеть наш XML-документ? Он довольно прост, имеется родительский элемент sphinx:docset, элемент sphinx:schema, содержащий одиночный элемент sphinx:field (который определяет элемент с содержимым документа), а затем по одному элементу sphinx:document для каждого документа в базе данных. Эти элементы будут содержать атрибут id и дочерний элемент content:

<sphinx:docset xmlns:sphinx="http://sphinxsearch.com/">
    <sphinx:schema>
        <sphinx:field name="content"/>
    </sphinx:schema>
    <sphinx:document id="1">
        <content>bar</content>
    </sphinx:document>
    <sphinx:document id="2">
        <content>foo bar baz</content>
    </sphinx:document>
</sphinx:docset>

Каждый XML-документ будет начинаться с одинаковых событий (начать docset, начать schema и т.д.) и заканчиваться одним и тем же событием (закончить docset). Вот соответствующий код:

toName :: Text -> X.Name
toName x = X.Name x (Just "http://sphinxsearch.com/") (Just "sphinx")

docset, schema, field, document, content :: X.Name
docset = toName "docset"
schema = toName "schema"
field = toName "field"
document = toName "document"
content = "content" -- no prefix

startEvents, endEvents :: [X.Event]
startEvents =
    [ X.EventBeginDocument
    , X.EventBeginElement docset []
    , X.EventBeginElement schema []
    , X.EventBeginElement field [("name", [X.ContentText "content"])]
    , X.EventEndElement field
    , X.EventEndElement schema
    ]

endEvents =
    [ X.EventEndElement docset
    ]

Теперь, когда у нас есть оболочка нашего документа, нам нужно достать Event’ы для каждого конкретного документа. Это можно сделать при помощи довольно простой функции:

entityToEvents :: (Entity Doc) -> [X.Event]
entityToEvents (Entity docid doc) =
    [ X.EventBeginElement document
      [ ("id", [X.ContentText $ toPathPiece docid]) ]
    , X.EventBeginElement content []
    , X.EventContent $ X.ContentText $ unTextarea $ docContent doc
    , X.EventEndElement content
    , X.EventEndElement document
    ]

Мы начинаем с элемента document, имеющего атрибут id, затем начинаем элемент content, вставляем содержимое документа, после чего закрываем оба элемента. Для преобразования DocId в значение типа Text мы используем функцию toPathPiece. Теперь нам нужно как-то преобразовать поток сущностей в поток событий. Для этого воспользуемся функцией concatMap из модуля Data.Conduit.List:

CL.concatMap entityToEvents

Но чего мы действительно хотим, это создать поток этих событий непосредственно из базы данных. На протяжении большей части книги, мы использовали функцию selectList, однако Persistent также предоставляет (более мощную) функцию selectSourceConn. С ее помощью мы получаем окончательный результат:

docSource :: Connection -> C.Source (C.ResourceT IO) X.Event
docSource conn =
  selectSourceConn conn [] [] C.$= CL.concatMap entityToEvents

Оператор $= соединяет источник с каналом, возвращая новый источник. Теперь, когда у нас есть источник Event’ов, все, что нам нужно — это окружить его событиями начала и конца документа. Благодаря тому, что Source является экземпляром класса Monoid, это проще простого:

fullDocSource :: Connection -> C.Source (C.ResourceT IO) X.Event
fullDocSource conn = mconcat
    [ CL.sourceList startEvents
    , docSource conn
    , CL.sourceList endEvents
    ]

Мы почти закончили, осталось только применить все это в функции getXmlpipeR. Нам необходимо установить соединение с базой данных. Обычно соединения с БД берутся и автоматически возвращаются с помощью функции runDB. В нашем случае требуется получить соединение и иметь к нему доступ до тех пор, пока тело ответа не будет передано полностью. Для этого воспользуемся функцией takeResource, а также регистрацией и очисткой, реализованными в монаде ResourceT.

Все WAI-приложения живут в трансформаторе монады ResourceT. Вы можете получить больше информации о монаде ResourceT в приложении, посвященном потокам (conduit).

По умолчанию ресурс не возвращается в пул. Это связано с правильной обработкой ошибок, что, впрочем, не относится к нашему случаю. Поэтому мы должны заставить соединение возвращаться в пул:

getXmlpipeR :: Handler RepXml
getXmlpipeR = do
    Searcher pool <- getYesod
    let headers = [("Content-Type", "text/xml")]
    managedConn <- lift $ takeResource pool
    let conn = mrValue managedConn
    lift $ mrReuse managedConn True
    let source = fullDocSource conn C.$= renderBuilder def
    sendWaiResponse $ ResponseSource status200 headers source

Пул соединений получается из базовой переменной, после чего отправляется WAI-ответ. Мы используем конструктор ResponseSource, передавая ему код ответа, а также заголовки и тело ответа.

С полной версией кода, написанного в рамках данной главы, вы можете ознакомиться, перейдя по этой ссылке.

Дополнение: Перевод еще одной главы — Работа с XML во фреймворке Yesod.

Метки: , , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.