Java后端系统学习路线--白卷项目优化(二)

460 阅读21分钟

白卷项目的10个优化事项,码云仓库地址: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算法+盐的方式来存储用户密码,我们在数据库中定义了一张用户表,如下图:

image.png

可以看到,每个用户的盐是不同的,所以说即使明文密码都是“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层全局异常处理器、登录优化、登录认证时,可以直接作为脚手架拿来使用,码云仓库地址:gitee.com/qinstudy/wj

6、参考内容

Vue + Spring Boot 项目实战(十四):用户认证方案与完善的访问拦截