第二十一章:數(shù)據(jù)庫(kù)的使用

2018-02-24 15:49 更新

第二十一章:數(shù)據(jù)庫(kù)的使用

網(wǎng)上論壇、播客抓取器(podcatchers)甚至備份程序通常都會(huì)使用數(shù)據(jù)庫(kù)進(jìn)行持久化儲(chǔ)存。基于 SQL 的數(shù)據(jù)庫(kù)非常常見:這種數(shù)據(jù)庫(kù)具有速度快、伸縮性好、可以通過網(wǎng)絡(luò)進(jìn)行操作等優(yōu)點(diǎn),它們通常會(huì)負(fù)責(zé)處理加鎖和事務(wù),有些數(shù)據(jù)庫(kù)甚至還提供了故障恢復(fù)(failover)功能以提高應(yīng)用程序的冗余性(redundancy)。市面上的數(shù)據(jù)庫(kù)有很多不同的種類:既有 Oracle 這樣大型的商業(yè)數(shù)據(jù)庫(kù),也有 PostgreSQL 、 MySQL 這樣的開源引擎,甚至還有 Sqlite 這樣的可嵌入引擎。

因?yàn)閿?shù)據(jù)庫(kù)是如此的重要,所以 Haskell 也必須對(duì)數(shù)據(jù)庫(kù)進(jìn)行支持。本章將介紹其中一個(gè)與數(shù)據(jù)庫(kù)進(jìn)行互動(dòng)的 Haskell 框架,并使用這個(gè)框架去構(gòu)建一個(gè)播客下載器(podcast downloader),本書的 23 章還會(huì)對(duì)這個(gè)博客下載器做進(jìn)一步的擴(kuò)展。

HDBC 簡(jiǎn)介

數(shù)據(jù)庫(kù)引擎位于數(shù)據(jù)庫(kù)棧(stack)的最底層,引擎負(fù)責(zé)將數(shù)據(jù)實(shí)際地儲(chǔ)存到硬盤里面,常見的數(shù)據(jù)庫(kù)引擎有 PostgreSQL 、 MySQL 和 Oracle 。

大多數(shù)現(xiàn)代化的數(shù)據(jù)庫(kù)引擎都支持 SQL ,也即是結(jié)構(gòu)化查詢語言(Structured Query Language),并將這種語言用作讀取和寫入關(guān)系式數(shù)據(jù)庫(kù)的標(biāo)準(zhǔn)方式。不過本書并不會(huì)提供 SQL 或者關(guān)系式數(shù)據(jù)庫(kù)管理方面的教程[49]。

在擁有了支持 SQL 的數(shù)據(jù)庫(kù)引擎之后,用戶還需要尋找一種方法與引擎進(jìn)行通信。雖然每個(gè)數(shù)據(jù)庫(kù)都有自己的獨(dú)有協(xié)議,但是因?yàn)楦鱾€(gè)數(shù)據(jù)庫(kù)處理的 SQL 幾乎都是相同的,所以通過為不同的協(xié)議提供不同的驅(qū)動(dòng),以此來創(chuàng)建一個(gè)通用的接口是完全可以做到的。

Haskell 有幾種不同的數(shù)據(jù)庫(kù)框架可用,其中某些框架在其他框架的基礎(chǔ)上提供了更高層次的抽象,而本章將對(duì) HDBC —— 也即是 Haskell DataBase Connectivity 系統(tǒng)進(jìn)行介紹。通過 HDBC ,用戶可以在只需進(jìn)行少量修改甚至無需進(jìn)行修改的情況下,訪問儲(chǔ)存在任意 SQL 數(shù)據(jù)庫(kù)里面的數(shù)據(jù)[50]。即使你并不需要更換底層的數(shù)據(jù)引擎,由多個(gè)驅(qū)動(dòng)構(gòu)成的 HDBC 系統(tǒng)也使得你在單個(gè)接口上面有更多選擇可用。

HSQL 是 Haskell 的另一個(gè)數(shù)據(jù)庫(kù)抽象庫(kù),它與 HDBC 具有相似的構(gòu)想。除此之外,Haskell 還有一個(gè)名為 HaskellDB 的高層次框架,這個(gè)框架可以運(yùn)行在 HDBC 或是 HSQL 之上,它被設(shè)計(jì)于用來為程序員隔離處理 SQL 時(shí)的相關(guān)細(xì)節(jié)。因?yàn)?HaskellDB 的設(shè)計(jì)無法處理一些非常常見的數(shù)據(jù)庫(kù)訪問模式,所以它并未被廣泛引用。最后,Takusen 是一個(gè)使用左折疊(left fold)方式從數(shù)據(jù)庫(kù)里面讀取數(shù)據(jù)的框架。

