显示导航

Grails GORM 数据服务

在本指南中,我们将学习如何在 Grails 应用程序中创建 GORM 数据服务。

作者: Nirav Assar、Sergio del Amo

Grails 版本 5.0.0

1 Grails 培训

Grails 培训 - 由创建并积极维护 Grails 框架的人员开发并提供!。

2 入门

在本指南中,我们将深入了解 GORM 数据服务。在 GORM 6.1 中引入的 GORM 数据服务免除了实现服务层逻辑的工作,因为它增加了使用 GORM 逻辑自动实现抽象类或接口的能力。这样可以减少要编写的代码,优化编译时间并简化测试。

本指南将详细介绍如何使用示例 Grails 应用程序创建和使用 GORM 数据服务。本指南将重点关注与持久性一致的应用程序服务层。

2.1 您需要什么

要完成本指南,您需要以下内容

  • 一些时间

  • 一个不错的文本编辑器或 IDE

  • 已安装 JDK 1.8 或更高版本,并且正确配置了 JAVA_HOME

2.2 如何完成指南

要开始,请执行以下操作

Grails 指南代码库包含两个文件夹

  • initial 初始项目。通常是一个简单的 Grails 应用程序,其中包含一些附加代码以帮助您快速入门。

  • complete 已完成的示例。它是完成本指南列出的步骤并对 initial 文件夹应用这些更改的结果。

要完成本指南,请转至 initial 文件夹

  • 进入 grails-guides/grails-gorm-data-services/initialcd

并按照后续章节中的说明进行操作。

如果您进入 grails-guides/grails-gorm-data-services/completecd,则可以直接转至已完成示例

3 编写应用程序

我们将编写一个包含 PersonAddress 域的简单应用程序。这些域具有多对一关系。我们将开发执行查询和写入操作的 GORM Data Services。

3.1 域对象

创建 PersonAddress 域类。它们具有多对一关系。

grails-app/domain/example/grails/Person.groovy
package example.grails

import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
class Person {

    String name
    Integer age
    Set<Address> addresses (1)

    static hasMany = [addresses: Address]
}
1 通常不需要在 hasMany 关联中指定默认类型 java.util.Set。然而,我们稍后在本教程中会在 JPA-QL 查询中查询关联,因此需要在此处明确指定。
grails-app/domain/example/grails/Address.groovy
package example.grails

import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
class Address {
    String streetName
    String city
    String state
    String country

    static belongsTo = [person: Person]
}

4 为什么使用 GORM Data Services

自动实现接口或抽象类可以减少编写的代码量。此外,GORM Data Services 自动定义事务边界语义。例如,所有公有方法都标记了 @Transactional(并且标记了只读查询方法)。

Data Services 的优势

总结优势

  • 类型安全 - Data Services 方法签名在编译时得到检查,如果任何参数的类型与域类中的属性不匹配,编译将失败。

  • 测试 - 由于 Data Services 为接口,因此容易模拟它们。

  • 性能 - 生成的服务是静态编译的,并且不同于 Java 领域中的竞争技术,生成的目的是创建代理,因而不会影响运行时性能。此外,更新操作的高效执行。

  • 正确定义事务语义。用户经常无法正确定义事务语义。Data Service 中的每种方法都封装在适当的事务中(在读操作中是只读事务)。

  • 支持多租户。结合使用 @CurrentTenant 注释,GORM 数据服务简化了多租户开发。

5 使用 GORM Data Services

要编写 Data Service,请创建一个接口并使用 grails.gorm.services.Service 和应用域类为其添加注释。@Service 转换查看接口的方法签名,以推断出应当生成什么实现。它会查看返回类型以及方法词干来实现功能。有关详细信息,请查看 Data Service 约定

5.1 动态查找器如

如果您过去使用过 GORM,您很可能使用过动态查找器进行查询。

动态查找器看着像是静态方法调用,但这些方法实际上在代码层中并不存在任何形式。相反,在运行时使用代码转换自动生成方法,该方法基于给定类的属性

借助 GORM 数据服务,您可以创建一个方法接口,这些方法与动态查找器的表达能力相同,但没有缺点。例如,由于 GORM 数据服务静态编译,因此您可以获得类型安全性。

