SpringBoot的经验汇总

2021-05-29 fishedee 后端

0 概述

SpringBoot的经验集锦,在看了《Spring源码解析》和《Spring Boot实战》以后,在实际使用中,依然遇到了很多问题,我们来总结一下吧

1 日志

代码在这里

SpringBoot默认使用的日志系统是,Slf4j作为日志门面,Logback作为日志实现

/**
 * Created by fish on 2021/3/15.
 */
logging.level.root = INFO
logging.level.spring_test.ServiceB = ERROR
logging.config = classpath:logging-config.xml

配置默认的日志等级,以及不同的包下面的日志等级,最后我们可以指定logback的配置文件,用来设置详尽的日志配置。

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>
                %d{HH:mm:ss.SSS} {%thread} %-5level %logger{36} -%msg%n
            </pattern>
        </encoder>
    </appender>

    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
            <charset>utf-8</charset>
        </encoder>
        <file>log/output.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.FixedWindowRollingPolicy">
            <fileNamePattern>log/output.log.%i</fileNamePattern>
        </rollingPolicy>
        <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy">
            <MaxFileSize>1MB</MaxFileSize>
        </triggeringPolicy>
    </appender>

    <logger name="root" level="INFO"/>

    <root level="INFO">
        <!--不同级别输出到不同的appender-->
        <appender-ref ref="STDOUT" level="info"/>
        <appender-ref ref="FILE" level="error"/>
    </root>
</configuration>

这是日志的xml配置文件,关键在于root,下面的各个appender,我们可以设置不同的日志level,使用不用的appender。这个配置是按照日志文件大小,进行回转,生成不同的日志文件。这个配置时按照日志大小进行回转,每个日志文件最大为1MB。

<configuration>
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
            <charset>utf-8</charset>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">   
            <fileNamePattern>log/output.log.%d{yyyy-MM-dd}</fileNamePattern>   
            <maxHistory>30</maxHistory>    
        </rollingPolicy> 
    </appender>

    <logger name="root" level="INFO"/>

    <root level="INFO">
        <appender-ref ref="FILE"/>
    </root>
</configuration>

这是所有日志等级,使用单个appender的实例。这个配置是按照日期进行回转,每天生成一个日志文件,最大保留30天的日志文件。

<configuration>
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
            <charset>utf-8</charset>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 归档的日志文件的路径,例如今天是2013-12-21日志,当前写的日志文件路径为file节点指定,可以将此文件与file指定文件路径设置为不同路径,从而将当前日志文件或归档日志文件置不同的目录。
            而2013-12-21的日志文件在由fileNamePattern指定。%d{yyyy-MM-dd}指定日期格式,%i指定索引 -->
            <fileNamePattern>log/output.log-%d{yyyy-MM-dd}-%i.log</fileNamePattern>
            <!-- 除按日志记录之外,还配置了日志文件不能超过2M,若超过2M,日志文件会以索引0开始,
            命名日志文件,例如log-2013-12-21.0.log -->
            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>2MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
        </rollingPolicy>
    </appender>

    <logger name="root" level="INFO"/>

    <root level="INFO">
        <appender-ref ref="FILE"/>
    </root>
</configuration>

按日期滚动的同时可以嵌套按照大小再次嵌套

注意,没有file选项。更多的配置看这里

package spring_test;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

/**
 * Created by fish on 2021/3/24.
 */
@Component
public class ServiceA {
    final Logger logger = LoggerFactory.getLogger(getClass());

    public void go(){
        logger.info("hello {},I am {}","fish","123");

        logger.error("I am {}","error!");
    }
}

使用日志的方法,可以用LoggerFactory创建日志以后使用。这段代码很重复,我们可以直接用lombok的@Slf4j注解可以省事点。输出的时候,{}就是参数占位符。

2 静态文件

代码在这里

2.1 静态文件服务

/**
 * Created by fish on 2021/3/15.
 */
logging.level.root = INFO

spring.resources.static-locations = file:static2

在配置文件中指定当前执行文件,所在的static2文件夹为资源文件夹

package spring_test;

import lombok.Data;
import org.springframework.web.bind.annotation.*;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Created by fish on 2021/4/25.
 */
@RestController
@RequestMapping("/hello")
public class Controller {

    @GetMapping("/go1")
    public String go1(){
        return "Hello World";
    }

}

然后我们创建一个普通的Controller

├── src
│   ├── main
│   │   ├── java
│   │   │   └── spring_test
│   │   │       ├── App.java
│   │   │       ├── Controller.java
│   │   │       └── MyResponseBodyAdvice.java
│   │   └── resources
│   │       └── application.properties
│   └── test
│       └── java
│           └── spring_test
│               └── AppTest.java
├── static.iml
├── static2
│   └── test.html

在static2文件夹加入一个test.html

启动后我们就能在服务器中直接访问test.html了

2.2 资源文件读写

├── src
│   ├── main
│   │   ├── java
│   │   │   └── spring_test
│   │   │       ├── App.java
│   │   │       ├── Controller.java
│   │   │       └── MyResponseBodyAdvice.java
│   │   └── resources
│   │       ├── country.json
│   │       └── application.properties
│   └── test
│       └── java
│           └── spring_test
│               └── AppTest.java
├── static.iml
├── static2
│   └── test.html

在resources文件夹加入一个test.json的文件

package spring_test;

        import com.fasterxml.jackson.core.type.TypeReference;
        import com.fasterxml.jackson.databind.ObjectMapper;
        import lombok.Data;
        import org.springframework.beans.factory.annotation.Autowired;
        import org.springframework.core.io.ClassPathResource;
        import org.springframework.core.io.Resource;
        import org.springframework.web.bind.annotation.*;

        import javax.validation.constraints.Min;
        import javax.validation.constraints.NotEmpty;
        import java.io.BufferedInputStream;
        import java.io.BufferedOutputStream;
        import java.io.ByteArrayOutputStream;
        import java.io.IOException;
        import java.util.ArrayList;
        import java.util.HashMap;
        import java.util.List;
        import java.util.Map;

/**
 * Created by fish on 2021/4/25.
 */
@RestController
@RequestMapping("/hello")
public class Controller {

    @GetMapping("/go1")
    public String go1(){
        return "Hello World";
    }

    @Data
    public static class Person{
        private String name;
        private Integer age;
    }

    @Data
    public static class Country{
        private String name;

        private List<Person> people = new ArrayList<>();
    }

    private byte[] getResource(){
        Resource resource = new ClassPathResource("country.json");
        try(
                BufferedInputStream inputStream = new BufferedInputStream(resource.getInputStream());
                ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ){
            byte[] buffer = new byte[1024];
            int length = 0;
            while( (length= inputStream.read(buffer))!=-1){
                outputStream.write(buffer,0,length);
            }
            return outputStream.toByteArray();
        }catch( IOException e){
            throw new RuntimeException(e);
        }
    }

    @Autowired
    private ObjectMapper objectMapper;

    @GetMapping("go2")
    public Country go2(){
        try{
            byte[] byteArray = this.getResource();
            TypeReference<Country> typeReference= new TypeReference<Country>() {};
            return objectMapper.readValue(byteArray,typeReference);
        }catch(IOException e){
            throw new RuntimeException(e);
        }
    }

}

然后我们用ClassPathResource就能读取这个资源文件了,该文件只能读,不能写

3 REST与校验

代码在这里

3.1 REST

package spring_test;

import lombok.Data;
import org.springframework.web.bind.annotation.*;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * Created by fish on 2021/4/25.
 */
@RestController
@RequestMapping("/hello")
public class Controller {

    @GetMapping("/go1")
    public Map<String,String> go1(){
        Map<String,String> result =  new HashMap<String,String>();
        result.put("a","b");
        return result;
    }

    @GetMapping("/go2/{id}")
    public Map<String,String> go2(@PathVariable(name = "id") String id, @RequestParam(name = "name",required = true) String name) {
        Map<String,String> result =  new HashMap<String,String>();
        result.put("id",id);
        result.put("name",name);
        return result;
    }

    @Data
    public static class Req{
        private List<String> names;
    }

    //使用urlEncode的数组参数成功,但要注意下标要从0开始
    //localhost:8080/hello/go3?names%5B0%5D=1&names%5B1%5D=2
    @GetMapping("/go3")
    public Map<String,Object> go3(Req req) {
        Map<String,Object> result =  new HashMap<>();
        result.put("names",req.getNames());
        return result;
    }

    //使用Get请求的requestBody是不成功的
    @GetMapping("/go4")
    public Map<String,Object> go4(@RequestBody List<String> names){
        Map<String,Object> result =  new HashMap<>();
        result.put("names",names);
        return result;
    }

    //使用Post请求,传递json的参数的对象体是没有问题的
    //注意,请求的HeaderType要设置为application/json
    @PostMapping("/go5")
    public Map<String,Object> go5(@RequestBody Map<String,Object> data){
        return data;
    }

    //使用Post请求,传递json的参数的数组体是没有问题的
    //注意,请求的HeaderType要设置为application/json
    @PostMapping("/go6")
    public Map<String,Object> go6(@RequestBody List<String> data){
        Map<String,Object> result =  new HashMap<>();
        result.put("list",data);
        return result;
    }

    @GetMapping("/go7")
    public String go7(){
        throw new RuntimeException("123");
    }
}

从代码中,我们可以看出:

  • 收集Query参数,用@RequestParam注解,只能是urlencode的类型
  • 收集PATH参数,用@PathVariable注解
  • 收集POST请求的请求体,用@RequestBody注解,必须是application/json类型

同样地,限制有:

  • @RequestParam注解可以简单地收集数组类型,例如go3请求,但是无法处理多维数组,或者数组嵌套对象的类型。收集数组类型,必须声明一个结构体再收集,不能直接用List收集。
  • @RequestBody注解只能收集一次,你不能在一个方法里面在多个参数上使用@RequestBody注解。

3.2 REST校验参数

3.2.1 基础类型校验

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.hibernate.validator.constraints.Range;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.constraints.Max;
import javax.validation.constraints.Min;

/**
 * Created by fish on 2021/4/25.
 */
//Validated只能放在Controller校验,放在Service方法上没有意义的
@RestController
@Slf4j
@Validated
@RequestMapping("/dog")
public class Controller3 {
    @GetMapping(value = "/go1")
    public void go1(
            @Range(min = 1, max = 9, message = "年级只能从1-9")   //第2步
            @RequestParam(name = "grade", required = true) int grade, //

            @Min(value = 1, message = "班级最小只能1") @Max(value = 99, message = "班级最大只能99")  //第2步
            @RequestParam(name = "classroom", required = true) int classroom) { //

        System.out.println(grade + "," + classroom);
    }
}

对于在方法参数上的基本类型校验,则必须注意要在类的顶头加入@Validated才会触发校验,否则校验不会生效

3.2.2 类校验

package spring_test;

import lombok.Data;

import javax.validation.constraints.*;
import java.math.BigDecimal;

/**
 * Created by fish on 2021/4/25.
 */
@Data
public class OrderDO {
    @NotNull
    private Long id;

    @NotBlank
    private String name;

    @NotNull
    @Email
    private String email;

    @Min(value = 1,message = "必须为正数")
    private int size;

    @NotNull
    @DecimalMin(value = "0.0001",message = "必须为正数")
    private BigDecimal total;
}

我们先定义一个OrderDO的类型,然后用java Validation注解来标注每个字段的约束

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

/**
 * Created by fish on 2021/4/25.
 */
@RestController
@RequestMapping("/cat")
@Slf4j
public class Controller2 {

    //这个方法仍然需要手动查看错误,并打印后抛出
    @PostMapping("/go1")
    public void go1(@RequestBody @Valid  OrderDO orderDO, BindingResult bindingResult){
        if (bindingResult.hasErrors()) {
            // 打印全部的错误信息
            for (ObjectError error : bindingResult.getAllErrors()) {
                System.out.println(error.getDefaultMessage());
            }
        }

        log.info("go1 {}",orderDO);
    }
}

然后我们可以用在请求参数的注解上使用@Valid来标注校验这个参数,这个时候我们需要手动查看BindingResult是否包含了错误。这样做可行,但有点麻烦。对于类而言,Controller2可以不加@Validated注解,但是方法的参数本身必须要加上@Valid注解才会触发校验

@PostMapping(value="/go3")
//方法上可以用@Valid或者@Validated注解
public void go3(@Valid @RequestBody Req2 req){
    System.out.println(req);
}

另外一个方法是去掉BindingResult参数,它会产生默认的异常错误

3.2.3 类嵌套集合校验

package spring_test;

import lombok.Data;

import javax.validation.Valid;
import javax.validation.constraints.*;
import java.math.BigDecimal;
import java.util.List;

/**
 * Created by fish on 2021/4/25.
 */
@Data
public class Order2DO {

    @Data
    public static class Item{
        private Long id;

        private String name;
    }
    @NotNull
    private Long id;

    @NotBlank
    private String name;

    @NotNull
    @Email
    private String email;

    @Min(value = 1,message = "必须为正数")
    private int size;

    @NotNull
    @DecimalMin(value = "0.0001",message = "必须为正数")
    private BigDecimal total;

    @Valid @NotNull
    @NotEmpty
    private List<Item> itemList;
}

Order2DO与OrderDO的不同的是,它有嵌套的数组类型,而且数组的元素本身又是另外一个类。这个时候,我们必须在List类型的字段,加入@Valid注解,才能触发校验,否则Item自身不会校验。

@PostMapping(value="/go6")
public void go6(@Valid @RequestBody Order2DO order2DO){
    System.out.println(order2DO);
}

这是测试代码

3.3 非REST校验参数

@Component
@Slf4j
public class OrderDOTest {
    @Autowired
    private Validator validator;

    //手动验证
    public void check(OrderDO orderDO){
        Set<ConstraintViolation<OrderDO>> sets =  validator.validate(orderDO);
        for( ConstraintViolation<OrderDO> t :sets){
            log.error("fail [{} {}]",t.getPropertyPath(),t.getMessage());
        }
    }

    public void go1(){
        log.info("OrderDOTest go1 ....");
        OrderDO data = new OrderDO();
        check(data);
    }
}

对于非Controller类,Spring不支持使用@Validated注解来对方法参数进行校验。我们必须手动注入Validator类,来执行校验。

@Component
@Slf4j
public class OrderDOTest {

    @Autowired
    private LocalValidatorFactoryBean validatorFactoryBean;

   //手动验证2,有更好的错误输出
    public void check2(OrderDO orderDO){
        Set<ConstraintViolation<Object>> validateSet = validatorFactoryBean.getValidator().validate(orderDO);
        if (!CollectionUtils.isEmpty(validateSet)) {
            Iterator<ConstraintViolation<Object>> iterator = validateSet.iterator();
            List<String> msgList = new ArrayList<>();
            while (iterator.hasNext()) {
                ConstraintViolation<?> cvl = iterator.next();
                msgList.add(cvl.getPropertyPath()+":"+cvl.getMessage());
            }
            log.error("fail {}",msgList.toString());
        }
    }

    public void go2(){
        log.info("OrderDOTest go2 ....");
        OrderDO data = new OrderDO();
        data.setId(10001L);
        check2(data);
    }
}

这是另外一种手动校验的方法,这次要注入的是LocalValidatorFactoryBean类,有更好的本地化错误输出。

3.4 方法级别校验

package spring_test;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;

import javax.validation.Valid;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import java.util.List;

@Data
public class Order3DO {

    @Data
    @Slf4j
    public static class Item{
        @Min(1)
        private int id;

        //JsonIgnore是禁止Json读写该字段
        @JsonIgnore
        //如果NotBlank,Validator就会采用Field直接读取的校验方式,不经过Get方法
        //@NotBlank
        private String name;

        //JackJson总是使用setter方法来将字段写进去的
        public void setId(int id){
            log.info("set Id",id);
            this.id = id;
        }

        @NotBlank
        public String getName(){
            log.info("getName {}",this.name);
            if( this.name == null ){
                this.name = "XXXX"+this.id+"XXXX";
            }
            return this.name;
        }
    }

    @Min(0)
    private int size;

