SpringBoot编写RESTful API

网易云课堂《用Spring Boot编写RESTful API》学习笔记

https://github.com/gexiangdong/tutorial

https://github.com/evilZ1994/SpringBootRestDemo

RESTful API

  • RESTRepresentational State Transfer的缩写
  • 所有的东西都是资源,所有的操作都通过对资源的增删改查(CRUD)实现
  • 对资源的增删改查对应于URL的操作(POST, DELETE, PUT, GET
  • 无状态

无状态:客户端到服务器的每个请求都必须包含理解请求所需的所有信息,并且不能利用服务器上存储的任何上下文。因此,会话状态完全保留在客户端上。

无状态意味着不能使用Session在服务器端存储登陆状态等信息。(可以使用JWT做无状态的身份认证)

RESTful API URI示例

以一个对电视剧列表的管理作为示例:

URL:http://somehost/tvseries

  • GET /tvseries 获取电视剧列表
  • POST /tvseries 创建一个新电视剧
  • GET /tvseries/101 获取编号为101的电视剧信息
  • PUT /tvseries/101 修改编号为101的电视剧信息
  • DELETE /tvseries/101 删除编号为101的电视剧
  • GET /tvseries/101/characters 获取编号为101的电视剧的人物列表

URI命名:

  • /资源名称
  • /资源名称/{资源ID}
  • /资源名称/{资源ID}/子资源名称
  • /资源名称/{资源ID}/子资源名称/{子资源ID}

示例程序

创建SpringBoot项目

选择依赖时,勾选Rest Repositories,其他依赖根据自己需要勾选

创建实体类

TvSeriesDto.java:

package com.example.rest;

import com.fasterxml.jackson.annotation.JsonFormat;

import java.util.Date;

public class TvSeriesDto {
    private Integer id;
    private String name;
    private int seasonCount;  // 总共多少季
    private List tvCharacters;  // 角色表
    // 设置日期格式化为Json格式
    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd")
    private Date originRelease;  // 发行日期

    public TvSeriesDto(){
    }

    public TvSeriesDto(int id, String name, int seasonCount, Date originRelease) {
        this.id = id;
        this.name = name;
        this.seasonCount = seasonCount;
        this.originRelease = originRelease;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getSeasonCount() {
        return seasonCount;
    }

    public void setSeasonCount(int seasonCount) {
        this.seasonCount = seasonCount;
    }

    public Date getOriginRelease() {
        return originRelease;
    }

    public void setOriginRelease(Date originRelease) {
        this.originRelease = originRelease;
    }

      public List getTvCharacters() {
        return tvCharacters;
    }

    public void setTvCharacters(List tvCharacters) {
        this.tvCharacters = tvCharacters;
    }

    @Override
    public String toString() {
        return "TvSeriesDto{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", seasonCount=" + seasonCount +
                ", originRelease=" + originRelease +
                '}';
    }
}

这里配置了日期的Json格式化,除了注解方式,还可以在配置文件中配置。这里我们采用.yml配置,将application.properties修改为application.yml,然后配置日期的Json格式化:

spring:
  jackson:
    date-format: yyyy-MM-dd  #如果使用字符串型表示,用这行设置格式
    timezone: GMT+8
    serialization:
      write-dates-as-timestamps: true  #使用数值时间戳表示日期

如果两种配置方式同时使用的话,注解优先。

TvCharacterDto.java:

public class TvCharacterDto {
    private Integer id;
    private int tvSeriesId;
    private String name;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public int getTvSeriesId() {
        return tvSeriesId;
    }

    public void setTvSeriesId(int tvSeriesId) {
        this.tvSeriesId = tvSeriesId;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

创建Controller

TvSeriesController.java:

@RestController
@RequestMapping("/tvseries")
public class TvSeriesController {

    @GetMapping
    public List getAll() {
        // 示例:http://localhost:8080/tvseries
        List list = new ArrayList<>();
        list.add(createWestWorld());
        list.add(createPoi());
        return list;
    }

      // 两个辅助方法,产生两个电视剧实例
    private TvSeriesDto createPoi() {
        Calendar c = Calendar.getInstance();
        c.set(2011, Calendar.SEPTEMBER, 22, 0, 0);
        return new TvSeriesDto(102, "Person of Interest", 5, c.getTime());
    }
    private TvSeriesDto createWestWorld() {
        Calendar c = Calendar.getInstance();
        c.set(2016, Calendar.OCTOBER, 2, 0, 0);
        return new TvSeriesDto(101, "West World", 1, c.getTime());
    }
}

然后启动SpringBoot,在浏览器地址输入:http://localhost:8080/tvseries,可以看到返回的Json数据:

[{"id":101,"name":"West World","seasonCount":1,"originRelease":"2016-10-02"},{"id":102,"name":"Person of Interest","seasonCount":5,"originRelease":"2011-09-22"}]

配置日志

application.yml中加入:

logging:
  file: target/app.log
  level:
    ROOT: WARN
    com.example: TRACE

其中file是日志的保存位置,level用于配置日志的级别。

日志级别:TARCE < DEBUG < INFO < WARN < ERROR < FATAL

如果日志级别设置为INFO,那么级别低于INFO的(TRACE, DEBUG)都不会输出。

ROOT表示项目的所有日志级别。我们还可以配置指定包下的日志级别,比如com.example

在项目中使用日志

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

@RestController
@RequestMapping("/tvseries")
public class TvSeriesController {
    // 日志
    private static final Log log = LogFactory.getLog(TvSeriesController.class);

    @GetMapping
    public List getAll() {
        // 示例:http://localhost:8080/tvseries
        if (log.isTraceEnabled()) {
            log.trace("getAll()被调用了");
        }
        List list = new ArrayList<>();
        list.add(createWestWorld());
        list.add(createPoi());
        return list;
    }
}

SpringBoot热部署

首先在pom.xml中加入devtools:


  org.springframework.boot
  spring-boot-devtools

然后在Idea中打开自动编译

http://upyun.nidhogg.cn/img/Idea20190423211200.png

使用快捷键Command+Shift+A,查找Registry,找到并勾选compiler.automake.allow.when.app.running

http://upyun.nidhogg.cn/img/SpringBootRest20190429193500.png

利用CURL命令或者PostMan工具进行调试

CURLhttp://blog.ishavanti.top/2019/04/23/%E5%91%BD%E4%BB%A4%E8%A1%8C%E5%B7%A5%E5%85%B7CURL/

PostMan:https://www.getpostman.com/

实现tvseries的API

根据id获取某个电视剧的信息

GET /tvseries/{id}

@GetMapping("/{id}")
public TvSeriesDto getOne(@PathVariable int id) {
    // 示例:http://localhost:8080/tvseries/101
    if (log.isTraceEnabled()) {
          log.trace("getOne " + id);
    }
    // TODO: 查找数据库
    if (id == 101) {
          return createWestWorld();
    } else if (id == 102) {
          return createPoi();
    } else {
          throw new ResourceNotFoundException();
    }
}

新增一个电视剧

POST /tvseries

@PostMapping
public TvSeriesDto insertOne(@RequestBody TvSeriesDto tvSeriesDto) {
  // Post请求
    if (log.isTraceEnabled()) {
          log.trace("这里应该写新增到数据库的代码,传递进来的参数是:" + tvSeriesDto);
    }
    tvSeriesDto.setId(9999);
    return tvSeriesDto;
}

根据id修改一个电视剧的信息

PUT /tvseries/{id}

@PutMapping("/{id}")
public TvSeriesDto updateOne(@PathVariable int id, @RequestBody TvSeriesDto tvSeriesDto) {
    if (log.isTraceEnabled()) {
        log.trace("updateOne " + tvSeriesDto);
    }
    if (id == 101 || id == 102) {
        //TODO: 根据tvSeriesDto的内容更新数据库,更新后返回新内容
        return createPoi();
    } else {
        throw new ResourceNotFoundException();
    }
}

根据id删除一个电视剧

DELETE /tvseries/{id}

@DeleteMapping("/{id}")
public Map deleteOne(@PathVariable int id, HttpServletRequest request, @RequestParam(value = "delete_reason", required = false)String deleteReason) throws Exception {
    if (log.isTraceEnabled()) {
        log.trace("deleteOne " + id);
    }
    Map result = new HashMap<>();
    if (id == 101) {
        //TODO: 执行删除代码
        result.put("message", "#101被" + request.getRemoteAddr() + "删除(原因:" + deleteReason + ")");
    } else if (id == 102) {
        throw new RuntimeException("#102不能被删除");
    } else {
        throw new ResourceNotFoundException();
    }
    return result;
}

文件上传示例

POST /tvseries/{id}/photos

import org.apache.commons.io.IOUtils;

...

@PostMapping(value = "/{id}/photos", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public void addPhoto(@PathVariable int id, @RequestParam("photo")MultipartFile imgFile) throws IOException {
    if (log.isTraceEnabled()) {
        log.trace("接收到文件 " + id + ", 收到文件: " + imgFile.getOriginalFilename());
    }
    // 保存文件
    FileOutputStream fos = new FileOutputStream("target/" + imgFile.getOriginalFilename());
    IOUtils.copy(imgFile.getInputStream(), fos);
    fos.close();
}

注意consumes = MediaType.MULTIPART_FORM_DATA_VALUE

IOUtils需要引入commons-io依赖:

pom.xml


    commons-io
    commons-io
    2.6

文件下载示例

GET /tvseries/{id}/icon

@GetMapping(value = "/{id}/icon", produces = MediaType.IMAGE_JPEG_VALUE)
public byte[] getIcon(@PathVariable int id) throws Exception {
    if (log.isTraceEnabled()) {
        log.trace("getIcon(" + id + ")");
    }
    String iconFile = "target/test.jpg";
    InputStream is = new FileInputStream(iconFile);
    return IOUtils.toByteArray(is);
}

注意:produces = MediaType.IMAGE_JPEG_VALUE

返回byte数组。

在RestController中获取各种相关信息的方法

http://upyun.nidhogg.cn/img/SpringBootRest20190429171500.png

http://upyun.nidhogg.cn/img/SpringBootRest20190429171600.png

对客户端传入的数据进行校验

JSR303

JSR是java的一个规范,JSR303是对JavaBean进行验证的规范(Bean Validation)

JSR303只是一个规范,没有具体的实现

Hibernate Validator是对JSR303的一个实现

Bean Validation注解

常用注解:

  • @Null 验证对象是否为空
  • @NotNull 验证对象是否为非空
  • @Min 验证Number和String对象是否大于等于指定的值
  • @Max 验证Number和String对象是否小于等于指定的值
  • @Size 验证对象(Array, Collection, Map, String)长度是否在给定的范围之内
  • @Past 验证Date和Calendar对象是否在当前时间之前
  • @Future 验证Date和Calendar对象是否在当前时间之后
  • @AssertTrue 验证Boolean对象是否为True
  • @AssertFalse 验证Boolean对象是否为False
  • @Valid 级联验证注解

代码示例

TvSeriesDto.java:

public class TvSeriesDto {
    @Null private Integer id;  // 表示客户端可以不传id,id必须为空
    @NotNull private String name;  // 表示客户端必须传名称
    @DecimalMin("1") private int seasonCount;  // 总共多少季
    @Valid @NotNull @Size(min = 2)  // @Valid表示要级联校验;@Size(min = 2)表示这个列表至少要有2项内容,否则不通过校验
    private List tvCharacters;
    // 设置日期格式化为Json格式
    @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd")
    // @Past表示只接受过去的时间,比当前时间晚的被认为不合格
    @Past private Date originRelease;  // 发行日期
}

TvCharacterDto.java:

public class TvCharacterDto {
    private Integer id;
    private int tvSeriesId;
    @NotNull private String name;
}

最重要的是在Controller函数中,要对需要验证的参数使用@Valid注解开启验证:

/**
 * @Valid 注解表示需要验证传入的参数TvSeriesDto,需要验证的field在TvSeriesDto内通过注解定义
 * @param tvSeriesDto
 * @return
 */
@PostMapping
public TvSeriesDto insertOne(@Valid @RequestBody TvSeriesDto tvSeriesDto) {
  // Post请求
  if (log.isTraceEnabled()) {
    log.trace("这里应该写新增到数据库的代码,传递进来的参数是:" + tvSeriesDto);
  }
  tvSeriesDto.setId(9999);
  return tvSeriesDto;
}

用PostMan测试

http://upyun.nidhogg.cn/img/SpringBootRest20190429201400.png

出现两个错误:

  • Field error in object ‘tvSeriesDto’ on field ‘id’: rejected value [0];…;default message [必须为null]

原因是TvSeriesDto类中,我把id设置为int类型,又加上了@Null注解,而int类型默认值为0,不会为空,所以无法通过验证。应该把int类型改为Integer类型:

@Null private Integer id;  // id必须为空
  • Field error in object ‘tvSeriesDto’ on field ‘tvCharacters’: rejected value [null];…;default message [不能为null]

原因是TvSeriesDto类中的字段tvCharacters忘了写gettersetter方法 - -!!!

前面的代码已纠正

进阶

http://upyun.nidhogg.cn/img/SpringBootRest20190429204100.png

注解的位置:

  • Field level 成员变量
  • Property level get(is) 方法上
  • Class level 类上

示例:

// images不可为null;每个元素(Image类型)需要级联验证
@NotNull
private List<@Valid Image> images;
// images长度至少为1,不可为null;每个元素(String类型)不可为null,最短长度为10
@NotNull
@Size(min = 1)
private List<@Size(min = 10) @NotNull String> images;

约束规则对子类有效

复杂验证情况

groups参数

  • 每个约束用注解都有一个groups参数
  • 可接收多个class类型(必须是接口)
  • 不声明groups参数是默认组javax.validation.groups.Default
  • 声明了groups参数的会从Default组移除,如需加入Default组需要显示声明,例如@Null(groups={Default.class, Step1.class})

http://upyun.nidhogg.cn/img/SpringBootRest20190429205900.png

http://upyun.nidhogg.cn/img/SpringBootRest20190429210900.png

手动验证

在Controller层,使用了@Validated注解,Spring会自动完成校验工作。但是有时在其他地方也需要进行校验,比如在Service层,就需要手动进行验证。

http://upyun.nidhogg.cn/img/SpringBootRest20190429211300.png

Bean Validation 2.0 中的约束用注解

http://upyun.nidhogg.cn/img/SpringBootRest20190429211800.png

http://upyun.nidhogg.cn/img/SpringBootRest20190429211900.png

后端程序的逻辑层次

http://upyun.nidhogg.cn/img/SpringBootRest20190503095100.png

数据对象

http://upyun.nidhogg.cn/img/SpringBootRest20190503095800.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503100100.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503100700.png

整合Mybatis

基本步骤

  • 修改pom.xml,添加Mybatis支持

    org.mybatis.spring.boot
    mybatis-spring-boot-starter
    2.0.1


    mysql
    mysql-connector-java

  • 修改application.yml,添加数据库连接
spring:
  datasource:
    dbcp2:
      validation-query: select 1
#    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/rest
    username: root
    password: ******
mybatis:
  configuration:
    map-underscore-to-camel-case: true

map-underscore-to-camel-case: true是为了实现驼峰命名的转换

driver-class-name: com.mysql.jdbc.Driver不需要手动导入:The driver is automatically registered via the SPI and manual loading of the driver class is generally unnecessary.

  • 修改启动类,增加@MapperScan(“package-of-mapping”)注解
@SpringBootApplication
@MapperScan("com.example.rest.mybatis.dao")
public class RestApplication {
    public static void main(String[] args) {
        SpringApplication.run(RestApplication.class, args);
    }
}
  • 添加Mybatis Mapping接口
  • 添加Mapping对应的XML(可选)

准备数据库

创建数据库:

create database if not exists rest default charset utf8mb4 collate utf8mb4_unicode_ci;

创建数据表:

create table tv_series(
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
season_count INT NOT NULL,
origin_release DATE,
delete_reason VARCHAR(100),
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

插入一条数据:

insert into tv_series(name, season_count, origin_release) values ('西部世界', 1, '2016/10/02');

创建层次结构

还是以电视剧列表管理作为例子。首先新建一个pojo目录,直接将之前的TvSeriesDto.javaTvCharacter.java拷贝过来重命名后作为我们的POJO。

Dao层

新建dao目录:

TvSeriesDao.java

@Repository
public interface TvSeriesDao {
    @Select("select * from tv_series")
    List getAll();
}

这里的查询语句是直接写在注解里的,如果查询比较复杂的话可以写在XML文件中

Service层

新建service目录:

TvSeriesService.java

@Service
public class TvSeriesService {
    @Autowired
    TvSeriesDao tvSeriesDao;

    public List getAllTvSeries() {
        return tvSeriesDao.getAll();
    }
}

Controller层

新建controller目录,把之前的TvSeriesController拷贝过来(之前的controller需要把注解删掉或者注释掉,否则两个controller会冲突),修改数据操作逻辑:

@RestController
@RequestMapping("/tvseries")
public class TvSeriesController {
    // 日志
    private static final Log log = LogFactory.getLog(TvSeriesController.class);

    @Autowired
    TvSeriesService tvSeriesService;

    @GetMapping
    public List getAll() {
        // 示例:http://localhost:8080/tvseries
        if (log.isTraceEnabled()) {
            log.trace("getAll()被调用了");
        }
        List list = tvSeriesService.getAllTvSeries();
        if (log.isTraceEnabled()) {
            log.trace("查询到" + list.size() + "条记录");
        }
        return list;
    }
}

其他

MyBatis可以用TypeHandler处理枚举、数组和JSON类型

单元测试

JUnit

http://upyun.nidhogg.cn/img/SpringBootRest20190503112700.png

mockito

pom.xml


    org.mockito
    mockito-core
    test

mockito是Java中使用最广泛的mock框架,mock就是创建一个虚假的类对象,在测试环境中替换掉真实对象。使用mockito可以避免测试时与数据库的依赖。

测试业务逻辑层

TvSeriesServiceTests.java

@RunWith(SpringRunner.class)
@SpringBootTest
public class TvSeriesServiceTests {

    @MockBean
    TvSeriesDao tvSeriesDao;
    @Autowired
    TvSeriesService tvSeriesService;

    @Test
    public void testGetAllWithMockito() {
        List list = tvSeriesService.getAllTvSeries();
        // 这里的测试结果依赖连接数据库内的记录,很难写一个判断是否成功的条件,甚至无法执行
        // 下面的testGetAll()方法,使用了mock出来的dao作为桩模块,避免了这一情形
        Assert.assertTrue(list.size() >= 0);
    }

    @Test
    public void testGetAll() {
        // 设置一个TvSeries list
        List list = new ArrayList<>();
        TvSeries ts = new TvSeries();
        ts.setName("POI");
        list.add(ts);

        // 下面这个语句是告诉mock出来的tvSeriesDao当执行getAll方法时,返回上面创建的list
        Mockito.when(tvSeriesDao.getAll()).thenReturn(list);

        // 测试tvSeriesService的getAllTvSeries()方法,获得返回值
        List result = tvSeriesService.getAllTvSeries();

        // 判断返回值是否和最初做的那个list相同,应该是相同的
        Assert.assertTrue(result.size() == list.size());
        Assert.assertTrue("POI".equals(result.get(0).getName()));
    }

    @Test
    public void testGetOne() {
        // 根据不同的传入参数,被mock的bean返回不同的数据的例子
        String newName = "Person of Interest";
        BitSet mockExecuted = new BitSet();
        Mockito.doAnswer(new Answer() {
            @Override
            public Object answer(InvocationOnMock invocationOnMock) throws Throwable {
                Object[] args = invocationOnMock.getArguments();
                TvSeries bean = (TvSeries)args[0];
                // 传入的值,应该和下面调用tvSeriesService.updateTvSeries()方法时的参数中的值相同
                Assert.assertEquals(newName, bean.getName());
                mockExecuted.set(0);
                return 1;
            }
        });

        TvSeries ts = new TvSeries();
        ts.setName(newName);
        ts.setId(111);

        tvSeriesService.updateTvSeries(ts);
        Assert.assertTrue(mockExecuted.get(0));
    }
}

测试web控制层

AppTests.java

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class AppTests {

    /**
     * @MockBean 可给当前的spring context装载一个假的bean上去替代原有的同名bean
     * mock了dao的所有bean后,数据访问层就别接管了,从而实现测试不受具体数据库内数据值影响的效果
     */
    @MockBean
    TvSeriesDao tvSeriesDao;
    @MockBean
    TvCharacterDao tvCharacterDao;

    @Autowired
    MockMvc mockMvc;

    @Autowired
    private TvSeriesController tvSeriesController;

    @Test
    public void testGetAll() throws Exception {
        List list = new ArrayList<>();
        TvSeries ts = new TvSeries();
        ts.setName("POI");
        list.add(ts);

        Mockito.when(tvSeriesDao.getAll()).thenReturn(list);

        // 下面这个是相当于在启动项目后,执行 GET /tvseries , 被测模块是web控制层,因为web控制层会调用业务逻辑层,所以业务逻辑层也会被测试
        // 业务逻辑层调用了被mock出来的数据访问层桩模块
        // 如果想仅仅测试web控制层,(例如业务逻辑层尚未编码完毕),可以mock一个业务逻辑层的桩模块
            mockMvc.perform(MockMvcRequestBuilders.get("/tvseries")).andDo(MockMvcResultHandlers.print()).andExpect(MockMvcResultMatchers.status().isOk()).andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("POI")));
        // 上面这几句和字面意思一致,期望状态是200,返回值包含POI三个字母,桩模块返回的一个电视剧名字是POI,如果测试正确是包含这三个字母的。
    }
}

SpringBoot事务Transactional

http://upyun.nidhogg.cn/img/SpringBootRest20190503154700.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503155200.png

propagation参数

http://upyun.nidhogg.cn/img/SpringBootRest20190503154800.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503154900.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503155000.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503155100.png

isolation参数

事务隔离

http://upyun.nidhogg.cn/img/SpringBootRest20190503160600.png

几种数据异常

http://upyun.nidhogg.cn/img/SpringBootRest20190503160700.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503160800.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503160900.png

几种隔离级别的比较

http://upyun.nidhogg.cn/img/SpringBootRest20190503161000.png

锁与隔离的区别

http://upyun.nidhogg.cn/img/SpringBootRest20190503161100.png

示例

http://upyun.nidhogg.cn/img/SpringBootRest20190503161200.png

timeout参数

http://upyun.nidhogg.cn/img/SpringBootRest20190503161300.png

默认值-1表示永远不会超时

Spring Security

依赖:


  org.springframework.boot
  spring-boot-starter-security

配置

http://upyun.nidhogg.cn/img/SpringBootRest20190503170300.png

  1. 关闭Session
  2. 所有请求都要通过授权验证
  3. 访问以“/manager”开始的url都必须具备admin这个角色
  4. 其他的url都可以直接访问

注解

http://upyun.nidhogg.cn/img/SpringBootRest20190503170400.png

  • prePostEnabled: 控制蓝色区域的注解是否可用
  • securedEnabled: 控制黄色区域的注解是否可用
  • jsr250Enabled: 控制绿色区域的注解是否可用

Pre Post:

  • @PreAuthorize: 在方法执行前,验证PreAuthorize里面的Spring表达式,True则继续执行,False则返回403
  • @PostAuthorize: 在方法执行后,验证方法执行的结果是否满足表达式,True则继续,False返回403
  • @PreFilter: 在方法执行前对传入参数进行过滤
  • @PostFilter: 在方法执行后对返回结果进行过滤

常用表达式

http://upyun.nidhogg.cn/img/SpringBootRest20190503171800.png

  • hasRole: 同时具有参数中设置的角色
  • hasAnyRole: 具有参数中任意一个角色即可
  • hasAuthority: 同时具有参数中的权限
  • hasAnyAuthority: 具有任意一个权限
  • permitAll: 所有的都允许
  • denyAll: 所有都不允许

角色和权限的区别

http://upyun.nidhogg.cn/img/SpringBootRest20190503171900.png

Controller中获取当前用户

http://upyun.nidhogg.cn/img/SpringBootRest20190503172000.png

在其他地方,比如Service层,可以用上图最后一条语句获取当前用户

示例

加入Spring Security的依赖后,访问http://localhost:8080/tvseries就会自动跳转到http://localhost:8080/login

http://upyun.nidhogg.cn/img/SpringBootRest20190503172100.png

然后在控制台我们可以看到Spring Security自动生成了密码:

http://upyun.nidhogg.cn/img/SpringBootRest20190503172200.png

我们输入用户名user,和控制台生成的密码就可以通过验证,查看到返回结果

需要注意的是,需要将之前的日志配置中,ROOT的日志级别设置为INFO,才能看到生成的密码

如果要实现更复杂的验证,就需要对Spring Security进行配置

配置

WebSecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, jsr250Enabled = false)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private final static Log log = LogFactory.getLog(WebSecurityConfig.class);

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        if (log.isTraceEnabled()) {
            log.trace("configure httpSecurity ...");
        }

        // 默认的spring-security配置会让所有的请求都必须在已登陆的情况下访问;下面这段代码禁止了这种操作
        http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests().anyRequest().permitAll();
    }
}

Token管理

TokenService.java

对Token的生成,查询和存储

@Service
public class TokenService {
    // 一般是把token和用户对应关系放在数据库或高速缓存(例如readis/memcache等),放在一个单例类的成员变量里仅适合很小规模的情形
    private Map tokenMap = new HashMap<>();

    /**
     * 登陆,成功返回token
     * @param username
     * @param password
     * @return
     */
    public String login(String username, String password) {
        UserDetails ud = null;
        // 此例中支持三个用户: author/reader/admin 三个用户的密码都是password; author具有author角色;reader具有reader角色;admin则2个角色都有
        if ("author".equals(username) && "password".equals(password)) {
            ud = createUser(username, password, new String[] {"author"});
        } else if ("reader".equals(username) && "password".equals(password)) {
            ud = createUser(username, password, new String[] {"reader"});
        } else if ("admin".equals(username) && "password".equals(password)){
            ud = createUser(username, password, new String[]{"author", "reader"});
        }
        if (ud != null) {
            String token = UUID.randomUUID().toString();
            tokenMap.put(token, ud);
            return token;
        } else {
            return null;
        }
    }

    /**
     * 退出,移除token
     * @param token
     */
    public void Logout(String token) {
        tokenMap.remove(token);
    }

    /**
     * 这个方法在每次访问都会被调用;为了提示效率应该使用@Cacheable注解缓存
     * @param token
     * @return
     */
    public UserDetails getUserFromToken(String token) {
        if (token == null) {
            return null;
        }
        return tokenMap.get(token);
    }

    private UserDetails createUser(String username, String password, String[] roles) {
        return new UserDetails() {
            @Override
            public Collection getAuthorities() {
                Collection authorities = new ArrayList<>();

                //这是增加了一种名为query的权限,可以使用 @hasAuthority("query") 来判断
                SimpleGrantedAuthority authority = new SimpleGrantedAuthority("query");
                authorities.add(authority);

                //这是增加xxx角色,可以用hasRole("xxx")来判断;需要注意所有的角色在这里增加时必须以ROLE_前缀,使用时则没有ROLES_前缀
                for (String role : roles) {
                    SimpleGrantedAuthority sga = new SimpleGrantedAuthority("ROLES_" + role);
                    authorities.add(sga);
                }

                return authorities;
            }

            @Override
            public String getPassword() {
                return password;
            }

            @Override
            public String getUsername() {
                return username;
            }

            @Override
            public boolean isAccountNonExpired() {
                return true;
            }

            @Override
            public boolean isAccountNonLocked() {
                return true;
            }

            @Override
            public boolean isCredentialsNonExpired() {
                return true;
            }

            @Override
            public boolean isEnabled() {
                return true;
            }
        };
    }
}

TokenController.java

@RestController
@RequestMapping("/token")
public class TokenController {
    private final static Log log = LogFactory.getLog(TokenController.class);

    @Autowired
    TokenService tokenService;

    @PostMapping
    public ResponseEntity> login(@RequestBody Map userInfo) {
        String userName = userInfo.get("username");
        String password = userInfo.get("password");

        HashMap result = new HashMap<>();
        String token = tokenService.login(userName, password);
        if (token == null) {
            result.put("message", "invalid username or password");
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result);
        } else {
            result.put("token", token);
            return ResponseEntity.status(HttpStatus.OK).body(result);
        }
    }
}

