GORM 事件侦听器
学习编写和测试 GORM 事件侦听器
作者:Zachary Klein、Sergio del Amo
Grails 版本 5.0.1
1 培训
Grails 培训 - 由创建并积极维护 Grails 框架的人员开发和提供!
2 开始使用
在本指南中,你将学习如何编写和测试 GORM 事件侦听器。GORM 事件侦听器允许你编写在从数据库保存、更新或删除对象时调用的方法,以执行自定义逻辑(可能创建日志条目,更新/创建关联对象或修改持久性对象中的属性)。这些侦听器利用 Grails 的异步功能,但添加了一些特定于捕获域类事件的有用快捷方式。
你可能熟悉域类中提供的持久性事件处理程序,例如 beforeInsert 、afterInsert 、beforeUpdate 等。事件侦听器实质上允许你执行与你可能放入这些域类方法中的相同类型的逻辑。但是,事件侦听器通常促进更好的代码组织,并且由于它们被添加到 Spring 上下文,它们可以调用服务方法和其他 Spring Bean,不同于域类(默认情况下不会自动连接,且不参与依赖项注入)。 |
2.1 所需条件
要完成本指南,你需要具备以下条件
-
一些空闲时间
-
一个合适的文本编辑器或 IDE
-
安装了 JDK 1.8 或更高版本,并正确配置了
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
。这些类的作用在下表中进行了说明
类 |
作用 |
Book |
核心领域模型 |
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 数据服务相对于动态查找器的主要优点包括类型检查(如果“Book”类中不存在名为“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,我们将此 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 (请注意,dataStore 属性在我们的测试中可用,同样是实现了 DataTest 特征的结果)。 |
5 | 我们现在可以调用 afterSave 方法,并传入事件。 |
6 | 我们现在可以断言我们的 Audit 实例已保存(通过 auditDataService.save() 方法调用)。 |
接下来创建集成测试以验证保存、更新或删除书籍会留下审计跟踪。由于我们的服务异步捕获 GORM 事件,所以我们将使用 Spock 轮询条件,反复评估一个或多个条件,直到满足条件或超时。
package demo
import grails.testing.mixin.integration.Integration
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
。
创建一个新的 Grails 服务,称为 SerialNumberGeneratorService
~ 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
我们希望在插入新的 Book
实例的时候同步填充 serialNumber。使用 @Listener
注释(而不是 @Subscriber
)可同步采集 GORM 事件。
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 (并带有相应方法签名的)任何方法。此注释带有值参数,该参数可以是单个域类或要“侦听”的域类列表;在这种情况中,仅图书实例触发的事件才会触发此方法。 |
2 | onBookPreUpdate 侦听器会检查图书的标题是否“脏”(表明该属性已更改)。如果是,则更新 friendlyUrl 属性。 |
3 | isDirty 方法使我们能够检查持久化对象上已更改的属性。 |
4 | 我们使用 event.entityAccess.getProperty() 方法从域对象中检索 title 属性。 |
5 | 要设置域对象上的 serialNumber 属性,我们使用 event.entityAccess.setProperty() 方法。 |
你可能会想,为什么不能简单地将 event.entityObject 强制转换为 Book ,然后直接设置 serialNumber 属性。在此处避免采用这种方法的原因是,直接分配本身会触发另一个事件,从而可能导致同一事件侦听器多次触发。通过使用 entityAccess 对象,我们所做的任何更改都将与当前持久化会话同步,并将与原始对象同时保存。 |
同样,使用 GORM 数据服务简化了单元测试。在下一个测试中,我们对 GORM 数据服务进行存根,并验证填充了序号。
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 (由GrailsUnitTest 特性提供)中包含的HibernateDatastore 实例。我们还设置了我们在规范中定义的transactionManager 共享属性。 |
3 | 在我们重写的doWithSpring 方法中,我们创建了friendlyUrlService 、serialNumberGeneratorService 、titleListenerService 以及我们的dataStore 的 Spring bean,使用我们测试所需的一系列领域类实例化后者。这将在我们测试的 Spring 上下文中配置我们的服务和 GORM 数据存储。 |
4 | 我们的测试现在变得非常简单,与以前的集成测试几乎相同。 |
4 运行测试
要运行测试
./grailsw
grails> test-app
grails> open test-report
或
./gradlew check
open build/reports/tests/index.html
5 结论
事件侦听器是一个强大的概念,GORM 的实现使你可以在持久性事件周围编写智能业务逻辑,而不会“杂乱”你的域类。此外,最近的 Grails 版本中的改进测试支持使得为你的事件侦听器编写简单而全面的测试变得比以往任何时候都容易。祝编码愉快!