安裝 HDBC 和驅(qū)動(dòng)

為了使用 HDBC 去連給定的數(shù)據(jù)庫(kù),用戶至少需要用到兩個(gè)包:一個(gè)包是 HDBC 的通用接口,而另一個(gè)包則是針對(duì)給定數(shù)據(jù)庫(kù)的驅(qū)動(dòng)。HDBC 包和所有其他驅(qū)動(dòng)都可以通過 Hackage [http://hackage.haskell.org/][[51]](#)獲得,本章將使用 1.1.3 版本的 HDBC 作為示例。

除了 HDBC 包之外,用戶還需要準(zhǔn)備數(shù)據(jù)庫(kù)后端和數(shù)據(jù)庫(kù)驅(qū)動(dòng)。本章會(huì)使用 Sqlite 3 作為數(shù)據(jù)庫(kù)后端,這個(gè)數(shù)據(jù)庫(kù)是一個(gè)嵌入式數(shù)據(jù)庫(kù),因此它不需要獨(dú)立的服務(wù)器,并且也非常容易設(shè)置。很多操作系統(tǒng)本身就內(nèi)置了 Sqlite 3 ,如果你的系統(tǒng)里面沒有提供這一數(shù)據(jù)庫(kù),那么你可以到 http://www.sqlite.org/ 里面進(jìn)行下載。HDBC 的主頁(yè)上面列出了指向已有 HDBC 后端驅(qū)動(dòng)的鏈接,針對(duì) Sqlite 3 的驅(qū)動(dòng)也可以通過 Hackage 下載到。

如果讀者打算使用 HDBC 去處理其他數(shù)據(jù)庫(kù),那么可以在 http://software.complete.org/hdbc/wiki/KnownDrivers 查看 HDBC 已有的驅(qū)動(dòng):上面展示的 ODBC 綁定(binding)基本上可以讓你在任何平臺(tái)(Windows、POSIX等等)上面連接任何數(shù)據(jù)庫(kù);針對(duì) PostgreSQL 的綁定也是存在的;而 MySQL 同樣可以通過 ODBC 綁定進(jìn)行支持,具體的信息可以在 HDBC-ODBC API 文檔 [http://software.complete.org/static/hdbc-odbc/doc/HDBC-odbc/]里面找到。

連接數(shù)據(jù)庫(kù)

連接至數(shù)據(jù)庫(kù)需要用到數(shù)據(jù)庫(kù)后端驅(qū)動(dòng)提供的連接函數(shù)。每個(gè)數(shù)據(jù)庫(kù)都有自己獨(dú)特的連接方法。用戶通常只會(huì)在初始化連接的時(shí)候直接調(diào)用從后端驅(qū)動(dòng)模塊載入的函數(shù)。

數(shù)據(jù)庫(kù)連接函數(shù)會(huì)返回一個(gè)數(shù)據(jù)庫(kù)連接,不同驅(qū)動(dòng)的數(shù)據(jù)庫(kù)連接類型可能并不相同,但它們總是 IConnection 類型類的一個(gè)實(shí)例,并且所有數(shù)據(jù)庫(kù)操作函數(shù)都能夠與這種類型的實(shí)例進(jìn)行協(xié)作。

在完成了與數(shù)據(jù)庫(kù)的通信指揮,用戶只要調(diào)用 disconnect 函數(shù)就可以斷開與數(shù)據(jù)庫(kù)的連接。以下代碼展示了怎樣去連接一個(gè) Sqlite 數(shù)據(jù)庫(kù):

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.1 ... linking ... done.
Loading package bytestring-0.9.0.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.0 ... linking ... done.
Loading package HDBC-1.1.5 ... linking ... done.
Loading package HDBC-sqlite3-1.1.4.0 ... linking ... done.

ghci> :type conn
conn :: Connection

ghci> disconnect conn

事務(wù)

大部分現(xiàn)代化 SQL 數(shù)據(jù)庫(kù)都具有事務(wù)的概念。事務(wù)可以確保一項(xiàng)修改的所有組成部分都會(huì)被實(shí)現(xiàn),又或者全部都不實(shí)現(xiàn)。更進(jìn)一步來說,事務(wù)可以避免訪問相同數(shù)據(jù)庫(kù)的多個(gè)進(jìn)程看見正在進(jìn)行的修改動(dòng)作所產(chǎn)生的不完整數(shù)據(jù)。

大多數(shù)數(shù)據(jù)庫(kù)都要求用戶通過顯式的提交操作來將所有修改儲(chǔ)存到硬盤上面,又或者在“自動(dòng)提交”模式下運(yùn)行:這種模式在每一條語句的后面都會(huì)進(jìn)行一次隱式的提交?!白詣?dòng)提交”模式可能會(huì)給不熟悉事務(wù)數(shù)據(jù)庫(kù)的程序員帶來一些方便,但它對(duì)于那些真正想要執(zhí)行多條語句事務(wù)的人來說卻是一個(gè)阻礙。

