JAX-RS 2.0 與 JSONP by MonsterSupreme | CodeData
top

JAX-RS 2.0 與 JSONP

分享:

JAX-RS 2.0 與 XML/JSON 資料轉換 – 神奇的 MOXy & Jackson << 前情

上次的文章介紹了 JAX-RS 2.0 神奇的 XML/JSON 支援能力,這一次要介紹的是 RESTful Services 常見的 JSONP 實作方式。

JSONP 介紹

有關 JSONP 的介紹,請參考良葛格的 JavaScript Essence: 使用 JSONP 跨站請求 一文,英文版在 這裡

JAX-RS 2.0 的 JSONP 支援

JAX-RS 2.0 規格裡面,對 JSONP 完全沒有著墨。

嗯,打完收工。

雖然 JAX-RS 2.0 規格沒有特別支援,並不表示我們就沒辦法輕易實現。底下我們分別從 Java EE 標準規格與 JAX-RS 2.0 實作產品特異功能兩個角度,分別來解決這個問題。

建立 JAX-RS 2.0 專案

為了示範方便,請執行底下的指令,建立一個可以部署到一般 Web Container 的 Jersey 專案:

mvn archetype:generate 
-DarchetypeGroupId=org.glassfish.jersey.archetypes
-DarchetypeArtifactId=jersey-quickstart-webapp
-DarchetypeVersion=2.5.1
-DgroupId=tw.com.codedata
-DartifactId=jsonpdemo
-Dpackage=tw.com.codedata.jsonpdemo
-DinteractiveMode=false

刪除自動產生的 MyResource.java 檔案,然後補上底下的 Region.javaRegionService.java 檔案。

Region 類別是我們定義的 Resource,內容如下:

package tw.com.codedata.jsonpdemo;

import java.io.*;

public class Region implements Serializable
{
    static final long serialVersionUID = 20140124L;

    private int regionId;
    private String regionDescription;

    public Region() {}

    public Region(int regionId, String regionDescription)
    {
        this.regionId = regionId;
        this.regionDescription = regionDescription;
    }

    public int getRegionId()
    {
        return regionId;
    }

    public void setRegionId(int regionId)
    {
        this.regionId = regionId;
    }

    public String getRegionDescription()
    {
        return regionDescription;
    }

    public void setRegionDescription(String regionDescription)
    {
        this.regionDescription = regionDescription;
    }
}

RegionService 類別會將 Region 對外展現為RESTful Service,內容如下:

package tw.com.codedata.jsonpdemo;

import java.util.*;
import javax.ws.rs.*;
import javax.ws.rs.core.*;

@Path("/regions")
public class RegionService
{
    private static List<Region> regionList = null;
    private static Region errorRegion = null;

    static 
    {
        regionList = new ArrayList<Region>();
        regionList.add(new Region(1, "Eastern"));
        regionList.add(new Region(2, "Western"));
        regionList.add(new Region(3, "Northern"));
        regionList.add(new Region(4, "Southern"));
        errorRegion = new Region(0, "Error");
    }

    public RegionService() 
    {
    }

    @GET
    @Path("/{regionId}")
    @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
    public Region retrieve(@PathParam("regionId") int regionId)
    {        
        for (Region region : regionList)
            if (region.getRegionId() == regionId) return region;

        return errorRegion; 
    }

    @GET
    @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
    public List<Region> retrieveAll()
    {        
        return regionList; 
    }        
}

因為 JSONP 一般來說,只用在 HTTP Get Method,所以 RegionService 類別我只實作 @GET 的部份。

pom.xml 檔案裡面記得打開註解掉的 JSON 支援,並且把 JDK 版本改為 1.7,因為 JAX-RS 2.0 在某些 Eclipse 版本裡面會被抱怨需要 JDK 1.7 才能使用。修改後的內容如下:

