加入俱乐部:类型安全加入 Esqueleto

加入俱乐部:类型安全加入 Esqueleto

原文:https://medium.com/hackernoon/join-the-club-type-safe-joins-with-esqueleto-db7ef2ea7b14

在过去的四篇左右的文章中,我们对 Haskell 库进行了一次真正的旋风之旅。我们使用 Persistent 创建了一个数据库模式,并使用它以类型安全的方式编写基本的 SQL 查询。我们看到了如何通过带有 Servant 的 API 来公开这个数据库。我们还使用 Redis 为该服务器添加了一些缓存。最后,我们围绕这个 API 的行为编写了一些基本测试。通过使用 Docker,我们使这些测试具有可重复性。

在本文中,我们将通过向模式中添加另一种类型来回顾整个过程。我们将为一个Article类型编写一些新的端点,并用一个外键将这个类型链接到我们现有的User类型。然后我们再学一个库: Esqueleto 。Esqueleto 允许我们编写类型安全的 SQL 连接,从而改进了 Persistent。

与之前的文章一样,本系列的 Github 库上有一个特定的分支。去那里看一看 esqueleto 分支以查看本文的完整代码。

将文章添加到我们的架构中

所以我们的第一步是用我们的Article类型扩展我们的模式。我们将为每篇文章指定一个标题、一些正文和发布时间的时间戳。我们将看到的一个新特性是,我们将添加一个外键来引用撰写文章的用户。下面是它在我们的模式中的样子:

PTH.share [PTH.mkPersist PTH.sqlSettings, PTH.mkMigrate "migrateAll"] [PTH.persistLowerCase|
 User sql=users
   ... Article sql=articles
   title Text
   body Text
   publishedTime UTCTime
   authorId UserId
   UniqueTitle title
   deriving Show Read Eq
|]

我们可以在模式中使用UserId作为类型。当我们在数据库中创建表时,这将创建一个外键列。实际上,当我们在 Haskell 中使用它时,我们的文章类型将如下所示:

data Article = Article
 { articleTitle :: Text
 , articleBody :: Text
 , articlePublishedTime :: UTCTime
 , articleAuthorId :: Key User
 }

这意味着它不引用整个用户。相反,它包含该用户的 SQL 键。因为我们将把文章添加到我们的 API 中,所以我们也需要添加 ToJSON 和 FromJSON 实例。这些也是非常基本的,所以如果你好奇的话,你可以在这里查看它们。如果你对 JSON 实例感兴趣,看看这篇文章。

添加端点

现在我们将扩展我们的 API 来公开关于这些文章的某些信息。首先,我们将编写几个基本的端点来创建一篇文章,然后通过它的 ID 获取它:

type FullAPI = 
      "users" :> Capture "userid" Int64 :> Get '[JSON] User
 :<|> "users" :> ReqBody '[JSON] User :> Post '[JSON] Int64
 :<|> "articles" :> Capture "articleid" Int64 :> Get '[JSON] Article
 :<|> "articles" :> ReqBody '[JSON] Article :> Post '[JSON] Int64

现在,我们将编写几个特殊的端点。第一个将用户 ID 作为一个键,然后它将提供用户写的所有不同的文章。我们将这个端点称为/articles/author/:authorid

...
 :<|> "articles" :> "author" :> Capture "authorid" Int64 :> Get '[JSON] [Entity Article]

我们的最后一个端点将获取最新的文章,最多 10 篇。这将不带任何参数,并在/articles/recent路线上运行。它将返回用户及其文章的元组,两者都是实体。

…
 :<|> "articles" :> "recent" :> Get '[JSON] [(Entity User, Entity Article)]

添加查询(使用 Esqueleto!)

在我们实际实现这些端点之前,我们需要为它们编写基本的查询。为了创建一篇文章,我们使用标准的持久化insert函数:

createArticlePG :: PGInfo -> Article -> IO Int64
createArticlePG connString article = fromSqlKey <$> runAction connString (insert article)

我们可以对基本的获取端点做同样的事情。但是为了开始学习语法,我们将使用 Esqueleto 编写这个基本查询。对于 Persistent,我们使用列表参数来指定不同的过滤器和 SQL 操作。相反,Esqueleto 使用一个特殊的单子来组成不同类型的查询。esqueleto select 调用的一般格式如下所示:

fetchArticlePG :: PGInfo -> Int64 -> IO (Maybe Article)
fetchArticlePG connString aid = runAction connString selectAction
 where
   selectAction :: SqlPersistT (LoggingT IO) (Maybe Article)
   selectAction = select . from $ \articles -> do
     ...

我们使用select . from,然后提供一个接受表变量的函数。我们的第一个查询将只引用单个表,但我们稍后将看到一个连接。为了完成这个函数,我们将提供一元动作,它将合并查询的不同部分。

我们可以从这个单子中调用的最基本的过滤函数是where_。这允许我们在查询中提供一个条件,就像我们在 Persistent 中使用过滤器列表一样。

selectAction :: SqlPersistT (LoggingT IO) (Maybe Article)
   selectAction = select . from $ \articles -> do
     where_ (articles ^. ArticleId ==. val (toSqlKey aid))

首先,我们使用ArticleId透镜来指定我们要过滤的表的值。然后我们指定要比较的值。我们不仅需要将我们的Int64提升到一个SqlKey,而且我们还需要使用val函数提升那个值。

但是现在我们已经添加了这个条件,我们需要做的就是返回表变量。现在,select在一个列表中返回我们的结果。但是因为我们是通过 ID 搜索,所以我们只期望一个结果。我们将使用listToMaybe,所以我们只返回 head 元素,如果它存在的话。我们还将再次使用entityVal来打开文章的实体。

selectAction :: SqlPersistT (LoggingT IO) (Maybe Article)
   selectAction = ((fmap entityVal) . listToMaybe) <$> (select . from $ \articles -> do
     where_ (articles ^. ArticleId ==. val (toSqlKey aid))
     return articles)

现在我们应该知道了足够多的信息,可以写出下一个查询。它将获取特定用户写的所有文章。我们仍然会在articles表上进行查询。但是现在我们检查文章 ID,我们将确保ArticleAuthorId等于某个值。同样,我们将把我们的Int64用户密钥提升到一个SqlKey中,然后再与val在“SQL-land”中进行比较。

fetchArticleByAuthorPG :: PGInfo -> Int64 -> IO [Entity Article]
fetchArticleByAuthorPG connString uid = runAction connString fetchAction
 where
   fetchAction :: SqlPersistT (LoggingT IO) [Entity Article]
   fetchAction = select . from $ \articles -> do
     where_ (articles ^. ArticleAuthorId ==. val (toSqlKey uid))
     return articles

这就是完整的查询!这次我们想要一个实体列表,所以我们去掉了listToMaybeentityVal

现在让我们编写最后一个查询,在这里我们将找到最近的 10 篇文章,不管是谁写的。我们将在每篇文章中包含作者。所以我们返回这些不同实体元组的列表。这个查询将涉及我们的第一个连接。我们将使用InnerJoin构造函数将users表和articles表组合起来,而不是使用单个表进行查询。

fetchRecentArticlesPG :: PGInfo -> IO [(Entity User, Entity Article)]
fetchRecentArticlesPG connString = runAction connString fetchAction
 where
   fetchAction :: SqlPersistT (LoggingT IO) [(Entity User, Entity Article)]
   fetchAction = select . from $ \(users `InnerJoin` articles) -> do

因为我们要将两个表连接在一起,所以我们需要指定要连接的列。为此,我们将使用on函数:

fetchAction :: SqlPersistT (LoggingT IO) [(Entity User, Entity Article)]
   fetchAction = select . from $ \(users `InnerJoin` articles) -> do
     on (users ^. UserId ==. articles ^. ArticleAuthorId)

现在我们将使用orderBy根据文章的时间戳对文章进行排序。最新的文章应该排在最前面,所以我们将使用降序排列。然后我们用limit函数限制结果的数量。最后,我们将返回用户和文章,我们就完成了!

fetchAction :: SqlPersistT (LoggingT IO) [(Entity User, Entity Article)]
   fetchAction = select . from $ \(users `InnerJoin` articles) -> do
     on (users ^. UserId ==. articles ^. ArticleAuthorId)
     orderBy [desc (articles ^. ArticlePublishedTime)]
     limit 10
     return (users, articles)

缓存不同类型的项目

我们不会深入研究在 Redis 中缓存文章的细节,但是有一个潜在的问题我们想观察一下。目前,我们在 Redis 存储中使用用户的 SQL 键作为他们的键。例如,字符串“15”可以是这样一个键。如果我们试图天真地在我们的文章中使用同样的想法,我们将会有冲突!试图存储 ID 为“15”的文章将会覆盖包含该用户的条目!

但是解决这个问题的方法很简单。我们要做的是,对于用户的密钥,我们将使字符串类似于users:15。那么对于我们的文章,我们将它的键设为articles:15。只要我们以正确的方式反序列化它,就不会有问题。

填写服务器处理程序

既然我们已经编写了数据库查询函数,那么填充服务器处理程序就非常简单了。其中大多数都归结为遵循我们已经为其他两个端点设置的模式:

fetchArticleHandler :: PGInfo -> Int64 -> Handler Article
fetchArticleHandler pgInfo aid = do
 maybeArticle <- liftIO $ fetchArticlePG pgInfo aid
 case maybeArticle of
   Just article -> return article
   Nothing -> Handler $ (throwE $ err401 { errBody = "Could not find article with that ID" })createArticleHandler :: PGInfo -> Article -> Handler Int64
createArticleHandler pgInfo article = liftIO $ createArticlePG pgInfo articlefetchArticlesByAuthorHandler :: PGInfo -> Int64 -> Handler [Entity Article]
fetchArticlesByAuthorHandler pgInfo uid = liftIO $ fetchArticlesByAuthorPG pgInfo uidfetchRecentArticlesHandler :: PGInfo -> Handler [(Entity User, Entity Article)]
fetchRecentArticlesHandler pgInfo = liftIO $ fetchRecentArticlesPG pgInfo

然后我们将像这样完成我们的Server FullAPI:

fullAPIServer :: PGInfo -> RedisInfo -> Server FullAPI
fullAPIServer pgInfo redisInfo =
 (fetchUsersHandler pgInfo redisInfo) :<|>
 (createUserHandler pgInfo) :<|>
 (fetchArticleHandler pgInfo) :<|>
 (createArticleHandler pgInfo) :<|>
 (fetchArticlesByAuthorHandler pgInfo) :<|>
 (fetchRecentArticlesHandler pgInfo)

我们可以做的一件有趣的事情是,我们可以将我们的 API 类型组成不同的部分。例如,我们可以将我们的FullAPI分成两部分。首先,我们可以从以前的UsersAPI型,然后我们可以为ArticlesAPI做一个新的类型。我们可以用 e-plus 操作符将它们粘合在一起,就像我们可以粘合单独的端点一样!

type FullAPI = UsersAPI :<|> ArticlesAPItype UsersAPI =
      "users" :> Capture "userid" Int64 :> Get '[JSON] User
 :<|> "users" :> ReqBody '[JSON] User :> Post '[JSON] Int64type ArticlesAPI =
 "articles" :> Capture "articleid" Int64 :> Get '[JSON] Article
 :<|> "articles" :> ReqBody '[JSON] Article :> Post '[JSON] Int64
 :<|> "articles" :> "author" :> Capture "authorid" Int64 :> Get '[JSON] [Entity Article]
 :<|> "articles" :> "recent" :> Get '[JSON] [(Entity User, Entity Article)]

如果我们这样做,我们将不得不在结合端点的其他区域进行类似的调整。例如,我们需要更新服务器处理程序连接和客户端功能。

写作测试

因为我们已经有了一些用户测试,所以在 API 的文章部分进行一些测试也是不错的。我们将围绕创建一篇文章并获取它添加一个简单的测试。然后,我们将为“按作者分类的文章”和“最近的文章”端点各添加一个测试。

所以填充这个部分的一个棘手的部分是我们需要制作 test Article对象。但是我们需要它们是用户 ID 上的函数。这是因为当我们将用户插入数据库时,我们无法先验地知道我们将得到什么 SQL IDs。但是我们可以填写所有其他字段,包括发布时间。这里有一个例子,但是我们总共有 18 篇不同的“测试”文章。

testArticle1 :: Int64 -> Article
testArticle1 uid = Article
 { articleTitle = "First post"
 , articleBody = "A great description of our first blog post body."
 , articlePublishedTime = posixSecondsToUTCTime 1498914000
 , articleAuthorId = toSqlKey uid
 }-- 17 other articles and some test users as well
…

我们的 before 钩子将在数据库中创建所有这些不同的实体。一般来说,我们将直接进入数据库,而不调用 API 本身。像我们的用户测试一样,我们想要删除我们创建的任何数据库项。让我们编写一个通用的后挂钩,它将获取用户 id 和文章 id,并将它们从数据库中删除:

deleteArtifacts :: PGInfo -> RedisInfo -> [Int64] -> [Int64] -> IO ()
deleteArtifacts pgInfo redisInfo users articles = do
 void $ forM articles $ \a -> deleteArticlePG pgInfo a
 void $ forM users $ \u -> do
   deleteUserCache redisInfo u
   deleteUserPG pgInfo u

重要的是,我们首先删除文章!如果我们先删除用户,我们会遇到外键异常!

我们的基本创建和获取测试看起来很像前面的用户测试。我们测试响应是否成功,新文章是否如我们预期的那样存在于 Postgres 中。

beforeHook4 :: ClientEnv -> PGInfo -> IO (Bool, Bool, Int64, Int64)
beforeHook4 clientEnv pgInfo = do
 userKey <- createUserPG pgInfo testUser2
 articleKeyEither <- runClientM (createArticleClient (testArticle1 userKey)) clientEnv
 case articleKeyEither of
   Left _ -> error "DB call failed on spec 4!"
   Right articleKey -> do
     fetchResult <- runClientM (fetchArticleClient articleKey) clientEnv
     let callSucceeds = isRight fetchResult
     articleInPG <- isJust <$> fetchArticlePG pgInfo articleKey
     return (callSucceeds, articleInPG, userKey, articleKey)spec4 :: SpecWith (Bool, Bool, Int64, Int64)
spec4 = describe "After creating and fetching an article" $ do
 it "The fetch call should return a result" $ \(succeeds, _, _, _) -> succeeds `shouldBe` True
 it "The article should be in Postgres" $ \(_, inPG, _, _) -> inPG `shouldBe` TrueafterHook4 :: PGInfo -> RedisInfo -> (Bool, Bool, Int64, Int64) -> IO ()
afterHook4 pgInfo redisInfo (_, _, uid, aid) = deleteArtifacts pgInfo redisInfo [uid] [aid]

我们的下一个测试将创建两个不同的用户和几篇不同的文章。我们将首先插入用户并获取他们的密钥。然后我们可以使用这些密钥来创建文章。我们在这个测试中创建了五篇文章。我们将三个分配给第一个用户,两个分配给第二个用户:

beforeHook5 :: ClientEnv -> PGInfo -> IO ([Article], [Article], Int64, Int64, [Int64])
beforeHook5 clientEnv pgInfo = do
 uid1 <- createUserPG pgInfo testUser3
 uid2 <- createUserPG pgInfo testUser4
 articleIds <- mapM (createArticlePG pgInfo)
   [ testArticle2 uid1, testArticle3 uid1, testArticle4 uid1
   , testArticle5 uid2, testArticle6 uid2 ]
 ...

现在我们想测试一下,当我们调用按用户排序的文章端点时,我们只得到正确的文章。我们将返回每组文章、用户 id 和文章 id 列表:

beforeHook5 :: ClientEnv -> PGInfo -> IO ([Article], [Article], Int64, Int64, [Int64])
beforeHook5 clientEnv pgInfo = do
 uid1 <- createUserPG pgInfo testUser3
 uid2 <- createUserPG pgInfo testUser4
 articleIds <- mapM (createArticlePG pgInfo)
   [ testArticle2 uid1, testArticle3 uid1, testArticle4 uid1
   , testArticle5 uid2, testArticle6 uid2 ]
 firstArticles <- runClientM (fetchArticlesByAuthorClient uid1) clientEnv
 secondArticles <- runClientM (fetchArticlesByAuthorClient uid2) clientEnv
 case (firstArticles, secondArticles) of
   (Right as1, Right as2) -> return (entityVal <$> as1, entityVal <$> as2, uid1, uid2, articleIds)
   _ -> error "Spec 5 failed!"

现在我们可以编写断言本身,测试返回的文章是否是我们所期望的。

spec5 :: SpecWith ([Article], [Article], Int64, Int64, [Int64])
spec5 = describe "When fetching articles by author ID" $ do
 it "Fetching by the first author should return 3 articles" $ \(firstArticles, _, uid1, _, _) ->
   firstArticles `shouldBe` [testArticle2 uid1, testArticle3 uid1, testArticle4 uid1]
 it "Fetching by the second author should return 2 articles" $ \(_, secondArticles, _, uid2, _) ->
   secondArticles `shouldBe` [testArticle5 uid2, testArticle6 uid2]

然后我们会用一个类似的 after 钩子跟踪它。

最终测试将遵循类似的模式。只是这一次,我们将检查用户和文章的组合。我们还将确保包含 12 篇不同的文章,以测试 API 将结果限制为 10 个。

beforeHook6 :: ClientEnv -> PGInfo -> IO ([(User, Article)], Int64, Int64, [Int64])
beforeHook6 clientEnv pgInfo = do
 uid1 <- createUserPG pgInfo testUser5
 uid2 <- createUserPG pgInfo testUser6
 articleIds <- mapM (createArticlePG pgInfo)
   [ testArticle7 uid1, testArticle8 uid1, testArticle9 uid1, testArticle10 uid2
   , testArticle11 uid2, testArticle12 uid1, testArticle13 uid2, testArticle14 uid2
   , testArticle15 uid2, testArticle16 uid1, testArticle17 uid1, testArticle18 uid2
   ]
 recentArticles <- runClientM fetchRecentArticlesClient clientEnv
 case recentArticles of
   Right as -> return (entityValTuple <$> as, uid1, uid2, articleIds)
   _ -> error "Spec 6 failed!"
 where
   entityValTuple (Entity _ u, Entity _ a) = (u, a)

我们的 spec 将检查我们得到的 10 篇文章的列表是否符合我们的期望。然后,像往常一样,我们从数据库中删除实体。

现在我们用其他测试来调用这些测试,用小包装器来调用钩子:

main :: IO ()
main = do
 ...
 hspec $ before (beforeHook4 clientEnv pgInfo) $ after (afterHook4 pgInfo redisInfo) $ spec4
 hspec $ before (beforeHook5 clientEnv pgInfo) $ after (afterHook5 pgInfo redisInfo) $ spec5
 hspec $ before (beforeHook6 clientEnv pgInfo) $ after (afterHook6 pgInfo redisInfo) $ spec6

现在我们完成了!测试通过了!

…
After creating and fetching an article
 The fetch call should return a result
 The article should be in PostgresFinished in 0.1698 seconds
2 examples, 0 failuresWhen fetching articles by author ID
 Fetching by the first author should return 3 articles
 Fetching by the second author should return 2 articlesFinished in 0.4944 seconds
2 examples, 0 failuresWhen fetching recent articles
 Should fetch exactly the 10 most recent articles

结论

这就完成了我们对有用的产品库的概述。在这些文章中,我们从头开始构建了一个小的 web API。我们已经看到了一些令人敬畏的抽象,它们让我们只处理项目中最重要的部分。Persistent 和 Servant 都为我们生成了许多额外的样板文件。本文展示了 Esqueleto 库在允许我们进行类型安全连接方面的强大功能。我们还看到了向我们的 API 添加新类型和端点的端到端过程。

在接下来的几周里,我们将会处理更多在构建这类系统时可能出现的问题。特别是,我们将看到如何在 Servant 之上使用可选的单子。这样做可能会提出我们将要探讨的某些问题。我们将通过探索封装效果的不同方法来达到高潮。

一定要看看我们的 Haskell Stack 迷你课程!!它将向您展示如何使用 Stack,这样您就可以集成本系列中的所有库了!

如果你是 Haskell 新手,还没有准备好,看看我们的入门清单并开始行动吧!


本站为非盈利网站,作品由网友提供上传,如无意中有侵犯您的版权,请联系删除