前后端分离必备的接口规范

前后端分离必备的接口规范_第1张图片

前言

  随着互联网的高速发展,前端页面的展示、交互体验越来越灵活、炫丽,响应体验也要求越来越高,后端服务的高并发、高可用、高性能、高扩展等特性的要求也愈加苛刻,从而导致前后端研发各自专注于自己擅长的领域深耕细作。然而带来的另一个问题:前后端的对接界面双方却关注甚少,不知大家是否还记得下面这张图:
前后端分离必备的接口规范_第2张图片
  没有任何接口约定规范情况下各自干各自的,导致我们在产品项目开发过程中,前后端的接口联调对接工作量占比在30%-50%左右,甚至会更高,往往前后端接口联调对接及系统间的联调对接都是整个产品项目研发的软肋。
  本文的主要初衷就是规范约定先行,尽量避免沟通联调产生的不必要的问题,让大家身心愉快地专注于各自擅长的领域。

一、概述

1.1 为何要分离

  目前现有前后端开发模式:“后端为主的MVC时代”,如下图所示:
前后端分离必备的接口规范_第3张图片

  代码可维护性得到明显好转,MVC 是个非常好的协作模式,从架构层面让开发者懂得什么代码应该写在什么地方。为了让 View 层更简单干脆,还可以选择 Velocity、Freemaker 等模板,使得模板里写不了 Java 代码。
  看起来是功能变弱了,但正是这种限制使得前后端分工更清晰。然而依旧并不是那么清晰,这个阶段的典型问题是:

  • 前端开发重度依赖开发环境,开发效率低。

  这种架构下,前后端协作有两种模式:一种是前端写demo,写好后,让后端去套模板 。淘宝早期包括现在依旧有大量业务线是这种模式。好处很明显,demo 可以本地开发,很高效。不足是还需要后端套模板,有可能套错,套完后还需要前端确定,来回沟通调整的成本比较大。
  另一种协作模式是前端负责浏览器端的所有开发和服务器端的 View 层模板开发,支付宝是这种模式。好处是 UI 相关的代码都是前端去写就好,后端不用太关注,不足就是前端开发重度绑定后端环境,环境成为影响前端开发效率的重要因素。

  • 前后端职责依旧纠缠不清。

  Velocity 模板还是蛮强大的,变量、逻辑、宏等特性,依旧可以通过拿到的上下文变量来实现各种业务逻辑。这样,只要前端弱势一点,往往就会被后端要求在模板层写出不少业务代码。
  还有一个很大的灰色地带是 Controller,页面路由等功能本应该是前端最关注的,但却是由后端来实现。Controller 本身与 Model 往往也会纠缠不清,看了让人咬牙的业务代码经常会出现在 Controller 层。这些问题不能全归结于程序员的素养,否则 JSP 就够了。

  • 对前端发挥的局限。

  性能优化如果只在前端做空间非常有限,于是我们经常需要后端合作才能碰撞出火花,但由于后端框架限制,我们很难使用Comet、Bigpipe等技术方案来优化性能。

1.2 什么是分离

  我们现在要做的前后分离第一阶段:“基于 Ajax 带来的 SPA 时代”,如图:
前后端分离必备的接口规范_第4张图片

  这种模式下,前后端的分工非常清晰,前后端的关键协作点是 Ajax 接口。看起来是如此美妙,但回过头来看看的话,这与 JSP 时代区别不大。复杂度从服务端的 JSP 里移到了浏览器的 JavaScript,浏览器端变得很复杂。类似 Spring MVC,这个时代开始出现浏览器端的分层架构:
前后端分离必备的接口规范_第5张图片

对于这一SPA阶段,前后端分离有几个重要挑战:

  • 前后端接口的约定。

  如果后端的接口一塌糊涂,如果后端的业务模型不够稳定,那么前端开发会很痛苦。关于这一块儿,在阿里有不少团队也有类似尝试,通过接口规则、接口平台等方式来做。有了和后端一起沉淀的接口规则,还可以用来模拟数据,使得前后端可以在约定接口后实现高效并行开发。

  • 前端开发的复杂度控制。

  SPA 应用大多以功能交互型为主,JavaScript 代码过十万行很正常。大量 JS 代码的组织,与 View 层的绑定等,都不是容易的事情。业界有很多的知名公司、技术大咖都提出过处理方法,可依旧存在大量空白区域需要挑战。

1.3 如何做分离

1.3.1 职责分离