    @NotEmpty
    @Valid
    private List<Item> items;
}

我们可以使用方法级别的校验,这样我们在Getter方法里面做默认值的填充,同时可以不影响校验。对于派生字段来说相当好用。

package spring_test;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;

import javax.validation.Valid;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import java.util.List;

@Data
public class Order4DO {

    @Data
    @Slf4j
    public static class Item{
        @Min(1)
        private int id;

        //JsonProperty可以将该字段设置为只读
        @JsonProperty(access= JsonProperty.Access.READ_ONLY)
        @NotBlank
        private String name;
    }

    @Min(0)
    private int size;

    private List<Item> items;

    @NotEmpty
    @Valid
    public List<Item> getItems(){
        for( Item item : this.items ){
            if( item.getName() == null ){
                item.setName("UU"+item.getId()+"KK");
            }
        }
        return this.items;
    }
}

同理我们也可以在List字段上面的Getter方法做校验,以及默认值的填充。对于需要往DB里面拉取批量的派生字段也好用。

3.5 总结

Spring的校验机制高度不统一,品味不好,但勉强能用:

  • 在REST接口的方法上,基本类型参数,需要在REST类加入@Validated注解
  • 在REST接口的方法上,类类型参数,需要在参数上加入@Valid注解
  • 类的嵌套集合参数,需要在参数上加入@Valid注解
  • 在非REST接口的方法上,需要手动校验,注解不起作用。

4 全局返回拦截

4.1 ResponseBodyAdvice

package com.fishedee.erp.framework.mvc;

import com.fasterxml.jackson.annotation.JsonFilter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

//返回的Body进行自动化封装,指定为xxxx包下所有的controller
@RestControllerAdvice(basePackages = "xxxx")
@Slf4j
public class MyResponseBodyAdvice implements ResponseBodyAdvice {
    @Data
    @AllArgsConstructor
    public static class ResponseResult{
        @JsonIgnore
        private HttpStatus statusCode;

        private int code;

        private String msg;

        private Object data;


        public ResponseResult(int code,String message,Object data){
            this.code = code;
            this.msg = message;
            this.data = data;
            this.statusCode = HttpStatus.OK;
        }
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        ResponseResult result = null;

        //处理body的不同类型
        if (body == null) {
            result = new ResponseResult(0,"",null);
        }else if ( body instanceof ResponseResult ) {
            result = (ResponseResult)body;
        }else{
            result = new ResponseResult(0,"",body);
        }

        //输出
        if( result.getStatusCode().isError() ){
            response.setStatusCode(result.getStatusCode());
        }
        return result;
    }

    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return true;
    }
}

我们可以添加一个ResponseBodyAdvice的Bean,它需要有@RestControllerAdvice注解,并且重写beforeBodyWrite方法。那么当REST在执行完了Controller方法获取到结果以后,我们可以进一步封装结果,包含code,msg等字段。

这个方法还是相当直观的

4.2 String类型

@GetMapping(value="/go7")
public String go7(){
    return "123";
}

但是这个Adive遇到返回类型是String类型的时候会报错。

例如是上面的这个go7方法。原因详情看这里

if(body instanceof String){
    String str  = JSON.toJSONString(Result.success(body));
    return str;
}

所以,我们在ResponseBodyAdvice里面需要加上一个特殊逻辑。当Controller返回的是String类型的时候,我们ResponseBodyAdvice也需要进行一层JSON序列化,转换为String类型吐出去,而不是直接吐出去一个结构体。

5 全局错误拦截

5.1 默认错误展示

@GetMapping(value="/go5")
public void go5(){
    throw new MyException(1,"你好",null);
}

如果我们写了这样的一个方法

spring_test.MyException: 你好
    at spring_test.Controller4.go5(Controller4.java:85) ~[classes/:na]
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_181]
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_181]
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_181]
    at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_181]
    at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:209) ~[spring-web-5.0.8.RELEASE.jar:5.0.8.RELEASE]

就会产生一个这样的报错页面,相当的不人性,我们希望以json的方式吐出错误到前端。

5.2 自定义错误展示

package spring_test;

import lombok.AllArgsConstructor;
import lombok.Data;

/**
 * Created by fish on 2021/4/26.
 */
//可以指定该Exception的错误类型
//@ResponseStatus(value= HttpStatus.NOT_FOUND,reason = "找不到呀")
@Data
@AllArgsConstructor
public class MyException extends RuntimeException{
    private int code;
    private String message;
    private Object data;
}

首先我们定义个特殊的MyException类型。

package com.fishedee.erp.framework.mvc;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageConversionException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.stereotype.Component;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.util.*;

/**
 * Created by fish on 2021/4/25.
 */
@ControllerAdvice
@Component
@Slf4j
public class GlobalExceptionHandler {

    /*
    404找不到异常,一般不需要重写,因为statusCode为404
    @ExceptionHandler(NoHandlerFoundException.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult exceptionHandler(Exception e){
        return new MyResponseBodyAdvice.ResponseResult(10001,e.getMessage(),null);
    }
    */

    //拦截其他错误
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult exceptionHandler(Exception e){
        log.error("server exception {}",e);
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.INTERNAL_SERVER_ERROR,500,"服务器内部错误",null);
    }

    //拦截我们自定义的错误
    @ExceptionHandler(MyException.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult exceptionMyHandler(MyException e){
        log.error("business exception {}",e);
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.OK,e.getCode(),e.getMessage(),e.getData());
    }

    @ExceptionHandler(HttpMessageConversionException.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult exceptionMyHandler(HttpMessageConversionException e){
        log.info("argument exception {}",e);
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.OK,1,"请求格式转换错误:"+e.getCause().getMessage(),null);
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult exceptionMyHandler(HttpMessageNotReadableException e){
        log.info("argument exception {}",e);
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.OK,1,"请求格式错误:"+e.getCause().getMessage(),null);
    }

    //请求方法不存在
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult exceptionMyHandler(HttpRequestMethodNotSupportedException e){
        log.info("argument exception {}",e);
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.NOT_FOUND,1,"不受支持的请求方法",null);
    }

    //https://xbuba.com/questions/51828879,直接返回String会报错
    //localhost:8080/dog/go1?grade=0,缺少一个classroom请求参数
    @ResponseBody
    @ExceptionHandler(value = MissingServletRequestParameterException.class)
    public MyResponseBodyAdvice.ResponseResult doMissingServletRequestParameterHandler(MissingServletRequestParameterException e) {
        log.info("argument exception {}",e);
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.OK,1,"缺少请求参数:"+e.getMessage(),null);
    }

    //将参数绑定到基础类型时报错
    //localhost:8080/dog/go1?grade=0&classroom=3,参数校验不合法,[grade年级只能从1-9]
    @ResponseBody
    @ExceptionHandler(value = ConstraintViolationException.class)
    public MyResponseBodyAdvice.ResponseResult  ConstraintViolationExceptionHandler(ConstraintViolationException ex) {
        log.info("argument exception {}",ex);
        Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
        Iterator<ConstraintViolation<?>> iterator = constraintViolations.iterator();
        List<String> msgList = new ArrayList<>();
        while (iterator.hasNext()) {
            ConstraintViolation<?> cvl = iterator.next();
            msgList.add(cvl.getPropertyPath()+":"+cvl.getMessage());
        }
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.OK,1,msgList.toString(),null);
    }

    //post localhost:8080/sheep/go3
    //{
    //    "items":[
    //    ]
    //}
    //post请求时将参数绑定到对象时报错
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult  doMethodArgumentNotValidException(MethodArgumentNotValidException ex){
        log.info("argument exception {}",ex);
        BindingResult result = ex.getBindingResult();
        List<String> msgList = new ArrayList<String>();
        if (result.hasErrors()) {
            List<ObjectError> errors = result.getAllErrors();
            ObjectError error=errors.get(0);
            msgList.add(ex.getParameter()+":"+error.getDefaultMessage());
        }
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.OK,1,msgList.toString(),null);
    }

    //localhost:8080/sheep/go1?count=-1
    //get请求时将参数绑定到对象时报错
    @ExceptionHandler(BindException.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult handleBindException(BindException ex) {
        log.info("argument exception {}",ex);
        List<FieldError> bindingResult = ex.getBindingResult().getFieldErrors();
        List<String> msgList = new ArrayList<String>();
        for (FieldError fieldError : bindingResult) {
            msgList.add(fieldError.getField()+":"+fieldError.getDefaultMessage());
        }
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.OK,1,msgList.toString(),null);
    }
}

然后我们添加一个类,包含有@ControllerAdvice注解,而且方法上有 @ExceptionHandler注解,这代表当特定的异常发生的时候,我们重写它的返回格式。重点有两个地方:

  • 对于MyException的异常,我们吐出具体错误,以备前端展示错误。
  • 对于其他的普通的Exception异常,我们报出500错误,但不向前端展示错误,这样能避免泄漏内部实现。

这个时候的返回格式就好看多了,统一整齐,避免泄漏内部实现。

6 全局请求拦截

代码在这里

package spring_test;

        import lombok.Data;
        import lombok.extern.slf4j.Slf4j;
        import org.springframework.validation.annotation.Validated;
        import org.springframework.web.bind.annotation.*;

        import javax.validation.constraints.Min;
        import javax.validation.constraints.NotEmpty;
        import javax.validation.constraints.NotNull;
        import java.util.HashMap;
        import java.util.List;
        import java.util.Map;

/**
 * Created by fish on 2021/4/25.
 */
@RestController
@RequestMapping("/hello")
@Slf4j
@Validated
public class Controller {

    //GET请求 http://localhost:8080/hello/go1
    @GetMapping("/go1")
    public String go1(){
        return "Hello World";
    }

    //POST请求 http://localhost:8080/hello/go2
    /*
    {
        "name":123,
        "email":"123@qq.com",
        "size":4,
        "total":"8.0",
        "id":1
    }
     */
    @PostMapping("/go2")
    public void go2(@NotNull Long id, OrderDO orderDO){
        log.info("go2 {} {}",id,orderDO);
    }

    //GET请求 http://localhost:8080/hello/go2
    //localhost:8080/hello/go3?id=123&data=%7B%22name%22:123,%22email%22:%22123@qq.com%22,%22size%22:4,%22total%22:%228.0%22%7D
    //localhost:8080/hello/go3?id=123&data={"name":123,"email":"123@qq.com","size":4,"total":"8.0"},原始格式
    //@RequestParam指定的放其指定的字段上
    //其他的参数默认放在data字段上,用json格式,并且用urlEncode过
    @GetMapping("/go3")
    public void go1(@NotNull @RequestParam("id") Long id,OrderDO orderDO){
        log.info("go3 {} {}",id,orderDO);
    }
}

我们在第3节REST参数与校验尝试过,默认的方法比较麻烦,有几个问题:

  • 需要加很多注解。@Valid,@RequestBody,@Validated等注解
  • 而且GET请求的时候,参数不支持嵌套多层。我们就想,要么GET参数也用json格式传递更好。这样做能支持嵌套多层,而且能使用JSON的基础设施,自定义JSON字段名,以及JSON的自定义序列化与反序列化
  • POST请求不支持多个参数持有@RequestBody。

因此,我们提出加入请求拦截器,让代码支持以上的这种写法,规范简洁。

package spring_test;

import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.std.StdArraySerializers;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.hibernate.validator.HibernateValidator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.core.MethodParameter;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.validation.*;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.validation.beanvalidation.SpringConstraintValidatorFactory;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import javax.annotation.PostConstruct;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.*;
import java.io.BufferedReader;
import java.io.IOException;
import java.lang.annotation.Native;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.*;

//参考这里
//https://blog.csdn.net/WoddenFish/article/details/82593317
/**
 * Created by fish on 2021/4/29.
 */
@Component
@Slf4j
public class MyRequestAdvice implements HandlerMethodArgumentResolver {

    final static String JSONBODY_ATTRIBUTE = "com.fishedee.jsonbody";

