Spring Boot 接口層公共能力抽取

在先後端分離的主流架構下,前端代碼和後端邏輯主要依靠已約定的格式進行交互。在這一前提下,若是後端代碼沒有進行必定的配置,就很容易出現大量重複代碼。本文以 Spring Boot 爲例,記錄一些能夠減小冗餘代碼的方案。

1. 使用 Filter 提供跨域支持

先後端分離後,若是不採用相同域名,跨域即是首先須要解決的問題。關於跨域方案,先前撰寫的文章中有比較詳細的方案羅列:跨域解決方案 - DB.Reid - SegmentFault 思否前端

這裏介紹在 SpringBoot 中採用 Filter 方式實現跨域的代碼:java

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsEnableFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String domain = httpServletRequest.getHeader("Origin");
        String method = httpServletRequest.getMethod();
        httpServletResponse.setHeader("Access-Control-Allow-Origin", domain);
        httpServletResponse.setHeader("Access-Control-Allow-Methods", method);
        httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");
        httpServletResponse.setHeader("Access-Control-Allow-Headers",
                "Client-Info, Captcha, X-Requested-With, Authorization, Content-Type, Credential, X-XSRF-TOKEN");

        if (StringUtils.equalsIgnoreCase(httpServletRequest.getMethod(), "OPTIONS")) {
            httpServletResponse.setStatus(HttpServletResponse.SC_OK);
        } else {
            chain.doFilter(request, response);
        }
    }

    @Override
    public void destroy() {
    }
}

注:因爲瀏覽器對 * 通配符有各類限制,於是這裏採用的方式是先獲取請求的方法類型和域名,再在 OPTION 響應中容許相同的內容。若是是線上服務,建議指定固定的前端域名。git

2. 使用 ResponseBodyAdvice 處理全局返回值格式

先後端分離後,接口返回的數據須要有更強的表現力,以便前端可以進行更多提高用戶體驗的處理。github

通常狀況下,對於正常狀況,和後端可預期的提示性錯誤,建議返回的 HTTP 狀態碼所有爲 2xx( 當遇到某些因 BUG 致使的異常時,再返回 5xx 錯誤以表示是由後端代碼致使的問題 )。而把帶有語義的狀態類型標識符放在響應體 JSON 的某個字段中。web

目前比較經常使用的響應類型爲:spring

{
    "status_code":0,
    "data":{

    },
    "message":""
}

2.1 定義狀態碼枚舉類

關於返回格式中 status_code 的設定,這裏建議使用 0 表示正常狀況;大於 0 的數字表示需前端額外處理的狀況,好比跳轉操做;小於 0 的數字表示異常。數據庫

@Getter
public enum StatusCode implements Constant {

    SUCCESS(0, "success", "成功"),

    ERROR(-1, "unknown error", "未知異常"),
    NO_PERMISSION(-2, "no permission", "無權限訪問"),
    NOT_FOUND(-3, "api not found", "接口不存在"),
    INVALID_REQUEST(-4, "invalid request", "請求類型不支持或缺乏必要參數"),
    ;

    StatusCode(Integer value, String name, String cnName) {
        this.value = value;
        this.name = name;
        this.cnName = cnName;
    }

    private Integer value;
    private String name;
    private String cnName;
}

如上述代碼所示,枚舉類中包含三個字段,其中,value 用於狀態碼惟一標識,namecn_name 可做爲文本提示賦值給返回值對象的 message 字段。json

其中,value 能夠根據業務狀況進行合理的組織,好比 1xxxxx 表示用戶類業務異常;2xxxxx 表示郵件短信類業務異常等。這種組織方式更易於錯誤定位和排查。segmentfault

2.2 定義返回格式類

按照預期的返回格式定義類:後端

@Data
public class SimpleResponse {

    protected Integer statusCode;
    protected Object data;
    protected String message;
    
    public SimpleResponse(StatusCode statusCode, Object data, String message) {
        if (message == null) message = "";
        this.statusCode = statusCode.getValue();
        this.data = data;
        this.message = message;
    }
}

