前言
数据库连接是常见的IO操作,这一节我们将一些IO与monad transformer结合起来,看看能否对我们有启发。
这里我们使用sqlite3数据库,库用sqlite-simple。
连接数据库
我们从教程中可以得到一个非常简单的示例。
main :: IO ()
main = do
conn <- open db
execute_ conn "create table if not exists users (id integer primary key, age integer not null, name text not null)"
execute conn "insert into users (name, age) values (?, ?)" ("haoren" :: T.Text, 10 :: Int)
id' <- lastInsertRowId conn
user' <- query conn "select * from users where id = ?" (Only id') :: IO [User]
close conn
print user'
putStrLn "Finished"
# output
[User {userId = 1, userName = "haoren", userAge = 10}]
Finished
open
打开一个连接,之后就可以用这个连接进行任何数据库操作了。
繁琐的代码
仔细观察上面的代码,每次操作都需要打开一个连接,而且每次操作都要带上conn
连接,我们有没有什么办法做到简单呢?或者我们观察其它语言会如何做的?
如果用过orm,大家一般都知道需要初始化后才能使用,用javascript可能会这样写:
let db = null;
orm.init(config)
.then(instance => {
instance.query("select 1");
// 保存db实例
db = instance;
....
})
;
当然,我们有时会保存instance
这个变量,以供其它地方使用。像上面的db
变量一样。
但在Haskell中无法这样做,这是因为它追求纯度决定的。那么我们还有什么办法解决呢?或者我们考虑一下上面的instance
实例,它并不会凭空产生,它是由orm初始化产生的一个特定实例,在它产生那刻起,它的所有一切都已经决定好了。简而言之,instance
的上下文是由config
决定。
或许我们可以特例化这些函数。
exe' :: Query -> IO ()
exe' sql = do
conn <- open db
execute_ conn sql
close conn
qq :: (FromRow r, ToRow t) => Query -> t -> IO [r]
qq sql arg = do
conn <- open db
result <- query conn sql arg
close conn
return result
exe'
是execute_
的特例,跟原始函数比较,它们都少了Connection
参数,我们手动帮它做了。但这种写法有个问题,那就是每执行一些这样的函数,都要年尊连接一次数据库,没有办法做到一次连接执行多个操作。
不知道你有没有这种感觉,db
是我们的一个环境变量,我们的所有实例连接也都是产生于它,再往下说,每次数据库操作都依然于conn
这个环境变量。我们是时候来试试ReaderT
了。
ReaderT封装
在封装之前,我们需要确定要封装到什么程度,或者说我们期待以什么样的形式书写。结合上面我们已遇到的问题,我们现在确定需要一种简便的方法,隐式或自动传递conn
,还要允许一次打开数据库,能多次操作。我们可以预想到这样的代码:
main :: IO ()
main = do
exec $ do
run' "create table if not exists users (id integer primary key, age integer not null, name text not null)"
user' <- exec $ do
run "insert into users (name, age) values (?, ?)" ("haoren" :: T.Text, 10 :: Int)
id' <- lastId
find "select * from users where id = ?" (Only id') :: Env [User]
print user'
putStrLn "Finished"
一次exec
就是一次数据库连接,do
后面可以跟多次操作。
刚才提到了,一次数据库连接可以当成对配置的依赖,一次数据操作可以当成是对连接的依赖,所以我们可能会有以下两个类型。
type App = ReaderT Config IO
type Env = ReaderT Connection IO
一个exec
可以简单理解成程序自动调用App
这个环境,并产生了一个Env
,之后exec
的do
语法都将在这个上下文中进行。
我们给出exec
的实现:
exec :: Env a -> IO a
exec r = flip runReaderT "user.db" $ do
db <- ask
conn <- liftIO $ open db
result <- liftIO $ runReaderT r conn
liftIO $ close conn
return result
user.db就是我们的db
啦。第一个runReaderT
就是在创建一个App
上下文,第二个runReaderT
创建了Env
上下文,所以我们把r
放在第二个runReaderT
里。此时它已经得到了一个连接实例。
完成了这一步,我们的任务还未完成,sqlite-simple提供的函数类型并不符合exec
的上下文,所以我们需要针对Env
创建特有的函数,为了避免重名,我们重新创建函数。
run :: ToRow t => Query -> t -> Env ()
run sql args = do
conn <- ask
liftIO $ execute conn sql args
run' :: Query -> Env ()
run' sql = do
conn <- ask
liftIO $ execute_ conn sql
find :: (ToRow t, FromRow r) => Query -> t -> Env [r]
find sql args = do
conn <- ask
liftIO $ query conn sql args
find' :: FromRow r => Query -> Env [r]
find' sql = do
conn <- ask
liftIO $ query_ conn sql
lastId :: Env Int64
lastId = do
conn <- ask
liftIO $ lastInsertRowId conn
到这一步,我们就完成了全部的工作,之后只要像样例那样使用即可。
多个实例
如果我们需要连接多个数据库,上面这些代码还能够使用吗?答案是肯定的,除了exec
,其它函数并不依赖于Config
环境变量,它们仅仅依赖于conn
这个上下文,所以除了exec
其它函数都可以照旧。
genExec :: Config -> Env a -> IO a
genExec db r = flip runReaderT db $ do
db <- ask
conn <- liftIO $ open db
result <- liftIO $ runReaderT r conn
liftIO $ close conn
return result
execA :: Env a -> IO a
execA = genExec "user.db"
execB :: Env a -> IO a
execB = genExec "myuser.db"
main :: IO ()
main = do
execB $ do
run' "create table if not exists users (id integer primary key, age integer not null, name text not null)"
user' <- execA $ do
run "insert into users (name, age) values (?, ?)" ("haoren" :: T.Text, 10 :: Int)
id' <- lastId
find "select * from users where id = ?" (Only id') :: Env [User]
print user'
putStrLn "Finished"
小结
我们从实际一个例子,看到了transformer对问题的解决方法。