<project 
    xmlns="http://maven.apache.org/POM/4.0.0" 
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <groupId>tw.com.codedata</groupId>
    <artifactId>jsonpdemo</artifactId>
    <packaging>war</packaging>
    <version>1.0-SNAPSHOT</version>
    <name>jsonpdemo</name>

    <build>
        <finalName>jsonpdemo</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>2.5.1</version>
                <inherited>true</inherited>
                <configuration>
                    <source>1.7</source>
                    <target>1.7</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.glassfish.jersey</groupId>
                <artifactId>jersey-bom</artifactId>
                <version>${jersey.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>servlet-api</artifactId>
            <version>2.5</version>
            <scope>provided</scope>
        </dependency>   
        <dependency>
            <groupId>org.glassfish.jersey.containers</groupId>
            <artifactId>jersey-container-servlet-core</artifactId>
            <!-- use the following artifactId if you don't need servlet 2.x compatibility -->
            <!-- artifactId>jersey-container-servlet</artifactId -->
        </dependency>
        <!-- uncomment this to get JSON support -->
        <dependency>
            <groupId>org.glassfish.jersey.media</groupId>
            <artifactId>jersey-media-moxy</artifactId>
        </dependency>
    </dependencies>
    <properties>
        <jersey.version>2.5.1</jersey.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
</project>

整個專案目錄架構如下:

jsonpdemo Tree View

執行之後,應該可以透過 RESTClient 正確觸發 regions,取得所有 Region 資訊,XML 跟 JSON 都一樣:

JSON Output

不過到目前為止,我們的 RESTful Service 雖然已經實作完成,但是還不支援 JSONP 呼叫方式。

使用 Java EE 標準規格實作 JSONP 支援

雖然 JAX-RS 2.0 沒有定義 JSONP 的相關支援,但是我們靜下心來想想,如果要支援 JSONP,我們需要做些什麼:

  • 支援 HTTP GET Method
  • 產生 JSON 格式資料
  • 從 Query String 取得 Callback Function 的名稱,再串上原先應該傳回的 JSON 格式資料,傳回 Client 端

以我們的判斷來說,這件事最簡單的作法,就是寫個 Filter。但是因為我們要把原有的 HTTP Response 攔截下來,前後串上 Callback Function 名稱與圓括號,所以,我們必須繼承 HttpServletResponseWrapper 類別,封裝出一個可以讓我們自由控制的 HttpServletResponse,內容如下:

package tw.com.codedata.jsonpdemo;

import java.io.*;
import javax.servlet.*;
import javax.servlet.http.*;

public class AIOServletResponseWrapper extends HttpServletResponseWrapper
{
    private ByteArrayOutputStream baos = new ByteArrayOutputStream();    
    private PrintWriter printWriter = new PrintWriter(baos);
    private ServletOutputStream outputStream = new ByteArrayServletOutputStream(baos); 

    public AIOServletResponseWrapper(HttpServletResponse response)
    {
        super(response);        
    }

    @Override
    public PrintWriter getWriter() throws IOException 
    {
        return printWriter;
    }

    @Override
    public ServletOutputStream getOutputStream() throws IOException 
    {        
        return outputStream;
    }    

    @Override
    public String toString()
    {
        return baos.toString();
    }
}

class ByteArrayServletOutputStream extends ServletOutputStream
{
    private ByteArrayOutputStream baos = null;

    public ByteArrayServletOutputStream(ByteArrayOutputStream baos)
    {
        this.baos = baos;
    }

    @Override
    public void write(int b) throws IOException
    {
        baos.write(b);
    }    
}

這麼一來,我們自己寫的 JSONPFilter 類別,就可以利用上面的 HttpServletResponseWrapper 類別,對 HTTP Response 進行加料的動作,內容如下:

package tw.com.codedata.jsonpdemo;

import java.io.*;
import javax.servlet.*;
import javax.servlet.http.*;

public class JSONPFilter implements Filter 
{
    private FilterConfig config = null;

    public void init(FilterConfig config) throws ServletException
    {
        this.config = config;
    }

    public void destroy()
    {
        this.config = null;
    }

    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) 
        throws IOException, ServletException 
    {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) resp;

        String method = request.getMethod();
        String callback = request.getParameter("callback");

        if (method.equalsIgnoreCase("GET") && callback != null)
        {
            AIOServletResponseWrapper responseWrapper = new AIOServletResponseWrapper(response);
            chain.doFilter(request, responseWrapper);
            String jsonData = responseWrapper.toString();
            response.setContentLength(-1);
            ServletOutputStream sos = response.getOutputStream();
            sos.print(callback + "(" + jsonData + ");");
            sos.close();            
            response.setContentType("application/javascript");
            System.out.println(jsonData);
        }
        else
        {    
            chain.doFilter(req, resp);
        }
    }
}