    @Autowired
    private ObjectMapper objectMapper;
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        if( hasRequestParamAnnotation(parameter) ||
                isServletRequest(parameter) ){
            return false;
        }
        return true;
    }

    private boolean isServletRequest(MethodParameter parameter){
        return parameter.getParameterType() == HttpServletRequest.class ||
                parameter.getParameterType() == ServletRequest.class ||
                parameter.getParameterType() == HttpServletResponse.class;
    }
    private boolean hasRequestParamAnnotation(MethodParameter parameter){
        return parameter.hasParameterAnnotation(RequestParam.class) == true;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        final String jsonString = this.getRequestBody(webRequest);
        Type genericParameterType = parameter.getGenericParameterType();
        Class valueType = this.getParameterType(parameter);
        boolean isBasicValueType = this.basicTypeSet.contains(valueType);
        if( jsonString.isEmpty() ){
            //空的情况下直接返回
            if( isBasicValueType == false){
                if( valueType == List.class ){
                    //List类型
                    return null;
                }else{
                    Object result = parameter.getConstructor().newInstance();
                    this.check(result);
                    return result;
                }
            }else{
                return null;
            }

        }
        if( isBasicValueType ){
            //基础类型
            String key = alwaysGetParameterKey(parameter);
            return this.getFromKeyBasicType(jsonString,key,valueType);
        }else{
            //复合类型
            if( collectionTypeSet.contains(valueType)){
                //集合类型
                String key = alwaysGetParameterKey(parameter);
                return this.getFromKey(jsonString,key,genericParameterType);
            }else{
                //非集合类型
                String key = getAnnotationKey(parameter);
                if( Strings.isNotBlank(key)){
                    return this.getFromKey(jsonString,key,genericParameterType);
                }else{
                    return this.getFromWhole(jsonString,genericParameterType);
                }
            }
        }
    }

    @Autowired
    private LocalValidatorFactoryBean localValidatorFactoryBean;

    public void check(Object target){
        Set<ConstraintViolation<Object>> validateSet = localValidatorFactoryBean.validate(target);
        if (!CollectionUtils.isEmpty(validateSet)) {
            Iterator<ConstraintViolation<Object>> iterator = validateSet.iterator();
            List<String> msgList = new ArrayList<>();
            while (iterator.hasNext()) {
                ConstraintViolation<?> cvl = iterator.next();
                msgList.add(cvl.getPropertyPath()+":"+cvl.getMessage());
            }
            throw new RuntimeException(msgList.toString());

        }
    }

    private Object getFromWhole(String jsonString,Type valueType){
        try {
            Object result = objectMapper.readValue(jsonString, objectMapper.getTypeFactory().constructType(valueType));
            this.check(result);
            return result;
        }catch(IOException e){
            throw new RuntimeException("格式错误:"+e.getMessage());
        }
    }

    private Object getFromKey(String jsonString,String key,Type valueType){
        try {
            JsonNode root = objectMapper.readTree(jsonString);
            Iterator<Map.Entry<String, JsonNode>> elements = root.fields();
            while (elements.hasNext()) {
                Map.Entry<String, JsonNode> node = elements.next();
                String nodeKey = node.getKey();
                if (nodeKey.equals(key)) {
                    Object result = objectMapper.readValue(node.getValue().toString(), objectMapper.getTypeFactory().constructType(valueType));
                    this.check(result);
                    return result;
                }
            }
            return null;
        }catch(IOException e){
            throw new RuntimeException("格式错误:"+e.getMessage());
        }
    }

    private Object getFromKeyBasicType(String jsonString,String key,Class valueType){
        try {
            JsonNode root = objectMapper.readTree(jsonString);
            Iterator<Map.Entry<String, JsonNode>> elements = root.fields();
            while (elements.hasNext()) {
                Map.Entry<String, JsonNode> node = elements.next();
                String nodeKey = node.getKey();
                if (nodeKey.equals(key)) {
                    Object result = readBasicType(valueType, node.getValue());
                    this.check(result);
                    return result;
                }
            }
            return null;
        }catch(IOException e){
            throw new RuntimeException("格式错误:"+e.getMessage());
        }
    }

    private Object readBasicType(Class clazz, JsonNode node){
        if( clazz.equals(String.class)){
            return node.asText();
        }else if (clazz.equals(Integer.class)){
            return node.asInt();
        }else if( clazz.equals(Long.class)){
            return node.asLong();
        }else if ( clazz.equals(Short.class)){
            return (short)node.asInt();
        }else if ( clazz.equals(Float.class)){
            return (float)node.asDouble();
        }else if( clazz.equals(Double.class)){
            return node.asDouble();
        }else if( clazz.equals(Boolean.class)){
            return node.asBoolean();
        }else if( clazz.equals(BigDecimal.class)){
            return node.decimalValue();
        }else{
            return null;
        }
    }

    private Set<Class> basicTypeSet= new HashSet<>();

    private Set<Class> collectionTypeSet = new HashSet<>();

    private String getAnnotationKey(MethodParameter parameter){
        RequestJson parameterAnnotation = parameter.getParameterAnnotation(RequestJson.class);
        return parameterAnnotation != null? parameterAnnotation.value():"";
    }

    private String alwaysGetParameterKey(MethodParameter parameter){
        RequestJson parameterAnnotation = parameter.getParameterAnnotation(RequestJson.class);
        String key = parameterAnnotation != null? parameterAnnotation.value():"";
        if(!StringUtils.isEmpty(key) ){
            return key;
        }

        return parameter.getParameterName();
    }

    @PostConstruct
    private void init(){
        basicTypeSet.add(BigDecimal.class);
        basicTypeSet.add(String.class);
        basicTypeSet.add(Integer.class);
        basicTypeSet.add(Long.class);
        basicTypeSet.add(Short.class);
        basicTypeSet.add(Float.class);
        basicTypeSet.add(Double.class);
        basicTypeSet.add(Boolean.class);

        collectionTypeSet.add(List.class);
        collectionTypeSet.add(LinkedList.class);
        collectionTypeSet.add(ArrayList.class);
        collectionTypeSet.add(Collection.class);
        collectionTypeSet.add(Set.class);
        collectionTypeSet.add(HashSet.class);
        collectionTypeSet.add(TreeSet.class);
        collectionTypeSet.add(Map.class);
        collectionTypeSet.add(HashMap.class);
        collectionTypeSet.add(TreeMap.class);
    }

    @SuppressWarnings("rawtypes")
    private Class getParameterType(MethodParameter parameter) {
        Class t = parameter.getParameterType();
        return t;
    }

    private String getRealRequestJson(NativeWebRequest webRequest){
        HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class);
        if( servletRequest.getMethod().toLowerCase().equals("get")){
            //get请求固定从data字段获取
            String json = servletRequest.getParameter("data");
            return json == null?"":json;
        }else {
            //post请求固定从body获取
            String contentType = servletRequest.getContentType();
            if (contentType != null&&
                    contentType.contains("application/x-www-form-urlencoded") == true) {
                return "";
            }
            try {
                BufferedReader input = servletRequest.getReader();
                StringBuilder stringBuilder = new StringBuilder();
                char[] b = new char[4096];
                for (int n = 0; (n = input.read(b)) != -1; ) {
                    stringBuilder.append(b);
                }
                return stringBuilder.toString();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
    private String getRequestBody(NativeWebRequest webRequest) {
        String jsonBody = (String) webRequest.getAttribute(JSONBODY_ATTRIBUTE, NativeWebRequest.SCOPE_REQUEST);
        if (jsonBody == null) {
            jsonBody = getRealRequestJson(webRequest);
            webRequest.setAttribute(JSONBODY_ATTRIBUTE, jsonBody, NativeWebRequest.SCOPE_REQUEST);
        }
        return jsonBody;
    }
}

我们先定义一个RequestAdivce

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.stereotype.Service;
import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;

/**
 * Hello world!
 *
 */
@SpringBootApplication
@Slf4j
public class App
{
    public static void main( String[] args )
    {
        SpringApplication.run(App.class,args);
    }

    @Autowired
    private RequestMappingHandlerAdapter adapter;

    @Autowired
    private MyRequestAdvice requestAdvice;

    @PostConstruct
    public void injectSelfMethodArgumentResolver() {
        List<HandlerMethodArgumentResolver> argumentResolvers = new ArrayList<>();
        argumentResolvers.add(requestAdvice);
        argumentResolvers.addAll(adapter.getArgumentResolvers());
        adapter.setArgumentResolvers(argumentResolvers);
    }
}

然后我们将它加入到原来的RequestMappingHandlerAdapter里面,注意,我们自定义的RequestAdvice必须要放在RequestMappingHandler的最前面,保证最先的优先级。

7 JSON

springboot默认使用jackjson作为JSON序列化与反序列化器。代码在这里

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.12.6.1</version>
</dependency>

一般情况下我们引入spring-boot-starter-json就能引入jackson了,当我们不在springboot环境的时候,就需要用jackson-databind来引入jackson.

官网文档在这里

7.1 配置

#spring.jackson.property-naming-strategy = SNAKE_CASE
spring.jackson.date-format = yyyy-MM-dd HH:mm:ss
spring.jackson.default-property-inclusion = ALWAYS
spring.jackson.time-zone=GMT+8

配置时间格式,以及所有时候都进行属性输出(在null的时候也输出字段)。注意设置jackjson的时区信息,它默认是以0时区输出的。因为在Java中,Date,LocalDateTime,等的类型都是没有时区信息的。

//未知属性反序列化,不会报错
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,false);
objectMapper.configure(DeserializationFeature.READ_ENUMS_USING_TO_STRING ,true);

对于自定义创建的ObjectMapper,我们需要加上以上的配置。

7.2 手动序列化

@Autowired
private ObjectMapper objectMapper;

@GetMapping("/go2")
public String go2()throws Exception{
    OrderDO orderDO = new OrderDO();
    orderDO.setEmail("123@qq.com");
    orderDO.setName("678");
    orderDO.setSize(123);
    orderDO.setTotal(new BigDecimal("9.1"));
    orderDO.setAddress(new OrderDO.Address("中国","西藏"));
    return objectMapper.writeValueAsString(orderDO);
}

手动序列化的方式简单,依赖ObjectMapper,然后调用writeValueAsString就可以了

@GetMapping("/go2_2")
public String go2_2()throws Exception{

    String input = "["+
            "{\"city\":\"中国\",\"street\":\"西藏\",\"extInfo\":[{\"city\":\"中国\",\"street\":\"西藏\"},{\"city\":\"中国2\",\"street\":\"西藏2\"}],\"size\":123,\"total\":\"9.1\",\"fish_name\":\"678\"},"+
            "{\"city\":\"中国2\",\"street\":\"西藏2\",\"extInfo\":[{\"city\":\"中国\",\"street\":\"西藏\"},{\"city\":\"中国2\",\"street\":\"西藏2\"}],\"size\":123,\"total\":\"9.1\",\"fish_name\":\"678\"}"+
            "]";

    //用匿名类来传递实际的类型,静态的方法
    List<OrderDO2> m1 = objectMapper.readValue(input, new TypeReference<List<OrderDO2>>() {});
    showOrderDO(m1);

    //动态构建Type
    CollectionType collectionType = objectMapper.getTypeFactory().constructCollectionType(List.class, OrderDO2.class);
    List<OrderDO2> m2 = objectMapper.readValue(input, collectionType);
    showOrderDO(m2);

    //这种方法是错误的,因为Java是运行时擦除泛型类型的,实际运行时会丢失类型信息
    //所以生成出来的结果,实际是List<LinkedHashMap>类型
    //List<OrderDO2> testClass = new ArrayList<>();
    //List<OrderDO2> m3 = objectMapper.readValue(input,testClass.getClass()) ;
    //showOrderDO(m3);
    return "";
}

泛型的反序列化就要注意了,要用readValue再加上TypeReference的匿名类来做,不要直接用传入Class。

7.3 字段注解

package spring_test;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.*;
import java.math.BigDecimal;

/**
 * Created by fish on 2021/4/25.
 */
@Data
public class OrderDO {
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Address{
        private String city;

        private String street;
    }

    @JsonProperty("fish_name")
    private String name;

    @JsonIgnore
    private String email;

    @JsonUnwrapped
    private Address address;

    @JsonRawValue
    private String extInfo;

    private int size;

    private BigDecimal total;

    //JsonProperty可以将该字段设置为只读
    @JsonProperty(access= JsonProperty.Access.READ_ONLY)
    @NotBlank
    private String mg;
}

字段里面有几个与JSON相关的注解:

  • @JsonProperty,重写json字段名
  • @JsonIgnore,忽略该json字段,不序列化与反序列化
  • @JsonUnwrapped,将嵌套的结构体输出到父级字段上
  • @JsonRawValue,将字段原样输出

这是输出结果

7.4 自定义插件

package spring_test;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Configurable;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;

import javax.annotation.PostConstruct;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;

/**
 * Created by fish on 2021/5/3.
 */
//https://www.cnblogs.com/scar1et/articles/14134024.html
//https://www.itranslater.com/qa/details/2583881735951877120
@Configuration
public class JsonConfigure {
    public static class BigDecimalSerializer extends JsonSerializer<BigDecimal> {
        @Override
        public void serialize(BigDecimal var1, JsonGenerator var2, SerializerProvider var3) throws IOException{
            var2.writeString(var1.toPlainString());
        }

    }

    public static class BigIntegerSerializer extends JsonSerializer<BigInteger> {
        @Override
        public void serialize(BigInteger var1, JsonGenerator var2, SerializerProvider var3) throws IOException{
            var2.writeString(var1.toString());
        }

    }
    @Autowired
    private ObjectMapper objectMapper;

    @PostConstruct
    public void injectSelfMethodArgumentResolver() {
        configure(objectMapper);
    }

    public static void configure(ObjectMapper objectMapper){
        SimpleModule module = new SimpleModule();
        module.addSerializer(BigDecimal.class, new BigDecimalSerializer());
        module.addSerializer(BigInteger.class, new BigIntegerSerializer());
        objectMapper.registerModule(module);
    }
}

由于JackJson默认对BigDecimal与BigInteger使用数字格式输出,当数字较大时,会转换为科学计数法输出。所以,我们可以对ObjectMapper加入插件,对BigDecimal.class与BigInteger.class类型使用我们指定的序列化器

这个时候,total作为BigDecimal类型,输出的是字符串格式,不会再用科学计数法的问题了。

7.5 类型特定序列化与反序列化

package spring_test;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;

/**
 * Created by fish on 2021/5/30.
 */
@AllArgsConstructor
@Getter
public enum EnabledEnum implements BaseEnumType{

    ENABLED(1,"可用"),
    DISABLE(2,"不可用");

    private int code;

    private String display;
}

首先我们定义一个枚举体

@GetMapping("/go3")
public Object go3(){
    Map<Integer,EnabledEnum> result = new HashMap<Integer,EnabledEnum>();
    result.put(3,EnabledEnum.ENABLED);
    result.put(4,EnabledEnum.DISABLE);
    return result;
}

然后我们尝试输出这个枚举体

缺乏中文和code的输出,不方便前端显示和使用

package spring_test;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;

import java.util.Objects;

/**
 * Created by fish on 2021/4/29.
 */
@JsonDeserialize(using = EnumJsonDeserializer.class)
@JsonSerialize(using = EnumJsonSerializer.class)
public interface BaseEnumType {
    /**
     * 用于显示的枚举名
     *
     * @return
     */
    String getDisplay();

    /**
     * 存储到数据库的枚举值
     *
     * @return
     */
    int getCode();

    //按枚举的value获取枚举实例
    static <T extends BaseEnumType> T fromValue(Class<T> enumType, int value) {
        for (T object : enumType.getEnumConstants()) {
            if (Objects.equals(value, object.getCode())) {
                return object;
            }
        }
        throw new RuntimeException(enumType.getCanonicalName()+"没有枚举类型为"+value);
    }
}

然后我们在BaseEnumType用注解的方式自定义序列化与反序列化器

package spring_test;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * Created by fish on 2021/4/29.
 */
@Component
public class EnumJsonSerializer  extends JsonSerializer<BaseEnumType>{
    @Override
    public void serialize(BaseEnumType value, JsonGenerator gen, SerializerProvider serializers) throws IOException{
        Map<String,Object> map = new HashMap<>();
        map.put("code", value.getCode());
        map.put("name", ((Enum)(value)).name().toLowerCase());
        map.put("display", value.getDisplay());
        gen.writeObject(map);
    }

}

对应的序列化器

package spring_test;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Created by fish on 2021/4/29.
 */
@Component
@Slf4j
public class EnumJsonDeserializer extends JsonDeserializer<BaseEnumType> {

    private Map<String,Class> nameToClass = new ConcurrentHashMap<>();

    @Override
    public BaseEnumType deserialize(JsonParser jp, DeserializationContext ctx) throws IOException, JsonProcessingException{
        int code = 0;
        if( jp.currentToken().equals(JsonToken.VALUE_NUMBER_INT)){
            //普通的整数类型
            code = jp.getIntValue();
        }else if( jp.currentToken().equals(JsonToken.START_OBJECT)){
            //嵌套的object类型
            boolean hasFound = false;
            while(!jp.currentToken().equals(JsonToken.END_OBJECT)){
                JsonToken jsonToken = jp.nextToken();
                if(JsonToken.FIELD_NAME.equals(jsonToken)){
                    String fieldName = jp.getCurrentName();

                    jsonToken = jp.nextToken();

                    if("code".equals(fieldName)){
                        hasFound = true;
                        code = jp.getIntValue();
                    }
                }
            }
            if( hasFound == false ){
                throw new RuntimeException("找不到"+jp.getCurrentName()+"对应的枚举值");
            }
        }else{
            throw new RuntimeException("不合法的enum输入:"+jp.getValueAsString());
        }

        //查找缓存
        String currentName = jp.getCurrentName();
        Class currentValue = jp.getCurrentValue().getClass();
        String key = currentValue.getName()+"_"+currentName;
        Class fieldClass = nameToClass.get(key);
        if( fieldClass == null ){
            Field field;
            try{
                field = currentValue.getDeclaredField(currentName);
            }catch(NoSuchFieldException e){
                throw new RuntimeException(e);
            }
            fieldClass = field.getType();
            nameToClass.putIfAbsent(key,fieldClass);
        }
        return BaseEnumType.fromValue(fieldClass,code);
    }
}

对应的反序列化器

这个时候对应的输出就比较顺眼了,而且我们反序列化enum的时候支持多种方式的反序列化。

7.6 其他

8 拦截器

代码在这里

package spring_test;


import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletRequest;

import org.springframework.lang.Nullable;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletResponse;

@Slf4j
public class LogTimeHandlerInterceptor extends HandlerInterceptorAdapter { // 单例多线程 开始时间绑定在线程上
    private ThreadLocal<Long> startTimeThreadLocal = new ThreadLocal<>();
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        long start = System.currentTimeMillis();
        startTimeThreadLocal.set(start);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
        if (handler instanceof HandlerMethod == false) {
            return;
        }
        try {
            Long startTime = startTimeThreadLocal.get();
            Long endTime = System.currentTimeMillis();

            StringBuilder logs = new StringBuilder();              //可在此处获取当前用户放日志信息里
            logs.append(" IP:").append(request.getRemoteAddr());//获取请求地址IP 自己实现
            if( ex == null ){
                //一般输出,对于Controller出现的异常,应该配合GlobalExceptionhandler来处理
                HandlerMethod method = (HandlerMethod) handler;
                String className = method.getBeanType().getName();
                String methodName = method.getMethod().getName();
                logs.append(" ").append(className).append("::").append(methodName);
            }else{
                //只能捕捉意味的异常,不能捕捉普通controller的异常
                logs.append(" ").append(ex.getClass()).append("::").append(ex.getMessage());
            }
            long time = endTime - startTime;
            logs.append(" 耗时:").append(time).append("(ms)");
            log.info(logs.toString());
        } finally {
            startTimeThreadLocal.remove();
        }
    }
}

定义一个HandlerInterceptorAdapter,我们就能记录一下每个请求的执行时间了

package spring_test;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Created by fish on 2021/5/31.
 */
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private LogTimeHandlerInterceptor logTimeHandlerInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(logTimeHandlerInterceptor);
    }
}

