Как стать автором
Обновить

REST-сервер для простого блога на Haskell

Время на прочтение 13 мин
Количество просмотров 28K
Некоторое время назад я окончательно устал от языков с динамической типизацией и решил попробовать изучить что-нибудь брутально-статическое. Haskell приглянулся мне красотой кода и бескомпромиссным стремлением явно отделить чистые функции от производящих сайд-эффекты. Я залпом проглотил несколько книжек по Haskell и решил, что пора что-нибудь уже и написать.

И тут-то меня ждало разочарование: я не был способен написать ничего кроме hello world-a. Т.е. я примерно представлял себе, как написать какую-нибудь консольную утилиту типа find или вроде того, — но первая же встреча с IO разрушала все мои представления. Библиотек для Haskell вроде бы много, а документации по ним почти совсем нету. Примеров решения типовых задач тоже очень мало.

Симптомы понятны, диагноз простой: отсутствие практики. А для Haskell это достаточно болезненно, т.к. язык крайне необычный. Даже то, что я неплохо знаю Clojure, почти никак мне не помогло, т.к. Clojure больше фокусируется на функциях, в то время как Haskell — на их типах.

Думаю, многие новички столкнулись с проблемой отсутствия практики в Haskell. Писать что-то совсем уж без интерфейса как-то не интересно, а сделать desktop- или web-приложение для начинающего хаскелиста довольно сложно. И в этой статье я собираюсь предложить простой пример, как написать сервер веб-приложения на Haskell специально для тех, кто хочет попрактиковаться в Haskell, но не знает, с какой стороны к нему подойти.

Для самых нетерпеливых: исходники здесь.

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

Задача


Разработать сервер веб-приложения для простого блога. Будут доступны следующие маршруты:
  • GET /articles — список статей.
  • GET /articles/:id — отдельная статья.
  • POST /admin/articles — создать статью.
  • PUT /admin/articles — обновить статью.
  • DELETE /admin/articles/:id — удалить статью.

Все маршруты, которые начинаются с «/admin» требуют аутентификацию пользователя. Для stateless-сервиса очень удобно использовать Basic-аутентификацию, т.к. каждый запрос содержит логин и пароль пользователя.

Что понадобится?


  • Некоторые начальные знания Haskell, общее понимание монад и функторов, устройства программы, ввода-вывода и т.д.
  • Утилита cabal, умение использовать sandbox-ы, подключать библиотеки, компилировать и запускать проект.
  • MySQL и самые начальные знания о нем.

Архитектура


Для реализации архитектуры предлагаю использовать следующие библиотеки.
  • Web-сервер — Warp.
  • Маршрутизатор — Scotty.
  • Конфигурация приложения — configurator.
  • Доступ к БД: mysql и mysql-simple.
  • Пул соединений с БД: resource-pool.
  • Взаимодействие с клиентом — REST с использованием JSON, библиотека — aeson.
  • wai-extra для basic-аутентификации, т.к. приложение будет stateless.

Разобьем наше приложение на модули.
  • Main.hs будет содержать код для запуска приложения, маршрутизатор и конфигурацию приложения.
  • Db.hs — все, что связано с доступом к базе данных.
  • View.hs — представление данных.
  • Domain.hs типы и функции для работы с предметной областью.
  • Auth.hs — функции для аутентификации.

Приступаем


Давайте создадим простой проект cabal для нашего приложения.

	mkdir hblog
	cd hblog
	cabal init

Здесь вам надо ответить на пару вопросов, при этом тип проекта выберите Executable, главный файл — Main.hs, директорию с исходниками — src. Вот используемые библиотеки, которые необходимо добавить в build-depends в файл hblog.cabal:

   base                          >= 4.6        && < 4.7
 , scotty                        >= 0.9.1
 , bytestring                    >= 0.9        && < 0.11
 , text                          >= 0.11       && < 2.0
 , mysql                         >= 0.1.1.8
 , mysql-simple                  >= 0.2.2.5
 , aeson                         >= 0.6        && < 0.9
 , HTTP                          >= 4000.2.19
 , transformers                  >= 0.4.3.0
 , wai                           >= 3.0.2.3
 , wai-middleware-static         >= 0.7.0.1
 , wai-extra                     >= 3.0.7
 , resource-pool                 >= 0.2.3.2
 , configurator                  >= 0.3.0.0
 , MissingH                      >= 1.3.0.1

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

	cabal sandbox init
	cabal install —dependencies-only 

Не забудьте создать файл src/Main.hs.

