[Из песочницы] REST-сервер для простого блога на Haskell
Некоторое время назад я окончательно устал от языков с динамической типизацией и решил попробовать изучить что-нибудь брутально-статическое. 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 — отдельная статья. PUT /admin/articles — создать статью. POST /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 ["
Scotty,», beam,» me up!
»] Первая же строка кода может повергнуть новичка в изумление: что еще за перегружаемые строки? Сейчас объясню.Поскольку я, как и многие другие, начал изучать Haskell с книг «Learn you a Haskell for a greater good» и «Real World Haskell», для меня сразу же стала большой проблемой обработка текста. Самое лучшее описание работы с текстом в Haskell я нашел в книге «Beginning Haskell» в главе 10.Если очень кратко, то на практике используются три базовых типа строковых данных:
String — список обычных ASCII-символов, восьмибитных, естественно. Этот тип данных встроен в язык. 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 в БД put "/admin/articles" $ do article <- getArticleParam insertArticle pool article createdArticle article
post »/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.