项目亮点之接口设计
接口设计
整体思路
接口设计大致分为四个部分组成:接口地址(url)、接口请求方式(get、post等)、请求数据(request)、响应数据(response)
-
接口地址
请求地址一般项目都类似,就是需求上的单个实体相关的方法都在一个类中,这样也方便后续维护。 -
接口请求方式
主要的参数提交方式是post和get,但是后端接口生产环境一般用post为主,这个一般就是以下原因参数不会在url地址泄露,post参数都在方法体中方便整体处理例如加密传输等,数据传输量比较大,可以约定特殊json格式方便后端直接转换为对象并验证等,慢慢后端接口就只支持post了。
-
请求数据
- 每个接口都需要有一些特殊参数
- 设备类型(h5,还是安卓/ios苹果设备)
- 设备唯一ID(方便分析与运营使用,例如用户未登录时添加到购物车的商品在登录后要合并给该用户)
- 请求版本号(app会有多个版本同时并存,后端可能要区分处理)
- 渠道(安卓要区分从哪个应用市场下载的,h5可能要区分来源)
- 用户的登录token(后台分为的标识,很多接口要验证用户的登录权限,例如我的订单列表接口)
- 接口文档如何提供给前端
- 每个接口都需要有一些特殊参数
-
响应数据
- 要告诉调用方本次任务我是否正常返回数据了,是否有异常了,如何设计?另外就是响应数据格式也得通过接口文档告诉调用方
- 另外响应数据的格式,目前一般用json
相关步骤总结:
- 设计统一的数据请求格式和数据响应格式
- 请求数据,通过Validator + 自动抛出异常来完成了方便的参数校验
- 响应数据,通过全局异常处理保证了异常时的响应符合格式
- 提供接口文档给调用方(前端和客户端)
请求响应数据设计
请求数据设计
- 每个接口都需要有一些特殊参数,例如设备类型、设备唯一ID、请求版本号、渠道、用户的登录token等。
- 另外,为了安全及性能当,使用的是post协议。
- 因此,实际生产环境一般是定义一套json格式,接口调用方组装好json后,直接在方法体里面提交给服务端(提交的时候需要在header中设置请求数据类型为json,即:Content-Type的值为application/json)
- 后端接口代码方面,spring可以直接把json转换为对象,代码中直接使用对象即可,代码可读性会非常好。
json范例格式为:
{
deviceType: "设备类型",
deviceNo: "设备唯一Id"
version: "请求版本号"
channelId: "渠道Id"
data: "当前请求接口的具体json数据"
}
对应这个json,在SpringBoot中直接将这个json转换为对象,我们定义一个BaseRequest<T>
对象,BaseRequest存储基本字段,泛型T用来存储data的值
@Data
public class BaseRequest<T> implements Serializable {
private String deviceType;
private String deviceNo;
private String version;
private String channelId;
private T data;
public BaseRequest() {
}
}
此处要考虑为什么一定要实现Serializable接口?因为这个里面相当于spring帮我们把json类型的字符串变成为对象,相当于是一种序列化,因此对象必须实现这个接口,即java实现序列化必须实现这个接口
然后我们来看看具体的接口
add接口
add接口是需要demo的text和dcode字段,因为demo后续还可以创建多个接口,另外我们接口使用的对象需要与数据库实体对象区分开(比如数据库对象中有很多状态字段前台不需要),这样后续我们接口的数据就不依赖与数据库,所以我们一般做法是建一个跟数据库类似的vo对象。
另外即使是相同的demo对象,有的接口需要两个字段,有的接口需要3个字段,所以我们会在vo里面创建多个对象,并且这个vo对象都在一个类中,方便后续管理。这种vo对象跟请求响应无关(请求中一般是查询和新增,返回一般都会根据需求返回vo),代码里面有请求到的用都用vo里面的对象。
请求类型如下:
public class DemoReq implements Serializable {
/**
* add接口需要的
*/
@Data
public static class BaseInfo implements Serializable {
private String text;
private String dcode;
}
/**
* listPage接口需要的
*/
@Data
public static class ShowBaseInfo extends BaseInfo implements Serializable {
private Long id;
private String createTimeString;
}
}
java里面static一般用来修饰成员变量或函数。但有一种特殊用法是用static修饰内部类,普通类是不允许声明为静态的,只有内部类才可以。内部类用static修饰是为了方便静态调用
相关接口如下:
@PostMapping("/add")
public BaseResponse<Boolean> add(@Validated @RequestBody BaseRequest<DemoReq.BaseInfo> demoData) {
DemoReq.BaseInfo baseInfo = demoData.getData();
demoService.insert(baseInfo.getText(), baseInfo.getDcode());
return BaseResponse.ok(true);
}
列表查询接口
列表是很多接口都会用到的,接口列表查询,因此会专门定义一个PageReq和PageRes,PageReq相关参数如下:
@Data
public class PageReq implements Serializable {
@NotNull(message = "page 不能为空")
private Integer pageNum;
@NotNull(message = "pageSize 不能为空")
private Integer pageSize;
}
PageReq为基础类,在DemoReq中创建vo继承PageReq拓展查询条件
相关接口方法如下:
@PostMapping("/listPage")
public BaseResponse<PageResp<DemoReq.ShowBaseInfo>> listPage(@Validated @RequestBody BaseRequest<PageReq> reqData) {
PageReq pageReq = reqData.getData();
PageResp<Demo> demoPageResp = demoService.page(pageReq);
//复制相同属性
PageResp<DemoReq.ShowBaseInfo> showPageResp = new PageResp<>();
BeanUtils.copyProperties(demoPageResp, showPageResp);
showPageResp.setList(new ArrayList<DemoReq.ShowBaseInfo>());
demoPageResp.getList().stream().forEach(e->{
DemoReq.ShowBaseInfo showBaseInfo = new DemoReq.ShowBaseInfo();
BeanUtils.copyProperties(e, showBaseInfo);
//时间要做一下专门处理,因为没有名称一致的属性,Demo中只有ceateTime,但是返回的DemoReq.ShowBaseInfo里面的是createTime
showBaseInfo.setCreateTimeString(DateUtils.formatDate(e.getCreateTime(),"yyyy-MM-dd HH:mm:SS"));
showPageResp.getList().add(showBaseInfo);
});
return BaseResponse.ok(showPageResp);
}
响应数据设计
返回的时候因为必须告诉调用方本次是出异常了,还是成功了,所以模拟http的code码,我们也设计一个code码和message,即BaseResponse的代码如下
public class BaseResponse<T> implements Serializable {
public static final BaseResponse OK = new BaseResponse(0, "成功", "");
private Integer code;
private String message;
private T data;
public BaseResponse(Integer code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
//成功,返回单个对象,或者返回泛型
public static BaseResponse ok(Object o) {
return new BaseResponse(0, "成功", o);
}
public static <T> BaseResponse<T> OK(T t) {
return new BaseResponse(0, "成功", t);
}
//失败,返回错误信息,或者错误信息带对象
public static BaseResponse error(Integer code, String message) {
return new BaseResponse(code, message, null);
}
public static <T> BaseResponse<T> error(Integer code, String message, T data) {
return new BaseResponse(code, message, data);
}
}
拓展
上传文件:
从以上设计就可以看出来,这类接口只能处理字符类请求(一般的数据都是这种格式,字符类就是是由英文或者汉字等组成,通俗意义上就是能用电脑键盘敲出来的),如果碰到二进制流文件(常见的就是图、视频、音频等,例如听的mp3,也包括一个完整文件例如word要通过接口上传)就不能满足需求。
解决方案:
- 额外写个接口,和现有体系分开,这个接口就是一般的Controller,然后专门用来处理流文件
Token的使用:
生产环境中,一般采用服务器下发token来判断是哪个用户,前端或者客户端拿到token后会缓存在本地,然后请求服务端接口的时候会把token传递过来,这个时候服务端一般是用一个Filter来做权限验证(具体可以查看1.3,用户权限验证),这个时候就会产生token传递问题。
解决方案:
- 在请求json中添加,这样在BaseRequest中增加一个名称为token的String类型即可,这样会带了一个问题是在filter中获取这个参数不方便,只能先拿Body的内容,然后把这个内容解析为String,然后再转换为json,再从json中哪个这个String的值,整个过程很繁琐
- http协议的请求header中可以添加内容,可以让调用方在调用的时候在请求header中增加字段token,值就是具体的值,这样Filter从header中取值即可,这样比较简便
参数验证和异常处理
一般做法是在业务层进行参数校验,如下:
public String addUser(User user) {
if (user == null || user.getId() == null || user.getAccount() == null || user.getPassword() == null || user.getEmail() == null) {
return "对象或者对象字段不能为空";
}
if (StringUtils.isEmpty(user.getAccount()) || StringUtils.isEmpty(user.getPassword()) || StringUtils.isEmpty(user.getEmail())) {
return "不能输入空字符串";
}
if (user.getAccount().length() < 6 || user.getAccount().length() > 11) {
return "账号长度必须是6-11个字符";
}
if (user.getPassword().length() < 6 || user.getPassword().length() > 16) {
return "密码长度必须是6-16个字符";
}
if (!Pattern.matches("^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$", user.getEmail())) {
return "邮箱格式不正确";
}
// 参数校验完毕后这里就写上业务逻辑
return "success";
}
改进一下,使用Spring Validator来进行方便的参数验证,并且参数验证失败了也可以返回BaseResponse的格式。
参数验证
Validator
Validator可以非常方便的制定校验规则,并自动帮你完成校验。首先在入参里需要校验的字段加上注解,每个注解对应不同的校验规则,并可制定校验失败后的信息,springboot中包含Spring Validator和Hibernate Validator这两套Validator来进行方便的参数校验!这两套Validator可以混用,一般知道这个概念就行,直接使用即可。
Validator可以非常方便的制定校验规则,并自动帮你完成校验。首先在入参里需要校验的字段加上注解,每个注解对应不同的校验规则,并可制定校验失败后的信息,如下:
@NotNull(message = "用户账号不能为空")
@Size(min = 6, max = 11, message = "账号长度必须是6-11个字符")
private String account;
@NotNull(message = "用户密码不能为空")
@Size(min = 6, max = 11, message = "密码长度必须是6-16个字符")
private String password;
@NotNull(message = "用户邮箱不能为空")
@NotBlank(message="邮箱不能是空串")
@Email(message = "邮箱格式不正确")
private String email;
validation-api-1.1.0.jar 包括如下约束注解:
约束注解 | 说明 |
---|---|
@AssertFalse | 被注释的元素必须为 false |
@AssertTrue | 被注释的元素必须为 true |
@DecimalMax(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@DecimalMin(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@Digits(integer, fraction) | 被注释的元素必须是一个数字,其值必须在可接受的范围内 |
@Null | 被注释的元素必须为 null |
@NotNull | 被注释的元素必须不为 null |
@Min(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@Max(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@Size(max, min) | 被注释的元素的大小必须在指定的范围内 |
@Past | 被注释的元素必须是一个过去的日期 |
@Future | 被注释的元素必须是一个将来的日期 |
@Pattern(value) | 被注释的元素必须符合指定的正则表达式 |
hibernate-validator-5.3.6.jar 包括如下约束注解:
约束注解 | 说明 |
---|---|
被注释的元素必须是电子邮箱地址 | |
@Length | 被注释的字符串的大小必须在指定的范围内 |
@NotBlank | 被注释的字符串的必须非空 |
@NotEmpty | 被注释的字符串、集合、Map、数组必须非空 |
@Range | 被注释的元素必须在合适的范围内 |
@SafeHtml | 被注释的元素必须是安全Html |
@URL | 被注释的元素必须是有效URL |
校验规则和错误提示信息配置完毕后,接下来只需要在接口需要校验的参数上加上@Valid注解或者@Validated注解,我们范例用的就是这个。
我们的验证代码修改为如下:
首先我们在BaseRequest中增加参数验证(要注意其中的嵌套验证):
@NotNull(message = "设备类型不能为空")
private String deviceType;
@NotNull(message = "设备No参数不能为空")
private String deviceNo;
@NotNull(message = "当前版本参数不能为空")
private String version;
@NotNull(message = "渠道ID不能为空")
private String channelId;
@Valid
private T data;
默认验证只是验证当前对象的属性,不会验证内部引入对象的属性(即data里面的字段就是加了参数验证的注解),也不会生效。所以我们要在data上增加@valid注解,这样data的属性如果增加了参数注解也会生效
add接口相关参数修改如下:
接下来是add接口的入参DemoReq.BaseInfo,添加的内容如下
public static class BaseInfo implements Serializable {
@NotNull(message = "text不能为空")
private String text;
@NotNull(message = "text不能为空")
private String dcode;
}
listPage接口
接下来是listPage相关的参数,添加的内容如下
public class PageReq implements Serializable {
@NotNull(message = "page 不能为空")
private Integer page;
@Range(min = 0, max = 20, message = "每页最多20条数据")
@NotNull(message = "pageSize 不能为空")
private Integer pageSize;
}
@validated和@valid不同点
- @Validated:提供分组功能,@Valid:没有分组功能
- @Validated:用在类型、方法和方法参数上。但不能用于成员属性(field);@Valid:可以用在方法、构造函数、方法参数和成员属性(field)上
自定义参数校验
创建接口注解
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = PhoneValidator.class)
public @interface Phone {
boolean required() default true;
String message() default "手机号码格式错误";
Class<?>[] groups() default { };
Class<? extends Payload>[] payload() default { };
}
实现参数验证类
public class PhoneValidator implements ConstraintValidator<Phone, String> {
private boolean required = false;
// 定义的手机号验证正则表达式
private Pattern pattern = Pattern.compile("1(([38]\\d)|(5[^4&&\\d])|(4[579])|(7[0135678]))\\d{8}");
@Override
public void initialize(Phone constraintAnnotation) {
required = constraintAnnotation.required();
}
@Override
public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) {
if(required) {
return pattern.matcher(s).matches();
}else {
if(StringUtils.isEmpty(s)) {
return false;
}else{
return pattern.matcher(s).matches();
}
}
}
}
异常处理
接下来我们讲使用SpringBoot全局异常处理来达到一劳永逸的效果:
基本使用
首先,我们需要新建一个类,在这个类上加上@ControllerAdvice或@RestControllerAdvice注解,这个类就配置成全局处理类了。然后在类中新建方法,在方法上加上@ExceptionHandler注解并指定你想处理的异常类型,接着在方法内编写对该异常的操作逻辑,完成对该异常的全局处理!
改造CommonExceptionHandler
@Slf4j
@RestControllerAdvice
public class CommonExceptionHandler {
/**
* 对方法参数校验异常处理方法(表单格式,不使用@Requestbody注解)
* 表单类型的提交时,则spring会采用表单数据的处理类进行处理(进行参数校验错误时会抛出BindException异常)
*
* @param exception 异常
* @return com.bmw.seed.util.bean.BaseResponse<?>
* @author 石一歌
* @date 2022/7/13 23:57
*/
@ExceptionHandler(BindException.class)
public BaseResponse<?> handlerBindException(BindException exception) {
return handlerNotValidException(exception);
}
/**
* 对方法参数校验异常处理方法(json格式,使用@Requestbody注解)
* json格式提交时,spring会采用json数据的数据转换器进行处理(进行参数校验时错误是抛出MethodArgumentNotValidException异常)
*
* @param exception 异常
* @return com.bmw.seed.util.bean.BaseResponse<?>
* @author 石一歌
* @date 2022/7/13 23:56
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public BaseResponse<?> handlerArgumentNotValidException(MethodArgumentNotValidException exception) {
return handlerNotValidException(exception);
}
/**
* 处理程序无效异常
*
* @param e 异常
* @return com.bmw.seed.util.bean.BaseResponse<?>
* @author 石一歌
* @date 2022/7/13 23:52
*/
public BaseResponse<?> handlerNotValidException(Exception e) {
log.debug("begin resolve argument exception");
BindingResult result;
if (e instanceof BindException) {
BindException exception = (BindException) e;
result = exception.getBindingResult();
} else {
MethodArgumentNotValidException exception = (MethodArgumentNotValidException) e;
result = exception.getBindingResult();
}
Map<String, Object> maps;
if (result.hasErrors()) {
List<FieldError> fieldErrors = result.getFieldErrors();
maps = new HashMap<>(fieldErrors.size());
fieldErrors.forEach(error -> {
maps.put(error.getField(), error.getDefaultMessage());
});
} else {
maps = Collections.EMPTY_MAP;
}
return BaseResponse.error(305, "参数错误", maps);
}
}
全局异常处理
全局处理当然不会只能处理一种异常,用途也不仅仅是对一个参数校验方式进行优化。在实际开发中,如何对异常处理其实是一个很麻烦的事情。传统处理异常一般有以下烦恼:
- 是捕获异常(try…catch)还是抛出异常(throws)
- 是在controller层做处理还是在service层处理又或是在dao层做处理
- 处理异常的方式是啥也不做,还是返回特定数据,如果返回又返回什么数据
- 不是所有异常我们都能预先进行捕捉,如果发生了没有捕捉到的异常该怎么办?
全局异常
所有异常都继承了Exception基类,而Exception基础类实现了ThrowAble接口,在CommonExceptionHandler
中添加对Throwable
的全局处理即可
@Slf4j
@RestControllerAdvice
public class CommonExceptionHandler {
/**
* 对方法参数校验异常处理方法(表单格式,不使用@Requestbody注解)
* 表单类型的提交时,则spring会采用表单数据的处理类进行处理(进行参数校验错误时会抛出BindException异常)
*
* @param exception 异常
* @return com.bmw.seed.util.bean.BaseResponse<?>
* @author 石一歌
* @date 2022/7/13 23:57
*/
@ExceptionHandler(BindException.class)
public BaseResponse<?> handlerBindException(BindException exception) {
return handlerNotValidException(exception);
}
/**
* 对方法参数校验异常处理方法(json格式,使用@Requestbody注解)
* json格式提交时,spring会采用json数据的数据转换器进行处理(进行参数校验时错误是抛出MethodArgumentNotValidException异常)
*
* @param exception 异常
* @return com.bmw.seed.util.bean.BaseResponse<?>
* @author 石一歌
* @date 2022/7/13 23:56
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public BaseResponse<?> handlerArgumentNotValidException(MethodArgumentNotValidException exception) {
return handlerNotValidException(exception);
}
/**
* 处理程序无效异常
*
* @param e 异常
* @return com.bmw.seed.util.bean.BaseResponse<?>
* @author 石一歌
* @date 2022/7/13 23:52
*/
public BaseResponse<?> handlerNotValidException(Exception e) {
log.debug("begin resolve argument exception");
BindingResult result;
if (e instanceof BindException) {
BindException exception = (BindException) e;
result = exception.getBindingResult();
} else {
MethodArgumentNotValidException exception = (MethodArgumentNotValidException) e;
result = exception.getBindingResult();
}
Map<String, Object> maps;
if (result.hasErrors()) {
List<FieldError> fieldErrors = result.getFieldErrors();
maps = new HashMap<>(fieldErrors.size());
fieldErrors.forEach(error -> {
maps.put(error.getField(), error.getDefaultMessage());
});
} else {
maps = Collections.EMPTY_MAP;
}
return BaseResponse.error(305, "参数错误", maps);
}
/**
* 全局异常,记录错误日志文件
*
* @param throwable 全局异常
* @return com.bmw.seed.util.bean.BaseResponse<?>
* @author 石一歌
* @date 2022/7/13 23:58
*/
@ExceptionHandler(Throwable.class)
@ResponseStatus(HttpStatus.OK)
public BaseResponse<?> exception(Throwable throwable) {
log.error("系统异常", throwable);
return BaseResponse.error(306, "服务器太忙碌了~让它休息一会吧!");
}
}
接口参数展示的问题我们用swagger来实现,Swagger是全球最流行的接口文档自动生成和测试的框架,几乎支持所有的开发语言。
接口文档
配置swagger
Knife4j
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>
@Configuration
@EnableOpenApi
public class Knife4jConfig {
/**
* Knife4j配置
*
* @param environment 环境参数
* @return Docket
* @author 石一歌
* @date 2022/4/11 0:39
*/
@Bean(value = "SpringBoot-Vue-2022-Api")
public Docket docket(Environment environment) {
return new Docket(DocumentationType.OAS_30)
.apiInfo(new ApiInfoBuilder()
.title("接口文档列表")
.description("接口文档")
.termsOfServiceUrl("http://www.springboot.vue.com")
.contact(new Contact("石一歌", "https://www.cnblogs.com/faetbwac/", "1456923076@qq.com"))
.version("1.0")
.license("Apache 2.0 许可")
.licenseUrl("许可链接").build())
// 根据配置文件选择是否开启
.enable(environment.acceptsProfiles(Profiles.of("dev", "test")))
.groupName("1.0版本")
.select()
// 这里指定Controller扫描包路径
.apis(RequestHandlerSelectors.basePackage("com.nuc.controller"))
.paths(PathSelectors.any())
.build();
}
}
springfox-swagger-ui
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>
@Configuration
@EnableSwagger2
public class SwaggerConfig {
@Bean
public Docket docket(Environment environment) {
return new Docket(DocumentationType.SWAGGER_2)
.enable(environment.acceptsProfiles(Profiles.of("dev", "test")))
.apiInfo(new ApiInfoBuilder().title("swagger接口文档")
.version("1.0")
.build())
.pathMapping("/")
.select()
.apis(RequestHandlerSelectors.withClassAnnotation(Api.class)).apis(RequestHandlerSelectors.basePackage("com"))
.build()
// 主要关注点----每个接口调用都填写token
.globalOperationParameters(globalOperation());
}
/**
* 为每个接口单独增加token参数
*
* @return java.util.List<springfox.documentation.service.Parameter>
* @author 石一歌
* @date 2022/7/14 11:18
*/
private List<Parameter> globalOperation() {
List<Parameter> pars = new ArrayList<>();
//第一个token为传参的key,第二个token为swagger页面显示的值
pars.add(new ParameterBuilder().name("token").description("token").modelRef(new ModelRef("string")).parameterType("header").required(false).build());
return pars;
}
}
注解说明
请求类的描述
注解 | 说明 |
---|---|
@Api | 对请求类的说明 |
方法和方法参数的描述
注解 | 说明 |
---|---|
@ApiOperation | 方法的说明 |
@ApiImplicitParams | 方法参数的说明; |
@ApiImplicitParam | 用于指定单个参数的说明。 |
方法的响应状态的描述
注解 | 说明 |
---|---|
@ApiResponses | 方法返回值的说明 ; |
@ApiResponse | 用于指定单个参数的说明。 |
对象的描述
注解 | 说明 |
---|---|
@ApiModel | 用在JavaBean类上,说明JavaBean的 整体用途 |
@ApiModelProperty | 用在JavaBean类的属性上面,说明此属性的的含议 |