前后端分离必备的接口规范_第6张图片

  • 前后端仅仅通过异步接口(AJAX/JSONP)来编程

  • 前后端都各自有自己的开发流程、构建工具、测试集合等

  • 关注点分离,前后端变得相对独立并松耦合

    后端 前端
    提供数据 接收数据,返回数据
    处理业务逻辑 处理渲染逻辑
    MVC架构 MV*结构
    代码跑在服务器上 代码跑在浏览器上

1.3.2 开发流程

  • 后端编写和维护接口文档,在 API 变化时更新接口文档
  • 后端根据接口文档进行接口开发
  • 前端根据接口文档进行开发 + Mock平台
  • 开发完成后联调和提交测试

Mock 服务器根据接口文档自动生成 Mock 数据,实现了接口文档即API:
前后端分离必备的接口规范_第7张图片

1.4 具体实施

现在已基本完成了,接口方面的实施:

  • 接口文档服务器:可实现接口变更实时同步给前端展示;
  • Mock接口数据平台:可实现接口变更实时Mock数据给前端使用;
  • 接口规范定义:很重要,接口定义的好坏直接影响到前端的工作量和实现逻辑;
    前后端分离必备的接口规范_第8张图片

二、接口规范

  一个后端接口大致分为四个部分组成:接口地址(url)、接口请求方式(get、post等)、请求数据(request)、响应数据(response)。虽然说后端接口的编写并没有统一规范要求,而且如何构建这几个部分每个公司要求都不同,也没有什么“一定是最好的”标准,但其中最重要的关键点就是看是否规范。
  因为讲解的重点是后端接口,所以需要导入一个 spring-boot-starter-web 包,而lombok作用是简化类,如下所示:

<dependency>
    
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-validationartifactId>
dependency>

<dependency>
    <groupId>com.github.xiaoymingroupId>
    <artifactId>knife4j-spring-boot-starterartifactId>
    
    <version>x.y.zversion>
dependency>

<dependency>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starter-webartifactId>
dependency>

<dependency>
    <groupId>org.projectlombokgroupId>
    <artifactId>lombokartifactId>
    <optional>trueoptional>
dependency>

2.1 规范原则

  • 接口返回数据即显示:前端仅做渲染逻辑处理。
  • 渲染逻辑禁止跨多个接口调用;
  • 前端关注交互、渲染逻辑,尽量避免业务逻辑处理的出现。
  • 请求响应传输数据格式:JSON,JSON数据尽量简单轻量,避免多级JSON的出现。

2.2 接口请求

  Controller 层在MVC设计中属于控制层,其设计初衷就是接受请求并响应请求,所以,该层尽量轻薄,避免编写涉及业务处理的代码。前后端分离的开发设计模式下,推荐使用 @RestController 注解,它相当于**@ResponseBody** + @Controller 的组合使用。

2.2.1 请求基本格式

GET请求、POST请求 必须包含key为body的入参,所有请求数据包装为JSON格式,并存放到入参body中,示例如下:

  • GET请求:

    xxx/login?body={"username":"admin","password":"123456","captcha":"scfd","rememberMe":1}
    
  • POST请求:

    在这里插入图片描述

2.2.2 请求方式

  使用注解@RequestMapping、@GetMapping、@PostMapping、@PutMapping、@DeleteMapping、@PatchMapping 映射到特定的处理方法上,来帮助简化常用的HTTP方法的映射,并更好地表达被注解方法的语义。注意,按照不同业务进行划分使用,避免乱写乱用。

  • @GetMapping是一个组合注解,它是@RequestMapping(method = RequestMethod.GET)的缩写
  • @PostMapping是一个组合注解,它是@RequestMapping(method = RequestMethod.POST)的缩写
  • @PutMapping是一个组合注解,它是@RequestMapping(method = RequestMethod.PUT)的缩写
  • @DeleteMapping是一个组合注解,它是@RequestMapping(method = RequestMethod.DELETE)的缩写
  • @PatchMapping是一个组合注解,它是@RequestMapping(method = RequestMethod.PATCH)的缩写

2.2.3 请求参数

  至于请求参数方式,需要按照不同业务进行划分使用,例如:

  • 表单提交,直接使用vo类或具体参数名接收

    @RestController
    public class LoginController {
        @RequestMapping(value = "login", method = RequestMethod.POST)
        public UserVO login(UserVO user){
            return user;
        }
    }
    
  • @RequestParam

    /**
     * @RequestParam 有三个属性:
     * 		value:请求参数名(必须配置)
     * 		required:是否必需,默认为 true,即 请求中必须包含该参数,如果没有包含,将会抛出异常(可选配置)
     * 		defaultValue:默认值,如果设置了该值,required 将自动设为 false
     */
    @RequestParam(value="", required=true, defaultValue="")
    

    例如:

    @PostMapping("/show")
    public Responses show(@RequestParam(value="userId",defaultValue="-1") Long userId) {
        Record data = recordService.getOne(vo.getId());
        return Responses.success(data);
    }
    
  • 提交,使用注解@RequestBody

    @RequestBody 主要用来接收前端以 POST 方式传递给后端的json字符串中的数据的(请求体中的数据的),GET方式无请求体,所以使用 @RequestBody 接收数据时,前端不能使用 GET 方式提交数据,而是用 POST 方式进行提交。在后端的同一个接收方法里,@RequestBody、@RequestParam 可以同时使用,但 @RequestBody 最多只能有一个,而 @RequestParam 可以有多个。

    @PostMapping("/get")
    public Responses getOne(@Validated @RequestBody IdVO vo){
        Record data = recordService.getOne(vo.getId());
        return Responses.success(data);
    }
    
  • PathVariable

    @RestController
    @RequestMapping("/")
    public class ChineseDrugController {
    	@ResponseBody
    	@RequestMapping(value = "/{name}")
    	public String showName(@PathVariable String name, @PathVariable(value = "name", required = false) String sex) {
    		return "Hello " + name + sex;
    	}
    }
    
  • @PathParam

    @RestController
    @RequestMapping(value = "/{sex}")
    public class ChineseDrugController {
        @ResponseBody
        @RequestMapping(value = "/{name}")
        public String showName(@PathVariable(value = "name") String name, @PathParam(value = "sex") String sex) {
            return "Hello " + name + " " + sex;
        }
    }
    

说明:以上代码仅仅展示功能上有很好的灵活性,实际开发中避免如此任意使用 。

2.2.4 校验请求参数

  我们可以使用注解 @Validated,使得参数自动校验生效,它是spring-contex中的注解。vo类中可以自定义各类校验,比如@NotNull等,它是 javax 下 validation-api 中的注解,此处不赘述。然后就是程序层面的校验。

@PostMapping("/applicationTypeBind")
public Boolean applicationTypeBind(@Validated @RequestBody ApplicationBindVO vo){
    applicationTypeService.applicationTypeBind(vo);
    return true;
}
  • 对应VO类示例
import lombok.Data; 
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.List;
import java.util.Set;

@Data
public class ApplicationBindVO {

    @NotNull
    private Long typeId;
    private List<Long> applicationIdList;
}

2.3 接口响应

  依据业务而定,格式尽量做到统一。响应前端(APP/PC)的参数,一般再封装一层,方便前端统一处理。

2.3.1 响应基本格式

{
    "code":200,
    "message":"success"
}
  • code : 请求处理状态编码

    返回状态值 说明
    200 请求处理成功
    500 请求处理失败
    401 请求未认证,跳转登录页
    403 请求未授权,跳转未授权提示页
  • message: 请求处理消息,即响应消息。

    • code=200 且 message=“success”: 请求处理成功。
    • code=200 且 message!=“success”: 请求处理成功,message内容为普通消息提示。
    • code=500: 请求处理失败,message内容为警告消息提示。

2.3.2 响应实体格式

{
    "code":200,
    "message":"success",
    "data":{
        "id":1,
        "name":"XXX",
        "code":"XXX"
    }
}
  • data:响应返回的实体数据

2.3.3 响应列表格式

{
    "code":200,
    "message":"success",
    "data":[
        {
            "id":1,
            "name":"XXX",
            "code":"XXX"
        }
    ]
}
  • data.list: 响应返回的列表数据

2.3.4 响应分页格式

{
    "code":200,
    "message":"success",
    "data":{
        "recordCount":2,
        "totalCount":2,
        "pageNo":1,
        "pageSize":10,
        "list":[
            {
                "id":1,
                "name":"XXX",
                "code":"H001"
            },
            {
                "id":2,
                "name":"XXX",
                "code":"H002"
            }
        ],
        "totalPage":1
    }
}
  • data.recordCount: 当前页记录数
  • data.totalCount: 总记录数
  • data.pageNo: 当前页码
  • data.pageSize: 每页大小
  • data.totalPage: 总页数