AuthenticationTokenFilter.java

对请求过滤时,将Token对应的用户信息取出放置到Spring上下文中

/**
 * 从request中获取token,并把token转换成用户,放置到当前的spring context内
 * 这个类必须写一个@Service注解,否则Spring不会把它加载作为filter
 */
@Service
public class AuthenticationTokenFilter extends OncePerRequestFilter {
    private final static Log log = LogFactory.getLog(AuthenticationTokenFilter.class);
    @Autowired
    TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        String authToken = null;

        // 下面的代码从Http Header的Authorization中获取token,也可以从其他header, cookie等中获取,看客户端怎么传递token
        String requestHeader = httpServletRequest.getHeader("Authorization");
        if (requestHeader != null && requestHeader.startsWith("bearer")) {
            authToken = requestHeader.substring(7);
        }
        if (log.isTraceEnabled()) {
            log.trace("token is " + authToken);
        }
        if (authToken != null) {
            UserDetails user = null;
            user = tokenService.getUserFromToken(authToken);
            if (user != null) {
                // 把user设置到SecurityContextHolder内,以便Spring使用
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }
}

示例

SampleService.java

@Service
public class SampleService {
    private final static Log log = LogFactory.getLog(SampleService.class);

    /**
     * PreAuthorize/PostAuthorize注解也可以写在Service的public方法上
     */
    @PreAuthorize("#id % 2 == 0")
    //  @PostAuthorize("returnObject.get('id') <= 10")
    public Map getOneById(int id) {
        if (log.isTraceEnabled()) {
            log.trace("getOneById: " + id);
        }
        HashMap map = new HashMap<>();
        map.put("id", id);
        return map;
    }
}

SampleController.java

@RestController
@RequestMapping("/")
public class SampleController {
    private final static Log log = LogFactory.getLog(SampleController.class);

