大多數時候,OkHttp 會自動解壓 gzip 響應。但某些老舊 server 或特殊場景,response 的 Content-Encoding 是 gzip/deflate,但 OkHttp 沒有自動幫你解。這時就要自己手動解壓。

問題場景

通常是這些情況:

  1. Server 回傳 Content-Encoding: gzip,但沒有設定好 headers
  2. 某些代理或中間層加了壓縮但沒遵循標準
  3. 特殊的嵌入式系統的 API
  4. 需要在 interceptor 層級處理壓縮和解壓

實作 UnzippingInterceptor

直接上完整代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import okhttp3.Interceptor;
import okhttp3.Response;
import okio.BufferedSource;
import okio.GzipSource;
import okio.Okio;

public class UnzippingInterceptor implements Interceptor {

@Override
public Response intercept(Chain chain) throws IOException {
Response response = chain.proceed(chain.request());

return unzip(response);
}

private Response unzip(Response response) throws IOException {
String contentEncoding = response.header("Content-Encoding");

// 如果沒有設定 Content-Encoding,直接返回
if (contentEncoding == null || contentEncoding.isEmpty()) {
return response;
}

// 如果已經是 identity(未壓縮),直接返回
if ("identity".equalsIgnoreCase(contentEncoding)) {
return response;
}

// 只處理 gzip
if ("gzip".equalsIgnoreCase(contentEncoding)) {
return unzipGzip(response);
}

// deflate 也可以支援,但比較少見
if ("deflate".equalsIgnoreCase(contentEncoding)) {
return unzipDeflate(response);
}

// 其他編碼就算了
return response;
}

private Response unzipGzip(Response response) throws IOException {
// 檢查 body 是否存在
if (response.body() == null) {
return response;
}

// 用 GzipSource 解壓
GzipSource gzipSource = new GzipSource(response.body().source());
BufferedSource decompressed = Okio.buffer(gzipSource);

// 重建 response,移除 Content-Encoding header
return response.newBuilder()
.body(new UncompressedResponseBody(decompressed))
.removeHeader("Content-Encoding")
.removeHeader("Content-Length") // 解壓後長度會改變
.build();
}

private Response unzipDeflate(Response response) throws IOException {
if (response.body() == null) {
return response;
}

// deflate 的處理類似,但用 InflaterSource
InflaterSource inflaterSource = new InflaterSource(response.body().source(), new Inflater(true));
BufferedSource decompressed = Okio.buffer(inflaterSource);

return response.newBuilder()
.body(new UncompressedResponseBody(decompressed))
.removeHeader("Content-Encoding")
.removeHeader("Content-Length")
.build();
}
}

配套的 ResponseBody 類:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import okhttp3.MediaType;
import okhttp3.ResponseBody;
import okio.BufferedSource;

public class UncompressedResponseBody extends ResponseBody {

private final BufferedSource source;

public UncompressedResponseBody(BufferedSource source) {
this.source = source;
}

@Override
public MediaType contentType() {
return null;
}

@Override
public long contentLength() {
return -1; // 解壓後長度未知
}

@Override
public BufferedSource source() {
return source;
}
}

怎麼用

在 OkHttpClient 初始化時加上 interceptor:

1
2
3
4
5
6
7
8
9
10
11
OkHttpClient httpClient = new OkHttpClient.Builder()
.addInterceptor(new UnzippingInterceptor())
.build();

Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create())
.build();

MyService service = retrofit.create(MyService.class);

之後所有的 gzip 響應都會自動解壓。

更簡潔的版本

如果只需要 gzip 支援,可以簡化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class GzipInterceptor implements Interceptor {

@Override
public Response intercept(Chain chain) throws IOException {
Response response = chain.proceed(chain.request());

if ("gzip".equalsIgnoreCase(response.header("Content-Encoding"))
&& response.body() != null) {

GzipSource gzipSource = new GzipSource(response.body().source());
BufferedSource decompressed = Okio.buffer(gzipSource);

return response.newBuilder()
.body(new UncompressedResponseBody(decompressed))
.removeHeader("Content-Encoding")
.removeHeader("Content-Length")
.build();
}

return response;
}
}

測試

怎麼驗證 interceptor 確實在解壓:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Mock server 回傳 gzip 響應
MockWebServer mockWebServer = new MockWebServer();
mockWebServer.enqueue(new MockResponse()
.setHeader("Content-Encoding", "gzip")
.setBody(gzipBody) // 壓縮過的 body
);

OkHttpClient httpClient = new OkHttpClient.Builder()
.addInterceptor(new GzipInterceptor())
.build();

Request request = new Request.Builder()
.url(mockWebServer.url("/"))
.build();

Response response = httpClient.newCall(request).execute();

// 驗證:Content-Encoding header 應該被移除
assertNull(response.header("Content-Encoding"));

// 驗證:body 應該是解壓後的內容
String body = response.body().string();
assertEquals("original content", body); // 應該是原始內容,不是壓縮過的

進階:支援其他編碼

如果要支援更多編碼(br、compress 等),可以擴展:

1
2
3
4
5
6
7
8
9
10
private Response handleBrotli(Response response) throws IOException {
if (response.body() == null) {
return response;
}

// 需要 brotli4j library
// BrotliDecoderJNI.decompress(...)

return response;
}

但大多數情況,gzip 夠用了。

常見坑

1. 忘記檢查 body 是否為 null

某些 response(比如 204 No Content)沒有 body:

1
2
3
4
5
6
7
8
// 爛做法
GzipSource gzipSource = new GzipSource(response.body().source()); // NPE!

// 正確做法
if (response.body() == null) {
return response;
}
GzipSource gzipSource = new GzipSource(response.body().source());

2. 忘記移除 Content-Encoding header

解壓後應該移除:

1
.removeHeader("Content-Encoding")  // 必須

否則下游的 JSON 解析器可能會嘗試再解一次。

3. Content-Length 會變

解壓後長度改變,最好移除:

1
.removeHeader("Content-Length")  // 解壓後無效

4. 某些 library 會自動解壓

OkHttp 3.4+ 預設會移除 gzip,Retrofit 的某些版本也會。加 interceptor 前先確認是否真的需要。

實務建議

  1. 優先用自動解壓 - OkHttp 和 Retrofit 通常會自動處理,不需要手動
  2. 只在真的需要時加 interceptor - 某些舊 server 或特殊 API
  3. 加上日誌便於 debug - 記錄什麼時候觸發了解壓
  4. 測試 edge case - 包括沒有 body、已經解壓過的 response

大多數時候根本用不到這個 interceptor,但知道怎麼做能救急。踩過的坑就寫在這裡。