servant-util:用数据库集成来扩展servant教程

263 阅读14分钟

对于后端应用, servant是Serokell中最常使用的框架。

然而,我们不时发现自己在写同样的模板,从理论上讲,可以进行概括和提取。 这种模板出现的逻辑可能不仅仅是关于webservers的,也是关于数据库的,因此,servant 在这里没有帮助我们是合理的。但是,有一个涵盖webserver-数据库集成的一些基本用例的库仍然是很方便的。

在这篇文章中,我们将讨论那些最好的功能,以及我们新的servant-util 库如何处理这些功能。

概述servant

servant 是最流行的Haskell 框架,用于围绕服务器-客户端架构实现应用程序。它可以提供REST API、原始HTML等。

在编写我们自己的服务器时,我们首先写下API的类型级描述。 作为一个例子,让我们考虑一个书店API。

type API
  =    "book" :> Capture "id" Int :> Get '[JSON] Book
  :<|> "book" :> "all" :> Get '[JSON] Book
  :<|> "book" :> ReqBody '[JSON] Book :> Post '[JSON] Result

这声明了三个端点。 我们的API的路由和任何复杂的结构都是在类型中处理的。 应该提供接受的数据和响应的类型,以及序列化方法(在我们的例子中是JSON ),这些都是由servant 自动处理的。服务器处理程序将只关注与简单的Haskell值的工作,如Book

该框架还提供了一些其他的集成。

  • 我们可以从API 类型中生成一个客户端。生成的客户端可以与GHCJS完美配合。

因此,如果前端也是用Haskell写的(通过使用Reflex或其他框架),我们可以在前端代码和后端代码之间共享API的类型来查询API,servant ,其余的都会为你处理。

  • OpenAPI 2和OpenAPI 3的文档也可以从API中生成。 在这里我们将确保文档保持最新,只是因为它是自动生成的。

上述API 的服务器处理程序可能看起来像:

getBookById :: Int -> Handler Book
getBooks :: Handler [Book]
createBook :: Book -> Handler Result

server :: Server API
server = getBookById
    :<|> getBooks
    :<|> createBook

在这里,我们可以看到所有处理程序的参数类型和结果类型都是普通的Haskell数据类型。默认情况下,处理程序在Handler monad中操作,但通过servant ,这也是可以定制的:我们也可以使用mtl 或任何其他方法。

其他内置的servant 功能包括:

  • GET参数、响应和请求头。
  • 对请求和响应进行JSON序列化。
  • 单个端点的多种可能结果(包括,例如,错误响应)。
  • 错误响应的定制(例如,对于序列化错误)。
  • 流媒体。

然而,正如这篇文章的主题所表明的那样,也有很多东西是缺失的:

  • 数据库集成。
  • 衡量标准(例如Prometeus)。
  • 语义跟踪和记录。
  • 对批量请求进行分类和过滤。
  • 分页。
  • 及其他。

所以在我们的一个项目中,我们需要实现我们自己的东西,其他公司通常不会分享,但我们决定分享我们的工作。 于是 servant-util包就这样诞生了。

这篇文章的其余部分是对这个库的快速浏览,以一些例子说明它能做什么。

介绍一下servant-util

为了展示这一点,我们将在一个简单的例子中工作:一个Web应用程序,在它的用户界面中,我们将显示一些物品的表格,这些物品可以是商店里的商品、图书馆里的书籍、数据库里的电影等等。 用户通常也有一个搜索字段来按名称过滤物品,以及分页控件来遍历整个物品列表。

为了显示这些数据,UI必须从某个地方获得这些数据,在我们的例子中,这些数据是由一个用servant 编写的Haskell后端提供的。但后端本身并不包含这些数据,所以它要到数据库中获得这些数据。 这是任何Web应用程序的一个众所周知的结构。

而实际的过滤、排序和分页必须在某个地方发生。

最简单的解决方案是告诉你的前端同事:好吧,就在你这边做吧。 我们将为你提供数据库中的所有项目,而你将做任何必要的处理。

UI sorting

的确,这是最直接的方法,也是最错误的方法,因为我们将在后台和用户界面之间传输太多的数据。

一个更合适的解决方案是在后端进行过滤,并只将数据的一个子集发送到用户界面。