定义一个WebMvcConfig,将拦截器添加进去。

9 错误页面

代码在这里

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
import spring_test.MyResponseBodyAdvice;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@RestController
@Slf4j
public class MyErrorController implements ErrorController {

    private final String ERROR_PATH ="/error";

    /**
     * 出现错误,跳转到如下映射中
     * @return
     */
    @Override
    public String getErrorPath() {
        return ERROR_PATH;
    }

    @RequestMapping(value =ERROR_PATH)
    public MyResponseBodyAdvice.ResponseResult handleError(HttpServletRequest request, HttpServletResponse response) {
        int code = response.getStatus();
        if (404 == code) {
            return new MyResponseBodyAdvice.ResponseResult(HttpStatus.NOT_FOUND,1,"未找到资源",null);
        } else if (403 == code) {
            return new MyResponseBodyAdvice.ResponseResult(HttpStatus.FORBIDDEN,1,"没有访问权限",null);
        } else if (401 == code) {
            return new MyResponseBodyAdvice.ResponseResult(HttpStatus.UNAUTHORIZED,1,"登录过期",null);
        } else {
            return new MyResponseBodyAdvice.ResponseResult(HttpStatus.INTERNAL_SERVER_ERROR,1,"服务器内部错误",null);
        }
    }

}

自定义各种错误页面的输出

10 健康监控

参看这里

代码在这里

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

添加actuator依赖

#默认打开管理站点
management.endpoints.enabled-by-default=true
#设置管理站点的端口
management.server.port=9090
#自定义管理端点路径
management.endpoints.web.base-path=/manage
#启动所有端点
management.endpoints.web.exposure.include=*

#是否支持远程关闭
#endpoints.shutdown.enable=true

#配置info信息,http://localhost:8080/manage/info查看info信息
info.app.name=Spring Boot Actuator Demo
info.app.version=v1.0.0
info.app.description=Spring Boot Actuator Demo

对actuator的端点的配置,要注意不要随便打开shutdown

打开API,我们就能看到各个输出了

http://localhost:9090/manage/metrics,各种指标
http://localhost:9090/manage/health,健康状态
http://localhost:9090/manage/heapdump,堆栈信息
http://localhost:9090/manage/threaddump,线程信息

常用的健康输出

11 度量与可视化

参看这里

代码这里

actuator只解决了SpringBoot运行是否健康的问题,我们还需要监控各个请求的情况,给出优化建议。而且,我们还需要可视化这些数据来方便分析。

11.1 编码度量

micrometer的使用在这里

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-core</artifactId>
</dependency>

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

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

这三个依赖都要用

package spring_test;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Service;
import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;

import javax.annotation.PostConstruct;

/**
 * Hello world!
 *
 */
@SpringBootApplication
@Slf4j
public class App
{
    public static void main( String[] args )
    {
        SpringApplication.run(App.class,args);
    }

    @Autowired
    private MeterRegistry meterRegistry;

    @PostConstruct
    public void init(){
        //全局的默认tag
        meterRegistry.config().commonTags("Machine","fish");
    }
}

设置全局的tag,SpringBoot默认已经定义了一个MeterRegistry

package spring_test;


import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Metrics;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletResponse;
import java.time.Duration;

@Component
@Slf4j
public class LogTimeHandlerInterceptor extends HandlerInterceptorAdapter {
    /*
    private static final Counter COUNTER = Counter.builder("Http请求统计")
            .tag("HttpCount", "HttpCount")
            .description("Http请求统计")
            .register(Metrics.globalRegistry);

    private static final Timer requestTimer = Timer.builder("timer")
            .tag("timer","timer")
            .description("timer")
            .register(Metrics.globalRegistry);*/

    //全局注入
    @Autowired
    private MeterRegistry meterRegistry;

    private ThreadLocal<Long> startTimeThreadLocal = new ThreadLocal<>();
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        long start = System.currentTimeMillis();
        startTimeThreadLocal.set(start);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
        //加全局请求数
        meterRegistry.counter("http.requests").increment();

        if (handler instanceof HandlerMethod == false) {
            return;
        }
        try {
            Long startTime = startTimeThreadLocal.get();
            Long endTime = System.currentTimeMillis();

            StringBuilder logs = new StringBuilder();              //可在此处获取当前用户放日志信息里
            logs.append(" IP:").append(request.getRemoteAddr());//获取请求地址IP 自己实现
            if( ex == null ){
                //一般输出,对于Controller出现的异常,应该配合GlobalExceptionhandler来处理
                HandlerMethod method = (HandlerMethod) handler;
                String className = method.getBeanType().getName();
                String methodName = method.getMethod().getName();
                logs.append(" ").append(className).append("::").append(methodName);
            }else{
                //只能捕捉意味的异常,不能捕捉普通controller的异常
                logs.append(" ").append(ex.getClass()).append("::").append(ex.getMessage());
            }
            long time = endTime - startTime;
            logs.append(" 耗时:").append(time).append("(ms)");
            log.info(logs.toString());

            String uri = request.getRequestURI();
            meterRegistry.timer("http.requests.uri","uri",uri).record(Duration.ofMillis(time));
        } finally {
            startTimeThreadLocal.remove();
        }
    }
}

对请求数量,和具体的uri的响应时间收集

11.2 prometheus

下载prometheus

#默认以9090端口启动
# my global config
global:
  scrape_interval:     15s # Set the scrape interval to every 15 seconds. Default is every 1 minute.
  evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute.
  # scrape_timeout is set to the global default (10s).

# Alertmanager configuration
alerting:
  alertmanagers:
  - static_configs:
    - targets:
      # - alertmanager:9093

# Load rules once and periodically evaluate them according to the global 'evaluation_interval'.
rule_files:
  # - "first_rules.yml"
  # - "second_rules.yml"

# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
  # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config.
  - job_name: 'prometheus'

    # metrics_path defaults to '/metrics'
    # scheme defaults to 'http'.
    metrics_path: /manage/prometheus
    static_configs:
    - targets: ['localhost:8083']

prometheus默认在9090端口启动,所以不要让其他服务也在9090端口启动。然后设置metrics_path与static_configs/targets指向我们的SpringBoot服务。注意,prometheus是pull模式拉取数据的。

./prometheus --config.file=prometheus.yml

启动prometheus

11.3 Grafana

安装Grafana就不说了,以前谈过。我们进去以后,先添加数据源

数据源为prometheus

添加一个模板的Dashboard,4701就是模板编号

进去以后,默认就有图标和数据了,真的好方便。

添加两项我们自定义的度量,运行成功

12 Cookie处理器

代码在这里

package spring_test;

import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;

import javax.servlet.http.HttpServletRequest;
import java.text.DateFormat;
import java.text.FieldPosition;
import java.util.Date;

/**
 * Created by fish on 2021/6/2.
 */
public class MyCookieProcessor extends Rfc6265CookieProcessor {
    public String generateHeader(javax.servlet.http.Cookie cookie, HttpServletRequest request) {
        cookie.setDomain("test.com");
        return super.generateHeader(cookie,request);
    }
}

在SpringBoot嵌入的Tomcat中,我们能设置Cookie处理器,能统一设置Cookie的Domain,SameSite,security等的属性

package spring_test;

import org.apache.tomcat.util.http.LegacyCookieProcessor;
import org.apache.tomcat.util.http.Rfc6265CookieProcessor;
import org.apache.tomcat.util.http.SameSiteCookies;
import org.springframework.boot.web.embedded.tomcat.TomcatContextCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * Created by fish on 2021/6/1.
 */
@Configuration
public class MvcConfiguration implements WebMvcConfigurer {
    @Bean
    public TomcatContextCustomizer sameSiteCookiesConfig() {
        return context -> {
            final MyCookieProcessor cookieProcessor = new MyCookieProcessor();
            cookieProcessor.setSameSiteCookies(SameSiteCookies.NONE.getValue());
            context.setCookieProcessor(cookieProcessor);
        };
    }
}

注意,这个特性要SpringBoot 2.3版本才能用

13 线程复用与threadLocal

代码在这里

package spring_test;


import lombok.extern.apachecommons.CommonsLog;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.http.HttpServletRequest;

import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletResponse;

@Component
@Slf4j
public class LogTimeHandlerInterceptor extends HandlerInterceptorAdapter { // 单例多线程 开始时间绑定在线程上
    private ThreadLocal<Long> startTimeThreadLocal = new ThreadLocal<>();

    private ThreadLocal<Exception> realException = new ThreadLocal<>();
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        long start = System.currentTimeMillis();
        startTimeThreadLocal.set(start);
        return true;
    }

    public void setException(Exception e){
        realException.set(e);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
        if (handler instanceof HandlerMethod == false) {
            return;
        }
        try {
            Long startTime = startTimeThreadLocal.get();
            Long endTime = System.currentTimeMillis();

            StringBuilder logs = new StringBuilder();              //可在此处获取当前用户放日志信息里
            logs.append(" IP:").append(request.getRemoteAddr());//获取请求地址IP 自己实现
            if( ex == null ){
                log.info("thread id: {},realException {}",Thread.currentThread().getId(),realException.get()!=null?realException.get().getMessage():"");
                //一般输出,对于Controller出现的异常,应该配合GlobalExceptionhandler来处理
                HandlerMethod method = (HandlerMethod) handler;
                String className = method.getBeanType().getName();
                String methodName = method.getMethod().getName();
                logs.append(" ").append(className).append("::").append(methodName);
            }else{
                //只能捕捉意味的异常,不能捕捉普通controller的异常
                logs.append(" ").append(ex.getClass()).append("::").append(ex.getMessage());
            }
            long time = endTime - startTime;
            logs.append(" 耗时:").append(time).append("(ms)");
            log.info(logs.toString());
        } finally {
            startTimeThreadLocal.remove();
        }
    }
}

为了在LogTimeHandlerInterceptor里面捕捉真正的业务错误,LogTimeHandlerInterceptor加入了setException的方法,将异常写入到当前线程的线程变量ThreadLocal<Exception>里面。

@ControllerAdvice
@Component
@Slf4j
public class GlobalExceptionHandler {
    @Autowired
    private LogTimeHandlerInterceptor logTimeHandlerInterceptor;

    //拦截其他错误
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult exceptionHandler(Exception e){
        log.error("server exception {}",e);
        logTimeHandlerInterceptor.setException(e);
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.INTERNAL_SERVER_ERROR,500,"服务器内部错误",null);
    }

    //拦截我们自定义的错误
    @ExceptionHandler(MyException.class)
    @ResponseBody
    public MyResponseBodyAdvice.ResponseResult exceptionMyHandler(MyException e){
        log.error("business exception {}",e);
        logTimeHandlerInterceptor.setException(e);
        return new MyResponseBodyAdvice.ResponseResult(HttpStatus.OK,e.getCode(),e.getMessage(),e.getData());
    }
}

另外一处,当异常发生的时候,将异常触发到logTimeHandlerInterceptor的setException方法。

我们先触发一次异常

然后不断重复触发/hello/go1方法

thread id: 24,realException !普通错误!
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go2 耗时:44(ms)
thread id: 25,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:5(ms)
thread id: 26,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:1(ms)
thread id: 27,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:3(ms)
thread id: 28,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:1(ms)
thread id: 29,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:1(ms)
thread id: 30,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:1(ms)
thread id: 31,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:1(ms)
thread id: 32,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:1(ms)
thread id: 33,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:1(ms)
thread id: 24,realException !普通错误!
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:1(ms)
thread id: 25,realException 
 IP:0:0:0:0:0:0:0:1 spring_test.Controller::go1 耗时:0(ms)

这是输入日志,显然代码写错了。只触发了一次/hello/go2方法,但是日志显示了2次的错误。这是因为SpringBoot使用的是线程池,线程在请求里面是不断复用的,因此你能看到thread id从24上升到33以后,又重新返回到24的位置。这最终会导致ThreadLocal的变量被复用了,即使在不同的请求里面。

解决方法是,每次请求执行前复位一下ThreadLocal变量,或者每次在请求完成后都复位一下ThreadLocal变量。

注意,ThreadLocal的生命周期与请求的生命周期并不一致

14 JdbcTemplate

jdbcTemplate是Spring里面最轻量的数据库交互工具,也比较好用。代码在这里

14.1 定义实体

package spring_test;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class User {
    private int id;

    private String name;

    public User(String name){
        this.name = name;
    }
}

定义实体

14.2 定义CURD操作

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.*;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Component;

import java.sql.*;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Component
@Slf4j
public class UserRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    //定义一个RowMapper
    private RowMapper mapper = new RowMapper() {
        @Override
        public Object mapRow(ResultSet resultSet, int i) throws SQLException {
            User user = new User();
            user.setName(resultSet.getString("name"));
            user.setId(resultSet.getInt("id"));
            return user;
        }
    };

    public List<User> findAll(){
        //不要使用queryForList,只能返回单列的数据
        //this.jdbcTemplate.queryForList("select * from user",User.class);
        return this.jdbcTemplate.query("select * from user",mapper);
    }

    public List<User> findByName(String name){
        return this.jdbcTemplate.query("select * from user where name like ?",
                new Object[]{"%"+name+"%"},
                new int[]{Types.VARCHAR},
                new BeanPropertyRowMapper(User.class));
    }

    public List<User> findByName2(String name){
        //使用RowMapper来映射数据,很少这样做

        return this.jdbcTemplate.query("select * from user where name like ?",
                new Object[]{"%"+name+"%"},
                new int[]{Types.VARCHAR},
                mapper);
    }

    public Map<String,Object> findForMap(String name){
        return this.jdbcTemplate.queryForMap("select * from user where name like ?",User.class,"%"+name+"%");
    }

    public void add(User user){
        final String INSERT_SQL = "insert into user(name) values(?)";
        KeyHolder keyHolder = new GeneratedKeyHolder();
        int affectedRows =jdbcTemplate.update(
            new PreparedStatementCreator() {
                public PreparedStatement createPreparedStatement(Connection connection) throws SQLException {
                    //注意要指定自增列的列名
                    PreparedStatement ps =
                            connection.prepareStatement(INSERT_SQL,new String[]{"id"});
                    //设置参数
                    ps.setString(1, user.getName());
                    return ps;
                }
            },keyHolder);
        if( affectedRows > 0 ){
            user.setId(keyHolder.getKey().intValue());
        }
    }

    public int mod(User user){
        //返回值就是影响行数
        return this.jdbcTemplate.update("update user set name = ? where id = ?",user.getName(),user.getId());
    }

    public int del(int userId){
        //返回值就是影响行数
        return this.jdbcTemplate.update("delete from user where id = ?",userId);
    }
}

CURD的工作都有了,注意点有:

  • 不要使用queryForObject,这个方法只是为了得到只有一列的数据用的。
  • 不要使用queryForMap,它不会进行类型转换的工作。拿到数据以后,你还需要进行数据类型转换工作,除非你的所有列数据都是string的话就比较安全。
  • 尽可能用query,加上BeanPropertyRowMapper,来做返回数据的映射,自动而且安全。
  • 插入操作,要用PreparedStatementCreator才能支持获取自增主键,记得需要设置自增列的列名
  • 删除与修改都比较简单

14.3 测试

package spring_test;


import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.jdbc.Sql;

import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

/**
 * Unit test for simple App.
 */
@DataJpaTest(includeFilters = @ComponentScan.Filter(
        type= FilterType.ASSIGNABLE_TYPE,
        classes = {UserRepository.class}
))
public class JdbcTemplateTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    @Sql("classpath:/init.sql")
    public void testCurd(){
        //插入并获取自增ID
        User user1 = new User("fish");
        User user2 = new User("cat");
        userRepository.add(user1);
        userRepository.add(user2);
        assertEquals(user1.getId(),1);
        assertEquals(user2.getId(),2);

        //findAll
        List<User> users = userRepository.findAll();
        JsonAssertUtil.checkEqualStrict(
                "[{id:1,name:\"fish\"},{id:2,name:\"cat\"}]",
                users
        );

        //findByName
        List<User> users2 = userRepository.findByName("a");
        JsonAssertUtil.checkEqualStrict(
                "[{id:2,name:\"cat\"}]",
                users2
        );

        //findByName2
        List<User> users3 = userRepository.findByName2("s");
        JsonAssertUtil.checkEqualStrict(
                "[{id:1,name:\"fish\"}]",
                users3
        );

        //mod
        user1.setName("dog");
        userRepository.mod(user1);
        List<User> users4 = userRepository.findAll();
        JsonAssertUtil.checkEqualStrict(
                "[{id:1,name:\"dog\"},{id:2,name:\"cat\"}]",
                users4
        );

        //del
        userRepository.del(2);
        List<User> users5 = userRepository.findAll();
        JsonAssertUtil.checkEqualStrict(
                "[{id:1,name:\"dog\"}]",
                users5
        );
    }
}