2.3.5 特殊内容规范

2.3.5.1 下拉框、复选框、单选框

  由后端接口统一逻辑判定是否选中,通过isSelect标示是否选中,示例如下:

{
    "code":200,
    "message":"success",
    "data":[
        {
            "id":1,
            "name":"XXX",
            "code":"H001",
            "isSelect":1
        },
        {
            "id":2,
            "name":"XXX",
            "code":"H002",
            "isSelect":0
        }
    ]
}

禁止下拉框、复选框、单选框判定选中逻辑由前端来处理,统一由后端逻辑判定选中返回给前端展示。

2.3.5.2 Boolean类型

  关于Boolean类型,JSON数据传输中一律使用1/0来标示,1为是/True,0为否/False。

2.3.5.3 日期类型

  关于日期类型,JSON数据传输中一律使用字符串,具体日期格式因业务而定。

三、参数校验

3.1 介绍

  一个接口一般对参数(请求数据)都会进行安全校验,参数校验的重要性自然不必多说,那么如何对参数进行校验就有讲究了。一般来说有三种常见的校验方式,我们使用了最简洁的第三种方法:业务层校验Validator + BindResult校验Validator + 自动抛出异常
  其中,业务层校验无需多说,即手动在 Service 层进行数据校验判断。不过这样太繁琐了,光校验代码就会有很多。而使用 Validator+ BindingResult 已经是非常方便实用的参数校验方式了,在实际开发中也有很多项目就是这么做的,不过这样还是不太方便,因为每写一个接口都要添加一个BindingResult参数,然后再提取错误信息返回给前端(简单看一下)。

import org.springframework.validation.BindingResult;

@PostMapping("/addUser")
public String addUser(@RequestBody @Validated User user, BindingResult bindingResult) {
    // 如果有参数校验失败,会将错误信息封装成对象组装在BindingResult里
    List<ObjectError> allErrors = bindingResult.getAllErrors();
    if (bindingResult != null && bindingResult.hasErrors()) {
        return allErrors.stream()
            .map(o->o.getDefaultMessage())
            .collect(Collectors.toList()).toString();
    }
    // 返回默认的错误信息
    // return allErrors.get(0).getDefaultMessage();
    return validationService.addUser(user);
}

3.2 Validator + 自动抛出异常

  而 Validator 内置参数校验如下:

注解 校验功能
@AssertFalse 限制必须为false
@AssertTrue 限制必须为true
@DecimalMax(value) 限制必须为一个不大于指定值的数字
@DecimalMin(value) 限制必须为一个不小于指定值的数字
@Digits(integer,fraction) 限制必须为一个小数,且整数部分的位数不能超过integer,小数部分的位数不能超过fraction
@Email 校验是否符合Email格式
@Future 限制必须是一个将来的日期
@FutureOrPresent 当前或将来时间
@Max(value) 限制必须为一个不大于指定值的数字
@Min(value) 限制必须为一个不小于指定值的数字
@Negative 绝对的负数,不能包含零,空元素有效可以校验通过
@NegativeOrZero 包含负数和零,空元素有效可以校验通过
@NotBlank 验证注解的元素值不为空(不为null、去除首位空格后长度为0)。
@NotBlank只应用于字符串且在比较时会去除字符串的空格
@NotEmpty 验证注解的元素值不为null且不为空(字符串长度不为0、集合大小不为0)
@NotNull 限制必须不为null
@Null 限制只能为null
@Past 限制必须是一个过去的日期
@PastOrPresent 必须是过去的时间,包含现在
@Pattern(value) 限制必须符合指定的正则表达式
@Positive 绝对的正数,不能包含零,空元素有效可以校验通过
@PositiveOrZero 包含正数和零,空元素有效可以校验通过
@Size(max,min) 限制字符长度必须在min到max之间

  首先Validator可以非常方便的制定校验规则,并自动帮你完成校验。首先在入参里需要校验的字段加上注解,每个注解对应不同的校验规则,并可制定校验失败后的信息:

@Data
public class User {
    @NotNull(message = "用户id不能为空")
    private Long id;

    @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 = "用户邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
}

  校验规则和错误提示信息配置完毕后,接下来只需要在接口仅需要在校验的参数上加上@Valid注解(去掉BindingResult后会自动引发异常,异常发生了自然而然就不会执行业务逻辑):

@RestController
@RequestMapping("user")
public class ValidationController {

