接口返回大量json数据
最近在做前端的一些事情。
使用echart绘图。遇到一个问题,就是用ajax 接收后端返回的json数据。测试发现速度很慢,调试发现后端返回的数据有54.7M,ajax接收时间在32-43秒左右,如图:
项目使用spring mvc框架,服务端使用@ResponseBody 自动打包 HttpServletResponse的返回内容,return HashMap,返回类型是application/json
这是在使用Ehcache 后的结果。令笔者想不到的是,返回的数据竟有54.7M大小,由于前端等待时间较长,因此需要做些优化。
首先,有哪些优化手段呢?ajax的格式是这样的:
$.ajax({
type: "POST",
url: '**/getModelData',
data: {jobId:jobId},
dataType:'json',
cache: false,
async: true,
success: function(data){
//
}
});
可以从同步/异步,cache入手。然而,异步通常用于加载dom,并不适用这里,网上一些异步方式讨论的也跟这里无关。笔者把cache 设置为true后,速度并没有提高。按理说,cache在接收第一次同样的数据后,会把数据临时缓存,下一次请求速度会快一些,实际发现,请求仍然是在返回后端的数据。没有看出明显提升。这让笔者有点奇怪。
既然无效,可不可以用一个js全局变量,临时存储后端返回的数据呢?这里每一个请求返回的数据大小都在几十M的规模,多请求几次,页面临时内存会有达到几百M的可能,这样是不是有些笨拙?
总之,并没有使用这样方式。剩下还有几种方式:
(1)压缩
(2)缓存
(3)服务端做优化。
首先是压缩。这是比较好的思路。tomcat,spring mvc,nginx 都提供压缩配置,主流的压缩格式是Gzip,恰好以上三者都提供。这里并没有用到nginx,所以,只考虑spring mvc和tomcat。
spring mvc 使用Gzip 需要一个GZIPResponseWrapper 类来继承HttpServletRespose,另外,fliter层需要GZIPFilter 实现Filter接口,简单说,就是再封装HttpServletResponse.
具体代码如下:
import java.io.*;
import javax.servlet.*;
import javax.servlet.http.*;
public class GZIPFilter implements Filter {
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
if (req instanceof HttpServletRequest) {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
String ae = request.getHeader("accept-encoding");
if (ae != null && ae.indexOf("gzip") != -1) {
GZIPResponseWrapper wrappedResponse = new GZIPResponseWrapper(response);
chain.doFilter(req, wrappedResponse);
wrappedResponse.finishResponse();
return;
}
chain.doFilter(req, res);
}
}
public void init(FilterConfig filterConfig) {
// noop
}
public void destroy() {
// noop
}
}
public class GZIPResponseWrapper extends HttpServletResponseWrapper {
protected HttpServletResponse origResponse = null;
protected ServletOutputStream stream = null;
protected PrintWriter writer = null;
public GZIPResponseWrapper(HttpServletResponse response) {
super(response);
origResponse = response;
}
public ServletOutputStream createOutputStream() throws IOException {
return (new GZIPResponseStream(origResponse));
}
public void finishResponse() {
try {
if (writer != null) {
writer.close();
} else {
if (stream != null) {
stream.close();
}
}
} catch (IOException e) {}
}
public void flushBuffer() throws IOException {
stream.flush();
}
public ServletOutputStream getOutputStream() throws IOException {
if (writer != null) {
throw new IllegalStateException("getWriter() has already been called!");
}
if (stream == null)
stream = createOutputStream();
return (stream);
}
public PrintWriter getWriter() throws IOException {
if (writer != null) {
return (writer);
}
if (stream != null) {
throw new IllegalStateException("getOutputStream() has already been called!");
}
stream = createOutputStream();
writer = new PrintWriter(new OutputStreamWriter(stream, "UTF-8"));
return (writer);
}
public void setContentLength(int length) {}
}
public class GZIPResponseStream extends ServletOutputStream {
protected ByteArrayOutputStream baos = null;
protected GZIPOutputStream gzipstream = null;
protected boolean closed = false;
protected HttpServletResponse response = null;
protected ServletOutputStream output = null;
public GZIPResponseStream(HttpServletResponse response) throws IOException {
super();
closed = false;
this.response = response;
this.output = response.getOutputStream();
baos = new ByteArrayOutputStream();
gzipstream = new GZIPOutputStream(baos);
}
public void close() throws IOException {
if (closed) {
throw new IOException("This output stream has already been closed");
}
gzipstream.finish();
byte[] bytes = baos.toByteArray();
response.addHeader("Content-Length",
Integer.toString(bytes.length));
response.addHeader("Content-Encoding", "gzip");
output.write(bytes);
output.flush();
output.close();
closed = true;
}
public void flush() throws IOException {
if (closed) {
throw new IOException("Cannot flush a closed output stream");
}
gzipstream.flush();
}
public void write(int b) throws IOException {
if (closed) {
throw new IOException("Cannot write to a closed output stream");
}
gzipstream.write((byte)b);
}
public void write(byte b[]) throws IOException {
write(b, 0, b.length);
}
public void write(byte b[], int off, int len) throws IOException {
if (closed) {
throw new IOException("Cannot write to a closed output stream");
}
gzipstream.write(b, off, len);
}
public boolean closed() {
return (this.closed);
}
public void reset() {
//noop
}
}
参考链接:
http://www.javablog.fr/javaweb-gzip-compression-protocol-http-filter-gzipresponsewrapper-gzipresponsewrapper.html
这是别人写好的,也是可用的。不过相对这个问题,改动比较大,改完还需要调试。因此,并没有采用。
有没有改动小一点的? tomcat,nginx也内置了Gzip压缩配置方式:
<Connector port="8888" protocol="org.apache.coyote.http11.Http11NioProtocol"
connectionTimeout="21000"
redirectPort="28080"
maxThreads="500"
minSpareThreads="50" maxIdleTime="60000
URIEncoding="UTF-8"
compression="on"
compressionMinSize="50"
noCompressionUserAgents="gozilla, traviata" compressableMimeType="text/html,text/xml,text/plain,text/javascript,text/csv,application/javascript" />
加上去之后,发现没有效果。原因是compressableMimeType=”text/html,text/xml,text/plain,text/javascript,text/csv,application/javascript”
并不支持这里的数据类型,这里的数据类型是:application/json
在浏览器调试器可查看数据类型:
觉得有点奇怪,这里用的是tomcat7,是否不支持application/json类型?又或者这个问题本身很少见,ajax就是用来传输少量数据的?于是又在stackoverflow上面找相关讨论。
找到一个帖子,发现一个逗比,他的服务端返回的数据高达2GB,问题跟笔者的类似。
链接:
https://stackoverflow.com/questions/47991007/compress-and-send-large-string-as-spring-http-response
同行讨论说有提供Gzip压缩的,有提供其它方式优化的,但是一下子没看明白,改动比较大。之后又看到这样一段话:
链接:
https://stackoverflow.com/questions/21410317/using-gzip-compression-with-spring-boot-mvc-javaconfig-with-restful?noredirect=1&lq=1
说的正是tomcat压缩方式,不巧的是,笔者没有注意到,他说的是tomcat默认支持这些数据类型,隐含意思是,不止这些数据类型。
而笔者误解tomcat只支持这些数据类型,不支持application/json 的Gzip压缩。
带着这个误解,笔者又查了一些资料,也没有找到简单的方式。既然没找到,笔者想是不是加上去试一下看看,于是便加上去,发现果然有效果,如图:
而且压缩率惊人,压缩了17倍。有点怀疑,又用echart绘了图,并没发现数据异常。数据传输速度,提高了2-10秒。应该是解压的步骤也会耗时。
回去后,找了下笔者之前做的资料,发现Gzip压缩之前就有做过,按理说,也做过类似的优化方面的思考。可是,时间一长,反而什么也不记得了。所以还是写下来吧。
(2)缓存
可是即便这样,浏览器要对gzip数据解压,耗时也是挺大的。还需要再优化。想了几个小时,笔者尝试使用js 全局变量数组,把服务端的数据暂时存在全局变量,这样第一次请求跟之前一样,之后请求会快很多。问题是,这个会增加页面内存消耗,虽然有限制存取的数据对象个数。还是没有解决第一次请求的速度。这是全局变量(包含一个modelData)的占用内存:
然后笔者又查询html5的函数,发现localStorage 可以尝试。localStorage 容量比cookie,session 都要大,有5M。尝试了一下,由于笔者这里的数据解压后会有几十M,所以localStorage 不适合。
不过笔者发现一个别人写好的js 库,是对localStorage 的一个应用。
链接地址:
https://github.com/WQTeam/jquery-ajax-cache
(3)服务器端优化
剩下的便只有服务器端优化。思考再三,笔者尝试减少服务器端返回的数据大小。经过分析,发现,是有减小空间的。于是针对每次请求,尽量只返回必要的数据,那些该次请求没有用到的数据就不返回,经过整理,返回的json数据有明显减小,反映在前端,就是响应变快很多。这是优化后的:
echarts的GeoJSON文件压缩
echarts为了加快GeoJSON的传输速度,对GeoJSON文件进行了压缩, 对GeoJSON中的坐标信息进行了压缩了,减少了坐标的数据量。
echarts中的GeoJSON数据格式:
采用的是zigzag的算法进行压缩的,“zigzag 的原理就是压缩多余的因补位造成数据变大的问题,它的原理是把符号位向右移到在最前一位,对负数除最后一位经行求为非;正数求不变。”
echarts内置解码算法,将坐标信息进行解析,在图上进行绘制。
压缩之前的中国行政区划数据:
压缩之后的数据:
压缩比例是相当高的,这在网络传输上能够加快传输速度,现在客户端的计算机性能都是可以的,解析并不需要很长的时间。
关键的压缩代码(摘自echarts源码):
关键的解压代码(摘自echarts):
在其他类型的地图开发时,涉及到大量的坐标信息,也可以按照此种方式进行压缩,之后在客户端解析数据,地图上显示,达到减少网络数据传输的目的。
http请求过多耗时
- 来源
- 前言
网站性能优化是属于每一个网站的必备过程,而最直接简单的就是http请求的优化,包含了减少http请求的数量,加快http请求的速度。
而本篇文章,将从http请求数量进行入手,将无用、无效请求全部过滤掉
。
- 开始
本篇将基于axios开源库,对http请求进行封装,包含请求的缓存、重复请求的过滤
两个小优化。
- 第一步
首先建立一个http-helper.js文件,里面将基于axios
进行上述相关功能的封装
首先里面的内容大概是这样的:
import axios from 'axios';
const http = axios.create();
export default http
上述就是简单地导出了一个axios实例,供项目使用
- 增加请求缓存功能
那么有了缓存功能,就要对缓存命中
进行定义,我定义的缓存命中是指:http请求的url相同、请求参数相同、请求类型相同
,以上三者都相同的情况下,就视为缓存允许命中,最后根据缓存过期时间
,判断是否获取最新数据,还是从缓存中取。
下面理一下流程:
- 发起请求,设置请求是否缓存,缓存多长时间
- axios请求拦截,判断该请求是否设置缓存,是?则判断是否缓存命中、是否过期,否?则继续发起请求
- axios响应拦截,判断该请求结果是否缓存,是?则缓存数据,并设置key值、过期时间
针对上面的流程,需要有几点确认一下:
- 当缓存命中时,如何终止请求
axios中,可以为每一个请求设置一个cancleToken,当调用请求取消方法的时候,则请求终止,并将终止的消息通过reject
回传给请求方法。 - 当缓存命中时,并
将缓存的数据通过resolve()返回给请求方法,而不是在reject中获取缓存数据
那么具体的代码可以是这样的:
// http-helper.js
import axios from 'axios';
const http = axios.create();
http.interceptors.request.use((config) => {
/**
* 为每一次请求生成一个cancleToken
*/
const source = axios.CancelToken.source();
config.cancelToken = source.token;
/**
* 尝试获取缓存数据
*/
const data = storage.get(cryptoHelper.encrypt(
config.url + JSON.stringify(config.data) + (config.method || ''),
));
/**
* 判断缓存是否命中,是否未过期
*/
if (data && (Date.now() <= data.exppries)) {
console.log(`接口:${config.url} 缓存命中 -- ${Date.now()} -- ${data.exppries}`);
/**
* 将缓存数据通过cancle方法回传给请求方法
*/
source.cancel(JSON.stringify({
type: CANCELTTYPE.CACHE,
data: data.data,
}));
}
return config;
});
http.interceptors.response.use((res) => {
if (res.data && res.data.type === 0) {
if (res.config.data) {
/**
* 获取请求体参数
*/
const dataParse = JSON.parse(res.config.data);
if (dataParse.cache) {
if (!dataParse.cacheTime) {
dataParse.cacheTime = 1000 * 60 * 3;
}
/**
* 加密
* 缓存
*/
storage.set(cryptoHelper.encrypt(res.config.url + res.config.data + (res.config.method || '')), {
data: res.data.data, // 响应体数据
exppries: Date.now() + dataParse.cacheTime, // 设置过期时间
});
console.log(`接口:${res.config.url} 设置缓存,缓存时间: ${dataParse.cacheTime}`);
}
}
return res.data.data;
} else {
return Promise.reject('接口报错了!');
}
});
/**
* 封装 get、post 请求
* 集成接口缓存过期机制
* 缓存过期将重新请求获取最新数据,并更新缓存
* 数据存储在localstorage
* {
* cache: true
* cacheTime: 1000 * 60 * 3 -- 默认缓存3分钟
* }
*/
const httpHelper = {
get(url, params) {
return new Promise((resolve, reject) => {
http.get(url, params).then(async (res) => {
resolve(res);
}).catch((error) => {
if (axios.isCancel(error)) {
const cancle = JSON.parse(error.message);
if (cancle.type === CANCELTTYPE.REPEAT) {
return resolve([]);
} else {
return resolve(cancle.data);
}
} else {
return reject(error);
}
});
});
},
post(url: string, params: any) {
return new Promise((resolve, reject) => {
http.post(url, params).then(async (res) => {
resolve(res);
}).catch((error: AxiosError) => {
if (axios.isCancel(error)) {
const cancle = JSON.parse(error.message);
if (cancle.type === CANCELTTYPE.REPEAT) {
return resolve(null);
} else {
return resolve(cancle.data);
}
} else {
return reject(error);
}
});
});
},
};
export default httpHelper
上面代码中,有些东西没有解释到:
- 其中
storage
是自己封装的缓存数据类,可以有.get、.set
等方法,cryptoHelper
是封装的MD5加密库
,主要是通过MD5加密请求url、请求数据、请求类型等拼接的字符串
,通过加密后的key来获取缓存中的数据(因为拼接后的字符串太长,通过MD5加密一下,会短很多) - 为什么要单独封装一个
httpHelper
,因为axios.CancelToken.source().cancle(***)
中的信息,只能在reject中取到,为了缓存命中时,仍然能在then中获取到正确的数据
,则需要单独处理一下这个情况。
- 增加重复请求过滤功能
规则: 以最新的请求为主,即最新的重复请求,会将之前的重复请求中断掉
大概流程如下:
- 发起请求
- axios请求拦截,判断请求列表数组中,是否存在相同的请求,是?终止之前所有重复请求,否?将当次请求添加进请求数组中,最终都继续会请求
- axios响应拦截器,将当次请求从请求数组中删除
具体代码如下:
// http-helper.js
import axios from 'axios';
const http = axios.create();
const pendingRequests = [];
http.interceptors.request.use((config) => {
/**
* 为每一次请求生成一个cancleToken
*/
const source = axios.CancelToken.source();
config.cancelToken = source.token;
// .... 省略部分代码
/**
* 重复请求判断
* 同url,同请求类型判定为重复请求
* 以最新的请求为准
*/
const md5Key = cryptoHelper.encrypt(config.url + (config.method || ''));
/**
* 将之前的重复且未完成的请求全部取消
*/
const hits = pendingRequests.filter((item) => item.md5Key === md5Key);
if (hits.length > 0) {
hits.forEach((item) => item.source.cancel(JSON.stringify({
type: CANCELTTYPE.REPEAT,
data: '重复请求,以取消',
})));
}
/**
* 将当前请求添加进请求对列中
*/
pendingRequests.push({
md5Key,
source,
});
return config;
});
http.interceptors.response.use((res) => {
/**
* 不论请求是否成功,
* 将本次完成的请求从请求队列中移除
*/
// 以同样的加密方式(MD5)获取加密字符串
const md5Key = cryptoHelper.encrypt(res.config.url + (res.config.method || ''));
const index = pendingRequests.findIndex((item) => item.md5Key === md5Key);
if (index > -1) {
pendingRequests.splice(index, 1);
}
// .... 省略部分代码
});
// .... 省略部分代码
其实逻辑很简单,通过一个数组去维护请求列表即可
- 最终成果物
是用ts写的,需要使用可以改成js
由于缓存和终止重复请求,都需要用到source.cancle
,因此需要一个type值,区分是缓存命中终止,还是重复请求终止
,代码中是CANCELTTYPE
常量。
http-helper.ts
import axios, {CancelTokenSource, AxiosResponse, AxiosRequestConfig, AxiosError} from 'axios';
import Storage from './storage-helper';
import CryptoHelper from './cryptoJs-helper';
const CANCELTTYPE = {
CACHE: 1,
REPEAT: 2,
};
interface ICancel {
data: any;
type: number;
}
interface Request {
md5Key: string;
source: CancelTokenSource;
}
const pendingRequests: Request[] = [];
const http = axios.create();
const storage = new Storage();
const cryptoHelper = new CryptoHelper('cacheKey');
http.interceptors.request.use((config: AxiosRequestConfig) => {
/**
* 为每一次请求生成一个cancleToken
*/
const source = axios.CancelToken.source();
config.cancelToken = source.token;
/**
* 缓存命中判断
* 成功则取消当次请求
*/
const data = storage.get(cryptoHelper.encrypt(
config.url + JSON.stringify(config.data) + (config.method || ''),
));
if (data && (Date.now() <= data.exppries)) {
console.log(`接口:${config.url} 缓存命中 -- ${Date.now()} -- ${data.exppries}`);
source.cancel(JSON.stringify({
type: CANCELTTYPE.CACHE,
data: data.data,
}));
}
/**
* 重复请求判断
* 同url,同请求类型判定为重复请求
* 以最新的请求为准
*/
const md5Key = cryptoHelper.encrypt(config.url + (config.method || ''));
/**
* 将之前的重复且未完成的请求全部取消
*/
const hits = pendingRequests.filter((item) => item.md5Key === md5Key);
if (hits.length > 0) {
hits.forEach((item) => item.source.cancel(JSON.stringify({
type: CANCELTTYPE.REPEAT,
data: '重复请求,以取消',
})));
}
/**
* 将当前请求添加进请求对列中
*/
pendingRequests.push({
md5Key,
source,
});
return config;
});
http.interceptors.response.use((res: AxiosResponse) => {
/**
* 不论请求是否成功,
* 将本次完成的请求从请求队列中移除
*/
// 以同样的加密方式(MD5)获取加密字符串
const md5Key = cryptoHelper.encrypt(res.config.url + (res.config.method || ''));
const index = pendingRequests.findIndex((item) => item.md5Key === md5Key);
if (index > -1) {
pendingRequests.splice(index, 1);
}
if (res.data && res.data.type === 0) {
if (res.config.data) {
const dataParse = JSON.parse(res.config.data);
if (dataParse.cache) {
if (!dataParse.cacheTime) {
dataParse.cacheTime = 1000 * 60 * 3;
}
storage.set(cryptoHelper.encrypt(res.config.url + res.config.data + (res.config.method || '')), {
data: res.data.data,
exppries: Date.now() + dataParse.cacheTime,
});
console.log(`接口:${res.config.url} 设置缓存,缓存时间: ${dataParse.cacheTime}`);
}
}
return res.data.data;
} else {
return Promise.reject('接口报错了!');
}
});
/**
* 封装 get、post 请求
* 集成接口缓存过期机制
* 缓存过期将重新请求获取最新数据,并更新缓存
* 数据存储在localstorage
* {
* cache: true
* cacheTime: 1000 * 60 * 3 -- 默认缓存3分钟
* }
*/
const httpHelper = {
get(url: string, params: any) {
return new Promise((resolve, reject) => {
http.get(url, params).then(async (res: AxiosResponse) => {
resolve(res);
}).catch((error: AxiosError) => {
if (axios.isCancel(error)) {
const cancle: ICancel = JSON.parse(error.message);
if (cancle.type === CANCELTTYPE.REPEAT) {
return resolve([]);
} else {
return resolve(cancle.data);
}
} else {
return reject(error);
}
});
});
},
post(url: string, params: any) {
return new Promise((resolve, reject) => {
http.post(url, params).then(async (res: AxiosResponse) => {
resolve(res);
}).catch((error: AxiosError) => {
if (axios.isCancel(error)) {
const cancle: ICancel = JSON.parse(error.message);
if (cancle.type === CANCELTTYPE.REPEAT) {
return resolve(null);
} else {
return resolve(cancle.data);
}
} else {
return reject(error);
}
});
});
},
};
export default httpHelper;
cryptoJs-helper.ts
import cryptoJs from 'crypto-js';
class CryptoHelper {
public key: string;
constructor(key: string) {
/**
* 如需秘钥,可以在实例化时传入
*/
this.key = key;
}
/**
* 加密
* @param word
*/
public encrypt(word: string | undefined): string {
if (!word) {
return '';
}
const encrypted = cryptoJs.MD5(word);
return encrypted.toString();
}
}
export default CryptoHelper;
storage-helper.ts
class Storage {
public get(key: string | undefined) {
if (!key) { return; }
const text = localStorage.getItem(key);
try {
if (text) {
return JSON.parse(text);
} else {
localStorage.removeItem(key);
return null;
}
} catch {
localStorage.removeItem(key);
return null;
}
}
public set(key: string | undefined, data: any) {
if (!key) {
return;
}
localStorage.setItem(key, JSON.stringify(data));
}
public remove(key: string | undefined) {
if (!key) {
return;
}
localStorage.removeItem(key);
}
}
export default Storage;
**