一樣的,咱們也能夠定義正常響應類和錯誤響應類:

@Data
@EqualsAndHashCode(callSuper = false)
public class SuccessResponse extends SimpleResponse {

    public SuccessResponse() {
        super(StatusCode.SUCCESS, null, "成功");
    }

    public SuccessResponse(Object data) {
        super(StatusCode.SUCCESS, data, "成功");
    }
}
@Data
@EqualsAndHashCode(callSuper = false)
public class ErrorResponse extends SimpleResponse {

    public ErrorResponse() {
        this(StatusCode.ERROR);
    }

    public ErrorResponse(StatusCode statusCode) {
        this(statusCode, null, statusCode.getCnName());
    }
}

注:接口返回的字段鍵通常爲下劃線,而 Java 對象屬性名通常爲駝峯體。在 Spring Boot 中,須要增長一些配置以實現這一轉換過程。這一部分會在後文中進行介紹。

2.3 配置 ResponseBodyAdvice

接下來,咱們添加一個配置,對全部 Controller 的返回值進行封裝,將其變成咱們想要的返回格式。

@ControllerAdvice
public class RestResponseConfiguration implements ResponseBodyAdvice {

    private static final Class[] annotations = {
            RequestMapping.class,
            GetMapping.class,
            PostMapping.class,
            DeleteMapping.class,
            PutMapping.class
    };

    /**
     * 須要限定方法,以便排除 ExceptionHandler 中的返回值
     *
     * @param returnType
     * @param converterType
     * @return
     */
    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {

        AnnotatedElement element = returnType.getAnnotatedElement();
        return Arrays.stream(annotations).anyMatch(annotation -> annotation.isAnnotation() && element.isAnnotationPresent(annotation));
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {

        // 默認返回成功響應
        // 錯誤響應由 exception -> @ControllerAdvice exceptionHandler  的方式響應
        response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
        return new SuccessResponse(body);
    }
}

這樣一來,全部 Controller、Service 等業務代碼返回的對象就無需進行多餘的格式封裝工做了。

注:採用上述代碼只會對正常結果進行處理,而要對異常狀況進行格式化封裝,則須要其餘一些步驟。

3. 使用 ControllerAdvice 處理業務異常

通常思路下,咱們須要對可能出現異常的地方進行捕獲,而後設定單獨的處理邏輯,返回特定的對象給調用者,以便前端可以收到對應的響應數據。

這一過程太過繁瑣,且須要在業務代碼中摻雜入許多無心義的分支代碼。

Spring Boot 容許咱們使用 @ControllerAdvice 處理異常。那麼,咱們就能夠在業務代碼處理的任一一個調用類中直接拋出運行時異常,而後利用上述配置統一處理。

3.1 添加 @ControllerAdvice

@Slf4j
@RestController
@ControllerAdvice
public static class RuntimeExceptionHandler {

    /**
     * 缺省運行時異常
     *
     * @param exception
     * @return
     */
    @ExceptionHandler(RuntimeException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse runtimeException(RuntimeException exception) {

        log.error("Spring Boot 未知錯誤", exception);
        return new ErrorResponse(StatusCode.ERROR);
    }
}

經過上述配置,當業務代碼中須要拋出異常時,能夠直接 throw new RuntimeException()

3.2 組織可預期的業務異常

在實際業務中,不少 「異常」 是先後端均可以預期的。好比用戶上傳的文件數量超過了限制、在禁止變動時期進行了相關操做等。這類異常也不一樣於正常狀況,須要後端進行檢驗並返回不一樣於正常狀況的響應值。

此時,咱們能夠自定義一些業務運行時異常,以便也可使用 ControllerAdvice 方式統一進行處理:

首先,咱們定義一個基礎業務異常類。定義基礎類的好處在於,咱們能夠利用繼承關係對這些業務異常統一進行處理:

@Data
@EqualsAndHashCode(callSuper = false)
public class BusinessException extends RuntimeException {

