Если вы использовали Google, Slack, Gmail и т. д., вы бы сталкивались с их функцией поиска. Многие приложения поддерживают эту функцию. В этой статье я попытаюсь создать функцию, похожую на поиск, используя Haskell и Elasticsearch.

Предпосылки

  1. Понимание языка программирования Haskell.
  2. Знакомство с ElasticSearch.

Эластичный поиск

Если вы просматривали документы ES, вы бы наткнулись на их пример банковского запроса. Это то, что мы будем использовать в качестве основы для того, что мы собираемся создать. Шаги по настройке ES с банковскими данными описаны в документах, поэтому я не буду их описывать, но основная идея заключается в том, что у нас есть документы следующей структуры.

{
    "account_number": 0,
    "balance": 16623,
    "firstname": "Bradshaw",
    "lastname": "Mckenzie",
    "age": 29,
    "gender": "F",
    "address": "244 Columbus Place",
    "employer": "Euron",
    "email": "[email protected]",
    "city": "Hobucken",
    "state": "CO"
}

И мы можем запросить их, используя что-то вроде следующего.

GET /bank/_search
{
  "query": { "match": { "account_number": 20 } }
}

Что вернет нам все банковские счета (из 1000), у которых номер счета равен 20. Как вы могли заметить, синтаксис поиска использует JSON! Я повторяю JSON!! Опять JSON!!! Вы бы не хотели, чтобы даже ваши разработчики писали это (не говоря уже о пользователях). Поэтому нам нужен удобный для разработчиков способ сделать это. Входить ….

Хаскелл

Отказ от ответственности: я новичок в Haskell, поэтому качество кода может быть, мягко говоря, сомнительным.

У Haskell действительно хороший клиент Elasticsearch и запрос DSL Bloodhound. Как и для любой другой библиотеки/фреймворка Haskell, по этой библиотеке также недостаточно руководств или документации. Но вы можете взглянуть на этот пример. Основная идея заключается в том, что вместо того, чтобы писать что-то вроде вышеупомянутого зверства JSON, вы можете написать что-то вроде.

TermQuery (Term "account_number" "20") Nothing

который (опять же) вернет все банковские счета (из 1000), у которых номер счета равен 20. Не знаю, как вы, но это, безусловно, улучшение. С которыми мы можем предложить работать разработчикам!

Но мы далеки от конечного состояния, мы хотим, чтобы наши пользователи вводили что-то вроде

account_number:20

который (опять же) вернет данные для учетной записи с номером 20.

Теперь нам нужен способ преобразовать запрос выше в запрос выше² (надеюсь, вы поняли суть и согласились с дерьмовой шуткой), и из этого Bloodhound преобразует его в запрос выше³. Введите Парсек. Согласно документам, Parsec — это промышленная монадическая библиотека комбинаторов синтаксических анализаторов для Haskell.

Наконец-то у нас есть все необходимые инструменты, и мы можем приступить к созданию чего-то с их помощью. УРА!

Язык запросов банка

Итак, сначала давайте объявим типы. Все, что нам нужно для нашей цели, это объявить некоторые операторы, которые мы будем использовать. :, <, <=, >=, > . И структура запроса, которую мы будем поддерживать, будет string, number, date, and, or . И это то, что мы определяем ниже.

data Operator
  = B_LessThan
  | B_GreaterThan
  | B_LessThanEqual
  | B_GreaterThanEqual
  | B_Equal
  deriving (Show, Eq)
data BQL
  = B_String Text Text
  | B_Num Text Operator Double
  | B_Date Text Operator UTCTime
  | B_Not BQL
  | B_And [BQL]
  | B_Or [BQL]
  deriving (Show, Eq)

Теперь давайте определим некоторые примитивные комбинаторы парсеров, которые мы сочтем полезными. Это более или менее похоже на шаблон и даже может быть проигнорировано.

