Persistent
表单处理用户与应用间的边界问题。另一个需要我们处理的边界是应用与存储层之间的。 不管是SQL数据库、YAML文件、或是二进制blob,大部分情况下你的存储层都无法原生的 理解你应用中的数据类型,你会需要执行一些数据编组(marshaling)操作。Persistent是 Yesod针对数据存储的解决方案,它是一个用Haskell写的类型安全、通用的数据存储接口 。
Haskell有很多不同的数据库绑定(binding)库。然而,它们大部分都不掌握数据模型 (schema)信息,因此不能提供有用的静态类型保证。它们还强制程序员对不同数据库使用 不同的API和数据类型。
有些Haskell程序员尝试了一种更具革命性的方法:创建Haskell专用的数据存储,能够容 易的存储任何强类型Haskell数据。这种方法对某些特定的应用场景很好,但它将程序员 限制在其类库所提供的存储技术中,而且与其它编程语言的交互不友好。
相比之下,Persistent允许我们在现有的数据库中选择,这些数据库针对不同的数据存储 应用场景做了优化、能与其它编程语言交互、能使用安全高效的查询接口,同时仍能保持 Haskell数据的类型安全。
Persistent遵循的指导原则是类型安全和简洁、声明式的语法。其它很棒的特性包括:
与数据库无关。对PostgreSQL、SQLite、MySQL和MongoDB提供一流的支持,对Redis的 支持还是实验性的。
方便的数据建模。 Persistent允许你以类型安全的方式建模和使用数据关系。Persistent默认的类型安全 API不支持join操作,这样能支持更广泛的存储层。 Join和其它SQL专用功能可以通过原始SQL层(只有极少的类型安全)完成。 另一个库,Esqueleto,构建 在Persistent的数据模型之上,为join和SQL语句提供了类型安全
自动执行数据库迁移(migration)。
Persistent与Yesod能很好配好,但它也完全可以作为单独的类库使用。本章大部分时候 都只讲Persistent本身。
概要
- {-# LANGUAGE EmptyDataDecls #-}
- {-# LANGUAGE FlexibleContexts #-}
- {-# LANGUAGE GADTs #-}
- {-# LANGUAGE OverloadedStrings #-}
- {-# LANGUAGE QuasiQuotes #-}
- {-# LANGUAGE TemplateHaskell #-}
- {-# LANGUAGE TypeFamilies #-}
- import Control.Monad.IO.Class (liftIO)
- import Database.Persist
- import Database.Persist.Sqlite
- import Database.Persist.TH
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name String
- age Int Maybe
- deriving Show
- BlogPost
- title String
- authorId PersonId
- deriving Show
- |]
- main :: IO ()
- main = runSqlite ":memory:" $ do
- runMigration migrateAll
- johnId <- insert $ Person "John Doe" $ Just 35
- janeId <- insert $ Person "Jane Doe" Nothing
- insert $ BlogPost "My fr1st p0st" johnId
- insert $ BlogPost "One more for good measure" johnId
- oneJohnPost <- selectList [BlogPostAuthorId ==. johnId] [LimitTo 1]
- liftIO $ print (oneJohnPost :: [Entity BlogPost])
- john <- get johnId
- liftIO $ print (john :: Maybe Person)
- delete janeId
- deleteWhere [BlogPostAuthorId ==. johnId]
解决边界问题
假设我们在一个SQL数据库中存储了人员信息。你的表可能是像这样的:
- CREATE TABLE person(id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, age INTEGER)
如果你用的是PostgreSQL数据库,可以保证数据库绝不会在age字段存储任意的文本值。 (SQLite就不能保证这一点,不过让我们暂时忽略这点。)为了映射这个数据库表,你需要 创建这样的Haskell数据类型:
- data Person = Person
- { personName :: Text
- , personAge :: Int
- }
看起来所有数据都类型安全了:数据库模型与我们的Haskell数据类型相匹配、数据库能 保证无效数据不会被存储、一切都很棒。然而,直到:
你想从数据库取出数据,数据库层给你返回的是无类型格式的数据。你想查询年龄在32
岁以上的人,而你不小心在SQL语句中写成了“三十二”。猜猜会怎样 :编译没问题,你直到运行时都不会发现问题。
你决定按字母顺序查询前10个人。没问题…直到你的SQL有拼写错误。再一次,你只有 运行时才能发现。
在动态语言中,对这些问题的解决方法是单元测试。对任何可能出错的地方,你都要 写一个测试用例。但我敢肯定你已经察觉到了,Yesod并不是这样来处理问题的。我们更 愿意利用Haskell的强类型来尽可能多的帮助我们,数据存储问题也不例外。
所以问题是:如何用Haskell的类型系统来拯救我们?
类型
像路由那样,要做到类型安全的数据访问本质上并不困难。它只是需要大量单调、容易出 错、样板式的代码。与往常一样,这意味着我们可以用类型系统来保证正确性。同时为了 避免重复代码,我们要用一些Haskell模板(Template Haskell)。
注意 | 早期版本的Persistent更大范围的使用了Haskell模板。从0.6版本开始,参照 groundhog包,persistent有了一个新架构。这种方法用影子类型(phantom types)来减轻 很多负担。 |
PersistValue是Persistent的基本单元。它是一个汇总类型(sum type),可以表示发 往数据库或从数据库读取的数据。它的定义是:
- data PersistValue = PersistText Text
- | PersistByteString ByteString
- | PersistInt64 Int64
- | PersistDouble Double
- | PersistRational Rational
- | PersistBool Bool
- | PersistDay Day
- | PersistTimeOfDay TimeOfDay
- | PersistUTCTime UTCTime
- | PersistZonedTime ZT
- | PersistNull
- | PersistList [PersistValue]
- | PersistMap [(Text, PersistValue)]
- | PersistObjectId ByteString -- ^ MongoDB后端专用
每一个Persistent后端都需要知道如何将相关的值转换成数据库所能理解的值。尽管如此 ,如果需要用这些基础类型来表达我们所有的数据会有点笨拙。下一个层次是 PersistField型类,它定义了任意Haskell数据与PersistValue相互转换的方法 。PersistField对应的是SQL数据库中的一列。在上面人员表的例子中,名字和年龄 都是PersistField。
为了与用户侧的代码关联起来,最后还有一个PersistEntity型类。 PersistEntity的实例对应的是SQL数据库中的一个表。这个类定义了很多函数和一些 关联类型。回顾一下,我们在Persistent和SQL数据库间有这样的对应关系:
SQL | Persistent |
---|---|
Datatypes (VARCHAR, INTEGER, etc) | PersistValue |
Column | PersistField |
Table | PersistEntity |
代码生成(Code Generation)
为了保证PersistEntity的实例能正确与你的Haskell数据类型匹配,Persistent会负责( 实例化及生成Haskell数据类型)。从不要重复自己(DRY: Don’t Repeat Yourself)的角度 :你只需要定义一次实体。让我们看一个简单的例子:
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell, OverloadedStrings, GADTs #-}
- import Database.Persist
- import Database.Persist.TH
- import Database.Persist.Sqlite
- import Control.Monad.IO.Class (liftIO)
- mkPersist sqlSettings [persistLowerCase|
- Person
- name String
- age Int
- deriving Show
- |]
我们结合使用了Haskell模板与准引用(就像定义路由时那样):persistLowerCase是 一个准引用,它将空格敏感的语法转换为一列实体定义。“Lower case“指的是生成的表名 是小写的。在这个定义中,名为SomeTable的实体会变成表为some_table的SQL表 。你还可以用persistFileWith函数从外部文件定义实体。mkPersist接受一列实 体,并声明:
给每个实体声明一个Haskell数据类型。
将每个数据类型都声明成PersistEntity的实例。
上面的例子生成的代码会是这样的:
- {-# LANGUAGE TypeFamilies, GeneralizedNewtypeDeriving, OverloadedStrings, GADTs #-}
- import Database.Persist
- import Database.Persist.Sqlite
- import Control.Monad.IO.Class (liftIO)
- import Control.Applicative
- data Person = Person
- { personName :: !String
- , personAge :: !Int
- }
- deriving (Show, Read, Eq)
- type PersonId = Key Person
- instance PersistEntity Person where
- -- 一个广义代数数据类型(GADT: Generalized Algebraic Datatype)。
- -- 这提供给我们一种匹配字段和其数据类型的类型安全的方法。
- data EntityField Person typ where
- PersonId :: EntityField Person PersonId
- PersonName :: EntityField Person String
- PersonAge :: EntityField Person Int
- data Unique Person
- type PersistEntityBackend Person = SqlBackend
- toPersistFields (Person name age) =
- [ SomePersistField name
- , SomePersistField age
- ]
- fromPersistValues [nameValue, ageValue] = Person
- <$> fromPersistValue nameValue
- <*> fromPersistValue ageValue
- fromPersistValues _ = Left "Invalid fromPersistValues input"
- -- 每个字段的信息,在内部被用来生成SQL语句
- persistFieldDef PersonId = FieldDef
- (HaskellName "Id")
- (DBName "id")
- (FTTypeCon Nothing "PersonId")
- SqlInt64
- []
- True
- Nothing
- persistFieldDef PersonName = FieldDef
- (HaskellName "name")
- (DBName "name")
- (FTTypeCon Nothing "String")
- SqlString
- []
- True
- Nothing
- persistFieldDef PersonAge = FieldDef
- (HaskellName "age")
- (DBName "age")
- (FTTypeCon Nothing "Int")
- SqlInt64
- []
- True
- Nothing
你可能想到了,Person数据类型与Haskell模板中的定义高度一致。我们还通过一个 广义代数数据类型(GADT)给每个域一个单独的构造函数。这个GADT编码了实体类型和字段 的类型。我们在Persistent中会多次使用这些构造函数,比如当我们进行数据筛选时,要 保证筛选条件的类型与字段的类型一致。
我们可以像使用其它Haskell类型一样使用所生成的Person类型,可以将它传递给其 它Persistent函数。
- main = runSqlite ":memory:" $ do
- michaelId <- insert $ Person "Michael" 26
- michael <- get michaelId
- liftIO $ print michael
我们从标准的数据库连接代码开始讲。这个例子中,我们用的是单次连接函数。 Persistent也自带了连接池(connection pool)函数,是我们通常在生产环境要用的。
这个例子中,我们能看到这两个函数:insert在数据库中创建一条新的记录,并返回 它的ID。和Persistent中的所有要素一样,ID是类型安全的。我们会在后文详述ID是怎么 工作的。因此当你运行insert $ Person "Michael" 26时,它的返回值类型是 PersonId。
第二个函数是get,它尝试通过Id从数据库加载一个值。在Persistent中,你永 远不用担心你把键值用到错误的表上:试图使用PersonId从另一个实体(比如 House)加载数据,是无法编译通过的。
PersistStore
上例中最后一个没解释的细节是:runSqlite函数究竟做了什么操作,还有我们数据 库操作是运行在哪个monad里?
所有数据库操作都需要在PersistStore实例中。就像它的名字所说的一样,每一种数 据存储(PostgreSQL、SQLite、MongoDB)都有PersistStore的实例。就是在这里进行 所有PersistValue到数据库相关值的转换、SQL查询、等等。
注意 | 你可以想象,虽然PersistStore给外部世界提供了安全、类型完善的接口,还 是有很多数据库操作可能会出错。然而,通过在一个地方自动、彻底的测试代码,我们可 以将容易出错的代码集中化,并尽可能的保证没有bug。 |
runSqlite用提供的连接语句创建到数据库的单次连接。作为例子,我们使用了 :memory:,它是一个内存中的数据库。所有SQL后端都共用一个PersistSotre实 例:即SqlPersist。runSqlite通过所生成的连接值,来运行SqlPersist操 作。
注意 | 其实还有一些型类:PersistUpdate和PersistQuery。不同的型类提供了 不同的功能,这让我们可以给更简单的数据库(如Redis)写绑定库,即使这些数据库不提 供Persistent中所有的高级功能。 |
需要重点注意的一件事是在一条runSqlite语句中执行的所有操作都是在一个事务 (transaction)中运行。它说明两件重要的事:
对很多数据库,提交一个事务是很耗费资源的。通过把多个操作放到一个事务中,你可 以大大加速代码运行。
如果在runSqlite中抛出了异常,所有操作都会回滚(假设你的后端支持回滚的话)。
注意 这实际上比看起来有更深远的影响。很多Yesod中的短路函数,比如重定向 (redirect),是用异常来实现的。如果你在Persistent代码块中使用了这些函数,整个事 务都会回滚。
迁移
很抱歉告诉你,我对你撒了个小谎:上一节的例子实际上不能工作。如果你尝试运行它, 会得到错误消息:缺失表。
对于SQL数据库,一个主要的痛苦是管理数据定义的变更。Persistent可以帮忙,而不是 让用户去处理,但你需要要求它来帮忙。让我们看看代码:
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell,
- OverloadedStrings, GADTs, FlexibleContexts #-}
- import Database.Persist
- import Database.Persist.TH
- import Database.Persist.Sqlite
- import Control.Monad.IO.Class (liftIO)
- share [mkPersist sqlSettings, mkSave "entityDefs"] [persistLowerCase|
- Person
- name String
- age Int
- deriving Show
- |]
- main = runSqlite ":memory:" $ do
- -- 增加的就是这一行!
- runMigration $ migrate entityDefs $ entityDef (Nothing :: Maybe Person)
- michaelId <- insert $ Person "Michael" 26
- michael <- get michaelId
- liftIO $ print michael
仅仅是这一个小变化,Persistent就能自动为你创建Person表。runMigration和 migrate作为两个函数是为了让你能同时迁移多个表。
当只处理几个实体时,这样能行,但如果需要处理几十个实体就会很烦。Persistent有一 个辅助函数,mkMigrate,这样就就不用重复自己。
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell,
- OverloadedStrings, GADTs, FlexibleContexts #-}
- import Database.Persist
- import Database.Persist.Sqlite
- import Database.Persist.TH
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name String
- age Int
- deriving Show
- Car
- color String
- make String
- model String
- deriving Show
- |]
- main = runSqlite ":memory:" $ do runMigration migrateAll
mkMigrate是一个Haskell模板函数,它会创建一个新函数,新函数会自动对所有 persist块中定义的实体调用migratte。share函数只是一个小辅助函数,它 将persist块中的信息传递到每个Haskell模板函数,并拼接结果。
Persistent对于迁移期间可以执行的操作相当保守。它先从数据库加载表信息,完全以定 义好的SQL数据类型表示。然后将其与代码中的实体定义做比较。对于以下情况,它会自 动修改数据定义:
字段的数据类型变更。然而,数据库可能会阻止修改,如果数据无法转义。
新增了字段。然而,如果是非空(not null)字段,又没有提供默认值(我们稍后会讨论) 且数据库中已经有数据,数据库就会阻止迁移。
一个字段从非空变成可空。在相反的情况下,Persistent会尝试转换,由数据库批准。
增加了新的实体。
然而,有些情况Persistent不能处理:
字段或实体重命名:Persistent无法知道“name”被重命名成“fullName”:它只知道有一 个旧的字段叫name,有一个新的字段叫fullName。
删除字段:因为这会导致数据丢失,Persistent默认拒绝这样的操作(你可以使用 runMigrationUnsafe代替runMigration来强制执行,虽然不推荐这么做) 。
runMigration会将迁移过程输出在stderr中(你可以用runMigrationSilent 来绕过输出)。它会尽可能的使用ALTER TABLE命令。然而,在SQLite中,ALTER TABLE的能力非常有限,因此,Persistent必须将数据从一个表拷贝到另一个表。
最后,如果你不想让Persistent替你执行迁移,而是希望它告诉你需要做哪些迁移, 可以用printMigration函数。这个函数会打印出runMigration会为你执行的操作 。这对于执行Persistent无法完成的迁移会有用,比如在迁移中加入任意SQL语句,或将 迁移内容写入日志等。
唯一性
除了可以声明实体中的字段,你还可以声明唯一性约束。一个典型的例子是要求用户名唯 一。
- User
- username Text
- UniqueUsername username
每个字段的名字必须以小写字母开始,而唯一性约束必须以大写字母开始,因为在 Haskell中它是一个数据构造函数。
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell,
- OverloadedStrings, GADTs, FlexibleContexts #-}
- import Database.Persist
- import Database.Persist.Sqlite
- import Database.Persist.TH
- import Data.Time
- import Control.Monad.IO.Class (liftIO)
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- firstName String
- lastName String
- age Int
- PersonName firstName lastName
- deriving Show
- |]
- main = runSqlite ":memory:" $ do
- runMigration migrateAll
- insert $ Person "Michael" "Snoyman" 26
- michael <- getBy $ PersonName "Michael" "Snoyman"
- liftIO $ print michael
为了声明字段组合的唯一性,我们在声明中增加一行。Persistent知道你是在定义一个唯 一性构造函数,因为那一行以大写字母开头。(构造函数后的)每个词都必须是实体中的字 段。
唯一性的主要限制是它只能被应用于非空字段。原因是SQL标准对于如何表达NULL的 唯一性很模糊(比如,NULL=NULL是真还是假?)。除了这个模糊性,大部分SQL引擎实 际上的规则与Haskell数据类型所想的相反(比如,PostgreSQL认为NULL=NULL为 假,而Haskell认为Nothing == Nothing为真)。
除了在数据库层面对数据一致性进行保证,唯一性限制还可以用来在你的Haskell代码中 执行特殊的查询,就像上面例子中的getBy函数。它借助Unique关联类型工作。 在上面的例子中,我们会得到一个新的构造函数:
- PersonName :: String -> String -> Unique Person
查询
基于你的目标是什么,可以有不同的方法来查询数据库。有些查询命令用数字ID,其它可 能用筛选。查询在返回结果的数量上也有差异:有些查询不会返回超过一个结果(如果查 询用的关键字是唯一的),而其它查询能返回很多结果。
Persistent因此提供了一些不同的查询函数。与往常一样,我们试图通过类型编码尽可能 多的不变量(invariants)。比如,一条查询如果只能返回0或1个结果,则用Maybe封 装,而能返回多个结果的查询,返回值的类型是列表。
用ID查询
在Persistent中最简单的查询是基于ID的。因为这个值有可能不存在,所以它的返回值封 装在Maybe中。
- personId <- insert $ Person "Michael" "Snoyman" 26
- maybePerson <- get personId
- case maybePerson of
- Nothing -> liftIO $ putStrLn "Just kidding, not really there"
- Just person -> liftIO $ print person
这对于提供像/person/5这样的URL的站点非常有用。然而,这样的话,我们通常不需 要考虑Maybe封装,只想要值,如果查询失败则返回404。幸运的是,get404(由 yesod-persistent包提供)函数能帮助我们。我们会在讲Persistent与Yesod集成时讲更多 细节。
通过唯一性约束查询
getBy和get几乎上一样,除了:
它的参数是唯一性约束;也就是说,它接收Unique值,而不是ID。
它返回一个Entity而不是一个值。Entity是ID和值的组合。
- personId <- insert $ Person "Michael" "Snoyman" 26
- maybePerson <- getBy $ UniqueName "Michael" "Snoyman"
- case maybePerson of
- Nothing -> liftIO $ putStrLn "Just kidding, not really there"
- Just (Entity personId person) -> liftIO $ print person
像get404一样,也有getBy404函数。
选择函数
极有可能,你会需要更强大的查询。你可能想查询年龄在一定岁数以上的所有人;所有蓝 色的汽车;没有用邮箱地址注册的用户。这些情况,你需要用选择函数。
所有的选择函数都有相似的接口,但输出略有不同:
函数名 | 返回值 |
---|---|
selectSource | 一个包含所有查询结果的ID和值的Source。这让你可以写流式代码 (streaming code)。 [caption="注意"] NOTE: Source是一个数据流,是conduit包的一部分。推荐阅读 School of Haskell conduit教程来开始。 |
selectList | 一个包含所有查询结果的ID和值的列表。所有记录都会被载入到内存中。 |
selectFirst | 如果查询成功,只取查询结果的第一个ID和值。 |
selectKeys | 只返回键,而不返回值, 返回结果的类型是Source。 |
selectList是最常用的,因此我们专门讲解它。之后理解其它几个函数也很容易。
selectList有两个参数:一列Filter和一列SelectOpt。前者限制了结果所 需具有的特征;它允许等于、小于、在范围内等(限制条件)。SelectOpt提供了三种 功能:排序、限制返回结果的数量、结果偏移(offset)一定行数。
注意 | 结合使用返回数量限制(limits)和偏移量(offsets)非常重要;它允许在你的web应 用中有效的分页(pagination)。 |
让我们看一个筛选的例子,再来分析它。
- people <- selectList [PersonAge >. 25, PersonAge <=. 30] []
- liftIO $ print people
这个例子很简单,我们只需讲三点:
PersonAge是一个关联影子类型(associated phantom type)的构造函数。这听起来 很可怕,但重点在于它唯一标识了“person”表的“age”列,而且它知道age列的类型是 Int。(这就是影子部分。)
我们有很多Persistent筛选运算符。它们都很直接:只要在普通的关系运算符后加个点 。有三个需要注意的地方,我下面会讲。
筛选条件是用逻辑与给合在一起,所以我们的限制条件意思是“年龄在25岁以上、在30 岁(含)以下”。我们稍后会介绍用逻辑或连接筛选条件。
有一个运算符的命名有点特别:“不等于”。我们用!=.,因为/=.被用作更新运算 符(表示“分离然后设置(divide-and-set)”,稍会后讲)。不用担心:如果你用错了,编译 器会报错。另外两个特殊的运算符是“在范围内”和“不在范围内”。他们分别是←.和 /←.(都以点结束)。
对于逻辑或连接筛选条件的情况,我们使用||.运算符。比如:
- people <- selectList
- ( [PersonAge >. 25, PersonAge <=. 30]
- ||. [PersonFirstName /<-. ["Adam", "Bonny"]]
- ||. ([PersonAge ==. 50] ||. [PersonAge ==. 60])
- )
- []
- liftIO $ print people
这个(完全胡谄)的例子说的是:查询年龄在26-30(含)间,或者名字既不是Adam也不是 Bonny,或者年龄是50或60岁的人。
选择选项(SelectOpt)
前面例子中selectList的第二个参数都是空列表。就是没有指明选项,意思是:按数 据库默认的方式排序、返回所有结果、不要跳过任何结果。一个SelectOpt有四个构 造函数,可以用来改变选择选项。
Asc
在指定列以升序排序。它使用与筛选一样的影子类型,比如PersonAge。Desc
与Asc一样,不过是降序。LimitTo
接受一个整型参数。只返回不超过指定数量的结果。OffsetBy
接受一个整型参数。跳过指定数量的结果。
下面的代码定义了一个函数,它会将结果分页。它返回所有年龄在18岁及以上的人,然后 按年龄排序(年长的在前)。对于年龄相同的人,再按姓排序,最后按名排序。
- resultsForPage pageNumber = do
- let resultsPerPage = 10
- selectList
- [ PersonAge >=. 18
- ]
- [ Desc PersonAge
- , Asc PersonLastName
- , Asc PersonFirstName
- , LimitTo resultsPerPage
- , OffsetBy $ (pageNumber - 1) * resultsPerPage
- ]
操作(Manipulation)
查询只是任务的一半。我们还需要能够给数据库增加数据,或修改现有数据。
插入
能够查询、筛选数据库中的数据很好,但首先数据是怎么进到数据库的呢?答案是 insert函数。你给它一个值,它返回一个ID。
在这里,有必要解释一下Persistent背后的哲学。在很多其它的对象关系映射(ORM: Object-Relational Mapping)方案中,用来存放数据的数据类型是不透明的:你需要通过 他们定义好的接口来存取数据。而Persistent不是这样的,Persistent的做法是:我们完 全用的是普通的代数数据类型。这意味着你能得到所有(Haskell)的优点:模式匹配、 currying和所有你习惯的。
尽管如此,有一些事我们无法做到。举个例子,当Haskell中的记录值变更时,没有 办法自动更新数据库中对应的值。当然,Haskell自身的纯计算(purity)和不可变性 (immutability),意味着这种想法本身就没有多少意义,所以我也不会为此伤心落泪。
然而,有一个问题是初学者经常感到困扰的:为什么ID和值是完全分离的?将ID嵌入值似 乎非常合逻辑。换句话说,不写成这样:
- data Person = Person { name :: String }
而是写成
- data Person = Person { personId :: PersonId, name :: String }
但是,这样做立即会有个问题:我们怎么执行insert?如果构造一个Person值需要ID ,而ID要通过插入才能得到,而插入又需要一个Person值,我们就陷入了无限循环。我们 可以用undefined来解决它,但那只是招来问题。
好,你说,让我们试试更安全的方法:
- data Person = Person { personId :: Maybe PersonId, name :: String }
比起insert $ Person undefined "Michael",我当然更喜欢insert $ Person Nothing "Michael"。我们的类型还能更简单,对吧?比如selectList函数的返回 值会变成简单的[Person],而不是丑陋的[Entity SqlPersist Person]。
问题是“丑陋的”返回值却相当有用。Entity Person在类型层面清楚的说明我们在 处理一个数据库中的值。比如说我们想创建到另一个页面的链接,但需要用到PersonId( 我们稍后会看到这很常见)。Entity Person的形式明白无误的告诉我们这一信息;将 PersonId作为Person的记录,并用Maybe封装,意味着运行时要额外检查 Just,而不是能更好预防错误的编译时检查。
最后,将ID嵌入值会导致语义不匹配。Person是值。两个人(在数据库语境中)是一样 的如果它们的所有字段值都一样。如果把ID嵌入值,我们讨论的不再是一个人,而是数据 库的一行。相等不再是相等,而是一致:这是同一个人,而不是相同的人。
换句话说,将ID分离会有些恼人的地方,但总体上,它是正确的做法,它能在大的框 架上保证更好、更少bug的代码。
更新
现在,在以上讨论的基础上,让我们来想想数据更新。最简单的更新方法是:
- let michael = Person "Michael" 26
- michaelAfterBirthday = michael { personAge = 27 }
但这实际上没有更新任何值,它只是基于旧的创建了一个新的Person值。当我们说更 新,我们说的不是修改Haskell代码中的值。(我们最好不要,因为Haskell数据类型 是不可修改的。)
相反,我们要考虑修改数据表中行数据的方法。最简单的方法是用update函数。
- personId <- insert $ Person "Michael" "Snoyman" 26
- update personId [PersonAge =. 27]
update函数有两个参数:ID和一列Update操作。最简单的更新操作是赋值,但它 不总是最佳选择。如果你想把某些人的年龄加1,但你不知道他们当前的年龄呢? Persistent可以帮你:
- haveBirthday personId = update personId [PersonAge +=. 1]
你可能想到了,我们可以用所有基础的数学运算符:+=.、-=.、*=.和 /=.(句号)。这些对于更新一条记录的情况很方便,但它们对于保证ACID(Atomicity 、Consistency、Isolation、Durability)也非常重要。想象另一种情况:取出一个 Person值,增加他/她的年龄,把新的值更新到数据库。如果你有两个线程/进程同时 在读写数据库,你可能有危险(提示:资源竞态(race conditions))。
有时候你会想一次更新多个域(比如,给所有员工加薪5%)。updateWhere接受两个参 数:一列筛选条件和一列要应用的更新。
- updateWhere [PersonFirstName ==. "Michael"] [PersonAge *=. 2] -- 漫长的一天(章)
有时候,你只想将数据库中的一个值完全替换为另一个值。这种情况,你要用(惊喜 )replace函数。
- personId <- insert $ Person "Michael" "Snoyman" 26
- replace personId $ Person "John" "Doe" 20
删除
虽然数据库操作让我们头疼,但有时我们还是要和数据它们说再见。要删除它们,有三个 函数:
delete
基于ID删除deleteBy
基于唯一约束删除deleteWhere
基于一列筛选条件删除
- personId <- insert $ Person "Michael" "Snoyman" 26
- delete personId
- deleteBy $ UniqueName "Michael" "Snoyman"
- deleteWhere [PersonFirstName ==. "Michael"]
我们甚至可以用deleteWhere删除表中全部记录,我们只要给一些提示,让GHC知道我们感 兴趣的是哪个表就可以:
- deleteWhere ([] :: [Filter Person])
属性
目前为止,我们已经看到persistLowerCase块的基本语法:第一行指明实体的名字, 然后每个字段对应缩进的一行,每行两个词:字段名和类型。Persistent实际上可以做更 多:你可以在这两个词后指定任意的属性。
假设我们想让Person实体有一个(可选的)年龄字段和表示他/她何时加入系统的时间 戳字段。对于已经在数据库中的实体,则用当前时刻作为时间戳。
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell,
- OverloadedStrings, GADTs, FlexibleContexts #-}
- import Database.Persist
- import Database.Persist.Sqlite
- import Database.Persist.TH
- import Data.Time
- import Control.Monad.IO.Class
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name String
- age Int Maybe
- created UTCTime default=CURRENT_TIME
- deriving Show
- |]
- main = runSqlite ":memory:" $ do
- time <- liftIO getCurrentTime
- runMigration migrateAll
- insert $ Person "Michael" (Just 26) time
- insert $ Person "Greg" Nothing time
Maybe是自带的、单词(single word)属性。它让该字段可选。在Haskell中,这意味 着它用Maybe封装。在SQL中,它让列可空。
default属性与数据库后端有关,它使用任何能被数据库理解的语法。在这里,它用 了数据库自带的CURRENT_TIME函数。假设我们想加一个字段,用来表示这个人最喜欢 的编程语言:
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell,
- OverloadedStrings, GADTs, FlexibleContexts #-}
- import Database.Persist
- import Database.Persist.Sqlite
- import Database.Persist.TH
- import Data.Time
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name String
- age Int Maybe
- created UTCTime default=CURRENT_TIME
- language String default='Haskell'
- deriving Show
- |]
- main = runSqlite ":memory:" $ do
- runMigration migrateAll
注意 | default属性对Haskell代码本身没有任何影响;你还是需要填充所有值。它只 会影响到数据库的数据定义及自动迁移。 |
我们需要将默认值用单引号包起来,这样数据库才能正确的解读它。最后,Persistent使 用双引号来包含有空格的值,因此,如果我们要将某人的默认家乡设置为“El Salvador” :
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell,
- OverloadedStrings, GADTs, FlexibleContexts #-}
- import Database.Persist
- import Database.Persist.Sqlite
- import Database.Persist.TH
- import Data.Time
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name String
- age Int Maybe
- created UTCTime default=now()
- language String default='Haskell'
- country String "default='El Salvador'"
- deriving Show
- |]
- main = runSqlite ":memory:" $ do
- runMigration migrateAll
最后一条关于属性的技巧是,你可以指定SQL中的表名和列名。对于与现有数据库交互的 情况很有用。
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person sql=the-person-table id=numeric_id
- firstName String sql=first_name
- lastName String sql=fldLastName
- age Int Gt Desc "sql=The Age of the Person"
- UniqueName firstName lastName
- deriving Show
- |]
关于实体定义的语法还有一些其它特性。一个最新的特性列表在 Yesod维基 上。
关系
Persistent允许用与非关系型(non-SQL)数据库一致的方式在数据类型间做引用。我们通 过在相关实体中嵌入ID来实现。因此如果一个人有很多辆车:
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell,
- OverloadedStrings, GADTs, FlexibleContexts #-}
- import Database.Persist
- import Database.Persist.Sqlite
- import Database.Persist.TH
- import Control.Monad.IO.Class (liftIO)
- import Data.Time
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name String
- deriving Show
- Car
- ownerId PersonId Eq
- name String
- deriving Show
- |]
- main = runSqlite ":memory:" $ do
- runMigration migrateAll
- bruce <- insert $ Person "Bruce Wayne"
- insert $ Car bruce "Bat Mobile"
- insert $ Car bruce "Porsche"
- -- 还可以插入更多汽车
- cars <- selectList [CarOwnerId ==. bruce] []
- liftIO $ print cars
使用这项技术,你可以定义一对多的关系。要定义多对多的关系,我们需要连接(join)实 体,它会对每个表都使用一对多的联系。在这里使用唯一性约束也是好主意。比如,如果 我们要对一个人在哪个商店买了哪些东西建模:
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell,
- OverloadedStrings, GADTs, FlexibleContexts #-}
- import Database.Persist
- import Database.Persist.Sqlite
- import Database.Persist.TH
- import Data.Time
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name String
- Store
- name String
- PersonStore
- personId PersonId
- storeId StoreId
- UniquePersonStore personId storeId
- |]
- main = runSqlite ":memory:" $ do
- runMigration migrateAll
- bruce <- insert $ Person "Bruce Wayne"
- michael <- insert $ Person "Michael"
- target <- insert $ Store "Target"
- gucci <- insert $ Store "Gucci"
- sevenEleven <- insert $ Store "7-11"
- insert $ PersonStore bruce gucci
- insert $ PersonStore bruce sevenEleven
- insert $ PersonStore michael target
- insert $ PersonStore michael sevenEleven
深入理解类型
目前为止,我们提到了Person和PersonId,但并没真正解释它们是什么。在最简 单的情况下,对于一个SQL数据库,PersonId可以是type PersonId = Int64。然 而,这意味着无法在类型层面将PersonId与Person实体进行绑定。因此,你可能 不小心用PersonId去查询Car。为了建模这种关系,我们要使用影子类型。所以 ,我们幼稚的下一步是:
- newtype Key entity = Key Int64
- type PersonId = Key Person
这很好,直到我们使用的数据库后端不使用Int64来表示ID。这不只是理论上的问题; MongoDB用的就是ByteString。所以我们需要键值能包含Int或ByteString 。看上去应该用一个汇总类型:
- data Key entity = KeyInt Int64 | KeyByteString ByteString
但那只是自找麻烦。下一次我们会遇到一个后端使用时间戳作为ID,所以我们又会需要给 Key增加构造函数。这可以持续好一会。幸运的是,我们已经有一个用来表示任意数 据的汇总类型:PersistValue:
- newtype Key entity = Key PersistValue
但这样有另一个问题。假设我们有个web应用从用户那得到ID作为参数。它需要以 Text类型接收参数,然后尝试将其转为Key。好,这很简单:写一个将Text 转为PersistValue的函数,然后将结果用Key构造函数封装,对吗?
不对。我们试过这种方法,它有很大的问题。我们最后得到不可能有的Key。比如, 如果我们要用SQL,键必须是整数。但上面描述的方法可以允许任意的文本数据。结果是 服务器返回一堆500错误,因为数据库用整型列去和文本值做比较而抽风了。
所以我们需要一种将文本值转为Key的方法,但它要遵循数据库后端的规则。而且一旦定型 ,答案就很简单:增加另一个影子类型。Persistent中Key的真正定义是:
- newtype KeyBackend backend entity = Key { unKey :: PersistValue }
- type Key val = KeyBackend (PersistEntityBackend val) val
这个略微有点吓人的构造说的是:我们有一个KeyBackend类型,它有两个参数:数据 库后端和实体。然而,我们还有一个简化的Key类型,它假设实体和键的后端一 样,这通常也是正确的假设。
在实践中,它能很好工作:我们可以有一个Text → KeyBackend MongoDB entity函 数和一个Text → KeyBackend SqlPersist entity函数,然后所有事情都能流畅运行 。
更复杂、更通用
默认情况下,Persistent会根据使用的数据库后端硬编码你的数据类型。当使用 sqlSettings时,它是SqlBackend类型。但如果你希望你的Persistent代码可以 工作在多个后端上,你可以启用更加通用的类型,将sqlSettings替换为 sqlSettings { mpsGeneric = True }。
要理解为什么需要这么做,考虑关系。假设我们想表示博客和博客文章。我们可以这样定 义实体:
- Blog
- title Text
- Post
- title Text
- blogId BlogId
但用Key数据类型来表达会是怎样的呢?
- data Blog = Blog { blogTitle :: Text }
- data Post = Post { postTitle :: Text, postBlogId :: KeyBackend <这里放什么?> Blog }
我们需要填入后端类型。理论上,我们可以将其硬编码为SqlPersist或Mongo, 但那样我们的数据类型就只能工作在一种后端上。对于一个单独的应用,这样做是可以的 ,但如果是类库呢?它需要被多个应用使用,需要使用多种后端。
因此问题会更复杂一些。我们的类型实际上是:
- data BlogGeneric backend = Blog { blogTitle :: Text }
- data PostGeneric backend = Post { postTitle :: Text, postBlogId :: KeyBackend backend (BlogGeneric backend) }
注意,我们还是保留了构造函数和记录的短名。最后,为了给普通代码一个简单的接口, 我们定义一些类型别名:
- type Blog = BlogGeneric SqlPersist
- type BlogId = Key SqlPersist Blog
- type Post = PostGeneric SqlPersist
- type PostId = Key SqlPersist Post
不,SqlPersist没有硬编码进Persistent。在调用mkPersist时你已经传入了 sqlSettings,它告诉我们要用SqlPersist。Mongo代码会用mongoSettings 。
这可能有点复杂,但用户代码基本上不会碰到它们。回顾本章:我们没有一次需要直接处 理Key或Generic类型。它们最有可能会出现的地方是在编译器的错误消息中。因 此重点是知道它存在,但它不会影响你的日常使用。
自定义字段
有些时候,你会想要在数据库中自定义字段。最常见的情况是枚举,比如雇员状态。为此 ,Persistent提供了一个Haskell模板辅助函数:
- -- @Employment.hs
- {-# LANGUAGE TemplateHaskell #-}
- module Employment where
- import Database.Persist.TH
- data Employment = Employed | Unemployed | Retired
- deriving (Show, Read, Eq)
- derivePersistField "Employment"
- -- @Main.hs
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, TemplateHaskell,
- OverloadedStrings, GADTs, FlexibleContexts #-}
- import Database.Persist.Sqlite
- import Database.Persist.TH
- import Employment
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name String
- employment Employment
- |]
- main = runSqlite ":memory:" $ do
- runMigration migrateAll
- insert $ Person "Bruce Wayne" Retired
- insert $ Person "Peter Parker" Unemployed
- insert $ Person "Michael" Employed
derivePersistField用字符串字段将数据存入数据库,并用该类型的Show和 Read实例进行数据编组。这可能没有通过整型存储高效,但也更灵活:即使你以后增 加新的构造函数,你当前的数据仍然有效。
注意 | 在这个例子中,我们将定义分成了两个模块。需要这样做是由于GHC的编译步骤约 束,它本质上是说,在很多情况下,Haskell模板生成的代码不能在它所在的模块中使用 。 |
Persistent: 原始(raw)SQL
Persistent包提供了与数据库间的类型安全的接口。它试图与后端无关,比如不依赖于 SQL的关系型特性。我的经验是你可以用这个高层接口轻松执行95%的数据库操作。(实际 上,我写的大部分web应用都完全使用高层接口。)
但有时候你会想用某个后端专有的特性。我以前使用过的一个特性是全文搜索。这种情况 下,我们要用到SQL的“LIKE”运算符,Persistent没有建模它。假设我们要查询所有姓氏 为“Snoyman”的人,然后打印出结果。
注意 | 实际上,你可以用Persisten 0.6新增的特性直接用普通语法表示LIKE运算符 ,它会使用后端对应的运算符。但这仍然是一个(使用原始SQL的)好例子,所以让我们看 看。 |
- {-# LANGUAGE OverloadedStrings, TemplateHaskell, QuasiQuotes, TypeFamilies #-}
- {-# LANGUAGE GeneralizedNewtypeDeriving, GADTs, FlexibleContexts #-}
- import Database.Persist.TH
- import Data.Text (Text)
- import Database.Persist.Sqlite
- import Control.Monad.IO.Class (liftIO)
- import Data.Conduit
- import qualified Data.Conduit.List as CL
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name Text
- |]
- main :: IO ()
- main = runSqlite ":memory:" $ do
- runMigration migrateAll
- insert $ Person "Michael Snoyman"
- insert $ Person "Miriam Snoyman"
- insert $ Person "Eliezer Snoyman"
- insert $ Person "Gavriella Snoyman"
- insert $ Person "Greg Weber"
- insert $ Person "Rick Richardson"
- -- Persistent没有提供LIKE运算符,但我们希望查询整个Snoyman家族...
- let sql = "SELECT name FROM Person WHERE name LIKE '%Snoyman'"
- rawQuery sql [] $$ CL.mapM_ (liftIO . print)
此外还有支持自动数据编组的高层接口。详情请参阅Haddock API文档。
与Yesod集成
希望你已经信服Persistent的威力。如何将它与你的Yesod应用集成呢?如果你使用了脚 手架(scaffolding),大部分工作都已为你做好。但像本书通常所做的那样,我们要手动 来集成,以说明它到底是怎么工作的。
yesod-persistent包提供了Persistent和Yesod间的交汇点。它提供了YesodPersist 型类,它通过runDB方法标准化了存取数据库的操作。让我们看看代码。
- {-# LANGUAGE QuasiQuotes, TypeFamilies, GeneralizedNewtypeDeriving, FlexibleContexts #-}
- {-# LANGUAGE TemplateHaskell, OverloadedStrings, GADTs, MultiParamTypeClasses #-}
- import Yesod
- import Database.Persist.Sqlite
- import Control.Monad.Trans.Resource (runResourceT)
- import Control.Monad.Logger (runStderrLoggingT)
- -- 和之前一样定义我们的实体
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- firstName String
- lastName String
- age Int Gt Desc
- deriving Show
- |]
- -- 我们将连接池放在基础数据类型中。在程序初始化时,我们就创建连接池,
- -- 每当要执行数据库操作时,就从连接池取出一个连接。
- data PersistTest = PersistTest ConnectionPool
- -- 我们只创建一条路由用于访问人员。在路由中使用Id类型非常常见。
- mkYesod "PersistTest" [parseRoutes|
- / HomeR GET
- /person/#PersonId PersonR GET
- |]
- -- 没什么特别的
- instance Yesod PersistTest
- -- 现在我们需要定义一个YesodPersist实例,它会记录我们使用的是哪个数据库后端,
- -- 以及怎么执行数据库操作
- instance YesodPersist PersistTest where
- type YesodPersistBackend PersistTest = SqlPersistT
- runDB action = do
- PersistTest pool <- getYesod
- runSqlPool action pool
- -- List all people in the database
- getHomeR :: Handler Html
- getHomeR = do
- people <- runDB $ selectList [] [Asc PersonAge]
- defaultLayout
- [whamlet|
- <ul>
- $forall Entity personid person <- people
- <li>
- <a href=@{PersonR personid}>#{personFirstName person}
- |]
- -- 我们返回字符串格式的人员信息,或者当人员在数据库中不存在时返回404。
- getPersonR :: PersonId -> Handler String
- getPersonR personId = do
- person <- runDB $ get404 personId
- return $ show person
- openConnectionCount :: Int
- openConnectionCount = 10
- main :: IO ()
- main = withSqlitePool "test.db3" openConnectionCount $ \pool -> do
- runResourceT $ runStderrLoggingT $ flip runSqlPool pool $ do
- runMigration migrateAll
- insert $ Person "Michael" "Snoyman" 26
- warp 3000 $ PersistTest pool
这里有两个常用的信息。runDB用来在Handler中执行数据库操作。在runDB 中,你可以使用本章提到的任何操作函数,比如insert和selectList。
注意 |
runDB的类型是YesodDB site a → HandlerT site IO a。YesodDB的定义是 :
因为它构建于YesodPersistBackend的关联类型上,它使用了与当前站点一样的数据 库后端。 |
另一个新特性是get404。它与get一样,但当查询无结果时不是返回Nothing ,而是返回404错误页。getPersonR函数是真实世界Yesod应用中非常常用的方法: 用get404查询一个值,然后基于查询结果做出响应。
更复杂的SQL
Persistent努力做到与后端无关。这种方法的好处是代码可以很容易切换后端。不足是你 无法用一些后端专用的特性。可能受影响最大的是SQL的join操作。
幸运的是,得益于Felip Lessa,你可以吃一块蛋糕。 Esqueleto库提供了类型安全的 SQL查询,它使用现有的Persistent框架。这个包的Haddocks文档很好的介绍了它的用法 。而且因为它用了很多Persistent的概念,你掌握的大部分Persistent知识都能用上。
SQLite以外的数据库
为了让本章例子的简单,我们都用的SQLite后端。为了让事情圆满,下面是概要中例子的 PostgreSQL版本:
- {-# LANGUAGE FlexibleContexts #-}
- {-# LANGUAGE GADTs #-}
- {-# LANGUAGE OverloadedStrings #-}
- {-# LANGUAGE QuasiQuotes #-}
- {-# LANGUAGE TemplateHaskell #-}
- {-# LANGUAGE TypeFamilies #-}
- import Control.Monad.IO.Class (liftIO)
- import Database.Persist
- import Database.Persist.Postgresql
- import Database.Persist.TH
- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
- Person
- name String
- age Int Maybe
- deriving Show
- BlogPost
- title String
- authorId PersonId
- deriving Show
- |]
- connStr = "host=localhost dbname=test user=test password=test port=5432"
- main :: IO ()
- main = withPostgresqlPool connStr 10 $ \pool -> do
- flip runSqlPersistMPool pool $ do
- runMigration migrateAll
- johnId <- insert $ Person "John Doe" $ Just 35
- janeId <- insert $ Person "Jane Doe" Nothing
- insert $ BlogPost "My fr1st p0st" johnId
- insert $ BlogPost "One more for good measure" johnId
- oneJohnPost <- selectList [BlogPostAuthorId ==. johnId] [LimitTo 1]
- liftIO $ print (oneJohnPost :: [Entity BlogPost])
- john <- get johnId
- liftIO $ print (john :: Maybe Person)
- delete janeId
- deleteWhere [BlogPostAuthorId ==. johnId]
小结
Persistent将Haskell的类型安全引入数据存储层。与其写一些容易出错、无类型的数据 访问或手写数据编组代码,你可以依靠Persistent帮你自动完成这些过程。
Persistent的目标是提供你所需要的一切功能,在大多数时候。当你需要一些更强大 的功能时,Persistent允许你直接访问底层的数据库,所以如果你想的话,可以写一个5 路(5-way)join运算。
Persistent可以直接集成到Yesod的工作流中。不仅yesod-persistent包提供了很多 辅助函数,yesod-form和yesod-auth包也使用了一些Persistent的功能。