一文学会 SpringBoot gzip 压缩传输(请求/响应)

前言

经常我们都会与服务端进行大数据量的文本传输,例如 JSON 就是常见的一种格式。通过 REST API 接口进行 GET 和 POST 请求,可能会有大量的文本格式数据提交、返回。然后对于文本,它有很高的压缩率,如果在 GET/POST 请求时候对文本进行压缩会节省大量的网络带宽,减少网络时延。

HTTP 协议在相应部分支持 Content-Encoding: gzip ,浏览器请求时带上 Accept-Encoding: gzip 即可,服务端对返回的 response body 进行压缩,并在 response 头带上 Content-Encoding: gzip,浏览器会自动解析。

然而 HTTP 没有压缩 request body 的设计,因为在客户端发起请求时并不知道服务器是否支持压缩。因此没法通过 HTTP 协议来解决,只能在服务端做一些过滤器进行判断,人为约束。压缩和解压在提升网络带宽的同时,会带来 CPU 资源的损耗。

本文将手把手带你实现 SpringBoot 项目中,请求时响应时对 body 文本进行 gzip 压缩,减小网络时延,提升传输效率。

一、请求压缩(request compress)

1. SpringBoot 整合 gzip

考虑到通用性,仿效 response 的 header Content-Encoding: gzip 方式。

客户端把压缩过的 json 作为 post-body 传输,然后增加一个 request header: Content-Encoding: gzip 来告诉服务器端是压缩的格式。

服务端增加一个 Filter,对 request 头进行检查,如果有 Content-Encoding 则解压缩后继续。这样不影响现有程序。

(1)Springboot 添加请求过滤器

增加 2 个类:

  • ContentEncodingFilter.java
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
package cn.frankfeekr.sample.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;


@Service
public class ContentEncodingFilter extends OncePerRequestFilter {
Logger logger = LoggerFactory.getLogger(ContentEncodingFilter.class);

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {

String conentEncoding = request.getHeader("Content-Encoding");
if (conentEncoding != null && ("gzip".equalsIgnoreCase(conentEncoding) || "deflate".equalsIgnoreCase(conentEncoding))) {
logger.trace("Content-Encoding: {}", conentEncoding);
chain.doFilter(new GZIPRequestWrapper(request), response);
return;
}

chain.doFilter(request, response);
}
}
  • GZIPRequestWrapper.java
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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
package cn.frankfeekr.sample.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.util.zip.DeflaterInputStream;
import java.util.zip.GZIPInputStream;

public class GZIPRequestWrapper extends HttpServletRequestWrapper {
private final static Logger logger = LoggerFactory.getLogger(GZIPRequestWrapper.class);

protected HttpServletRequest request;

public GZIPRequestWrapper(HttpServletRequest request) {
super(request);
this.request = request;
}

@Override
public ServletInputStream getInputStream() throws IOException {
ServletInputStream sis = request.getInputStream();
InputStream is = null;
String conentEncoding = request.getHeader("Content-Encoding");
if ("gzip".equalsIgnoreCase(conentEncoding)) {
is = new GZIPInputStream(sis);
} else if ("deflate".equalsIgnoreCase(conentEncoding)) {
is = new DeflaterInputStream(sis);
} else {
throw new UnsupportedEncodingException(conentEncoding + " is not supported.");
}
final InputStream compressInputStream = is;
return new ServletInputStream() {
ReadListener readListener;

@Override
public int read() throws IOException {
int b = compressInputStream.read();
if (b == -1 && readListener != null) {
readListener.onAllDataRead();
}
return b;
}

@Override
public boolean isFinished() {
try {
return compressInputStream.available() == 0;
} catch (IOException e) {
logger.error("error", e);
if (readListener != null) {
readListener.onError(e);
}
return false;
}
}

@Override
public boolean isReady() {
try {
return compressInputStream.available() > 0;
} catch (IOException e) {
logger.error("error", e);
if (readListener != null) {
readListener.onError(e);
}
return false;
}
}

@Override
public void setReadListener(final ReadListener readListener) {
this.readListener = readListener;
sis.setReadListener(new ReadListener() {
@Override
public void onDataAvailable() throws IOException {
logger.trace("onDataAvailable");
if (readListener != null) {
readListener.onDataAvailable();
}
}

@Override
public void onAllDataRead() throws IOException {
logger.trace("onAllDataRead");
}

@Override
public void onError(Throwable throwable) {
logger.error("onError", throwable);
if (readListener != null) {
readListener.onError(throwable);
}
}
});
}
};
}
}

(2)SpringBoot Controller Demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package cn.frankfeekr.sample.controller;

import com.alibaba.fastjson.JSON;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@ResponseBody
@RestController
@RequestMapping(produces = "application/json;charset=UTF-8")
public class HelloController {

@RequestMapping(value = "/gzip", method = RequestMethod.POST, consumes = "application/json")
public String post(@RequestBody Map<String, String> request) {
return request.toString();
}
}

2. 客户端测试

(1)gzip body 方式请求

只要在 Headers 头域带上 gzip,即可告知服务器为通过 gzip 方式来提交

1
2
3
4
5
6
7
8
# 生成一个 gzip 压缩的包
echo '{"type": "json", "length": 2020, "name": "Frank"}' | gzip > body.gz

# curl 命令模拟 POST gzip 压缩请求
curl --location --request POST 'http://127.0.0.1:9090/gzip' \
--header 'Content-Type: application/json' \
--header 'Content-Encoding: gzip' \
--data-binary '@body.gz'

断点调试结果如下:

img

(2)json body 方式请求

如果不需要进行压缩,则不带上 Content-Encoding: gzip 头域配置项即可。

1
2
3
4
5
# curl 命令模拟 gzip
curl --location --request POST 'http://127.0.0.1:9090/gzip' \
--header 'Content-Type: application/json' \
--header 'Content-Encoding: gzip' \
--data-raw '{"type": "json", "length": 2020, "name": "Frank"}'

img

上述可以发现如果通过 data-raw 方式请求,则必须要去掉 --header 'Content-Encoding: gzip',否则会出现 400 Bad Request 错误。现正确请求如下:

1
2
3
4
# curl 命令模拟 gzip
curl --location --request POST 'http://127.0.0.1:9090/gzip' \
--header 'Content-Type: application/json' \
--data-raw '{"type": "json", "length": 2020, "name": "Frank"}'

二、响应压缩(response compress)

1. SpringBoot 配置 response 策略

SpringBoot 默认是不开启 gzip 压缩的,需要我们手动开启,在配置文件中添加两行

1
2
3
4
server:
compression:
enabled: true
mime-types: application/json,application/xml,text/html,text/plain,text/css,application/x-javascript

注意下上面配置中的 mime-types,在 SpringBoot 2.0+ 的版本中,默认值如下,所以一般我们不需要特意添加这个配置

1
2
3
4
/**
* Comma-separated list of MIME types that should be compressed.
*/
private String[] mimeTypes = new String[]{"text/html", "text/xml", "text/plain", "text/css", "text/javascript", "application/javascript", "application/json", "application/xml"};

2. 测试

写一个测试的demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package cn.frankfeekr.sample.controller;

import com.alibaba.fastjson.JSON;
import org.springframework.web.bind.annotation.*;

import java.util.*;

@ResponseBody
@RestController
@RequestMapping(produces = "application/json;charset=UTF-8")
public class HelloController {

@GetMapping("bigReq")
public String bigReqList() {
List<String> result = new ArrayList<>(2048);
for (int i = 0; i < 2048; i++) {
result.add(UUID.randomUUID().toString());
}
return JSON.toJSONString(result);
}
}

测试效果,可以明显发现请求 body 被压缩,时延也变小。

img

参考资料