返回文章列表

運用 Scala 與 doobie 打造流暢資料庫操作介面

本文探討如何使用 Scala 與 doobie 函式庫,建構一個功能完整的資料庫操作介面。內容涵蓋將查詢結果無縫映射至 Scala Case Class、利用 Fragment.const 處理動態 SQL,並實作通用方法以簡化資料讀取。文章進一步闡述如何執行 DDL(資料定義語言)與 DML(資料操作語言)語句,並透過方法鏈接設計流暢介面,將建立資料表、插入數據、查詢結果等複雜流程,整合成簡潔易讀的程式碼序列。

數據工程 軟體開發

在現代數據工程中,與資料庫的互動已超越單純的 SQL 執行,轉而追求更高的程式碼抽象化、型別安全性與可維護性。一個設計優良的資料庫介面層,能有效隔離底層 JDBC 的複雜性,並將關聯式數據安全地轉換為應用程式中的領域物件。本文深入探討如何運用 Scala 的強型別特性與 doobie 函式庫的函數式模型,來實現此一目標。文章從 Read 類型類別出發,展示如何建構一個不僅能處理查詢,更能優雅執行 DDL 與 DML 操作的通用介面。其採用的流暢介面設計模式,將多個資料庫操作串聯成具備高度可讀性的業務邏輯流,體現了函數式設計在數據處理流程中的實用價值。

數據工程核心理論與實踐

第四章:資料庫操作與Spark JDBC API

使用資料庫
| longitude | double | YES | | NULL | |
+-----------+--------------+------+------+--------+-------+

這是my_db.airports資料表的Schema描述,它列出了資料表中的所有欄位、它們的資料類型、是否允許空值、是否為主鍵、預設值以及其他額外資訊。這個Schema對於理解資料表的結構和如何將資料庫記錄映射到Scala物件至關重要。

玄貓可以從這個資料表讀取記錄,如下所示:

case class Airports(
iata_code: String,
airport: String,
city: String,
state: String,
country: String,
latitude: Double,
longitude: Double
)

val airports: List[Airports] =
sql"select * from my_db.airports"
.query[Airports] // Query0[Airports]
.to[List] // ConnectionIO[List[Airports]]
.transact(transactor) // IO[List[Airports]]
.unsafeRunSync() // List[Airports]

// 列印北卡羅來納州有機場的城市
airports
.filter(_.state == "NC") // 使用filter過濾出北卡羅來納州的機場
.map(_.city) // 提取城市名稱
.foreach(println) // 逐一列印城市名稱

// 輸出範例:
// Asheville
// Charlotte
// New Bern
// Fayetteville
// Greensboro
// Wilmington
// Jacksonville
// Raleigh

這段程式碼展示了doobie如何將資料庫查詢結果無縫地映射到Airportscase class的列表。透過sql"select * from my_db.airports".query[Airports],doobie能夠根據Airportscase class的欄位名稱和類型自動匹配資料庫的列。to[List]將查詢結果轉換為ConnectionIO[List[Airports]],表示一個在資料庫連接中執行並返回Airports列表的計算。最後,transact(transactor).unsafeRunSync()執行這個計算,並將結果賦值給airports變數。隨後,程式碼對airports列表進行過濾和轉換,列印出北卡羅來納州所有機場所在的城市。

基於這個範例,玄貓可以考慮向資料庫介面添加一個records方法,其簽名如下:

def records[T: Read](selectStatement: String): List[T]

這裡的T: Read語法是上下文綁定(context-bound),它自動引入了一個Read[T]類型的隱式參數。doobie可以自動為IntStringTuplecase class等類型推導出Read[T]類型類別實例。玄貓也可以使用Tuple類型而不是case class

實際的實現與上述範例非常相似。唯一的區別是,玄貓使用的是Fragment語句而不是sql內插器。原因是sql內插器會將任何變數替換為?並返回一個Fragment語句;換句話說,"$selectStatement" SQL將無法工作:

def records[T: Read](selectStatement: String): List[T] = {
Fragment.const(selectStatement) // 使用Fragment.const來處理動態SQL字串
.query[T]
.to[List]
.transact(transactor)
.unsafeRunSync()
}

這段程式碼定義了一個通用的records方法,它接受一個SQL查詢字串selectStatement,並返回一個List[T]T: Read上下文綁定確保了doobie能夠將查詢結果映射到T類型。關鍵點在於使用Fragment.const(selectStatement)來構建doobie的查詢片段,而不是sql"..."內插器。這是因為sql內插器主要用於靜態SQL和參數化查詢,而Fragment.const則適用於動態構建整個SQL語句。這種設計使得records方法能夠執行任意的SELECT查詢,並將結果安全地映射到指定的Scala類型。

