编写令人愉悦的API接口(二)

原创
2020/12/24 13:48
阅读数 2.3K

引言

制定API接口的规范在设计接口时尤为重要,统一的格式规范会减少联调的难度,也能让后续维护者身心愉悦.在编写接口时,运用适合的参数检验技巧也能提高代码的健壮性.本文将围绕这两点详解一些我的一些经验.

思考

  1. 业务接口通常都包含最基础的CRUD操作,怎么尽可能的统一这部分接口?
  2. API接口部分会使用到HttpServletRequestHttpServletResponse里的数据,如何方便的获取到这两个对象?
  3. 接口的参数有时通过单个参数传递,有时会通过对象传递,如何方便的检验这些参数,而不用频繁的使用if判断去甄别临界值或者判空?

带着这些疑问,我们一步步去解决.

整体类图

统一基础接口路径

在前一篇文章中有提到过,如何用正确的姿势使用不同的协议,GET,POST,PUT,PATCH,DELETE这五种协议在日常CRUD开发中很常用.不同的业务基本都会罗列出这些API接口,那么我们能不能尝试把他们抽离成接口,然后让不同的业务控制层去实现这个接口,从而规范基础的接口路径呢?理论存在,实践开始

实践

v1版本

先定义一个普通的java接口类,写入四个方法,分别对应增删查改四种操作. 注:PATCH接口修改单个属性值,因为不同业务中字段存在太大差异,所以她比较适合单独实现,这个接口中就不涵盖这个接口了.

public interface BaseCrud {
    R selectList(@ModelAttribute Map s);
    R selectOne(@PathVariable String id);
    R add(@RequestBody Map t);
    R upp(@PathVariable String id, @RequestBody Map t);
    R del(@PathVariable String id);
}

相信大家看到这五个接口一目了然,这不就是增删查改加上一个分页接口么.有了这个接口我们编写某种业务的控制层不就简单了么,直接实现他,然后再实现不同的逻辑,完美.

//这里以用户相关业务和User用户相关业务为例
//教师业务控制层
@Controller
@RequestMapping("user")
public class UserController implements BaseCrud {
    @Override
    @GetMapping
    @ResponseBody
    public R selectList(Map s) {
        return null;
    }
    @Override
    @GetMapping("{id}")
    @ResponseBody
    public R selectOne(String id) {
        return null;
    }
    @Override
    @PostMapping
    @ResponseBody
    public R add(Map t) {
        return null;
    }
    @Override
    @PutMapping("{id}")
    @ResponseBody
    public R upp(String id, Map t) {
        return null;
    }
    @Override
    @DeleteMapping("{id}")
    @ResponseBody
    public R del(String id) {
        return null;
    }
}
//教师业务控制层,跟上面一样的操作,这里节省篇幅,先省略....

通过postMan测试,分别用teacher和user前缀就能调用各自业务的接口,简单的增删查改统一路径便实现了.但是这也会出现一个缺陷,因为我们都是用Map接收的,map键值对因为不确定字段,后期如果不debug是很难维护的,甚至连字段掉了也可以调通,不便于排查,其次如果我们用Mybatis操作时,还需要转换为对象,虽然统一了接口路径,但是后面的操作还是很繁琐,所以我们还是得进一步优化.

v2版本

相信大家对泛型都不陌生.我们这里也可用泛型去优化.因为搜索分页需要携带页码,排序等等,我们把他作为两个泛型去实现.

//需要注意一点小细节,因为这五个接口的路径协议都是可以统一的,所以我们在定义接口的时候就把后面拼接的路径写在接口中.
public interface BaseCrud<T, S> {

    /**
     * @description: 分页查询接口
     * @author: chenyunxuan
     * @updateTime: 2020/12/18 11:40 上午
     */
    @GetMapping
    R selectList(@ModelAttribute S s);

    /**
     * @description: 根据id查询单条数据
     * @author: chenyunxuan
     * @updateTime: 2020/12/18 11:44 上午
     */
    @GetMapping("{id}")
    R selectOne(@PathVariable String id);

    /**
     * @description: 新增单条数据
     * @author: chenyunxuan
     * @updateTime: 2020/12/18 1:39 下午
     */
    @PostMapping
    R add(@RequestBody T t);