    @Autowired
    SampleService sampleService;

    /**
     * 使用注解@PreAuthorize来保护方法只能被特定用户组的用户访问
     * 测试时:
     * curl http://localhost:8080/ 会返回403禁止访问
     * curl -H "Authorization: Bearer xxxx" http://localhost:8080/ 则会正常返回内容,得到一个长度为2的JSON数组(xxxx是token)
     * @return
     */
    @GetMapping
    @PreAuthorize("hasRole('author')")
    public List> getAll() {
        if (log.isTraceEnabled()) {
            log.trace("getAll()");
        }
        List> list = new ArrayList<>();
        HashMap m1 = new HashMap<>();
        m1.put("id", 101);
        m1.put("name", "jack");
        list.add(m1);

        HashMap m2 = new HashMap<>();
        m2.put("id", 102);
        m2.put("name", "Dolores");
        list.add(m2);

        return list;
    }

    /**
     * 通过参数 Authentication auth 在程序中获取当前的用户。
     * 测试时可使用 #curl http://localhost:8080/{id},得到的数据中没有author字段。
     * #curl -H "Authorization: Bearer xxxx" http://localhost:8080/{id} 得到的数据中有authro字段,值是当前token对应的用户名
     */
    @GetMapping("/{id}")
    public Map getOne(@PathVariable int id, Authentication authentication) {
        if (log.isTraceEnabled()) {
            log.trace("getOne: " + id);
        }
        HashMap map = new HashMap<>();
        map.put("id", id);
        map.put("name", (id == 101 ? "Waytt" : (id == 102 ? "Dolores" : "unknow")));
        if (authentication != null) {
            // 获取当前用户 (这里的auth即 AuthenticationTokenFilter中设置的 UsernamePasswordAuthenticationToken line: 51)
            // UserDetail是 AuthenticationTokenFilter line: 48行的UserDetails
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            map.put("author", userDetails.getUsername());
        }
        return map;
    }