添加了records方法後,玄貓可以將範例重寫如下:

val airports2: List[Airports] =
db.records[Airports]("select * from my_db.airports")

airports2
.filter(_.state == "NC")
.map(_.city)
.foreach(println)

這段程式碼展示了records方法如何簡化資料庫查詢。透過db.records[Airports]("select * from my_db.airports"),玄貓可以直接從db物件獲取Airports物件的列表,而無需重複構建doobie的查詢管道。這種抽象使得應用程式程式碼更加簡潔、易讀,並將資料庫操作的細節封裝在Database介面中。

T: Read類型參數語法被稱為上下文綁定,它自動引入了一個Read[T]類型的隱式參數。doobie可以自動為IntStringTuplecase classT類型推導出Read[T]類型類別實例。玄貓也可以使用Tuple類型而不是case class。請參考以下程式碼片段:

val airports3 =
db.records[(String, String, String, String, String, Double,
Double)](
"select * from my_db.airports"
)

這段程式碼展示了records方法如何將查詢結果映射到一個元組類型,而不是case class。這在某些情況下可能很有用,例如當玄貓不需要為每個查詢結果定義一個專門的case class時。doobie的Read類型類別實例推導機制非常靈活,可以處理各種Scala類型。

doobie還允許運行資料定義語言(DDL)語句,例如創建、刪除資料表和視圖等。為此,玄貓將使用update而不是query。例如,讓玄貓創建一個flight_count資料表,該資料表捕獲起點和目的地機場之間的航班總數:

create table my_db.flight_count(
origin_state varchar(100),
origin_city varchar(100),
origin_airport varchar(500),
destination_state varchar(100),
destination_city varchar(100),
destination_airport varchar(500),
number_of_flights int
)

創建資料表的語法與查詢非常相似。然而,玄貓將使用update而不是query

Fragment
.const("""|
|create table my_db.flight_count(
| origin_state varchar(100),
| origin_city varchar(100),
| origin_airport varchar(500),
| destination_state varchar(100),
| destination_city varchar(100),
| destination_airport varchar(500),
| number_of_flights int
|);""".stripMargin)
.update // Update0
.run // ConnectionIO[Int]
.transact(transactor) // IO[Int]
.unsafeRunSync()

這段程式碼展示了如何使用doobie執行DDL語句來創建資料表。它使用Fragment.const來構建包含CREATE TABLE語句的SQL片段。與SELECT查詢不同,DDL操作使用.update方法,它返回一個Update0物件,表示一個不帶參數的更新操作。.run方法將Update0轉換為ConnectionIO[Int],其中Int表示受影響的行數(對於DDL通常為0)。最後,透過transact(transactor).unsafeRunSync()執行該操作。

上述表達式的返回類型是Int,在本例中,它返回0。讓玄貓創建一個runDDL方法,它將對資料庫運行任何任意的DDL語句。這是它的簽名:

def runDDL(statement: String): Database

Database的返回類型將使這些方法呼叫能夠串聯起來:

def runDDL(statement: String): Database = {
Fragment
.const(statement)
.update
.run
.transact(transactor)
.unsafeRunSync()
this // 返回當前Database實例,以便進行方法鏈接
}

這段程式碼將runDDL方法添加到Database介面中。它接受一個statement字串作為DDL語句,並執行該語句。關鍵點在於它返回this,這使得runDDL方法可以被鏈接,例如db.runDDL("DROP TABLE ...").runDDL("CREATE TABLE ...")。這種設計模式稱為流暢介面(Fluent Interface)方法鏈接(Method Chaining),它提高了程式碼的可讀性和表達力。

數據工程核心理論與實踐

第四章:資料庫操作與Spark JDBC API

執行各種資料庫操作

執行資料操作語言(DML)語句,例如插入(insert)、更新(update)和刪除(delete),與執行DDL語句是相同的。然而,為了區分DDL和DML語句,創建一個單獨的方法來處理DML語句可能會很有用:

def runDML(statement: String): Database = runDDL(statement)

玄貓不需要提供單獨的實現,因為兩者是相同的。有了這些,讓玄貓看看它們如何協同工作。

在以下範例中,玄貓將刪除flight_count資料表(如果它存在),創建一個資料表,插入記錄,查詢資料表並將結果載入到列表中。最後,玄貓將列印出給定狀態內的所有航班:

val createTable =
"""|
|create table my_db.flight_count(
| origin_state varchar(100),
| origin_city varchar(100),
| origin_airport varchar(500),
| destination_state varchar(100),
| destination_city varchar(100),
| destination_airport varchar(500),
| number_of_flights int
|);""".stripMargin

val insert =
"""|
|insert into my_db.flight_count
|select
| oa.state o_state
|, oa.city o_city
|, oa.airport o_airport
|, da.state d_state
|, da.city d_city
|, da.airport d_airport
|, count(1)
|from my_db.airlines
|inner join my_db.flights on airlines.iata_code = flights.airline
|inner join my_db.airports oa on flights.origin_airport = oa.iata_code
|inner join my_db.airports da on flights.destination_airport = da.iata_code
|group by
| oa.state
|, oa.city
|, oa.airport
|, da.state
|, da.city
|, da.airport
|""".stripMargin

case class FlightCount(
origin_state: String,
origin_city: String,
origin_airport: String,
destination_state: String,
destination_city: String,
destination_airport: String,
number_of_flights: Int
)

val flightCount: List[FlightCount] = db
.runDDL("drop table if exists my_db.flight_count;") // 如果存在則刪除
.runDDL(createTable) // 創建資料表
.runDML(insert) // 插入資料
.records[FlightCount]("select * from my_db.flight_count;") // 查詢資料並載入到List

flightCount
.collect { case fc if fc.origin_state == fc.destination_state => fc } // 過濾出起點和目的地狀態相同的航班
.foreach(println) // 列印結果

這段程式碼展示了一個完整的資料庫操作流程,包括DDL和DML語句的執行,以及資料查詢。它利用了Database介面的方法鏈接能力,使得整個流程非常流暢和易讀。首先,它定義了createTableinsert的SQL語句。然後,定義了一個FlightCountcase class來匹配flight_count資料表的結構。接著,透過鏈接runDDLrunDML方法來執行刪除、創建和插入操作,最後使用records方法查詢資料並將結果映射到FlightCount物件的列表。最終,程式碼過濾並列印出起點和目的地州相同的航班資訊。

在本節中,玄貓探討了如何向玄貓的資料庫介面添加功能,以實現資料表的創建、刪除、運行查詢等。目前,玄貓的介面還缺乏使用Spark JDBC API執行讀寫操作的能力,這正是玄貓在下一節中將要探討的內容。

數據工程核心理論與實踐

第四章:資料庫操作與Spark JDBC API

使用資料庫
| longitude | double | YES | | NULL | |
+-----------+--------------+------+------+--------+-------+

這是my_db.airports資料表的Schema描述,它列出了資料表中的所有欄位、它們的資料類型、是否允許空值、是否為主鍵、預設值以及其他額外資訊。這個Schema對於理解資料表的結構和如何將資料庫記錄映射到Scala物件至關重要。

玄貓可以從這個資料表讀取記錄,如下所示:

case class Airports(
iata_code: String,
airport: String,
city: String,
state: String,
country: String,
latitude: Double,
longitude: Double
)

val airports: List[Airports] =
sql"select * from my_db.airports"
.query[Airports] // Query0[Airports]
.to[List] // ConnectionIO[List[Airports]]
.transact(transactor) // IO[List[Airports]]
.unsafeRunSync() // List[Airports]

// 列印北卡羅來納州有機場的城市
airports
.filter(_.state == "NC") // 使用filter過濾出北卡羅來納州的機場
.map(_.city) // 提取城市名稱
.foreach(println) // 逐一列印城市名稱

// 輸出範例:
// Asheville
// Charlotte
// New Bern
// Fayetteville
// Greensboro
// Wilmington
// Jacksonville
// Raleigh

這段程式碼展示了doobie如何將資料庫查詢結果無縫地映射到Airportscase class的列表。透過sql"select * from my_db.airports".query[Airports],doobie能夠根據Airportscase class的欄位名稱和類型自動匹配資料庫的列。to[List]將查詢結果轉換為ConnectionIO[List[Airports]],表示一個在資料庫連接中執行並返回Airports列表的計算。最後,transact(transactor).unsafeRunSync()執行這個計算,並將結果賦值給airports變數。隨後,程式碼對airports列表進行過濾和轉換,列印出北卡羅來納州所有機場所在的城市。

基於這個範例,玄貓可以考慮向資料庫介面添加一個records方法,其簽名如下:

def records[T: Read](selectStatement: String): List[T]

這裡的T: Read語法是上下文綁定(context-bound),它自動引入了一個Read[T]類型的隱式參數。doobie可以自動為IntStringTuplecase class等類型推導出Read[T]類型類別實例。玄貓也可以使用Tuple類型而不是case class