HDBC 有意地不對(duì)自動(dòng)提交模式進(jìn)行支持。當(dāng)用戶在修改數(shù)據(jù)庫(kù)的數(shù)據(jù)之后,它必須顯式地將修改提交到硬盤上面。有兩種方法可以在 HDBC 里面做到這件事:第一種方法就是在準(zhǔn)備好將數(shù)據(jù)寫入到硬盤的時(shí)候,調(diào)用 commit 函數(shù);而另一種方法則是將修改數(shù)據(jù)的代碼包裹到 withTransaction 函數(shù)里面。withTransaction 會(huì)在被包裹的函數(shù)成功執(zhí)行之后自動(dòng)執(zhí)行提交操作。

在將數(shù)據(jù)寫入到數(shù)據(jù)庫(kù)里面的時(shí)候,可能會(huì)出現(xiàn)問題。也許是因?yàn)閿?shù)據(jù)庫(kù)出錯(cuò)了,又或者數(shù)據(jù)庫(kù)發(fā)現(xiàn)正在提交的數(shù)據(jù)出現(xiàn)了問題。在這種情況下,用戶可以“回滾”事務(wù)進(jìn)行的修改:回滾動(dòng)作會(huì)撤銷最近一次提交或是最近一次回滾之后發(fā)生的所有修改。在 HDBC 里面,你可以通過 rollback 函數(shù)來進(jìn)行回滾。如果你使用 withTransaction 函數(shù)來包裹事務(wù),那么函數(shù)將在事務(wù)發(fā)生異常時(shí)自動(dòng)進(jìn)行回滾。

要記住,回滾操作只會(huì)撤銷掉最近一次 commit 函數(shù)、 rollback 函數(shù)或者 withTransaction 函數(shù)引發(fā)的修改。數(shù)據(jù)庫(kù)并不會(huì)像版本控制系統(tǒng)那樣記錄全部歷史信息。本章稍后將展示一些 commit 函數(shù)的使用示例。

簡(jiǎn)單的查詢示例

最簡(jiǎn)單的 SQL 查詢語句都是一些不返回任何數(shù)據(jù)的語句,這些查詢可以用于創(chuàng)建表、插入數(shù)據(jù)、刪除數(shù)據(jù)、又或者設(shè)置數(shù)據(jù)庫(kù)的參數(shù)。

run 函數(shù)是向數(shù)據(jù)庫(kù)發(fā)送查詢的最基本的函數(shù),這個(gè)函數(shù)接受三個(gè)參數(shù),它們分別是一個(gè) IConnection 實(shí)例、一個(gè)表示查詢的 String 以及一個(gè)由列表組成的參數(shù)。以下代碼展示了如何使用這個(gè)函數(shù)去將一些數(shù)據(jù)儲(chǔ)存到數(shù)據(jù)庫(kù)里面。

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.1 ... linking ... done.
Loading package bytestring-0.9.0.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.0 ... linking ... done.
Loading package HDBC-1.1.5 ... 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

在連接到數(shù)據(jù)庫(kù)之后,程序首先創(chuàng)建了一個(gè)名為 test 的表,接著向表里面插入了一個(gè)行。最后,程序?qū)⑿薷奶峤坏綌?shù)據(jù)庫(kù),并斷開與數(shù)據(jù)庫(kù)的連接。記住,如果程序不調(diào)用 commit 函數(shù),那么修改將不會(huì)被寫入到數(shù)據(jù)庫(kù)里面。

run 函數(shù)返回因?yàn)椴樵冋Z句而被修改的行數(shù)量。在上面展示的代碼里面,第一個(gè)查詢只是創(chuàng)建一個(gè)表,它并沒有修改任何行;而第二個(gè)查詢則向表里面插入了一個(gè)行,因此 run 函數(shù)返回了數(shù)字 1 。

SqlValue