加上 response.setContentLength(-1); 這一行是因為在 Filter 一路攔截下去的過程中,可能會有一些 Servlet 自己設定 Content Length,這會導致我們的輸出被截斷,所以透過這一行指令,逼迫 HttpServletResponse 重新計算一下 Content Length。

web.xml 部署描述檔案也必須要宣告 JSONPFilter 類別,內容如下:

<?xml version="1.0" encoding="UTF-8"?>
<!-- This web.xml file is not required when using Servlet 3.0 container, 
    see implementation details http://jersey.java.net/nonav/documentation/latest/jax-rs.html -->
<web-app version="2.5"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://java.sun.com/xml/ns/javaee"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

    <servlet>
        <servlet-name>Jersey Web Application</servlet-name>
        <servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class>
        <init-param>
            <param-name>jersey.config.server.provider.packages</param-name>
            <param-value>tw.com.codedata.jsonpdemo</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>Jersey Web Application</servlet-name>
        <url-pattern>/webapi/*</url-pattern>
    </servlet-mapping>

    <filter>
        <display-name>JSONPFilter</display-name>
        <filter-name>JSONPFilter</filter-name>
        <filter-class>tw.com.codedata.jsonpdemo.JSONPFilter</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>JSONPFilter</filter-name>
        <url-pattern>/webapi/*</url-pattern>
    </filter-mapping>

</web-app>

重新編譯之後執行,透過 RESTClient 觸發 http://localhost:8080/jsonpdemo/webapi/regions?callback=processJSON 的時候,如果附帶送出 Accept: application/json Header,應該可以順利看到 JSONP 的輸出:

JSONP Output

這個時候暫時還不能送出 Accept: application/javascript Header 來觸發,因為這個 HTTP Request 送到 RegionService 的時候,會因為我們沒有自行撰寫並註冊處理這種 Content Type 的 MessageBodyProvider 而產生錯誤。

使用 Jersey 特異功能實作 JSONP 支援

剛剛透過標準 Servlet API 的想法雖然簡單,但是實作上有點辛苦,不太符合 科技始終來自於惰性 這個理念。有沒有輕鬆一點的作法呢?

有的。Jersey 這個 JAX-RS 2.0 的 Reference Implementation,提供了一個特異功能,只要加上 @JSONP 這個 Annotation 一切就搞定了。

RegionService 類別的兩個 @GET 方法,調整之後內容如下:

package tw.com.codedata.jsonpdemo;

import java.util.*;
import javax.ws.rs.*;
import javax.ws.rs.core.*;
import org.glassfish.jersey.server.*;

@Path("/regions")
public class RegionService
{
    private static List<Region> regionList = null;
    private static Region errorRegion = null;

    static 
    {
        regionList = new ArrayList<Region>();
        regionList.add(new Region(1, "Eastern"));
        regionList.add(new Region(2, "Western"));
        regionList.add(new Region(3, "Northern"));
        regionList.add(new Region(4, "Southern"));
        errorRegion = new Region(0, "Error");
    }

    public RegionService() 
    {
    }

    @GET
    @Path("/{regionId}")
    @JSONP(callback="jsonp", queryParam="callback")
    @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, "application/javascript"})
    public Region retrieve(@PathParam("regionId") int regionId)
    {        
        for (Region region : regionList)
            if (region.getRegionId() == regionId) return region;

        return errorRegion; 
    }

    @GET
    @JSONP(callback="jsonp", queryParam="callback")
    @Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML, "application/javascript"})
    public List<Region> retrieveAll()
    {        
        return regionList; 
    }
}

web.xml 部署描述檔案也必須要註解掉有關 JSONPFilter 類別的宣告,內容如下:

<?xml version="1.0" encoding="UTF-8"?>
<!-- This web.xml file is not required when using Servlet 3.0 container, 
    see implementation details http://jersey.java.net/nonav/documentation/latest/jax-rs.html -->
<web-app version="2.5"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://java.sun.com/xml/ns/javaee"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

    <servlet>
        <servlet-name>Jersey Web Application</servlet-name>
        <servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class>
        <init-param>
            <param-name>jersey.config.server.provider.packages</param-name>
            <param-value>tw.com.codedata.jsonpdemo</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>Jersey Web Application</servlet-name>
        <url-pattern>/webapi/*</url-pattern>
    </servlet-mapping>

    <!-- Comment out JSONPFilter when using @JSONP Annotation --> 
    <!-- 
    <filter>
        <display-name>JSONPFilter</display-name>
        <filter-name>JSONPFilter</filter-name>
        <filter-class>tw.com.codedata.jsonpdemo.JSONPFilter</filter-class>
    </filter>

    <filter-mapping>
        <filter-name>JSONPFilter</filter-name>
        <url-pattern>/webapi/*</url-pattern>
    </filter-mapping>
    -->

</web-app>

重新編譯之後執行,透過 RESTClient 觸發 http://localhost:8080/jsonpdemo/webapi/regions?callback=processJSON 的時候,如果附帶送出 Accept: application/javascript Header,應該可以順利看到 JSONP 的輸出:

JSON Output

使用 @JSONP 這個 Annotation 應該注意的地方:

  • 雖然不是 JAX-RS 2.0 標準,但是超級方便
  • 可以接受以下的 Content Type:
    • application/x-javascript
    • application/javascript
    • application/ecmascript
    • text/javascript
    • text/x-javascript
    • text/ecmascript
    • text/jscript
  • 提供 callbackqueryParam 兩個屬性
  • callback 指的是 Callback Function 名稱,預設值為 callback
  • queryParam 指的是 Query String 裡面紀錄 Callback Function 的參數名稱
  • queryParam 的優先順序高於 callback
  • 不要把 MediaType.APPLICATION_XML 擺在 @Produces 的第一個位置,也就是優先使用 XML,因為這時回傳的 JSONP 資料裡面會變成 XML (XML with Padding?)

其中,callbackqueryParam 兩個屬性,是比較容易搞混的地方。以我們的範例設定
@JSONP(callback="jsonp", queryParam="callback") 來說:

  • 如果網址是 http://localhost:8080/jsonpdemo/webapi/regions?callback=processJSON,因為 queryParam 優先順序比較高,所以 Server 端會先檢查 Query String 有沒有出現 queryParam 屬性所設定的 callback 這個名稱?剛好有,所以 Server 端就會以 callback 的值 processJSON 做為 Callback Function 的名稱,如下圖:Callback Identification using queryParam
  • 如果網址是 http://localhost:8080/jsonpdemo/webapi/regions,因為沒有加上任何 Query String,所以 Server 端會使用 callback 屬性所設定的值 jsonp 做為 Callback Function 的名稱,如下圖:Callback Identification using callback

參考資料

  1. Looking for an example for inserting content into the response using a servlet filter
  2. The Essentials of Filters
分享:
按讚!加入 CodeData Facebook 粉絲群

留言

留言請先。還沒帳號註冊也可以使用FacebookGoogle+登錄留言

關於作者

目前從事教育訓練工作。自認為會的技術不多,但是學不會的也不多,最擅長把老闆交代的工作,以及找不到老師教的技術,想辦法變成自己的專長。

熱門論壇文章

熱門技術文章