實際的實現與上述範例非常相似。唯一的區別是,玄貓使用的是Fragment語句而不是sql內插器。原因是sql內插器會將任何變數替換為?並返回一個Fragment語句;換句話說,"$selectStatement" SQL將無法工作:

def records[T: Read](selectStatement: String): List[T] = {
Fragment.const(selectStatement) // 使用Fragment.const來處理動態SQL字串
.query[T]
.to[List]
.transact(transactor)
.unsafeRunSync()
}

這段程式碼定義了一個通用的records方法,它接受一個SQL查詢字串selectStatement,並返回一個List[T]T: Read上下文綁定確保了doobie能夠將查詢結果映射到T類型。關鍵點在於使用Fragment.const(selectStatement)來構建doobie的查詢片段,而不是sql"..."內插器。這是因為sql內插器主要用於靜態SQL和參數化查詢,而Fragment.const則適用於動態構建整個SQL語句。這種設計使得records方法能夠執行任意的SELECT查詢,並將結果安全地映射到指定的Scala類型。

添加了records方法後,玄貓可以將範例重寫如下:

val airports2: List[Airports] =
db.records[Airports]("select * from my_db.airports")

airports2
.filter(_.state == "NC")
.map(_.city)
.foreach(println)

這段程式碼展示了records方法如何簡化資料庫查詢。透過db.records[Airports]("select * from my_db.airports"),玄貓可以直接從db物件獲取Airports物件的列表,而無需重複構建doobie的查詢管道。這種抽象使得應用程式程式碼更加簡潔、易讀,並將資料庫操作的細節封裝在Database介面中。

T: Read類型參數語法被稱為上下文綁定,它自動引入了一個Read[T]類型的隱式參數。doobie可以自動為IntStringTuplecase classT類型推導出Read[T]類型類別實例。玄貓也可以使用Tuple類型而不是case class。請參考以下程式碼片段:

val airports3 =
db.records[(String, String, String, String, String, Double,
Double)](
"select * from my_db.airports"
)

這段程式碼展示了records方法如何將查詢結果映射到一個元組類型,而不是case class。這在某些情況下可能很有用,例如當玄貓不需要為每個查詢結果定義一個專門的case class時。doobie的Read類型類別實例推導機制非常靈活,可以處理各種Scala類型。

doobie還允許運行資料定義語言(DDL)語句,例如創建、刪除資料表和視圖等。為此,玄貓將使用update而不是query。例如,讓玄貓創建一個flight_count資料表,該資料表捕獲起點和目的地機場之間的航班總數:

create table my_db.flight_count(
origin_state varchar(100),
origin_city varchar(100),
origin_airport varchar(500),
destination_state varchar(100),
destination_city varchar(100),
destination_airport varchar(500),
number_of_flights int
)

創建資料表的語法與查詢非常相似。然而,玄貓將使用update而不是query

Fragment
.const("""|
|create table my_db.flight_count(
| origin_state varchar(100),
| origin_city varchar(100),
| origin_airport varchar(500),
| destination_state varchar(100),
| destination_city varchar(100),
| destination_airport varchar(500),
| number_of_flights int
|);""".stripMargin)
.update // Update0
.run // ConnectionIO[Int]
.transact(transactor) // IO[Int]
.unsafeRunSync()

這段程式碼展示了如何使用doobie執行DDL語句來創建資料表。它使用Fragment.const來構建包含CREATE TABLE語句的SQL片段。與SELECT查詢不同,DDL操作使用.update方法,它返回一個Update0物件,表示一個不帶參數的更新操作。.run方法將Update0轉換為ConnectionIO[Int],其中Int表示受影響的行數(對於DDL通常為0)。最後,透過transact(transactor).unsafeRunSync()執行該操作。

上述表達式的返回類型是Int,在本例中,它返回0。讓玄貓創建一個runDDL方法,它將對資料庫運行任何任意的DDL語句。這是它的簽名:

def runDDL(statement: String): Database

Database的返回類型將使這些方法呼叫能夠串聯起來:

def runDDL(statement: String): Database = {
Fragment
.const(statement)
.update
.run
.transact(transactor)
.unsafeRunSync()
this // 返回當前Database實例,以便進行方法鏈接
}

這段程式碼將runDDL方法添加到Database介面中。它接受一個statement字串作為DDL語句,並執行該語句。關鍵點在於它返回this,這使得runDDL方法可以被鏈接,例如db.runDDL("DROP TABLE ...").runDDL("CREATE TABLE ...")。這種設計模式稱為流暢介面(Fluent Interface)方法鏈接(Method Chaining),它提高了程式碼的可讀性和表達力。

