Kotlin 資料存取設計:Unit of Work 與 Repository 模式實戰

15 分鐘閱讀

其實在寫 Kotlin 的不是我,是另一位認識的網友。
我幫他 code review 之後發現 repository 沒有一起實作 UoW。

我的問句是一種 使用 AI 來產生詳細指引 的提問法,當資深人員在產出指南給新人時非常適合這種提示詞技巧。

琳 avatar

解釋 Unit of Work 的意義,和 Repository Pattern 為什麼會一起使用。並說明使用 kotlin 推薦的 ORM 如何實作它們。

Felo Search avatar
Felo Search

本報告旨在闡釋 Unit of Work (UoW) 模式的意義,以及其與 Repository 模式協同使用的原因。同時,將說明如何運用 Kotlin 語言中推薦的物件關聯對應 (ORM) 框架來實作這兩種模式。

Repository 模式主要用於抽象化資料存取邏輯,提供一個中介層來隔離應用程式的業務邏輯與資料庫互動的細節 12。Unit of Work 模式則專注於管理單一業務交易中的所有資料變更操作,確保這些操作要麼全部成功提交,要麼在發生錯誤時全部回滾,從而維護資料的一致性與完整性 34。這兩種模式結合使用,能夠建立一個職責分明、易於測試且更具彈性的資料存取層 56

Repository 模式 (Repository Pattern)

定義與目的 Repository 模式是一種軟體設計模式,其核心思想是在應用程式的領域模型(或業務邏輯)與資料對應層(資料存取層)之間建立一個中介層 27。它扮演著類似記憶體中領域物件集合的角色,封裝了存取資料來源所需的邏輯 87。透過 Repository,應用程式的其餘部分可以透過一個清晰定義的介面來執行資料操作(如新增、讀取、更新、刪除,即 CRUD),而無需關心底層資料庫的具體實作細節,例如是 SQL 資料庫、NoSQL 資料庫還是遠端 API 89。每個 Repository 通常對應一個聚合根 (Aggregate Root),這是領域驅動設計 (DDD) 中的一個重要概念,確保了交易的一致性 7

優點 採用 Repository 模式能帶來多項益處:

Kotlin 中的基本實作概念 在 Kotlin 中實作 Repository 模式通常涉及以下步驟 89

  1. 定義資料類別 (Data Class):表示要操作的資料實體。

    data class User(val id: Long, val name: String, val email: String)
    
  2. 定義 Repository 介面 (Repository Interface):宣告資料存取操作的方法。

    interface UserRepository {
        suspend fun getUserById(id: Long): User?
        suspend fun getAllUsers(): List<User>
        suspend fun addUser(user: User)
        suspend fun updateUser(user: User)
        suspend fun deleteUser(userId: Long)
    }
    
  3. 實作 Repository 介面 (Repository Implementation):建立一個具體類別來實作介面中定義的方法,這個類別將使用 ORM 框架或其他方式與實際資料來源互動。

    // 具體實作會依賴選擇的 ORM 框架,例如 Exposed、Ktorm 或 Room
    class UserRepositoryImpl(/* ...dependencies like DataSource or DAO... */) : UserRepository {
        // override suspend fun getUserById(id: Long): User? { /* ... */ }
        // ... 其他方法的實作
    }
    

Unit of Work 模式 (Unit of Work Pattern)

定義與目的 Unit of Work (UoW) 模式的核心職責是追蹤在一個業務交易 (business transaction) 過程中所有受影響的物件(新增、修改、刪除),並協調這些變更的寫入以及解決並行問題 412。它確保所有這些操作被視為一個不可分割的單元:要麼全部成功執行並提交到資料庫,要麼在任何一個操作失敗時全部回滾,從而保持資料庫的一致性和完整性 31314。可以將 UoW 想像成一個業務交易的容器,它收集了所有需要執行的資料庫操作,然後一次性地應用它們 15

優點 使用 Unit of Work 模式的主要優點包括:

Repository 與 Unit of Work 模式的協同作業 (Synergy of Repository and Unit of Work Patterns)

為何結合使用 Repository 模式和 Unit of Work 模式經常一起使用,因為它們共同構成了一個強大的資料存取層抽象 519。Repository 負責封裝單個實體或聚合根的資料存取邏輯(即 如何 進行 CRUD 操作),而 Unit of Work 則負責協調跨越多個 Repository 或多個操作的交易(即 何時 將這些操作作為一個整體提交或回滾)620

