GORM 事件侦听器
学习如何编写和测试 GORM 事件侦听器
作者:Zachary Klein、Sergio del Amo
Grails 版本 3.3.2
1 培训
Grails 培训 - 由创建和积极维护 Grails 框架的人员开发和提供!
2 开始
在本指南中,你将学习如何编写和测试 GORM 事件侦听器。GORM 事件侦听器允许你编写在对象从数据库中保存、更新或删除时调用的方法,以执行自定义逻辑(或许创建日志条目、更新/创建相关对象,或修改持久化对象中的属性)。这些侦听器利用了 Grails 的异步功能,但添加了一些特定于从你的领域类捕获事件的有用捷径。
你可能熟悉域类中可用的持久化事件处理程序,例如 beforeInsert 、afterInsert 、beforeUpdate 等。事件侦听器本质上允许你执行与你可能已放入这些域类方法中的相同类型的逻辑。但是,事件侦听器通常会促进更好的代码组织,并且因为它们已添加到 Spring 上下文中,所以它们可以调用服务方法和其他 Spring bean,这与域类不同(域类默认情况下没有自动装配并且不参与依赖注入)。 |
2.1 你将需要
要完成本指南,你需要以下内容
-
一点时间
-
一个不错的文本编辑器或 IDE
-
JDK 1.7 或更高版本,已适当配置
JAVA_HOME
2.2 如何完成指南
要开始,请执行以下操作
-
下载并解压源代码
或
-
克隆 Git 存储库
git clone https://github.com/grails-guides/gorm-event-listeners.git
Grails 指南存储库包含两个文件夹
-
initial
原始项目。 通常是带有部分附加代码的简单 Grails 应用,旨在帮助您快速入门。 -
complete
完成的示例。它是逐步完成指南内容并在initial
文件夹中应用这些更改后的结果。
要完成指南,请转至 initial
文件夹
-
cd
至grails-guides/gorm-event-listeners/initial
并按照下一章节中的说明进行操作。
您可以直接进入已完成示例,方法是 cd 至 grails-guides/gorm-event-listeners/complete |
3 编写应用程序
我们的应用程序领域模型非常简单。我们将创建四个领域类:Book
、Tag
、BookTag
和 Audit
。这些类的作用在下表中进行了解释
类 |
作用 |
图书 |
核心领域模型 |
审计 |
记录给定 |
创建领域类,并将其编辑如下所示
package demo
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class Book {
String author
String title
String friendlyUrl
Integer pages
String serialNumber
static constraints = {
serialNumber nullable: true
friendlyUrl nullable: true
title nullable: false
pages min: 0
serialNumber nullable: true
}
}
package demo
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
class Audit {
String event
Long bookId
static constraints = {
event nullable: false, blank: false
bookId nullable: false
}
}
3.1 数据服务
为了处理我们的应用程序中的持久性逻辑(例如,更新和删除图书和标签),我们将创建多个 GORM 数据服务。
数据服务允许我们集中我们的应用程序的数据访问和持久性函数。我们无需调用动态查找器或直接更新我们的领域对象,我们可以在接口(或抽象类)中定义我们需要什么样的查询和持久性操作,以便 GORM 提供实施。数据服务是交易性的,可以像任何其他 Spring Bean 一样注入到其他服务或控制器中。所有相同的 GORM“魔术”都在起作用,例如,在服务中,我们指定一个方法,如 Book findByTitleAndPagesGreaterThan(String title, Long pages)
, GORM 将提供与使用同名动态查找器相同类型的实施。
为什么使用数据服务?GORM 数据服务与动态查找器的主要优点是类型检查(如果在图书 类上没有 String title 和 Long pages 属性,上面显示的方法将无法编译),并且可以静态编译。此外,将常见查询和更新集中到数据服务中可能拥有一些架构上的优点:如果您选择在数据服务中针对更好的性能来优化查询,那么使用该方法的所有代码都可以受益于此更改,而无需您追踪每个动态查找器或 where 查询并分别更新它们。 |
在 grails-app/services/demo/
下创建以下文件
~ touch AuditDataService.groovy
~ touch BookDataService.groovy
按照如下所示编辑文件
package demo
import grails.gorm.services.Service
import grails.gorm.services.Where
import groovy.transform.CompileStatic
@CompileStatic
@Service(Audit)
interface AuditDataService {
Audit save(String event, Long bookId)
Number count()
List<Audit> findAll(Map args)
@Where({ bookId == id })
void deleteByBookId(Long id)
}
package demo
import grails.gorm.services.Service
import groovy.transform.CompileStatic
@CompileStatic
@Service(Book)
interface BookDataService {
Book save(String title, String author, Integer pages)
List<Book> findAll()
Book update(Serializable id, String title)
void delete(Serializable id)
}
请注意,上面提到的两个数据服务都是接口,没有自己的任何实现。这意味着 GORM 会为类中的每个方法提供其默认实现。但是,有时您可能需要在数据服务方法中提供一些自定义逻辑。为完成此操作,我们可以将数据服务定义为一个抽象类,并创建非抽象方法来处理自定义代码(GORM 仍会实现所有抽象方法)。
3.2 异步侦听 GORM 事件
当创建新Book
时,我们的第一个侦听器将保存Audit
实例,当更新和删除Book
时也是如此。
创建名为AuditListenerService
的新 Grails 服务
~ grails create-service demo.AuditListenerService
编写事件侦听器不需要 Grails 服务。您可以在src/main/groovy 下的 Groovy 类中放置我们准备编写相同的那些方法。但是,侦听器确实需要连接到 Spring 上下文,因此如果我们不使用 Grails 服务,就必须手动执行此操作。我们将使用 Grails 服务以方便操作。 |
如下所示编辑AuditListenerService
package demo
import grails.events.annotation.Subscriber
import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.PostDeleteEvent
import org.grails.datastore.mapping.engine.event.PostInsertEvent
import org.grails.datastore.mapping.engine.event.PostUpdateEvent
@Slf4j
@CompileStatic
class AuditListenerService {
AuditDataService auditDataService
Long bookId(AbstractPersistenceEvent event) {
if ( event.entityObject instanceof Book ) {
return ((Book) event.entityObject).id (2)
}
null
}
@Subscriber (1)
void afterInsert(PostInsertEvent event) {
Long bookId = bookId(event)
if ( bookId ) {
log.info 'After book save...'
auditDataService.save('Book saved', bookId)
}
}
@Subscriber (1)
void afterUpdate(PostUpdateEvent event) { (3)
Long bookId = bookId(event)
if ( bookId ) {
log.info "After book update..."
auditDataService.save('Book updated', bookId)
}
}
@Subscriber (1)
void afterDelete(PostDeleteEvent event) {
Long bookId = bookId(event)
if ( bookId ) {
log.info 'After book delete ...'
auditDataService.save('Book deleted', bookId)
}
}
}
1 | Subscriber 注释和方法签名指出此方法对哪些事件感兴趣 - 例如,名为afterInsert 、带有类型为PostInsertEvent 参数的方法表示该方法仅在对象保存后才会被调用。 |
2 | 我们可以通过event.entityObject 访问触发该事件的域对象。为了访问此对象的 id,我们将其转换为Book 并获取 id,并将其用作Audit 实例的bookId 属性。 |
3 | 同样,这里的方法签名表示该方法将为类型为PostUpdateEvent 的事件调用 - 在对象更新后。 |
请记住,除Book 实体之外,方法bookId 将为任何类返回 null。因此,上一个类为Book 插入、更新或删除操作创建Audit 实例。 |
编写单元测试,以验证每当收到类型为PostInsertEvent
、PostUpdateEvent
或PostDeleteEvent
的Book
事件时,都会调用auditDataService
。下一个示例说明了 GORM 数据服务的另一个巨大优势。它们创建易于测试的场景。作为接口,它们易于模拟。
package demo
import grails.testing.gorm.DataTest
import grails.testing.services.ServiceUnitTest
import org.grails.datastore.mapping.engine.event.PostDeleteEvent
import org.grails.datastore.mapping.engine.event.PostInsertEvent
import org.grails.datastore.mapping.engine.event.PostUpdateEvent
import spock.lang.Specification
class AuditListenerServiceSpec extends Specification implements ServiceUnitTest<AuditListenerService>, DataTest { (1)
void setupSpec() {
mockDomains Book (2)
}
void "Book.PostInsertEvent triggers auditDataService.save"(){
given:
service.auditDataService = Mock(AuditDataService)
Book book = new Book(title: 'Practical Grails 3',
author: 'Eric Helgeson',
pages: 1).save() (3)
PostInsertEvent event = new PostInsertEvent(dataStore, book) (4)
when:
service.afterInsert(event) (5)
then:
1 * service.auditDataService.save(_, _) (6)
}
void "Book.PostUpdateEvent triggers auditDataService.save"(){
given:
service.auditDataService = Mock(AuditDataService)
Book book = new Book(title: 'Practical Grails 3',
author: 'Eric Helgeson',
pages: 1).save() (3)
PostUpdateEvent event = new PostUpdateEvent(dataStore, book) (4)
when:
service.afterUpdate(event) (5)
then:
1 * service.auditDataService.save(_, _) (6)
}
void "Book.PostDeleteEvent triggers auditDataService.save"(){
given:
service.auditDataService = Mock(AuditDataService)
Book book = new Book(title: 'Practical Grails 3',
author: 'Eric Helgeson',
pages: 1).save() (3)
PostDeleteEvent event = new PostDeleteEvent(dataStore, book) (4)
when:
service.afterDelete(event) (5)
then:
1 * service.auditDataService.save(_, _) (6)
}
}
1 | 我们实现了grails.testing.services.ServiceUnitTest (单元测试服务的特征)和DataTest 特征,因为我们将使用 GORM 功能。 |
2 | DataTest 特征提供了一个mockDomains 方法,我们用我们打算在测试中使用的域类为其提供数据。 |
3 | 因为DataTest 已经为我们连接好 GORM,我们只需创建一个我们的Book 类的实例即可。 |
4 | 我们现在可以使用 Book 实例来设置 PostInsertEvent (请注意,由于实现了 DataTest 特性,我们的测试中包含 dataStore 属性)。 |
5 | 我们现在可以调用 afterSave 方法,传入事件。 |
6 | 我们现在可以断言已保存我们的 Audit 实例(通过 auditDataService.save() 方法调用)。 |
接下来,创建一个集成测试来验证保存、更新或删除一本书的路径会留下审计跟踪。由于我们的服务异步捕获 GORM 事件,我们正在使用 Spock 轮询条件,它会反复评估一个或多个条件,直到它们满足或超时为止。
package demo
import grails.testing.mixin.integration.Integration
import grails.transaction.Rollback
import spock.lang.Specification
import spock.util.concurrent.PollingConditions
@Integration
class AuditListenerServiceIntegrationSpec extends Specification {
BookDataService bookDataService
AuditDataService auditDataService
void "saving a Book causes an Audit instance to be saved"() {
when:
def conditions = new PollingConditions(timeout: 30)
Book book = bookDataService.save('Practical Grails 3', 'Eric Helgeson', 1)
then:
book
book.id
conditions.eventually {
assert auditDataService.count() == old(auditDataService.count()) + 1
}
when:
Audit lastAudit = this.lastAudit()
then:
lastAudit.event == "Book saved"
lastAudit.bookId == book.id
when: 'A books is updated'
book = bookDataService.update(book.id, 'Grails 3')
then: 'a new audit instance is created'
conditions.eventually {
assert auditDataService.count() == old(auditDataService.count()) + 1
}
when:
lastAudit = this.lastAudit()
then:
book.title == 'Grails 3'
lastAudit.event == 'Book updated'
lastAudit.bookId == book.id
when: 'A book is deleted'
bookDataService.delete(book.id)
then: 'a new audit instance is created'
conditions.eventually {
assert auditDataService.count() == old(auditDataService.count()) + 1
}
when:
lastAudit = this.lastAudit()
then:
lastAudit.event == 'Book deleted'
lastAudit.bookId == book.id
cleanup:
auditDataService.deleteByBookId(book.id)
}
Audit lastAudit() {
int offset = Math.max(((auditDataService.count() as int) - 1), 0)
auditDataService.findAll([max: 1, offset: offset]).first()
}
}
3.3 同步侦听 GORM 事件
在事件侦听器中你需要频繁访问甚至修改领域对象中的属性。例如,你可能希望在保存到数据库之前对用户的密码进行编码,或者对字符串属性的值与禁止的单词/字符的黑名单进行对比。GORM 事件提供了一个 entityAccess
对象,它允许你在触发事件的实体上获取并设置属性。
我们将在下一个 GORM 事件侦听器中了解如何使用 entityAccess
。
我们需要为 Book
领域类实例生成并分配一个序列号。对于此示例,序列号将只是以书的标题前几个字母为前缀的一个随机全大写字符串。例如,标题为 Groovy in Action
的一本书的序列号可能是 GROO-WKVLEQEDK
。
创建一个名为 SerialNumberGeneratorService
的新 Grails 服务
~ grails create-service demo.SerialNumberGeneratorService
package demo
import groovy.transform.CompileStatic
import org.apache.commons.lang.RandomStringUtils
@CompileStatic
class SerialNumberGeneratorService {
String generate(String bookTitle) {
String randomString = RandomStringUtils.random(8, true, false)
String titleChars = "${bookTitle}".take(4) (1)
"${titleChars}-${randomString}".toUpperCase()
}
}
1 | 我们使用 take 方法从标题中安全获取前 4 个字符(如果字符串长度小于所需范围,take 不会抛出 IndexOutOfBoundsException ) |
此外,我们想要为我们的书标题生成友好的、人类可读的 URL。我们不想使用诸如 http://localhost:8080/book/show/1
的 URL,而是想要使用 http://localhost:8080/book/practical-grails-3
创建一个名为 FriendlyUrlService
的新 Grails 服务
~ grails create-service demo.FriendlyUrlService
package demo
import groovy.transform.CompileStatic
import java.text.Normalizer
@CompileStatic
class FriendlyUrlService {
/**
* This method transforms the text passed as an argument to a text without spaces,
* html entities, accents, dots and extranges characters (only %,a-z,A-Z,0-9, ,_ and - are allowed).
*
* Borrowed from Wordpress: file wp-includes/formatting.php, function sanitize_title_with_dashes
* http://core.svn.wordpress.org/trunk/wp-includes/formatting.php
*/
String sanitizeWithDashes(String text) {
if ( !text ) {
return ''
}
// Preserve escaped octets
text = text.replaceAll('%([a-fA-F0-9][a-fA-F0-9])','---$1---')
text = text.replaceAll('%','')
text = text.replaceAll('---([a-fA-F0-9][a-fA-F0-9])---','%$1')
// Remove accents
text = removeAccents(text)
// To lower case
text = text.toLowerCase()
// Kill entities
text = text.replaceAll('&.+?;','')
// Dots -> ''
text = text.replaceAll('\\.','')
// Remove any character except %a-zA-Z0-9 _-
text = text.replaceAll('[^%a-zA-Z0-9 _-]', '')
// Trim
text = text.trim()
// Spaces -> dashes
text = text.replaceAll('\\s+', '-')
// Dashes -> dash
text = text.replaceAll('-+', '-')
// It must end in a letter or digit, otherwise we strip the last char
if (!text[-1].charAt(0).isLetterOrDigit()) text = text[0..-2]
return text
}
/**
* Converts all accent characters to ASCII characters.
*
* If there are no accent characters, then the string given is just returned.
*
*/
private String removeAccents(String text) {
Normalizer.normalize(text, Normalizer.Form.NFD)
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "")
}
}
如以下所示,编辑 FriendlyUrlService
生成的单元测试规范
package demo
import grails.testing.services.ServiceUnitTest
import spock.lang.Specification
import spock.lang.Unroll
class FriendlyUrlServiceSpec extends Specification implements ServiceUnitTest<FriendlyUrlService> {
@Unroll
def "Friendly Url for #title : #expected"(String title, String expected) {
expect:
expected == service.sanitizeWithDashes(title)
where:
title | expected
'Practical Grails 3' | 'practical-grails-3'
}
}
现在,我们将创建一个侦听器来处理新的和更新的书标题。创建一个名为 TitleListenerService
的 Grails 服务
~ grails create-service demo.TitleListenerService
我们希望同步填充 serialNumber:每当我们插入一个新的 Book
实例时。要同步捕获 GORM 事件,我们可以使用 @Listener
批注而不是 @Subscriber
。
package demo
import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
@CompileStatic
class TitleListenerService {
FriendlyUrlService friendlyUrlService
SerialNumberGeneratorService serialNumberGeneratorService
@Listener(Book) (1)
void onBookPreInsert(PreInsertEvent event) {
populateSerialNumber(event)
populateFriendlyUrl(event)
}
@Listener(Book) (1)
void onBookPreUpdate(PreUpdateEvent event) { (2)
Book book = ((Book) event.entityObject)
if ( book.isDirty('title') ) { (3)
populateFriendlyUrl(event)
}
}
void populateSerialNumber(AbstractPersistenceEvent event) {
String title = event.entityAccess.getProperty('title') as String (4)
String serialNumber = serialNumberGeneratorService.generate(title)
event.entityAccess.setProperty('serialNumber', serialNumber) (5)
}
void populateFriendlyUrl(AbstractPersistenceEvent event) {
String title = event.entityAccess.getProperty('title') as String
String friendlyUrl = friendlyUrlService.sanitizeWithDashes(title)
event.entityAccess.setProperty('friendlyUrl', friendlyUrl)
}
}
1 | @Listener 注解将此方法转换为 GORM 事件侦听器。当 GORM 触发持久化事件时,将调用标记为 @Listener (且具有相应方法签名的)的任何方法。该注解获取 一个值参数,该参数可以是单个域类或域类的列表,用来“侦听” - 在此情况下,仅针对 Book 实例触发的事件才会触发此方法。 |
2 | onBookPreUpdate 侦听器检查书籍的 title 是否为“dirty”(表示属性已更改)。如果是,则更新 friendlyUrl 属性。 |
3 | isDirty 方法允许我们检查在持久化对象上已更改的属性。 |
4 | 我们使用 event.entityAccess.getProperty() 方法从域对象中检索 title 属性。 |
5 | 为设置域对象上的 serialNumber 属性,我们使用 event.entityAccess.setProperty() 方法。 |
您可能想知道为什么不能将 event.entityObject 仅仅强制转换为 Book ,然后直接设置 serialNumber 属性。这里避免采取该方法的原因在于直接赋值本身将触发另一个事件,从而可能导致同一个事件侦听器触发多次。通过使用 entityAccess 对象,我们将进行的任何更改都将与当前持久化会话保持同步,并将与原始对象同时保存。 |
同样,使用 GORM 数据服务减轻了单元测试。在下一个测试中,我们对 GORM 数据服务进行 Stub 处理,我们验证了所填充的序列号。
package demo
import grails.testing.gorm.DataTest
import grails.testing.services.ServiceUnitTest
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.springframework.test.annotation.Rollback
import spock.lang.Specification
class TitleListenerServiceSpec extends Specification implements ServiceUnitTest<TitleListenerService>, DataTest {
def setupSpec() {
mockDomain Book
}
Closure doWithSpring() {{ -> (1)
friendlyUrlService(FriendlyUrlService)
}}
@Rollback
void "test serial number generated"() {
given:
Book book = new Book(title: 'Practical Grails 3', author: 'Eric Helgeson', pages: 100)
when:
service.serialNumberGeneratorService = Stub(SerialNumberGeneratorService) {
generate(_ as String) >> 'XXXX-5125'
}
service.onBookPreInsert(new PreInsertEvent(dataStore, book))
then:
book.serialNumber == 'XXXX-5125'
book.friendlyUrl == 'practical-grails-3'
}
}
1 | 要在上下文中提供或替换 bean,可以在您的测试中覆盖 doWithSpring 方法。 |
集成测试
接下来,我们创建一个集成测试,以验证当 title
属性更新时 friendlyUrl
属性是否得到刷新。
package demo
import grails.testing.mixin.integration.Integration
import spock.lang.Specification
@Integration
class TitleListenerServiceIntegrationSpec extends Specification {
BookDataService bookDataService
AuditDataService auditDataService
def "saving a book, generates automatically a serial number"() {
when:
Book book = bookDataService.save('Practical Grails 3', 'Eric Helgeson', 100)
String serialNumber = book.serialNumber
String friendlyUrl = book.friendlyUrl
then:
book
!book.hasErrors()
serialNumber
friendlyUrl == 'practical-grails-3'
when: 'updating book title'
book = bookDataService.update(book.id, 'Grails 3')
then: 'serial number stays the same'
serialNumber == book.serialNumber
and: 'friendly url changes'
friendlyUrl != book.friendlyUrl
book.friendlyUrl == 'grails-3'
cleanup:
auditDataService.deleteByBookId(book.id)
bookDataService.delete(book.id)
}
}
高级单元测试
可以说,对事件正确处理的测试应是如先前所示的**集成测试**。但是您可以创建等效的单体测试。我们只能连接应用程序的某些部分,这些部分需要验证我们的侦听器是否在我们预期的时刻被调用,而无需进行全面的集成测试(该测试需要在整个应用程序启动才能执行测试)。
在 src/test/groovy/demo
下创建文件 TitleListenerServiceGrailsUnitSpec
,按如下所示编辑内容。
package demo
import grails.gorm.transactions.Rollback
import org.grails.orm.hibernate.HibernateDatastore
import org.grails.testing.GrailsUnitTest
import org.springframework.transaction.PlatformTransactionManager
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
class TitleListenerServiceGrailsUnitSpec extends Specification implements GrailsUnitTest { (1)
@Shared
@AutoCleanup
HibernateDatastore hibernateDatastore (2)
@Shared
PlatformTransactionManager transactionManager
void setupSpec() {
hibernateDatastore = applicationContext.getBean(HibernateDatastore) (2)
transactionManager = hibernateDatastore.getTransactionManager()
}
@Override
Closure doWithSpring() { (3)
{ ->
friendlyUrlService(FriendlyUrlService)
serialNumberGeneratorService(SerialNumberGeneratorService)
titleListenerService(TitleListenerService) {
friendlyUrlService = ref('friendlyUrlService')
serialNumberGeneratorService = ref('serialNumberGeneratorService')
}
datastore(HibernateDatastore, [Book])
}
}
@Rollback
def "serialNumber and friendyUrl are populated after book is saved"() { (4)
when:
Book book = new Book(title: 'Practical Grails 3', author: 'Eric Helgeson', pages: 100)
book.save(flush: true)
then:
!book.hasErrors()
when:
book = Book.findByTitle('Practical Grails 3')
String serialNumber = book.serialNumber
String friendlyUrl = book.friendlyUrl
then:
serialNumber
friendlyUrl == 'practical-grails-3'
when: 'updating book title'
book.title = 'Grails 3'
book.save(flush: true)
then:
!book.hasErrors()
when:
book = Book.findByTitle('Grails 3')
then: 'serial number stays the same'
serialNumber == book.serialNumber
and: 'friendly url changes'
friendlyUrl != book.friendlyUrl
book.friendlyUrl == 'grails-3'
}
}
1 | 因为我们将自己连接 GORM、Spring 上下文和事件系统,我们将直接实现基本 GrailsUnitTest 特性而非更具体 ServiceUnitTest 特性。 |
2 | 我们为我们的数据存储声明一个 Shared 和 AutoCleanup 属性。在 setupSpec 方法中,我们获取 applicationContext 中包含的 HibernateDatastore 实例(由 GrailsUnitTest 特性提供)。我们还设置在我们规格中定义的 transactionManager 共享属性。 |
3 | 在我们重写的 doWithSpring 方法中,我们创建了 friendlyUrlService 、serialNumberGeneratorService 、titleListenerService 及其 dataStore 的 Spring bean,通过一个我们测试需要的 domain 类清单实例化后者。这将配置我们在测试的 Spring 环境中服务和 GORM 数据存储。 |
4 | 我们的测试现在非常简单,并且与之前的集成测试几乎相同。 |
4 运行测试
如要运行测试
./grailsw
grails> test-app
grails> open test-report
或
./gradlew check
open build/reports/tests/index.html
5 结论
事件侦听器是一个强大的概念,而 GORM 的实现将允许你在持久性事件周围编写智能业务逻辑而不“混乱”你的 domain 类。此外,最新 Grails 版本中已改进的测试支持使为你的事件侦听器编写简单透彻的测试变得比以往任何时候都要容易。编程愉快!