要通过动态查找器按名称查找一个Person,您将使用Person.findByName。我们来实现一个数据服务以实现相同的查询。

创建一个使用import grails.gorm.services.Service进行注释的接口,并声明一个具有相同签名的接口。

grails-app/services/example/grails/PersonDataService.groovy
package example.grails

import grails.gorm.services.Query


import grails.gorm.services.Service

@Service(Person) (1)
interface PersonDataService {

    Person findByName(String name) (1)


    void delete(Serializable id) (3)

}
1 使用您想要使用的 domain 类装饰@Service
2 在方法findByName中,词干是find,它告诉 GORM 这是一条查询(因此将使用只读事务),并且Name与 domain 属性匹配。
3 方法delete获取应删除的人员的id。它将自动包装在可写事务中。

5.2 单元测试

您可以为前一个数据服务编写单元测试,如下所示

src/test/groovy/example/grails/PersonDataServiceWithoutHibernateSpec.groovy
package example.grails

import grails.gorm.transactions.Transactional


import org.grails.orm.hibernate.HibernateDatastore
import org.springframework.test.annotation.Rollback
import org.springframework.transaction.PlatformTransactionManager
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification

@Transactional
class PersonDataServiceWithoutHibernateSpec extends Specification {

    @Shared (2)
    PersonDataService personDataService

    @Shared (2)
    @AutoCleanup (3)
    HibernateDatastore hibernateDatastore

    @Shared (2)
    PlatformTransactionManager transactionManager

    void setupSpec() {
        hibernateDatastore = new HibernateDatastore(Person) (4)
        transactionManager = hibernateDatastore.getTransactionManager() (5)
        personDataService = this.hibernateDatastore.getService(PersonDataService)
    }

    @Rollback (6)
    void "test find person by name"() {
        given:
        Person p = new Person(name: "Nirav", age: 39).save()

        when:
        Person person = personDataService.findByName("Nirav")

        then:
        person.name == "Nirav"
        person.age == 39
    }
}
1 测试应该扩展spock.lang.Specification
2 Shared注释用于指示 Spock,HibernateDatastorepersonDataServicetransactionManager属性在所有测试中共享。
3 当所有测试执行完毕时,AutoCleanup注释可确保关闭 HibernateDatastore。
4 setupSpec方法中,使用 domain 类构造一个新的 HibernateDatastore,并将其作为构造函数参数。
5 通常,您需要在会话或事务中包装您的测试执行逻辑。您可以从 HibernateDatastore 获取 PlatformTransactionManager。
6 通常,您需要使用 Rollback 注解来注释您的功能方法,此注解用于回滚每个测试中进行的任何更改。

幸运的是,Grails Hibernate 插件包含一个实用类grails.test.hibernate.HibernateSpec,您可以从中扩展并简化前一个单元测试

src/test/groovy/example/grails/PersonDataServiceSpec.groovy
package example.grails

import grails.test.hibernate.HibernateSpec
import spock.lang.Shared

class PersonDataServiceSpec extends HibernateSpec {

    @Shared
    PersonDataService personDataService

    def setup() {
        personDataService = hibernateDatastore.getService(PersonDataService)
    }

    void "test find person by name"() {
        given:
        new Person(name: "Nirav", age: 39).save(flush: true)

        when:
        Person person = personDataService.findByName("Nirav")

        then:
        person.name == "Nirav"
        person.age == 39

        cleanup:
        personDataService.delete(person.id)
    }
}

5.3 属性投影

向返回投射的 GORM 数据服务添加一个方法。这具有仅返回一列而不是整个对象的好处。有几种实现投射的方法。一种方法是使用约定T find[Domain Class][Property]

例如,对于域类 Person 的属性 age,方法将会是

grails-app/services/example/grails/PersonDataService.groovy
Integer findPersonAge(String name)

加入到单元测试中以验证正确性。

src/test/groovy/example/grails/PersonDataServiceSpec.groovy
void "test find persons age projection"() {
    given:
    Person person = new Person(name: "Nirav", age: 39).save(flush: true)

    when:
    Integer age = personDataService.findPersonAge("Nirav")

    then:
    age == 39

    cleanup:
    personDataService.delete(person.id)
}

