要在 Haskell 中操作 MySQL,最容易想到的做法是直接拿一个连接对象,拼好 SQL 字符串然后执行。这个方式在小型脚本里问题不大,但只要业务逻辑稍微复杂,隐式 IO 和字符串拼接就会渗透到各个角落,函数不再纯,测试也离不开真实数据库。纯函数式访问层的核心思路不是完全消灭副作用,而是把副作用压缩到一个非常窄的执行边界里:查询描述、参数映射、结果解析保持纯函数,只有最终执行查询这一步进入 IO。

把查询描述与执行动作分开
函数式语言和数据库交互时,最容易混淆的是“描述一个查询”和“执行一个查询”。在命令式语言里,这两件事常常写在一行代码里,比如 conn.execute("SELECT ...")。Haskell 的类型系统可以把它们拆开。我们可以定义一个代数数据类型来代表所有用户查询,每个构造子的返回类型明确标出结果形状。
{-# LANGUAGE GADTs #-}
module UserQuery where
import Data.Text (Text)
data UserQuery a where
GetUserById :: Int -> UserQuery (Maybe User)
ListActiveUsers :: Int -> Int -> UserQuery [User]
CountUsers :: UserQuery Int
这段代码没有接触任何数据库连接,只是定义了“查询用户”“列出活跃用户”“统计用户数”三种操作。得益于 GADT,编译器知道 GetUserById 的结果是 Maybe User,而 CountUsers 的结果是 Int。这样一来,后续不管是拼接 SQL、绑定参数还是解析结果,类型信息都始终跟着查询描述走,不会出现把行集合强行当成单值处理的低级错误。
纯查询描述还有一个额外好处:它天然是可组合的。你可以写一个纯函数,接收分页参数然后返回一个新的查询描述,整个过程不产生任何副作用。例如 activeUsersPage page size 只是把页码换算成偏移量再调用 ListActiveUsers,测试这个函数时完全不需要数据库。等到真正需要数据时,再交给执行器去处理。
参数绑定与行解析需要类型类支撑
纯函数式访问层不能靠手工拼接字符串来防注入。MySQL 的参数化查询需要明确区分 SQL 文本和参数值,而 Haskell 的类型类非常适合做这件事。可以定义 ToSQLParam 类型类,让每种可入库类型都知道如何把自身转换为安全的参数表示。比如 Int 对应整数参数,Text 对应文本参数,UTCTime 对应时间参数。
data SQLParam = IntParam Int | TextParam Text | NullParam class ToSQLParam a where toSQLParam :: a -> SQLParam instance ToSQLParam Int where toSQLParam = IntParam instance ToSQLParam Text where toSQLParam = TextParam instance ToSQLParam (Maybe a) where toSQLParam Nothing = NullParam toSQLParam (Just v) = toSQLParam v
执行器在拼装查询时,遇到问号占位符就按顺序填充这些参数,数据库驱动最终负责转义和传输。开发者不需要手动给字符串加引号,也不容易漏掉特殊字符。返回结果的处理则可以通过 FromRow 类型类完成:把数据库返回的字段列表转换为 Haskell 的业务类型,如果某一列类型不匹配或缺失,就返回一个错误而不是让程序在后续逻辑中崩溃。
这种类型类设计让访问层在编译期就能发现很多错误。比如如果你尝试把 User 类型当作参数绑定,而它没有实现 ToSQLParam,编译会直接失败。行解析也一样,为 User 实现 FromRow 之后才能让查询结果自动映射成 [User]。这比用 HashMap String Value 到处传递要可靠得多。
用 ReaderT 组织连接与事务
查询描述本身是纯的,但执行需要连接、需要 IO。Haskell 的常见做法是用 ReaderT Connection IO 作为访问层的基础单子,把连接放到只读环境里,这样任何需要连接的函数都可以通过 ask 拿到它,又不会把连接暴露成全局可变状态。配合 MonadIO,只有真正执行 SQL 时才进入 IO,其余逻辑仍然保持可推理。
newtype DB a = DB { unDB :: ReaderT Connection IO a }
deriving (Functor, Applicative, Monad, MonadIO, MonadReader Connection)
runQuery :: UserQuery a -> DB a
runQuery q = do
conn <- ask
let (sqlText, params) = renderQuery q
liftIO $ execute conn sqlText params
事务也在这个单子栈里表达。withTransaction 可以包住任意 DB a 动作,先执行 BEGIN,动作正常结束就提交,抛异常就回滚。因为 DB 本质是 ReaderT,事务组合不会改变内部查询的纯描述逻辑,调用方只需要关心业务动作是否成功。
withTransaction :: DB a -> DB a
withTransaction action = do
conn <- ask
liftIO $ execute_ conn "BEGIN"
result <- action `catch` \e -> do
liftIO $ execute_ conn "ROLLBACK"
throwIO e
liftIO $ execute_ conn "COMMIT"
pure result
这种结构的好处是连接管理、事务边界、重试策略都属于外层基础设施,和具体查询内容解耦。业务逻辑可以只用纯函数加 runQuery 组成,测试时用一个假的执行器替换 DB 环境即可,不必搭建完整 MySQL 实例。
纯函数层带来的可测试性与维护收益
如果查询构造、参数绑定、结果映射都写成了纯函数,那么单元测试的范围就大幅扩大。比如 renderQuery 接受一个 UserQuery,返回 SQL 文本和参数列表,这个函数完全可以脱离数据库验证:构造一个 GetUserById 42,断言生成的 SQL 是 SELECT id, name, active FROM users WHERE id = ?,参数列表是 [IntParam 42]。这种测试执行速度快,而且能覆盖边界条件。
renderQuery :: UserQuery a -> (Text, [SQLParam])
renderQuery (GetUserById uid) =
("SELECT id, name, active FROM users WHERE id = ?", [IntParam uid])
renderQuery (ListActiveUsers offset limit) =
("SELECT id, name, active FROM users WHERE active = 1 LIMIT ? OFFSET ?", [IntParam limit, IntParam offset])
renderQuery CountUsers =
("SELECT COUNT(*) FROM users", [])
另一个容易被忽视的收益是重构时的信心。MySQL 表结构变化后,只需要调整 FromRow 实例和对应的查询描述,所有使用该查询的地方都会在编译期得到提醒。不像命令式代码中,一个字段名拼错可能要到运行时才暴露。函数式访问层把数据库字段和 Haskell 类型的映射集中在一处,修改影响面清晰可见。
当然,这种设计也有成本:初期要为每个查询写类型、参数类型类和结果类型类,样板代码比直接拼 SQL 多。但在查询数量较多、团队重视类型安全的项目中,这些前期投入会随着功能增加而被摊薄。尤其当多个模块都要访问用户表时,一个强类型的访问层能避免大量重复的字段解析逻辑,也让新成员更容易理解数据流。