    @Autowired
    private ValidationService validationService;

    @PostMapping("/addUser")
    public String addUser(@RequestBody @Validated User user) {
        return validationService.addUser(user);
    }
}

  现在我们进行测试,打开knife4j文档地址,当输入的请求数据为空时,Validator会将所有的报错信息全部进行返回。

3.3 分组校验和递归校验

  分组校验有三个步骤:1、定义一个分组类(或接口);2、在校验注解上添加groups属性指定分组;3、Controller方法的@Validated注解添加分组类。如下所示:

public interface Update extends Default{
}

@Data
public class User {
    @NotNull(message = "用户id不能为空",groups = Update.class)
    private Long id;
  ......
}

@PostMapping("update")
public String update(@Validated({Update.class}) User user) {
    return "success";
}

  如果 Update 不继承 Default,@Validated({Update.class}) 就只会校验属于 Update.class 分组的参数字段;如果继承了,会校验了其他默认属于 Default.class 分组的字段。
  对于递归校验(比如类中类),只要在相应属性类上增加@Valid注解即可实现(对于集合同样适用)

3.4 自定义校验

  Spring Validation允许用户自定义校验,实现很简单,分两步:1、自定义校验注解;2、编写校验者类。如下所示:

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {HaveNoBlankValidator.class})// 标明由哪个类执行校验逻辑
public @interface HaveNoBlank {

    // 校验出错时默认返回的消息
    String message() default "字符串中不能含有空格";
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
    
    /**
     * 同一个元素上指定多个该注解时使用
     */
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
    @Retention(RUNTIME)
    @Documented
    public @interface List {
        NotBlank[] value();
    }
}

public class HaveNoBlankValidator implements ConstraintValidator<HaveNoBlank, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // null 不做检验
        if (value == null) {
            return true;
        }
        // 校验失败
        return !value.contains(" ");
        // 校验成功
    }
}

四、全局异常处理

  参数校验失败会自动引发异常,我们当然不可能再去手动捕捉异常进行处理。但我们又不想手动捕捉这个异常,又要对这个异常进行处理,那正好使用SpringBoot全局异常处理来达到一劳永逸的效果!

4.1 基本使用

  首先,我们需要新建一个类,在这个类上加上 @ControllerAdvice@RestControllerAdvice 注解,这个类就配置成全局处理类了。至于使用哪个注解,这个根据 Controller 层用的是 @Controller 还是 @RestController 来决定。
  然后在类中新建方法,在方法上加上 @ExceptionHandler 注解并指定想要处理的异常类型,接着在方法内编写对该异常的操作逻辑,就完成了对该异常的全局处理!我们现在就来演示一下对参数校验失败抛出的 MethodArgumentNotValidException 全局处理:

import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
@ResponseBody
public class ExceptionControllerAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public String MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {
        // 从异常对象中拿到ObjectError对象
        ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
        // 然后提取错误提示信息进行返回
        return objectError.getDefaultMessage();
    }

    /**
     * 系统异常 预期以外异常
     */
    @ExceptionHandler(Exception.class)
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public ResultVO<?> handleUnexpectedServer(Exception ex) {
        log.error("系统异常:", ex);
        // GlobalMsgEnum.ERROR是我自己定义的枚举类
        return new ResultVO<>(GlobalMsgEnum.ERROR);
    }

    /**
     * 所以异常的拦截
     */
    @ExceptionHandler(Throwable.class)
    @ResponseStatus(value = HttpStatus.INTERNAL_SERVER_ERROR)
    public ResultVO<?> exception(Throwable ex) {
        log.error("系统异常:", ex);
        return new ResultVO<>(GlobalMsgEnum.ERROR);
    }
}

  我们再次进行测试,这次返回的就是我们制定的错误提示信息!我们通过全局异常处理优雅的实现了我们想要的功能!以后我们再想写接口参数校验,就只需要在入参的成员变量上加上Validator校验规则注解,然后在参数上加上@Valid注解即可完成校验,校验失败会自动返回错误提示信息,无需任何其他代码!
前后端分离必备的接口规范_第9张图片

4.2 自定义异常

  在很多情况下,我们需要手动抛出异常,比如在业务层当有些条件并不符合业务逻辑,而使用自定义异常有诸多优点:

  • 自定义异常可以携带更多的信息,不像这样只能携带一个字符串。
  • 项目开发中经常是很多人负责不同的模块,使用自定义异常可以统一了对外异常展示的方式。
  • 自定义异常语义更加清晰明了,一看就知道是项目中手动抛出的异常。

  我们现在就来开始写一个自定义异常:

import lombok.Getter;

@Getter
public class APIException extends RuntimeException {
    private int code;
    private String msg;

    public APIException() {
        this(1001, "接口错误");
    }

    public APIException(String msg) {
        this(1001, msg);
    }

    public APIException(int code, String msg) {
        super(msg);
        this.code = code;
        this.msg = msg;
    }
}

  然后在刚才的全局异常类中加入如下:

//自定义的全局异常
@ExceptionHandler(APIException.class)
public String APIExceptionHandler(APIException e) {
    return e.getMsg();
}

  这样就对异常的处理就比较规范了,当然还可以添加对Exception的处理,这样无论发生什么异常我们都能屏蔽掉然后响应数据给前端,不过建议最后项目上线时这样做,能够屏蔽掉错误信息暴露给前端,在开发中为了方便调试还是不要这样做。
  另外,当我们抛出自定义异常的时候全局异常处理只响应了异常中的错误信息msg给前端,并没有将错误代码code返回,这还需要配合数据统一响应。

五、接口版本控制

  系统上线后,随着需求的变化,产品迭代过程中,同一个接口可能同时存在多个版本,不同版本的接口URL、参数相同,可能就是内部逻辑不同。尤其是在同一接口需要同时支持旧版本和新版本的情况下,比如APP发布新版本了,有的用户可能不选择升级。为了兼容新老用户的使用方便,需要提供不同需求调用不同接口版本来实现。

5.1 简介

  在 Spring Boot 项目中,如果要进行 restful 接口的版本控制一般有以下几个方向:

  • 基于path的版本控制
  • 基于header的版本控制

  在 Spring MVC 下,url映射到哪个 method 是由 RequestMappingHandlerMapping 来控制的,那么我们也是通过 RequestMappingHandlerMapping 来做版本控制的。

5.2 Path控制实现

5.2.1 定义注解

  首先,我们需要自定义ApiVersion注解,以实现随机获取路径的版本号。

import java.lang.annotation.*;
/**
 * 版本控制
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface ApiVersion {
    /**
     * 版本。x.y格式
     * 默认接口版本号1.0.0开始
     */
    String value() default "1.0";
}

然后自定义ApiVersion注解,是否开启API版本控制。

@Target(ElementType.TYPE)
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Import(ApiAutoConfiguration.class)
public @interface EnableApiVersion {
    
}

  在启动类上添加这个注解后就可以开启接口的多版本支持。使用Import引入配置ApiAutoConfiguration。

5.2.2 注解实现

  ApiVersionCondition 用来控制当前request 指向哪个method,其自定义实现了RequestCondition接口,通过正则表达式获得需要匹配的版本号。

import org.springframework.web.servlet.mvc.condition.RequestCondition;
import javax.servlet.http.HttpServletRequest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
    // 路径中版本的前缀, 这里用 /v[1-9]/的形式
    private static final Pattern VERSION_PREFIX_PATTERN = Pattern.compile("v(\\d+\\.\\d+)");
    private final String version;

    public ApiVersionCondition(String version) {
        this.version = version;
    }

    /**
     * 方法和类上都存在相同的条件时的处理方法
     */
    @Override
    public ApiVersionCondition combine(ApiVersionCondition other) {
        // 采用最后定义优先原则,则方法上的定义覆盖类上面的定义
        return new ApiVersionCondition(other.getApiVersion());
    }

    /**
     * 判断是否符合当前请求,返回null表示不符合
     */
    @Override
    public ApiVersionCondition getMatchingCondition(HttpServletRequest httpServletRequest) {
        Matcher m = VERSION_PREFIX_PATTERN.matcher(httpServletRequest.getRequestURI());
        if (m.find()) {
            String pathVersion = m.group(1);
            // 该方法是只要大于等于最低接口version即匹配成功,需要和compareTo()配合
            // 举例:定义有1.0/1.1接口,访问1.2,则实际访问的是1.1,如果从小开始那么排序反转即可
            if(Float.parseFloat(pathVersion) >= Float.parseFloat(version)){
                return this;
            }
        }
        return null;
    }

    /**
     * 如果存在多个符合条件的接口,则会根据这个来排序,然后用集合的第一个元素来处理
     */
    @Override
    public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
        // 优先匹配最新的版本号,和getMatchingCondition注释掉的代码同步使用
        return other.getApiVersion().compareTo(this.version);
    }

    public String getApiVersion() {
        return version;
    }
}

