使用 JSON Views 技術,讓 Controller 返回 JSON 串

簡介

當我們實現 REST 接口時,需要讓 controller 的方法返回 json 串,這可以用 grails 的 JSON Views 技術來實現。

用gson view的好處是實現 MVC 架構,讓視圖和其他模塊分開;同時可以避免使用複雜的json序列化技術,JSON Views 技術 比常用的json框架(Jackson、Grails的marshaller API、FastJson)更加靈活。例如通過模板的方式可以將公用部分抽取出來重用。

Grails JSON Views 技術和其他的 Grails 技術一樣,對同一個實現需求提供了多種實現選擇和約定,這對初學者不太友好,其實提供一種方法就足夠了,使用約定,如果不熟悉時,會帶來額外的困擾及花費更多的學習時間。

因此,我們來總結一個切實可行的 JSON Views 使用方法,在日常開發中儘量都使用這一種方式。

實現步驟分以下幾步:

  1. 準備工作,引入 json-view 插件
  2. 在 controller 中指定要渲染的 model 對象
  3. 在 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 文件中的預定義變量 jsonStreamingJsonBuilder 的一個實例。所以我們需要了解 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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章