(快速參考)

12 服務層

版本 6.2.0

12 服務層

Grails 定義了服務層的概念。Grails 團隊不建議在控制器中嵌入核心應用程式邏輯,因為這不會促進重複使用和明確的分工。

Grails 中的服務是放置應用程式中大部分邏輯的地方,讓控制器負責處理請求流程、重新導向等等。

建立服務

您可以在終端機視窗中,從專案根目錄執行 create-service 指令來建立 Grails 服務

grails create-service helloworld.simple
如果未在 create-service 腳本中指定套件,Grails 會自動使用在 grails-app/conf/application.yml 中定義的 grails.defaultPackage 作為套件名稱。

上述範例會在位置 grails-app/services/helloworld/SimpleService.groovy 建立一個服務。服務的名稱以慣例 Service 結尾,除此之外,服務是一個純 Groovy 類別

package helloworld

class SimpleService {
}

12.1 宣告式交易

宣告式交易

服務通常會涉及協調 網域類別 之間的邏輯,因此經常涉及跨越大型作業的持久性。考量到服務的性質,它們經常需要交易行為。您可以使用 withTransaction 方法使用程式交易,但這很重複,而且無法完全發揮 Spring 底層交易抽象化的能力。

服務啟用交易分界,這是一種宣告式的方法,用於定義哪些方法要設為交易。若要對服務啟用交易,請使用 Transactional 轉換

import grails.gorm.transactions.*

@Transactional
class CountryService {

}

結果是所有方法都包在交易中,如果方法擲回例外(已檢查或執行時期例外)或錯誤,就會自動回滾。交易的傳播層級預設設定為 PROPAGATION_REQUIRED

Grails 3.2.0 版是第一個預設使用 GORM 6 的版本。在 GORM 6 之前,已檢查例外不會回滾交易。只有擲回執行時期例外(即擴充 RuntimeException 的例外)的方法會回滾交易。
警告:相依性注入 是宣告式交易運作的唯一方法。如果您使用 new 算子(例如 new BookService()),您將不會取得交易服務

Transactional 註解與 transactional 屬性

在 Grails 3.1 之前的 Grails 版本中,Grails 會建立 Spring 代理,並使用 transactional 屬性來啟用和停用代理建立。在使用 Grails 3.1 及以上版本建立的應用程式中,這些代理預設會停用,優先使用 @Transactional 轉換。

對於 Grails 3.1.x 和 3.2.x 版本,如果您想要重新啟用此功能(不建議),則必須將 grails.spring.transactionManagement 設定為 true,或移除 grails-app/conf/application.ymlgrails-app/conf/application.groovy 中的設定。

在 Grails 3.3.x 中,已完全捨棄用於交易管理的 Spring 代理,您必須使用 Grails 的 AST 轉換。在 Grails 3.3.x 中,如果您想要繼續使用 Spring 代理進行交易管理,則必須使用適當的 Spring 設定手動設定它們。

此外,在 Grails 3.1 之前,服務預設是交易的,從 Grails 3.1 開始,只有套用 @Transactional 轉換時,它們才是交易的。

自訂交易組態

Grails 也提供 @Transactional@NotTransactional 注解,適用於需要在方法層級對交易進行更細緻控制,或需要指定替代傳播層級的情況。例如,@NotTransactional 注解可用於標記特定方法,以在類別以 @Transactional 注解時略過該方法。

使用 Transactional 對服務方法進行註解會停用該服務的預設 Grails 交易行為(與新增 transactional=false 的方式相同),因此如果您使用任何註解,您必須對所有需要交易的方法進行註解。

在此範例中,listBooks 使用唯讀交易,updateBook 使用預設讀寫交易,而 deleteBook 則是非交易的(考量到其名稱,這可能不是個好主意)。

import grails.gorm.transactions.Transactional

class BookService {

    @Transactional(readOnly = true)
    def listBooks() {
        Book.list()
    }

    @Transactional
    def updateBook() {
        // ...
    }

