Java后端系统学习路线--白卷项目优化(二)
白卷项目的10个优化事项,码云仓库地址:http://gitee.com/qinstudy/wj
1、统一的返回格式封装
大榜:前面,我们讨论了白卷项目的前3个优化事项,接下来我们继续进行优化,主要是下面4个优化项:统一的返回格式封装、统一的Web层全局异常处理器、登录优化、登录认证之Cookie/Session。
小汪:好啊,我们一起讨论学习,共同进步!第一个优化点是统一的返回响应格式封装,感觉在接口数量比较多的情况,才会有很大作用。我一般写后端请求接口,代码是这样的:
/**
* 登出接口
* @return
*/
@ResponseBody // 该注解,表示后端返回的是JSON格式。
@GetMapping("/api/logout")
public Result logout() {
Subject subject = SecurityUtils.getSubject();
subject.logout();
log.info("成功登出");
return ResultFactory.buildSuccessResult("成功登出");
}
你看,这是我写的一个登出接口,使用ResultFactory.buildSuccessResult方法封装得到Result对象,然后返回给前端。假设,我们有100个接口,那就需要编写100次重复的return ResultFactory.buildSuccessResult("成功登出")语句,来封装得到Result对象,返回给前端。
大榜:你这个例子很不错啊。其实,对于前后端分离项目,前端与后端是通过统一的格式进行交互,比如你代码中的Result类。这样的话,就像你说的,有多少个接口,你就需要重复编写对应次数的封装语句,来封装得到Result对象,费时费力啊。
小汪:是啊,那怎么才能不编写这些重复的返回封装语句呢?
大榜:哈哈哈,我们可以使用统一的返回格式封装,这样就可以不用编写重复的封装语句了。代码是下面这样的:
package com.bang.wj.component;
import com.bang.wj.entity.enumeration.ErrorCode;
import com.bang.wj.exception.GlobalWebExceptionAdvice;
import com.bang.wj.exception.ResponseJson;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import javax.servlet.http.HttpServletRequest;
/**
* 此处约定:哪个分系统需要使用此返回数据封装的功能,就添加上自己分系统所属的Controller层的包名即可。
* 将Controller层返回的数据,统一封装为ResponseJson,然后返回给前端
*
* 注解@ControllerAdvice("com.bang.wj.controller")
* // 对controller包中,所有Controller类的HTTP响应都做统一格式封装,封装为ResponseJson
*
* @author qinxubang
* @Date 2021/6/13 12:45
*/
@Slf4j
@ControllerAdvice(basePackages = {"com.bang.wj.controller2"}) // 当前,我们只对controller2包中的HTTP响应做统一格式封装
public class ResponseJsonAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
Class<?> declaringClass = returnType.getMethod().getDeclaringClass();
return !declaringClass.isAssignableFrom(GlobalWebExceptionAdvice.class);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof ResponseJson) {
log.warn("该请求的响应,返回的是ResponseJson,无需再次封装,{}", body);
return body;
}
ResponseJson<Object> responseJson = new ResponseJson<>(ErrorCode.SUCCESS);
responseJson.setData(body == null ? "" : body);
if (request instanceof HttpServletRequest) {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
log.info("请求路径是: {}", httpServletRequest.getRequestURI());
}
log.info("将指定包中的HTTP响应,做统一ResponseJson格式封装,请求url:{}", request.getURI());
return responseJson;
}
}
你看,我们编写了一个类ResponseJsonAdvice,实现了Spring的ResponseBodyAdvice接口,重写了的beforeBodyWrite方法,逻辑是这样的:如果返回体body属于我们自定义的ResponseJson格式,就直接返回;否则,添加自定义的状态码,并对body进行封装得到ResponseJson格式。这样,就实现了统一的返回格式封装。需要注意的是,ResponseJsonAdvice类上面有个注解:
@ControllerAdvice(basePackages = {"com.bang.wj.controller2"})
这个注解中,定义了包名称,它的意思是只对com.bang.wj.controller2 包下的类,进行统一返回格式封装,其他包下的类 则不会进行统一格式封装。
小汪:我懂了。我们一般只对Http请求接口进行统一封装,也就是对xxx.controller包下的Controller类进行封装,你这个注解中定义的包名称,没问题啊。这个ResponseJsonAdvice类,如果我拿来用,只需要将@ControllerAdvice注解中的包名称修改为我自己的Http接口对应的包名称就可以了,这样就实现了统一返回格式封装。以后即使1000个接口,我也不用重复编写返回封装语句了,很香很香啊!
2、Web层的全局异常处理器
大榜:哈哈哈,小伙子很稳啊。后端接口数量比较多的情况下,使用Spring的ResponseBodyAdvice接口实现统一的返回格式封装,以后就可以少搬一会儿砖、多喝一杯茶了。
小汪:是啊。那第2个优化是统一的Web层的全局异常处理器,这个在什么需求场景下使用呢?
大榜:一般是后端的Http接口产生异常了,由我们的Web层全局异常器来对异常进行处理。
小汪:我感觉用处不大啊。你看,如果后端接口要是产生异常了,我也可以自己来处理异常,代码是这样的:
@ResponseBody // 该注解,表示后端返回的是JSON格式。
@GetMapping("/api/logout")
public Result logout() {
Subject subject = SecurityUtils.getSubject();
try {
subject.logout();
} catch (Exception e) {
log.error("登出的用户名:{};/api/logout产生异常:",subject.getPrincipal().toString(), e);
}
log.info("成功登出");
return ResultFactory.buildSuccessResult("成功登出");
}
代码中通过catch来捕获异常,然后将登出的用户名、请求url打印出来,而且为了便于排故,我还将捕获的异常打印出来了。感觉我自己通过catch来捕获和处理异常也很方便,没有必要使用全局异常处理器啊?
大榜:你这个接口产生的异常,把必要信息和异常都打印出来了,处理得很好。但如果有100个接口呢,你是不是需要自己来写100个异常处理了呢?
小汪:哦哦,是啊。和第一个优化点:使用统一的返回响应格式封装很类似啊,都是在接口比较多的需求场景下使用。那如何实现Web层的全局异常处理器呢?
大榜:其实也不难,因为Spring已经帮我们做好了Web层的全局异常处理器,代码是下面这样的:
package com.bang.wj.exception;
import com.bang.wj.component.RepeatReadFilter;
import com.bang.wj.entity.enumeration.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* Web层接口的全局异常处理器
*
* 此处加上包名称"com.bang.wj.controller",全局异常处理器则只会扫描该包路径下抛出的异常,就会导致com.bang.wj.controller2下抛出的异常不会被捕获。
* 注解@RestControllerAdvice("com.bang.wj.controller")
*
* @author qinxubang
* @Date 2021/6/12 13:54
*/
@Slf4j
@RestControllerAdvice
public class GlobalWebExceptionAdvice {
// 捕获下面的异常后,将请求的url、请求参数写入响应体中,然后返回给前端。
@ExceptionHandler(IllegalStateException.class)
public ResponseJson<RequestInfo> illegalStateExceptionHandler(HttpServletRequest request, Exception exception)
throws IOException {
String stackTrace = ExceptionUtils.getStackTrace(exception);
log.error("Web全局处理器处理异常:{}", stackTrace);
// 响应Json格式中,包装了请求参数信息; 响应的构造函数中,传入的参数为自定义的错误码ErrorCode
ResponseJson<RequestInfo> responseJson = new ResponseJson<>(ErrorCode.SERVER_ERROR);
// 给请求实体requestInfo初始化赋值
RequestInfo requestInfo = RequestJsonUtils.getRequestInfo(request);
requestInfo.setMessage(exception.getMessage());
// 将导致异常的该次请求url、请求参数、错误信息,存入响应体中
responseJson.setData(requestInfo);
return responseJson;
}
@ExceptionHandler(BaseException.class)
public ResponseJson<RequestInfo> customExceptionHandler(HttpServletRequest request, BaseException exception) throws IOException {
// 堆栈信息和错误码记录日志
String stackTrace = ExceptionUtils.getStackTrace(exception);
log.error("BaseException异常:{}", stackTrace);
// 获取异常码,存入响应体中
ResponseJson<RequestInfo> responseData = new ResponseJson(exception.getErrorCodeEnum());
/**
* 对于Post请求的流数据,由于流数据只能被读取一次,导致全局异常处理器无法获取Post请求的请求体。
* 所以我们需要解决Request中的流数据只能读取一次的问题。
* @see RepeatReadFilter
*/
RequestInfo requestInfo = RequestJsonUtils.getRequestInfo(request);
requestInfo.setMessage(exception.getMessage());
responseData.setData(requestInfo);
return responseData;
}
// 在最后一个方法将异常类型定为Exception.class,作为抛出的异常匹配不到异常方法的兜底方法
@ExceptionHandler(Exception.class)
public ResponseJson<RequestInfo> unknownExceptionHandler(HttpServletRequest request, Exception exception) throws IOException {
// 堆栈信息和错误码记录日志
String stackTrace = ExceptionUtils.getStackTrace(exception);
log.error("兜底异常:" + stackTrace);
ResponseJson<RequestInfo> responseData = new ResponseJson(ErrorCode.SYSTEM_UNKNOWN_ERROR);
RequestInfo requestInfo = RequestJsonUtils.getRequestInfo(request);
requestInfo.setMessage(exception.getMessage());
responseData.setData(requestInfo);
return responseData;
}
}
你看,我们使用@RestControllerAdvice注解来标识GlobalWebExceptionAdvice类为Web层的全局异常器类;然后使用@ExceptionHandler(IllegalStateException.class)注解,来获取web层抛出的IllegalStateException异常。进一步,我们在illegalStateExceptionHandler方法中,对Web层抛出的IllegalStateException异常进行了统一处理,首先打印异常堆栈,然后将状态码封装为ResponseJson格式返回给前端。
小汪:榜哥,你封装的ResponseJson格式,除了异常的状态码,还有个RequestInfo对象,我猜它是前端的请求参数等信息?
大榜:猜得很对,RequestInfo类的定义是这样的:
package com.bang.wj.exception;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* @author qinxubang
* @Date 2021/6/12 14:30
*/
@ApiModel("Web层产生异常时的前端请求信息")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RequestInfo {
@ApiModelProperty("请求参数")
private String parameter;
@ApiModelProperty("请求url路径")
private String url;
@ApiModelProperty("异常的消息内容")
private String message;
}
根据传入的HttpServletRequest对象,来获取请求参数信息,然后封装得到RequestInfo对象,其实现类如下:
package com.bang.wj.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
/**
* 需要包装request,因为controller层读取了一次request,流里面的内容会被清空
* */
@Slf4j
public class RequestJsonUtils {
public static RequestInfo getRequestInfo(HttpServletRequest request) throws IOException {
RequestInfo requestInfo = new RequestInfo();
String parameter = getRequestParameter(request);
String url = request.getRequestURL().toString();
requestInfo.setParameter(parameter);
requestInfo.setUrl(url);
return requestInfo;
}
/***
* 获取request中json字符串的内容
*/
private static String getRequestParameter(HttpServletRequest request) throws IOException {
String submitMehtod = request.getMethod();
// GET请求
if (HttpMethod.GET.name().equals(submitMehtod) ) {
List<String> params = new ArrayList<>();
Enumeration<String> parameterNames = request.getParameterNames();
while (parameterNames.hasMoreElements()) {
params.add(parameterNames.nextElement());
}
if(params.size() == 0) {
return "[]";
}
// 将ISO_8859_1编码的字符串,转成UTF_8编码的字符串
return new String(request.getQueryString().getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8).replaceAll("%22", """);
} else { // POST
// return "Post请求属于流数据";
return getRequestPostStr(request);
}
}
/**
* 获取 post 请求内容
*/
private static String getRequestPostStr(HttpServletRequest request) throws IOException {
byte[] buffer = getRequestPostBytes(request);
String charEncoding = request.getCharacterEncoding();
if (charEncoding == null) {
charEncoding = StandardCharsets.UTF_8.name();
}
return new String(buffer, charEncoding);
}
/**
* 获取 post 请求的 byte[] 数组
*/
private static byte[] getRequestPostBytes(HttpServletRequest request)
throws IOException {
int contentLength = request.getContentLength();
if(contentLength<0){
return null;
}
byte[] buffer = new byte[contentLength];
for (int i = 0; i < contentLength;) {
int readlen = -1;
try {
readlen = request.getInputStream().read(buffer,i,contentLength - i);
} catch (Exception e) {
log.error("请求对象request中,读取POST请求的流数据失败,{}",readlen, e);
}
if (readlen == -1) {
break;
}
i += readlen;
}
return buffer;
}
}
小汪:我懂了,当Web层产生异常时,异常处理器会将前端请求信息封装到RequestInfo对象,然后进一步封装为ResponseJson格式返回给前端。这样,当后端有异常时,前端可以通过查看ResponseJson,来检查自己输入的url、请求入参是否存在问题,并结合后端返回的异常消息,来进一步判断为什么 前端请求会导致后端接口产生异常。
大榜:是啊,你看,当前端去访问后端的“/api/books/my”接口,当接口产生异常时,异常处理器返回的ResponseJson对象如下:
{
"code": "3000",
"msg": "服务器内部异常,请联系管理员",
"data": {
"parameter": "page=1&pageSize=5&startDate=2018-05-15%2015%3A00%3A00&endDate=2022-12-30%2016%3A00%3A00&field=bookId&sort=DESC",
"url": "http://localhost:8443/api/books/my",
"message": "每页记录数为5,太小!"
}
}
小汪:当前端发送请求给后端,然后 后端返回上面的异常信息给前端,我要是前端人员,可以得到请求入参(parameter)、请求url(url)、后端返回给前端的异常信息(message),然后就可以推断出,前端传入的每页记录数太小,导致后端抛出了异常。所以,在接下来的前端请求中,前端只需要将每页记录数调大一点就可以了。
大榜:网上写的Web层的全局异常处理器,一般只对状态码进行封装,得到ResponseJson响应格式,响应格式中不包含对RequestInfo请求对象信息,这就会导致前端得到的响应格式如下:
{
"code": "3000",
"msg": "服务器内部异常,请联系管理员",
"data": {
}
}
你看,后端返回的响应格式中,不包含请求信息。前端拿到上面的响应数据后,只知道状态码为3000,提示消息为"服务器内部异常,请联系管理员"。如果前端人员看到上面的响应数据,就只能联系后端人员了。
小汪:有道理啊。如果后端产生异常,只返回了状态码,并没有将请求信息返回给前端,那前端得到的信息就很少,那就只能找后端麻烦了。
大榜:小伙子,悟性很不错啊。但这个Web层全局异常处理器GlobalWebExceptionAdvice只能获取GET请求中的入参信息,无法获取Post请求中的请求体数据,这是为什么呢?
小汪:榜哥,我测试了GET请求和POST请求,确实是你说的结果。对于GET请求,我编写的接口是"api/books/my",当前端输入的每页数量pageSize为5时,全局异常处理器返回的响应数据如下:
{
"code": "3000",
"msg": "服务器内部异常,请联系管理员",
"data": {
"parameter": "page=1&pageSize=5&startDate=2018-05-15%2015%3A00%3A00&endDate=2022-12-30%2016%3A00%3A00&field=bookId&sort=DESC",
"url": "http://localhost:8443/api/books/my",
"message": "每页记录数为5,太小!"
}
}
而对于POST请求,代码是这样的:
@ApiOperation(value = "用户登录的请求")
@PostMapping(value = "api/login")
@ResponseBody
public Result login(@RequestBody UserDto requestUser, HttpServletRequest request, HttpSession session) throws IOException {
if (requestUser == null) {
throw new BaseException(ErrorCode.CM_USER_ACCOUNT_ERROR);
}
......
}
返回的响应数据ResponseJson中,请求参数parameter为空,如下所示:
{
"code": "1000",
"msg": "账号不存在或密码错误",
"data": {
"parameter": "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
"url": "http://localhost:8443/api/login",
"message": ""
}
}
你看,当POST请求的接口产生异常时,请求入参parameter为空,感觉好奇怪啊,这是什么原因?
大榜:你的测试结果是对的。因为POST请求中的请求体是流数据,而Servlet中流数据的特点是只能被读取一次,第二次读取时为空。而全局异常处理器读取请求参数,属于第二次读取流数据,所以读取的请求体为空,最终导致返回的响应格式中parameter参数为空。
我们还是举个栗子把,当前端发送“/login”的POST请求,请求入参(也就是请求体)是UserDto格式,后端Spring MVC先接收请求体,第一次读取 请求对象request中的请求体流数据,解析得到userDto对象;然后我们编写的后端逻辑会去校验用户登录是否合法,当不合法时,抛出异常,此时异常被全局异常处理器捕获,它第二次读取请求对象request中的请求体流数据,由于流数据只能被读取一次,所以异常处理器读取到的流数据为空,从而导致响应格式中parameter参数为空。
小汪:举的栗子,我听懂了。照你这么说,只要是POST请求的接口,带有请求体流数据,如果产生异常了,异常处理器返回的请求参数中parameter都为空。那怎么解决呢,我完全没有思路啊?
大榜:不着急,我们先把思路捋清楚。问题的本质是请求对象request中的流数据只能被读取一次,反过来思考一下,我们能不能将request对象包装一下,让request对象中的流数据可以被多次读取呢?我们按照这个解决思路,定义一个RepeatReadFilter过滤器,将所有的HttpServletRequest请求都包装成RequestWrapper类,RequestWrapper类中定义了一个实例变量body,body专门用来存储请求对象中的流数据,代码是下面这样的:
package com.bang.wj.entity;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;
/**
* 可重复读的过滤器RepeatReadFilter + RequestWrapper
* 解决Post请求中请求体body只能读取一次的问题。说明:Get请求是放在请求行中,不属于Request对象的流数据,所以Get请求中的参数可以一直往下传递。
*
* todo RequestWrapper包装类对象,Post请求中的请求体可以被多次读取,是因为其实例变量body吗?
*/
public class RequestWrapper extends HttpServletRequestWrapper {
// 实例变量,不是静态变量。也就是每个requestWrapper对象都会有一个实例变量body
private final String body;
public RequestWrapper(HttpServletRequest request) {
super(request);
StringBuilder stringBuilder = new StringBuilder();
BufferedReader bufferedReader = null;
InputStream inputStream = null;
try {
inputStream = request.getInputStream();
if (inputStream != null) {
bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
char[] charBuffer = new char[128];
int bytesRead = -1;
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
stringBuilder.append(charBuffer, 0, bytesRead);
}
} else {
stringBuilder.append("");
}
} catch (IOException ex) {
} finally {
if (inputStream != null) {
try {
inputStream.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
if (bufferedReader != null) {
try {
bufferedReader.close();
}
catch (IOException e) {
e.printStackTrace();
}
}
}
body = stringBuilder.toString();
}
@Override
public ServletInputStream getInputStream(){
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
ServletInputStream servletInputStream = new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() {
return byteArrayInputStream.read();
}
};
return servletInputStream;
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
public String getBody() {
return this.body;
}
}
可重复读的过滤器RepeatReadFilter,代码是这样的:
package com.bang.wj.component;
import com.bang.wj.entity.RequestWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 拦截所有请求,将ServletRequest包装成自定义的RequestWrapper,为了解决Request中的流数据只能读取一次的问题
* 说明:Request对象中,GET请求的参数是放在请求行,不属于流数据;POST请求中的请求体(即json格式的数据)属于流数据。
* 需要使用@ServletComponentScan注解,将@WebFilter的bean对象注入到Spring容器中。
*/
@Slf4j
@Order(1)
@WebFilter(filterName="repeatReadFilter",value={"/*"})
public class RepeatReadFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) {
log.info("开始初始化过滤器:{},为了解决Request中的流数据只能读取一次的问题", filterConfig.getFilterName());
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
ServletRequest requestWrapper = null;
// 当为Http请求时,对请求进行包装,得到RequestWrapper包装对象
if(servletRequest instanceof HttpServletRequest) {
requestWrapper = new RequestWrapper((HttpServletRequest) servletRequest);
}
if(requestWrapper == null) {
// 放行原生的servletRequest请求
filterChain.doFilter(servletRequest, servletResponse);
} else {
// 将包装类requestWrapper作为请求对象,并放行包装之后的请求
filterChain.doFilter(requestWrapper, servletResponse);
}
}
@Override
public void destroy() {
log.info("RepeatReadFilter过滤器销毁");
}
}
你看,上面的代码中,当请求属于Http请求时,先将请求包装为requestWrapper对象,然后将请求对象request中的流数据赋值给实例变量body,它专门用来存储请求对象request中的流数据。例如POST请求接口中的请求体,经过这样的包装后,每次POST请求的请求体流数据,我们都可以根据requestWrapper对象中的body得到流数据,这样就实现了请求对象中的流数据可以被多次读取。按照这种思路的话,我们的异常处理器应该就可以读取到请求体中数据,然后返回给前端了。
小汪:那我来测试一下。访问登录接口,输入错误的用户名和密码,登录接口抛出异常,由全局异常处理器进行处理,返回的ResponseJson格式如下:
{
"code": "1000",
"msg": "账号不存在或密码错误",
"data": {
"parameter": "{\n "id": 0,\n "password": "1234",\n "username": "aaa"\n}",
"url": "http://localhost:8443/api/login",
"message": "java.lang.IllegalStateException: UserDto(id=0, username=aaa, password=1234)"
}
}
你看,当接口产生异常时,返回的请求参数parameter中有数据了,不再是空数据了。
大榜:厉害呀。所以说全局异常处理器中,如果需要返回请求信息,我们需要编写RepeatReadFilter过滤器来对所有的HTTP请求进行包装,并在包装类RequestWrapper定义一个实例变量body,用来存储请求对象request中的流数据。因为body实例变量中存储的是流数据,body可以被多次访问,所以流数据也可以被多次访问了。
小汪:嗯嗯,是滴了。榜哥,你这个全局处理器叫做Web层的全局处理器,是不是还有非Web层的全局处理器呢?
大榜:哈哈哈,被你发现了,采用对比差异的方式学习,确实更容易发现问题。我们自定义的Web层全局异常处理器只能捕获Web层接口产生的异常,标识全局处理器的注解@RestControllerAdvice的包名称是下面这样:
import org.springframework.web.bind.annotation.RestControllerAdvice;
你看,RestControllerAdvice注解位于org.springframework.web包下,很显然它只针对Web层接口产生的异常。
而非Web层的异常处理器是处理 非Web层产生的异常,非Web层 指 不是Web层接口,比如我们从消息中间件的队列中取出一条消息,处理该消息时,抛出异常了。这个时候,Web层的全局异常处理器GlobalWebExceptionAdvice类是无法捕获并处理该异常的,于是非Web层的异常处理器就派上用场了。
小汪:我懂了,Web层的全局异常处理器只针对Web层的接口产生的异常,非Web层的异常处理器是针对非Web层产生的异常。那如何实现非Web层的全局异常处理器呢?
大榜:这个不是本文的重点,我们先放到以后的文章中讨论。
小汪:榜哥,非Web层的全局异常处理器实现起来还是有难度的,需要自己写异常注解、异常捕获的逻辑等等。榜哥,你是不是 不会做吗?
大榜:哈哈哈,目前还在开发中,只实现了一部分逻辑,确实不会哟!要不我们讨论第3个优化点:登录认证。
3、登录优化
小汪:非Web层的全局异常处理器,这篇文章到时候别忘了,我可记着呢,哈哈哈。白卷中的登录认证,作者一开始实现得很简单,直接比较数据库的明文密码;之后,作者引入了Shiro安全框架,来做登录认证,但Shiro封装得太好了,导致我对登录认证的本质还是云里雾里啊。
大榜:一开始我也是很懵逼,于是我自己实现了登录认证逻辑,思路就清晰多了。
小汪:那我们讨论下把。
大榜:好的啊。白卷项目最开始的登录认证,数据库中直接存储明文密码,安全性太低。所以,为了保证用户信息不被泄露,最常规的做法是使用MD5算法+盐的方式来存储用户密码,我们在数据库中定义了一张用户表,如下图:
可以看到,每个用户的盐是不同的,所以说即使明文密码都是“123456”,经过MD5算法和加盐的方式计算加密后,得到的密文密码是完全不一样的,这样也保证了每个用户的信息安全。
小汪:数据库的用户表中,每个用户拥有不同的盐,即使用户表被黑客攻击了,还需要针对每个用户进行破解,难度可想而知。榜哥,你是怎么实现登录认证的呢?
大榜:其实也不难,思路是这样的,首先我们得有一个注册用户信息的接口,其核心功能是随机生成一定长度的盐,然后调用MD5算法组件库,将明文密码和盐作为参数,生成密文密码,然后存储在用户表中。代码是这样的:
@ApiOperation(value = "用户注册的请求")
@PostMapping("api/register")
@ResponseBody
public Result register(@RequestBody UserDto userDto) {
String username = userDto.getUsername();
String password = userDto.getPassword();
username = HtmlUtils.htmlEscape(username);
userDto.setUsername(username);
User user = new User();
BeanUtils.copyProperties(userDto, user);
boolean exist = userService.isExist(username);
if (exist) {
String tipMessage = "该用户名已被占用";
return new Result(400, tipMessage);
}
//生成盐,默认长度16位
String salt = new SecureRandomNumberGenerator().nextBytes().toString();
// 使用MD5算法和盐,生成加密的密码
String encodePassword = MD5Utils.formPassToDBPass(password, salt);
// 存储用户信息,包括盐、Hash之后的密码
user.setSalt(salt);
user.setPassword(encodePassword);
userService.add(user);
// 应该返回用户输入的用户名,让用户进一步记住自己的用户名
return ResultFactory.buildSuccessResult(userDto.getUsername());
}
当前端访问注册接口,输入注册的用户名和密码后,后端就生成了密文密码,然后存储在数据库中。
接下来,后端编写登录接口,核心逻辑是,先根据用户名去数据库中查询用户对象是否存在,若不存在,直接返回“账号不存在或密码错误”。若用户对象存在,则根据用户对象获取数据库中对应的密文密码dbPass和盐,然后以前端输入的用户明文密码为参数1,以数据库中用户对象的盐作为参数2,利用MD5算法,得到计算后的密码calcPass;之后,我们比对数据库密码(dbPass)和计算的密码(calcPass),若两者不相等,则返回“账号不存在或密码错误”;若相等,则返回“登录成功,欢迎回来!”。代码是这样的:
public Result login(UserDto requestUser, HttpServletRequest request, HttpSession session) throws IOException {
if (requestUser == null) {
throw new BaseException(ErrorCode.REQUEST_PARAMETER_ERROR, "WJ.UserService.login");
}
String username = requestUser.getUsername();
username = HtmlUtils.htmlEscape(username);
String formPass = requestUser.getPassword();
// 根据用户名,去数据库中查找是否存在该用户
User user = getByName(username);
if (user == null) {
throw new BaseException(ErrorCode.CM_USER_ACCOUNT_ERROR);
}
// 若数据库中存在该用户名对应的用户信息,则去验证密码
String dbPass = user.getPassword();
String dbSalt = user.getSalt();
// 利用MD5算法,对表单的密码做MD5加密,计算得到密文,并与数据库的密文进行比较
String calcPass = MD5Utils.formPassToDBPass(formPass, dbSalt);
// 将计算出来的密码与数据库的密钥做比较
if(!calcPass.equals(dbPass)) {
log.error("密码错误,表单密码:{};数据库密码:{}", formPass, dbPass);
throw new BaseException(ErrorCode.CM_USER_ACCOUNT_ERROR);
}
// 当部署多个节点时,需要做基于Redis的分布式Session方案,用来解决用户状态信息丢失的问题。
session.setAttribute("loginUser", user);
return new Result(200, "欢迎回来!");
}
小汪:我看懂了。首先用户注册时,会对明文密码做MD5算法得到密文密码,存入用户表中;然后用户登录时,后端接收的用户输入的明文密码,使用盐+MD5算法 得到计算值,并将计算值与数据库用户表的密码进行比较。若两者相等,说明用户名、密码校验通过;若不相等,则用户名或密码错误。
大榜:登录功能没那么难,是吧。其实白卷项目中后面使用Shiro做登录,本质上也是这么实现的,只是Shiro都封装好了,导致我们搞不清楚登录的细节罢了。
4、登录认证之Cookie/Session
4.1、Cookie/Session机制
小汪:登录认证,我记得最简单的认证方法是,前端在每次请求时都加上用户名和密码,交由后端验证。但这种方法有2个缺点:1)需要频繁查询数据库,导致服务器压力较大;2)安全性问题,如果信息被截获,攻击者就可以一直利用 用户名、密码进行登录。
大榜:是的啊,为了在某种程度上解决上述的问题,有2种改进方案:第一种是Cookie/Session,第二种token令牌。通俗地讲,session表示会话机制,可以管理用户状态,比如控制会话存在时间,在会话中保存属性等。
第二种改进方案是token令牌,它本质上是一个字符串序列,携带了一些信息,比如用户id、过期时间等,然后通过签名算法防止伪造,在Web领域最常见的token令牌解决方案是JWT,具体实现可以参照官网。
本文,我们只讨论登录认证的第一种方案,即采用Cookie/Session,来实现用户的登录认证。
小汪:哈哈哈,榜哥只讨论Cookie/Session,不讨论token令牌,又给自己挖坑了。
大榜:没事儿,以后咱两一块填坑嘛。咱们言归正传哈,Cookie/Session机制的特点:
服务器接收第一个请求时,生成session对象,并通过响应头告诉客户端在cookie中放入sessionId;客户端之后发送请求时,会带上包含了sessionId的cookie,然后服务器通过sessionId获取session对象,进而得到当前用户的状态(是否登录)等信息。
小汪:也就是说,客户端只需要在第一次登录的时候发送用户名和密码,之后 只需要在发送请求时带上sessionId,服务器就可以验证用户是否登录了。
大榜:说白了,登录认证是为了保存登录状态,一个很简单的实现思路是这样的:我们可以把用户信息存在 Session 对象中(当用户在应用程序的 Web 页面之间跳转时,存储在 Session 对象中的变量不会丢失),这样在访问别的页面时,可以通过判断是否存在用户变量来判断用户是否登录。我们在用户登录时,当校验用户名、密码通过后,先把用户变量存入Session对象中,代码是下面这样的:
@PostMapping(value = "api/login")
@ResponseBody
public Result login(@RequestBody UserDto requestUser, HttpServletRequest request, HttpSession session) throws IOException {
User user = userService.get(username, requestUser.getPassword());
// 校验用户信息通过后,将用户对象存入session中
// 当部署多个节点时,需要做基于Redis的分布式Session方案,用来解决用户状态信息丢失的问题。
session.setAttribute("loginUser", user);
return new Result(200, "欢迎回来!");
}
小汪:那接下来,是不是要去实现 访问别的页面时,可以通过判断是否存在用户变量来判断用户是否登录?
大榜:是滴了。接下来,我们要完善登录功能,需要限制未登录状态下对核心功能页面的访问。登录拦截可以由多种方式来实现,我们首先讨论下后端拦截器的实现。
4.2、后端拦截器
大榜:一般而言,后端拦截器一般用于将前后端一体化的项目,也就是html、js和java源代码都在一个项目中,后端涉及到页面本身的内容。而前后端分离的意思是前后端之间通过 RESTful API 传递 JSON 数据进行交流,后端是不涉及页面本身的内容。关于前后端分离和前后端一体化的区别,可以参考这篇文章:前端路由与登录拦截器。
本节,我们主要是学习后端拦截器的思路和代码实现,首先自定义一个登录拦截器LoginInterceptor类中,逻辑为:校验用户是否登录,若登录,则放行;若未登录,则拦截。代码是下面这样的:
String requestURI = httpServletRequest.getRequestURI();
log.info("preHandle拦截的请求路径是: {}",requestURI);
//登录检查逻辑
HttpSession session = httpServletRequest.getSession();
User loginUser = (User) session.getAttribute("loginUser");
if(loginUser != null){
// 放行
log.info("用户已登录,拦截器直接放行:{};{};{}", loginUser.getUsername(), requestURI, session);
return true;
} else {
log.error("认证不通过,请先登录。{}", session);
// 前后端项目整合在一起,下面这行代码,才能重定向到登录页面
httpServletResponse.sendRedirect("login");
return false;
}
接着,我们将登录拦截器LoginInterceptor类,加入到Spring容器中,让这个拦截器生效,代码是这样的:
package com.bang.wj.config;
import com.bang.wj.interceptor.LoginInterceptor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableMBeanExport;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* 将写好的拦截器LoginInterceptor,配置到这个Web项目中
* 我们访问一个 URL,会首先通过 MyWebConfigurer 判断是否需要拦截;
* 如果需要,才会触发拦截器LoginInterceptor,根据我们自定义的逻辑进行再次判断。
* @author qinxubang
* @Date 2021/5/1 10:19
*/
@Configuration
public class MyWebConfigurer implements WebMvcConfigurer {
// 注入登录的拦截器到Bean
@Bean
public LoginInterceptor getLoginInterceptor() {
return new LoginInterceptor();
}
/**
* 允许跨域的cookie
* @param registry
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowCredentials(true)
.allowedOrigins("http://localhost:8080")
.allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")
.allowedHeaders("*");
}
// 只放行 /index.html、swagger相关的资源、容器自带的/error请求 ,其他的url请求都会被拦截
// 因为注册、登录、登出功能 是不需要被拦截,所以我们对这3个请求进行放行
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 若没有取消对“/index.html”的拦截,则重定向到“/index.html”会再次触发LoginInterceptor拦截器,
// 从而再次重定向到“/login”,引发重定向次数过多的问题。
// registry.addInterceptor(getLoginInterceptor()).addPathPatterns("/**");
registry.addInterceptor(getLoginInterceptor()).addPathPatterns("/**").
excludePathPatterns("/index.html", "/swagger-resources/**", "/webjars/**", "/swagger-ui.html",
"/error/**", "/error", "/api/books/my", "/api/login", "/api/logout", "/api/register");
}
}
小汪:我懂了,登录拦截器的逻辑是这样的:当用户访问URL,检查是否为登录页面,如果是登录页面 则不拦截;如果用户访问的不是登录页面,则检测用户是否已登录,若未登录,则重定向到到登录页面。不过我对代码有个问题,LoginInterceptor类中的这行代码:
User loginUser = (User) session.getAttribute("loginUser");
我怎么感觉,你实现的登录认证只能用于单个用户的登录,如果多个用户同时登录和访问服务器,你的代码应该有问题把?
大榜:先说结论把,当多个用户同时做登录认证,代码也不会有问题。你说的这个问题,我之前也疑惑过,你看,登录接口中,当用户登录成功后,我们将用户变量放入到session中,代码如下:
session.setAttribute("loginUser", user);
然后,假设有2个浏览器,模拟2个用户进行登录,这2个用户表示不同的会话,所以这2个用户的session对象是不一样的,我们称之为session1、session2,session1中保存了用户1的信息,session2保存了用户2的信息。
小汪:那之后呢?
大榜:接下来,用户1去请求图书管理页面,用户2去请求用户管理页面,我们的登录拦截器发现这2个请求都不是登录请求,于是检测用户是否登录。检测用户是否登录的代码,是这样的:
//登录检查逻辑
HttpSession session = httpServletRequest.getSession();
User loginUser = (User) session.getAttribute("loginUser");
if(loginUser != null){
// 放行
log.info("用户已登录,拦截器直接放行:{};{};{}", loginUser.getUsername(), requestURI, session);
return true;
} else {
log.error("认证不通过,请先登录。{}", session);
// 前后端项目整合在一起,下面这行代码,才能重定向到登录页面
httpServletResponse.sendRedirect("login");
return false;
}
首先要明确一点:对于每个请求,httpServletRequest对象是不一样的。也就是说用户1去请求图书管理页面,对应是httpServletRequest对象;用户2去请求用户管理页面,对应的是另一个不同的httpServletRequest对象。
小汪:我懂你的意思,每个请求,产生的httpServletRequest对象是不一样的。
大榜:这一点说清楚之后,我们接着往下说:对于用户1去请求图书管理页面,httpServletRequest对象去获取用户1对应的session1,然后去查找是否存在用户信息,显然存在,于是放行图书管理页面的请求。
对于用户2去请求用户管理页面,用户2的httpServletRequest对象去获取用户2的session2,也能从session2中找到用户2的信息,于是也放行用户管理页面的请求。
小汪:听你这么一解释,多个用户同时做登录认证,代码应该也不会有问题了。
大榜:是啊,如果你想验证一下,可以把在登录拦截器的逻辑中,添加打印session的语句,看看这2个用户的session对象是否相等?
小汪:这2个session对象,应该是不相等的,毕竟是2个不同的会话。对了,榜哥,这个简单的登录认证是基于Cookie/Session来实现的,但Session是存放在服务器的内存中的,那么问题来了,如果有多个服务器时,因为用户变量信息是存放在每个服务器的内存中,我们是不是要做服务器间的Session同步,或者使用共享内存来存储Session啊?
大榜:你考虑得对,如果白卷项目部署多个节点时,用户变量信息可能在不同的服务器上,为了保证用户信息的一致性,我们需要做Session同步或者共享Session。业界比较好的做法是使用共享内存来存储Session信息,一般采用基于Redis的共享内存方案,也就是说将用户变量信息都存储在Redis中。这个我们后面在填坑,哈哈哈。
小汪:好啊,咱们又挖了一个坑。不说了,到饭点了,咱们干饭去啊!
5、总结
承接上篇博客对白卷项目的前3个优化项,小汪和大榜继续进行了优化讨论,讨论了4个优化项:统一返回格式封装、统一的Web层全局异常处理器、登录优化、登录认证之Cookie/Session。针对优化事项,设想了需求场景,并进行了专项优化,然后做了代码实战演示。
这4个优化事项对应的代码,我放在了码云仓库,大家为团队引入 统一返回格式封装、Web层全局异常处理器、登录优化、登录认证时,可以直接作为脚手架拿来使用,码云仓库地址:http://gitee.com/qinstudy/wj