    /**
     * @description: 修改单条数据
     * @author: chenyunxuan
     * @updateTime: 2020/12/18 1:39 下午
     */
    @PutMapping("{id}")
    R upp(@PathVariable String id, @RequestBody T t);

    /**
     * @description: 删除单条数据
     * @author: chenyunxuan
     * @updateTime: 2020/12/18 1:40 下午
     */
    @DeleteMapping("{id}")
    R del(@PathVariable String id);
}

定义两个不同的业务实体类.分别用于分页和新增修改.

@Data
//对应泛型T,用于新增修改
public class User {
    private String mobile;
    private String name;
    private String email;
    private Integer age;
    private LocalDateTime birthday;
}
@Data
//对应泛型S,用于搜索
public class UserSearch {
    private Integer pageNum;
    private String mobile;
    private String name;
    private String email;
}

接下来是业务控制层实现.

@RestController 
@RequestMapping("teacher")
public class UserController implements BaseCrud<User, UserSearch> {
    @Override
    public R selectList(UserSearch userSearch) {
        return null;
    }
    @Override
    public R selectOne(String id) {
        return null;
    }
    @Override
    public R add(User user) {
        return null;
    }
    @Override
    public R upp(String id, User user) {
        return null;
    }
    @Override
    public R del(String id) {
        return null;
    }
}

可以看到这个版本比V1版本又方便了不少,优化了传输对象,不同业务可以创建不同对象进行传输.有一个小细节,方法上的@ResponseBody被我省略了,因为@RestController里已经包含了@ResponseBody注解.至此一个通用的API接口统一demo已经完成.

统一的参数效验

实践

人总是不断追求完美的,我也不例外,上面的v2版本虽然把基础接口都统一了,但是看到这么多字段需要效验也是爱不起来啊.比如在新增时,用户的昵称不可为空.用户的年龄最少也要是一岁等等效验再一次充斥着我的代码,看着满满的if判断,我得想办法优化一番.

v3版本

我们先引入一个spring的参数检验组件validation,该组件可以用注解很方便的校验入参,如果异常也可以直接通过捕捉相应的异常信息抛出.

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

下面列举一些常用的验证约束注解

  • @Null 被注解的元素必须为null
  • @NotNull 被注解的元素必须不为null
  • @AssertTure 被注解的元素必须为ture
  • @AssertFalse 被注解的元素必须为false
  • @Min(value) 被注解的元素必须是数字且必须大于等于指定值
  • @Max(value) 被注解的元素必须是数字且必须小于等于指定值
  • @DecimalMin(value) 被注解的元素必须是数字且必须大于等于指定值
  • @DecimalMax(value) 被注解的元素必须是数字且必须小于等于指定值
  • @Size(max, min) 被注解的元素必须在指定的范围内
  • @Digits(integer, fraction) 被注解的元素必须是数字且其值必须在给定的范围内
  • @Past 被注解的元素必须是一个过去的日期
  • @Future 被注解的元素必须是一个将来的日期
  • @Pattern(value) 被注解的元素必须符合给定正则表达式
  • @Email 被注解的元素必须是Email地址
  • @Length(min, max) 被注解的元素长度必须在指定的范围内
  • @NotEmpty 被注解的元素必须不为空,空字符串也不可以
  • @Range 被注解的元素(可以是数字或者表示数字的字符串)必须在给定的范围内
  • @URL 被注解的元素必须是URL
  • @Valid 对实体类进行校验

接下来我们开始改造我们的接口,BaseCrud中用到实体的地方可以加一下注解

    /**
     * @description: 分页查询接口
     * @author: chenyunxuan
     * @updateTime: 2020/12/18 11:40 上午
     */
    @GetMapping
    R selectList(@Validated @ModelAttribute S s);
    /**
     * @description: 新增单条数据
     * @author: chenyunxuan
     * @updateTime: 2020/12/18 1:39 下午
     */
    @PostMapping
    R add(@Validated @RequestBody T t);
    /**
     * @description: 修改单条数据
     * @author: chenyunxuan
     * @updateTime: 2020/12/18 1:39 下午
     */
    @PutMapping("{id}")
    R upp(@PathVariable String id, @Validated @RequestBody T t);

同时我们的实体类也要做相应的改造,加入你想要的校验注解

public class User {
    /**
     * @description: 自定义参数效验(电话号码校验)
     * @author: chenyunxuan
     * @updateTime: 2019-12-18 17:30
     */
    @MobileVail(groups = {Add.class})
    private String mobile;
    //用户名称最短两位,最长30位
    //这里的group分组后面会介绍其作用
    @Size(min = 2, max = 30, groups = {Upp.class})
    private String name;
    /**
     * @description: 自定义错误信息
     * @author: chenyunxuan
     * @updateTime: 2019-12-18 17:30
     */
    //校验注解都可以自定义message,可配合异常拦截返回你想要message
    @NotEmpty(message = "自定义错误信息,Email不能为空")
    @Email
    private String email;
    @NotNull
    @Min(18)
    @Max(100)
    private Integer age;
    @DateTimeFormat(pattern = "MM/dd/yyyy")
    //不可为空且必须是在系统时间之前
    @NotNull
    @Past
    private LocalDateTime birthday;
}

由上面的代码我们可以得出以下结论

  1. 校验注解可以叠加使用
  2. 在预设校验注解满足不了的时候可以自定义注解
  3. 可用分组实现不同业务需求,不同的校验方式
  4. 抛出的校验信息可自行设置
自定义校验注解

预设的校验有时是不可以满足业务校验要求的,比如电话号码校验,身份证校验等等.好在validation也想到了这部分需求,提供了ConstraintValidator接口可自定义匹配规则.

首先我们得自定义一个注解以及一个校验规则类