運作方式 在一個典型的應用場景中:

  1. 應用程式的服務層 (Service Layer) 或業務邏輯層會使用一個或多個 Repository 來執行資料操作(例如,新增一個使用者,然後為該使用者新增一個訂單)16
  2. 這些 Repository 的操作(如 addUser, saveOrder)並不會立即寫入資料庫,而是將變更的實體註冊到 Unit of Work 實例中 1620。例如,UserRepository.add(newUser) 會通知 UoW 有一個新的 User 物件需要被持久化。
  3. 當所有業務操作完成後,服務層會呼叫 Unit of Work 的 commit() 方法 16
  4. Unit of Work 接著會以原子方式執行所有已註冊的變更。如果所有操作都成功,交易便提交;若有任何失敗,則整個交易回滾,資料庫狀態恢復到操作開始前的樣子 316

許多 ORM 框架(如 Entity Framework)的 DbContext 或 Session 物件本身就隱含了 Unit of Work 的功能,例如 EF 中的 SaveChanges() 方法就扮演了 UoW 中 commit() 的角色 217

Entity Framework 是 C# .NET 的 ORM,Kotlin 小夥伴請略過它。

使用 Kotlin 和 ORM 實作 (Implementation with Kotlin and ORM)

在 Kotlin 中,可以選用多種 ORM 框架來實作 Repository 和 Unit of Work 模式。

推薦的 Kotlin ORM 框架

實作範例概念

Repository 實作 (使用 Exposed 概念) 首先,定義 Exposed 的 Table 物件和對應的資料類別:

import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.dao.id.LongIdTable

// 資料類別
data class Product(val id: Long, val name: String, val price: Double)

// Exposed Table 定義
object ProductsTable : LongIdTable("products") {
    val name = varchar("name", 255)
    val price = double("price")
}

// Repository 介面
interface ProductRepository {
    fun findById(id: Long): Product?
    fun findAll(): List<Product>
    fun save(product: Product): Product
    fun delete(id: Long)
}

// Exposed Repository 實作
class ExposedProductRepository(private val db: Database) : ProductRepository {
    init {
        transaction(db) {
            SchemaUtils.create(ProductsTable) // 確保資料表存在
        }
    }

    private fun toProduct(row: ResultRow): Product =
        Product(
            id = row[ProductsTable.id].value,
            name = row[ProductsTable.name],
            price = row[ProductsTable.price]
        )

    override fun findById(id: Long): Product? = transaction(db) {
        ProductsTable.select { ProductsTable.id eq id }
            .map(::toProduct)
            .singleOrNull()
    }

    override fun findAll(): List<Product> = transaction(db) {
        ProductsTable.selectAll().map(::toProduct)
    }

    override fun save(product: Product): Product = transaction(db) {
        if (product.id == 0L) { // 假設 id 0L 代表新產品
            val newId = ProductsTable.insertAndGetId {
                it[name] = product.name
                it[price] = product.price
            }
            product.copy(id = newId.value)
        } else {
            ProductsTable.update({ ProductsTable.id eq product.id }) {
                it[name] = product.name
                it[price] = product.price
            }
            product
        }
    }

    override fun delete(id: Long) {
        transaction(db) {
            ProductsTable.deleteWhere { ProductsTable.id eq id }
        }
    }
}

Unit of Work 實作 (使用 Exposed 概念) Exposed 的 transaction { ... } 區塊本身就提供了交易的原子性,可以視為一個 Unit of Work 的執行單位 23。如果需要一個更明確的 UoW 物件來管理交易,特別是當服務層需要協調多個 Repository 操作時,可以這樣設計:

interface AppUnitOfWork {
    fun <T> execute(action: Transaction.() -> T): T
}

class ExposedAppUnitOfWork(private val db: Database) : AppUnitOfWork {
    override fun <T> execute(action: Transaction.() -> T): T {
        return transaction(db) {
            // 可以在此處加入通用的交易設定,例如日誌記錄
            // addLogger(StdOutSqlLogger)
            action()
            // 交易成功則自動 commit,發生例外則自動 rollback
        }
    }
}

// 服務層使用 UoW 和 Repository
class ProductService(
    private val productRepository: ProductRepository,
    private val unitOfWork: AppUnitOfWork
    // 可能還有其他 Repositories
) {
    fun createProductAndUpdateStock(productName: String, productPrice: Double, stockChange: Int) {
        unitOfWork.execute { // 所有在此區塊內的操作都在同一個交易中
            val newProduct = productRepository.save(Product(0L, productName, productPrice))
            // 假設有 StockRepository
            // val stockRepository = ExposedStockRepository(this.db) // 'this.db' 來自 Transaction
            // stockRepository.updateStock(newProduct.id, stockChange)

            // 如果任何操作失敗,整個 execute 區塊會回滾
            println("Product ${newProduct.name} created and stock updated within a transaction.")
        }
    }
}

在這個範例中,ExposedAppUnitOfWorkexecute 方法包裹了 Exposed 的 transaction 函數,確保傳入的 action 區塊內的所有資料庫操作都在單一交易中執行。Repository 的方法(如 save)在被 execute 呼叫時,會自動參與到這個由 UoW 管理的交易中。