    def deleteBook() {
        // ...
    }
}

您也可以對類別進行註解,以定義整個服務的預設交易行為,然後再針對每個方法覆寫該預設值

import grails.gorm.transactions.Transactional

@Transactional
class BookService {

    def listBooks() {
        Book.list()
    }

    def updateBook() {
        // ...
    }

    def deleteBook() {
        // ...
    }
}

此版本預設所有方法都是讀寫交易(由於類別層級註解),但 listBooks 方法會覆寫此設定,以使用唯讀交易

import grails.gorm.transactions.Transactional

@Transactional
class BookService {

    @Transactional(readOnly = true)
    def listBooks() {
        Book.list()
    }

    def updateBook() {
        // ...
    }

    def deleteBook() {
        // ...
    }
}

儘管在此範例中未對 updateBookdeleteBook 進行註解,但它們會繼承自類別層級註解的組態。

如需更多資訊,請參閱 Spring 使用者指南中關於 使用 @Transactional 的章節。

與 Spring 不同,您不需要任何先前的組態即可使用 Transactional;只需視需要指定註解,Grails 便會自動偵測它們。

交易狀態

在 Grails 交易服務方法中,預設會提供 TransactionStatus 的實例。

範例

import grails.gorm.transactions.Transactional

@Transactional
class BookService {

    def deleteBook() {
        transactionStatus.setRollbackOnly()
    }
}

12.1.1 交易與多個資料來源

假設有兩個網域類別,例如

class Movie {
    String title
}
class Book {
    String title

    static mapping = {
        datasource 'books'
    }
}

您可以提供所需的資料來源給 @Transactional@ReadOnly 注解。

import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic

@CompileStatic
class BookService {

    @ReadOnly('books')
    List<Book> findAll() {
        Book.where {}.findAll()
    }

    @Transactional('books')
    Book save(String title) {
        Book book = new Book(title: title)
        book.save()
        book
    }
}
@CompileStatic
class MovieService {

    @ReadOnly
    List<Movie> findAll() {
        Movie.where {}.findAll()
    }
}

12.1.2 交易回滾與工作階段

了解交易與 Hibernate 工作階段

在使用交易時,您必須考量 Hibernate 如何處理基礎的持續性工作階段。當交易回滾時,GORM 所使用的 Hibernate 工作階段會被清除。這表示工作階段內的任何物件都會被分離,而存取未初始化的延遲載入集合會導致 LazyInitializationException

要了解清除 Hibernate 會話的重要性,請考慮以下範例

class Author {
    String name
    Integer age

    static hasMany = [books: Book]
}

如果您要使用連續交易儲存兩個作者,如下所示

Author.withTransaction { status ->
    new Author(name: "Stephen King", age: 40).save()
    status.setRollbackOnly()
}

Author.withTransaction { status ->
    new Author(name: "Stephen King", age: 40).save()
}

只有第二個作者會被儲存,因為第一個交易會透過清除 Hibernate 會話來回滾作者的 save()。如果 Hibernate 會話沒有被清除,那麼兩個作者實例都會被保留,而且會導致非常意外的結果。

但是,由於會話被清除,因此可能會令人沮喪地收到 LazyInitializationException

例如,請考慮以下範例

class AuthorService {

    void updateAge(id, int age) {
        def author = Author.get(id)
        author.age = age
        if (author.isTooOld()) {
            throw new AuthorException("too old", author)
        }
    }
}
class AuthorController {

    def authorService

    def updateAge() {
        try {
            authorService.updateAge(params.id, params.int("age"))
        }
        catch(e) {
            render "Author books ${e.author.books}"
        }
    }
}

在上述範例中,如果 Author 年齡超過 isTooOld() 方法中定義的最大值,則交易會被回滾,方法是擲回 AuthorExceptionAuthorException 參照作者,但是當存取 books 關聯時,會擲回 LazyInitializationException,因為底層 Hibernate 會話已被清除。

