Какие механизмы используются для включения API на основе типов Servant?
Я очень озадачен тем, как Слуга способен достичь волшебства, которое он делает, используя набор текста. Пример на веб-сайте уже очень озадачивает меня:
type MyAPI = "date" :> Get '[JSON] Date
:<|> "time" :> Capture "tz" Timezone :> Get '[JSON] Time
Я получаю "дату", "время", [JSON]
и "tz" - литералы уровня типа. Это ценности, которые стали "типами". Хорошо.
я понимаю :>
а также :<|>
являются операторами типа. Хорошо.
Я не понимаю, как эти вещи, после того как они стали типами, могут быть извлечены обратно в значения. Какой механизм для этого?
Я также не понимаю, как первая часть этого типа может заставить фреймворк ожидать функцию сигнатуры IO Date
или как вторая часть этого типа может заставить платформу ожидать функцию сигнатуры Timezone -> IO Time
от меня. Как происходит это преобразование?
И как тогда фреймворк может вызвать функцию, для которой он изначально не знал тип?
Я уверен, что здесь есть ряд расширений GHC и уникальных функций, которые я не знаком с этим объединением, чтобы это волшебство произошло.
Может кто-нибудь объяснить, какие функции здесь задействованы и как они работают вместе?
1 ответ
Глядя на документ Слуги для полного объяснения может быть лучшим вариантом. Тем не менее, я попытаюсь проиллюстрировать подход, принятый 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
, В полном слуге,Capture
имеет дополнительную строку в качестве параметра, который используется для генерации документации. Мы опускаем строку здесь.
Пример API
Теперь мы можем записать версию спецификации API из вопроса, адаптированной к фактическим типам, встречающимся в Data.Time
и к нашему упрощенному DSL:
type MyAPI = "date" :> Get Day
:<|> "time" :> Capture TimeZone :> Get ZonedTime
Интерпретация как сервер
Самым интересным аспектом, конечно же, является то, что мы можем сделать с помощью API, и это также в основном вопрос.
Слуга определяет несколько интерпретаций, но все они следуют схожему образцу. Мы определим один здесь, который вдохновлен интерпретацией как веб-сервер.
У Слуги 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 тип, который должен иметь обработчик (и). Это один из основных компонентов того, что заставляет Слугу работать правильно.
Список строк представляет запрос, сведенный к списку компонентов 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)