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 будет следующим:

“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
  application _ respond = respond $
    responseLBS status200
                [(hContentType, "text/plain")]
                "Hello World"

  main :: IO ()
  main = do
    putStrLn "Serving..."
    run 8000 application

К слову, этот код практически слово-в-слово повторяет 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
              [(hContentType, "text/plain")]
              -- :: [(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
  responseOk         = responsePlainText status200
  responseNotFound   = responsePlainText notFound404
  responseBadRequest = responsePlainText badRequest400

  responsePlainText :: Status -> LBS.ByteString -> Response
  responsePlainText =
    (`responseLBS` [(hContentType, "text/plain")])

(в зависимости проекта нужно будет добавить bytestring)

Так как мы собираемся обрабатывать только GET-запросы, добавим отсечку по типу запроса с соответствующим сообщением об ошибке:

  import Network.Wai (requestMethod)
  import Network.HTTP.Types (methodGet)

  application req respond = respond $
    if requestMethod req /= methodGet
    then responseBadRequest "Only GET method is allowed!"
    else -- далее всё как раньше с учетом вспом. функций
      responseOk "Hello World"

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
  library = fromList []

  getFunctionSpec :: FunctionName -> Maybe FunctionSpec
  getFunctionSpec = (`lookup` library)

  listOfFunctions :: [FunctionName]
  listOfFunctions = keys library

  describe :: FunctionSpec -> FunctionDescription
  describe = fst

  call :: FunctionSpec -> FunctionArg -> FunctionResult
  call = snd

(в зависимости проекта нужно будет добавить 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
      query = rawQueryString req
      path  = BS.tail $ rawPathInfo req -- без ведущего '/'

      renderedListOfFunctions =
        LBS.intercalate "\n"
        $ "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
      (responseNotFound "Unknown function!")
      (\spec -> responseOk
                $ 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 =
    fromList [("reverse", ("returns string with characters in reverset order",
                           BS.reverse ))
             ,("upper",   ("returns string with each character in upper case",
                           BS.map toUpper ))]

И, разумеется, проверим:

  $ 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 =
    app req $ \response -> do
      putStrLn $ statusOf response ++ ": " ++ query
      respond response
    where
      query = BS.unpack
            $ BS.concat [ rawPathInfo    req
                        , rawQueryString req ]
      statusOf = show . statusCode . responseStatus

   main = do
     putStrLn ...
     run 8000 $ withLogging application

Ничего сверх-естественного, оборачивание вызова функции, как оно есть.

Выглядит вывод logger’а примерно так:

  $ stack exec wai-rpc
  Serving (hit Ctrl+C to stop)...
  200: /reverse?Hello%20World
  200: /
  404: /asdf
  400: /?asdf
  ...

Подобным образом можно осуществлять маршрутизацию, проверку на наличие, или отсутствие cookies, оптимизацию(сжатие) ответов и кэширование запросов. Такой подход, на мой взгляд, очень композируем да и просто и элегантен!

Заключение

Даже такой простой пример позволяет понять, что разработка сервисов на “голом” WAI не только довольно проста, но и вполне удобна и приятна :)