Какие механизмы используются для включения API Servant на основе типов?

Я очень озадачен тем, как Servant может достичь волшебства, которое он делает, используя набор текста. Пример на веб-сайте меня уже сильно озадачивает:

type MyAPI = "date" :> Get '[JSON] Date
        :<|> "time" :> Capture "tz" Timezone :> Get '[JSON] Time

Я получаю, что «дата», «время», [JSON] и «tz» являются литералами уровня типа. Это значения, которые "стали" типами. Хорошо.

Я понимаю, что :> и :<|> являются операторами типов. Хорошо.

Я не понимаю, как эти вещи после того, как они стали типами, могут быть извлечены обратно в значения. Каков механизм для этого?

Я также не понимаю, как первая часть этого типа может заставить фреймворк ожидать функцию сигнатуры IO Date, или как вторая часть этого типа может заставить фреймворк ожидать от меня функцию сигнатуры Timezone -> IO Time. Как происходит это превращение?

И как тогда фреймворк может вызвать функцию, для которой он изначально не знал тип?

Я уверен, что здесь есть ряд расширений и уникальных функций GHC, с которыми я не знаком, и которые объединяются, чтобы воплотить это волшебство.

Может кто-нибудь объяснить, какие функции здесь задействованы и как они работают вместе?


person Ana    schedule 31.10.2015    source источник
comment
Вы ознакомились с статьей? ... Не знаю, сможем ли мы получить лучшее объяснение, чем это ... может быть, вы прочитали его и вернетесь с подробными вопросами, которые вы не понимаете - вопрос здесь, по крайней мере, такой же широкий, как и статья;)   -  person Random Dev    schedule 31.10.2015
comment
Класс GHC.TypeLits.KnownSymbol и связанные с ним функции используются для преобразования строк уровня типа (Symbol) в строки уровня значения. Механизм практически одинаков для любого другого типа: используйте класс типа. Для генерации типов из других типов можно использовать класс типов или семейство типов. Вопрос о том, как довольно широко, но это короткая версия.   -  person user2407038    schedule 01.11.2015
comment
@Карстен О. Я не знал, что есть бумага. Спасибо :)   -  person Ana    schedule 01.11.2015
comment
Привет, я один из авторов. Бумаги были написаны именно для того, чтобы ответить, как все это работает? -- дайте мне знать, если у вас есть более точные вопросы, и я сделаю все возможное, чтобы ответить на них.   -  person Alp Mestanogullari    schedule 01.11.2015
comment
Это хорошее видео о серванте: youtube.com/watch?v=gMDiKOuwLXw   -  person The Internet    schedule 04.11.2015


Ответы (1)


Лучше всего посмотреть документ 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 :: *)

Мы определяем только четыре конструкции на нашем упрощенном языке:

  1. Get a представляет конечную точку типа a (типа *). По сравнению с полным Servant здесь мы игнорируем типы контента. Нам нужен тип данных только для спецификаций API. Теперь есть непосредственно соответствующие значения, и, следовательно, нет конструктора для Get.

  2. С помощью a :<|> b мы представляем выбор между двумя маршрутами. Опять же, нам не нужен конструктор, но оказывается, что мы будем использовать пару обработчиков для представления обработчика API с помощью :<|>. Для вложенных приложений :<|> мы получим вложенные пары обработчиков, которые выглядят несколько некрасиво, используя стандартную нотацию в Haskell, поэтому мы определяем конструктор :<|> как эквивалент пары.

  3. С помощью item :> rest мы представляем вложенные маршруты, где item — первый компонент, а rest — остальные компоненты. В нашем упрощенном DSL есть только две возможности для item: строка уровня типа или Capture. Поскольку строки уровня типа относятся к типу Symbol, а Capture, определенный ниже, относится к типу *, мы делаем первый аргумент :> полиморфным для вида, так что оба варианта принимаются системой типов Haskell. .

  4. 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