5.2.3 配置类注入容器

  PathVersionHandlerMapping 用于注入spring用来管理

public class PathVersionHandlerMapping extends RequestMappingHandlerMapping {

    @Override
    protected boolean isHandler(Class<?> beanType) {
        return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
    }

    @Override
    protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
        ApiVersion apiVersion = AnnotationUtils.findAnnotation(handlerType,ApiVersion.class);
        return createCondition(apiVersion);
    }

    @Override
    protected RequestCondition<?> getCustomMethodCondition(Method method) {
        ApiVersion apiVersion = AnnotationUtils.findAnnotation(method,ApiVersion.class);
        return createCondition(apiVersion);
    }

    private RequestCondition<ApiVersionCondition>createCondition(ApiVersion apiVersion) {
        return apiVersion == null ? null : new ApiVersionCondition(apiVersion.value());
    }
}

  WebMvcConfiguration 配置类让spring来接管

@Configuration
public class WebMvcConfiguration implements WebMvcRegistrations {

    @Override
    public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
        return new PathVersionHandlerMapping();
    }
}

5.2.4 测试

  最后controller进行测试,默认是v1.0,如果方法上有注解,以方法上的为准(该方法vx.x在路径任意位置出现都可解析)。根据代码逻辑如果version是v1 那么就会匹配到v1的方法,否则则会匹配到默认方法。

@RestController
@ApiVersion
@RequestMapping(value = "/{version}/test")
public class TestController {

    @GetMapping(value = "one")
    public String query(){
        return "test api default";
    }

    @GetMapping(value = "one")
    @ApiVersion("1.1")
    public String query2(){
        return "test api v1.1";
    }

    @GetMapping(value = "one")
    @ApiVersion("3.1")
    public String query3(){
        return "test api v3.1";
    }
}

5.2 header控制实现

总体原理与Path类似,修改ApiVersionCondition 即可,之后访问时在header带上X-VERSION参数即可

public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
    private static final String X_VERSION = "X-VERSION";
    private final String version ;

    public ApiVersionCondition(String version) {
        this.version = version;
    }

    @Override
    public ApiVersionCondition combine(ApiVersionCondition other) {
        // 采用最后定义优先原则,则方法上的定义覆盖类上面的定义
        return new ApiVersionCondition(other.getApiVersion());
    }

    @Override
    public ApiVersionCondition getMatchingCondition(HttpServletRequest httpServletRequest) {
        String headerVersion = httpServletRequest.getHeader(X_VERSION);
        // 这个方法是精确匹配
        if(Objects.equals(version,headerVersion)){
            return this;
        }
        return null;
    }

    @Override
    public int compareTo(ApiVersionCondition apiVersionCondition, HttpServletRequest httpServletRequest) {
        return 0;
    }
    public String getApiVersion() {
        return version;
    }
}

六、API接口安全

6.1 简介

  APP、前后端分离项目都采用API接口形式与服务器进行数据通信,传输的数据被偷窥、被抓包、被伪造时有发生,那么如何设计一套比较安全的API接口方案至关重要,一般的解决方案有以下几点:

  • Token授权认证,防止未授权用户获取数据。
  • 时间戳超时机制。
  • URL签名,防止请求参数被篡改。
  • 防重放,防止接口被第二次请求,防采集。
  • 采用HTTPS通信协议,防止数据明文传输。

6.2 Token授权认证

  因为HTTP协议是无状态的,Token的设计方案是用户在客户端使用用户名和密码登录后,服务器会给客户端返回一个Token,并将Token以键值对的形式存放在缓存(一般是Redis)中,后续客户端对需要授权模块的所有操作都要带上这个Token,服务器端接收到请求后进行Token验证,如果Token存在,说明是授权的请求。因此,Token生成的设计有如下要求:

  • 应用内一定要唯一,否则会出现授权混乱,例如A用户看到了B用户的数据。
  • 每次生成的Token一定要不一样,防止被记录,授权永久有效。
  • 一般Token对应的是Redis的key,value存放的是这个用户相关缓存信息,比如:用户的id。
  • 要设置Token的过期时间,过期后需要客户端重新登录,获取新的Token,如果Token有效期设置较短,会反复需要用户登录,体验比较差。
  • 单独设计刷新Token的接口,但是一定要注意刷新机制和安全问题。

  根据上面的设计方案要求,我们很容易得到Token=md5(用户ID+登录的时间戳+服务器端秘钥)这种方式来获得Token,因为用户ID是应用内唯一的,登录的时间戳保证每次登录的时候都不一样,服务器端秘钥是配置在服务器端参与加密的字符串(即:盐),目的是提高Token加密的破解难度,注意一定不要泄漏。

