Если вы использовали Google, Slack, Gmail и т. д., вы бы сталкивались с их функцией поиска. Многие приложения поддерживают эту функцию. В этой статье я попытаюсь создать функцию, похожую на поиск, используя Haskell и Elasticsearch.
Предпосылки
- Понимание языка программирования Haskell.
- Знакомство с 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.
Ресурсы
- Вы можете найти весь код и многое другое в репозитории bank-query-es.
- Вы можете выполнить некоторые запросы здесь.
- Есть даже UI, написанный на вязе. (ПРИМЕЧАНИЕ: вам нужно будет разрешить смешанный контент, чтобы это работало, так как соединение с github к месту, где размещен код Haskell, осуществляется через http).