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

语言: CN / TW / HK

白卷项目的10个优化事项,码云仓库地址:https://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层全局异常处理器、登录优化、登录认证时,可以直接作为脚手架拿来使用,码云仓库地址:https://gitee.com/qinstudy/wj

6、参考内容

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