最簡單易懂的Gzip壓縮實現,最清晰的OkHttp的Gzip壓縮詳解

Gzip壓縮和解壓的實現

Gzip壓縮使用起來很簡單,以前我也只是在客戶端使用,服務器端不用管,所以我只用過GZIPInputStream來讀取,用起來也沒有問題。後來OkHttp開始流行,後來聽說OkHttp會自動處理Gzip壓縮的數據,不需要我們使用GZIPInputStream來處理,於是我想驗證一下是否真的是這樣的,這時我就需要寫個服務器端Demo了,發現行不通,會報錯,找不到原因,老辦法,先寫個最簡單Demo,把Gzip流用起來,整體思路如下:

  1. 創建一個很長的數據,一長串1,如:1111111111111111,1萬個1
  2. 使用GZIPOutputStream壓縮數據
  3. 使用GZIPInputStream解壓數據

具體代碼如下(使用Kotlin語言編寫):

fun main() {
    // 原始數據
    val sb = StringBuffer()
    repeat(10000) { sb.append(1) } // 生成1萬個1的字符串
    val rawBytes = sb.toString() .toByteArray(Charsets.UTF_8) // 原始數據
    println("壓縮前size = ${rawBytes.size},  數據 = ${rawBytes.map { byteToHex(it) }}")

    // 壓縮數據
    var baos = ByteArrayOutputStream()
    val gzipOut = GZIPOutputStream(baos)
    gzipOut.write(rawBytes)
    val gzipBytes = baos.toByteArray() // 拿到壓縮後的數據
    println("壓縮後size = ${gzipBytes.size},  數據 = ${gzipBytes.map { byteToHex(it) }}")

    // 解壓數據
    val gzipIn = GZIPInputStream(ByteArrayInputStream(gzipBytes))
    baos.reset() // 重置內存流,以便重新使用
    var byte: Int
    while (gzipIn.read().also { byte = it } != -1) baos.write(byte)
    println("解壓結果:size = ${baos.size()}, 數據 = ${String(baos.toByteArray(), Charsets.UTF_8)}")
}

/** 把字byte轉換爲十六進制的表現形式,如ff  */
fun byteToHex(byte: Byte) = String.format("%02x", byte.toInt() and 0xFF)

代碼很簡單,看似沒什麼問題,運行結果卻出了異常,如下:

壓縮前size = 10000,  數據 = [31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31]
壓縮後size = 10,  數據 = [1f, 8b, 08, 00, 00, 00, 00, 00, 00, 00]
Exception in thread "main" java.io.EOFException: Unexpected end of ZLIB input stream
	at java.base/java.util.zip.InflaterInputStream.fill(InflaterInputStream.java:245)
	at java.base/java.util.zip.InflaterInputStream.read(InflaterInputStream.java:159)
	at java.base/java.util.zip.GZIPInputStream.read(GZIPInputStream.java:118)
	at java.base/java.util.zip.InflaterInputStream.read(InflaterInputStream.java:123)
	at KtMainKt.main(KtMain.kt:31)
	at KtMainKt.main(KtMain.kt)

想不出這麼簡單的代碼哪裏會出問題,不就是一個讀一個寫嗎?而且我發現不論我寫什麼數據,壓縮後的數據結果都是一樣的,說明是在寫數據(壓縮)的時候出了問題,百度也找不到原因,百度Gzip壓縮的相關知識並沒有得到答案,百度上面報的異常信息也沒有答案,最後是讀了一下JDK文檔才發現了問題的原因(所以JDK文檔是個好東西,要多看看),在GZIPOutputStream文檔上有這麼一個方法:

finish() 完成將壓縮數據寫入輸出流的操作,無需關閉底層流。

之前我有試過調用輸出流的flush方法,沒想到要調用的竟然是finish方法,在GZIPOutputStream的wirte方法執行之後再調用一下finish方法即可,運行結果如下:

壓縮前size = 10000,  數據 = [31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31]
壓縮後size = 46,  數據 = [1f, 8b, 08, 00, 00, 00, 00, 00, 00, 00, ed, c1, 01, 0d, 00, 00, 00, c2, a0, 4c, ef, 5f, ce, 1c, 6e, 40, 01, 00, 00, 00, 00, 00, 00, 00, 00, c0, bf, 01, 5e, 62, 1a, 8f, 10, 27, 00, 00]
解壓結果:size = 10000,  數據 = 11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111

從結果可以看到,壓縮前的數據長度爲10000,壓縮後的長度爲46,真是牛B呀。解壓結果正確還原了1萬個1,因爲1萬個1太長,上面的打印結果我是進行了刪除的,所以大家不要覺得結果不對。

學會了Gzip大家平時做傳輸時就可以節省大量的流量了,比如獲取服務器端數據時可以讓服務器壓縮後再傳輸,我們給服務器傳參數時,如果參數很大也可Gzip壓縮後再傳給服務器,又比如上傳文本文件(如bug文件、log日誌等)到服務器時,也可以Gzip壓縮後再上傳,節省流量、加快訪問、提供用戶體驗耿耿的!

OkHttp中的Gzip壓縮處理

聽說Gzip壓縮是自動處理的,我也是最近看到別人文章上說的,我之前一直是在請求頭上加上gzip的,讀流的時候就自己用GZIPInputStream來解壓,也是沒問題的,但關鍵是既然OkHttp自動處理了,則我們就不要自己處理了,於是我要寫個Demo來驗證一下。

服務器端

打開陳年老舊的Eclipse(因爲在IntelliJ上寫JavaEE不熟),寫了個JavaEE的Demo,創建了一個Servlet,如下:

/** 服務器端 */
public class Hello extends HttpServlet {

	protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		// 數據內容:一段Json
		byte[] datas = "{name:\"張三\",age:18}".getBytes("UTF-8");							
		
		// 告訴客戶端我們發送的數據是經過Gzip壓縮的
		response.setHeader("Content-Encoding", "gzip");		
		
		// 告訴客戶端我們發送的數據是什麼類型,以及用的什麼編碼
		response.setContentType("application/json; charset=UTF-8"); 
		
		// 創建一個內存流,用於保存壓縮後的數據
		ByteArrayOutputStream baos = new ByteArrayOutputStream();	
		
		// 壓縮數據
		GZIPOutputStream gzipOut = new GZIPOutputStream(baos);
		gzipOut.write(datas);
		gzipOut.finish();
		
		// 告訴客戶端我們發送的數據總共的長度
		response.setContentLength(baos.size()); 
		
		// 把壓縮後的數據傳給客戶端
		response.getOutputStream().write(baos.toByteArray());
	}


}

客戶端

客戶端就使用了IntelliJ,Kotlin語言編寫,OkHttp進行數據請求,代碼如下:

fun main() {
    val url = "http://localhost:8080/WebDemo/Hello"
    val request = Request.Builder().url(url).build()

    OkHttpClient().newCall(request).execute().use { response ->
        println(response.body()?.string())
    }
}

Kotlin是個好東西,OkHttp也是個好東西,寫個請求就是這麼簡單,幾行代碼搞定,運行結果如下:

{name:"張三",age:18}

沒有出現亂碼,實驗證明OkHttp自動幫我們進行了Gzip解壓,我們不需要特殊處理,而且我們看打印結果有中文,中文並沒有顯示成亂碼,說明OkHttp還會根據服務器端指定的編碼來處理String。這樣在我們去面試時,如果別人要問OkHttp的優點時這裏就有兩點可說了:

  1. OkHttp會自動進行Gzip處理
  2. OkHttp會根據響應頭中指定的編碼來處理字符數據

接下來我們繼續實驗上面的兩條優點

1、實驗:OkHttp對Gzip數據的處理

我們在客戶端代碼中打印一下響應體中的所有響應頭:

OkHttpClient().newCall(request).execute().use { response ->
    response.headers()?.names()?.forEach { key -> println("$key=${response.header(key)}")}
    println(response.body()?.string())
}

運行客戶端,打印結果如下:

Content-Type=application/json;charset=UTF-8
Date=Mon, 23 Mar 2020 09:22:38 GMT
Server=Apache-Coyote/1.1
{name:"張三",age:18}

姨,奇怪了!服務器明明告訴了客戶端數據是經過Gzip壓縮的,怎麼響應頭裏看不到Gzip呢?這其是OkHttp把這個Gzip的Header給刪除了,因爲它已經幫我們把Gzip的數據給解壓了,所以Header裏面就沒必要有Gzip的Header存在,意思就是告訴你數據沒有壓縮了,你直接使用就行了,千萬別自己拿個GZIPInputStream再解壓了,如果再解壓一次肯定就亂碼了。

接下來把服務器端代碼中的這行代碼註釋掉:

response.setHeader("Content-Encoding", "gzip");		

再次運行客戶端,打印結果如下:

Content-Length=42
Content-Type=application/json;charset=UTF-8
Date=Mon, 23 Mar 2020 09:23:54 GMT
Server=Apache-Coyote/1.1
       ��K�M�Rz�g���J:��V�� )F��     

亂碼,因爲服務器沒有告訴客戶端數據是經過Gzip壓縮的,所以OkHttp就不會使用Gzip來解壓,不解壓直接當字符串來用肯定是亂碼啊。

這時,我們把上面註釋的代碼再恢復回來,修改一下客戶端,以告訴服務器我們可以處理Gzip壓縮的數據:

val request = Request.Builder().url(url).header("Accept-Encoding", "gzip").build()

再次運行,客戶端,打印結果如下:

Content-Encoding=gzip
Content-Length=42
Content-Type=application/json;charset=UTF-8
Date=Mon, 23 Mar 2020 09:25:35 GMT
Server=Apache-Coyote/1.1
       ��K�M�Rz�g���J:��V�� )F��    

從上面打印的響應頭中我們看到了關於Gzip的Header,這就說明數據是經過壓縮的,需要進行解壓,然而我們並沒有進行解壓,所以數據亂碼了,這說明只要我們在請求頭裏加上Gzip的Header,就代碼我們要自己處理Gzip,需要自己解壓,OkHttp就不會幫我們處理了。

對於Gzip請求頭,服務器在收到請求時會讀取這個請求頭,如果有gzip頭,則服務器把數據gzip壓縮後再傳給客戶端,如果沒有gzip頭,則服務器不會壓縮,直接把數據傳給客戶端。這時你可能會覺得鬱悶,如果我不加Gzip請求頭,則服務器不會給我壓縮數據,如果我加了Gzip請求頭,雖然服務器給我壓縮數據了,但是OkHttp又不會自動給我解壓,怎麼解?其實不存在這個問題,雖然我們在請求時沒有加入Gzip請求頭,但是OkHttp會自動幫我們加入的,怎麼驗證呢?使用Fiddler抓包工具抓包一看就知道,具體如何抓包這裏就不講解了,大家可以百度一下很多教程的,這裏就截圖給大家看一下抓包OkHttp請求,OkHttp確實是自動給我們加入了Gzip請求頭的:
在這裏插入圖片描述

2、實驗:OkHttp對字符編碼的處理

把服務器端的這行代碼註釋掉:

response.setContentType("application/json; charset=UTF-8"); 

運行客戶端,打印結果如下:

Date=Mon, 23 Mar 2020 10:01:57 GMT
Server=Apache-Coyote/1.1
{name:"張三",age:18}

在響應頭中看不到關於數據編碼的響應頭了,但是客戶端的中文顯示正常,說明OkHttp默認使用UTF-8進行編碼,我們再把服務器端設置編碼爲GBK,如下:

response.setContentType("application/json; charset=GBK"); 

再次運行客戶端,打印結果如下:

Content-Type=application/json;charset=GBK
Date=Mon, 23 Mar 2020 10:07:25 GMT
Server=Apache-Coyote/1.1
{name:"寮犱笁",age:18}

結果中文顯示爲亂碼,因爲服務器的數據是以UTF-8編碼進行傳輸的,卻告訴客戶端使用GBK編碼來解析,肯定是亂碼的。我們修改服務器的數據以GBK進行編碼,如下:

byte[] datas = "{name:\"張三\",age:18}".getBytes("GBK");

再次運行,結果如下:

Content-Type=application/json;charset=GBK
Date=Mon, 23 Mar 2020 10:09:40 GMT
Server=Apache-Coyote/1.1
{name:"張三",age:18}

OK,結果正常,這些實驗充分說明了OkHttp會以響應頭中指定的編碼來處理字符數據。

總結

  1. 在發送請求時,OkHttp會自動加入Gzip請求頭,當返回的數據帶有Gzip響應頭時,OkHttp會自動幫我們解壓數據。也就是說,對於Gzip,我們不需要做任何處理。如果我們在請求里加入Gzip請求頭,則表明我們想要自己處理Gzip數據,此時OkHttp就不會給我們解壓數據了。
  2. 在處理字符數據時,如果是使用OkHttp的response.body()?.string()方法,或者使用OkHttp的response.body().charStream()來讀取字符,則OkHttp會根據響應頭中的編碼來處理字符,如果響應頭中沒有編碼,則默認使用UTF-8編碼來處理字符,也可以認爲對於字符數據的編碼我們不需要做任何處理。除非服務器端沒有指定字符編碼,比如服務器使用GBK編碼發送數據,但是又沒在響應頭中聲明編碼,則OkHttp會以UTF-8處理則會亂碼,這樣的情況應該很少出現,除非是小學生寫服務器端。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章