    private StatusCode exceptionCode;

    public BusinessException(StatusCode exceptionCode, String message) {
        super(message);
        this.exceptionCode = exceptionCode;
    }

    public BusinessException(StatusCode exceptionCode) {
        this(exceptionCode, exceptionCode.getCnName());
    }
}

接下來,咱們定義某一場景下的業務異常,好比用戶在同類申請單未完結的狀況下、又提交了一個申請的異常:

public class UnfinishedApplicationExistsException extends BusinessException {

    private static final StatusCode statusCode = StatusCode.UNFINISHED_APPLICATION_EXISTS;

    public UnfinishedApplicationExistsException() {
        super(statusCode);
    }
}

上述 UNFINISHED_APPLICATION_EXISTS 枚舉類的內容是:

UNFINISHED_APPLICATION_EXISTS(-12345, "unfinished application exists", "相同類型的申請正在處理,請勿重複提交"),

3.3 添加更多類型的 ControllerAdvice

基於此,咱們即可以對不一樣類型的由 Controller 及其後續調用鏈拋出的異常進行分類處理了。

較經常使用的類型包括:已知的業務異常、MVC 異常( 如接口地址不存在等 )、數據庫異常、未知的運行時異常等。

此時咱們須要爲不一樣類型的異常配置不一樣的 ControllerAdvice,爲了更方便的在一個文件中進行配置,咱們可使用以下方式:

@Configuration
public class ExceptionHandlerConfiguration {

    @Slf4j
    @RestController
    @Order(1)
    @ControllerAdvice
    public static class BusinessExceptionHandler {

        /**
         * 業務異常處理( 可由前端指引用戶修正輸入值以規避該狀況 )
         * 仍返回 200 狀態碼
         *
         * @param exception
         * @return
         */
        @ExceptionHandler(BusinessException.class)
        @ResponseStatus(HttpStatus.OK)
        public ErrorResponse defaultException(BusinessException exception) {

            log.error("業務異常: {}", exception);
            return new ErrorResponse(exception.getExceptionCode(), exception.getMessage());
        }

        @ExceptionHandler(SQLException.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ErrorResponse defaultException(SQLException exception) {

            log.error("數據庫異常", exception);
            return new ErrorResponse(StatusCode.DATABASE_ERROR);
        }
    }

    @Slf4j
    @RestController
    @Order(9)
    @ControllerAdvice
    public static class MVCExceptionHandler {

        /**
         * 404
         *
         * @return
         */
        @ExceptionHandler(NoHandlerFoundException.class)
        @ResponseStatus(HttpStatus.NOT_FOUND)
        public ErrorResponse notFoundException(NoHandlerFoundException exception) {

            log.info("請求地址不存在: {}", exception.getMessage());
            return new ErrorResponse(StatusCode.NOT_FOUND);
        }

        /**
         * 方法類型不容許、缺乏參數等
         *
         * @return
         */
        @ExceptionHandler(ServletException.class)
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        public ErrorResponse servletException(ServletException exception) {

            log.info("請求方式或參數不合法: {}", exception.getMessage());
            return new ErrorResponse(StatusCode.INVALID_REQUEST);
        }
    }

    @Slf4j
    @RestController
    @Order(98)
    @ControllerAdvice
    public static class RuntimeExceptionHandler {

        /**
         * 缺省運行時異常
         *
         * @param exception
         * @return
         */
        @ExceptionHandler(RuntimeException.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public ErrorResponse runtimeException(RuntimeException exception) {

            log.error("Spring Boot 未知錯誤", exception);
            return new ErrorResponse(StatusCode.ERROR);
        }
    }
}

進行上述配置後,在某個 Service 處理中,若是遇到可預期的異常,直接拋出對應的異常對象便可。Spring Boot 會自動對該異常對象進行處理,將其封裝成標準輸出格式,且在 message 中填充已定義的錯誤提示,以便前端向用戶進行提示。

4. 使用 HandlerExceptionResolver 處理其餘異常

在 Spring Boot 中,部分代碼未通過 MVC 階段便出現了異常,好比 Spring Security 的處理等。此種狀況的異常沒法利用 ControllerAdvice 進行統一處理,須要藉助 HandlerExceptionResolver 進行配置:

@Slf4j
@Configuration
public class ExceptionConfiguration {

