第21章 使用数据库 从网络论坛到播客采集软件甚至备份程序的一切频繁地使用持久存储的数据库。基于SQL的数据库每每是至关方便:速度快,可扩展从微小到巨大的尺寸,能够在网络上运行,常常帮助处理锁定和事务,甚至还能够为应用提供故障转移和冗余。数据库有许多不一样:大的商业数据库如Oracle,开源的引擎如PostgreSQL或MySQL,甚至嵌入式引擎如sqlite。 由于数据库是如此重要,haskell对他们的支持也很重要。在本章中,咱们将向你介绍一个用于数据库的haskell框架。咱们也将使用这个框架创建一个播客下载器,咱们将在第22章中开发。 HDBC概述 在数据库栈的底层是数据库引擎,它负责在磁盘上实际存储数据。著名的数据库引擎包括PostgreSQL,MySQL和Oracle。 大多数现代数据库引擎支持SQL,做为数据流入流出关系数据库的标准方式。这本书将不会提供SQL或关系型数据库管理的教程。 一旦你有一个支持SQL的数据库引擎,你须要一个和它沟通的方式。每一个数据库都有它本身的协议。因为SQL在不一样数据库之间是相同的,使用不一样的驱动为每一个单独的协议生成一个通用的接口是可能的。 Haskell有几个可用的不一样的数据库框架,一些提供基于其它框架的高层。本章中,咱们将集中精力在Haskell数据库链接系统(HDBC)。HDBC是一个数据库抽象库。也就是说,你可使用HDBC编写代码而且能够访问存储在几乎任何SQL数据库中的数据而不多或根本不须要修改代码。即便你历来不须要切换底层数据库引擎,HDBC系统的驱动程序使你在统一的接口下有大量的选择。 Haskell的另外一个数据库抽象库是HSQL ,这和HDBC有相似的用途。还有一个更高层次的框架称为HaskellDB,它位于HDBC或HSQL之上,旨在帮助程序员隔离使用SQL的细节。然而,它不具备普遍的吸引力,由于它的设计限制某些至关常见的数据库访问模式。最后,Takusen是一个使用“left fold”的方式来读取数据库的框架。 安装HDBC和驱动程序 要使用HDBC链接到一个给定的数据库,你须要至少两个包:通用接口和一个特定的数据库驱动程序。您能够获取通用HDBC包,和全部其余的驱动,从Hackage(http://hackage.haskell.org/)。本章中,咱们将使用HDBC版本1.1.3。 你还须要一个后台的数据库和后台的驱动。这一章中,咱们将使用SQLite版本3。SQLite是一个嵌入式数据库,因此它不须要一个单独的服务器并容易创建。许多操做系统已经附带SQLite版本3 。若是你的没有,你能够下载它从http://www.sqlite.org/ 。HDBC首页有一个到已知HDBC后端驱动的连接。 SQLite版本3的特定的驱动程序能够从Hackage得到。 若是你想使用HDBC与其余数据库,检查HDBC已知的驱动页面http://software.complete.org/hdbc/wiki/KnownDrivers 。在那里,你会发现一个到ODBC绑定的连接,它可让你链接到几乎任何平台(Windows,POSIX和其余)上的几乎任何数据库 。你也将找到一个PostgreSQL绑定。MySQL经过ODBC绑定支持,MySQL用户的具体信息能够在HDBC-ODBC API文档(http://software.complete.org/static/hdbc-odbc/doc/HDBC-odbc/)找到。 链接数据库 要链接到一个数据库,您将使用来自数据库后端的驱动程序的链接函数。每一个数据库都有它本身独特的链接方法。初始链接通常是惟一的一次,你直接从后端驱动模块调用全部的东西。 数据库链接函数会返回一个数据库handle。这个handle的精确类型对于不一样驱动会有所不一样,但它永远是IConnection类型类的实例。全部您将使用操做数据库的函数将和IConnection的实例的类型一块儿工做。当你已经和数据库完成会话,调用disconnect函数断开它。下面是一个链接到SQLite数据库的例子: ghci> :module Database.HDBC Database.HDBC.Sqlite3 ghci> conn <- connectSqlite3 "test1.db" Loading package array-0.1.0.0 ... linking ... done. Loading package containers-0.1.0.2 ... linking ... done. Loading package bytestring-0.9.0.1.1 ... linking ... done. Loading package old-locale-1.0.0.0 ... linking ... done. Loading package old-time-1.0.0.0 ... linking ... done. Loading package mtl-1.1.0.1 ... linking ... done. Loading package HDBC-1.1.4 ... linking ... done. Loading package HDBC-sqlite3-1.1.4.0 ... linking ... done. ghci> :type conn conn :: Connection ghci> disconnect conn 事务 大多数现代的SQL数据库有一个事务的概念。事务的设计是为了确保全部组件的修改被应用,或者,他们都没有作。此外,事务有助于防止其余进程访问相同的数据库并看到正在修改的部分数据。 许多数据库要求你要么明确提交全部更改在它们出如今磁盘上以前,或者运行在autocommit模式 。自动提交模式在每一个语句后运行隐含的commit。这使得对于事务型数据库的调整对于不习惯它们的程序员更容易,但它对于想要使用多语句事务的人们是一个阻碍。 HDBC有意不支持autocommit模式。当你在你的数据库中修改数据,你必须明确将它提交到磁盘。在HDBC中有两种方式来作到这一点:你能够调用commit当你准备将数据写入磁盘,或者您可使用withTransaction函数将你的修改代码包起来。你的函数一经成功完成,withTransaction便将数据提交。 有时,你正在写数据到数据库时,一个问题发生。也许你从数据库中获得一个错误或在数据中发现了一个问题。在这些状况下,您能够“回滚”你的改变。这将致使自最后一次提交或回滚以后所作的所有更改被遗忘。在HDBC中,你能够调用rollback函数作到这一点。若是您使用的是withTransaction,任何未捕获的异常将致使回滚发生。 请注意,回滚操做回滚自从上次commit,rollback或者withTransaction的变化,或withTransaction。没有一个数据库像版本控制系统维护大量的历史。您将稍后在这章看到commit的例子。 %一种流行的数据库,MySQL,默认的表类型不支持事务。MySQL默认配置忽略对commit或rollback的调用,并当即提交全部更改到磁盘。HDBC ODBC驱动程序有关配置MySQL的说明代表HDBC不支持事务,这将致使commit和rollback产生错误。另外,您可使用MySQL的InnoDB表,它支持事务。InnoDB表被推荐使用和HDBC一块儿。 简单查询 最简单的SQL查询涉及不返回任何数据的语句。这些查询能够用来建立表,插入数据,删除数据,并设置数据库参数。 用于给数据库发送请求的最基本的函数是run。此函数须要一个IConnection,一个表明查询自身的String和一个参数的列表。让咱们用它来设置一些东西在咱们的数据库中: ghci> :module Database.HDBC Database.HDBC.Sqlite3 ghci> conn <- connectSqlite3 "test1.db" Loading package array-0.1.0.0 ... linking ... done. Loading package containers-0.1.0.2 ... linking ... done. Loading package bytestring-0.9.0.1.1 ... linking ... done. Loading package old-locale-1.0.0.0 ... linking ... done. Loading package old-time-1.0.0.0 ... linking ... done. Loading package mtl-1.1.0.1 ... linking ... done. Loading package HDBC-1.1.4 ... linking ... done. Loading package HDBC-sqlite3-1.1.4.0 ... linking ... done. ghci> run conn "CREATE TABLE test (id INTEGER NOT NULL, desc VARCHAR(80))" [] 0 ghci> run conn "INSERT INTO test (id) VALUES (0)" [] 1 ghci> commit conn ghci> disconnect conn 在这个例子中,链接到数据库后,咱们首先建立一个名为test的表。而后,咱们在表中插入一行数据。最后,咱们提交改变并从数据库断开。须要注意的是,若是咱们没有提交,最终的变化将不会被写入到数据库中。 run函数返回每一个请求修改的行数。对于第一个请求,建立了一个表,没有行被修改。第二个请求插入一个单一的行,因此run返回1。 SqlValue 在继续以前,咱们须要讨论一个HDBC中引进的数据类型SqlValue。由于Haskell和SQL都是强类型系统,HDBC试图尽量保留类型信息。与此同时,Haskell和SQL类型不彻底等价。此外,不一样的数据库有不一样的方式来表示如日期或特殊字符串中的字符。 SqlValue是有一些构造函数如SqlString和SqlBool,SqlNull,SqlInteger等的数据类型,这使您能够表示不一样的数据类型在参数列表中并在返回结果中看到不一样的数据类型,仍然将它存储在列表中。有些便利的函数,toSql和fromSql,你一般会使用。若是你关心数据的精确表示,若是你须要您仍然能够手动构建SqlValue数据。 查询参数 HDBC,像大多数的数据库,支持一个可替换参数的概念。使用可替换参数有三个主要优势:当输入包含引号字符时防止SQL注入攻击,当反复执行相似查询时提升性能,容许容易便携的数据的插入到请求中。 比方说,你要添加的数千行到咱们的新表test。你能够发出请求看起来像测试值INSERT INTO test VALUES (0, 'zero')和 INSERT INTO test VALUES (1, 'one')。这迫使数据库服务器来解析每一个单独的SQL语句。若是你用占位符取代两个值,服务器能够解析SQL查询一次并与不一样的数据执行屡次。 第二个问题涉及到转义字符。若是你要插入字符串"I don't like 1"? SQL使用单引号字符显示字段的结束。大多数SQL数据库就须要你这样写'I don''t like 1'。但其余特殊字符的规则如数据库之间反斜杠的不一样。与其本身尝试写代码,HDBC能够为您处理这一切。让咱们来看一个例子: ghci> conn <- connectSqlite3 "test1.db" ghci> run conn "INSERT INTO test VALUES (?, ?)" [toSql 0, toSql "zero"] 1 ghci> commit conn ghci> disconnect conn 在这个例子中INSERT查询中的问号是占位符。咱们向那里传递参数。run接受SqlValue的列表,因此咱们使用toSql来将每一个项目转换成一个SqlValue。HDBC自动处理将String"zero"转换成数据库使用的合适的表示。 这种作法实际上不会得到任何性能优点当插入大量的数据。对于这一点,咱们须要在建立SQL查询的过程当中有更多的控制权。咱们将在下一节讨论。 %使用可替换参数 %可替换参数只能在请求的一部分中工做,那里服务器期待一个值,如在SELECT语句中的WHERE子句或一个用于INSERT语句的值。你不能说 run "SELECT * from ?" [toSql "tablename"],并指望它工做。表名不是一个值,大多数数据库不会接受这种语法。这在实践中不是一个很大的问题,由于不多这样作。 Prepared Statements HDBC定义了一个准备了SQL查询的函数prepare,但它还没有绑定查询的参数。prepare返回一个表明编译过的Statement。 一旦你有一个Statement,你能够作一些事情。您能够在它上面调用execute一次或屡次。在请求上调用execute后返回数据,你可使用获取函数中的一个来检索数据。函数如run和quickQuery'在内部使用语句及execute,他们是让你快速执行常见任务的简单的快捷方式。当你须要更多的控制什么正在发生,你可使用一个Statement代替函数如run。 让咱们来看看使用一个单一的查询语句插入多个值。这里有一个例子: ghci> conn <- connectSqlite3 "test1.db" ghci> stmt <- prepare conn "INSERT INTO test VALUES (?, ?)" ghci> execute stmt [toSql 1, toSql "one"] 1 ghci> execute stmt [toSql 2, toSql "two"] 1 ghci> execute stmt [toSql 3, toSql "three"] 1 ghci> execute stmt [toSql 4, SqlNull] 1 ghci> commit conn ghci> disconnect conn 在这里,咱们建立了prepared statement并叫它stmt。而后咱们执行该语句四次,每次传递不一样的参数。这些参数被用于取代原来的查询字符串中的问号。最后,咱们提交变化并断开数据库。 HDBC还提供了一个函数,executeMany,能够用于在下面的状况下。executeManysimply须要行数据的列表来调用语句。这是一个例子: ghci> conn <- connectSqlite3 "test1.db" ghci> stmt <- prepare conn "INSERT INTO test VALUES (?, ?)" ghci> executeMany stmt [[toSql 5, toSql "five's nice"], [toSql 6, SqlNull]] ghci> commit conn ghci> disconnect conn %更高效的执行 %在服务器上,大多数数据库将有一个优化,他们能够应用executeMany这样他们只须要编译这个查询字符串一次而不是两次。当一次插入大量的数据时,这可能会致使显著的性能加强。有些数据库也适用于这样的优化来执行,但不是全部的。 %HDBC为了避免提供prepared statement的数据库仿真这样的行为,提供给程序员一个用于反复运行查询的统一的API。 读取结果 到目前为止,咱们已经讨论过插入或更改数据的查询。如今让咱们再次从数据库取回数据。函数quickQuery'的类型看起来和run很是类似,但它返回一个结果的列表,而不是改变的行的数目。quickQuery'一般和SELECT语句一块儿使用。让咱们看一个例子: ghci> conn <- connectSqlite3 "test1.db" ghci> quickQuery' conn "SELECT * from test where id < 2" [] [[SqlString "0",SqlNull],[SqlString "0",SqlString "zero"], [SqlString "1",SqlString "one"],[SqlString "0",SqlNull], [SqlString "0",SqlString "zero"],[SqlString "1",SqlString "one"]] ghci> disconnect conn quickQuery'和可替换参数一块儿工做,正如咱们刚才讨论的。在这种状况下,咱们不使用任何,因此在quickQuery'调用的最后用于替代的值的集合是空列表。 quickQuery'返回行的列表,每行表明[SqlValue]。行中的值由数据库返回的顺序列出。您可使用fromSql将它们转换成一般的须要的Haskell类型。 有点难以阅读这个输出。让咱们扩展这个例子来良好地格式化结果。下面是一些代码来作到这一点: -- file: ch21/query.hs import Database.HDBC.Sqlite3 (connectSqlite3) import Database.HDBC {- | Define a function that takes an integer representing the maximum id value to look up. Will fetch all matching rows from the test database and print them to the screen in a friendly format. -} query :: Int -> IO () query maxId = do -- Connect to the database conn <- connectSqlite3 "test1.db" -- Run the query and store the results in r r <- quickQuery' conn "SELECT id, desc from test where id <= ? ORDER BY id, desc" [toSql maxId] -- Convert each row into a String let stringRows = map convRow r -- Print the rows out mapM_ putStrLn stringRows -- And disconnect from the database disconnect conn where convRow :: [SqlValue] -> String convRow [sqlId, sqlDesc] = show intid ++ ": " ++ desc where intid = (fromSql sqlId)::Integer desc = case fromSql sqlDesc of Just x -> x Nothing -> "NULL" convRow x = fail $ "Unexpected result: " ++ show x 此程序几乎和咱们在ghci中的例子作的是一样的事情,但有一个新的增长:convRow函数。此函数从数据库中取得一行数据并将其转换为一个字符串。此字符串能够很容易地打印出来。 Notice how we took intidfrom fromSqldirectly but processed fromSql sqlDescas a Maybe Stringtype.若是你再次调用,咱们声明此表中的第一列永远不能包含NULL值,但第二列能够。所以,咱们能够安全地忽略第一列中存在NULL的可能性,但不排除第二列。使用fromSql将第二列直接转换成String是可能的,它一直工做,直到在那个位置上遇到一行NULL。这会致使一个运行时异常。因此,咱们将SQL NULL值转换成字符串“NULL”。当打印时,这将与SQL字符串“NULL”没法区分,可是在这个例子中这是能够接受的。让咱们尝试在ghci中调用这个函数: ghci> :load query.hs [1 of 1] Compiling Main ( query.hs, interpreted ) Ok, modules loaded: Main. ghci> query 2 0: NULL 0: NULL 0: zero 0: zero 1: one 1: one 2: two 2: two Reading with Statements 正如咱们在第498页讨论的“预处理语句” ,您可使用statement用于读取。有不少从statement读取数据的方式,它在某些状况下是有用的。像run, quickQuery'是一个方便的函数,它使用statement来完成它的任务。 要建立一个用于读取的statement,we use preparejust as we would for a statement that will be used to write data.您也可使用execute在数据库服务器上执行。而后,咱们可使用各类函数从statement中读取数据。fetchAllRows'函数返回[[SqlValue]]就像quickQuery。这里还有一个称为sFetchAllRows'的函数,它在返回以前将每列的数据转换成Maybe String。最后,这有fetchAllRowsAL',它为每一列返回(String, SqlValue)对。String是做为数据库返回的列名;其余获取列名的方式参见第502页上的“Database Metadata”。 您也能够经过调用fetchRow一次读取一行数据,它返回IO (Maybe[SqlValue])。这将会是Nothing若是全部的结果已经读取,不然它将会是一行数据。 Lazy Reading 回到第178页的“Lazy I/O”,咱们谈论了来自文件的lazy I/O。从数据库中惰性读取数据也是可能的。当处理返回庞大的数据的请求时,这是特别有用的。经过惰性读取数据,你仍然可使用便捷的函数如fetchAllRows代替手动读取每一行。若是咱们关心咱们使用的数据,咱们能够避免在存储器中缓存全部的结果。 然而,从数据库比从文件惰性读取更复杂。当咱们从一个文件惰性读取数据,文件是关闭的,这是一般是很好的。当咱们从数据库中完成惰性读取数据,数据库链接仍然是打开的,你能够提交其余查询。有些数据库甚至能够同时支持多个查询,因此当咱们完成时,HDBC不能只是关闭链接。 当使用惰性读取时,在咱们试图关闭链接或执行一个新的查询以前,咱们完成整个数据集读取,这是极为重要的。咱们鼓励您使用严格的函数,或者一行一行地处理,尽可能减小惰性读取的复杂程度。 %若是你对HDBC或惰性读取的概念是新手,但有不少数据要读取,对fetchRow的重复调用可能会更容易理解。惰性读取是一种强大的有用的工具,但必须正确使用。 从数据库惰性读取,咱们使用咱们以前使用的相同的函数,不带撇号。举例来讲,使用fetchAllRows代替fetchAllRows'。惰性函数的类型和他们严格版本是相同的。下面是惰性读取的一个例子: ghci> conn <- connectSqlite3 "test1.db" ghci> stmt <- prepare conn "SELECT * from test where id < 2" ghci> execute stmt [] 0 ghci> results <- fetchAllRowsAL stmt [[("id",SqlString "0"),("desc",SqlNull)],[("id",SqlString "0"), ("desc",SqlString "zero")],[("id",SqlString "1"),("desc",SqlString "one")] ,[("id",SqlString "0"),("desc",SqlNull)],[("id",SqlString "0"), ("desc",SqlString "zero")],[("id",SqlString "1"),("desc",SqlString "one")]] ghci> mapM_ print results [("id",SqlString "0"),("desc",SqlNull)] [("id",SqlString "0"),("desc",SqlString "zero")] [("id",SqlString "1"),("desc",SqlString "one")] [("id",SqlString "0"),("desc",SqlNull)] [("id",SqlString "0"),("desc",SqlString "zero")] [("id",SqlString "1"),("desc",SqlString "one")] ghci> disconnect conn 请注意,您也能够在这里使用fetchAllRowsAL'。可是,若是你有一个大数据集要读,它会消耗大量的内存。经过惰性读取数据,咱们可使用一个固定数量的内存打印出极其巨大的结果集。在惰性版本中,结果将被以块计算;严格版本中,全部结果预先读取,存储在RAM中,而后打印。 数据库元数据 有时程序学习有关数据库自己的信息也是有用的。例如,程序可能想要看什么表存在,而后它能够自动建立丢失的表或升级数据库架构。在某些状况下,程序可能须要依赖后端使用的数据库来改变它的行为。 首先,这有一个getTables函数,它将得到数据库中定义的表的列表。您还可使用describeTable函数,它提供给定表中定义的列信息。 例如,您能够经过调用dbServerVer和proxiedClientName了解使用中的数据库服务器。dbTransactionSupport函数能够用来肯定是否一个给定的数据库支持事务。让咱们来看看这些中的一些例子: ghci> conn <- connectSqlite3 "test1.db" ghci> getTables conn ["test"] ghci> proxiedClientName conn "sqlite3" ghci> dbServerVer conn "3.5.6" ghci> dbTransactionSupport conn True ghci> disconnect conn 您也能够经过从它的statement获取信息来了解一个特定的查询结果。describeResult函数返回 [(String, SqlColDesc)]。第一项给出列名,而第二项提供了有关列的信息:类型,大小,以及它是否能够为NULL。完整规范在HDBC API参考中给出。 %有些数据库可能没法提供全部这些元数据。在这些的状况下,将引起异常。 例如,sqlite3,写这篇文章时还不支持describeResult和describeTableas。 错误处理 错误发生时,HDBC将引起异常。异常具备SqlError类型。它们传送来自底层的SQL引擎的信息,如数据库的状态,错误消息,和该数据库的数字错误代码,和任何其它信息。 ghci不知道如何在屏幕上显示SqlError当它发生时。异常致使程序终止的同时,它也不会显示有用的信息。下面是一个例子: ghci> conn <- connectSqlite3 "test1.db" ghci> quickQuery' conn "SELECT * from test2" [] *** Exception: (unknown) ghci> disconnect conn 在这里,咱们试图SELECT一个不存在的表中的数据。咱们获得的错误消息没有任何帮助。这有一个实用函数,handleSqlError,它将捕获一个SqlError并再次引起一个IOError。在这种形式下,它会在屏幕上打印,但它会更难以编程的方式来提取信息的特定片断。让咱们来看看它的用法: ghci> conn <- connectSqlite3 "test1.db" ghci> handleSqlError $ quickQuery' conn "SELECT * from test2" [] *** Exception: user error (SQL error: SqlError {seState = "", seNativeError = 1, seErrorMsg = "prepare 20: SELECT * from test2: no such table: test2"}) ghci> disconnect conn 在这里,咱们获得了更多的信息,包括表示没有Test2这样的表的消息。这更有帮助。许多HDBC程序员在程序开始写main = handleSqlError $ do,这已经成为标准作法,这将确保每一个未捕获的SqlError被打印。 这也有catchSql和handleSql,相似于标准的catch和handle函数。 catchSql和handleSql只截获HDBC错误。欲了解错误处理的更多信息,请参阅第19章。