5.4 保存操作

数据服务也可以执行写操作。

grails-app/services/example/grails/PersonDataService.groovy
Person save(String name, Integer age)

加入到单元测试中以验证正确性。

src/test/groovy/example/grails/PersonDataServiceSpec.groovy
void "test save person"() {
    when:
    Person person = personDataService.save("Bob", 22)

    then:
    person.name == "Bob"
    person.age == 22
    personDataService.count() == old(personDataService.count()) + 1 (1)

    cleanup:
    personDataService.delete(person.id)
}
1 使用 Spock 的旧方法,我们可以在 when: 块被执行前得到一个语句的值。
运行所有测试以确保它们通过 → gradlew check

5.5 联接查询

数据服务也可以使用 grails.gorm.services.Join 注释来实现一个联接查询。默认情况下 Grails 关联会懒加载。对于一个一对多的关系,加载多方会导致 n + 1 问题,从而导致许多 select 语句。这会严重妨碍性能。

实际上我们可以使用 @Join 执行一个急切加载。将此概念应用到对 PersonAddress 的查询。

grails-app/services/example/grails/PersonDataService.groovy
@Join('addresses') (1)
Person findEagerly(String name)

截至目前,我们已经使用一个接口定义了 Person GORM 数据服务。你可以使用一个抽象类来代替。一个常见模式是接口和一个实现该接口的抽象类的组合。按照如下所示的方式重写 PersonDataService

grails-app/services/example/grails/PersonDataService.groovy
package example.grails

import grails.gorm.services.Query


import grails.gorm.services.Service
import grails.gorm.services.Join
import grails.gorm.transactions.Transactional
import groovy.util.logging.Slf4j

interface IPersonDataService {

    Person findByName(String name)

    Integer findPersonAge(String name)

    Number count()

    @Join('addresses') (1)
    Person findEagerly(String name)

    void delete(Serializable id)

    Person save(Person person)

    Person save(String name, Integer age)

}

@Service(Person)
abstract class PersonDataService implements IPersonDataService {

    @Transactional
    Person saveWithListOfAddressesMap(String name, Integer age, List<Map<String, Object>> addresses) {
        saveWithAddresses(name, age, addresses.collect { Map m ->
            new Address(streetName:  m.streetName as String,
                city:  m.city as String,
                state: m.state as String,
                country: m.country as String)
        } as List<Address>)
    }

    @Transactional
    Person saveWithAddresses(String name, Integer age, List<Address> addresses) {
        Person person = new Person(name: name, age: age)
        addresses.each { Address address ->
            person.addToAddresses(address)
        }
        save(person)
    }
}

创建单元测试

src/integration-test/groovy/example/grails/PersonDataServiceIntSpec.groovy
package example.grails

import grails.testing.mixin.integration.Integration
import spock.lang.Specification

@Integration (1)
class PersonDataServiceIntSpec extends Specification {

    PersonDataService personDataService (2)

    void "test join eager load"() {
        given:
        Person p = personDataService.saveWithListOfAddressesMap('Nirav',39, [
                [streetName: "101 Main St", city: "Grapevine", state: "TX", country: "USA"],
                [streetName: "2929 Pearl St", city: "Austin", state: "TX", country: "USA"],
                [streetName: "21 Sewickly Hills Dr", city: "Sewickley", state: "PA", country: "USA"]
        ])

        when:
        Person person = personDataService.findEagerly("Nirav")

        then:
        person.name == "Nirav"
        person.age == 39

        when:
        List<String> cities = person.addresses*.city

        then:
        cities.contains("Grapevine")
        cities.contains("Austin")
        cities.contains("Sewickley")

        cleanup:
        personDataService.delete(p.id)

    }

}
1 将你的单元测试放在 src/integration-test 中,并使用 grails.testing.mixin.integration.Integration 注释它们。
2 注入数据服务。

为了观察数据服务操作如何转换为 SQL 语句,我们将调整日志记录配置。