    @PutMapping("/{id}")
    // @PreAuthorize("map.get('id') == 10")
    //  @PostAuthorize("returnObject.get('id') == 10")
    public Map updateOne(@PathVariable int id, @RequestBody Map map) {
        Map result = sampleService.getOneById(id);
        map.remove("id");
        result.putAll(map);
        return result;
    }

    /**
     * PreFilter/PostFilter这2个注解的作用是过滤参数/返回值的;PreFilter会按照注解参数设定,只保留符合规则的参数传给方法;
     * PostFilter则把方法返回值再次过滤,只保留符合规则的返回给客户端。
     * 例如下面的例子,PreFilter会过滤掉客户端传递过来的参数中所有不以a开头的字符串;而PostFilter则过滤掉返回数据中所有不以b结尾的字符串。
     * 执行时,客户端传递的字符串数组,只有以a开头的会被打印,并且只有以a开头并以b结尾的字符串才可以被返回给客户端;
     * PreFilter/PostFilter也和PreAuthorize/PostAuthorize一样必须用@EnableGlobalMethodSecurity(prePostEnabled = true打开才能用。
     */
    @PostMapping("/children")
    @PreFilter(filterTarget = "list", value = "filterObject.startsWith('a')")
    @PostFilter("filterObject.startsWith('b')")
    public List echo(@RequestBody List list) {
        if(log.isTraceEnabled()) {
            log.trace("echo ... list.size()= " + list.size());
            for(String s : list) {
                log.trace("  " + s );
            }
        }
        return list;
    }
}

OAuth2.0

http://upyun.nidhogg.cn/img/SpringBootRest20190503204800.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503204900.png

OAuth2.0有四种授权模式,其中授权码模式、密码模式比较常用,简化模式、客户端模式较少使用。

Refresh Token不是OAuth的授权模式,但是在SpringBoot中可以设置,用来在Token到期前刷新Token,得到有效期更长的Token。

授权码模式

http://upyun.nidhogg.cn/img/SpringBootRest20190503210000.png

http://upyun.nidhogg.cn/img/SpringBootRest20190503210100.png

服务器和浏览器之间通过token进行交互,但是服务器并不知道token对应的是谁,如果需要知道对应的用户信息,则需要向OAuth服务器进行查询。

将token与用户关系存入缓存可以缓解这种问题,但是OAuth端可以随时让token失效,缓存中无法即时更新token。

另外的解决办法就是跳过向OAuth服务器查询的步骤,让token自己证明自己。JWT就是这样一种token。

JWT

http://upyun.nidhogg.cn/img/SpringBootRest20190503211200.png

注意Playload部分没有加密,不要存放敏感信息。

依赖:


    com.auth0
  java-jwt
  3.3.0

https://github.com/auth0/java-jwt

Spring异步 @Async

http://upyun.nidhogg.cn/img/SpringBootRest20190505161800.png

@EnableAsync注解只能加在有@SpringBootApplication或@Configuration注解的类上,其他类上无作用。

@EnableAsync启用异步后,在方法中加上@Async注解后就可以异步执行了。

只有从外部调用才能异步执行:

http://upyun.nidhogg.cn/img/SpringBootRest20190505162700.png

  • 1是异步执行的
  • 2不是异步执行的,因为doC方法没有@Async注解
  • 3,4不是异步执行的,因为不是从外部调用

@Async注解的方法的返回值:

  • void
  • Future\
  • 其他类型一律返回null;遇到int/double/float/boolean基本类型,执行时会抛出异常:AopInvocationException

Spring Scheduling 定时任务

  • @EnableScheduling 注解启用Scheduling(@SpringBootApplication类或者@Configuration类上)
  • 方法上加@Scheduled注解,方法会按照参数定期执行

参数

http://upyun.nidhogg.cn/img/SpringBootRest20190505163900.png

http://upyun.nidhogg.cn/img/SpringBootRest20190505164000.png

, : 用于设置多个非连续值,比如在小时处使用 6, 8, 12 就表示6点,8点,12点三个时间点

- : 用于设置连续的区间,比如在小时处使用 6-12 就表示6点到12点期间

* : 表示任意

? : 只能写在日和星期几,当日期和星期几冲突时,表示任意

/ : 用来指定数值的增量,比如在子表达式(分钟)里的“0/15”表示从第0分钟开始,每15分钟

L : 表示Last,最后一个;在日期那里表示一个月的最后一天;在星期那里表示一周的最后一天;“6L”表示这个月的倒数第6天

W : workday;表示为最近工作日,如“15W”放在每月(day-of-month)字段上表示为“到本月15日最近的工作日”

# : 是用来指定每月第n个星期几,例如在每周(day-of-week)这个字段中内容为”6#3” or “FRI#3” 则表示“每月第三个星期五”

示例:

http://upyun.nidhogg.cn/img/SpringBootRest20190505170400.png

默认只有一个线程执行@Scheduled任务

配置多个线程执行:

http://upyun.nidhogg.cn/img/SpringBootRest20190505170500.png

缓存

Spring Cache

