RPC на основе WAI
Введение
Сейчас web-разработка на Haskell достаточно проста, даже для новичка. Этому способствует наличие таких пакетов, как Yesod и Snap. Но не всегда их мощь и полнота охвата необходимы. Порой от “сервера” требуется столь мало, что не хочется иметь в зависимостях подобных “монстров”, особенно в тех случаях, когда задача достаточно легко решаема и более простыми средствами.
Пусть примером послужит такая задача: требуется реализовать сервис, позволяющий вызывать на сервере некие функции и получать результат вызова, или, говоря общепринятым языком, выполнять RPC.
Такую задачу можно решить, используя Spock, scotty или, скажем, servant, но “мы пойдем другим путем”©!
Большинство библиотек для web-разработки внутри использует так называемый Web Application Interface (WAI) - обобщенный протокол общения web-сервера и web-приложения. Приложения, реализующие этот протокол, называют WAI-приложениями и запускают с помощью сервера wai-приложений - warp.
Реализуем же и мы простой сервис на чистом WAI!
WAI-сервис
Задача
Реализовать сервис вызова функций типа String -> String
. Для примера реализуем
функции reverse
, upper
и lower
.
API будет следующим:
GET
на/
возвращает список имен доступных функций (по одному на строку),GET
на/<function_name>
возвращает описание функции,GET
на/<function_name>?<argument>
возвращает результат вызова функции.
“Hello, World!”
Для начала создадим проект и реализуем сервер-заглушку, отвечающий известной строкой на любой запрос. Обратите внимание: исходники проекта доступны на github.
Создаем проект:
$ stack new wai-rpc simple --resolver lts-3.2
ВНИМАНИЕ: предполагается, что у вас установлена утилита stack,
а ключ --resolver lts-3.2
означает, что будет использоваться снимок версии 3.2 -
именно этот снимок был актуален на момент написания статьи.
(подробнее о снимках можно почитать в документации к stack).
После создания проекта добавляем зависимости http-types
, wai
и warp
в .cabal
-файл:
-- ...часть файла опущена...
executable wai-rpc
hs-source-dirs: src
main-is: Main.hs
default-language: Haskell2010
build-depends: base >= 4.7 && < 5,
http-types, wai, warp -- <-- добавлено
Затем содержимое файла src/Main.hs
заменяем на:
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Network.Wai
import Network.HTTP.Types (status200, hContentType)
import Network.Wai.Handler.Warp (run)
application :: Application
= respond $
application _ respond
responseLBS status200"text/plain")]
[(hContentType, "Hello World"
main :: IO ()
= do
main putStrLn "Serving..."
8000 application run
К слову, этот код практически слово-в-слово повторяет helloworld от авторов библиотеки WAI ;)
Осталось собрать проект:
$ stack build
И запустить:
$ stack exec wai-rpc
Serving (hit Ctrl+C to stop)...
Если при запущенном сервере открыть в браузере url http://localhost:8000, то в окне отобразится ожидаемое приветствие. Сервер работает!
Теперь стоит разобрать, из чего же состоит наш сервер.
main
содержит строку
run 8000 application
Это запуск сервера warp
на порту 8000
с единственным WAI-приложением - application
.
Приложение application
имеет тип Application, который является синонимом для
type Application = Request
-> (Response -> IO ResponseReceived)
-> IO ResponseReceived
Здесь первый аргумент, это тип Request, описывающий запрос, а второй, это “ответчик” -
функция, призванная возвращать ответ Response в процессе выполнения некой работы
(для этого в типе монада IO
).
В данном случае приложение сразу же отвечает фиксированным сообщением, поэтому
тело приложения - единственный вызов ответчика respond
.
Ответ же в данном случае выглядит так:
responseLBS status200-- :: Network.HTTP.Types.Status
"text/plain")]
[(hContentType, -- :: [(Network.HTTP.Types.HeaderName
-- ,ByteString)]
"Hello World!"
-- :: Lazy ByteString
Всё достаточно привычно: статус, заголовки и тело.
Вот, собственно и всё! Это уже вполне самостоятельный сервер, можно пускать в production :) И это не шутка - warp испытан и проверен, и, ко всему прочему, весьма быстр и пригоден для “вывешивания наружу” (т.е. не требует заворачивания во всякие Nginx).
Маршрутизация
Сервер работает, настало время решать конкретную задачу. Для начала заведем несколько вспомогательных функций для работы с ответами:
import Network.Wai (Response)
import Network.HTTP.Types (Status, notFound404,
badRequest400)import qualified Data.ByteString.Lazy as LBS
-- ...
responseOk, responseNotFound, responseBadRequest :: LBS.ByteString -> Response
= responsePlainText status200
responseOk = responsePlainText notFound404
responseNotFound = responsePlainText badRequest400
responseBadRequest
responsePlainText :: Status -> LBS.ByteString -> Response
=
responsePlainText `responseLBS` [(hContentType, "text/plain")]) (
(в зависимости проекта нужно будет добавить bytestring
)
Так как мы собираемся обрабатывать только GET
-запросы, добавим отсечку по типу
запроса с соответствующим сообщением об ошибке:
import Network.Wai (requestMethod)
import Network.HTTP.Types (methodGet)
= respond $
application req respond if requestMethod req /= methodGet
then responseBadRequest "Only GET method is allowed!"
else -- далее всё как раньше с учетом вспом. функций
"Hello World" responseOk
GET
-запросы мы уже фильтруем, теперь на запрос к корневому url нужно
возвращать список функций. Значит нужна библиотека функций:
import Data.Map.Strict (Map, fromList, lookup, keys)
import qualified Data.ByteString.Char8 as BS
import Prelude hiding (lookup)
-- ...
type FunctionName = BS.ByteString
type FunctionDescription = BS.ByteString
type FunctionArg = BS.ByteString
type FunctionResult = BS.ByteString
type FunctionSpec = (FunctionDescription
FunctionArg -> FunctionResult))
,(
library :: Map FunctionName FunctionSpec
= fromList []
library
getFunctionSpec :: FunctionName -> Maybe FunctionSpec
= (`lookup` library)
getFunctionSpec
listOfFunctions :: [FunctionName]
= keys library
listOfFunctions
describe :: FunctionSpec -> FunctionDescription
= fst
describe
call :: FunctionSpec -> FunctionArg -> FunctionResult
= snd call
(в зависимости проекта нужно будет добавить containers
)
Самих функций пока нет, но библиотека есть, как есть и функции для работы
с ней. Можно уже выводить список функций, но перед этим нужно понять, что
запрос производится на “корневой” url и не содержит параметров. Добавим ветвления
в наше приложение, заодно переписав if
-ветки в виде охранных выражений:
import Network.Wai (rawPathInfo, rawQueryString)
-- ...
application req respond| requestMethod req /= methodGet =
respond$ responseBadRequest "Only GET method is allowed!"
| path == "" =
respond$ if query /= ""
then responseBadRequest "No query parameters needed!"
else responseOk renderedListOfFunctions
| otherwise =
respond$ responseOk "Hello World"
where
= rawQueryString req
query = BS.tail $ rawPathInfo req -- без ведущего '/'
path
=
renderedListOfFunctions "\n"
LBS.intercalate $ "Available functions:"
: map LBS.fromStrict listOfFunctions
Теперь у нашего сервера есть маршрутизация, пусть и в зачаточном виде :)
Проверим работу того, что уже наработано, с помощью curl
(предполагается,
что сервер запущен в другом окне терминала):
$ curl http://localhost:8000
Available functions:
$ curl http://localhost:8000?asdf
No query parameters needed!
Получение описание и вызов функций
Теперь корневой url обрабатывается. Настало время поиска функции в библиотеке:
application req respond-- тут существующая маршрутизация
| otherwise =
respond$ maybe
"Unknown function!")
(responseNotFound -> responseOk
(\spec $ LBS.fromStrict
$ if query == ""
then describe spec
else call spec query)
$ getFunctionSpec path
Функций пока нет, но поиск уже работает. Проверим:
$ curl http://localhost:8000/func
Unknown function!
Добавим же наконец пару функций в библиотеку:
import Data.Char (toUpper)
-- ...
library :: Map FunctionName FunctionSpec
=
library "reverse", ("returns string with characters in reverset order",
fromList [(
BS.reverse ))"upper", ("returns string with each character in upper case",
,(toUpper ))] BS.map
И, разумеется, проверим:
$ curl http://localhost:8000
Available functions:
reverse
upper
$ curl http://localhost:8000/reverse
returns string with characters in reverset order
$ curl http://localhost:8000/reverse?Hello+World
dlroW olleH
Готово! Есть функции, и их можно вызывать удалённо!
Финальные штрихи
Сервер у нас есть, но неплохо было бы видеть какие запросы он получает и что на них отвечает, т.е. нам нужно логирование. Существует готовый пакет wai-logger, однако для практики мы напишем свою реализацию логирования - тоже простейшую.
Когда при работе с WAI-приложениями возникает необходимость сделать что-то с запросами и/или ответами на них, на сцену выходит тип Middleware:
Middleware :: Application -> Application
Middleware - это преобразователь приложений, настоящая функция высшего порядка! Как же такие преобразователи пишутся? Довольно просто:
import Network.Wai (Middleware, responseStatus)
import Network.HTTP.Types (statusCode)
-- ...
withLogging :: Middleware
=
withLogging app req respond $ \response -> do
app req putStrLn $ statusOf response ++ ": " ++ query
respond responsewhere
= BS.unpack
query $ BS.concat [ rawPathInfo req
, rawQueryString req ]= show . statusCode . responseStatus
statusOf
= do
main putStrLn ...
8000 $ withLogging application run
Ничего сверх-естественного, оборачивание вызова функции, как оно есть.
Выглядит вывод logger’а примерно так:
$ stack exec wai-rpc
Serving (hit Ctrl+C to stop)...
200: /reverse?Hello%20World
200: /
404: /asdf
400: /?asdf
...
Подобным образом можно осуществлять маршрутизацию, проверку на наличие, или отсутствие cookies, оптимизацию(сжатие) ответов и кэширование запросов. Такой подход, на мой взгляд, очень композируем да и просто и элегантен!
Заключение
Даже такой простой пример позволяет понять, что разработка сервисов на “голом” WAI не только довольно проста, но и вполне удобна и приятна :)