Backend sorting

然而,从数据库中获取所有的项目对于后端和数据库之间的网络以及后端代码本身来说仍然是一个太大的负担。

在99%的情况下,最合适的解决方案是使用内置的数据库功能进行过滤、排序和分页。 这样,UI和后台都只对数据的一个子集进行操作。

Managed from database

然而,请记住,实际上是由用户来控制过滤和排序的执行,我们需要将用户的过滤和排序的标准传播到数据库中。 而且我们需要将这些数据沟通两次--首先从UI到后台,然后从后台到数据库。

第二部分很容易--将过滤和排序参数传达给数据库,通常可以通过查询命令如WHEREORDER BY

然而,在用户界面和后端之间没有标准化的协议来执行这种通信。 而且,不幸的是,每个面临这个问题的网络开发者都不得不发明他们自己的组合器,他们自己的服务器可以理解的查询语言。

我们已经搜索了世界上最常用的模式,并建立了我们的库来支持它们。

特点

排序

假设我们的API有这个Book ,有3个字段。

data Book = Book
  { isbn :: Isbn
  , bookName :: Text
  , author :: Text
  , year :: Int
  }

还有一个简单的路由,从服务中获取所有的书籍。

type GetBooks
  =  "books"
  :> Get '[JSON] [Book]

这个路由提供了一个GET 方法,返回一个序列化为JSON 的书籍列表。我们想用获取参数来扩展这个路由,以指定过滤和排序的内容。

我们的要求是什么?

  • 默认按isbn (升序)排序。
  • 允许用户对任何字段进行排序。

默认的排序对于确定的结果是必要的。我们不希望得到的列表是由数据库产生的一些随机顺序。

为了这个目的,servant-util 提供了一个组合器,叫做SortingParams 。所以我们可以这样扩展我们的API。

type GetBooks
  =  "books"
  :> SortingParams
      '["isbn" ?: Isbn, "name" ?: Text, "author" ?: Text, "year" ?: Int]
      '["isbn" ?: 'Asc Isbn]
  :> Get '[JSON] [Book]

这个新增的SortingParams ,接受两个类型级别的列表:

  1. 用户可以排序的字段。
  2. 参与基本排序的字段,在这两种情况下,都将按词汇表的顺序最后应用。

我们在类型级别添加这个参数,因为我们希望它被记录下来,影响生成的客户端等。

我们的GetBooks 路线现在可以接受哪些查询? 一些例子:

  • GET /books - 只是返回所有的东西,除了默认的 ,没有排序将被应用。isbn
  • GET /books?sortBy=asc(name) - 指定按名称排序是升序的。如果有两本同名的书,它们将按 进行排序。isbn
  • GET /books?sortBy=asc(name),desc(author) - 首先按名字升序排序,然后按作者降序排序。
  • GET /books?sortBy=+name,-author - 这是上述查询的另一种语法。你可以根据你的审美或哪些字符可以出现在字段名中来选择语法。

在我们看来,这个API足以满足大多数使用情况。 现在让我们来看看如何为这个API实现一个处理程序。

SortingParams combinator使处理程序接受一个各自的 参数,该参数保持用户要求我们执行的排序。在这个SortingSpec 里面,大致上看起来像 SortingSpec

newtype SortingParams provided base = SortingParams [SortingItem]

data SortingItem = SortingItem
  { siName :: Text           -- ^ field name
  , siOrder :: SortingOrder  -- ^ order to sort by
  }

data SortingOrder
  = Descendant
  | Ascendant

以后这个规范可以用于:

  • 在后端手动应用排序。
  • 将规范翻译成SQL。
  • 生成数据(例如,用于模拟服务器)。

在我们的案例中,我们想使用SQL查询。如果我们使用beam-postgresql 来编写我们的查询,我们的处理程序将看起来像这样。

import Data.Beam.Query (all_, select, runSelectReturningList)
import Servant.Util.Beam.Postgres (fieldSort, sortBy_)

getBooks
  :: SortingSpec [...] [...]
  -> Handler [Book]
