在前后端分离的主流架构下,前端代码和后端逻辑主要依靠已约定的格式进行交互。在这一前提下,如果后端代码没有进行一定的配置,就很容易出现大量重复代码。本文以 Spring Boot 为例,记录一些可以减少冗余代码的方案。
前后端分离后,如果不采用相同域名,跨域便是首先需要解决的问题。关于跨域方案,先前撰写的文章中有比较详细的方案罗列: 跨域解决方案 - DB.Reid - SegmentFault 思否 。
这里介绍在 SpringBoot 中采用 Filter 方式实现跨域的代码:
@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 响应中允许相同的内容。如果是线上服务,建议指定固定的前端域名。
前后端分离后,接口返回的数据需要有更强的表现力,以便前端能够进行更多提升用户体验的处理。
一般情况下,对于正常情况,和后端可预期的提示性错误,建议返回的 HTTP 状态码全部为 2xx( 当遇到某些因 BUG 导致的异常时,再返回 5xx 错误以表示是由后端代码导致的问题 )。而把带有语义的状态类型标识符放在响应体 JSON 的某个字段中。
目前比较常用的响应类型为:
{ "status_code":0, "data":{ }, "message":"" }
关于返回格式中 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
用于状态码唯一标识, name
和 cn_name
可作为文本提示赋值给返回值对象的 message
字段。
其中, value
可以根据业务情况进行合理的组织,比如 1xxxxx
表示用户类业务异常; 2xxxxx
表示邮件短信类业务异常等。这种组织方式更易于错误定位和排查。
按照预期的返回格式定义类:
@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 中,需要增加一些配置以实现这一转换过程。这一部分会在后文中进行介绍。
接下来,我们添加一个配置,对所有 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 等业务代码返回的对象就无需进行多余的格式封装工作了。
注:采用上述代码只会对正常结果进行处理,而要对异常情况进行格式化封装,则需要其他一些步骤。
一般思路下,我们需要对可能出现异常的地方进行捕获,然后设定单独的处理逻辑,返回特定的对象给调用者,以便前端能够收到对应的响应数据。
这一过程太过繁琐,且需要在业务代码中掺杂入许多无意义的分支代码。
Spring Boot 允许我们使用 @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()
。
在实际业务中,很多 “异常” 是前后端都可以预期的。比如用户上传的文件数量超过了限制、在禁止变更时期进行了相关操作等。这类异常也不同于正常情况,需要后端进行检验并返回不同于正常情况的响应值。
此时,我们可以自定义一些业务运行时异常,以便也可以使用 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", "相同类型的申请正在处理,请勿重复提交"),
基于此,我们便可以对不同类型的由 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
中填充已定义的错误提示,以便前端向用户进行提示。
在 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; } } }
为了使得包括异常在内的返回值中,驼峰字段都能被正确转换为下划线,我们需要添加 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)); } }
很多接口需要使用验证码校验,但校验逻辑基本是相同的,所以也可以进行代码抽离以免产生冗余。
我们可以在需要使用校验步骤的接口之后添加特殊标识,以便程序进行统一处理( 当然,将需要校验的接口地址放入某个 Set 也可以 )。
比如,我们使用在接口地址后增加 /_captcha
的方式标识该接口需要进行验证码校验。
此时,验证码校验的整体步骤如下:
/_captcha
其中,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() { } }
分页问题也是接口层实现时所需考虑的一大问题。当使用 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);