要解決這個問題,您有許多選項。其中一個是確保您急切查詢以取得您需要的資料

class AuthorService {
    ...
    void updateAge(id, int age) {
        def author = Author.findById(id, [fetch:[books:"eager"]])
        ...

在此範例中,會在擷取 Author 時查詢 books 關聯。

這是最佳解決方案,因為它需要的查詢比以下建議的解決方案少。

另一個解決方案是在交易回滾後重新導向要求

class AuthorController {

    AuthorService authorService

    def updateAge() {
        try {
            authorService.updateAge(params.id, params.int("age"))
        }
        catch(e) {
            flash.message = "Can't update age"
            redirect action:"show", id:params.id
        }
    }
}

在這種情況下,一個新的要求將處理再次擷取 Author。最後,第三個解決方案是再次擷取 Author 的資料,以確保會話保持在正確的狀態

class AuthorController {

    def authorService

    def updateAge() {
        try {
            authorService.updateAge(params.id, params.int("age"))
        }
        catch(e) {
            def author = Author.read(params.id)
            render "Author books ${author.books}"
        }
    }
}

驗證錯誤和回滾

如果發生驗證錯誤,一個常見的用例是回滾交易。例如,請考慮此服務

import grails.validation.ValidationException

class AuthorService {

    void updateAge(id, int age) {
        def author = Author.get(id)
        author.age = age
        if (!author.validate()) {
            throw new ValidationException("Author is not valid", author.errors)
        }
    }
}

要在交易被回滾的同一個檢視中重新呈現,您可以在呈現之前將錯誤重新與重新整理的實例關聯

import grails.validation.ValidationException

class AuthorController {

    def authorService

    def updateAge() {
        try {
            authorService.updateAge(params.id, params.int("age"))
        }
        catch (ValidationException e) {
            def author = Author.read(params.id)
            author.errors = e.errors
            render view: "edit", model: [author:author]
        }
    }
}

12.2 範圍服務

預設情況下,存取服務方法並未同步,因此沒有什麼可以阻止這些方法同時執行。事實上,由於服務是單例而且可能會同時使用,因此您在服務中儲存狀態時應該非常小心。或者採取簡單(而且更好的)方法,永遠不要在服務中儲存狀態。

您可以透過將服務置於特定範圍中來變更此行為。支援的範圍如下

  • prototype - 每當注入到另一個類別時,就會建立一個新的服務

  • request - 每個要求都會建立一個新的服務

  • flash - 僅為目前和下一個要求建立一個新的服務

  • flow - 在 Web 流程中,服務會存在於流程的範圍內

  • conversation - 在 Web 流程中,服務會存在於對話的範圍內。例如,根流程及其子流程

  • session - 為使用者階段的範圍建立一個服務

  • singleton (預設) - 服務只會存在一個執行個體

如果您的服務是 flashflowconversation 範圍,則它必須實作 java.io.Serializable,而且只能在 Web 流程的內容中使用。

若要啟用其中一個範圍,請將靜態範圍屬性新增到您的類別,其值為上述其中一個值,例如

static scope = "flow"
升級

從 Grails 2.3 開始,新的應用程式會產生設定檔,將控制器的範圍預設為 singleton。如果 singleton 控制器與 prototype 範圍服務互動,則服務實際上會表現為每個控制器的 singleton。如果需要非 singleton 服務,則也應變更控制器範圍。

請參閱使用者指南中的 控制器和範圍 以取得更多資訊。

延遲初始化

您也可以設定服務是否延遲初始化。預設情況下,這設定為 true,但您可以停用它,並使用 lazyInit 屬性進行急切初始化

static lazyInit = false

12.3 相依性注入和服務

相依性注入基礎

Grails 服務的一個重要面向是能夠使用 Spring Framework 的相依性注入功能。Grails 支援「依慣例進行相依性注入」。換句話說,您可以使用服務類別名稱的屬性名稱表示法,將它們自動注入到控制器、標籤庫等等。

舉例來說,假設有一個稱為 BookService 的服務,如果您在控制器中定義一個稱為 bookService 的屬性,如下所示

class BookController {
    def bookService
    ...
}

在這種情況下,Spring 容器會根據其設定的範圍自動注入該服務的執行個體。所有相依性注入都是依名稱進行。您也可以指定類型,如下所示

class AuthorService {
    BookService bookService
}
注意:通常,屬性名稱是透過將類型的第一個字母轉換為小寫字母而產生的。例如,BookService 類別的執行個體會對應到一個稱為 bookService 的屬性。

為了符合標準 JavaBean 慣例,如果類別名稱的前 2 個字母是大寫,則屬性名稱與類別名稱相同。例如,JDBCHelperService 類別的屬性名稱將會是 JDBCHelperService,而不是 jDBCHelperServicejdbcHelperService

請參閱 JavaBean 規範的第 8.8 節,以取得有關取消大寫規則的更多資訊。

只有頂層物件會進行注入,因為遍歷所有巢狀物件以執行注入會造成效能問題。

注入非預設資料來源時請小心。例如,使用下列設定檔

dataSources:
    dataSource:
        pooled: true
        jmxExport: true
        .....
    secondary:
        pooled: true
        jmxExport: true
        .....

您可以像預期的那樣注入主要 dataSource

class BookSqlService {

      def dataSource
}

但是,若要注入 secondary 資料來源,您必須使用 Spring 的 Autowired 注入或 resources.groovy

class BookSqlSecondaryService {

  @Autowired
  @Qualifier('dataSource_secondary')
  def dataSource2
}

相依性注入和服務

您可以使用相同的技術在其他服務中注入服務。如果您有需要使用 BookServiceAuthorService,則宣告 AuthorService 如下所示將允許這樣做

class AuthorService {
    def bookService
}

相依性注入和網域類別/標籤函式庫

您甚至可以將服務注入網域類別和標籤函式庫中,這有助於開發豐富的網域模型和檢視

class Book {
    ...
    def bookService

    def buyBook() {
        bookService.buyBook(this)
    }
}
自 Grails 3.2.8 起,這項功能並未預設啟用。如果您想再次啟用它,請參閱 網域執行個體的 Spring 自動配線

服務 Bean 名稱

如果在不同的套件中定義了多個具有相同名稱的服務,則與服務關聯的預設 Bean 名稱可能會造成問題。例如,考慮一個應用程式定義了一個名為 com.demo.ReportingService 的服務類別,而應用程式使用了一個名為 ReportingUtilities 的外掛程式,並且該外掛程式提供了一個名為 com.reporting.util.ReportingService 的服務類別。

這兩個類別的預設 Bean 名稱都會是 reportingService,因此它們會彼此衝突。Grails 會透過在 Bean 名稱之前加上外掛程式名稱來變更外掛程式所提供的服務的預設 Bean 名稱,來管理這個問題。

在上述情況中,reportingService Bean 將會是應用程式中定義的 com.demo.ReportingService 類別的執行個體,而 reportingUtilitiesReportingService Bean 將會是 ReportingUtilities 外掛程式所提供的 com.reporting.util.ReportingService 類別的執行個體。

對於外掛程式所提供的全部服務 Bean,如果應用程式或應用程式中的其他外掛程式中沒有其他具有相同名稱的服務,則會建立一個不包含外掛程式名稱的 Bean 別名,而該別名指向由包含外掛程式名稱前綴的名稱所引用的 Bean。

例如,如果 ReportingUtilities 外掛程式提供一個名為 com.reporting.util.AuthorService 的服務,而且應用程式或應用程式正在使用的任何外掛程式中沒有其他 AuthorService,則會有一個名為 reportingUtilitiesAuthorService 的 bean,它是這個 com.reporting.util.AuthorService 類別的執行個體,而且會在名為 authorService 的內容中定義一個 bean 別名,指向同一個 bean。