一、理论基础
1.1 如何实现一个相对健壮的接口
接口设计应该假设所有的调用者都是不靠谱的,所以需要做全方位的防御措施并尽可能考虑到各种因素
正常访问
一个接口能正常访问是最基本的、最低的要求。不管调用者传递什么参数,接口应该都能给予良好的反馈,即使参数是错的。
当用户参数传递错误时,应该将错误信息反馈给用户,比如缺少参数或者参数格式不正确等
返回值统一化
标准化的返回格式,是绝对有利于同事间的感情发展的。
如果你一会返回个S,一会返回个B,你一定会被诅咒成这个返回值的拼接体的(人工狗头)
返回该返回的
尽可能的精简返回值,不要返回过多的冗余信息,比如调用方只需要两个字段,如果返回几十个字段,对于调用者来讲使用起来也不是很方便,而且还可能会暴露内部的业务逻辑。
报错信息全局处理
最常见的一个场景就是接口调用错误会将内部代码返给调用者,遇到过一个报名页面报错后,直接返回了几行代码,而且因为报错的很多都是关键逻辑,连代码注释的作者都能看到。
所以未知的错误最好统一处理下,比如“网络错误“等
及时更新的文档
写文档是最不受待见的一件事,那么一个自动化接口文档就很必要了,Swagger虽然繁琐,但是用起来很香。
而且可以设置默认的一些参数值,直接调试接口,真的香。
版本管理
不管会不会迭代,最好加个版本号。一来可以让接口看起来像”正规军”,二来可以有效应对随时到来的需求变更。
健壮的业务逻辑
这个就不多说了,相信每个人都有自己的一套方法论。
1.2 本实例集成的功能
- 接口异常全局处理
- Knife4j接口文档
- 简单的token验证
- Validator参数校验
- 返回值的统一化
- 解决跨域问题
- AOP统计接口访问次数
1.3 示例源码地址
https://github.com/lysmile/spring-boot-demo/tree/master/spring-boot-api-handler-demo
二、接口异常全局处理
/**
* 接口访问统一异常处理
* - 当程序报错时,由此类进行统一处理
* - 防止将程序内部错误暴露给用户
* @author smile
*/
@RestControllerAdvice
@Slf4j
public class ControllerExceptionHandleAdvice {
@ExceptionHandler(ValidatorRuntimeException.class)
public CodeResult validationExceptionHandler(HttpServletRequest request, ValidatorRuntimeException e) {
if (log.isDebugEnabled()) {
log.error("接口[{}]请求发生[接口检验异常],错误信息:{}", request.getRequestURI(), e.getMessage());
}
return new CodeResult(ResponseEnum.REQUEST_PARAM_ERROR.getCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
public CodeResult exceptionHandler(HttpServletRequest request, Exception e) {
e.printStackTrace();
log.error("接口[{}]请求发生异常,错误信息:{}", request.getRequestURI(), e.getMessage());
return new CodeResult(ResponseEnum.SERVICE_ERROR);
}
@ExceptionHandler(TokenException.class)
public CodeResult exceptionHandler(HttpServletRequest request, TokenException e) {
if (log.isDebugEnabled()) {
log.error("接口[{}]请求发生[token验证异常],错误信息:{}, token:{}", request.getRequestURI(), e.getMessage(), request.getHeader("token"));
}
return new CodeResult(ResponseEnum.TOKEN_ERROR.getCode(), e.getMessage());
}
}
三、Knife4j接口文档
官网文档:https://xiaoym.gitee.io/knife4j/
3.1 依赖引入
com.github.xiaoymin
knife4j-spring-boot-starter
2.0.7
3.2 配置文件
knife4j:
# 开启增强配置
enable: true
# 开启Swagger的Basic认证功能,默认是false
basic:
enable: true
username: smile
password: 123456
# 配置文档路径
documents:
-
group: 0.1
name: 说明文档
# 某一个文件夹下所有的.md文件
locations: classpath:markdown/*
-
group: 0.1
name: 说明文档2
# 某一个文件夹下所有的.md文件
locations: classpath:markdown/*
3.3 配置文件
@Configuration
@EnableSwagger2WebMvc
public class Knife4jConfig {
private final OpenApiExtensionResolver openApiExtensionResolver;
public Knife4jConfig(OpenApiExtensionResolver openApiExtensionResolver) {
this.openApiExtensionResolver = openApiExtensionResolver;
}
@Bean(value = "defaultApi2")
public Docket defaultApi2() {
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.apiInfo(new ApiInfoBuilder()
.title("Springboot API Handler")
.description("Springboot API 处理方法集成")
.version("0.1")
.build())
//分组名称
.groupName("0.1")
.select()
//这里指定Controller扫描包路径
.apis(RequestHandlerSelectors.basePackage("com.smile.demo.apihandler.controller"))
.paths(PathSelectors.any())
.build()
// 此处的0.1对应配置文件中的document-group
.extensions(openApiExtensionResolver.buildExtensions("0.1"));
return docket;
}
}
3.4 简单应用
@Api(tags = "接口测试")
@RestController
@ApiSort(102)
@Slf4j
@RequestMapping("v0.1")
public class MainController {
@ApiImplicitParam(name = "name", value = "姓名", required = true)
@ApiOperation(value = "测试接口")
@GetMapping("first-demo")
public CodeResult firstDemo(@RequestParam(value = "name") String name) {
log.info("!!成功进入接口, 参数是:[{}]", name);
return new CodeResult(ResponseEnum.SUCCESS, name);
}
}
完整代码可参考本实例源码
四、简单的token验证
本示例只实现了简单的token验证(验证用户和token是否过期)
优化方向
- Springboot全家桶:集成Springboot Security
- 自实现功能:Jwt + Redis可实现较为完善的Token验证功能
4.1 依赖引入
com.auth0
java-jwt
3.10.3
4.2 配置文件
my:
security:
username: api
password: 123456
4.3 Jwt工具类
@Component
public class JwtUtils {
@Value("${my.security.username}")
private String defaultUsername;
@Value("${my.security.password}")
private String defaultPassword;
/**
* 过期时间,单位毫秒
* - 10分钟
*/
private static final long EXPIRE_TIME = 1000 * 60 * 10;
public TokenInfo createToken(String username, String password) throws TokenException {
if (!username.equals(defaultUsername) || !password.equals(defaultPassword)) {
throw new TokenException("用户名或密码不正确");
}
//设置头信息
HashMap header = new HashMap<>(2);
header.put("typ", "JWT");
header.put("alg", "HMAC256");
long expireAt = System.currentTimeMillis() + EXPIRE_TIME;
String token = JWT.create()
.withHeader(header)
// 存入需要保存在token的信息
.withAudience(username)
// 过期时间
.withExpiresAt(new Date(expireAt))
.sign(Algorithm.HMAC256(password));
return new TokenInfo(token, expireAt);
}
public void verify(String token) throws TokenException {
Algorithm algorithm = Algorithm.HMAC256(defaultPassword);
JWTVerifier verifier = JWT.require(algorithm).build();
try {
DecodedJWT jwt = verifier.verify(token);
if(!defaultUsername.equals(jwt.getAudience().get(0))) {
throw new TokenException("用户不存在");
}
} catch(TokenExpiredException e) {
throw new TokenException("token已过期");
} catch(Exception e) {
e.printStackTrace();
throw new TokenException("token无效");
}
}
}
4.4 拦截器
4.4.1 拦截器功能实现
/**
* 拦截器
* - 验证token
* @author yangjunqiang
*/
@Component
@Slf4j
public class UserTokenInterceptor implements HandlerInterceptor {
private final JwtUtils jwtUtils;
public UserTokenInterceptor(JwtUtils jwtUtils) {
this.jwtUtils = jwtUtils;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("!!进入拦截器方法");
String token = request.getHeader("token");
if (StringUtils.isBlank(token)) {
throw new TokenException("请传入正确的token");
}
jwtUtils.verify(token);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
4.4.2注册拦截器
@Component
public class UserTokenAppConfig implements WebMvcConfigurer {
private final UserTokenInterceptor userTokenInterceptor;
public UserTokenAppConfig(UserTokenInterceptor userTokenInterceptor) {
this.userTokenInterceptor = userTokenInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userTokenInterceptor)
// 配置需要被拦截的接口
// 注意此处是controller中的路径,配置文件中的server.servlet.context-path不能在此处加上,否则拦截器不会生效
.addPathPatterns("/v0.1/**")
// 不被拦截的接口
.excludePathPatterns("/token/get")
;
}
}
五、Validator参数校验
5.1 依赖引入
org.hibernate.validator
hibernate-validator
5.2 快速失败配置(可选)
/**
* 参数校验快速失败配置
* - 此模式下只要发现一个参数不匹配便会快速返回错误
* @author smile
*/
@Configuration
public class ValidatorConfig {
@Bean
public Validator validator() {
ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
.configure()
.failFast( true )
.buildValidatorFactory();
Validator validator = validatorFactory.getValidator();
return validator;
}
}
5.3 工具类
/**
* 参数验证通用方法提取
* @author smile
*/
public class ValidatorUtils {
/**
* controller bindingResult处理
* @param bindingResult 参数验证结果集
*/
public static void handleBindingResult(BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
throw new ValidatorRuntimeException(bindingResult.getAllErrors().get(0).getDefaultMessage());
}
}
}
5.4 简单应用
关键词:@Validated、BindingResult
配合全局异常处理一块使用
@PostMapping("get")
@ApiOperationSupport(author = "smile")
@ApiOperation(value = "获取token")
public CodeResult getToken(@RequestBody @Validated User user, BindingResult bindingResult) throws TokenException {
ValidatorUtils.handleBindingResult(bindingResult);
return new CodeResult(ResponseEnum.SUCCESS, JSON.toJSON(jwtUtils.createToken(user.getUsername(), user.getPassword())));
}
六、接口返回值统一化
6.1 接口返回实体定义
@Data
public class CodeResult {
private String errCode;
private String errMsg;
private Object data;
public CodeResult() { }
public CodeResult(String errCode, String errMsg) {
this.errCode = errCode;
this.errMsg = errMsg;
}
public CodeResult(ResponseEnum response) {
this.errCode = response.getCode();
this.errMsg = response.getMsg();
}
public CodeResult(ResponseEnum response, Object data) {
this.errCode = response.getCode();
this.errMsg = response.getMsg();
this.data = data;
}
}
定义枚举
/**
* 错误码
* 0 :成功
* 1*:业务内自定义错误码
* 2*:
* 3*:
* 4*:网络错误
* 5*:系统内部错误,包含代码执行异常等
* @author smile
*
*/
public enum ResponseEnum {
/**
* 请求成功
*/
SUCCESS("0", "success"),
/**
* 请求参数错误
*/
REQUEST_PARAM_ERROR("1001", "参数错误"),
/**
* token验证失败
*/
TOKEN_ERROR("1002", "token验证失败"),
/**
* 服务内部异常,包括代码执行错误及一些不确定错误
*/
SERVICE_ERROR("5001", "服务异常,请稍后再试!")
;
private String code;
private String msg;
ResponseEnum(String code, String msg) {
this.code = code;
this.msg = msg;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
}
七、解决跨域问题
/**
* 解决跨域问题
* @author smile
*/
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
final UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
final CorsConfiguration corsConfiguration = new CorsConfiguration();
/*是否允许请求带有验证信息*/
corsConfiguration.setAllowCredentials(true);
/*允许访问的客户端域名*/
corsConfiguration.addAllowedOrigin("*");
/*允许服务端访问的客户端请求头*/
corsConfiguration.addAllowedHeader("*");
/*允许访问的方法名,GET POST等*/
corsConfiguration.addAllowedMethod("*");
urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
return new CorsFilter(urlBasedCorsConfigurationSource);
}
}
八、AOP统计接口访问次数
详见实战代码(四):Springboot AOP实现接口访问次数统计