  • @EnableCaching 启用缓存(@SpringBootApplication类或@Configuration类)

  • @Cacheable:声明方法可缓存。查询缓存中是否有值,有则直接取用;没有则执行相应的操作,并且会将结果保存到缓存中

  • @CacheEvict:删除。额外的参数allEntries表示是否需要清除缓存中的所有元素,默认为false,表示不需要;当指定了allEntries为true时,Spring Cache将忽略指定的key。
  • @CachePut:更新
  • @CacheConfig:有时候一个类中可能会有多个缓存操作,而这些缓存操作可能是重复的。这个时候可以使用@CacheConfig。它是类级别的注解,允许共享缓存的名称、KeyGenerator、CacheManager 和CacheResolver。该操作会被覆盖。

http://upyun.nidhogg.cn/img/SpringBootRest20190505172300.png

http://upyun.nidhogg.cn/img/SpringBootRest20190505172100.png

http://upyun.nidhogg.cn/img/SpringBootRest20190505172200.png

key

http://upyun.nidhogg.cn/img/SpringBootRest20190505173400.png

默认即“cacheName::key”

@CacheEvict

http://upyun.nidhogg.cn/img/SpringBootRest20190505173500.png

示例

http://upyun.nidhogg.cn/img/SpringBootRest20190505173600.png

设置过期

http://upyun.nidhogg.cn/img/SpringBootRest20190505173700.png

Redis

http://upyun.nidhogg.cn/img/SpringBootRest20190505200900.png

@Autowired加载规则

http://upyun.nidhogg.cn/img/SpringBootRest20190505210800.png

http://upyun.nidhogg.cn/img/SpringBootRest20190505211500.png

http://upyun.nidhogg.cn/img/SpringBootRest20190505211600.png

定制Spring Rest的错误返回信息

http://upyun.nidhogg.cn/img/SpringBootRest20190505213100.png

http://upyun.nidhogg.cn/img/SpringBootRest20190505213200.png