使用 Ktorm 的概念 Ktorm 的 Repository 實作會使用其 Database 物件和序列 API (sequence API) 2526。對於 Unit of Work,Ktorm 依賴於底層的 JDBC 交易管理。開發者需要手動控制 JDBC ConnectionautoCommit 屬性,並在操作完成後呼叫 commit() 或在發生錯誤時呼叫 rollback()。一個 UoW 封裝類可以簡化這個過程:

// Ktorm Entity 和 Table (簡化)
interface KProduct : Entity<KProduct> { /* ... */ }
object KProductsTable : Table<KProduct>("products") { /* ... */ }
val Database.products get() = this.sequenceOf(KProductsTable)


// Ktorm Repository 介面 (同上)
// class KtormProductRepository(private val db: Database) : ProductRepository { /* ... */ }

// Ktorm Unit of Work 概念
interface KtormAppUnitOfWork {
    fun <T> execute(block: (Database) -> T): T
}

class DefaultKtormUnitOfWork(private val dataSource: javax.sql.DataSource) : KtormAppUnitOfWork {
    override fun <T> execute(block: (Database) -> T): T {
        dataSource.connection.use { connection ->
            connection.autoCommit = false // 開始交易
            try {
                val db = Database.connect(connection) // Ktorm Database 使用此連線
                val result = block(db)
                connection.commit() // 提交交易
                return result
            } catch (e: Exception) {
                connection.rollback() // 回滾交易
                throw e
            }
        }
    }
}

服務層在使用 KtormAppUnitOfWork 時,會將需要交易性執行的 Repository 操作放在 execute 的 lambda 區塊中,並將傳入的 Database 實例傳遞給 Repository。

總結來說,Repository 模式提供了資料存取的抽象,而 Unit of Work 模式確保了這些存取操作的交易一致性。在 Kotlin 中,Exposed 和 Ktorm 等 ORM 框架都提供了實作這兩種模式的機制,Exposed 的 transaction 區塊使其更為簡潔,而 Ktorm 則更依賴手動的 JDBC 交易管理,但兩者都能有效地幫助開發者建立穩健且可維護的資料存取層。選擇哪種 ORM 取決於專案的具體需求、複雜性以及開發團隊的偏好。

  1. Repository Design Pattern | GeeksforGeeks

  2. Repository - Martin Fowler ↩2

  3. Unit Of Work Pattern - Medium ↩2 ↩3 ↩4 ↩5 ↩6

  4. Unit of Work - Martin Fowler ↩2

  5. Implementing the Unit of Work and Repository Pattern ↩2

  6. Unit of Work Pattern - Cosmic Python ↩2

  7. Designing the infrastructure persistence layer - .NET | Microsoft Learn ↩2 ↩3 ↩4 ↩5

  8. Repository Design Pattern in kotlin | by App Dev Insights ↩2 ↩3 ↩4 ↩5 ↩6

  9. Repository Design Pattern - Medium ↩2

  10. Unit of Work With Generic Repository - DZone ↩2

  11. Repository Pattern and Unit of Work with Asp.Net Core 5

  12. Unit of Work

  13. Unit of Work Pattern in Java: Orchestrating Efficient ...

  14. Demystifying the Unit of Work Design Pattern - Medium

  15. How can I better understand the concepts of unit of work and ...

  16. Thinking In Design Pattern——Unit Of Work(工作单元)模式探索-腾讯云开发者社区-腾讯云 ↩2 ↩3 ↩4 ↩5

  17. Unit Of Work | Java Design Patterns (中文)

  18. A Deep Dive into the Unit of Work Pattern: Pros and Cons

  19. Implementing the Unit of Work and Repository Pattern

  20. 掀起你的盖头来:Unit Of Work-工作单元 - 田园里的蟋蟀 - 博客园 ↩2

  21. Designing the infrastructure persistence layer - .NET

  22. Guide to the Kotlin Exposed Framework - Baeldung

  23. Guide to the Kotlin Exposed Framework | Baeldung on Kotlin ↩2 ↩3

  24. kotlin-orm/ktorm - GitHub

  25. kotlin-orm/ktorm - GitHub ↩2

  26. GitHub - kotlin-orm/ktorm: A lightweight ORM framework for Kotlin with strong-typed SQL DSL and sequence APIs. ↩2

  27. Implementing Room Databases with Kotlin Coroutines in ...

  28. Persist data with Room - Android Developers

  29. Implementing Room Databases with Kotlin Coroutines in ...

  30. Simplified Hibernate ORM with Panache and Kotlin - Quarkus

  31. Simplified Hibernate ORM with Panache - Quarkus