在數據工程實務中,配置管理是確保系統穩健性與安全性的關鍵環節,卻常被忽略。本文從一個常見問題——明文傳遞資料庫憑證——出發,逐步引導讀者進行重構。初始階段將導入 PureConfig 函式庫,實現從環境變數安全讀取配置,解決資安漏洞。然而,文章的核心不止於此,而是展示如何透過 Scala 的特徵(Trait)建立抽象介面,並結合工廠設計模式,將零散的配置讀取邏輯封裝成高內聚、低耦合的模組。這個演進過程不僅是程式碼的優化,更體現了軟體架構思維在數據工程領域的應用價值,為打造可擴展且易於維護的數據應用奠定堅實基礎。
載入資料庫配置
在離開本節之前,玄貓鼓勵讀者嘗試以下操作:
- 將機場數據集的Schema列印到控制台。
- 確定德克薩斯州(Texas)有多少個機場。
在下一節中,玄貓將探討如何使用開源函式庫來管理配置,該函式庫允許讀者從環境變數中讀取各種參數。
數據工程核心理論與實踐
第四章:資料庫操作與Spark JDBC API
載入資料庫配置
在進一步討論之前,讓玄貓解決讀者可能已經注意到的程式碼問題——玄貓以明文形式傳遞憑證,這不僅是一種不良實踐,也帶來了重大的安全風險。在生產程式碼中,敏感資訊通常作為環境變數提供,並在運行時載入。
為了將PureConfig函式庫添加到讀者的專案中,讀者需要在build.sbt檔案中添加以下依賴:
libraryDependencies += "com.github.pureconfig" %% "pureconfig" % "0.17.2"
本節的重點是在運行時載入資料庫配置。為此,玄貓可以創建一個Config檔案,其中包含以下case class和一個輔助物件:
package com.blackcat.dewithscala
import pureconfig._
import pureconfig.generic.auto._
final case class Opaque(value: String) extends AnyVal {
override def toString = "****"
}
case class Database(
name: String,
scheme: String,
host: String,
port: String,
username: Opaque,
password: Opaque
)
case class ProjectConfig(db: List[Database])
object Config {
val cfg = ConfigSource.default.loadOrThrow[ProjectConfig]
def getDB(name: String): Option[Database] = cfg.db.filter(_.name == name).headOption
}
這段程式碼定義了用於安全載入配置的結構。Opaque是一個值類別,用於封裝敏感資訊(如密碼),並在toString方法中將其隱藏,防止意外洩露。Databasecase class定義了資料庫連接所需的各種參數,包括名稱、協議、主機、埠、使用者名稱和密碼。ProjectConfig則包含一個Database物件的列表。Config物件是配置的入口點,它使用PureConfig從預設配置源載入ProjectConfig,並提供getDB方法,允許根據名稱獲取特定的資料庫配置。
db = [
{
name = "my_db"
scheme = "jdbc:mysql"
host = "localhost"
port = "3306"
username = ${?MYSQL_USER}
password = ${?MYSQL_PASS}
}
]
讀者一定已經注意到,username和password現在都是從環境變數中載入的。此外,請注意Config物件中的getDB方法返回的是Option[Database]類型,因此如果找不到匹配的資料庫,它會將錯誤處理委託給應用程式端。
玄貓現在可以將先前的範例重寫如下:
package com.blackcat.dewithscala.chapter4
import org.apache.spark.sql.SparkSession
import com.blackcat.dewithscala._
@SuppressWarnings(Array("org.wartremover.warts.OptionPartial"))
object ReadTableUsingConfig extends App {
private val session = SparkSession
.builder()
.appName("de-with-scala")
.master("local[*]")
.getOrCreate()
private val mysqlDB = Config.getDB("my_db")
def getDBParams(param: String): Option[String] = param match {
case "scheme" => mysqlDB.map(_.scheme)
case "host" => mysqlDB.map(_.host)
case "port" => mysqlDB.map(_.port)
case "name" => mysqlDB.map(_.name)
case "username" => mysqlDB.map(_.username.value)
case "password" => mysqlDB.map(_.password.value)
case _ => None
}
private val scheme = getDBParams("scheme").get
private val host = getDBParams("host").get
private val port = getDBParams("port").get
private val name = getDBParams("name").get
private val username = getDBParams("username").get
private val password = getDBParams("password").get
private val airportsDF = session.read
.format("jdbc")
.option("url", s"$scheme://$host:$port/$name")
.option("user", username)
.option("password", password)
.option("dbtable", "airports")
.load()
airportsDF.show()
}
這個重寫後的應用程式展示了如何安全地從配置中載入資料庫憑證。它使用Config.getDB("my_db")來獲取my_db的資料庫配置,然後透過getDBParams函數從Option[Database]中提取各個參數。這些參數(scheme、host、port、name、username、password)隨後用於構建JDBC連接URL,並傳遞給Spark的read.jdbc選項。這種方法避免了在程式碼中硬編碼敏感資訊,顯著提升了安全性。
在上述範例中,玄貓在運行時載入了資料庫配置。雖然這是一個好的開始,但它存在幾個陷阱。例如,getDBParams方法接受一個字串作為其參數,如果玄貓傳遞的是schema而不是scheme,它不會拋出任何警告。這將在運行時才顯現出來。這可以透過改進getDBParams方法來克服。此外,每次玄貓想在不同的應用程式中讀取資料庫參數時,都需要定義getDBParams方法,並且重複使用getDBParams("parameter").get來讀取各種參數也很麻煩。
在下一節中,玄貓將改進玄貓的程式碼,並提出一個介面,以減輕前面提到的一些陷阱。
數據工程核心理論與實踐
第四章:資料庫操作與Spark JDBC API
創建資料庫介面
在上一節中,玄貓探討了如何使用像PureConfig這樣的配置管理函式庫來讀取環境變數。然而,getDBParams函數並不是很實用,因為每次玄貓需要存取給定資料庫的主機、埠等資訊時,都需要重複這些操作。一個可能的選項是更新getDBParams,如下所示:
package com.blackcat.dewithscala.chapter4
import org.apache.spark.sql.SparkSession
import com.blackcat.dewithscala._
@SuppressWarnings(Array("org.wartremover.warts.OptionPartial"))
object UpdatedDBParamMethod extends App {
private val session = SparkSession
.builder()
.appName("de-with-scala")
.master("local[*]")
.getOrCreate()
private val mysqlDB = Config.getDB("my_db").get // 直接取得Database物件
def getDBParams(db: Database): String => Option[String] = param =>
param match {
case "scheme" => Some(db.scheme)
case "host" => Some(db.host)
case "port" => Some(db.port)
case "name" => Some(db.name)
case "username" => Some(db.username.value)
case "password" => Some(db.password.value)
case _ => None
}
private val scheme = getDBParams(mysqlDB)("scheme").get
private val host = getDBParams(mysqlDB)("host").get
private val port = getDBParams(mysqlDB)("port").get
private val name = getDBParams(mysqlDB)("name").get
private val username = getDBParams(mysqlDB)("username").get
private val password = getDBParams(mysqlDB)("password").get
private val airportsDF = session.read
.format("jdbc")
.option("url", s"$scheme://$host:$port/$name")
.option("user", username)
.option("password", password)
.option("dbtable", "airports")
.load()
airportsDF.show()
}
這個改進後的getDBParams函數接受一個Database物件作為參數,並返回一個函數,該函數再接受一個字串參數來獲取特定的資料庫屬性。這種柯里化(Currying)的寫法使得getDBParams更加通用,但應用程式端仍然需要重複呼叫getDBParams(mysqlDB)("parameter").get來獲取每個參數。這無疑會使getDBParams更加通用,但在應用程式端編寫起來仍然很麻煩。如果玄貓能這樣寫會更好:
val db = Database("my_db")
val host = db.host
val port = db.port
// 等等
提供這種功能非常容易。玄貓可以創建一個Database特徵(trait)來提供該API,然後透過一個私有類別來實現它。最後,伴生物件定義了一個工廠方法來創建Database實例,如下例所示:
package com.blackcat.dewithscala.utils
import com.blackcat.dewithscala.Opaque
import com.blackcat.dewithscala.Config.getDB // 引入Config物件的getDB方法
trait Database {
def scheme: String
def host: String
def port: String
def name: String
def jbdcURL: String // 新增一個方法來直接獲取JDBC URL
def username: Opaque
def password: Opaque
}
object Database {
def apply(name: String): Database = new DatabaseImplementation(name)
private class DatabaseImplementation(dbname: String) extends Database {
private val db = getDB(dbname).get // 這裡假設getDB一定會找到,實際應用中應處理Option
def scheme = db.scheme
def host = db.host
def port = db.port
def name = db.name
def jbdcURL = s"$scheme://$host:$port/$name" // 組合JDBC URL
def username = db.username
def password = db.password
}
}
此圖示:資料庫介面設計
@startuml
!define DISABLE_LINK
!define PLANTUML_FORMAT svg
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100
package "com.blackcat.dewithscala" {
class Opaque {
+ value: String
+ toString(): String
}
class DatabaseConfig <<case class>> {
+ name: String
+ scheme: String
+ host: String
+ port: String
+ username: Opaque
+ password: Opaque
}
class ProjectConfig <<case class>> {
+ db: List<DatabaseConfig>
}
object Config {
+ cfg: ProjectConfig
+ getDB(name: String): Option<DatabaseConfig>
}
}
package "com.blackcat.dewithscala.utils" {
interface Database {
+ scheme: String
+ host: String
+ port: String
+ name: String
+ jbdcURL: String
+ username: Opaque
+ password: Opaque
}
object Database {
+ apply(name: String): Database
}
class DatabaseImplementation {
- db: DatabaseConfig
+ scheme: String
+ host: String
+ port: String
+ name: String
+ jbdcURL: String
+ username: Opaque
+ password: Opaque
}
}
Database <|-- DatabaseImplementation
DatabaseImplementation ..> Config : 使用 getDB
DatabaseImplementation ..> Opaque
Database "1" -- "1" DatabaseImplementation : 實現
Database "1" -- "1" Database.apply : 創建
@enduml看圖說話:
此圖示清晰地展示了資料庫介面的設計及其與配置管理模組的互動。com.blackcat.dewithscala套件定義了底層的配置結構,包括用於敏感資訊的Opaque類別、描述單一資料庫配置的DatabaseConfig、包含多個資料庫配置的ProjectConfig,以及負責載入和提供配置的Config物件。com.blackcat.dewithscala.utils套件則定義了更高層次的抽象:Database特徵定義了資料庫物件應具備的公共屬性和行為(如scheme、host、jbdcURL等)。Database伴生物件提供了一個apply工廠方法,用於創建Database實例。DatabaseImplementation是一個私有內部類別,它實現了Database特徵,並透過組合Config.getDB獲取的DatabaseConfig物件來提供具體的屬性值。這種設計模式將配置的細節與資料庫操作的介面分離,提高了程式碼的模組化、可讀性和可維護性。
介面定義後,玄貓可以將範例簡化如下:
package com.blackcat.dewithscala.chapter4
import org.apache.spark.sql.SparkSession
import com.blackcat.dewithscala.utils.Database // 引入新的Database介面
object WithSimlifiedConfig extends App {
private val session = SparkSession
.builder()
.appName("de-with-scala")
.master("local[*]")
.getOrCreate()
private val mysqlDB = Database("my_db") // 使用工廠方法創建Database實例
private val url = mysqlDB.jbdcURL
private val username = mysqlDB.username.value
private val password = mysqlDB.password.value
private val airportsDF = session.read
.format("jdbc")
.option("url", url)
.option("user", username)
.option("password", password)
.option("dbtable", "airports")
.load()
airportsDF.show()
}
這個簡化後的程式碼顯著提升了可讀性和易用性。透過Database("my_db"),玄貓直接獲得了一個包含所有必要資料庫連接資訊的mysqlDB物件。接著,玄貓可以直接從mysqlDB物件中存取jbdcURL、username.value和password.value,而無需重複呼叫getDBParams函數。這不僅減少了冗餘程式碼,也使得資料庫配置的存取更加直觀和型別安全。
在本節中,玄貓逐步創建了一個Database介面,使得處理資料庫物件變得更加容易。在下一節中,玄貓將為SparkSession創建一個工廠方法。
結論
縱觀現代管理者的多元挑戰,這段從硬編碼到抽象介面的工程演進,不僅是技術優化,更是一場深刻的思維框架突破。相較於傳統僅止於解決表面問題的「應對式修補」,此路徑透過將「意圖」(如 Database 介面)與「實現」(如配置細節)徹底分離,展現了從混亂走向秩序的「原則性建構」價值。這種設計哲學的轉變,不僅消除了潛在的安全風險與重複勞動,更將脆弱的單點依賴,轉化為具備高度可擴展性與韌性的系統資產,大幅降低了團隊的認知負擔。
此種「介面導向」的開發心法,預示了未來高效能團隊的關鍵協作模式——將複雜的底層實務封裝為簡潔、標準化的工具,從而釋放核心人才的精力,使其能專注於更高層次的商業邏輯創新。未來,能否掌握這種抽象化與模組化的能力,將是區分優秀工程師與卓越架構師的關鍵分水嶺。
玄貓認為,這套從具象到抽象的重構思維,已展現其在提升系統韌性與開發效率上的卓越效益,值得所有追求精實工程(Lean Engineering)與卓越工藝的專業人士,採納為核心的實踐準則。