-- Lexical token spec
bqlDef :: LanguageDef st
bqlDef =
  emptyDef
    { commentStart    = ""
    , commentEnd      = ""
    , commentLine     = ""
    , nestedComments  = False
    , identStart      = letter
    , identLetter     = alphaNum
    , reservedNames   = [ "and", "or", "in"]
    , reservedOpNames = [ ":", "-", "=", ">", "<", ">=", "<="]
    , caseSensitive  = False
    }

lexer      = Token.makeTokenParser bqlDef
reserved   = Token.reserved lexer
reservedOp = Token.reservedOp lexer
double     = Token.float lexer
integer    = Token.integer lexer
int        = fromInteger <$> Token.integer lexer
parens     = Token.parens lexer
identifier = fmap pack (Token.identifier lexer)

Теперь давайте перейдем к более сложным комбинаторам. На данный момент мы поддерживаем только один вид анализа даты в формате гггг/мм/дд.

date :: Parser UTCTime
date =
  let
    dayParser = fromGregorian <$> integer <*> (char ‘-’ *> int) <*> (char ‘-’ *> int)
  in
    UTCTime <$> dayParser <*> pure (secondsToDiffTime 0)

А для строковых значений полей будем парсить либо все, пока не увидим пробел, либо будем парсить все внутри кавычек.

fieldValue :: Parser Text
fieldValue =
  let
    anyStr = many (satisfy (not . isSpace))
    escape = (:) <$> char ‘\\’ <*> ((:) <$> oneOf “\\\”0nrvtbf” <*> pure [])
    nonEscape = noneOf “\\\”\0\n\r\v\t\b\f”
    character = fmap return nonEscape <|> escape
    parseString = concat <$> between (char ‘“‘) (char ‘“‘) (many character)
  in
    fmap pack (parseString <|> anyStr)

Теперь давайте разберем операторы, которые мы будем использовать, что говорит само за себя.

operator :: Parser Operator
operator
  =   (reservedOp “>”  *> pure B_GreaterThan)
  <|> (reservedOp “<”  *> pure B_LessThan)
  <|> (reservedOp “<=” *> pure B_LessThanEqual)
  <|> (reservedOp “>=” *> pure B_GreaterThanEqual)
  <|> (reservedOp “:”  *> pure B_Equal)

Теперь давайте начнем с разбора базовых случаев типа данных BQL.

B_String — это идентификатор, за которым следует :, за которым следует fieldValue.

B_Num — это идентификатор, за которым следует любой оператор, за которым следует целое число или двойное число.

B_Date — это идентификатор, за которым следует любой оператор, за которым следует дата.

customString :: Parser BQL
customString = B_String <$> (identifier <* reservedOp “:”) <*> fieldValue
customNum :: Parser BQL
customNum = B_Num <$> identifier <*> operator <*> (try double <|> fmap fromInteger integer)
customDate :: Parser BQL
customDate = B_Date <$> identifier <*> operator <*> date

С этим мы можем создать базовое определение для языка поисковых запросов parens', а not' будет определено позже.

bql' :: Parser BQL
bql' = parens'
  <|> not'
  <|> try customDate
  <|> try customNum
  <|> customString

Теперь давайте определим рекурсивные случаи в нашем типе данных. Здесь не то, чтобы мы относились к пустым местам как к and выражениям.

-- bql' queries seperated by and or spaces
andQ :: Parser BQL
andQ = B_And <$> sepBy1 bql' (reserved "and" <|> spaces)

-- bql' queries or and queries seperated by or expressions
orQ :: Parser BQL
orQ = B_Or <$> sepBy1 (andQ <|> bql') (reserved "or")
-- `-` followed by a bql query
not' :: Parser BQL
not' = B_Not <$> (reservedOp "-" *> bql')
-- bql query inside of parenthesis
parens' :: Parser BQL
parens' = parens bql

И, наконец, наше определение языка запросов, для которого все это было построено. Вы готовы!!

-- bql is nothing but an or query
bql :: Parser BQL
bql = orQ

Теперь, когда у нас есть наш запрос, давайте также попробуем его оптимизировать. т. е. удалить много избыточных и или блоков. Хорошая новость заключается в том, что сопоставление с образцом в Haskell упрощает задачу. Этот блок кода говорит сам за себя.