6.3 时间戳超时机制

  客户端每次请求接口都带上当前时间的时间戳timestamp,服务端接收到timestamp后跟当前时间进行比对,如果时间差大于一定时间(比如:1分钟),则认为该请求失效。时间戳超时机制是防御DOS攻击的有效手段。 例如http://url/getInfo?id=1&timetamp=1661061696

6.4 URL签名

  写过支付宝或微信支付对接的同学肯定对URL签名不陌生,我们只需要将原本发送给server端的明文参数做一下签名,然后在server端用相同的算法再做一次签名,对比两次签名就可以确保对应明文的参数有没有被中间人篡改过。例如id=1&timetamp=1559396263&sign=e10adc3949ba59abbe56e057f20f883e
  签名算法过程也比较简单,首先对通信的参数按key进行字母排序放入数组中,对排序完的数组键值对用&进行连接,形成用于加密的参数字符串。接着在加密的参数字符串前面或者后面加上私钥,然后用md5进行加密,得到sign,然后随着请求接口一起传给服务器。服务器端接收到请求后,用同样的算法获得服务器的sign,对比客户端的sign是否一致,如果一致请求有效。

6.5 防重放

  客户端第一次访问时,将签名sign存放到服务器的Redis中,超时时间设定为跟时间戳的超时时间一致,二者时间一致可以保证无论在timestamp限定时间内还是外 URL都只能访问一次,如果被非法者截获,使用同一个URL再次访问,如果发现缓存服务器中已经存在了本次签名,则拒绝服务。
  如果在缓存中的签名失效的情况下,有人使用同一个URL再次访问,则会被时间戳超时机制拦截,这就是为什么要求sign的超时时间要设定为跟时间戳的超时时间一致。拒绝重复调用机制确保URL被别人截获了也无法使用,如爬虫程序抓取数据。
  防重放的设计方法有很多种,但多是大同小异,如下所示:

  1. 客户端通过用户名密码登录服务器并获取Token。
  2. 客户端生成时间戳timestamp,并将timestamp作为其中一个参数。
  3. 客户端将所有的参数,包括Token和timestamp按照自己的签名算法进行排序加密得到签名sign。
  4. 将token、timestamp和sign作为请求时必须携带的参数加在每个请求的URL后边,例:http://url/request?token=h4f583a&timetamp=1559396263&sign=e10ad3e
  5. 服务端对token、timestamp和sign进行验证,只有在token有效、timestamp未超时、缓存服务器中不存在sign三种情况同时满足,本次请求才有效。

6.6 采用HTTPS通信协议

  安全套接字层超文本传输协议HTTPS,为了数据传输的安全,HTTPS在HTTP的基础上加入了SSL协议,SSL依靠证书来验证服务器的身份,并为客户端和服务器之间的通信加密。
  注意:HTTPS也不是绝对安全的,比如中间人劫持攻击,中间人可以获取到客户端与服务器之间所有的通信内容。

未来的大前端

  目前我们现在用的前后端分离模式可以在前端工程化方面,对技术框架的选择、前端模块化重用方面,可多做考量,也就是要迎来“前端为主的 MV* 时代”,完全有前端来控制页面、URL、Controller、路由等,后端的应用就逐步弱化为真正的数据服务+业务服务,做且仅能做的是提供数据、处理业务逻辑,关注高可用、高并发等。

小结

把今天最好的表现当作明天最新的起点…….~

  投身于天地这熔炉,一个人可以被毁灭,但绝不会被打败!一旦决定了心中所想,便绝无动摇。迈向光明之路,注定荆棘丛生,自己选择的路,即使再荒谬、再艰难,跪着也要走下去!放弃,曾令人想要逃离,但绝境重生方为宿命。若结果并非所愿,那就在尘埃落定前奋力一搏!

  • https://jianshu.com/p/c81008b68350
  • https://blog.csdn.net/u010782227/article/details/74905404

你可能感兴趣的:(#,开发规范,Java,技术栈,java,后端,前端)