在繼續(xù)討論后續(xù)內(nèi)容之前,我們需要先了解一種由 HDBC 引入的數(shù)據(jù)類型:SqlValue 。因?yàn)?Haskell 和 SQL 都是強(qiáng)類型系統(tǒng),所以 HDBC 會(huì)嘗試盡可能地保留類型信息。與此同時(shí),Haskell 和 SQL 類型并不是一一對(duì)應(yīng)的。更進(jìn)一步來說,日期和字符串里面的特殊字符這樣的東西,在每個(gè)數(shù)據(jù)庫(kù)里面的表示方法都是不相同的。

SqlValue 類型具有 SqlString 、 SqlBool 、 SqlNull 、 SqlInteger 等多個(gè)構(gòu)造器,用戶可以通過使用這些構(gòu)造器,在傳給數(shù)據(jù)庫(kù)的參數(shù)列表里面表示各式各樣不同類型的數(shù)據(jù),并且仍然能夠?qū)⑦@些數(shù)據(jù)儲(chǔ)存到一個(gè)列表里面。除此之外,SqlValue 還提供了 toSql 和 fromSql 這樣的常用函數(shù)。如果你非常關(guān)心數(shù)據(jù)的精確表示的話,那么你還是可以在有需要的時(shí)候,手動(dòng)地構(gòu)造 SqlValue 數(shù)據(jù)。

查詢參數(shù)

HDBC 和其他數(shù)據(jù)庫(kù)一樣,都支持可替換的查詢參數(shù)。使用可替換參數(shù)主要有幾個(gè)好處:它可以預(yù)防 SQL 注射攻擊、避免因?yàn)檩斎肜锩姘厥庾址鴮?dǎo)致的問題、提升重復(fù)執(zhí)行相似查詢時(shí)的性能、并通過查詢語句實(shí)現(xiàn)簡(jiǎn)單且可移植的數(shù)據(jù)插入操作。

假設(shè)我們想要將上千個(gè)行插入到新的表 test 里面,那么我們可能會(huì)執(zhí)行像 INSERTINTOtestVALUES(0,'zero') 和 INSERTINTOtestVALUES(1,'one') 這樣的查詢上千次,這使得數(shù)據(jù)庫(kù)必須獨(dú)立地分析每條 SQL 語句。但如果我們將被插入的兩個(gè)值替換為占位符,那么服務(wù)器只需要對(duì) SQL 查詢進(jìn)行一次分析,然后就可以通過重復(fù)地執(zhí)行這個(gè)查詢來處理不同的數(shù)據(jù)了。

使用可替換參數(shù)的第二個(gè)原因和特殊字符有關(guān)。因?yàn)?SQL 使用單引號(hào)表示域(field)的末尾,所以如果我們想要插入字符串 "Idon'tlike1" ,那么大多數(shù) SQL 數(shù)據(jù)庫(kù)都會(huì)要求我們把這個(gè)字符串寫成 Idon''tlike1' ,并且不同的特殊字符(比如反斜杠符號(hào))在不同的數(shù)據(jù)庫(kù)里面也會(huì)需要不同的轉(zhuǎn)移規(guī)則。但是只要使用 HDBC ,它就會(huì)幫你自動(dòng)完成所有轉(zhuǎn)義動(dòng)作,以下展示的代碼就是一個(gè)例子:

ghci> conn <- connectSqlite3 "test1.db"

ghci> run conn "INSERT INTO test VALUES (?, ?)" [toSql 0, toSql "zero"]
1

ghci> commit conn

ghci> disconnect conn

在這個(gè)示例里面,INSERT 查詢包含的問號(hào)是一個(gè)占位符,而跟在占位符后面的就是要傳遞給占位符的各個(gè)參數(shù)。因?yàn)?run 函數(shù)的第三個(gè)參數(shù)接受的是 SqlValue 組成的列表,所以我們使用了 toSql 去將列表中的值轉(zhuǎn)換為 SqlValue 。HDBC 會(huì)根據(jù)目前使用的數(shù)據(jù)庫(kù),自動(dòng)地將 String"zero" 轉(zhuǎn)換為正確的表示方式。

在插入大量數(shù)據(jù)的時(shí)候,可替換參數(shù)實(shí)際上并不會(huì)帶來任何性能上的提升。因此,我們需要對(duì)創(chuàng)建 SQL 查詢的過程做進(jìn)一步的控制,具體的方法在接下來的一節(jié)里面就會(huì)進(jìn)行討論。

Note

使用可替換參數(shù)