CURD的测试都有了,简单

15 SpringBoot模块开发

用得时间久了,我们就会将公共模块抽取出来,建立一个个的spring-boot-starter-xxx的工具了,我们谈谈大概的步骤是怎样的。参考项目在这里

15.1 目录结构

.
├── pom.xml
├── spring-boot-starter-id-generator.iml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── fishedee
    │   └── resources
    │       └── META-INF
    │           └── spring.factories
    └── test
        └── java
            └── com
                └── fishedee

11 directories, 3 files

这是目录结构,顶层有一个pom.xml的Maven配置文件,src的resources里面有一个固定路径的spring.factories文件。

15.2 pom配置

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.fishedee.id_generator</groupId>
  <artifactId>spring-boot-starter-id-generator</artifactId>
  <version>1.4</version>
  <packaging>jar</packaging>

  <name>spring-boot-starter-id-generator</name>
  <url>http://maven.apache.org</url>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <java.version>1.8</java.version>
    <version.compiler-plugin>3.8.1</version.compiler-plugin>
    <!--只能用Idea来做单元测试,Maven test不支持-->
    <skipTests>true</skipTests>
  </properties>



  <dependencyManagement>
    <dependencies>
      <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-dependencies -->
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>2.5.4</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>

    <!-- @ConfigurationProperties annotation processing (metadata for IDEs) -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-configuration-processor</artifactId>
      <optional>true</optional>
    </dependency>

    <!-- Compile dependencies -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-autoconfigure</artifactId>
    </dependency>

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

    <!-- Test dependencies -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-json</artifactId>
      <scope>test</scope>
    </dependency>

    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>1.18.20</version>
      <scope>provided</scope>
    </dependency>

  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>${version.compiler-plugin}</version>
        <configuration>
          <source>${java.version}</source>
          <target>${java.version}</target>
          <compilerVersion>${java.version}</compilerVersion>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

这是模块的pom.xml文件,注意点有:

  • 不能用parent来导入整个org.springframework.boot的parent包,这样会影响其他项目的。我们要用dependencyManagement的import来导入spring-boot-dependencies。
  • spring-boot-configuration-processor的包是用来处理属性配置的,spring-boot-autoconfigure的包是用来处理自动加载的,这两个基本是标配的。
  • plugin只能使用maven-compiler-plugin,千万不要导入spring-boot-maven-plugin的插件,这个插件只能在最终的SpringBoot项目中使用,不能在依赖模块中使用。具体看这里,因为这个插件会将所有依赖都打到一个最终包里面,在工具模块中使用会有问题的。

15.3 自动加载与属性配置

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.fishedee.id_generator.autoconfig.IdGeneratorAutoConfiguration

在spring.factories中配置好,我们的Configuration类,这个路径是约定的,必须是resources/META-INF/spring.factories的这个文件,不能更改。

package com.fishedee.id_generator.autoconfig;

import com.fishedee.id_generator.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.AbstractApplicationContext;

@Slf4j
@Configuration
@EnableConfigurationProperties(IdGeneratorProperties.class)
public class IdGeneratorAutoConfiguration {
    private final AbstractApplicationContext applicationContext;

    private final IdGeneratorProperties properties;

    public IdGeneratorAutoConfiguration(AbstractApplicationContext applicationContext, IdGeneratorProperties properties) {
        this.applicationContext = applicationContext;
        this.properties = properties;
    }

    @Bean
    @ConditionalOnMissingBean(CurrentTime.class)
    public CurrentTime currentTime() {
        return new DefaultCurrentTime();
    }

    @Bean
    @ConditionalOnMissingBean(PersistConfigRepository.class)
    @ConditionalOnProperty(value = "spring.id-generator.enable", havingValue = "true")
    public PersistConfigRepository persistConfigRepository(){
        return new PersistConfigRepositoryJdbc(this.properties.getTable());
    }

    @Bean
    @ConditionalOnMissingBean(PersistCounterGenerator.class)
    @ConditionalOnProperty(value = "spring.id-generator.enable", havingValue = "true")
    public PersistCounterGenerator persistCounterGenerator(){
        return new PersistCounterGenerator();
    }


    @Bean
    @ConditionalOnMissingBean(IdGenerator.class)
    @ConditionalOnProperty(value = "spring.id-generator.enable", havingValue = "true")
    public IdGenerator idGenerator(PersistCounterGenerator counterGenerator){
        return new PersistGenerator(counterGenerator);
    }

}

这个是普通的配置类,我们根据需要注册每个Bean。同时,注意,我们打开了属性配置类的功能,这样也允许使用方在使用这些属性的时候会有IDE提示。

package com.fishedee.id_generator.autoconfig;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Data
@ConfigurationProperties(prefix="spring.id-generator")
public class IdGeneratorProperties {
    private boolean enable;

    private String table = "id_generator_config";
}

赋值的等号提供了默认值功能,prefix就是属性的前缀了,没啥好说的

15.4 单元测试