optimizebql :: BQL -> BQL
optimizebql (B_Or [query]) = optimizebql query
optimizebql (B_And [query]) = optimizebql query
optimizebql (B_Or ors) = B_Or (map optimizebql ors)
optimizebql (B_And ands) = B_And (map optimizebql ands)
optimizebql (B_Not notq) = B_Not (optimizebql notq)
optimizebql query = query

Наконец, нам нужно проанализировать беспорядок, который вводит пользовательский ввод.

parseBQL :: String -> Either ParseError BQL
parseBQL =  parse bql ""

И мы закончили разбор.

Преобразование Bloodhound DSL

Теперь, когда мы можем анализировать пользовательский ввод, давайте преобразуем его в DSL ищейки, и на этом все будет готово. ура

getRangeValue :: Operator -> Double -> RangeValue
getRangeValue B_LessThan value =
  RangeDoubleLt (LessThan value)
getRangeValue B_GreaterThan value =
  RangeDoubleGt (GreaterThan value)
getRangeValue B_LessThanEqual value =
  RangeDoubleLte (LessThanEq value)
getRangeValue B_GreaterThanEqual value =
  RangeDoubleGte (GreaterThanEq value)
getRangeValue B_Equal value =
  RangeDoubleGteLte (GreaterThanEq value) (LessThanEq value)
getDateRange :: Operator -> UTCTime -> RangeValuegetDateRange B_LessThan value =
  RangeDateLt (LessThanD value)
getDateRange B_GreaterThan value =
  RangeDateGt (GreaterThanD value)
getDateRange B_LessThanEqual value =
  RangeDateLte (LessThanEqD value)
getDateRange B_GreaterThanEqual value =
  RangeDateGte (GreaterThanEqD value)
getDateRange B_Equal value =
  RangeDateGteLte (GreaterThanEqD value) (LessThanEqD value)

И мы теперь для основного запроса преобразования

bqlToES :: BQL -> Query
bqlToES (B_String k v) =
  QueryMatchQuery $ mkMatchQuery (FieldName k) (QueryString v)
bqlToES (B_Date k op v) =
  QueryRangeQuery $ mkRangeQuery (FieldName k) (getDateRange op v)
bqlToES (B_Num k op v)  =
  QueryRangeQuery $ mkRangeQuery (FieldName k) (getRangeValue op v)
bqlToES (B_And qs) =
  QueryBoolQuery $ mkBoolQuery (map bqlToElastic qs) [] []
bqlToES (B_Or qs) =
  QueryBoolQuery $ mkBoolQuery [] [] (map bqlToElastic qs)
bqlToES (B_Not q) =
  QueryBoolQuery $ mkBoolQuery [] [bqlToElastic q] []

Теперь приступим к выполнению этого запроса.

queryES' :: (BH IO Reply -> IO Reply) -> IndexName -> BQL -> IO ByteString
queryES' withBH' bankIndex bankQuery =
  let
    searchQ = bqlToElastic bankQuery
    filterQ = Nothing
    query = mkSearch (Just searchQ) filterQ
  in
    fmap responseBody (withBH' $ searchByIndex bankIndex query)

-- quick Hack! Please don't judge.
queryES :: (BH IO Reply -> IO Reply) -> IndexName -> BQL -> IO [Value]
queryES withBH' bankIndex =
  fmap (fromRight' . fmap (fromJust . mapM hitSource . hits . searchHits) . eitherDecode) . queryES' withBH' bankIndex

И мы закончили.

Вывод

Мы увидели, как легко создавать DSL с помощью haskell, и поэтому мы создали собственный DSL на основе паршивого синтаксиса поиска ES с помощью Parsec и Bloodhound.

Ресурсы

  1. Вы можете найти весь код и многое другое в репозитории bank-query-es.
  2. Вы можете выполнить некоторые запросы здесь.
  3. Есть даже UI, написанный на вязе. (ПРИМЕЧАНИЕ: вам нужно будет разрешить смешанный контент, чтобы это работало, так как соединение с github к месту, где размещен код Haskell, осуществляется через http).