    @Component
    public class CustomExceptionResolver implements HandlerExceptionResolver {

        @Override
        public ModelAndView resolveException(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception exception) {
            httpServletResponse.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            httpServletResponse.setContentType("application/json;charset=UTF-8");
            try {
                log.error("服務未知錯誤:{}", exception);
                exception.printStackTrace();
                ErrorResponse errorResponse = new ErrorResponse(exception.getMessage());
                httpServletResponse.getWriter().write(JSONObject.toJSONString(errorResponse));
            } catch (IOException e) {
                log.error("未知異常響應錯誤: {}", e);
                e.printStackTrace();
            }
            return null;
        }
    }
}

5. 使用 WebMvcConfigurer 進行全局 JSON 配置

爲了使得包括異常在內的返回值中,駝峯字段都能被正確轉換爲下劃線,咱們須要添加 WebMvcConfigurer 配置。

注意,直接在 .yml 文件中進行的配置沒法在 ControllerAdvice 中生效。

/**
 * 增長 @EnableWebMvc 註解的目的是爲了使 WebMvcConfigurer 配置生效
 */
@EnableWebMvc
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    /**
     * 增長這一配置,以便由 ControllerAdvice 統一處理的異常返回值也能進行駝峯轉下劃線等處理
     *
     * @param converters
     */
    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {

        ObjectMapper objectMapper = new ObjectMapper();
        // 設置駝峯法與下劃線法互相轉換
        objectMapper.setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
        // 設置忽略不存在的字段
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        converters.add(new MappingJackson2HttpMessageConverter(objectMapper));
    }
}

6. 使用 Filter 進行驗證碼等先行校驗

不少接口須要使用驗證碼校驗,但校驗邏輯基本是相同的,因此也能夠進行代碼抽離以避免產生冗餘。

咱們能夠在須要使用校驗步驟的接口以後添加特殊標識,以便程序進行統一處理( 固然,將須要校驗的接口地址放入某個 Set 也能夠 )。

好比,咱們使用在接口地址後增長 /_captcha 的方式標識該接口須要進行驗證碼校驗。

此時,驗證碼校驗的總體步驟以下:

  1. 前端將校驗信息加入某個請求頭字段中;
  2. 後端過濾器對每一個接口進行檢測,當發現接口後存在 /_captcha 後綴時,檢測請求頭中的校驗字段;
  3. 若是經過則放行,不然直接返回錯誤響應。

其中,Filter 檢驗邏輯以下:

@Slf4j
@Component
public class CaptchaValidatorFilter implements Filter {

    @Autowired
    private SiteProperties siteProperties;

    private static final String URI_SIGNATURE = "_captcha";

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        String uri = httpServletRequest.getRequestURI();
        if (StringUtils.contains(uri, URI_SIGNATURE)) {
        
            // 檢驗請求頭中的字段
            
        } else {
            chain.doFilter(request, response);
        }
    }

    @Override
    public void destroy() {
    }
}

7. 使用 pagehelper 實現基於 MyBatis 的快捷分頁

分頁問題也是接口層實現時所需考慮的一大問題。當使用 MyBatis 進行 ORM 時,建議使用 pagehelper 進行分頁處理:

MyBatis 分頁插件 PageHelper

首先添加以下依賴:

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>${pagehelper.version}</version>
</dependency>

而後在須要分頁的代碼前,加上以下語句便可:

PageHelper.startPage(pageNum, pageSize);

參考連接

  1. java - EnableWebMvc annotation meaning - Stack Overflow
相關文章
相關標籤/搜索