簡介
當我們實現 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-view 插件
- 在 controller 中指定要渲染的 model 對象
- 在 GSON View 中按需求渲染 json 串
第一步,準備工作,引入 json-view 插件
首先需要添加 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 中指定要渲染的 model 對象
讓 controller 返回一個 json 串的方法是用 respond 函數。respond 函數會根據約定查找對應的視圖模板。
約定體現在下面幾點:
- “Content Negotiation”,即 grails 會根據請求頭中的 Accept 字段值,例如是 application/json 則返回 json 視圖。
- 模板名稱和路徑的查找規則,如根據 controller 的方法名查找模板。
使用 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 對象。
第三步,創建一個 JSON View 視圖文件
根據約定創建視圖文件 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 的原因有關。
使用 GSON View 模板
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)
補充信息
respond 方法
The respond method will then look for an appriopriate Renderer for the object and the calculated media type from the RendererRegistry.
StreamingJsonBuilder 的用法
基本用法
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,熟悉整個使用方法後再來看這些官方文檔,否則容易陷入糾結狀態
雜記
respond 方法如何指定一個模板?
不用方法名的約定,而是指定使用一個模板,難道非要寫一個 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())
使用 GSON view 出現死循環的情況
問題代碼如下
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 View 文件中定義函數、添加判斷邏輯
因爲 .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<TenantUser> 類型。
* @param obj 要判斷的對象
* @return true 是 List<TenantUser> 列表類型,false 不是該類型或者列表的長度爲0
*/
static boolean isTenantUserList(def obj) {
obj instanceof List && obj.size() > 0 && obj[0] instanceof TenantUser
}
GSON view 文件會進行 static type check
所以類型要正確,如果 IDEA 報告類型警告,是會造成運行錯誤的。
要在controller單元測試中用到 domain 方法需要繼承用 HibernateSpec 類而不是 Specification 類
如果用常規的 Specification 會報告 GORM not initialize 異常。
class UserControllerSpec extends HibernateSpec implements ControllerUnitTest<UserController> {
...
}
用 rest profile 生成的 _errors.gson 渲染domain驗證錯誤時報告異常
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