數據工程核心理論與實踐

第四章:資料庫操作與Spark JDBC API

執行各種資料庫操作

執行資料操作語言(DML)語句,例如插入(insert)、更新(update)和刪除(delete),與執行DDL語句是相同的。然而,為了區分DDL和DML語句,創建一個單獨的方法來處理DML語句可能會很有用:

def runDML(statement: String): Database = runDDL(statement)

玄貓不需要提供單獨的實現,因為兩者是相同的。有了這些,讓玄貓看看它們如何協同工作。

在以下範例中,玄貓將刪除flight_count資料表(如果它存在),創建一個資料表,插入記錄,查詢資料表並將結果載入到列表中。最後,玄貓將列印出給定狀態內的所有航班:

val createTable =
"""|
|create table my_db.flight_count(
| origin_state varchar(100),
| origin_city varchar(100),
| origin_airport varchar(500),
| destination_state varchar(100),
| destination_city varchar(100),
| destination_airport varchar(500),
| number_of_flights int
|);""".stripMargin

val insert =
"""|
|insert into my_db.flight_count
|select
| oa.state o_state
|, oa.city o_city
|, oa.airport o_airport
|, da.state d_state
|, da.city d_city
|, da.airport d_airport
|, count(1)
|from my_db.airlines
|inner join my_db.flights on airlines.iata_code = flights.airline
|inner join my_db.airports oa on flights.origin_airport = oa.iata_code
|inner join my_db.airports da on flights.destination_airport = da.iata_code
|group by
| oa.state
|, oa.city
|, oa.airport
|, da.state
|, da.city
|, da.airport
|""".stripMargin

case class FlightCount(
origin_state: String,
origin_city: String,
origin_airport: String,
destination_state: String,
destination_city: String,
destination_airport: String,
number_of_flights: Int
)

val flightCount: List[FlightCount] = db
.runDDL("drop table if exists my_db.flight_count;") // 如果存在則刪除
.runDDL(createTable) // 創建資料表
.runDML(insert) // 插入資料
.records[FlightCount]("select * from my_db.flight_count;") // 查詢資料並載入到List

flightCount
.collect { case fc if fc.origin_state == fc.destination_state => fc } // 過濾出起點和目的地狀態相同的航班
.foreach(println) // 列印結果

這段程式碼展示了一個完整的資料庫操作流程,包括DDL和DML語句的執行,以及資料查詢。它利用了Database介面的方法鏈接能力,使得整個流程非常流暢和易讀。首先,它定義了createTableinsert的SQL語句。然後,定義了一個FlightCountcase class來匹配flight_count資料表的結構。接著,透過鏈接runDDLrunDML方法來執行刪除、創建和插入操作,最後使用records方法查詢資料並將結果映射到FlightCount物件的列表。最終,程式碼過濾並列印出起點和目的地州相同的航班資訊。

在本節中,玄貓探討了如何向玄貓的資料庫介面添加功能,以實現資料表的創建、刪除、運行查詢等。目前,玄貓的介面還缺乏使用Spark JDBC API執行讀寫操作的能力,這正是玄貓在下一節中將要探討的內容。

解構本章所建構的資料庫抽象層設計可以發現,其核心價值在於透過函式庫(doobie)實現了工程實踐上的一次重要突破。它成功地在傳統ORM(物件關聯對應)的過度封裝與原生JDBC的繁瑣易錯之間,找到了一個精妙的平衡點。透過將型別安全(type safety)的編譯期檢查與流暢介面(fluent interface)的設計模式相結合,開發者不僅能大幅降低運行期錯誤,更能在架構層次上建立起一套清晰、可複用且易於維護的資料存取邏輯。然而,此方法的價值實現,其關鍵瓶頸在於團隊對函式化編程思維的掌握程度,初期的學習曲線雖較陡峭,但換來的長期程式碼品質與穩定性,是極具價值的投資。

展望未來,此種精緻化的應用層級資料操作,與下一章節將探討的Spark JDBC API所代表的大規模分散式處理,並非相互取代,而是一種能力互補。掌握兩者的整合應用,將是現代數據工程師從「完成功能」邁向「建構穩健系統」的關鍵躍升。

玄貓認為,對於追求卓越工程實踐的團隊而言,投入資源建立類似的型別安全資料庫抽象層,已是現代數據工程中提升開發效能與系統韌性的必要基礎建設。