logback.groovy 中,追加下一行以获得正在执行的 SQL 查询的更多详细输出

grails-app/conf/logback.groovy
logger 'org.hibernate.SQL', TRACE, ['STDOUT'], false

执行单元测试 ./gradlew integrationTest --tests example.grails.PersonDataServiceIntSpec

前面的测试输出了一个 SQL 日志语句,该语句演示了一个联接已被发布。下面是一个示例。

日志输出
2018-08-20 16:28:40.460 DEBUG --- [    Test worker] org.hibernate.SQL                        : insert into person (id, version, age, name) values (null, ?, ?, ?)
2018-08-20 16:28:40.472 DEBUG --- [    Test worker] org.hibernate.SQL                        : insert into address (id, version, person_id, street_name, city, country, state) values (null, ?, ?, ?, ?, ?, ?)
2018-08-20 16:28:40.474 DEBUG --- [    Test worker] org.hibernate.SQL                        : insert into address (id, version, person_id, street_name, city, country, state) values (null, ?, ?, ?, ?, ?, ?)
2018-08-20 16:28:40.474 DEBUG --- [    Test worker] org.hibernate.SQL                        : insert into address (id, version, person_id, street_name, city, country, state) values (null, ?, ?, ?, ?, ?, ?)
2018-08-20 16:28:40.533 DEBUG --- [    Test worker] org.hibernate.SQL                        : select this_.id as id1_1_1_, this_.version as version2_1_1_, this_.age as age3_1_1_, this_.name as name4_1_1_, addresses2_.person_id as person_i3_0_3_, addresses2_.id as id1_0_3_, addresses2_.id as id1_0_0_, addresses2_.version as version2_0_0_, addresses2_.person_id as person_i3_0_0_, addresses2_.street_name as street_n4_0_0_, addresses2_.city as city5_0_0_, addresses2_.country as country6_0_0_, addresses2_.state as state7_0_0_ from person this_ left outer join address addresses2_ on this_.id=addresses2_.person_id where this_.name=?

如你所见,只执行了一个 select 查询。一个联接地址的查询。

可以在测试报告 build/reports/tests/classes/example.grails.PersonDataServiceIntSpec.html 的标准输出中看到这些语句。

6 JPA-QL 查询

JPA-QL(JPA 查询语言)查询使用类似 SQL 的文本查询语言查询实体模型。查询使用实体、属性和关系来表达。

GORM 数据服务通过 grails.gorm.services.Query 注释支持 JPA-QL 查询。

JPA-QL 查询允许使用更复杂的查询。尽管如此,它们仍然是静态编译的,而且可以防止 SQL 注入攻击。

创建一个按国家/地区搜索人的查询。

grails-app/services/example/grails/PersonDataService.groovy
    (1)
    @Query("""\
select distinct ${p}
from ${Person p} join ${p.addresses} a
where a.country = $country
""") (2)
    List<Person> findAllByCountry(String country) (3)
1 @Query 用来定义一个 JPA-QL 查询。
2 你可以使用多行字符串来定义你的 JPA-QL 查询,这增加了可读性。
3 country 参数可以在 HQL 查询中使用。

创建单元测试

src/integration-test/groovy/example/grails/PersonDataServiceIntSpec.groovy
package example.grails

import grails.testing.mixin.integration.Integration
import spock.lang.Specification

@Integration (1)
class PersonDataServiceIntSpec extends Specification {

    PersonDataService personDataService (2)

