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 專案實戰(十四):使用者認證方案與完善的訪問攔截