getBooks sortingSpec = do
  runSelectReturningList . select $
    sortBy_ sortingSpec sortingApp $  -- (A)
    all_ (books booksDB)
  where
    sortingApp Book{..} =  -- (B)
      fieldSort @"isbn" isbn .*.
      fieldSort @"name" bookName .*.
      fieldSort @"author" author .*.
      HNil

(A) 中,我们使用相邻的servant-util-beam-pg 包提供的sortBy_ 函数来添加必要的排序。这将把用户提供的排序规范翻译成必要的ORDER BY SQL查询。

(B) ,我们定义sortingApp ,声明用户提供的排序必须应用到我们的数据库模型中。 在这里,我们把在我们的API中出现的字段与我们数据库中的字段联系起来。

sortingApp 这里将有 类型,并将检查关联是否正确。如果某些字段名称错误,或者API中的类型和数据库模型中的类型不匹配,我们将得到一个编译时错误。SortingApp BeamSortingBackend ["isbn" ?: Isbn, ...]

通过这段代码,如果用户向我们提供sortBy=+name,-author 参数,我们将向数据库提交以下SQL查询。

SELECT * FROM books
ORDER BY name ASC, author DESC

因此,回顾一下:

  • servant-util 定义了GET排序参数的查询格式。
  • 这个格式被解析为排序规范。
  • 处理程序可以直接使用该规格或翻译成SQL。
  • 我们提供servant-util-beam-pg 与Beam集成。
  • 合理的OpenAPI文档和客户端处理程序可以自动生成。

如果你不想使用Beam,你可以随时声明你自己的数据库后端,并提供必要的类型实例来实现排序。

筛选

对于有关的用户界面来说,仅仅有排序总是不够的,我们还必须支持过滤。

用户可能想要:

  • 按确切的ISBN过滤。
  • 按年份范围进行过滤。
  • 通过名字的子串过滤。
  • 按模式过滤。

为了支持这一点,servant-util 提供了专用的FilteringParams 组合器,所以我们可以写:

type GetBooks
  =  FilteringParams
       [ "isbn" ?: 'AutoFilter Isbn
       , "name" ?: 'AutoFilter Text
       , "year" ?: 'AutoFilter Int
       ]
  :> Get '[JSON] [Books]

FilteringParams ,我们列出了我们允许过滤的字段名和相应的类型。

'AutoFilter 指定允许用户为给定的字段提供多种过滤操作,它们将被自动支持。 允许的过滤操作的确切列表是在每个类型的基础上指定的。 例如,如果我们想允许 Isbn的精确匹配和比较操作,我们将写:

type instance SupportedFilters Isbn = '[FilterMatching, FilterComparing]

现在让我们来探讨一下我们可以创建什么样的过滤器。

筛选器匹配

这是最基本的过滤器,通常被提供:

  • GET /books?isbn=12345 - 获得具有给定ISBN的书。
  • GET /books?isbn[neq]=12345 - 得到所有的书,除了有给定的ISBN的书。
  • GET /books?isbn[in]=[123,456,789] - 获得具有所提供的ISBN值的书。

筛选比较

数值字段通常支持比较操作:

  • GET /books?year[gt]=1990 - 获取 之后的书籍。1990
  • GET /books?year[gte]=1994&year[lte]=2007 - 获取 范围内的书籍。[1994..2007]

筛选器类似

文本过滤器:

  • GET /books?author[like]=Alexander* - 对作者做一个简单的类似于Rgex的匹配。
  • GET /books?author[contains]=sweet - 做一个简单的子串匹配。

手动过滤器

相对于'AutoFilter'ManualFilter ,不提供自动逻辑。相反,它允许用户传递一个值,后台为其提供一个任意的匹配逻辑。

当然,定义自定义过滤器也是可能的。请注意,像TextInt 这样的基本类型所支持的过滤器列表是由库硬编码的,不能扩展到包括自定义过滤器操作。但在servant 的情况下,为所有类型定义自定义数据类型和新类型包装器是一个好的做法(至少是为了生成更整洁的文档)。在这种情况下,可以为API类型定义任何必要的过滤器。

和排序一样,对于过滤,我们也提供了与beam 包的集成。我们提供了一个名为matches_ (A)的组合器,它与beam的标准组合器guard_ 兼容。

