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
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 не только довольно проста, но и вполне удобна и приятна :)