當(dāng)服務(wù)器期望在查詢語句的指定部分看見一個(gè)值的時(shí)候,用戶才能使用可替換參數(shù):比如在執(zhí)行 SELECT 語句的 WHERE 子句時(shí)就可以使用可替換參數(shù);又或者在執(zhí)行 INSERT 語句的時(shí)候就可以把要插入的值設(shè)置為可替換參數(shù);但執(zhí)行 run"SELECT*from?"[toSql"tablename"] 是無法運(yùn)行的。這是因?yàn)楸淼拿植⒎且粋€(gè)值,所以大多數(shù)數(shù)據(jù)庫(kù)都不允許這種語法。因?yàn)樵趯?shí)際中很少人會(huì)使用這種方式去替換一個(gè)不是值的事物,所以這并不會(huì)帶來什么大的問題。

預(yù)備語句

HDBC 定義了一個(gè) prepare 函數(shù),它可以預(yù)先準(zhǔn)備好一個(gè) SQL 查詢,但是并不將查詢語句跟具體的參數(shù)綁定。prepare 函數(shù)返回一個(gè) Statement 值來表示已編譯的查詢。

在擁有了 Statement 值之后,用戶就可以對(duì)它調(diào)用一次或多次 execute 函數(shù)。在對(duì)一個(gè)會(huì)返回?cái)?shù)據(jù)的查詢執(zhí)行 execute 函數(shù)之后,用戶可以使用任意的獲取函數(shù)去取得查詢所得的數(shù)據(jù)。諸如 run 和 quickQuery' 這樣的函數(shù)都會(huì)在內(nèi)部使用查詢語句和 execute 函數(shù);為了讓用戶可以更快捷妥當(dāng)?shù)貓?zhí)行常見的任務(wù),像是 run 和 quickQuery' 這樣的函數(shù)都會(huì)在內(nèi)部使用 Statement 值和 execute 函數(shù)。當(dāng)用戶需要對(duì)查詢的具體執(zhí)行過程有更多的控制時(shí),就可以考慮使用 Statement 而不是 run 函數(shù)。

以下代碼展示了如何通過 Statement 值,在只使用一條查詢的情況下插入多個(gè)值:

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

在這段代碼里面,我們創(chuàng)建了一個(gè)預(yù)備語句并使用 stmt 函數(shù)去調(diào)用它。我們一共執(zhí)行了那個(gè)語句四次,每次都向它傳遞了不同的參數(shù),這些參數(shù)會(huì)被用于替換原有查詢字符串中的問號(hào)。在代碼的最后,我們提交了修改并斷開數(shù)據(jù)庫(kù)。

為了方便地重復(fù)執(zhí)行同一個(gè)預(yù)備語句,HDBC 還提供了 executeMany 函數(shù),這個(gè)函數(shù)接受一個(gè)由多個(gè)數(shù)據(jù)行組成的列表作為參數(shù),而列表中的數(shù)據(jù)行就是需要調(diào)用預(yù)備語句的數(shù)據(jù)行。正如以下代碼所示:

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

Note

更高效的查詢執(zhí)行方法

在服務(wù)器上面,大多數(shù)數(shù)據(jù)庫(kù)都會(huì)對(duì) executeMany 函數(shù)進(jìn)行優(yōu)化,使得查詢字符串只會(huì)被編譯一次而不是多次。[52]在一次插入大量數(shù)據(jù)的時(shí)候,這種優(yōu)化可以帶來極為有效的性能提升。有些數(shù)據(jù)庫(kù)還可以將這種優(yōu)化應(yīng)用到執(zhí)行查詢語句上面,并并非所有數(shù)據(jù)庫(kù)都能做到這一點(diǎn)。

讀取結(jié)果

本章在前面已經(jīng)介紹過如何通過查詢語句,將數(shù)據(jù)插入到數(shù)據(jù)庫(kù);在接下來的內(nèi)容中,我們將學(xué)習(xí)從數(shù)據(jù)庫(kù)里面獲取數(shù)據(jù)的方法。quickQuery' 函數(shù)的類型和 run 函數(shù)非常相似,只不過 quickQuery' 函數(shù)返回的是一個(gè)由查詢結(jié)果組成的列表而不是被改動(dòng)的行數(shù)量。quickQuery' 函數(shù)通常與 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"]]

ghci> disconnect conn

正如之前展示過的一樣,quickQuery' 函數(shù)能夠接受可替換參數(shù)。上面的代碼沒有使用任何可替換參數(shù),所以在調(diào)用 quickQuery' 的時(shí)候,我們沒有在函數(shù)調(diào)用的末尾給定任何的可替換值。quickQuery' 返回一個(gè)由行組成的列表,其中每個(gè)行都會(huì)被表示為 [SqlValue] ,而行里面的值會(huì)根據(jù)數(shù)據(jù)庫(kù)返回時(shí)的順序進(jìn)行排列。在有需要的時(shí)候,用戶可以使用 fromSql 可以將這些值轉(zhuǎn)換為普通的 Haskell 類型。

