2016年6月16日 星期四

使用 JAX-RS 2 做 HTTP Response 的壓縮

因為前陣子看了一些 RESTful API 的最佳實例(連結可以參考這裡
裡面有提到好的 RESTful API 應該要支援 GZIP 壓縮,對伺服器端或者客戶端都有好處
因此開始在實作 RESTful API 時,就搜尋了一下,Jersey 上應該如何正確地支援 GZIP 壓縮。

對於「正確地支援 GZIP 壓縮」這個問題,當然最簡單的作法,就是每個 RESTful API 都各自把輸出的東西都呼叫 GZIP 壓縮
然後把壓縮好的結果丟去 Response,並且適當地加上 HTTP Header 即可。
但我覺得這種作法應該不是最好的作法,應該有更好的方式可以讓 Jersey 自動幫每個 Response 都做好這些事。
在 Jersey 上,這件事情最適合的方式就是透過 Interceptors 來實作了。

    應該支援哪些壓縮方法?

    在要開始嘗試實作以前,第一個問題是應該支援哪些壓縮方法?HTTP 標準中有哪些公認的壓縮方法呢?
    這些問題可以簡單地先從 Wikipedia [1] 入手,根據 Wikipedia 的描述,基本的壓縮方法大概有以下幾種:

    1. compress – UNIX "compress" program method (historic; deprecated in most applications and replaced by gzip or deflate)
    2. deflate – compression based on the deflate algorithm (described in RFC 1951), wrapped inside the zlib data format (RFC 1950);
    3. exi – W3C Efficient XML Interchange
    4. gzip – GNU zip format (described in RFC 1952). This method is the most broadly supported as of March 2011.
    5. identity – No transformation is used. This is the default value for content coding.
    6. pack200-gzip – Network Transfer Format for Java Archives

    其中,裡面特別有兩個壓縮方法(deflate、gzip)有被標注了 RFC 文件,因此…既然有標準,當然優先採用標準了。

    不過在我自己的嘗試中,deflate 不知道為什麼一直無法通過 Unit Test,不太確定問題是出在哪裡
    另外過程中也發現一些問題,例如客戶端發出的 Accept-Encoding 標頭中,好像通常都會同時帶有 gzip, deflate
    讓我有點懷疑 deflate 到底是不是一個獨立的壓縮方法?

    在快速搜尋之後,看到 [5-6] 的討論,雖然因為沒有真的去翻 RFC 1950、1951 和 1952,但看起來的確有用字上的問題。
    deflate 在 RFC 1950 和 RFC 1952 中,看起來只是一種資料的表示格式而已
    像是 gzip, deflate 的意思應該是 gzip 壓縮格式,但使用 deflate 格式來表達。
    而 RFC 1950 對於 deflate 的定義是定義了 header 和 trail bytes
    但 RFC 1951 卻又定義了 deflate 這個演算法,是以 zlib 壓縮格式去包裝的資料壓縮方法。
    因此就有了用字混淆的問題,當 HTTP 標頭只看到 deflate 時,到底該依循 RFC 1950 還是 RFC 1951?
    這會影響到實作上資料有沒有包含 zlib 標頭,當伺服器端與客戶端雙方的認知不同時,會造成解壓縮上的問題。
    根據 [6] 的描述,不是所有廠商都會實作 RFC 1950 和 RFC 1951,有些會直接略過 RFC 1950
    因此同樣的 deflate 壓縮方法,可能會遇到有些客戶端能用、有些不能用的問題。

    基於上述的狀況,加上其實我也不是真的完全了解 deflate 的問題
    (換句話說,上面的解釋其實我並不完全確定正確,如果真心想解決這個問題,建議還是去讀一下 RFC 1950 和 RFC 1951 比較好)
    因此這篇就先略過 deflate,只描述 gzip 的作法了。

    Maven 設定

    要使用 gzip 壓縮,其實不需要額外的套件,Java 內建就有 gzip 壓縮套件了。
    因此實際上這裡只是引用了 Jersey 跟 Jersey Test Framework 而已。
    另外額外引用了一個 Apache Common IO,是在 Unit Test 時要把結果解開的過程要使用的。

    <properties>
        <jersey.version>2.23</jersey.version>
    </properties>
    
    <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>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>2.5</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.jersey.containers</groupId>
            <artifactId>jersey-container-grizzly2-http</artifactId>
        </dependency>
        <!-- Unit Test -->
        <dependency>
            <groupId>org.glassfish.jersey.test-framework.providers</groupId>
            <artifactId>jersey-test-framework-provider-jetty</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    使用 Jersey 的 WriterInterceptor 實作 Gzip 壓縮

    最前面有提到,目標是希望能讓 Jersey 自動在每個 Response 回覆時,將 Response 內容壓縮,並且附上對應的 HTTP Header
    因此實作時會使用 Jersey 的 WriterInterceptor,也就是在 Response 要回覆時,對回覆的內容做再處理。

    RESTful API

    首先先建立一個 RESTful API,這裡直接用 Jersey 範本產生的 RESTful API。

    @Path("myresource")
    public class MyResource {
        @GET
        @Produces(MediaType.TEXT_PLAIN)
        public String getIt() {
            return "Got it!";
        }
    }
    WriterInterceptor

    在要建立的 WriterInterceptor 上,要做的事情是擷取原始的 HTTP Request,判斷使用者的客戶端是否能接受 Gzip
    如果可以的話,就把回覆的原始內容使用 Gzip 壓縮,然後附上對應的 HTTP Header。

    public class CompressionWriterInterceptor implements WriterInterceptor {
        private static final Logger log = LoggerFactory.getLogger(CompressionWriterInterceptor.class);
        private HttpHeaders httpHeaders;
        
        public CompressionWriterInterceptor (@Context @NotNull HttpHeaders httpHeaders) {
            this.httpHeaders = httpHeaders;
        }
    
        @Override
        public void aroundWriteTo (WriterInterceptorContext context)
                throws IOException, WebApplicationException {
            List<String> encodings = this.httpHeaders.getRequestHeader(HttpHeaders.ACCEPT_ENCODING);
            
            try {
                final OutputStream outputStream = context.getOutputStream();
                
                // Compress the response using 'gzip' if the client accepts it.
                if(encodings.contains("gzip")) {
                    log.trace("Compress the response using gzip.");
                    context.getHeaders().add(HttpHeaders.CONTENT_ENCODING, "gzip");
                    context.setOutputStream(new GZIPOutputStream(outputStream));
                }
            } finally {
                log.trace("Proceed the response.");
                context.proceed();
            }
        }
    }
    WriterInterceptor 的單元測試

    在上面的程式碼都做好以後,接下來的問題就是,我怎麼驗證 WriteInterceptor 有正常運作?
    雖然也有古老的作法,直接裝一台 Application Server,然後打包佈署上去跑看看就知道了
    但還有更快一點的方式,利用 Jersey Test Framework 做初步的單元測試。

    public class CompressionWriterInterceptorTest extends JerseyTest {
        @Override
        protected Application configure() {
                return new ResourceConfig(
                        CompressionWriterInterceptor.class,
                        MyResource.class);
        }
        
        @Test
        public void testGzipEncoding () throws IOException {
            Response response = target("myresource").request().acceptEncoding("gzip").get(Response.class);
            
            // Validate the basic information from the response.
            Assert.assertEquals(200, response.getStatus());
            Assert.assertTrue(response.getHeaderString(HttpHeaders.CONTENT_ENCODING).contains("gzip"));
            
            // Validate the content of the response.
            try (InputStream stream = (InputStream) response.getEntity()) {
                byte[] responseBody = IOUtils.toByteArray(stream);
                // The result should not be the same since the response should be compressed by gzip.
                Assert.assertNotEquals("Got it!", new String(responseBody));
                // The result should be the same after we de-compress the response body.
                Assert.assertEquals("Got it!", new String(decompress(responseBody)));
            }
        }
        
        private byte[] decompress (byte[] compressedBytes) {
            if (compressedBytes == null || compressedBytes.length == 0) {
                    return new byte[0];
            }
            
            try (ByteArrayInputStream in = new ByteArrayInputStream(compressedBytes);
                            ByteArrayOutputStream out = new ByteArrayOutputStream()) {
                    try (GZIPInputStream gunzip = new GZIPInputStream(in)) {
                            byte[] buffer = new byte[256];
                            int n;
                            while ((n = gunzip.read(buffer)) >= 0) {
                                    out.write(buffer, 0, n);
                            }
                    }
                    
                    out.flush();
                    return out.toByteArray();
            } catch (IOException e) {
                    return compressedBytes;
            }
        }
    }

    在測試中,是直接發送一個 HTTP Request 給指定的 RESTful API,並且指定 Accept-Encoding 是 gzip
    接著拿回來的結果,先做簡單的 HTTP statuscode 和 Content-Encoding 檢查
    預期應該要獲得正常的 HTTP statuscode 200、Content-Encoding 應包含 gzip 字樣
    然後就是檢查內容是否真的有被 gzip 壓縮~
    首先 19 行先把回應的 Response 內容轉回成 byte array
    接著 21 行檢查 byte array 如果直接轉成字串,應該會轉成不知道是什麼東西,總之不會是正解
    再來 23 行就是將 byte array 做 gzip 解壓縮,再檢查轉成字串的結果,應該要是正解了。

    如何設定 Content-Length?

    我在實作時,上述流程下第一個想到的問題是,如果遵循 HTTP 標準的話,我應該要加入 Content-Length 和 Content-MD5
    不過在使用 GZIPInputStream 的情況下,其實我根本沒辦法在程式碼中得知真實的壓縮後長度….
    於是花了一些時間實驗和找資料,就發現…..我的知識好像過時很久了~囧

    Jersey 一般會自動在 Response 裡加入 Transfer-Encoding = ‘chunked’ 的 Header
    而這代表的意思是,根據 RFC 2616 [8] 對於 Message Length 的規範
    是表示伺服器回應的內容是自動被切割成多個 blocks,客戶端應依照 Transfer-Encoding = ‘chunked’ 的方式去收取內容。
    同時,RFC 2616 也規範了 Content-Length 應該同時表示此次傳輸的長度、以及內容的完整長度
    如果資料是被切割成多個 Response 時,Content-Length 就不能存在。
    因此,因為 Jersey 會自動加入 Transfer-Encoding = ‘chunked’,所以我們也不需要再做任何事
    刻意加上 Content-Length 只會造成客戶端誤判資料長度而已。

    如何設定 Content-MD5?

    除了 Content-Length 以外,另一個問題是要如何設定 Content-MD5?
    這個讓我更囧了,查了以後才發現,原來它直接被廢棄了!!
    請參考 RFC 7231 的 Appendix B,有如下的描述:

    The Content-MD5 header field has been removed because it was inconsistently implemented with respect to partial responses.

    所以簡單來說,不用考慮這個問題了……(拍板)。

    到此就是整個 Jersey 自動壓縮 gzip 的流程~
    如果有關於如何正確處理 deflate 的資料可以分享,歡迎大家提供!XD

    參考資料
    1. Wikipedia: HTTP compression
    2. JAX-RS 2: How to compress response if client accepts gzip; "Accept-Encoding: gzip, deflate, sdch"
    3. How to compress responses in Java REST API with GZip and Jersey
    4. Turn on gzip compression for grizzly under JerseyTest
    5. Why do real-world servers prefer gzip over deflate encoding?
    6. Handling HTTP ContentEncoding “deflate”
    7. Chapter 10. Filters and Interceptors - 10.3. Interceptors
    8. RFC 2616 - 4.4 Message Length
    9. RFC 7231 – Appendix B

    沒有留言: