项目亮点之接口设计

接口设计

整体思路

接口设计大致分为四个部分组成:接口地址(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 包括如下约束注解:

约束注解 说明
@Email 被注释的元素必须是电子邮箱地址
@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

http://localhost:8080/doc.html

        <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

http://localhost:8080/swagger-ui.html

            <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类的属性上面,说明此属性的的含议
posted @ 2022-07-20 13:33  Faetbwac  阅读(459)  评论(0编辑  收藏  举报