因?yàn)?quickQuery' 的輸出有一些難讀,我們可以對(duì)上面的示例進(jìn)行一些擴(kuò)展,將它的結(jié)果格式化得更美觀一些。以下代碼展示了對(duì)結(jié)果進(jìn)行格式化的具體方法:

-- file: ch21/query.hs
import Database.HDBC.Sqlite3 (connectSqlite3)
import Database.HDBC

{- | 定義一個(gè)函數(shù),它接受一個(gè)表示要獲取的最大 id 值作為參數(shù)。
函數(shù)會(huì)從 test 數(shù)據(jù)庫(kù)里面獲取所有匹配的行,并以一種美觀的方式將它們打印到屏幕上面。 -}
query :: Int -> IO ()
query maxId =
    do -- 連接數(shù)據(jù)庫(kù)
        conn <- connectSqlite3 "test1.db"

        -- 執(zhí)行查詢并將結(jié)果儲(chǔ)存在 r 里面
        r <- quickQuery' conn
            "SELECT id, desc from test where id <= ? ORDER BY id, desc"
            [toSql maxId]

        -- 將每個(gè)行轉(zhuǎn)換為 String
        let stringRows = map convRow r

        -- 打印行
        mapM_ putStrLn stringRows

        -- 斷開與服務(wù)器之間的連接
        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

這個(gè)程序所做的工作和本書之前展示過的 ghci 示例差不多,唯一的區(qū)別就是新添加了一個(gè) convRow 函數(shù)。這個(gè)函數(shù)接受來自數(shù)據(jù)庫(kù)行的數(shù)據(jù),并將它轉(zhuǎn)換為一個(gè)易于打印的 String 值。

注意,這個(gè)程序會(huì)直接通過 fromSql 取出 intid 值,但是在處理 fromSqlsqlDesc 的時(shí)候卻使用了 MaybeString 。不知道你是否還記得,我們?cè)诙x表的時(shí)候,曾經(jīng)將表的第一列設(shè)置為不準(zhǔn)包含 NULL 值,但是第二列卻沒有進(jìn)行這樣的設(shè)置。所以,程序不需要擔(dān)心第一列是否會(huì)包含 NULL 值,只要對(duì)第二行進(jìn)行處理就可以了。雖然我們也可以使用 fromSql 去將第二行的值直接轉(zhuǎn)換為 String ,但是這樣一來的話,程序只要遇到 NULL 值就會(huì)出現(xiàn)異常。因此,我們需要把 SQL 的 NULL 轉(zhuǎn)換為字符串 "NULL" 。雖然這個(gè)值在打印的時(shí)候可能會(huì)與字符串 'NULL' 出現(xiàn)混淆,但對(duì)于這個(gè)例子來說,這樣的問題還是可以接受的。讓我們嘗試在 ghci 里面調(diào)用這個(gè)函數(shù):

ghci> :load query.hs
[1 of 1] Compiling Main             ( query.hs, interpreted )
Ok, modules loaded: Main.

ghci> query 2
0: NULL
0: zero
1: one
2: two

使用語句進(jìn)行數(shù)據(jù)讀取操作

正如前面的《預(yù)備語句》一節(jié)所說,用戶可以使用預(yù)備語句進(jìn)行讀取操作,并且在一些環(huán)境下,使用不同的方法從這些語句里面讀取出數(shù)據(jù)將是一件非常有用的事情。像 run 、 quickQuery' 這樣的常用函數(shù)實(shí)際上都是使用語句去完成任務(wù)的。

為了創(chuàng)建一個(gè)執(zhí)行讀取操作的預(yù)備語句,用戶只需要像之前執(zhí)行寫入操作那樣使用 prepare 函數(shù)來創(chuàng)建預(yù)備語句,然后使用 execute 去執(zhí)行那個(gè)預(yù)備語句就可以了。在語句被執(zhí)行之后,用戶就可以使用各種不同的函數(shù)去讀取語句中的數(shù)據(jù)。fetchAllRows' 函數(shù)和 quickQuery' 函數(shù)一樣,都返回 [[SqlValue]] 類型的值。除此之外,還有一個(gè)名為 sFetchAllRows' 的函數(shù),它在返回每個(gè)列的數(shù)據(jù)之前,會(huì)先將它們轉(zhuǎn)換為 MaybeString 。最后,fetchAllRowsAL' 函數(shù)對(duì)于每個(gè)列返回一個(gè) (String,SqlValue) 二元組,其中 String 類型的值是數(shù)據(jù)庫(kù)返回的列名。本章接下來的《數(shù)據(jù)庫(kù)元數(shù)據(jù)》一節(jié)還會(huì)介紹其他獲取列名的方法。