@SpringBootTest
@Import(MyConfig.class)
@DirtiesContext(classMode= DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class PersistCounterTest {

    @Autowired
    private CurrentTimeStub currentTimeStub;

    @Autowired
    private PersistConfigRepositoryStub persistConfigRepositoryStub;

    @Test
    public void testIncrementOne(){
    }
}

对spring-boot-starter-xxx项目的测试,我们肯定是要引入SpringBoot类了,同时也要更改其中的bean,毕竟不是所有外部依赖都不需要Mock或者Stub就能测试的。在单元测试里面,加入@SpringBootTest注解,同时打开@DirtiesContext(可选操作,意义前面说过了)

package com.fishedee.id_generator.id_generator;

import com.fishedee.id_generator.CurrentTime;
import com.fishedee.id_generator.PersistConfigRepository;
import com.fishedee.id_generator.PersistCounterGenerator;
import com.fishedee.id_generator.PersistGenerator;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;

@SpringBootConfiguration
public class MyConfig {
    @Bean
    @Primary
    public CurrentTime getCurrentTime() {
        return new CurrentTimeStub();
    }

    @Bean
    @Primary
    public PersistConfigRepository getPersistConfigRepository(){
        return new PersistConfigRepositoryStub();
    }


    @Bean
    @Primary
    public PersistCounterGenerator persistCounterGenerator(){return new PersistCounterGenerator();}

    @Bean
    @Primary
    public PersistGenerator persistGenerator(PersistCounterGenerator counterGenerator){return new PersistGenerator(counterGenerator);}

}

然后在MyConfig中使用@SpringBootConfiguration就可以正常测试了,在上面例子中,我们使用了Stub来辅助测试这个工具模块。

16 RestTemplate

代码看这里

RestTemplate是一个便利的HttpClient工具,可以作为Http客户端与其他服务器通信,也可以作为爬虫工具来用(性能就稍差一点了)。

16.1 依赖与配置

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

SpringBootStarterWeb就含有RestTemplate这个库。

package spring_test;

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

import java.time.Duration;

@Configuration
public class MainConfig {
    @Bean
    public RestTemplate getTemplate(RestTemplateBuilder builder){
        return builder.setConnectTimeout(Duration.ofSeconds(5))
                .setReadTimeout(Duration.ofSeconds(5))
                .build();
    }
}

先创建配置文件,用RestTemplateBuilder创建一个全局的RestTemplate的bean。

16.2 HTTP响应解析

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.HashMap;
import java.util.Map;

@Component
@Slf4j
public class TestService {
    @Autowired
    private RestTemplate restTemplate;

    public void getResponseBody(){
        ResponseEntity<byte[]> result = restTemplate.getForEntity("http://localhost:8585/hello/get1",byte[].class);
        log.info("byte[] length {}",new String(result.getBody()));

        ResponseEntity<String> result2 = restTemplate.getForEntity("http://localhost:8585/hello/get1",String.class);
        log.info("string length {}",result2.getBody());
    }

    public void getResponseHeader(){
        //getForXXXX,只能发送get请求
        ResponseEntity<String> result = restTemplate.getForEntity("http://localhost:8585/hello/get2",String.class);

        log.info("status code {}",result.getStatusCode());
        log.info("header {}",result.getHeaders());
    }

    public void go(){
        getResponseHeader();
        getResponseBody();
    }
}

请求方法的区别:

  • getForEntity就是发送GET请求了,对应的postForEntity就是发送POST请求了。
  • getForObject与getForEntity的区别在于,getForEntity主要是获取byte[]与String返回,getForObject就是默认用JSON作为响应的解析。

返回结果的区别:

  • ResponseEntity,当StatusCode不是200的时候,会自动抛出异常。
  • getHeaders,获取响应头信息
  • getBody,获取响应主体信息

16.3 请求HTTP发送

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.HashMap;
import java.util.Map;

@Component
@Slf4j
public class TestService {
    @Autowired
    private RestTemplate restTemplate;

    public void requestParam(){

        HttpHeaders headers = new HttpHeaders();
        headers.set("MyHeader", "MyHeaderValue");

        UriComponentsBuilder builder = UriComponentsBuilder
                .fromUriString("http://localhost:8585/hello/get2")
                .queryParam("name", "fish")
                .queryParam("age",123);
        HttpEntity<?> entity = new HttpEntity<>(headers);

        //exchange发送任意方法请求
        ResponseEntity<String> result = restTemplate.exchange(builder.build().encode().toUri(), HttpMethod.GET,entity,String.class);

        log.info("status code {}",result.getStatusCode());
        log.info("header {}",result.getHeaders());
        log.info("body {}",result.getBody());
    }

    public void requestFormBody(){
        //entity传递body与header信息
        HttpHeaders headers = new HttpHeaders();
        headers.set("MyHeader2", "MyHeaderValue2");

        //输入body类型为String的时候,默认的contentType为text/plain
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        //手动序列化uri-query参数
        UriComponentsBuilder bodyBuilder = UriComponentsBuilder
                .fromUriString("")
                .queryParam("height", "300 px")
                .queryParam("width","200px");

        HttpEntity<?> entity = new HttpEntity<>(bodyBuilder.build().encode().getQuery(),headers);

        //url传入query信息
        UriComponentsBuilder builder = UriComponentsBuilder
                .fromUriString("http://localhost:8585/hello/post1")
                .queryParam("name", "fish")
                .queryParam("age",123);

        //exchange发送任意方法请求
        ResponseEntity<String> result = restTemplate.exchange(builder.build().encode().toUri(), HttpMethod.POST,entity,String.class);

        log.info("status code {}",result.getStatusCode());
        log.info("header {}",result.getHeaders());
        log.info("body {}",result.getBody());
    }

    public void requestJsonBody(){
        //entity传递body与header信息
        HttpHeaders headers = new HttpHeaders();
        headers.set("MyHeader3", "MyHeaderValue3");

        Map<String,Object> postBody = new HashMap<>();
        postBody.put("height","300px");
        postBody.put("width","200px");

        HttpEntity<?> entity = new HttpEntity<>(postBody,headers);

        //url传入query信息
        UriComponentsBuilder builder = UriComponentsBuilder
                .fromUriString("http://localhost:8585/hello/post1")
                .queryParam("name", "fish")
                .queryParam("age",123);

        //exchange发送任意方法请求
        ResponseEntity<String> result = restTemplate.exchange(builder.build().encode().toUri(), HttpMethod.POST,entity,String.class);

        log.info("status code {}",result.getStatusCode());
        log.info("header {}",result.getHeaders());
        log.info("body {}",result.getBody());
    }


    public void go(){
        requestParam();
        requestFormBody();
        requestJsonBody();
    }
}

请求的话相对来说就复杂一点了:

  • URL中添加Query参数,就需要用UriComponentsBuilder来拼接出URL。注意,对于数组参数,参数名称要手动加上[]符号
  • 添加额外请求的头信息,就需要用更强大的exchange方法,叠加用HttpEntity构造出请求头信息。
  • 添加请求的主体信息时,需要用HttpEntity,来传入Body,与Header信息。对于String类型的Body,默认ContentType为text/plain。对于Object类型的Body,默认ContentType为application/json,并且用jackjson序列化以后发送出去。

16.4 Controller解析

package spring_test;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpRequest;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.stereotype.Controller;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.http.HttpServletRequest;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringWriter;
import java.util.*;

/**
 * Created by fish on 2021/4/25.
 */
//@Controller+@ResponseBody,相当于@RestController
@Controller
@ResponseBody
@RequestMapping("/hello")
@Slf4j
public class MyController {

    @Autowired
    private TestService testService;

    @GetMapping("/go")
    public void go(){
        testService.go();
    }

    @GetMapping("/get1")
    public String get1(){
        return "Hello World";
    }

    @GetMapping("/get2")
    public String get2(){
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        String target = "";
        target += request.getMethod()+"<br/>";
        target += this.getQueryParam(request)+"<br/>";
        target += this.getHeader(request)+"<br/>";
        return target;
    }

    //同时获取query与body里面的参数
    public String getQueryAndBodyParam(HttpServletRequest request){
        List<String> paramList = new ArrayList<>();

        Map<String,String[]> parameterMap = request.getParameterMap();
        for( String key :parameterMap.keySet() ){
            paramList.add(key+":"+Arrays.asList(parameterMap.get(key))+"<br/>");
        }
        return paramList.toString();
    }

    //只获取query里面的参数,这里用UriComponentsBuilder来反向解析url
    public String getQueryParam(HttpServletRequest request){
        UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(request.getRequestURI()+"?"+request.getQueryString());
        UriComponents components = builder.build();
        MultiValueMap<String,String> queryParam = components.getQueryParams();
        List<String> paramList = new ArrayList<>();
        for( String key : queryParam.keySet()){
            paramList.add(key+":"+queryParam.get(key)+"<br/>");
        }
        return paramList.toString();
    }

    //获取header
    public String getHeader(HttpServletRequest request){
        List<String> nameList = new ArrayList<String>();
        Enumeration<String> enumeration = request.getHeaderNames();
        while( enumeration.hasMoreElements() ){
            String key = enumeration.nextElement();
            nameList.add(key+":"+request.getHeader(key)+"<br/>");
        }
        return nameList.toString();
    }

    //获取body
    public String getBody(HttpServletRequest request)throws IOException{
        StringWriter writer = null;
        try {
            writer = new StringWriter();
            BufferedReader reader = request.getReader();
            String s = null;
            while (true) {
                s = reader.readLine();
                if (s == null) {
                    break;
                }
                writer.append(s);
            }
            return writer.toString();
        }catch(IOException e){
            throw new RuntimeException(e);
        }finally {
            if( writer != null ){
                writer.close();
            }
        }
    }

    @PostMapping("/post1")
    public String post1() throws IOException {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String target = "";
        target += request.getMethod()+"<br/>";
        target += this.getQueryParam(request)+"<br/>";
        target += this.getHeader(request)+"<br/>";
        target += this.getBody(request)+"<br/>";
        return target;
    }

}

这里,顺便贴一下代码,如何从HttpServletRequest中提取参数信息:

  • 使用UriComponentsBuilder,从request.getQueryString中提取Query参数信息。
  • 使用request.getHeaderNames中提取Header参数信息
  • 使用request.getReader中提取body参数信息。

可以看到UriComponentsBuilder,同时承担了Uri的序列化与反序列化的工作。

17 定时任务

代码在这里

SpringBoot原生支持后台定时任务

17.1 配置

@EnableScheduling
@SpringBootApplication
@Slf4j
public class App
{
    public static void main( String[] args )
    {
        SpringApplication.run(App.class,args);
    }
}

使用@EnableScheduling注解启动,定时任务

17.2 普通定时任务

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.text.SimpleDateFormat;
import java.util.Date;

@Component
@Slf4j
public class MyScheduService {

    private SimpleDateFormat ft = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Scheduled(cron = "*/5 * * * * *")
    //秒,分,小时,日,月,年或者星期
    public void cronTask(){
        log.info("cronTask: {}",ft.format(new Date()));
    }
    /*
    输出为:
    cronTask: 2022-01-12 21:20:25
    cronTask: 2022-01-12 21:20:30
    cronTask: 2022-01-12 21:20:35
     */

    @Scheduled(fixedRate = 5000)
    public void fixedRateTask()throws Exception{
        log.info("fixedRateTask: {}",ft.format(new Date()));
        Thread.sleep(1000);
    }
    /*
    输出为:
    fixedRateTask: 2022-01-12 21:20:25
    fixedRateTask: 2022-01-12 21:20:30
    fixedRateTask: 2022-01-12 21:20:35
     */

    @Scheduled(fixedDelay = 5000)
    public void fixedDelayTask()throws Exception{
        log.info("fixedDelayTask: {}",ft.format(new Date()));
        Thread.sleep(1000);
    }
    /*
    输出为:
    fixedDelayTask:2022-01-12 21:20:27
    fixedDelayTask: 2022-01-12 21:20:33
    fixedDelayTask: 2022-01-12 21:20:39
     */

    @Scheduled(fixedDelay = 5000,initialDelay = 1000)
    public void fixedDelayAndInitialDelayTask()throws Exception{
        log.info("fixedDelayAndInitialDelayTask: {}",ft.format(new Date()));
        Thread.sleep(1000);
    }
    /*
    输出为:
    fixedDelayAndInitialDelayTask: 2022-01-12 21:20:28
    fixedDelayAndInitialDelayTask: 2022-01-12 21:20:34
    fixedDelayAndInitialDelayTask: 2022-01-12 21:20:41
     */
}

在普通Bean上使用@Scheduled注解就可以启动定时任务,没啥好说的

  • Crontab是配置方式的定时任务
  • fixedRate,是固定速率的定时任务,不受到上一个任务的耗时影响
  • fixedDelay,是固定速率的定时任务,会受到上一个任务的耗时影响
  • initialDelay,初始延迟

注意,默认情况下,所有的任务都是在同一个线程上执行的。一个任务的堵塞会影响其他任务的执行。

17.3 异步定时任务

@Configuration
@EnableAsync
public class MainConfig {
    private int corePoolSize = 10;
    private int maxPoolSize = 200;
    private int queueCapacity = 10;
    @Bean
    public Executor taskExcutor(){
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(corePoolSize);
        executor.setMaxPoolSize(maxPoolSize);
        executor.setQueueCapacity(queueCapacity);
        return executor;
    }
}

启动异步任务,并配置好Executor

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.text.SimpleDateFormat;
import java.util.Date;

@Component
@Slf4j
public class MyScheduAsyncService {

    private SimpleDateFormat ft = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Scheduled(cron = "*/5 * * * * *")
    @Async
    //秒,分,小时,日,月,年或者星期
    public void cronTask(){
        log.info("async cronTask: {}",ft.format(new Date()));
    }
    /*
    输出为:
    async cronTask: 2022-01-12 21:25:41
    async cronTask: 2022-01-12 21:25:45
    async cronTask: 2022-01-12 21:25:50
     */

    @Scheduled(fixedRate = 5000)
    @Async
    public void fixedRateTask()throws Exception{
        log.info("async fixedRateTask: {}",ft.format(new Date()));
        Thread.sleep(1000);
    }
    /*
    输出为:
    async fixedRateTask: 2022-01-12 21:25:39
    async fixedRateTask: 2022-01-12 21:25:44
    async fixedRateTask: 2022-01-12 21:25:49
     */

    @Scheduled(fixedDelay = 5000)
    @Async
    public void fixedDelayTask()throws Exception{
        log.info("async fixedDelayTask: {}",ft.format(new Date()));
        Thread.sleep(1000);
    }
    /*
    输出为:
    async fixedDelayTask: 2022-01-12 21:25:40
    async fixedDelayTask: 2022-01-12 21:25:45
    async fixedDelayTask: 2022-01-12 21:25:50
    每个任务都是一个独立的async,这使得delay的配置失效了
     */

    @Scheduled(fixedDelay = 5000,initialDelay = 1000)
    @Async
    public void fixedDelayAndInitialDelayTask()throws Exception{
        log.info("async fixedDelayAndInitialDelayTask: {}",ft.format(new Date()));
        Thread.sleep(1000);
    }
    /*
    输出为:
    async fixedDelayAndInitialDelayTask: 2022-01-12 21:25:41
    async fixedDelayAndInitialDelayTask: 2022-01-12 21:25:47
    有初始化延迟,但是没有之间延迟
     */
}

在每个任务上加入一个@Async注解就能让任务运行在一个单独的线程上了,一个任务的堵塞不会影响其他任务的执行。

18 命令行

代码在这里

18.1 CommandLineRunner

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.stereotype.Service;
import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;

import javax.annotation.PostConstruct;

/**
 * Hello world!
 *
 */
@EnableScheduling
@SpringBootApplication
@Slf4j
public class App implements CommandLineRunner
{
    public static void main( String[] args )
    {
        SpringApplication.run(App.class,args);
    }

    @PostConstruct
    public void init(){
        log.info("after bean init");
    }
    
    @Override
    public void run(String... args) throws Exception{
        log.info("run!");
    }

}

使用CommandLineRunner的run接口,来确定应用启动的时机。注意,不要使用@PostConstruct的回调,因为@PostContruct的回调仅仅是App这个bean初始化了,并不代表其他依赖都准备好了。

18.2 ApplicationRunner

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.stereotype.Service;
import org.springframework.validation.beanvalidation.MethodValidationPostProcessor;

import javax.annotation.PostConstruct;

/**
 * Hello world!
 *
 */
@EnableScheduling
@SpringBootApplication
@Slf4j
public class App implements ApplicationRunner
{
    public static void main( String[] args )
    {
        SpringApplication.run(App.class,args);
    }

    @PostConstruct
    public void init(){
        log.info("after bean init");
    }

    @Override
    public void run(ApplicationArguments args) throws Exception{
        log.info("run!");
    }
}

ApplicationRunner与CommandLineRunner类似,只是缺少了显式的命令行参数而已

18.3 注意点

使用ApplicationRunner或者CommandLineRunner,都不影响server端的运行。

19 缓存

Spring提供了通用的缓存工具接口,并提供了多样的缓存实现后端,以简洁的方式保证了缓存操作下的线程安全性。

代码在这里

19.1 依赖

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

加入依赖


@SpringBootApplication
@EnableCaching
@Slf4j
public class App{

}

开启缓存,加入注解@EnableCaching

19.2 基础使用

package spring_test;

import lombok.Getter;
import lombok.ToString;

import java.io.Serializable;

@ToString
@Getter
public class User  {
    private String name;

    private Integer age;

    public User(String name,Integer age){
        this.name = name;
        this.age = age;
    }

    public void setAge(Integer age){
        this.age = age;
    }
}

定义实体

package spring_test;

import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Component
@CacheConfig(cacheNames = "user")
public class UserCache {

    private List<User> userList = new ArrayList<>();

    private int reqCacheGetCount = 0;

    private int reqDbGetCount = 0;

    public void clearReqCount(){
        this.reqCacheGetCount = 0;
        this.reqDbGetCount = 0;
    }

    public int getReqCacheGetCount(){
        return this.reqCacheGetCount;
    }

    public int getReqDbGetCount(){
        return this.reqDbGetCount;
    }

    @PostConstruct
    public void init(){
        for( int i = 0 ;i != 100;i++){
            userList.add(new User("fish_"+i,i));
        }
    }

    private User directGet(String name){
        List<User> result = userList.stream().filter((single)->{
            return single.getName().equals(name);
        }).collect(Collectors.toList());
        if( result.size() != 0 ){
            return result.get(0);
        }else{
            throw new RuntimeException("找不到User"+name);
        }
    }

    @Cacheable
    public User cacheGet(String name){
        this.reqCacheGetCount++;
        return this.directGet(name);
    }

    @CachePut
    public User dbGet(String name){
        this.reqDbGetCount++;
        return this.directGet(name);
    }

    @CacheEvict
    public void clear(String name){

    }

    @CacheEvict(allEntries = true)
    public void clearAll(){

    }
}

定义了多个缓存的基础操作,包括有:

  • @Cacheable,获取缓存,缓存不存在的时候自动调用方法拉取
  • @CachePut,调用方法并设置缓存,每次都调用方法拉取,并写入缓存
  • @CacheEvict,缓存失效操作,有单key失效,和全部失效两种
package spring_test;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.core.AutoConfigureCache;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.stereotype.Component;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
public class UserCacheTest {

    @Autowired
    private UserCache userCache;

    @BeforeEach
    public void setUp(){
        userCache.clearAll();
        userCache.clearReqCount();
    }

    private void testGetSampleZero(int count){
        User a = userCache.cacheGet("fish_0");
        assertEquals(a.getAge(),0);

        User b = userCache.cacheGet("fish_0");
        assertEquals(b.getAge(),0);

        User c = userCache.cacheGet("fish_0");
        assertEquals(c.getAge(),0);

        assertEquals(userCache.getReqCacheGetCount(),count);
    }

    private void testGetSampleOne(int count){
        User a = userCache.cacheGet("fish_1");
        assertEquals(a.getAge(),1);

        User b = userCache.cacheGet("fish_1");
        assertEquals(b.getAge(),1);

        User c = userCache.cacheGet("fish_1");
        assertEquals(c.getAge(),1);

        assertEquals(userCache.getReqCacheGetCount(),count);
    }

    @Test
    public void testBasic(){
        //初始
        this.testGetSampleZero(1);
        this.testGetSampleOne(2);

        //全部清理
        userCache.clearAll();
        this.testGetSampleZero(3);
        this.testGetSampleOne(4);

        //清理单个key
        userCache.clear("fish_0");
        this.testGetSampleZero(5);
        this.testGetSampleOne(5);

        //清理单个key
        userCache.clear("fish_1");
        this.testGetSampleZero(5);
        this.testGetSampleOne(6);
    }

    @Test
    public void testBasic2(){
        //CachePut总是会执行的,他产生一个副作用是自动放入到cache里面
        userCache.dbGet("fish_0");
        userCache.dbGet("fish_0");
        assertEquals(userCache.getReqDbGetCount(),2);

        //这个时候,使用cacheGet会自动从cache里面拿,不需要实际调用
        User a = userCache.cacheGet("fish_0");
        assertEquals(a.getAge(),0);
    }

    @Test
    public void testCacheSerialize(){
        //默认情况下,对本地的cache使用按照引用缓存的方式,所以会触发修改
        User a = userCache.cacheGet("fish_2");
        assertEquals(a.getAge(),2);
        a.setAge(102);

        User b = userCache.cacheGet("fish_2");
        assertEquals(b.getAge(),102);
    }

    @Test
    public void testThrow(){
        //当抛出异常的时候,异常不会放入缓存
        for( int i = 0 ;i != 2;i++){
            try{
                User a = userCache.cacheGet("fish_1000");
            }catch(Exception e){
            }
        }
        assertEquals(userCache.getReqCacheGetCount(),2);
    }
}

缓存的测试代码,要点如下:

  • @Cacheable只有在缓存失效的时候才调用实际方法,当缓存有效的时候,实际方法不调用。
  • @CachePut,每次都会调用实际方法,并写入缓存
  • 缓存默认是以引用的方式存放,所以注意对缓存的修改,会导致其他线程对缓存的可见性变化
  • 异常发生的时候,异常不会写入缓存。
@Autowired
private ConcurrentMapCacheManager concurrentMapCacheManager;

@PostConstruct
public void init(){
    //设置按值存储,但需要对象实现Serializable接口
    //concurrentMapCacheManager.setStoreByValue(true);
}

默认情况下,SpringCache使用ConcurrentMap作为缓存的后端实现,以引用的方式存放缓存。我们可以通过它的setStoreByValue改为按值存放缓存,但对应的数据要先实现Serializable接口

19.3 进阶使用

package spring_test;

import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Component
@CacheConfig(cacheNames = "enhance_user")
public class EnhanceUserCache {
    private List<User> userList = new ArrayList<>();

    private int reqCacheGetNormalCount = 0;

    private int reqCacheGetKeyCount = 0;

    private int reqCacheGetCondtionCount = 0;

    private int reqCacheGetUnlessCount = 0;

    public void clearReqCount(){
        this.reqCacheGetNormalCount = 0;
        this.reqCacheGetKeyCount = 0;
        this.reqCacheGetCondtionCount = 0;
        this.reqCacheGetUnlessCount = 0;
    }

    public int getReqCacheGetNormalCount(){
        return this.reqCacheGetNormalCount;
    }

    public int getReqCacheGetKeyCount(){
        return this.reqCacheGetKeyCount;
    }

    public int getReqCacheGetCondtionCount(){
        return this.reqCacheGetCondtionCount;
    }

    public int getReqCacheGetUnlessCount(){
        return this.reqCacheGetUnlessCount;
    }

    @PostConstruct
    public void init(){
        for( int i = 0 ;i != 100;i++){
            userList.add(new User("cat_"+i,i));
        }
    }

    private User directGet(String name){
        List<User> result = userList.stream().filter((single)->{
            return single.getName().equals(name);
        }).collect(Collectors.toList());
        if( result.size() != 0 ){
            return result.get(0);
        }else{
            throw new RuntimeException("找不到User"+name);
        }
    }

    @Cacheable
    public User cacheGetNormal(String name,int nothing){
        this.reqCacheGetNormalCount++;
        return this.directGet(name);
    }

    //指定某个参数作为key
    @Cacheable(key = "#name")
    public User cacheGetKey(String name,int nothing){
        this.reqCacheGetKeyCount++;
        return this.directGet(name);
    }

    //condition是符合条件才缓存,示例用的是输入参数
    @Cacheable(condition = "#name.length() <= 5 ")
    public User cacheGetCondition(String name){
        this.reqCacheGetCondtionCount++;
        return this.directGet(name);
    }

    //unless是符合条件的不缓存,示例用的是返回值
    @Cacheable(unless = "#result.getAge() > 10")
    public User cacheGetUnless(String name){
        this.reqCacheGetUnlessCount++;
        return this.directGet(name);
    }

    @CacheEvict
    public void clear(String name){

    }

    @CacheEvict(allEntries = true)
    public void clearAll(){

    }
}

进阶使用的要点如下:

  • key参数,指定特定的key是什么。暂时没有找到批量缓存一堆key的方法。
  • condition参数,指定符合什么条件下才缓存
  • unless参数,指定符合什么条件下必须不缓存
  • sync参数,当缓存失效的时候,可能会有多个线程同时检查到缓存失效,导致多线程拉取实际数据,最终产生缓存击穿的问题。一个简单的方式是,让首个检查到缓存失效的线程进行查询操作,其他线程等待他的结果返回。在这种情况下,我们需要使用双锁检查来实现。而对于SpringBoot来说,我们仅需要打开sync参数即可,SpringBoot会保证缓存失效的时候,仅有一个线程进行实际数据的拉取操作。注意,对方法本身仅仅加入synchronized并不能实现这个功能。
package spring_test;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.core.AutoConfigureCache;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.stereotype.Component;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
public class EnhanceCacheTest {

    @Autowired
    private EnhanceUserCache userCache;

    @BeforeEach
    public void setUp(){
        userCache.clearAll();
        userCache.clearReqCount();
    }

    @Test
    public void testNormal(){
        //默认情况下,以所有的参数组合作为key
        User a = userCache.cacheGetNormal("cat_1",1);
        assertEquals(a.getAge(),1);

        User b = userCache.cacheGetNormal("cat_1",2);
        assertEquals(b.getAge(),1);

        User c = userCache.cacheGetNormal("cat_1",3);
        assertEquals(c.getAge(),1);

        assertEquals(userCache.getReqCacheGetNormalCount(),3);
    }

    @Test
    public void testKey(){
        //使用key参数可以覆盖这个设定,指定cache的key
        User a = userCache.cacheGetKey("cat_1",1);
        assertEquals(a.getAge(),1);

        User b = userCache.cacheGetKey("cat_1",2);
        assertEquals(b.getAge(),1);

        User c = userCache.cacheGetKey("cat_1",3);
        assertEquals(c.getAge(),1);

        assertEquals(userCache.getReqCacheGetKeyCount(),1);
    }

    @Test
    public void testCondition(){
        //condition参数可以指定输入参数cache的条件。
        User a = userCache.cacheGetCondition("cat_1");
        assertEquals(a.getAge(),1);

        User b = userCache.cacheGetCondition("cat_1");
        assertEquals(b.getAge(),1);

        User c = userCache.cacheGetCondition("cat_1");
        assertEquals(c.getAge(),1);

        assertEquals(userCache.getReqCacheGetCondtionCount(),1);

        User a2 = userCache.cacheGetCondition("cat_12");
        assertEquals(a2.getAge(),12);

        User b2 = userCache.cacheGetCondition("cat_12");
        assertEquals(b2.getAge(),12);

        User c2 = userCache.cacheGetCondition("cat_12");
        assertEquals(c2.getAge(),12);

        assertEquals(userCache.getReqCacheGetCondtionCount(),4);
    }

    @Test
    public void testUnless(){
        //condition参数可以指定输入参数cache的条件。
        User a = userCache.cacheGetUnless("cat_1");
        assertEquals(a.getAge(),1);

        User b = userCache.cacheGetUnless("cat_1");
        assertEquals(b.getAge(),1);

        User c = userCache.cacheGetUnless("cat_1");
        assertEquals(c.getAge(),1);

        assertEquals(userCache.getReqCacheGetUnlessCount(),1);

        User a2 = userCache.cacheGetUnless("cat_12");
        assertEquals(a2.getAge(),12);

        User b2 = userCache.cacheGetUnless("cat_12");
        assertEquals(b2.getAge(),12);

        User c2 = userCache.cacheGetUnless("cat_12");
        assertEquals(c2.getAge(),12);

        assertEquals(userCache.getReqCacheGetUnlessCount(),4);
    }
}

测试代码,没啥好说的了

19.4 双写一致性

缓存对于提供系统性能有奇效,但是有很多细节需要仔细考虑

19.4.1 问题

于更新完数据库,是更新缓存呢,还是删除缓存。又或者是先删除缓存,再更新数据库。数据库与缓存是在两个独立的系统中,如何保持两者数据的一致性。

19.4.2 需求

对于这个问题,我们首先明确一个上限,和两个原则。

  • 上限,当数据库与缓存是在两个独立的系统中,例如是mysql与redis,根据CAP定理,我们基本不可能引入paxos算法来保持两者数据的一致性。在任何方案中,现实中依然肯定会存在缓存中的数据与数据库中的数据不一致的情况。我们探讨不同的方案,只是为了明确哪个方案相对来说代价更少,对业务的可接受度更高而已。
  • 原则1,我们不能忍受cache永远持有过期的旧数据,我们能忍受暂时性的cache持有过期的旧数据,而后在可接受的时间内cache会更新到最新的数据上。
  • 原则2,我们不能忍受cache任何时候暂时的持有假数据。例如,数据库是A值,缓存也是A值。但是当数据库从A,改到B值,而后数据库提交失败,回滚到A值了,我们不能忍受缓存曾经持有过B值的事实。

19.4.3 方案选择

缓存有可选模块的方案组合

  • 缓存更新的办法,
  • 缓存更新的时机
  • 缓存的实现方法

首先,模块1,缓存更新的方法有:

  • A. 写方向cache写入新数据。
  • B. 写法clear数据,由读方读取到cache为空以后,触发读取db数据,而后写入到cache中。
  • C. 独立线程周期读取db的最新数据,然后写入cache中
  • D. 独立线程读取db的binlog,实时写入到cache中

然后,模块2,缓存更新的时机有:

  • A. db写入成功前,进行cache的写入或者clear操作。
  • B. db写入成功后,进行cache的写入或者clear操作。

最后,模块3,缓存的实现方法有:

  • A. cache与业务在同一个机器中,无过期时间,cache的write与clear操作总是成功,除非业务宕机了。例如ConcurrentHashMap实现,ecache实现。
  • B. cache与业务不在同一个机器中,有过期时间,cache的write与clear操作可能会失败,跟业务宕机没有关系。例如redis实现。

19.4.4 推导

先用排除法:

  • 排除1A的方案,当两个线程U1,和U2,并发更新数据的时候,会产生U1先写入数据,但是由U1的旧数据更新缓存的情况。这种操作会产生并发冲突,最终导致,破坏原则1。
  • 排除2A的方案,当db写入成功前,先write或者clear缓存数据。情况1,先write数据,再写入db。那么当如果db写入成功前业务宕机了,但是缓存已经write了新数据,会导致破坏原则2。情况2,先clear数据,再写入db。那么当另外一个读操作在clear以后,write db之前,这个时候读操作读取的依然是旧数据,最终导致cache持有的依然是旧数据。破坏原则1。

再考虑用显然成立法

  • 1C显然成立,原则1和原则2不可能破坏,缺点是,数据的过期时间永远保持在独立线程的更新周期中,与数据库的更新速度无关,这显然是一种体验下降。
  • 1D显然成立,原则1和原则2不可能破坏,优点是,cache的更新有实时性,缺点是需要额外引入kafka等工具,其实也推荐这种做法。

最后,我们剩下,1B,2B,3A和 3B的这几个可行模块。

19.4.5 结论

  • 方案1,1B+2B+3A,db先写,然后clear cache。由于缓存与业务是在同一个机器中,clear cache不可能失败。cache的旧数据仅仅可能在db写入成功后,与clear cache的操作之间的短暂时间。这种方案推荐,实现简单,而且可靠,唯一缺点是单机实现,重启后丢失缓存。
  • 方案2,1B+2B+3B,db先写,然后clear cache。由于缓存与业务不在同一个机器中,clear cache可能会失败。当clear cache失败以后,cache持有的必然是一个旧数据,cache旧数据的更新只能等待cache的过期来触发更新。所以,这种方案中,cache的旧数据过期最大时间为redis设置的过期时间。这种方案也推荐,实现麻烦一点,但是成熟,而且重启后不丢失缓存。这是著名的Cache Aside 模式
  • 方案3,1D,通过db binlog来同步cache数据,DDIA推荐的模式。实现较为复杂,但是实时性较高,cache持有旧数据的最大可能时间最短。

注意,我们要特别注意在带有事务的db操作中,只有在事务提交以后,才能触发clear cache操作。如果在事务提交以前触发clear cache操作,就是2A的方案,可能会带来违反原则1的问题。

19.5 参考资料

参考资料在这里:

20 请求级别上下文

有些时候,我们需要某些变量是请求级别的。就是变量在当前请求使用的时候会创建,在当前请求范围内都会一直复用这个变量,在当前请求结束以后会自动释放这个变量。

代码在这里

20.1 基础

package spring_test;

import lombok.Getter;
import lombok.ToString;

import java.io.Serializable;

@ToString
@Getter
public class User  {
    private String name;

    private Integer age;

    public User(String name,Integer age){
        this.name = name;
        this.age = age;
    }

    public void setAge(Integer age){
        this.age = age;
    }
}

基础的User实体

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;

@Component
@Slf4j
public class UserHolder {

    private final static String ATTR_NAME= "user";

    private User getFromDb(){
        log.info("getFromDb User");
        return new User("fish",120);
    }

    public User get(){
        RequestAttributes reqattr = RequestContextHolder.getRequestAttributes();
        Object result = reqattr.getAttribute(ATTR_NAME,RequestAttributes.SCOPE_REQUEST);
        if( result != null ){
            return (User)result;
        }
        User result2 = this.getFromDb();
        reqattr.setAttribute(ATTR_NAME,result2,RequestAttributes.SCOPE_REQUEST);
        reqattr.registerDestructionCallback(ATTR_NAME,()->{
            log.info("release user");
        },RequestAttributes.SCOPE_REQUEST);
        return result2;
    }
}

填写以上代码,用RequestContextHolder来进行读写属性操作就可以了。

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("test")
@Slf4j
public class TestController {

    @Autowired
    private UserHolder userHolder;

    @GetMapping("/go")
    public void go(){
        log.info("get0");
        User user = userHolder.get();
        log.info("get1 {}",user);
        User user2 = userHolder.get();
        log.info("get2 {} {}",user2,user==user2);
        /*
        每次的输出结果都是一致的,都是以下的结果
2022-02-10 17:34:56.077  INFO 8796 --- [nio-8080-exec-1] spring_test.TestController               : get0
2022-02-10 17:34:56.077  INFO 8796 --- [nio-8080-exec-1] spring_test.UserHolder                   : getFromDb User
2022-02-10 17:34:56.077  INFO 8796 --- [nio-8080-exec-1] spring_test.TestController               : get1 User(name=fish, age=120)
2022-02-10 17:34:56.078  INFO 8796 --- [nio-8080-exec-1] spring_test.TestController               : get2 User(name=fish, age=120) true
2022-02-10 17:34:56.094  INFO 8796 --- [nio-8080-exec-1] spring_test.UserHolder                   : release user
*/
    }
}

代码测试如上,也比较简单了。要点如下:

  • 相当于请求级别的缓存,在当前请求结束的时候,自动清理这些数据。这个功能经常用来做登录用户相关信息的存储,例如是租户信息,权限信息,部门信息等等。
  • 可以通过registerDestructionCallback来注册变量释放的时机回调

21 AspectJ

代码在这里

21.1 基础

package spring_test;


import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation {

}

先声明一个注解

package spring_test;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Component
@Aspect
@Slf4j
public class MyAnnotationAspectJ {

    @Before("@annotation(spring_test.MyAnnotation)")
    public void before(){
        log.info("before annotation...");
    }

    @After("@annotation(spring_test.MyAnnotation)")
    public void after(){
        log.info("after annotation...");
    }

    @Around("@annotation(spring_test.MyAnnotation)")
    public Object around(ProceedingJoinPoint joinPoint)throws Throwable{
        log.info("around 1 ");
        Object result = joinPoint.proceed();
        log.info("around 2");
        return result;
    }
}

然后定义一个AspectJ实现,这个实现需要三点:

  • 用@Component声明为一个Bean
  • 用@Aspect声明这个bean为AspectJ
  • 用@Before,@After和@Around注解来定义执行指定注解的时候,进行对应的增强方法实现
package spring_test;

        import lombok.Data;
        import org.springframework.web.bind.annotation.*;

        import javax.validation.constraints.Min;
        import javax.validation.constraints.NotEmpty;
        import java.util.HashMap;
        import java.util.List;
        import java.util.Map;

/**
 * Created by fish on 2021/4/25.
 */
@RestController
@RequestMapping("/hello")
public class Controller {

    @GetMapping("/go1")
    @MyAnnotation
    public String go1(){
        return "Hello World";
    }

}

最后,我们在方法里面加入一个@MyAnnotation注解就可以增强方法了。当然了,增强的前提是这个类是受Spring管理的bean,这个在Spring源码解析中说得很清楚了。

22 Swagger

Swagger是一个神器,可以将SpringBoot的API导出为文档,并且生成对应的json文件,这有助于,进一步生成代码

代码在这里

官网文档在这里

22.1 依赖

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-boot-starter</artifactId>
    <version>3.0.0</version>
</dependency>

SpringBoot的环境,在Maven中加入这两个依赖就可以了

22.2 配置

springfox.documentation.swagger-ui.enabled=true

在properties中打开swagger-ui

package spring_test;

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@Profile("development")
public class SwaggerUiWebMvcConfigurer implements WebMvcConfigurer {
    private final String baseUrl = "";

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        String baseUrl = StringUtils.trimTrailingCharacter(this.baseUrl, '/');
        registry.
                addResourceHandler(baseUrl + "/swagger-ui/**")
                .addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/")
                .resourceChain(false);
    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController(baseUrl + "/swagger-ui/")
                .setViewName("forward:" + baseUrl + "/swagger-ui/index.html");
    }
}