Давайте посмотрим, как устроено минимальное веб-приложение на Scotty. Документация и примеры использования этого микро-фреймворка очень хороши, так что с первого взгляда все становится понятно. А если у вас есть опыт с Sinatra, Compojure или Scalatra — считайте, что вам повезло, т.к. этот опыт здесь полностью пригодится.

Вот как выглядит минимальный src/Main.hs:

{-# LANGUAGE OverloadedStrings #-}

import Web.Scotty

import Data.Monoid (mconcat)

main = scotty 3000 $ do
  get "/:word" $ do
    beam <- param "word"
    html $ mconcat ["<h1>Scotty, ", beam, " me up!</h1>"]

Первая же строка кода может повергнуть новичка в изумление: что еще за перегружаемые строки? Сейчас объясню.

Поскольку я, как и многие другие, начал изучать Haskell с книг «Learn you a Haskell for a greater good» и «Real World Haskell», для меня сразу же стала большой проблемой обработка текста. Самое лучшее описание работы с текстом в Haskell я нашел в книге «Beginning Haskell» в главе 10.

Если очень кратко, то на практике используются три базовых типа строковых данных:
  • String — список символов. Этот тип данных встроен в язык.
  • Text — тип данных, предназначенный как для ASCII, так и для UTF-символов. Находится в библиотеке text и существует в двух видах: строгой и ленивой. Подробнее — здесь
  • ByteString — предназначен для сериализации строк в поток байтов. Поставляется в библиотеке bytestring и также в двух вариантах: строгом и ленивом.

Вернемся к заголовку OverloadedStrings. Штука в том, что, учитывая наличие нескольких типов строковых данных, исходник будет пестреть вызовами вроде T.pack «Hello» там, где лексему «Hello» необходимо преобразовать в Text; или B.pack «Hello» там, где лексему нужно преобразовать в ByteString. Вот чтобы убрать этот синтаксический мусор используется директива OverloadedStrings, которая самостоятельно выполняет преобразование строковой лексемы к нужному строковому типу.

Файл Main.hs


Главная функция:

main :: IO ()
main = do

-- Здесь мы загружаем конфигурационный файл application.conf, в котором хранятся настройки соединения с базой данных
    loadedConf <- C.load [C.Required "application.conf"]
    dbConf <- makeDbConfig loadedConf
    
    case dbConf of
      Nothing -> putStrLn "No database configuration found, terminating..."
      Just conf -> do
-- Создаем пул соединений (время жизни неиспользуемого соединения — 5 секунд, максимальное количество соединений с БД -- 10)      
          pool <- createPool (newConn conf) close 1 5 10
-- Запускаем маршрутизатор Scotty
          scotty 3000 $ do
-- Доступ к статическим файлам из директории «static»
              middleware $ staticPolicy (noDots >-> addBase "static")
-- Логирование всех запросов. Для продакшена используйте logStdout вместо logStdoutDev
              middleware $ logStdoutDev
-- Запрос на аутентификацию для защищенных маршрутов
              middleware $ basicAuth (verifyCredentials pool)
                           "Haskell Blog Realm" { authIsProtected = protectedResources }

              get    "/articles" $ do articles <- liftIO $ listArticles pool
                                      articlesList articles
-- Получит из запроса параметр :id и найдет в БД соответствующую запись
              get    "/articles/:id" $ do id <- param "id" :: ActionM TL.Text
                                          maybeArticle <- liftIO $ findArticle pool id
                                          viewArticle maybeArticle
-- Распарсит тело запроса в тип Article и создаст новую запись Article в БД
              post   "/admin/articles" $ do article <- getArticleParam
                                            insertArticle pool article
                                            createdArticle article

              put    "/admin/articles" $ do article <- getArticleParam
                                            updateArticle pool article
                                            updatedArticle article

              delete "/admin/articles/:id" $ do id <- param "id" :: ActionM TL.Text
                                                deleteArticle pool id
                                                deletedArticle id

Для конфигурации приложения воспользуемся пакетом configurator. Конфигурацию будем хранить в файле application.conf, и вот его содержимое:

database {
  name = "hblog"
  user = "hblog"
  password = "hblog"
}

Для пула соединений используем библиотеку resource-pool. Соединение с БД — удовольствие дорогое, так что лучше не создавать его на каждый запрос, а дать возможность переиспользовать старые. Тип функции createPool такой:

createPool :: IO a -> (a -> IO ()) -> Int -> NominalDiffTime -> Int -> IO (Pool a)
createPool create destroy numStripes idleTime maxResources

Здесь create и destroy — функции для создания и завершения соединения с базой данных, numStripes — количество раздельных суб-пулов соединений, idleTime — время жизни неиспользуемого соединения (в секундах), maxResources — максимальное количество соединений в суб-пуле.

Для открытия соединения используем функцию newConn (из Db.hs).

data DbConfig = DbConfig {
     dbName :: String,
     dbUser :: String,
     dbPassword :: String
     }
     deriving (Show, Generic)

newConn :: DbConfig -> IO Connection
newConn conf = connect defaultConnectInfo
                       { connectUser = dbUser conf
                       , connectPassword = dbPassword conf
                       , connectDatabase = dbName conf
                       }

Ну а сам DbConfig создается так:

makeDbConfig :: C.Config -> IO (Maybe Db.DbConfig)
makeDbConfig conf = do
  name <- C.lookup conf "database.name" :: IO (Maybe String)
  user <- C.lookup conf "database.user" :: IO (Maybe String)
  password <- C.lookup conf "database.password" :: IO (Maybe String)
  return $ DbConfig <$> name
                    <*> user
                    <*> password

На вход передается Data.Configurator.Config, который мы прочитали и распарсили из application.conf, а на выходе — Maybe DbConfig, заключенный в оболочку IO.

Такая запись для начинающих возможно покажется немного непонятной, и я попытаюсь пояснить, что здесь происходит.
Тип выражения C.lookup conf «database.name» — это Maybe String, заключенный в IO. Извлечь его из IO можно так:

name <- C.lookup conf "database.name" :: IO (Maybe String)

Соответственно, у констант name, user, password тип — Maybe String.

Тип конструктора данных DbConfig такой:

DbConfig :: String -> String -> String -> DbConfig 

Эта функция принимает на вход три строки и возвращает DbConfig.

Тип функции (<$>) такой:

(<$>) :: Functor f => (a -> b) -> f a -> f b

Т.е. он берет обычную функцию, функтор и возвращает функтор с примененной к его значению функцией. Короче, это обычный map.

Запись DbConfig <$> name извлекает из name строку (тип name — это Maybe String) присваивает значение первому параметру в конструкторе DbConfig и возвращает в оболочке Maybe каррированный DbConfig:

DbConfig <$> name :: Maybe (String -> String -> DbConfig) 

Обратите внимание, что здесь уже на один String передается меньше.

Тип (<*>) похож на <$>:

(<*>) :: Applicative f => f (a -> b) -> f a -> f b

Он берет функтор, значением которого является функция, берет еще один функтор и применяет функцию из первого функтора к значению из второго, возвращая новый функтор.

Таким образом, запись DbConfig <$> name <*> user имеет тип:

DbConfig <$> name <*> user :: Maybe (String -> DbConfig)

Остался последний String-овый параметр, который мы заполним password-ом:

DbConfig <$> name 
	     <*> user 
	     <*> password 
:: Maybe DbConfig

Аутентификация


В функции main осталась последняя сложная конструкция — это middleware basicAuth. Тип функции basicAuth такой:

basicAuth :: CheckCreds -> AuthSettings -> Middleware

Первый параметр — функция, проверяющая наличие пользователя в БД, вторая — определяет, какие маршруты требуют защиты аутентификацией. Их типы:

type CheckCreds = ByteString -> ByteString -> ResourceT IO Bool

data AuthSettings = AuthSettings
    { authRealm :: !ByteString
     , authOnNoAuth :: !(ByteString -> Application)
     , authIsProtected :: !(Request -> ResourceT IO Bool)
    }

Тип данных AuthSettings достаточно сложный, и если хотите поглубже с ним разобраться — смотрите исходники здесь. Нас же интересует здесь всего один параметр — authIsProtected. Это функция, которая по Request-у умеет определить, требовать ли аутентификацию, или нет. Вот её реализация для нашего блога:

protectedResources ::  Request -> IO Bool
protectedResources request = do
    let path = pathInfo request
    return $ protect path
    where protect (p : _) =  p == "admin"
          protect _        =  False

Функция pathInfo имеет следующий тип:

pathInfo :: Request -> [Text]

Она берет Request и возвращает список строк, которые получились после разделения маршрута запроса на подстроки по разделителю «/».
Таким образом, если наш запрос начинается с «/admin», то функция protectedResources вернет IO True, требуя аутентификацию.

А вот функция verifyCredentials, которая проверяет пользователя и пароль, относится к взаимодействию с БД, и поэтому о ней — ниже.

Взаимодействие с базой данных


Утилитные функции для извлечения данных из БД с использованием пула соединений:

fetchSimple :: QueryResults r => Pool M.Connection -> Query -> IO [r]
fetchSimple pool sql = withResource pool retrieve
       where retrieve conn = query_ conn sql

fetch :: (QueryResults r, QueryParams q) => Pool M.Connection -> q -> Query -> IO [r]
fetch pool args sql = withResource pool retrieve
      where retrieve conn = query conn sql args

Функцию fetchSimple нужно использовать для запросов без параметров, а fetch — для запросов с параметрами. Изменение данных можно сделать функцией execSql:

execSql :: QueryParams q => Pool M.Connection -> q -> Query -> IO Int64
execSql pool args sql = withResource pool ins
       where ins conn = execute conn sql args

Если необходимо использовать транзакцию, то вот функция execSqlT:

execSqlT :: QueryParams q => Pool M.Connection -> q -> Query -> IO Int64
execSqlT pool args sql = withResource pool ins
       where ins conn = withTransaction conn $ execute conn sql args

Используя функцию fetch можно, например, найти хэш пароля пользователя в БД по его логину:

findUserByLogin :: Pool Connection -> String -> IO (Maybe String)
findUserByLogin pool login = do
         res <- liftIO $ fetch pool (Only login) 
			      "SELECT * FROM user WHERE login=?" :: IO [(Integer, String, String)]
         return $ password res
         where password [(_, _, pwd)] = Just pwd
               password _ = Nothing

Она нужна в модуле Auth.hs:

verifyCredentials :: Pool Connection -> B.ByteString -> B.ByteString -> IO Bool
verifyCredentials pool user password = do
   pwd <- findUserByLogin pool (BC.unpack user)
   return $ comparePasswords pwd (BC.unpack password)
   where comparePasswords Nothing _ = False
         	  comparePasswords (Just p) password =  p == (md5s $ Str password)

Как видите, если хэш пароля в БД найден, то его можно сопоставить с паролем из запроса, закодированным при помощи алгоритма md5.

Но в базе данных хранятся не только пользователи, но и статьи, которые блог должен уметь создавать-редактировать-отображать. В файле Domain.hs определим тип данных Article c полями id title bodyText:

data Article = Article Integer Text Text
     deriving (Show)

Теперь можно определить функции CRUD в БД для этого типа:

listArticles :: Pool Connection -> IO [Article]
listArticles pool = do
     res <- fetchSimple pool "SELECT * FROM article ORDER BY id DESC" :: IO [(Integer, TL.Text, TL.Text)]
     return $ map (\(id, title, bodyText) -> Article id title bodyText) res
   
findArticle :: Pool Connection -> TL.Text -> IO (Maybe Article)
findArticle pool id = do
     res <- fetch pool (Only id) "SELECT * FROM article WHERE id=?" :: IO [(Integer, TL.Text, TL.Text)]
     return $ oneArticle res
     where oneArticle ((id, title, bodyText) : _) = Just $ Article id title bodyText
           oneArticle _ = Nothing


insertArticle :: Pool Connection -> Maybe Article -> ActionT TL.Text IO ()
insertArticle pool Nothing = return ()
insertArticle pool (Just (Article id title bodyText)) = do
     liftIO $ execSqlT pool [title, bodyText]
                            "INSERT INTO article(title, bodyText) VALUES(?,?)"
     return ()

updateArticle :: Pool Connection -> Maybe Article -> ActionT TL.Text IO ()
updateArticle pool Nothing = return ()
updateArticle pool (Just (Article id title bodyText)) = do
     liftIO $ execSqlT pool [title, bodyText, (TL.decodeUtf8 $ BL.pack $ show id)]
                            "UPDATE article SET title=?, bodyText=? WHERE id=?"
     return ()

deleteArticle :: Pool Connection -> TL.Text -> ActionT TL.Text IO ()
deleteArticle pool id = do
     liftIO $ execSqlT pool [id] "DELETE FROM article WHERE id=?"
     return ()

Наиболее важными здесь являются функции insertArticle и updateArticle. Они принимают на вход Maybe Article и вставляют/обновляют соответствующую запись в БД. Но откуда взять этот Maybe Article?

Все просто, пользователь должен передать Article, закодированный в JSON, в теле PUT- или POST- запроса. Вот функции для кодирования и декодирования Article в- и из- JSON:

instance FromJSON Article where
     parseJSON (Object v) = Article <$>
                            v .:? "id" .!= 0 <*>
                            v .:  "title"       <*>
                            v .:  "bodyText"

instance ToJSON Article where
     toJSON (Article id title bodyText) =
         object ["id" .= id,
                     "title" .= title,
                     "bodyText" .= bodyText]

Для обработки JSON используем библиотеку aeson, подробнее о ней — здесь.

Как видите, при декодировании поле id — не обязательное, и если его нет в строке с JSON, то подставится значение по умолчанию — 0. Поля id не будет при создании записи Article, т.к. id должна создать сама БД. Но id будет в update-запросе.

Представление данных


Вернемся в файл Main.hs и посмотрим, как мы получаем параметры запроса. Получить параметр из маршрута можно при помощи функции param:

param :: Parsable a => TL.Text -> ActionM a

А тело запроса можно получить функцией body:

body :: ActionM Data.ByteString.Lazy.Internal.ByteString

Вот функция, которая умеет получить тело запроса, распарсить его и вернуть Maybe Article

getArticleParam :: ActionT TL.Text IO (Maybe Article)
getArticleParam = do b <- body
                     return $ (decode b :: Maybe Article)
                     where makeArticle s = ""

Осталось последнее: вернуть данные клиенту. Для этого в файле Views.hs определим следующие функции:

articlesList :: [Article] -> ActionM ()
articlesList articles = json articles

viewArticle :: Maybe Article -> ActionM ()
viewArticle Nothing = json ()
viewArticle (Just article) = json article

createdArticle :: Maybe Article -> ActionM ()
createdArticle article = json ()

updatedArticle :: Maybe Article -> ActionM ()
updatedArticle article = json ()

deletedArticle :: TL.Text -> ActionM ()
deletedArticle id = json ()


Производительность сервера


Для тестов я использовал ноутбук Samsung 700Z c 8Гб памяти и четырехядерным Intel Core i7.
  • 1000 последовательных PUT-запросов для создания записи article.
    Среднее время ответа: 40 милисекунд, это примерно 25 запросов в секунду.
  • 100 потоков по 100 PUT-запросов в каждом.
    Среднее время ответа: 1248 миллисекунд, примерно 80 параллельных запросов в секнуду.
  • 100 потоков по 1000 GET-запросов, возвращающих 10 записей article.
    Среднее время ответа: 165 миллисекунд, примерно 600 запросов в секунду.

Просто для того, чтобы было хоть с чем-то сравнивать, я реализовал точно такой же сервер на Java 7 и Spring 4 с вебсвервером Tomcat 7 и получил следующие цифры.
  • 1000 последовательных PUT-запросов для создания записи article.
    Среднее время ответа: 51 миллисекунда, это примерно 19-20 запросов в секунду.
  • 100 потоков по 100 PUT-запросов в каждом.
    Среднее время ответа: 104 миллисекунды, примерно 960 параллельных запросов в секнуду.
  • 100 потоков по 1000 GET-запросов, возвращающих 10 записей article.
    Среднее время ответа: 26 миллисекунд, примерно 3800 запросов в секунду.

Выводы


Если вам не хватает практики в Haskell, и хочется попробовать писать на нем веб-приложения, то здесь вы найдете описанный в статье пример простого сервера с CRUD-операциями для одной сущности — Article. Приложение реализовано в виде JSON REST-сервиса и требует basic authentication на защищенных маршрутах. Для хранения данных используется СУБД MySQL, для повышения производительности применён пул соединений. Поскольку приложение не хранит состояния в сессии, его очень легко масштабировать горизонтально, кроме того stateless-сервер идеально подходит для разработки микросервисной архитектуры.

Применение Haskell для разработки JSON REST-сервера позволило получить краткий и красивый исходник, который, помимо прочего, легко поддерживать: рефакторинг, внесение изменений и дополнений не потребует большого труда, т.к. компилятор сам проверит корректность всех изменений. Минусом применения Haskell является не очень высокая производительность полученного веб-сервиса в сравнении с аналогичным, написанным на Java.

P.S.


По советам из комментов провел дополнительное тестирование. Изменение числа потоков до N=8 включительно — не влияет на производительность. При уменьшении N далее, производительность падает, т.к. на моем ноуте 8 логических ядер.

Еще интересная штука. Если отключить сохранение записи в БД, то средняя задержка ответа сервиса на Haskell падает аж до 6 миллисекунд (!), в аналогичном сервисе на java это время — 80мс. Т.е. узкое место в показанном проекте — именно взаимодействие с БД, если его отключить, то Haskell быстрее аналогичного функционала на Java в 13 раз. Потребление памяти тоже в несколько раз ниже: примерно 80Мб против 400Мб.
Теги:
Хабы:
+26
Комментарии 30
Комментарии Комментарии 30

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн