接口返回大量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="60000URIEncoding="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.jsimport 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.jsimport 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;
**
