第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章。