    void "test search persons by country"() {
        given:
        [
                [name: 'Nirav', age: 39, addresses: [
                        [streetName: "101 Main St", city: "Grapevine", state: "TX", country: "USA"],
                        [streetName: "2929 Pearl St", city: "Austin", state: "TX", country: "USA"],
                        [streetName: "21 Sewickly Hills Dr", city: "Sewickley", state: "PA", country: "USA"]]],
                [name: 'Jeff', age: 50, addresses: [
                        [streetName: "888 Olive St", city: "St Louis", state: "MO", country: "USA"],
                        [streetName: "1515 MLK Blvd", city: "Austin", state: "TX", country: "USA"]]],
                [name: 'Sergio', age: 35, addresses: [
                        [streetName: "19001 Calle Mayor", city: "Guadalajara", state: 'Castilla-La Mancha', country: "Spain"]]]
        ].each { Map<String, Object> m ->
            personDataService.saveWithListOfAddressesMap(m.name as String,
                    m.age as Integer,
                    m.addresses as List<Map<String, Object>>)
        }

        when:
        List<Person> usaPersons = personDataService.findAllByCountry("USA")

        then:
        usaPersons
        usaPersons.size() == 2
        usaPersons.find { it.name == "Nirav"}
        usaPersons.find { it.name == "Jeff"}

        when:
        List<Person> spainPersons = personDataService.findAllByCountry("Spain")

        then:
        spainPersons.size() == 1
        spainPersons.find { it.name == "Sergio"}
    }

7 JPA-QL 投影

你还可以使用 JPA-QL 为一个 POGO(普通 Groovy 对象)创建一个投影。

如果你有一个这样的 POGO

src/main/groovy/example/grails/Country.groovy
package example.grails

import groovy.transform.CompileStatic
import groovy.transform.TupleConstructor

@TupleConstructor (1)
@CompileStatic
class Country {
    String name
}
1 使用 Groovy TupleConstructor AST 转换以生成带一个参数的构造函数。

Address 域类创建一个 GORM 数据服务

grails-app/services/example/grails/AddressDataService.groovy
package example.grails

import grails.gorm.services.Query
import grails.gorm.services.Service

@Service(Address)
interface AddressDataService {

    @Query("select new example.grails.Country(${a.country}) from ${Address a} group by ${a.country}") (1)
    List<Country> findCountries()

}
1 TupleConstructor 生成的构造函数用于 JPA-QL 查询。

您可以创建单元测试验证行为

src/test/groovy/example/grails/AddressDataServiceSpec.groovy
package example.grails

import grails.test.hibernate.HibernateSpec
import spock.lang.Shared

class AddressDataServiceSpec extends HibernateSpec {
    @Shared
    AddressDataService addressDataService

    @Shared
    PersonDataService personDataService

    def setup() {
        addressDataService = hibernateDatastore.getService(AddressDataService)
        personDataService = hibernateDatastore.getService(PersonDataService)
    }

    def "projections to POGO work"() {
        given:
        List<Long> personIds = []
        [
                [name: 'Nirav', age: 39, addresses: [
                        [streetName: "101 Main St", city: "Grapevine", state: "TX", country: "USA"],
                        [streetName: "2929 Pearl St", city: "Austin", state: "TX", country: "USA"],
                        [streetName: "21 Sewickly Hills Dr", city: "Sewickley", state: "PA", country: "USA"]]],
                [name: 'Jeff', age: 50, addresses: [
                        [streetName: "888 Olive St", city: "St Louis", state: "MO", country: "USA"],
                        [streetName: "1515 MLK Blvd", city: "Austin", state: "TX", country: "USA"]]],
                [name: 'Sergio', age: 35, addresses: [
                        [streetName: "19001 Calle Mayor", city: "Guadalajara", state: 'Castilla-La Mancha', country: "Spain"]]]
        ].each { Map<String, Object> m ->
            personIds << personDataService.saveWithListOfAddressesMap(m.name as String,
                    m.age as Integer,
                    m.addresses as List<Map<String, Object>>).id
        }

        when:
        List<Country> countries = addressDataService.findCountries()

        then:
        countries
        countries.size() == 2
        countries.any { it.name == 'USA' }
        countries.any { it.name == 'Spain' }

        cleanup:
        personIds.each {
            personDataService.delete(it)
        }
    }
}

8 结论

允许 GORM 为您实现方法和管理事务,同时您只需定义界面。数据服务可以减少错误、性能问题,可测试。此外它具有很高的 COOL 系数!

阅读 GORM 数据服务 文档,了解更多信息。

观看 Graeme Rocher 在 Greach Conf 2018 上的演讲

9 Grails 帮助

Object Computing, Inc. (OCI) 赞助了本指南的编写。提供各种咨询和支持服务。

OCI 是 Grails 之家

认识团队