首先加入WebMvcConfigurer,将swagger-ui映射出来。(这一步经过实践,可以不加)

package spring_test;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.oas.annotations.EnableOpenApi;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Configuration
@EnableOpenApi
public class SwaggerConfiguration {
    //api接口包扫描路径
    public static final String SWAGGER_SCAN_BASE_PACKAGE = "spring_test";
    public static final String VERSION = "1.0.0";

    @Bean
    public Docket createRestApi() {
        return new Docket(DocumentationType.OAS_30)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage(SWAGGER_SCAN_BASE_PACKAGE))
                .paths(PathSelectors.any()) // 可以根据url路径设置哪些请求加入文档,忽略哪些请求
                .build();
    }

    //http://localhost:8080/swagger-ui/index.html
    //http://localhost:8080/v2/api-docs
    //http://localhost:8080/v3/api-docs
    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("测试服务") //设置文档的标题
                .description("测试服务API接口文档") // 设置文档的描述
                .version(VERSION) // 设置文档的版本信息-> 1.0.0 Version information
                .termsOfServiceUrl("https://www.baidu.com") // 设置文档的License信息->1.3 License information
                .build();
    }
}

然后加上以上的bean即可,注意,Swagger有三个版本的API,分别是Swagger 1,Swagger 2和Open API(oas),我们建议使用OpenAPI,字段的类型描述更为准确。更换API的版本只需修改new Docket(DocumentationType.OAS_30)的构造器参数就可以了。

这个时候,我们能得到:

  • http://localhost:8080/swagger-ui/index.html,Swagger-ui
  • http://localhost:8080/v2/api-docs,Swagger 2版本的API结构化描述
  • http://localhost:8080/v3/api-docs,OpenAPI版本的API结构化描述

22.3 注解

package spring_test;

        import io.swagger.annotations.Api;
        import io.swagger.annotations.ApiImplicitParam;
        import io.swagger.annotations.ApiImplicitParams;
        import io.swagger.annotations.ApiOperation;
        import lombok.AllArgsConstructor;
        import lombok.Data;
        import lombok.NoArgsConstructor;
        import lombok.extern.slf4j.Slf4j;
        import org.springframework.core.annotation.Order;
        import org.springframework.validation.annotation.Validated;
        import org.springframework.web.bind.annotation.*;

        import javax.validation.constraints.Min;
        import javax.validation.constraints.NotEmpty;
        import javax.validation.constraints.NotNull;
        import java.util.HashMap;
        import java.util.List;
        import java.util.Map;

/**
 * Created by fish on 2021/4/25.
 */
@RestController
@RequestMapping("/hello")
@Slf4j
@Validated
@Api(value="控制器A",tags = "接口B")
public class Controller {

    //GET请求 http://localhost:8080/hello/go1
    @GetMapping("/go1")
    public String go1(){
        return "Hello World";
    }

    //POST请求 http://localhost:8080/hello/go2
    /*
    {
        "name":123,
        "email":"123@qq.com",
        "size":4,
        "total":"8.0",
        "id":1
    }
     */
    @PostMapping("/go2")
    public void go2(@NotNull Long id, OrderDO orderDO){
        log.info("go2 {} {}",id,orderDO);
    }

    //GET请求 http://localhost:8080/hello/go2
    //localhost:8080/hello/go3?id=123&data=%7B%22name%22:123,%22email%22:%22123@qq.com%22,%22size%22:4,%22total%22:%228.0%22%7D
    //localhost:8080/hello/go3?id=123&data={"name":123,"email":"123@qq.com","size":4,"total":"8.0"},原始格式
    //@RequestParam指定的放其指定的字段上
    //其他的参数默认放在data字段上,用json格式,并且用urlEncode过
    @GetMapping("/go3")
    public void go3(@NotNull @RequestParam("id") Long id,OrderDO orderDO){
        log.info("go3 {} {}",id,orderDO);
    }

