当我们实现 REST 接口时,需要让 controller 的方法返回 json 串,这可以用 grails 的 JSON Views 技术来实现。
用gson view的好处是实现 MVC 架构,让视图和其他模块分开;同时可以避免使用复杂的json序列化技术,JSON Views 技术 比常用的json框架(Jackson、Grails的marshaller API、FastJson)更加灵活。例如通过模板的方式可以将公用部分抽取出来重用。
Grails JSON Views 技术和其他的 Grails 技术一样,对同一个实现需求提供了多种实现选择和约定,这对初学者不太友好,其实提供一种方法就足够了,使用约定,如果不熟悉时,会带来额外的困扰及花费更多的学习时间。
因此,我们来总结一个切实可行的 JSON Views 使用方法,在日常开发中尽量都使用这一种方式。
实现步骤分以下几步:
首先需要添加 JSON views 的依赖包
compile 'org.grails.plugins:views-json:2.0.0' // or whatever is the latest version
为了将 JSON views 编译为class,打包到 production 部署文件,我们需要添加 gradle 插件。
buildscript {
...
dependencies {
...
classpath "org.grails.plugins:views-gradle:2.0.0"
}
}
...
apply plugin: "org.grails.grails-web"
apply plugin: "org.grails.plugins.views-json"
这样会为 gradle 创建一个 compileGsonViews
任务,会在创建 production JAR 或 WAR 文件的时候被预先调用。
让 controller 返回一个 json 串的方法是用 respond 函数。respond 函数会根据约定查找对应的视图模板。
约定体现在下面几点:
使用 respond 指定渲染 model 的例子:
@Secured("ROLE_ADMIN")
class ApiV1Controller {
StaffService staffService
/**
* 列出“坐席”
*/
def listStaff(){
TenantUser tenantUser = authenticatedUser as TenantUser
List<TenantUser> staffList = staffService.findByTenant(tenantUser.tenant)
respond tenantUserList: staffList
}
}
这里使用 Map 给 view 传递了一个命名 model tenantUserList: staffList
,可以添加更多的 key: value 来传递更多的 model 对象。
根据约定创建视图文件 views/< controller >/< method >.gson
.gson
文件就是一个普通的 Groovy 脚本文件,它有一个优点,就是可以在 IDEA 中设置断点,查看变量的值。
一个简单的 json view 例子
json.person {
name "bob"
}
会输出
{"person":{"name":"bob"}}
.gson 文件中的预定义变量 json
是 StreamingJsonBuilder 的一个实例。所以我们需要了解 StreamingJsonBuilder 的用法。
一个实际的 json view 例子,这个例子中我们需要排除 Domain Class 中的某些属性,让他们不出现在返回的 json 串中。
import com.telecwin.jinanyuan.TenantUser
import groovy.transform.Field
@Field List<TenantUser> tenantUserList
json g.render(tenantUserList, [excludes: ["password"]])
更改 GSON View 文件名、内容后,如果不生效,请重启应用试试。
可能和 GSON view 会编译为 class 的原因有关。
json g.render(template:"person", model:[person:person])
# 模板和View不在一个目录下时,使用URI
json g.render(template:"/person/person", model:[person:person])
# tmpl.person 方法名对应了模板名,模板中model变量名和模板名相同
json tmpl.person(person)
# 指定model变量名
json tmpl.person(individual:person)
The respond method will then look for an appriopriate Renderer for the object and the calculated media type from the RendererRegistry.
StringWriter writer = new StringWriter()
StreamingJsonBuilder builder = new StreamingJsonBuilder(writer)
builder.records {
car {
name 'HSV Maloo'
make 'Holden'
year 2006
country 'Australia'
record {
type 'speed'
description 'production pickup truck with speed of 271kph'
}
}
}
String json = JsonOutput.prettyPrint(writer.toString())
会产生下面的 json
{
"records": {
"car": {
"name": "HSV Maloo",
"make": "Holden",
"year": 2006,
"country": "Australia",
"record": {
"type": "speed",
"description": "production pickup truck with speed of 271kph"
}
}
}
}
想要排除Null属性、排除特定属性、自定义对象转换为String的逻辑,可以用 JsonGenerator instance 创建一个 StreamingJsonBuilder ,这样就可以自定义输出逻辑,比如:
def generator = new JsonGenerator.Options()
.excludeNulls()
.excludeFieldsByName('make', 'country', 'record')
.excludeFieldsByType(Number)
.addConverter(URL) { url -> "http://groovy-lang.org" }
.build()
StringWriter writer = new StringWriter()
StreamingJsonBuilder builder = new StreamingJsonBuilder(writer, generator)
builder.records {
car {
name 'HSV Maloo'
make 'Holden'
year 2006
country 'Australia'
homepage new URL('http://example.org')
record {
type 'speed'
description 'production pickup truck with speed of 271kph'
}
}
}
assert writer.toString() == '{"records":{"car":{"name":"HSV Maloo","homepage":"http://groovy-lang.org"}}}'
用上面方法输出的 json 串,总是有一个多余的对象,如
{
"records": {
"car": {
"name": "HSV Maloo",
},
"ship": {...}
}
}
如何去掉 “records” 直接将里面的属性放到顶层,成为下面这样的结构呢?
{
"car": {
"name": "HSV Maloo",
},
"ship": {...}
}
使用 JsonStreamingBuilder 的各种 call() 函数。Groovy 中一个类如果实现了call() 函数,那么实例对象就可以被当成方法一样被调用。
比如 JsonStreamingBuilder 就可以这样来使用:
StreamingJsonBuilder builder = new StreamingJsonBuilder(writer, generator)
builder(myPOJO)
这样就可以将 myPOJO 作为“root JSON object”输出了。
用这种方法不但可以将一个对象作为 root JSON object 输出,还可以把数组、多个对象作为 root JSON array 输出,也可以用来给clousure 传递额外的参数。这些在 StreamingJsonBuilder 的API 中都有说明和举例。
示例代码:
new StringWriter().with { w ->
def json = new groovy.json.StreamingJsonBuilder(w)
def result = json 1, 2, 3
assert result instanceof List
assert w.toString() == "[1,2,3]"
}
更简单的方法是使用 Closure 参数来调用 builder 对象,像这样:
new StringWriter().with { w ->
def json = new groovy.json.StreamingJsonBuilder(w)
json {
name "Tim"
age 39
}
assert w.toString() == '{"name":"Tim","age":39}'
}
注意:上面指定 json key 的方法是用 函数 调用的方式,也就是说 “name” 是一个函数名,“Tim” 是函数的参数。
但在 GSON views 中如何使用 JsonGenerator 呢?
没有找到,但是可以用
json g.render(book, [excludes:['password'])
来达到排除的目的。回头试试 generator 预定义变量。
先不要着急读下面的文档,等看完本blog,熟悉整个使用方法后再来看这些官方文档,否则容易陷入纠结状态
不用方法名的约定,而是指定使用一个模板,难道非要写一个 json view 文件,在里面使用 tmpl 变量吗?
答:可以指定 view 名。
render 函数的使用方法可以有以下各种。
render(view: "display", model: map)
render(view: "/shared/display", model: map)
class ReportingController {
static namespace = 'business'
def accountsReceivable() {
// This will render grails-app/views/business/reporting/numberCrunch.gsp
// if it exists.
// If grails-app/views/business/reporting/numberCrunch.gsp does not
// exist the fallback will be grails-app/views/reporting/numberCrunch.gsp.
// The namespaced GSP will take precedence over the non-namespaced GSP.
render view: 'numberCrunch', model: [numberOfEmployees: 13]
}
}
渲染一个text片段可以这样
// render a template for each item in a collection
render(template: 'book_template', collection: Book.list())
问题代码如下
import com.telecwin.jinanyuan.api.Response
import groovy.transform.Field
@Field Response response
json {
code response.code.code
msg response.code.name()
info response.info
}
原因:上面的写法info response.info
在生成 json string 时,没有将 GORM 添加的额外属性排除在外,导致序列化 “constrainedProperties” 属性,从而进行了很深的对象树序列化工作,造成调用堆栈溢出。
[{"tenantId":1,"constrainedProperties":{"dateCreated":{"editable":true,
...
}]
解决办法:
使用模板,让 grails 知道正在序列化的是一个 GORM 实体对象。
response.gson
import com.telecwin.jinanyuan.api.Response
import groovy.transform.Field
@Field Response response
json {
code response.code.code
msg response.code.name()
info tmpl.tenantUser(response.info)
}
_tenantUser.gson
import com.telecwin.jinanyuan.TenantUser
import groovy.transform.Field
@Field TenantUser tenantUser
json g.render(tenantUser, [excludes: ["password"]]) {
tenant tenantUser.tenant.id
}
上面的代码还用到了一个技巧,就是“先将 Domain Class 中的属性 tenant 去掉,换成自定义的属性值”,因为原始的 tenant 属性会被渲染成一个对象,而我们希望是一个 int 类型值。
因为 .gson 文件就是一个 Groovy 脚本,所以我们可以各种 groovy 语言的控制语句、函数定义来实现 json 视图逻辑。
下面是一个高级使用例子。
import com.telecwin.jinanyuan.TenantUser
import com.telecwin.jinanyuan.api.Response
import groovy.transform.Field
// 本 gson 对象会被编译为一个 class,这里定义了一个 field
@Field Response response
json {
code response.code.code
msg response.code.name()
if (isTenantUserList(this.response.info)) {
info tmpl.tenantUser(response.info)
}else{
info response.info
}
}
/**
* 辅助方法,判断一个对象是否是 List 类型。
* @param obj 要判断的对象
* @return true 是 List 列表类型,false 不是该类型或者列表的长度为0
*/
static boolean isTenantUserList(def obj) {
obj instanceof List && obj.size() > 0 && obj[0] instanceof TenantUser
}
所以类型要正确,如果 IDEA 报告类型警告,是会造成运行错误的。
如果用常规的 Specification 会报告 GORM not initialize 异常。
class UserControllerSpec extends HibernateSpec implements ControllerUnitTest<UserController> {
...
}
Caused by: java.lang.NullPointerException
at grails.views.api.internal.DefaultGrailsViewHelper.link(DefaultGrailsViewHelper.groovy:40)
猜想可能是我的 domain class 没有添加 @Rest 注解,导致查找资源链接失败了。
也不排除是 grails 的bug。
完整异常堆栈如下,需要问下 grails 开发者,或者提个 bug:
Error rendering view: Error rendering view: Error rendering view: null
grails.views.ViewException: Error rendering view: Error rendering view: Error rendering view: null
at grails.views.AbstractWritableScript.writeTo(AbstractWritableScript.groovy:46)
at grails.views.mvc.GenericGroovyTemplateView.renderMergedOutputModel(GenericGroovyTemplateView.groovy:73)
at org.springframework.web.servlet.view.AbstractView.render(AbstractView.java:317)
at grails.views.mvc.renderer.DefaultViewRenderer.render(DefaultViewRenderer.groovy:117)
at grails.artefact.controller.RestResponder$Trait$Helper.internalRespond(RestResponder.groovy:192)
at grails.artefact.controller.RestResponder$Trait$Helper.respond(RestResponder.groovy:98)
at chess_api.UserController.register(UserController.groovy:52)
at org.grails.testing.runtime.support.ActionSettingMethodHandler.invoke(ActionSettingMethodHandler.groovy:29)
at chess_api.UserControllerSpec.注册验证失败返回错误提示json(UserControllerSpec.groovy:70)
Caused by: grails.views.ViewException: Error rendering view: Error rendering view: null
at grails.views.AbstractWritableScript.writeTo(AbstractWritableScript.groovy:46)
at grails.plugin.json.view.api.internal.DefaultGrailsJsonViewHelper$6.writeTo(DefaultGrailsJsonViewHelper.groovy:829)
at grails.plugin.json.view.JsonViewWritableScript.json(JsonViewWritableScript.groovy:126)
at grails.plugin.json.view.JsonViewWritableScript.json(JsonViewWritableScript.groovy:149)
at chess_api_user_register_gson.run(chess_api_user_register_gson:7)
at grails.plugin.json.view.JsonViewWritableScript.doWrite(JsonViewWritableScript.groovy:27)
at grails.views.AbstractWritableScript.writeTo(AbstractWritableScript.groovy:40)
... 8 more
Caused by: grails.views.ViewException: Error rendering view: null
at grails.views.AbstractWritableScript.writeTo(AbstractWritableScript.groovy:46)
at grails.plugin.json.view.api.internal.DefaultGrailsJsonViewHelper$6.writeTo(DefaultGrailsJsonViewHelper.groovy:829)
at grails.plugin.json.builder.StreamingJsonBuilder$StreamingJsonDelegate.call(StreamingJsonBuilder.java:699)
at grails.plugin.json.builder.StreamingJsonBuilder$StreamingJsonDelegate.invokeMethod(StreamingJsonBuilder.java:516)
at chess_api_user__apiResponse_gson.run_closure1(chess_api_user__apiResponse_gson:14)
at grails.plugin.json.builder.StreamingJsonBuilder$StreamingJsonDelegate.cloneDelegateAndGetContent(StreamingJsonBuilder.java:793)
at grails.plugin.json.builder.StreamingJsonBuilder$StreamingJsonDelegate.access$000(StreamingJsonBuilder.java:478)
at grails.plugin.json.builder.StreamingJsonBuilder.call(StreamingJsonBuilder.java:238)
at grails.plugin.json.view.JsonViewWritableScript.json(JsonViewWritableScript.groovy:72)
at chess_api_user__apiResponse_gson.run(chess_api_user__apiResponse_gson:10)
at grails.plugin.json.view.JsonViewWritableScript.doWrite(JsonViewWritableScript.groovy:27)
at grails.views.AbstractWritableScript.writeTo(AbstractWritableScript.groovy:40)
... 14 more
Caused by: java.lang.NullPointerException
at grails.views.api.internal.DefaultGrailsViewHelper.link(DefaultGrailsViewHelper.groovy:40)
at chess_api_errors__errors_gson.run_closure1(chess_api_errors__errors_gson:16)
at grails.plugin.json.builder.StreamingJsonBuilder$StreamingJsonDelegate.cloneDelegateAndGetContent(StreamingJsonBuilder.java:793)
at grails.plugin.json.builder.StreamingJsonBuilder$StreamingJsonDelegate.access$000(StreamingJsonBuilder.java:478)
at grails.plugin.json.builder.StreamingJsonBuilder.call(StreamingJsonBuilder.java:238)
at grails.plugin.json.view.JsonViewWritableScript.json(JsonViewWritableScript.groovy:72)
at chess_api_errors__errors_gson.run(chess_api_errors__errors_gson:12)
at grails.plugin.json.view.JsonViewWritableScript.doWrite(JsonViewWritableScript.groovy:27)
at grails.views.AbstractWritableScript.writeTo(AbstractWritableScript.groovy:40)
... 25 more