Лучше всего посмотреть документ Servant для полного объяснения. Тем не менее, я попытаюсь проиллюстрировать здесь подход Servant, реализовав «TinyServant», версию Servant, урезанную до минимума.
Извините, что этот ответ такой длинный. Тем не менее, он все же немного короче статьи, а обсуждаемый здесь код состоит «всего» из 81 строки и доступен также в виде файла Haskell здесь.
Подготовка
Для начала вот языковые расширения, которые нам понадобятся:
{-# LANGUAGE DataKinds, PolyKinds, TypeOperators #-}
{-# LANGUAGE TypeFamilies, FlexibleInstances, ScopedTypeVariables #-}
{-# LANGUAGE InstanceSigs #-}
Первые три необходимы для определения самого DSL уровня типа. DSL использует строки уровня типа (DataKinds
), а также полиморфизм типа (PolyKinds
). Использование операторов инфикса уровня типа, таких как :<|>
и :>
, требует расширения TypeOperators
.
Вторые три нужны для определения интерпретации (мы определим нечто, напоминающее то, что делает веб-сервер, но без всей веб-части). Для этого нам нужны функции уровня типов (TypeFamilies
), некоторое программирование классов типов, для которого потребуется (FlexibleInstances
), и некоторые аннотации типов для руководства средством проверки типов, для которых требуется ScopedTypeVariables
.
Чисто для целей документации мы также используем InstanceSigs
.
Вот заголовок нашего модуля:
module TinyServant where
import Control.Applicative
import GHC.TypeLits
import Text.Read
import Data.Time
После этих предварительных действий мы готовы приступить к работе.
Спецификации API
Первым компонентом является определение типов данных, которые используются для спецификаций API.
data Get (a :: *)
data a :<|> b = a :<|> b
infixr 8 :<|>
data (a :: k) :> (b :: *)
infixr 9 :>
data Capture (a :: *)
Мы определяем только четыре конструкции на нашем упрощенном языке:
Get a
представляет конечную точку типа a
(типа *
). По сравнению с полным Servant здесь мы игнорируем типы контента. Нам нужен тип данных только для спецификаций API. Теперь есть непосредственно соответствующие значения, и, следовательно, нет конструктора для Get
.
С помощью a :<|> b
мы представляем выбор между двумя маршрутами. Опять же, нам не нужен конструктор, но оказывается, что мы будем использовать пару обработчиков для представления обработчика API с помощью :<|>
. Для вложенных приложений :<|>
мы получим вложенные пары обработчиков, которые выглядят несколько некрасиво, используя стандартную нотацию в Haskell, поэтому мы определяем конструктор :<|>
как эквивалент пары.
С помощью item :> rest
мы представляем вложенные маршруты, где item
— первый компонент, а rest
— остальные компоненты. В нашем упрощенном DSL есть только две возможности для item
: строка уровня типа или Capture
. Поскольку строки уровня типа относятся к типу Symbol
, а Capture
, определенный ниже, относится к типу *
, мы делаем первый аргумент :>
полиморфным для вида, так что оба варианта принимаются системой типов Haskell. .
Capture a
представляет компонент маршрута, который захватывается, анализируется и затем предоставляется обработчику как параметр типа a
. В полном Servant Capture
имеет дополнительную строку в качестве параметра, который используется для генерации документации. Здесь мы опускаем строку.
Пример API
Теперь мы можем записать версию спецификации API из вопроса, адаптированную к фактическим типам, встречающимся в Data.Time
, и к нашему упрощенному DSL:
type MyAPI = "date" :> Get Day
:<|> "time" :> Capture TimeZone :> Get ZonedTime
Интерпретация как сервер
Самым интересным аспектом, конечно же, является то, что мы можем делать с API, и это также в основном то, о чем идет речь.
Слуга определяет несколько интерпретаций, но все они следуют одному и тому же шаблону. Здесь мы определим один, который вдохновлен интерпретацией веб-сервера.
В Servant функция serve
принимает прокси для типа API и обработчик, соответствующий типу API для WAI Application
, который по сути является функцией от HTTP-запросов до ответов. Здесь мы абстрагируемся от веб-части и определим
serve :: HasServer layout
=> Proxy layout -> Server layout -> [String] -> IO String
вместо.
Класс HasServer
, который мы определим ниже, имеет экземпляры для всех различных конструкций DSL уровня типов и, следовательно, кодирует то, что означает, что тип Haskell layout
может быть интерпретирован как тип API сервера.
Proxy
устанавливает связь между типом и уровнем значения. Это определяется как
data Proxy a = Proxy
и его единственная цель заключается в том, что, передав конструктор Proxy
с явно указанным типом, мы можем сделать его очень явным для того, какой тип API мы хотим вычислить на сервере.
Аргумент Server
является обработчиком API
. Здесь Server
сам по себе является семейством типов и вычисляет из типа API тип, который должен иметь обработчик (обработчики). Это один из основных компонентов того, что заставляет Servant работать правильно.
Список строк представляет собой запрос, сведенный к списку компонентов URL. В результате мы всегда возвращаем ответ String
и разрешаем использование IO
. Full Servant использует несколько более сложные типы, но идея та же.
Семейство типов Server
Сначала мы определяем Server
как семейство типов. (В Servant фактическое используемое семейство типов — ServerT
, и оно определено как часть класса HasServer
.)
type family Server layout :: *
Обработчик конечной точки Get a
— это просто действие IO
, создающее a
. (Еще раз, в полном коде Servant у нас немного больше опций, например, создание ошибки.)
type instance Server (Get a) = IO a
Обработчик для a :<|> b
представляет собой пару обработчиков, поэтому мы могли бы определить
type instance Server (a :<|> b) = (Server a, Server b) -- preliminary
Но, как указано выше, для вложенных вхождений :<|>
это приводит к вложенным парам, которые выглядят несколько лучше с конструктором инфиксной пары, поэтому Servant вместо этого определяет эквивалентный
type instance Server (a :<|> b) = Server a :<|> Server b
Остается объяснить, как обрабатывается каждый из компонентов пути.
Литеральные строки в маршрутах не влияют на тип обработчика:
type instance Server ((s :: Symbol) :> r) = Server r
Захват, однако, означает, что обработчик ожидает дополнительный аргумент захватываемого типа:
type instance Server (Capture a :> r) = a -> Server r
Вычисление типа обработчика примера API
Если мы расширим Server MyAPI
, мы получим
Server MyAPI ~ Server ("date" :> Get Day
:<|> "time" :> Capture TimeZone :> Get ZonedTime)
~ Server ("date" :> Get Day)
:<|> Server ("time" :> Capture TimeZone :> Get ZonedTime)
~ Server (Get Day)
:<|> Server ("time" :> Capture TimeZone :> Get ZonedTime)
~ IO Day
:<|> Server ("time" :> Capture TimeZone :> Get ZonedTime)
~ IO Day
:<|> Server (Capture TimeZone :> Get ZonedTime)
~ IO Day
:<|> TimeZone -> Server (Get ZonedTime)
~ IO Day
:<|> TimeZone -> IO ZonedTime
Итак, как и предполагалось, серверу для нашего API требуется пара обработчиков, один из которых предоставляет дату, а другой, учитывая часовой пояс, предоставляет время. Мы можем определить их прямо сейчас:
handleDate :: IO Day
handleDate = utctDay <$> getCurrentTime
handleTime :: TimeZone -> IO ZonedTime
handleTime tz = utcToZonedTime tz <$> getCurrentTime
handleMyAPI :: Server MyAPI
handleMyAPI = handleDate :<|> handleTime
Класс HasServer
Нам еще предстоит реализовать класс HasServer
, который выглядит следующим образом:
class HasServer layout where
route :: Proxy layout -> Server layout -> [String] -> Maybe (IO String)
Задача функции route
почти как serve
. Внутри мы должны отправить входящий запрос на правильный маршрутизатор. В случае :<|>
это означает, что мы должны сделать выбор между двумя обработчиками. Как мы делаем этот выбор? Простой вариант — допустить ошибку route
, вернув Maybe
. (Опять же, полный Servant здесь несколько сложнее, а версия 0.5 будет иметь значительно улучшенную стратегию маршрутизации.)
Как только мы определили route
, мы можем легко определить serve
в терминах route
:
serve :: HasServer layout
=> Proxy layout -> Server layout -> [String] -> IO String
serve p h xs = case route p h xs of
Nothing -> ioError (userError "404")
Just m -> m
Если ни один из маршрутов не совпадает, мы теряем 404. В противном случае мы возвращаем результат.
HasServer
экземпляров
Для конечной точки Get
мы определили
type instance Server (Get a) = IO a
поэтому обработчик представляет собой действие ввода-вывода, создающее a
, которое мы должны превратить в String
. Мы используем show
для этой цели. В фактической реализации Servant это преобразование обрабатывается механизмом типов контента и обычно включает кодирование в JSON или HTML.
instance Show a => HasServer (Get a) where
route :: Proxy (Get a) -> IO a -> [String] -> Maybe (IO String)
route _ handler [] = Just (show <$> handler)
route _ _ _ = Nothing
Поскольку мы сопоставляем только конечную точку, на этом этапе требуется, чтобы запрос был пустым. Если это не так, этот маршрут не совпадает, и мы возвращаем Nothing
.
Давайте посмотрим на выбор дальше:
instance (HasServer a, HasServer b) => HasServer (a :<|> b) where
route :: Proxy (a :<|> b) -> (Server a :<|> Server b) -> [String] -> Maybe (IO String)
route _ (handlera :<|> handlerb) xs =
route (Proxy :: Proxy a) handlera xs
<|> route (Proxy :: Proxy b) handlerb xs
Здесь мы получаем пару обработчиков и используем <|>
для Maybe
, чтобы попробовать оба.
Что происходит с литеральной строкой?
instance (KnownSymbol s, HasServer r) => HasServer ((s :: Symbol) :> r) where
route :: Proxy (s :> r) -> Server r -> [String] -> Maybe (IO String)
route _ handler (x : xs)
| symbolVal (Proxy :: Proxy s) == x = route (Proxy :: Proxy r) handler xs
route _ _ _ = Nothing
Обработчик для s :> r
имеет тот же тип, что и обработчик для r
. Мы требуем, чтобы запрос был непустым, а первый компонент соответствовал эквиваленту уровня значения строки уровня типа. Мы получаем строку уровня значения, соответствующую строковому литералу уровня типа, применяя symbolVal
. Для этого нам нужно ограничение KnownSymbol
для строкового литерала уровня типа. Но все конкретные литералы в GHC автоматически являются экземплярами KnownSymbol
.
Последний случай для захватов:
instance (Read a, HasServer r) => HasServer (Capture a :> r) where
route :: Proxy (Capture a :> r) -> (a -> Server r) -> [String] -> Maybe (IO String)
route _ handler (x : xs) = do
a <- readMaybe x
route (Proxy :: Proxy r) (handler a) xs
route _ _ _ = Nothing
В этом случае мы можем предположить, что наш обработчик на самом деле является функцией, которая ожидает a
. Мы требуем, чтобы первый компонент запроса можно было разобрать как a
. Здесь мы используем Read
, тогда как в Servant мы снова используем механизм типов контента. Если чтение не удается, мы считаем, что запрос не соответствует. В противном случае мы можем передать его обработчику и продолжить.
Тестирование всего
Теперь мы закончили.
Мы можем подтвердить, что все работает в GHCI:
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI ["time", "CET"]
"2015-11-01 20:25:04.594003 CET"
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI ["time", "12"]
*** Exception: user error (404)
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI ["date"]
"2015-11-01"
GHCi> serve (Proxy :: Proxy MyAPI) handleMyAPI []
*** Exception: user error (404)
person
kosmikus
schedule
01.11.2015
GHC.TypeLits.KnownSymbol
и связанные с ним функции используются для преобразования строк уровня типа (Symbol
) в строки уровня значения. Механизм практически одинаков для любого другого типа: используйте класс типа. Для генерации типов из других типов можно использовать класс типов или семейство типов. Вопрос о том, как довольно широко, но это короткая версия. - person user2407038   schedule 01.11.2015