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
相关文章
相关标签/搜索