@Documented
// 指定真正实现校验规则的类
@Constraint(validatedBy = MobileValidator.class)
@Target( { ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface MobileVail {
    String message() default "不是正确的手机号码";

    Class<?>[] groups() default { };

    Class<? extends Payload>[] payload() default { };

    @Target({ElementType.METHOD,ElementType.FIELD,ElementType.PACKAGE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @interface List {
        MobileVail[] value();
    }
}

//ConstraintValidator接口使用了泛型,需要指定两个参数,第一个自定义注解类,第二个为需要校验的数据类型。
public class MobileValidator  implements ConstraintValidator<MobileVail, String> {
    //这里是具体的匹配规则
    private static final Pattern PHONE_PATTERN = Pattern.compile(
            "^((13[0-9])|(15[^4])|(18[0,2,3,5-9])|(17[0-8])|(147))\\d{8}$"
    );

    @Override
    public void initialize(MobileVail constraintAnnotation) {

    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        //实现验证方法
        if ( value == null || value.length() == 0 ) {
            return false;
        }
        Matcher m = PHONE_PATTERN.matcher(value);
        return m.matches();
    }
}

做完这步后只需要在需要验证的字段上加上@MobileVail就可以愉快的使用了

分组验证

看v3实现的代码在验证校验name和mobile属性时候,我们用到了分组功能,这是为了不同的业务场景采用不同的校验方式.

    ......
    @MobileVail(groups = {Add.class})
    private String mobile;
    @Size(min = 2, max = 30, groups = {Upp.class})
    private String name;
    ......

Add和Upp类定义比较简单,一个空的注解类就OK

public @interface Add {}
public @interface Upp {}

完成这一步后只需要在不同业务中加入不同的组就会把校验按组隔离开,比如上面用户的例子,在新增数据时,我们需要校验电话号码的正确性而修改的时候则不需要验证,在修改的时候我们需要验证用户的昵称长度,新增的时候不需要验证,我们只需要在@Validated中注明他的分组即可.

    @PostMapping
    R add(@Validated(value = Add.class) @RequestBody T t);
    @PutMapping("{id}")
    R upp(@PathVariable String id, @Validated(value = Upp.class) @RequestBody T t);
捕捉异常

在使用validation后,如果不自定义捕捉异常,抛出的异常信息很详细,给客户端提示不友好,所以我们需要拦截这部分异常,自定义抛出message.这需要用到上篇文章提到的统一异常拦截类.

@ControllerAdvice
@Log4j2
public class GlobalExceptionHandler {
    ......
    /**
     * @description: JSON传值出现异常(对应@RequestBody传值错误)
     * @author: chenyunxuan
     * @date: 2019-12-18 16:37
     * @version: 1.0.0
     * @updateTime: 2019-12-18 16:37
     */
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    @ResponseBody
    public R handleMethodArgumentNotValidException(HttpServletRequest req, MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        StringBuilder sb = new StringBuilder();
        sb.append("url=");
        sb.append(req.getRequestURI().replace("/", ""));
        sb.append(",");
        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            sb.append("field=");
            sb.append(fieldError.getObjectName());
            sb.append(".");
            sb.append(fieldError.getField());
            sb.append(",error=");
            sb.append(fieldError.getDefaultMessage());
            sb.append(";");
        }
        String msg = sb.toString();
        log.error(String.format("MethodArgumentNotValidException RequestURI:%s msg:%s", req.getRequestURI(), msg), e);
        return ResultUtil.error(400, bindingResult.getFieldError().getDefaultMessage());
    }

    /**
     * @title: 单个参数参数异常(对应单个参数传值错误)
     * @author: chenyunxuan
     * @date: 2019-12-18 16:37
     * @version: 1.0.0
     * @updateTime: 2019-12-18 16:37
     */
    @ExceptionHandler(value = ConstraintViolationException.class)
    @ResponseBody
    public R handleMethodArgumentNotValidException(HttpServletRequest req, ConstraintViolationException e) {
        log.error(String.format("ConstraintViolationException RequestURI:%s", req.getRequestURI()), e);
        return ResultUtil.error(400, e.getMessage());
    }

    /**
     * @title: 提交FORM参数异常(对应form表单传值错误)
     * @author: chenyunxuan
     * @date: 2019-12-18 16:41
     * @version: 1.0.0
     * @updateTime: 2019-12-18 16:41
     */
    @ExceptionHandler(value = BindException.class)
    @ResponseBody
    public R handleBindException(HttpServletRequest req, BindException e) throws BindException {
        // ex.getFieldError():随机返回一个对象属性的异常信息。如果要一次性返回所有对象属性异常信息,则调用ex.getAllErrors()
        FieldError fieldError = e.getFieldError();
        StringBuilder sb = new StringBuilder();
        sb.append(fieldError.getDefaultMessage());
        // 生成返回结果
        log.error("BindException requestURI:{} paramName:{} msg:{}", req.getRequestURI(), e.getObjectName(), fieldError.getDefaultMessage());
        return ResultUtil.error(400, fieldError.getDefaultMessage());
    }
}

v4版本

API接口部分会使用到HttpServletRequestHttpServletResponse里的数据,如何方便的获取到这两个对象,常规做法就是每次用到的时候加在对应的控制层方法入参里.

public R selectOne(String id, HttpServletRequest request) {
    return null;
}

这样在切面打印入参的时候用多出一个request对象,多处用到的话也要一搜索全都是相同HttpServletRequest对象,这里我选择用一个抽象类去注入这两个对象

/**
 * @description: 控制层基类
 * @author: chenyunxuan
 * @updateTime: 2020/12/18 3:36 下午
 */
public abstract class BaseController {
    @Autowired
    protected HttpServletRequest request;
    @Autowired
    protected HttpServletResponse response;
}

然后在控制层继承这个抽象类,就可以直接在方法里愉快的使用request和response对象了

public class UserController extends BaseController implements BaseCrud<User, UserSearch> {

    @Override
    public R selectList(UserSearch userSearch) {
        request.getRequestURI();
        return null;
    }
}

后记

经过了四个版本的优化,给大家呈现的是一个完整的业务控制层脚手架

  1. 统一基础CRUD请求地址
  2. 优化入参校验
  3. 优化request和response对象获取方式

有更多技巧可以在评论区交流,希望能和大家交流编写更令人愉悦的API接口.下篇文章准备从接口版本控制入手,讲述接口新旧接口版本控制上的小技巧.欢迎持续关注.

展开阅读全文
打赏
0
3 收藏
分享
加载中
打赏
1 评论
3 收藏
0
分享
返回顶部
顶部