    @ApiOperation("获取信息go4")
    @PostMapping("/go4")
    public OrderDO go4(@RequestBody OrderDO order){return order;}

    @ApiOperation("获取信息go5")
    @ApiImplicitParam(name="u",value="必传",paramType = "query")
    @GetMapping("/go5")
    public OrderDO go5(OrderDO u,Long id){return u;}

    @ApiOperation("获取信息go6")
    @GetMapping("/go6")
    public OrderDO2 go6(){return new OrderDO2();}

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Page<T>{
        private int count;
        private List<T> data;
    }

    @ApiOperation("获取信息go7")
    @GetMapping("/go7")
    public Page<OrderDO> go7(){return new Page<>();}

    @ApiOperation("获取信息go8")
    @GetMapping("/go8")
    public Page<OrderDO2> go8(){return new Page<>();}
}

这是一个普通的Controller接口,需要的注解有:

  • @Api,描述这个Controller
  • @ApiOperation,描述这个接口
  • @RequestBody@RequestParam,用来指定参数的来源,如果没有这两个注解的话,默认所有的参数都是Query参数
  • @ApiImplicitParam,额外描述每一个接口参数
package spring_test;

import com.fasterxml.jackson.annotation.JsonUnwrapped;
import io.swagger.annotations.ApiModel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Created by fish on 2021/4/25.
 */
@Data
public class OrderDO {
    @NotBlank
    private String name;

    @NotNull
    private OrderType orderType;

    @NotNull
    @Email
    private String email;

    @Min(value = 1,message = "必须为正数")
    private int size;

    @NotNull
    @DecimalMin(value = "0.0001",message = "必须为正数")
    private BigDecimal total;

    private Map<String,Item> addressMap;

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @ApiModel(value="OrderDOItem", description="OrderDO的Item")
    public static class Item{
        private String name;

        private int id;

        private BigDecimal count;
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Info{
        private String address;
    }

    //生成的JSON会抹去info信息,因为有JsonUnwrapped
    @JsonUnwrapped
    private Info info;

    private List<Item> itemList = new ArrayList<>();
}

OrderDO模型,

package spring_test;

public enum OrderType {
    DIRECT,//直销
    PROXY,//代销
}

OrderType的枚举

package spring_test;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderDO2 {

    @ApiModel(value="OrderDO2Item", description="OrderDO2的Item")
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Item{

        @ApiModelProperty(name="名称")
        private String name2;

        private int id2;

        private BigDecimal count2;
    }

    private List<Item> itemList = new ArrayList<>();
}

在API接口返回的数据上,我们需要加入以下的注解

  • @ApiModel,默认返回的Model名字为类名(不包含包名),这显然会产生问题,因为OrderDO与OrderDO2的两个Item都是同名的,会有问题。所以,要用@ApiModel来指定生成的Model名字,另外用description来描述类型。
  • @ApiModelProperty,就是描述Model的字段了,没啥好说的。

22.4 结构化json

{
  "openapi": "3.0.3",
  "info": {
    "title": "测试服务",
    "description": "测试服务API接口文档",
    "termsOfService": "https://www.baidu.com",
    "version": "1.0.0"
  },
  "servers": [{
    "url": "http://localhost:8080",
    "description": "Inferred Url"
  }],
  "tags": [{
    "name": "接口B",
    "description": "Controller"
  }],
  "paths": {
    "/hello/go1": {
      "get": {
        "tags": ["接口B"],
        "summary": "go1",
        "operationId": "go1UsingGET",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "*/*": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    },
    "/hello/go2": {
      "post": {
        "tags": ["接口B"],
        "summary": "go2",
        "operationId": "go2UsingPOST",
        "parameters": [{
          "name": "email",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "info.address",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "itemList[0].count",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "number",
            "format": "bigdecimal"
          }
        }, {
          "name": "itemList[0].id",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "integer",
            "format": "int32"
          }
        }, {
          "name": "itemList[0].name",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "name",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "orderType",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "string",
            "enum": ["DIRECT", "PROXY"]
          }
        }, {
          "name": "size",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "minimum": 1,
            "exclusiveMinimum": false,
            "type": "integer",
            "format": "int32"
          }
        }, {
          "name": "total",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "number",
            "format": "bigdecimal"
          }
        }],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "integer",
                "format": "int64"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK"
          },
          "201": {
            "description": "Created"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    },
    "/hello/go3": {
      "get": {
        "tags": ["接口B"],
        "summary": "go3",
        "operationId": "go3UsingGET",
        "parameters": [{
          "name": "email",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "id",
          "in": "query",
          "description": "id",
          "required": true,
          "style": "form",
          "schema": {
            "type": "integer",
            "format": "int64"
          }
        }, {
          "name": "info.address",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "itemList[0].count",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "number",
            "format": "bigdecimal"
          }
        }, {
          "name": "itemList[0].id",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "integer",
            "format": "int32"
          }
        }, {
          "name": "itemList[0].name",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "name",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "orderType",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "string",
            "enum": ["DIRECT", "PROXY"]
          }
        }, {
          "name": "size",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "minimum": 1,
            "exclusiveMinimum": false,
            "type": "integer",
            "format": "int32"
          }
        }, {
          "name": "total",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "number",
            "format": "bigdecimal"
          }
        }],
        "responses": {
          "200": {
            "description": "OK"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    },
    "/hello/go4": {
      "post": {
        "tags": ["接口B"],
        "summary": "获取信息go4",
        "operationId": "go4UsingPOST",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrderDO"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "*/*": {
                "schema": {
                  "$ref": "#/components/schemas/OrderDO"
                }
              }
            }
          },
          "201": {
            "description": "Created"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    },
    "/hello/go5": {
      "get": {
        "tags": ["接口B"],
        "summary": "获取信息go5",
        "operationId": "go5UsingGET",
        "parameters": [{
          "name": "email",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "info.address",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "itemList[0].count",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "number",
            "format": "bigdecimal"
          }
        }, {
          "name": "itemList[0].id",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "integer",
            "format": "int32"
          }
        }, {
          "name": "itemList[0].name",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "name",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "string"
          }
        }, {
          "name": "orderType",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "string",
            "enum": ["DIRECT", "PROXY"]
          }
        }, {
          "name": "size",
          "in": "query",
          "required": false,
          "style": "form",
          "schema": {
            "minimum": 1,
            "exclusiveMinimum": false,
            "type": "integer",
            "format": "int32"
          }
        }, {
          "name": "total",
          "in": "query",
          "required": true,
          "style": "form",
          "schema": {
            "type": "number",
            "format": "bigdecimal"
          }
        }, {
          "name": "u",
          "in": "query",
          "description": "必传",
          "required": false
        }, {
          "name": "id",
          "in": "query",
          "description": "id",
          "required": false,
          "style": "form",
          "schema": {
            "type": "integer",
            "format": "int64"
          }
        }],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "*/*": {
                "schema": {
                  "$ref": "#/components/schemas/OrderDO"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    },
    "/hello/go6": {
      "get": {
        "tags": ["接口B"],
        "summary": "获取信息go6",
        "operationId": "go6UsingGET",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "*/*": {
                "schema": {
                  "$ref": "#/components/schemas/OrderDO2"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    },
    "/hello/go7": {
      "get": {
        "tags": ["接口B"],
        "summary": "获取信息go7",
        "operationId": "go7UsingGET",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "*/*": {
                "schema": {
                  "$ref": "#/components/schemas/Page«OrderDO»"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    },
    "/hello/go8": {
      "get": {
        "tags": ["接口B"],
        "summary": "获取信息go8",
        "operationId": "go8UsingGET",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "*/*": {
                "schema": {
                  "$ref": "#/components/schemas/Page«OrderDO2»"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Forbidden"
          },
          "404": {
            "description": "Not Found"
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "OrderDO": {
        "title": "OrderDO",
        "required": ["email", "name", "orderType", "total"],
        "type": "object",
        "properties": {
          "address": {
            "type": "string"
          },
          "addressMap": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/OrderDOItem"
            }
          },
          "email": {
            "type": "string"
          },
          "itemList": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderDOItem"
            }
          },
          "name": {
            "type": "string"
          },
          "orderType": {
            "type": "string",
            "enum": ["DIRECT", "PROXY"]
          },
          "size": {
            "type": "integer",
            "format": "int32"
          },
          "total": {
            "minimum": 0.0001,
            "exclusiveMinimum": false,
            "type": "number",
            "format": "bigdecimal"
          }
        }
      },
      "OrderDO2": {
        "title": "OrderDO2",
        "type": "object",
        "properties": {
          "itemList": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderDO2Item"
            }
          }
        }
      },
      "OrderDO2Item": {
        "title": "OrderDO2Item",
        "type": "object",
        "properties": {
          "count2": {
            "type": "number",
            "format": "bigdecimal"
          },
          "id2": {
            "type": "integer",
            "format": "int32"
          },
          "name2": {
            "type": "string"
          }
        },
        "description": "OrderDO2的Item"
      },
      "OrderDOItem": {
        "title": "OrderDOItem",
        "type": "object",
        "properties": {
          "count": {
            "type": "number",
            "format": "bigdecimal"
          },
          "id": {
            "type": "integer",
            "format": "int32"
          },
          "name": {
            "type": "string"
          }
        },
        "description": "OrderDO的Item"
      },
      "Page«OrderDO2»": {
        "title": "Page«OrderDO2»",
        "type": "object",
        "properties": {
          "count": {
            "type": "integer",
            "format": "int32"
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderDO2"
            }
          }
        }
      },
      "Page«OrderDO»": {
        "title": "Page«OrderDO»",
        "type": "object",
        "properties": {
          "count": {
            "type": "integer",
            "format": "int32"
          },
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OrderDO"
            }
          }
        }
      }
    }
  }
}

这是生成的OpenAPI结构化描述,注意Page泛型被直接实例化了。另外,@JsonUnwrapped也能很好地处理了

22.5 Bug

看这里,SpringFox实现的OpenAPI生成的enum不是以ref生成的,而是每个Order生成一个enum,这个问题目前依然无解,看这里。SpringDoc能解决enum的问题,但是它无法解决bigdecimal缺少format字段的问题。这个问题的解决方法是,自己做反射代码,向外提供一个新的API,指出Component的哪些字段是枚举类型。生成器通过OpenAPI的结构,和补丁API,合成出最终的结构文件。

23 Swagger-SpringDoc

SpringDoc是另外一个用Swagger注解生成结构化json的工具,官网在这里

23.1 依赖

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-ui</artifactId>
    <version>1.6.6</version>
</dependency>

依赖也比较简单

23.2 配置

package spring_test;

import io.swagger.v3.oas.models.ExternalDocumentation;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springdoc.core.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SwaggerConfiguration {
    @Bean
    public GroupedOpenApi publicApi() {
        return GroupedOpenApi.builder()
                .group("spring_test")
                .pathsToMatch("/**")
                .build();
    }

    @Bean
    public OpenAPI springShopOpenAPI() {
        return new OpenAPI()
                .info(new Info().title("SpringShop API")
                        .description("Spring shop sample application")
                        .version("v0.0.1")
                        .license(new License().name("Apache 2.0").url("http://springdoc.org")))
                .externalDocs(new ExternalDocumentation()
                        .description("SpringShop Wiki Documentation")
                        .url("https://springshop.wiki.github.org/docs"));
    }
}

配置要比SpringFox简单不少,而且文档也很清晰

23.3 注解

package spring_test;

        import io.swagger.v3.oas.annotations.Operation;
        import io.swagger.v3.oas.annotations.Parameter;
        import io.swagger.v3.oas.annotations.tags.Tag;
        import lombok.AllArgsConstructor;
        import lombok.Data;
        import lombok.NoArgsConstructor;
        import lombok.extern.slf4j.Slf4j;
        import org.springframework.core.annotation.Order;
        import org.springframework.validation.annotation.Validated;
        import org.springframework.web.bind.annotation.*;

        import javax.validation.constraints.Min;
        import javax.validation.constraints.NotEmpty;
        import javax.validation.constraints.NotNull;
        import java.util.HashMap;
        import java.util.List;
        import java.util.Map;

/**
 * Created by fish on 2021/4/25.
 */
@RestController
@RequestMapping("/hello")
@Slf4j
@Validated
@Tag(name="控制器A",description = "接口B")
public class Controller {

    //GET请求 http://localhost:8080/hello/go1
    @GetMapping("/go1")
    public String go1(){
        return "Hello World";
    }

    //POST请求 http://localhost:8080/hello/go2
    /*
    {
        "name":123,
        "email":"123@qq.com",
        "size":4,
        "total":"8.0",
        "id":1
    }
     */
    @PostMapping("/go2")
    public void go2(@NotNull Long id, OrderDO orderDO){
        log.info("go2 {} {}",id,orderDO);
    }

    //GET请求 http://localhost:8080/hello/go2
    //localhost:8080/hello/go3?id=123&data=%7B%22name%22:123,%22email%22:%22123@qq.com%22,%22size%22:4,%22total%22:%228.0%22%7D
    //localhost:8080/hello/go3?id=123&data={"name":123,"email":"123@qq.com","size":4,"total":"8.0"},原始格式
    //@RequestParam指定的放其指定的字段上
    //其他的参数默认放在data字段上,用json格式,并且用urlEncode过
    @GetMapping("/go3")
    public void go3(@NotNull @RequestParam("id") Long id,OrderDO orderDO){
        log.info("go3 {} {}",id,orderDO);
    }

    @Operation(summary = "获取信息go4")
    @PostMapping("/go4")
    public OrderDO go4(@RequestBody OrderDO order){return order;}

    @Operation(summary = "获取信息go5")
    @Parameter(name="u",description="必传")
    @GetMapping("/go5")
    public OrderDO go5(OrderDO u,Long id){return u;}

    @Operation(summary = "获取信息go6")
    @GetMapping("/go6")
    public OrderDO2 go6(){return new OrderDO2();}

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Page<T>{
        private int count;
        private List<T> data;
    }

    @Operation(summary = "获取信息go7")
    @GetMapping("/go7")
    public Page<OrderDO> go7(){return new Page<>();}

    @Operation(summary = "获取信息go8")
    @GetMapping("/go8")
    public Page<OrderDO2> go8(){return new Page<>();}
}

注解用了swagger3的方案,更加清晰了

package spring_test;

import com.fasterxml.jackson.annotation.JsonUnwrapped;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Created by fish on 2021/4/25.
 */
@Data
public class OrderDO {

    @NotBlank
    private String name;

    @NotNull
    private OrderType type;

    @NotNull
    @Email
    private String email;

    @Min(value = 1,message = "必须为正数")
    private int size;

    @NotNull
    @DecimalMin(value = "0.0001",message = "必须为正数")
    private BigDecimal total;

    private Map<String,Item> addressMap;

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @Schema(name="OrderDOItem")
    public static class Item{
        private String name;

        private int id;

        private BigDecimal count;
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Info{
        private String address;
    }

    //生成的JSON会抹去info信息,因为有JsonUnwrapped
    @JsonUnwrapped
    private Info info;

    private List<Item> itemList = new ArrayList<>();
}

用@Schema注解代替了原来的@ApiModel的注解

package spring_test;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderDO2 {

    @NotNull
    private OrderType orderType;

    @Schema(name="OrderDO2Item", description="OrderDO2的Item")
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Item{

        @Schema(description="名称")
        private String name2;

        private int id2;

        private BigDecimal count2;
    }

    private List<Item> itemList = new ArrayList<>();
}

@Schema注解也可以用在字段上

23.4 Bug

SpringFox对于BigDecimal生成的类型会有format描述,但是SpringDoc却只有type为number的描述,丢失了format字段,这点真的很蛋疼。

30 FAQ

30.1 配置文件无提示

application.properties配置文件,无提示,显示灰色的,启动也是不正常的

打开IDEA,Module Settings,将resources文件夹设置为资源即可

31 总结

SpringBoot还是有很多细节要搞一下的,不过胜在它框架灵活,我们可以随意定制我们的需求。

相关文章