DTeam 团队日志

Doer、Delivery、Dream

Grails GORM查询总结

冯宇 Posted at — Jun 6, 2018 阅读

GORM绝对是Grails框架的一大亮点。GORM基于Hibernate的ORM之上做二次封装,既有Hibernate强大的功能,又有使用简便的特点。本篇主要总结和类比在项目开发中用到的GORM查询方式。

GORM支持的查询方式

GORM底层使用Hibernate,所以支持Hibernate的所有查询方式,此外,还支持Dynamic finder这种简便的查询。先对GORM支持的查询方式做一个表(参考来源: http://tatiyants.com/how-and-when-to-use-various-gorm-querying-options/):

dynamic finder where clause criteria HQL SQL
simple queries x x x x x
complex filters x x x x
associations x x x x
property comparisons x x x x
some subqueries x x x x
eager fetches w/ complex filters x x x
projections x x x
queries with arbitrary return sets x x
highly complex queries (like self joins) x
some database specific features x
performance-optimized queries x

从上表我们就能大致看出在什么样的场景下应该用什么类型的查询,每种查询方式具体的使用方式GORM参考官方文档。这里对这些查询方式简单做个总结

Dynamic finder

相信很多人第一眼对GORM的这个特性所惊艳到,可以用一种类似于静态方法的调用方式,就可以从数据库查询结果。它的优点和缺点一样明显,使用极为简单,功能也极为简陋,基本只能实现类似于:

SELECT * FROM domain WHERE condtions LIMIT x OFFSET y

这种方式的查询。只适合于单一Domain,查询条件固定的场景,也没有太多要说的,非常容易使用。

Book.findByName('bookName')
User.findAllByEnabled(true)
Persion.findAllByAgeBetween(10, 20)

Criteria

这个功能可以说是Hibernate的一个亮点,也是一个难点。它非常适合构造结构化查询的SQL,当查询条件不固定的时候,不需要在StringBuilder中编写大量的判断条件拼接的SQL,使得代码整洁度和可读性都大大提高。同时由于针对结构化查询的条件加了很多额外的方法,使得这个玩意对新手并不那么友好,有一定的上手门槛。另外也有一些查询上的限制,即使对SQL较为熟悉的用户,在写一个Criteria查询的时候可能也要想半天与调试一会。

Grails创建一个Criteria的语法有两种: Domain.createCriteria()Domain.withCriteria(Closure),后者可以看做是Domain.createCriteria().list(Closure)的别名。此外,由于Groovy的动态语言特性,所以Grails支持通过DSL的形式定义Criteria查询规则,而不需要像Hibernate那样写一堆.addXXX()的方法,比如:

User.createCriteria().list(params) {
    if (params.dateCreated) {
        gt('dateCreated', params.dateCreated)
    }
    if (params.status) {
        eq('status', params.status)
    }
}

list(Map params)支持的参数详见: http://docs.grails.org/latest/ref/Domain%20Classes/list.html#_description

代表要查询User这个Domain,但是dateCreatedstatus都是可选的查询条件,可能有也可能没有,通过Criteria的DSL可以很方便的定义这些查询条件,如果用HQL或者SQL写的话,是没有那么方便的,需要写判断条件,然后根据条件拼接对应的where语句,会让代码很冗长,而且四处拼接字符串也会让代码很难懂,一眼看不出来产生的SQL长什么样子。

此外,还可以加入projections,这样返回的就不是整个Domain对象了,而是Domain中指定的field或者field的聚合。相当于原本SQL中的SELECT * FROM domain变成了SELECT column1, count(column2), sum(column3),..., FROM domain。比如我要查询符合条件的用户数量以及平均年龄,可以这么写:

User.createCriteria().get {
    if (params.dateCreated) {
        gt('dateCreated', params.dateCreated)
    }
    if (params.status) {
        eq('status', params.status)
    }
    projections {
        count()
        avg('age')
    }
}

这样最终的结果就返回[count, avg]。如果Criteria的查询方式为list,并且传递有maxoffset参数的话,Grails会自动封装成一个PagedResultList对象,这个类中不但会包含符合条件的List,而且还会带有一个totalCount属性,便于分页查询,比如:

PagedResultList result = User.createCriteria().list([max: 10, offset: 10]) {
    if (params.dateCreated) {
        gt('dateCreated', params.dateCreated)
    }
    if (params.status) {
        eq('status', params.status)
    }
}

List<User> = result.resultList
int totalCount = result.totalCount

如果开启了Hibernate的DEBUG及TRACE级别的日志,会发现这里其实执行了两条SQL语句,一条是按照where条件查询出符合条件的结果集,另一条是去掉order by之后的SELECT count(*) FROM domain WHERE conditions。也就是说,有分页查询的需求时,不需要自己写两条查询语句查询count + list了,写一个查询条件就行了,通过PagedResultList在Grails内部就会给你产生两条这样的SQL语句,减少了代码量。

此外还有一个更令人称道的特性,就是Criteria的DSL定义放在了groovy闭包中,因此可以利用闭包的动态delegate特性,复用查询条件。当需要复用Criteria的查询条件时,这个特性会变的特别好用。

还是上面那个例子,比如我们需要在用户查询的详情页面中返回符合查询条件的用户列表(支持分页),在dashboard统计页面中,返回用户数和平均年龄的统计就行了,我们会发现这个where条件是完全一样的,因此可以考虑复用:

Closure criteria = { Map params = [:] ->
    if (params.dateCreated) {
        gt('dateCreated', params.dateCreated)
    }
    if (params.status) {
        eq('status', params.status)
    }
}

PagedResultList getUserList(Map params = [:]) {
    User.createCriteria().list(params) {
        criteria.delegate = delegate
        criteria(params)
    }
}

List<Integer> getUserSummary(Map params = [:]) {
    User.createCriteria().get {
        criteria.delegate = delegate
        criteria(params)
        projections {
            count()
            avg('age')
        }
    }
|

这样在复用查询条件的时候,会让代码大大缩短,而且便于集中维护查询条件,而不是需要增加支持的查询条件的时候,所有调用的方法全改一遍。

NOTE: 直接这么使用时不支持并发的,如果这个闭包被同时delegate,并且使用的参数不一致,那么在GORM底层就会抛出java.util.ConcurrentModificationException,类似于这样:

java.util.ConcurrentModificationException: null
      at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
      at java.util.ArrayList$Itr.next(ArrayList.java:859)
      at org.hibernate.loader.criteria.CriteriaQueryTranslator.getQueryParameters(CriteriaQueryTranslator.java:328)
      at org.hibernate.loader.criteria.CriteriaLoader.list(CriteriaLoader.java:109)
      at org.hibernate.internal.SessionImpl.list(SessionImpl.java:1787)
      at org.hibernate.internal.CriteriaImpl.list(CriteriaImpl.java:363)
      at org.grails.orm.hibernate.query.AbstractHibernateCriteriaBuilder.invokeMethod(AbstractHibernateCriteriaBuilder.java:1700)
      at org.codehaus.groovy.runtime.InvokerHelper.invokePogoMethod(InvokerHelper.java:931)
      at org.codehaus.groovy.runtime.InvokerHelper.invokeMethod(InvokerHelper.java:908)
      at org.grails.datastore.gorm.GormStaticApi$_withCriteria_closure11.doCall(GormStaticApi.groovy:384)
      at sun.reflect.GeneratedMethodAccessor633.invoke(Unknown Source)
      at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
      at java.lang.reflect.Method.invoke(Method.java:498)
      at org.codehaus.groovy.reflection.CachedMethod.invoke(CachedMethod.java:93)
      at groovy.lang.MetaMethod.doMethodInvoke(MetaMethod.java:325)
      at org.codehaus.groovy.runtime.metaclass.ClosureMetaClass.invokeMethod(ClosureMetaClass.java:294)
      at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:1022)
      at groovy.lang.Closure.call(Closure.java:414)
      at org.codehaus.groovy.runtime.ConvertedClosure.invokeCustom(ConvertedClosure.java:54)
      at org.codehaus.groovy.runtime.ConversionHandler.invoke(ConversionHandler.java:124)
      at com.sun.proxy.$Proxy111.doInSession(Unknown Source)
      at org.grails.datastore.mapping.core.DatastoreUtils.execute(DatastoreUtils.java:319)
...

此时,应考虑将这个闭包放到一个函数中,每次调用的时候返回一个新的闭包对象,这样就可以避免这个问题,比如:

Closure criteria() {
   { Map params = [:] ->
       if (params.dateCreated) {
           gt('dateCreated', params.dateCreated)
       }
       if (params.status) {
           eq('status', params.status)
       }
   }
}

说了这么多Criteria的优点,也说说它的限制吧:

所以不支持join结果。也就是说你需要SELECT domain1.c1, domain2.c2 FROM domain1 LEFT JOIN domain2 ON condtions这样的结果集的时候,是不能用Criteria实现的。

比如定义了UserUserRole两个Domain:

class User {
}

class UserRole {
    User user
    Role role
}

想查询角色下包含了哪些用户(一对多),如果用Criteria,只能这么查:

UserRole.withCriteria {
    role {
        eq('name', roleName)
    }
    projections {
        property('user')
    }
}

想用User去join UserRole是不可以的:

User.withCriteria {
    join('UserRole')  // invalid
}

因此在join上会有不小的限制,而HQL中就自由的多。

Detached Criteria

Detached Criteria的作用是仅构建查询条件,不执行查询,仅当调用了执行查询语句的方法(list, count, exists等等),才会真正执行查询。当一个查询条件可能要经过多道手续才能最终确认的时候,这个特性就比较有用了。另外在find and update这种情况下也比较有用:

def criteria = new DetachedCriteria(Person).build {
    eq 'lastName', 'Simpson'
}
def bartQuery = criteria.build {
    eq 'firstName', 'Bart'
}

def results = bartQuery.list(max:4, sort:"firstName")

这里的DSL和Criteria的DSL是完全一样的,此外,还多了deleteAllupdateAll(Map)这样的方法。

where clause

where clause本质上返回的是一个Detached Criteria,所以理论上具有Detached Criteria的功能与限制,但是并不支持eager fetches这样的特性。本质上来看where clause查询可以看做是一个简化版本的Detached Criteria。

def query = Pet.where {
    year(birthDate) == 2011
}

和Criteria一样,在闭包中定义查询条件,但是where clause可以使用groovy的条件判断语法,而不是Criteria DSL的条件判断函数。比如下面这样的例子:

def results = Person.where {
    age > where { age > 18 }.avg('age')
}

很明显groovy的条件判断语句更灵活,不再局限于Criteria的DSL。最后必须提醒一下,where(Closure)本质上返回的是Detached Criteria,因此必须调用Detached Criteria的查询方法才会真正执行查询,如:

result.list()

HQL

用过Hibernate的用户对这个东西应该都不陌生,融合了SQL和Java对象的特性,并且可以支持join结果:

User.executeQuery('''
   FROM User user
   LEFT JOIN LoginHistory history
   ON history.user.id = user.id
   WHERE history.dateCreated >= LocalDateTime.now().minusDays(1)
''')

比如上面的结果可以返回[[User, LoginHistory], [User, LoginHistory], [],...[]]这样的join之后的结果,甚至这两个Domain可以没有直接的外键关联,因此需要复杂join的需求的时候,HQL会比Criteria方便的多,并且性能上也会比Criteria占优势。不过HQL对于查询条件不固定的需求就不那么友好了,同样需要很麻烦的拼接String。还需要注意的HQL支持的SQL标准很少,是所有SQL的子集,比如不支持postgresql的json query,不支持sql 99标准的CTE等等。

HQL的返回结果就不要求一定绑定Domain了,因此跨Domain的查询会比Criteria更灵活。

实际项目开发中,JOIN的操作应尽可能减少,防止未来出现大表JOIN这种严重拖慢性能的隐患。

SQL

这个其实最没啥要说的,熟悉RDB的用户对这玩意应该都会用,但是GORM的文档和Grails官方文档对怎么使用SQL却提之甚少。这里简单说一下怎么用SQL:

Book.withSession { Session session ->
    session.clear()
}

从任意一个Domain中通过withSession方法拿到Hibernate Session对象,然后通过Session的api就可以执行SQL了.

User.withSession { Session session ->
    session.createSQLQuery('SELECT * FROM user WHERE id = :id')
        .setParameter('id', userId)
        .addEntity(User)
}

我们只是用User中拿过来了Hibernate Session,这玩意非常底层了,并不会帮我们自动做ORM绑定,因此如果希望绑定到Domain中,必须加上.addEntity(Domain)方式进行绑定,当然也可以最后调用.list()让闭包直接返回List结果。

使用SQL就可以使用数据库的全套特性了,是最自由的。同样这也将跟数据库耦合更紧密,你可能不能再切换底层数据库了。当需要数据库独有特性的时候,只能通过SQL解决了。比如写一个超长的SQL查询,使用CTE等高级特性。

不管怎样,在实际开发中还是应该尽可能避免直接使用这么底层的玩意,也应该尽可能减少直接使用SQL。

总结

本篇内容我们对Grails的GORM查询方式做了小结,对比了下各种查询方式的优缺点,实际项目中需要根据实际场景选择合适的使用方式。

虽然在ORM框架中应该尽可能避免使用底层的SQL,因为这会在一定程度上破坏框架的封装性,并且使用不当也会有SQL注入的风险。但是作为开发者,实际最应该熟练掌握的反而是最底层的SQL。