通過 fetchRow 函數(shù),用戶可以每次只讀取一個(gè)行上面的數(shù)據(jù),這個(gè)函數(shù)會(huì)返回 IO(Maybe[SqlValue]) 類型的值:當(dāng)所有行都已經(jīng)被讀取了之后,函數(shù)返回 Nothing ;如果還有尚未讀取的行,那么函數(shù)返回一個(gè)行。

惰性讀取

前面的《惰性I/O》一節(jié)曾經(jīng)介紹過如何對(duì)文件進(jìn)行惰性 I/O 操作,同樣的方法也可以用于讀取數(shù)據(jù)庫(kù)中的數(shù)據(jù),并且在處理可能會(huì)返回大量數(shù)據(jù)的查詢時(shí),這種特性將是非常有用的。通過惰性地讀取數(shù)據(jù),用戶可以繼續(xù)使用 fetchAllRows 這樣的方便的函數(shù),不必再在行數(shù)據(jù)到達(dá)時(shí)手動(dòng)地讀取數(shù)據(jù)。通過以謹(jǐn)慎的方式使用數(shù)據(jù),用戶可以避免將所有結(jié)構(gòu)都緩存到內(nèi)存里面。

不過要注意的是,針對(duì)數(shù)據(jù)庫(kù)的惰性讀取比針對(duì)文件的惰性讀取要負(fù)責(zé)得多。用戶在以惰性的方式讀取完整個(gè)文件之后,文件就會(huì)被關(guān)閉,不會(huì)留下什么麻煩的事情。另一方面,當(dāng)用戶以惰性的方式從數(shù)據(jù)庫(kù)讀取完數(shù)據(jù)之后,數(shù)據(jù)庫(kù)的連接仍然處于打開狀態(tài),以便用戶繼續(xù)執(zhí)行其他操作。有些數(shù)據(jù)庫(kù)甚至支持同時(shí)發(fā)送多個(gè)查詢,所以 HDBC 是無法在用戶完成一次惰性讀取之后就關(guān)閉連接的。

在使用惰性讀取的時(shí)候,有一點(diǎn)是非常重要的:在嘗試關(guān)閉連接或者執(zhí)行一個(gè)新的查詢之前,一定要先將整個(gè)數(shù)據(jù)集讀取完。我們推薦你使用嚴(yán)格(strict)函數(shù)又或者以一行接一行的方式進(jìn)行處理,從而盡量避免惰性讀取帶來的復(fù)雜的交互行為。

Tip

如果你是剛開始使用 HDBC ,又或者對(duì)惰性讀取的概念并不熟悉,但是又需要讀取大量數(shù)據(jù),那么可以考慮通過反復(fù)調(diào)用 fetchRow 來獲取數(shù)據(jù)。這是因?yàn)槎栊宰x取雖然是一種非常強(qiáng)大而且有用的工具,但是正確地使用它并不是那么容易的。

要對(duì)數(shù)據(jù)庫(kù)進(jìn)行惰性讀取,只需要使用不帶單引號(hào)版本的數(shù)據(jù)庫(kù)函數(shù)就可以了。比如 fetchAllRows 就是 fetchAllRows' 的惰性讀取版本。惰性函數(shù)的類型和對(duì)應(yīng)的嚴(yán)格版本函數(shù)的類型一樣。以下代碼展示了一個(gè)惰性讀取示例:

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")]]

ghci> mapM_ print results
[("id",SqlString "0"),("desc",SqlNull)]
[("id",SqlString "0"),("desc",SqlString "zero")]
[("id",SqlString "1"),("desc",SqlString "one")]

ghci> disconnect conn

雖然使用 fetchAllRowsAL' 函數(shù)也可以達(dá)到取出所有行的效果,但是如果需要讀取的數(shù)據(jù)集非常大,那么 fetchAllRowsAL' 函數(shù)可能就會(huì)消耗非常多的內(nèi)容。通過以惰性的方式讀取數(shù)據(jù),我們同樣可以讀取非常大的數(shù)據(jù)集,但是只需要使用常數(shù)數(shù)量的內(nèi)存。惰性版本的數(shù)據(jù)庫(kù)讀取函數(shù)會(huì)把結(jié)果放到一個(gè)塊里面進(jìn)行求值;而嚴(yán)格版的數(shù)據(jù)庫(kù)讀取函數(shù)則會(huì)直接獲取所有結(jié)果,把它們儲(chǔ)存到內(nèi)存里面,接著打印。

