• [Real World Haskell翻译]第21章 使用数据库


    第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章。
    作者:Hevienz
    出处:http://www.cnblogs.com/hymenz/
    知识共享许可协议
    本博客原创作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
  • 相关阅读:
    BZOJ2435 NOI2011道路修建
    BZOJ2431 HAOI2009逆序对数列(动态规划)
    BZOJ2456 mode
    BZOJ2324 ZJOI2011营救皮卡丘(floyd+上下界费用流)
    BZOJ2303 APIO2011方格染色(并查集)
    BZOJ2299 HAOI2011向量(数论)
    BZOJ2169 连边(动态规划)
    BZOJ2159 Crash的文明世界(树形dp+斯特林数)
    洛谷 P1306 斐波那契公约数 解题报告
    洛谷 P2389 电脑班的裁员 解题报告
  • 原文地址:https://www.cnblogs.com/hymenz/p/3359849.html
Copyright © 2020-2023  润新知