import Database.Beam.Query (all_, guard_, select, runSelectReturningList)
import Servant.Util.Beam.Postgres (matches_)

getBooks
  :: FilteringSpec [...]
  -> Handler [Book]
getBooks filterSpec = do
  runSelectReturningList . select $
    guard_ (matches_ filterSpec filterApp) $  -- (A)
    all_ (books booksDB)
  where
  filterApp Book{..} =  -- (B)
    filterOn @"isbn" isbn .*.
    filterOn @"name" bookName .*.
    HNil

在这里,我们提供了从用户那里得到的filterSpec ,并构建了一个filterApp (B),描述了如何将我们的过滤器应用于具体的数据类型,这些数据类型被存储在数据库中。

分页

最后一个重要部分是分页。

对于它,我们提供了一个简单的组合器,叫做PaginationParams

type GetBooks
  =  PaginationParams (DefPageSize 20)
  :> Get '[JSON] [Book]

它包含默认的页面大小--如果用户没有指定页面大小,我们一次只显示20

处理程序将接受--正如人们所猜测的--PaginationSpec 参数,其内部看起来就像:

data PaginationSpec = PaginationSpec
  { psOffset :: Natural
  , psLimit :: Maybe (Positive Natural)
  }

所以它代表了众所周知的偏移限制分页,它有自己的缺点,但在基本情况下可以安全使用。

正如预期的那样,有了PaginationParams ,服务器API开始接受offsetlimit 查询参数,例如:

  • GET /books?offset=40&limit=20.

客户端生成

我们也可以生成客户端处理程序来构建对服务器的查询书API。为此你可以使用来自servant-client 的标准client 函数。

getBooks :: ClientM GetBooks
getBooks = client (Proxy @GetBooks)
-- getBooks
--   :: SortingSpec
--        ["isbn" ?: Isbn, "name" ?: Text, "author" ?: Text]
--       '["isbn" ?: Isbn]
--   -> FilteringSpec
--        [ "isbn" ?: 'AutoFilter Isbn, "year" ?: 'AutoFilter Int,
--        , "name" ?: 'AutoFilter Text, "author" ?: 'AutoFilter Text
--        ]
--   -> PaginationSpec
--   -> ClientM [Book]

因此,通过API类型,我们得到一个getBooks 函数来执行单体ClientM 中的查询。这个ClientM 是标准的服务客户端东西。它基本上携带了服务器的坐标和一些查询选项。

SortingSpec 和其他规格可以用专用的智能构造器来构造。例如,我们可以写:

foo :: ClientM Int
foo = do
  books <- getBooks sortingSpec filterSpec paginationSpec
  return books
  where
    sortingSpec = mkSortingSpec [asc #name, desc #author]
    filterSpec = mkFilteringSpec [#year ?/>= 1994, #year ?/<= 2007]
    paginationSpec = skipping 30 $ itemsOnPage 10

这里mkSortingSpecmkFilteringSpec 只是fromList 的别名。当编写许多场景时,在模块中启用OverloadedLists 可以获得最佳体验。

我们使用ascdesc 组合器来指定排序顺序,并使用?/ 类似的操作符来构建过滤器。哈希符号符号,如#year ,意味着名称被提升到类型级别,以便进一步进行类型级别检查。

字段名以及过滤器操作中的任何错误都会导致编译错误。 提供给过滤器的值的类型是自动推断的,所以我们不需要像1994 :: Int 那样手动指定类型。

Swagger

该库还支持生成OpenAPI 2文档,又称Swagger,OpenAPI 3的生成正在进行中。

文档中包含了对排序工作原理的完整描述,所以你不需要为此提供任何额外的代码。 你只需要提供servant组合器,你的前端开发同事就能从OpenAPI规范中获得他们需要的所有信息。

Sorting in Swagger

总结

让我们回顾一下这篇文章中我们所看到的内容:

  • servant-util 提供排序、过滤和分页组合器(而且该库不限于此)。
  • 通过Beam与PostgreSQL集成,并且可以通过添加一些类型类实例扩展到支持其他后端。
  • 类型类的可扩展性:新的后端,新的过滤器,等等。
  • servant-client 是支持的。
  • 生成Swagger规范。
  • 用于原型设计的假后端,不含数据库。