數(shù)據(jù)庫(kù)元數(shù)據(jù)

在一些情況下,能夠知道一些關(guān)于數(shù)據(jù)庫(kù)自身的信息是非常有用的。比如說,一個(gè)程序可能會(huì)想要看看數(shù)據(jù)庫(kù)里面目前已有的表,然后自動(dòng)創(chuàng)建缺失的表或者對(duì)數(shù)據(jù)庫(kù)的模式(schema)進(jìn)行更新。而在另外一些情況下,程序可能會(huì)需要根據(jù)正在使用的數(shù)據(jù)庫(kù)后端對(duì)自己的行為進(jìn)行修改。

通過使用 getTables 函數(shù),我們可以取得數(shù)據(jù)庫(kù)目前已定義的所有列表;而 describeTable 函數(shù)則可以告訴我們給定表的各個(gè)列的定義信息。

調(diào)用 dbServerVer 和 proxiedClientName 可以幫助我們了解正在運(yùn)行的數(shù)據(jù)庫(kù)服務(wù)器,而 dbTransactionSupport 函數(shù)則可以讓我們了解到數(shù)據(jù)庫(kù)是否支持事務(wù)。以下代碼展示了這三個(gè)函數(shù)的調(diào)用示例:

ghci> conn <- connectSqlite3 "test1.db"

ghci> getTables conn
["test"]

ghci> proxiedClientName conn
"sqlite3"

ghci> dbServerVer conn
"3.5.9"

ghci> dbTransactionSupport conn
True

ghci> disconnect conn

describeResult 函數(shù)返回一組 [(String,SqlColDesc)] 類型的二元組,二元組的第一個(gè)項(xiàng)是列的名字,第二個(gè)項(xiàng)則是與列相關(guān)的信息:列的類型、大小以及這個(gè)列能夠?yàn)?NULL 等等。完整的描述可以參考 HDBC 的 API 手冊(cè)。

需要注意一點(diǎn)是,某些數(shù)據(jù)庫(kù)并不能提供所有這些元數(shù)據(jù)。在這種情況下,程序?qū)⒁l(fā)一個(gè)異常。比如 Sqlite3 就不支持前面提到的 describeResult 和 describeTable 。

錯(cuò)誤處理

HDBC 在錯(cuò)誤出現(xiàn)時(shí)會(huì)引發(fā)異常,異常的類型為 SqlError 。這些異常會(huì)傳遞來自底層 SQL 引擎的信息,比如數(shù)據(jù)庫(kù)的狀態(tài)、錯(cuò)誤信息、數(shù)據(jù)庫(kù)的數(shù)字錯(cuò)誤代號(hào)等等。

因?yàn)?ghci 并不清楚應(yīng)該如何向用戶展示一個(gè) SqlError ,所以這個(gè)異常將導(dǎo)致程序停止,并打印一條沒有什么用的信息。就像這樣:

ghci> conn <- connectSqlite3 "test1.db"

ghci> quickQuery' conn "SELECT * from test2" []
*** Exception: (unknown)

ghci> disconnect conn

上面的這段代碼因?yàn)槭褂昧?SELECT 去獲取一個(gè)不存在的表,所以引發(fā)了錯(cuò)誤,但 ghci 返回的的錯(cuò)誤信息并沒有說清楚這一點(diǎn)。通過使用 handleSqlError 輔助函數(shù),我們可以捕捉 SqlError 并將它重新拋出為 IOError 。這種格式的錯(cuò)誤可以被 ghci 打印,但是這種格式會(huì)使得用戶比較難于通過編程的方式來獲取錯(cuò)誤信息的指定部分。以下是一個(gè)使用 handleSqlError 處理異常的例子:

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

這個(gè)新的錯(cuò)誤提示具有更多信息,它甚至包含了一條說明 test2 表并不存在的消息,這比之前的錯(cuò)誤提示有用得多了。作為一種標(biāo)準(zhǔn)實(shí)踐(standard practice),很多 HDBC 程序員都將 main=handleSqlError$do 放到程序的開頭,確保所有未被捕獲的 SqlError 都會(huì)以更有效的方式被打印。

除了 handleSqlError 之外,HDBC 還提供了 catchSql 和 handleSql 這兩個(gè)函數(shù),它們類似于標(biāo)準(zhǔn)的 catch 函數(shù)和 handle 函數(shù),主要的區(qū)別在于 catchSql 和 handleSql 只會(huì)中斷 HDBC 錯(cuò)誤。想要了解更多關(guān)于錯(cuò)誤處理的信息,可以參考本書第 19 章《錯(cuò)誤處理》一章。

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)