# 1. SpringBoot 热部署

# 1.1 开启热部署

何为热部署?
​ 就是当你修改了某一处代码,不需要重启项目直接就能看到效果
为什么不直接重启呢?

img

​ 重启会将所有的资源文件进行重新加载,会检查原有的依赖是否还会存在等等, 而热部署,也就是 build 那一下操作,使用的重载而不是重启,相比之下速度会快许多

步骤:

  1. 导入 Springboot 的 devtools 依赖

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-devtools</artifactId>
                <scope>runtime</scope>
                <optional>true</optional>
            </dependency>
    
  2. 设置 idea,自动 build 项目,否则修改代码后只能手动的去 build 了

    img

​ 3. 设置允许 idea 在项目运行期间进行 build 操作, 这样才能实现真正的热部署

img

当我们离开 idea 工具时,也就是 idea 失去了焦点 在 5 秒后,项目就会被自动的 build

# 1.2 设置热部署的检测范围

img

自定义,那些文件或者文件夹,不会触发 因为功能是 jar 包提供的,所以我们使用 Spring 的配置文件进行配置

spring:
  devtools:
    restart:
      #排除范围
      exclude: config/**,templates/**

# 1.3 关闭热部署

配置文件关闭:

spring:
  devtools:
    restart:
      #false 关闭热部署
      enabled: false

当出现设置没有效果时,可能是存在一个比你的配置文件优先级更高的配置,在那里面设置了运行热部署时,就会覆盖你的配置

通过查看文档中 14 条配置方式中, 找到下列通过设置系统配置,优先级高于配置文件, 所以低有该优先级的冲突配置都被覆盖

img

# 2. 第三方 Bean 属性绑定

  1. 在配置类中写好对应的属性

    datasource:
      ipAddress: 192.168.1.123
      port: 9236
      driverClassName: com.mysql.jdbc.driver123
    
  2. 编写第三方对象的工厂方法,在上面加上@Bean 注解和@ConfigurationProperties(prefix=".....")注解

    //第三方Bean, 以德鲁伊数据库连接池为例
        @Bean
        @ConfigurationProperties("datasource")
        public DruidDataSource druidDataSource(){
            return new DruidDataSource();
        }
    
  3. 测试是否注入属性成功

    public static void main(String[] args) {
            ConfigurableApplicationContext run = SpringApplication.run(SpringBootConfigurationApplication.class, args);
            DruidDataSource bean = run.getBean(DruidDataSource.class);
        //输出 DriverClassName
            System.out.println(bean.getDriverClassName());
        }
    

# **@EnableConfigurationProperties**注解说明

img

如果同时加上了@Component 注解会报错,容器中含有该类中的两个 Bean

# 3. 属性的宽松绑定

img

而 @Value 注解不支持宽松绑定

img

# 4. SpringBoot 常用的计量单位应用

@Data
//@Component
@ConfigurationProperties("datasource")
public class ServerConfig {
    private String ipAddress;
    private String port;
    private String driverClassName;
    //设置单位为秒, 如果配置文件设置值没有跟上单位,就会采用这里设置单位,有就自动转换为对应值
    @DurationUnit(ChronoUnit.HOURS)
    private Duration timeout;
    //设置单位为kb, 如果配置文件设置值没有跟上单位,就会采用这里设置单位,有就自动转换为对应值
    @DataSizeUnit(DataUnit.KILOBYTES)
    private DataSize dataSize;
}

# 5. SpringBoot 的 Bean 属性值进行校验

步骤:

  1. 导入依赖

    <!--引入JSR303规范接口-->
    <dependency>
        <groupId>javax.validation</groupId>
        <artifactId>validation-api</artifactId>
    </dependency>
    <!--使用hibernate的校验框架,实现了JSR303规范的接口-->
    <dependency>
        <groupId>org.hibernate.validator</groupId>
        <artifactId>hibernate-validator</artifactId>
    </dependency>
    
  2. 在校验的类上添加注解@Validated ,表明这个类的属性需要校验规则

  3. 在想要校验的属性上添加各种各样的校验规则注解

    @Data
    //@Component
    @Validated //表明这个类的属性需要校验规则
    @ConfigurationProperties("datasource")
    public class ServerConfig {
        private String ipAddress;
        //添加具体的校验规则
        @Max(value = 9999,message = "最大值不能超过9999")
        @Min(value = 1111,message = "最小值不能小于1111")
        private String port;
        private String driverClassName;
    }
    

如果不符合规范就会输出一个错误日志

img

# 6. SpringBoot 测试

# 6.1 在测试类中,设置一些测试才使用的配置

// 设置properties属性,来测试一些配置文件中不存在的属性,优先级高于配置文件
//@SpringBootTest(properties = {"test.prop=testValue2"})
//设置 args属性, 跟设置临时属性的格式一样,优先级高于配置文件
//@SpringBootTest(args = {"--test.prop=testValue3"})
//在高版本2.7.1中在这里设置Properties属性的优先级高于args,老师的版本低,显示的是args的配置
@SpringBootTest(properties = {"test.prop=testValue2"},args = {"--test.prop=testValue3"})
class SpringBootTestApplicationTests {
    @Value("${test.prop}")
    private String prop;
    @Test
    void contextLoads() {
        System.out.println(prop);
    }
}

优点: 配置信息作用范围小,只适用于这个测试类, 并且这些测试配置写在了代码里,不像 idea 工具设置参数,如果更换工具,测试参数丢失.

# 6.2 在测试时加载指定的配置文件

​ 当我们在测试类想要加载指定的配置类时,可以使用@import(配置类.class)注解,来加载

不过实际测试发现,只要给配置类加上对应的配置注解,就可以测试了,不需要再使用@import 注解

即这两个注解二选一, 要么在配置类上加上,这样可以被 Spring 扫描到,要么就在测试类中通过@import(配置类.class)注解,加载这个配置类

配置类:

package com.snake.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration //表明这个是配置类, spring会自动加载这个配置类
public class testConfiguration {
    //模拟设置一个第三方对象作为Bean
    @Bean
    public String getString(){
        return "Bean...";
    }
}

测试类:

@SpringBootTest
//@Import({testConfiguration.class}) 加载指定的配置类,如果那个类,有@Configuration注解就不需要了
public class SpringbootTest {
    @Autowired
    private String bean;
    @Test
    public void test(){
        System.out.println(bean);
    }
}

# 6.3 测试 Web 环境,使用测试类时,开启 Web 服务

/**
 * webEnvironment属性,是用来设置web环境下的设置,可以设置关闭web环境的启动,就是不启动tomcat
 * 也可以设置以配置中的端口启动,或者是以随机一个端口启动
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public class SpringBootWebTest {
    @Test
    public void test(){

    }
}

# 6.4 模拟发送请求

  1. 在测试类中添加 @AutoConfigureMockMvc 注解, 添加该注解之后就开启了模拟请求的功能
  2. 注入 MockMvc 类型 的对象,通过它的 perform 方法,发送请求
  3. 获取一个对应的请求方式的请求对象,传递给 perform 方法
package com.snake;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

/**
 * webEnvironment属性,是用来设置web环境下的设置,可以设置关闭web环境的启动,就是不启动tomcat
 * 也可以设置以配置中的端口启动,或者是以随机一个端口启动
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@AutoConfigureMockMvc //开启模拟请求
public class SpringBootWebTest {
    /**
     * 发送一个请求.
     *  可以在参数里,写自动注入的注解,会自动传入
     */
    @Test
    public void test(@Autowired  MockMvc mockMvc) throws Exception {
        //通过MockRequestBuilders工具类获取一个get请求对象 RequestBuilder
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/books");//前缀已经默认了,不用写 http://localhost:8888
        mockMvc.perform(requestBuilder);
    }
}

# 6.5 检测请求的状态码是否匹配,即是否请求成功等

  1. 获取状态匹配器对象
  2. 设置预期状态
  3. 获取本次请求结果,调用方法进行匹配判断
/**
     * 判断请求的结果是否符合预期
     */
    @Test
    public void testStatus(@Autowired  MockMvc mockMvc) throws Exception {
        //通过MockRequestBuilders工具类获取一个get请求对象 RequestBuilder
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/books");//前缀已经默认了,不用写 http://localhost:8888
        //发送请求,并获取本次请求结果
        ResultActions perform = mockMvc.perform(requestBuilder);
        //获取请求匹配器
        StatusResultMatchers status = MockMvcResultMatchers.status();
        //设置预期值 ,预期本次请求结果为成功
        ResultMatcher ok = status.isOk();
        //调用本次请求的比较方法, 在这个方法,自动封装了断言等测试的东西,如果匹配,测试通过,否则失败,打印信息
        perform.andExpect(ok);
    }

# 6.6 检测响应体的内容是否匹配

1.使用的是content.string()方法,用于匹配 String 类型的数据,而 Json 格式的数据有专用的方法

	/**
     * 判断请求的结果中响应体是否符合预期
     */
    @Test
    public void testContent(@Autowired MockMvc mockMvc) throws Exception {
        //通过MockRequestBuilders工具类获取一个get请求对象 RequestBuilder
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/books");//前缀已经默认了,不用写 http://localhost:8888
        //发送请求,并获取本次请求结果
        ResultActions perform = mockMvc.perform(requestBuilder);
        //获取响应体的请求匹配器
        ContentResultMatchers content = MockMvcResultMatchers.content();
        //设置预期值 ,预期本次请求结果为成功
        ResultMatcher springBoot = content.string("springBoot");//预期响应结果是"SpringBoot"的字符串
        //调用本次请求的比较方法, 在这个方法,自动封装了断言等测试的东西,如果匹配,测试通过,否则失败,打印信息
        perform.andExpect(springBoot);
    }
  1. 匹配 Json 格式的响应体数据
/**
     * 判断请求的结果中响应体是否符合预期
     *  匹配Json数据
     */
    @Test
    public void testContentJson(@Autowired MockMvc mockMvc) throws Exception {
        //通过MockRequestBuilders工具类获取一个get请求对象 RequestBuilder
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/books/book");//前缀已经默认了,不用写 http://localhost:8888
        //发送请求,并获取本次请求结果
        ResultActions perform = mockMvc.perform(requestBuilder);
        //获取响应体的请求匹配器
        ContentResultMatchers content = MockMvcResultMatchers.content();
        //设置预期值 ,预期本次请求结果为成功
        ResultMatcher book = content.json("{\"id\":2,\"name\":\"快乐世界\",\"author\":\"snake\",\"price\":123}");//预期响应结果是"SpringBoot"的字符串
        //调用本次请求的比较方法, 在这个方法,自动封装了断言等测试的东西,如果匹配,测试通过,否则失败,打印信息
        perform.andExpect(book);
    }

# 6.7 检测响应头内容是否匹配


    /**
     * 判断请求的结果中响应头是否符合预期
     */
    @Test
    public void testHeader(@Autowired MockMvc mockMvc) throws Exception {
        //通过MockRequestBuilders工具类获取一个get请求对象 RequestBuilder
        RequestBuilder requestBuilder = MockMvcRequestBuilders.get("/books/book");//前缀已经默认了,不用写 http://localhost:8888
        //发送请求,并获取本次请求结果
        ResultActions perform = mockMvc.perform(requestBuilder);
        //获取响应体的请求匹配器
        HeaderResultMatchers header = MockMvcResultMatchers.header();
        //设置预期值 ,预期本次请求结果为成功
        ResultMatcher head = header.string("Content-Type","application/json");
        //调用本次请求的比较方法, 在这个方法,自动封装了断言等测试的东西,如果匹配,测试通过,否则失败,打印信息
        perform.andExpect(head);
    }

# 6.8 表现层检测总结

实际测试时,可以同时测试 状态码,响应头,响应体内容, 当都符合预期时,测试才通过.

# 6.8 数据层,测试后将数据回滚

如果在进行数据层的相关测试时,不想要将一些测试数据写入到数据库,可以通过开启事务的方式, 开启事务后,再设置回滚,即可以不写入数据到数据库

@SpringBootTest
@Transactional //开启事务
@Rollback(value = false) //进行回滚,默认值为true
public class ServiceTest {
    @Autowired
    private BookService bookService;
    @Autowired
    private Book book;
    @Test
    public void test(){
        bookService.save(book);
    }
}

# 6.9 测试时自动设置随机数据

通过在配置文件中,根据 spring 表达式设置随机数据,之后使用配置注入相关知识点,将配置属性注入到对象中,这样我们就得到了随机数据的 pojo 对象了

#测试环境下,Book的数据
testcase:
  #设置为随机值
  book:
    id: ${random.int(1000)} #随机生成一个int值, 并设置最大值为1000
    type: ${random.long(1,100)} #随机生成一个long值,后面的()指定范围,最大100,最小1
    name: ${random.value} #随机生成字符串 32位长度, md5加密
    author: ${random.value}
    description: ${random.value}

# 7. 数据层面解决方案

# 7.1 SpringBoot 提供的三种内置数据库连接池

druid 数据库连接池,其实不用写任何配置,只要导入了依赖,就会自动的被使用,如果不想要使用 Druid 连接池,可以使用 SpringBoot 提供的三种连接池

img

# 7.2 SpringBoot 的 jdbcTemplate

可以使用 jdbcTemplate 代替 mybatis 作为数据层的解决方案

导入 spring-boot-start-jdbc 依赖,后即可使用

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

可选的设置:

spring:
  datasource:
    url: jdbc:mysql://localhost/mp
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: root
    #设置jdbcTemplate的一些参数
  jdbc:
    template:
      #缓存条数
      fetch-size: 10
      #单次最大查询条数
      max-rows: 50
      #查询最大超时时间
      query-timeout: 2m

实例测试代码:

@SpringBootTest
public class jdbcTemplateTest {
    //注入jdbcTemplate
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    public void testQuery(){
        String sql = "SELECT * FROM tb_user";
        List<User> query = jdbcTemplate.query(sql, new RowMapper<User>() {
            @Override
            public User mapRow(ResultSet rs, int rowNum) throws SQLException {
                //封装结果集
                User user = new User();
                user.setId(rs.getInt("id"));
                user.setUsername(rs.getString("user_name"));
                user.setPassword(rs.getString("password"));
                user.setEmail(rs.getString("email"));
                user.setSex(rs.getInt("sex"));
                user.setName(rs.getString("name"));
                return user;
            }
        });
        for (User user : query) {
            System.out.println(user);
        }
    }
}

# 7.3 SpringBoot 内置的可以内嵌使用的三款数据库

  1. H2
  2. HSQL
  3. derby

本次我们尝试使用 H2 数据库,这个数据库有安装版,也有内嵌版,本次就是使用内嵌的 H2, 如果想要通过 web 网页去访问它的控制台,还需要加入 spring-boot-starter-web 的包

针对 H2 这个数据库,我们只需要在测试时使用,不要上线中使用,我们对它的配置加到 测试的配置环境或者配置文件

步骤:

  1. 导入 H2 的两个依赖, data-jpa 和 h2database

         <!--H2数据库需要的两个依赖-->
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-data-jpa</artifactId>
         </dependency>
         <!--H2这个数据库,只是适合测试时使用,不要用到实际的环境 -->
         <dependency>
             <groupId>com.h2database</groupId>
             <artifactId>h2</artifactId>
             <!-- <scope>test</scope>--> 如果设置了这个,可能会报错,数据库url没指定,
         </dependency>
    
  2. 配置 H2 数据库 springBoot

    如果是只想要,在 web 网页的控制台中使用 h2,则在第一次使用 H2 时配置一下数据源连接信息,之后就可以不用配置,就可以使用控制台

    如果想要使用 java 去操作数据库,就需要配置数据源

    spring:
      config:
        activate:
          on-profile: test-h2 #h2数据库测试
      h2:
        console:
          path: /h2 #在web下访问h2数据库的路径,需要 web依赖
          enabled: true #开启h2数据库
    #  datasource:
    #    driver-class-name: org.h2.Driver
    #    url: jdbc:h2:~/test
    #    username: sa #默认用户
    #    password: 123456 #默认密码
    
  3. 设置了连接池信息后,去使用 ORM 框架去操作数据库, 比如说 jdbcTemplate,mybatis,mybatis-plus

# 8.SpringBoot 整合 Redis

# 8.1 Redis 的基本使用

Redis 是一个 key-value 的内存级 NOSQL 的数据库,不像 mysql 拥有表啊,字段等东西, 它有的是键值对

SpringBoot 整合 redis 共三步:

  1. 导入 redis 的依赖,可以在 idea 创建项目,勾选 Nosql 里面的 redis(Access+driver)

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis</artifactId>
            </dependency>
    
  2. 进行 redis 的配置,访问 ip 和端口号,账号密码等, 里面的 ip 和端口号已经存在了默认值,而账号密码不是必须的,所以可以不配

    spring:
      redis:
        host: localhost
        port: 6379
    
  3. 获取 redisTemplate 类,获取操作类对象,进行 set 和 get 操作

@SpringBootTest
public class redisTest {
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 设置redis中Value类型
     */
    @Test
    public void testSet(){
        ValueOperations valueOperations = redisTemplate.opsForValue();
        valueOperations.set("name","snake");
    }
    /**
     * 获取redis中Value类型
     */
    @Test
    public void testGet(){
        ValueOperations valueOperations = redisTemplate.opsForValue();
        Object name = valueOperations.get("name");
        System.out.println(name);
    }

    /**
     * 设置redis中Hash类型
     */
    @Test
    public void testHSet(){
        HashOperations hashOperations = redisTemplate.opsForHash();
        hashOperations.put("usr","name","snake");
    }
    /**
     * 获取redis中Hash类型
     */
    @Test
    public void testHGet(){
        HashOperations hashOperations = redisTemplate.opsForHash();
        Object o = hashOperations.get("usr", "name");
        System.out.println(o);
    }
}

经过使用,我们发现通过 redis 客户端设置的 key-value 我们无法通过 idea 获取,反之亦然,这是因为, redis 操作的对象不一样,

在 java 中我们应该使用 StringRedisTemplate 类,进行操作,这样才和客户端直接设置的一样,才能获取到.

img keys * 是获取所有的键值对

使用 StringRedisTemplate 类对象 设置和回去 key-value

   //获取 StringRedisTemplate, 其实它就是个RedisTemplate<String,String> 类
    @Autowired
	@Test
    public void testSetByString(){
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        ops.set("name","snake123");
    }
    @Test
    public void testGetByString(){
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
        ops.get("name");
    }

# 9. SpringBoot 使用 JSR303 规范和实现 jar 包,进行 参数校验

# 9.1 简介, 一个入门视频

JSR303 是一种规范,里面都是接口定义, 而 hibernate-validator 是实现

参数校验 JSR303-SpringBoot 参数校验_哔哩哔哩_bilibili (opens new window)

# 9.2 Springboot 项目 使用校验

  1. 需要我们导入 spring-boot-starter-validation 依赖, 据说引入了 spring-boot-start-web 包后, 就会引入这个依赖, 但是经过测试 springboot 2.7.9 版本还是需要手动引入
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

# 9.3 校验相关的注解

img

# 9.3 注意

img

针对第三点: 当方法的类例如 Person 对象, 传入的是一个 null, 那么@Valid 是不会给你校验的, 但是如果我们使用的是 SpringMVC 框架, 对于 Controller 层的每一个请求方法的参数类对象时自动 实例化的, 也就是说对象不是一个 null 值,但是这个对象里面的属性都为 null 值的话, 就会触发这个字段上的一些校验是否为 null 的注解

img

@Valid 不能直接校验 List 类型, 但是我们可以在 List 的外面一层包装类上进行添加校验注解, 例如 User 对象里面有一个 List<Role> roleList 属性, 我们可以在上面加上 @NotEmpty 属性来校验这个属性的 list 不能为 null, 并且 size 不为 0

如果接口的参数是一个 List<Person> list  集合, 难道我们就不能对 List 里面的每一个 Person 进行校验了吗?

当我们在类上使用@Validated 注解时, 在底层会注册一个 BeanPostProcessor, 然后进行增强功能,

然后我们就发现, @vaild 对 List 集合中的每一个对象 也可以进行校验了, 但是我没有进行测试...

# 9.4 @Validated 注解 与@valid 注解的区别

img

(166 条消息) Springboot @Validated 和@Valid 的区别 及使用_王大地 X 的博客-CSDN 博客 (opens new window)

(166 条消息) Spring 注解之@validated 的使用_屿麟的博客-CSDN 博客_spring @validated (opens new window)

# 9.5 自定义注解进行参数校验

如果我有如下的需求, 前段传来一个字符串, 或者数字数组, 我们需要校验里面的元素值是否来自于我们实现配置的值, 比如说: 我要求这个数组参数的值只能是asc或者desc.

比如说我们实现如下这样的一个效果.

@ArrayEnumValue(strValues= {"asc","desc"},message="元素的值只能是asc或者desc")
private String[] orders;

# 步骤

  1. 创建校验注解

    /**
     * 对于数组中的元素的校验注解
     */
    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
    @Retention(RUNTIME)
    @Documented
    //在@Constraint注解中, 指定哪一个校验器,来执行校验.
    @Constraint(validatedBy = {ArrayEnumValueValidator.class})
    public @interface ArrayEnumValue {
    
        // 默认错误消息
        String message() default "必须为指定值";
    
        //可以指定的值, 用户可以选择被校验的值是字符串,还是数值
        String[] strValues()  default {};
        int[] intValues() default {};
    
        // 负载, 这两个值必须有, 不然编译时报错
        Class<? extends Payload>[] payload() default {};
        Class<?>[] groups() default { };
    }
    
  2. 创建校验器类, 校验器实现 ConstraintValidator<校验注解.class, 接收参数的类型> 接口

    public class ArrayEnumValueValidator implements ConstraintValidator<ArrayEnumValue, Object> {
     //可以根据排序的字段
     private  String[] strValues;
     private  Integer[] intValues;
    
     /**
      * 初始化校验注解,
      * 在这里初始化,添加允许的值
      * @param constraintAnnotation 注释实例
      */
     @Override
     public void initialize(ArrayEnumValue constraintAnnotation) {
         //获取注解使用的时候, 上面所填写设定的值
         strValues = constraintAnnotation.strValues();
         //将int[]数组转化为包装类型数组.
         intValues = Arrays.stream(constraintAnnotation.intValues()).boxed().toArray(Integer[]::new);
     }
    
     /**
      * 校验逻辑
      * @param value 被校验的对象
      * @param context 评估 被校验的对象的 上下文
      * @return true则校验通过, false则校验失败.
      */
     @Override
     public boolean isValid(Object value, ConstraintValidatorContext context) {
         if(!value.getClass().isArray()){
             return false;
         }
         for (Object o : (Object[])value) {
             if(o instanceof String){
                 if(Arrays.asList(strValues).contains((String) o)){
                     continue;
                 }
             }else if (o instanceof Integer){
                 if(Arrays.asList(intValues).contains(o)){
                     continue;
                 }
             }
             return false;
         }
         return true;
     }
    }
    
  3. 在指定字段位置使用校验注解

    @ArrayEnumValue(strValues= {"asc","desc"},message="元素的值只能是asc或者desc")
    private String[] orders;
    

# 10. 全员统一时间格式

通常我们可以手动的在字段上添加@JsonFormat注解, 来定义时间的格式. 但是当实体类多了起来时, 如果针对一个个时间字段手动设置格式则非常的麻烦.

因此这里有三种全局统一时间格式的方法

# 1. 在 yaml 中配置时间格式(只对 Date 类型生效)

spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8

# 2. 通过@JsonComponent 注解

上面的方式只能针对于 Date 时间类型, 如果针对的是 LocalDateTime 类型呢? 因此我们得手动配置. 将如下的类, 添加@JsonComponent注解即可.

使用这种方式的好处是, @JsonFormat依然可以生效, 优先级大于下面的配置, 因此我们针对于个别的时间类型, 可以通过@JsonFormat 针对化的配置.

@JsonComponent
public class DateFormatConfig {

    @Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}")
    private String pattern;

    /**
     * @author xiaofu
     * @description date 类型全局时间格式化
     * @date 2020/8/31 18:22
     */
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilder() {

        return builder -> {
            TimeZone tz = TimeZone.getTimeZone("UTC");
            DateFormat df = new SimpleDateFormat(pattern);
            df.setTimeZone(tz);
            builder.failOnEmptyBeans(false)
                    .failOnUnknownProperties(false)
                    .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                    .dateFormat(df);
        };
    }

    /**
     * @author xiaofu
     * @description LocalDate 类型全局时间格式化
     * @date 2020/8/31 18:22
     */
    @Bean
    public LocalDateTimeSerializer localDateTimeDeserializer() {
        return new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(pattern));
    }

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        return builder -> builder.serializerByType(LocalDateTime.class, localDateTimeDeserializer());
    }
}

# 3. @Configuration 注解

使用这种配置方式后, @JsonFormat将不再生效 参考博客: 统一全局时间返回格式 (opens new window)

# 11. 自定义返回值类型转换器

当我们 id 为 Long 类型时, 返回给前段接收会出现精度丢失, 因此需要将 long 类型,转换为 string 类型, 而返回类型最终都会被转化为 json 格式, 因此我们需要配置的就是 Jackson 序列化器, 自定义 Long 类型的序列化方式为字符串.

 /**
     * 自定义消息类型转化器, 实际上我们根据业务类型还可以具体的自定义序列化器
     * @param converters
     */
    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        MappingJackson2HttpMessageConverter jackson2HttpMessageConverter = new MappingJackson2HttpMessageConverter();
        ObjectMapper objectMapper = new ObjectMapper();
        SimpleModule simpleModule = new SimpleModule();
        //long或者Long类型转化为string
        simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
        simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
        objectMapper.registerModule(simpleModule);
        jackson2HttpMessageConverter.setObjectMapper(objectMapper);
        // 坑2 ,注意转化器的优先级, 只该类型的第一个转化器会生效
        // converters.add(jackson2HttpMessageConverter);
        converters.add(0, jackson2HttpMessageConverter);
    }

DANGER

上述配置方式, 回使自定义的时间格式转换失效 Jackson2ObjectMapperBuilderCustomizer 也会使, 通过配置文件中修改 jackson 的date-format属性失效.

在上面的配置中额外的添加时间的转化器即可

    //自定义时间, Jackson2ObjectMapperBuilderCustomizer会失效, 这里可以进行配置
    objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

如果想要更加的细化,转化的细节, 而不仅仅只是替换一个类型, 可以自定义序列化器(对象 转 json)或者反序列化器(json 转 对象)

# 12. 多模块打包

如果项目是一个多模块项目, 首先设置父工程的打包方式为pom, 然后每一个子模块都需要<parent>标签指向父工程, 然后父工程的modules 里面包含每一个子模块.

最后主程序在哪个模块, 就在哪个模块里添加打包插件

 <!--多模块打包:只需在启动类所在模块的POM文件:指定打包插件 -->
    <build>
        <plugins>
            <plugin>
                <!--该插件主要用途:构建可执行的JAR -->
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

如果打包时报错,找不到某某个模块的依赖坐标, 那么就检查一下, 被依赖模块的坐标是否有错, 没有错的话, 就修改一下坐标再试试.

# 13. 如果在项目打包后,修改 jar 中的配置文件?

提前安装 zip 和 unzip, 通过 vim 命令进入 jar 文件vim xxx.jar, 通过搜索功能找到配置文件:/application.yml,按下回车后发现进入了这个配置文件, 然后修改即可, 退出时使用wq!保存退出.

# 14. 时区相关.

一个接口返回的时间受到三方面的影响:

  1. 数据库时区和时间
  2. jvm 获取的系统时间.
  3. json 序列化时的时区设置(jackson, fastjson)

# jvm 时区

jvm 在启动时, 会读取操作系统的时区. new Date(), 会以对应的时区的时间进行展示. 可以通过TimeZone.getDefault(),来查看读取到的时区.

:::tip: 主动设置 JVM 时区,解决前段非 json 数据格式传递时间参数时的时间问题.

//设置JVM时区为东八区,这段代码放在启动类, 或者某个应用启动后只会调用一次的地方即可.
TimeZone.setDefault(TimeZone.getTimeZone("GMT+8"));

:::

# 15. jvm 的 Date 对象 与 前段时间的转化.

# @DateTimeFormat@JsonFormat 注解的区别.

# @DateTimeFormat

@DateTimeFormat, 是 Spring 框架提供的注解. 作用仅仅只是为了约束前段通过url方式和表单方式传来的时间字符串. 当前段传递的时间字符串的格式与@DateTimeFormat不同时, 就会弹出警告(不继续执行).

该注解默认将传递的时间值与当前 jvm 的时区相同. 比如说 jvm 读取到的时区为UTC. 那么前段传递来的2024-1-25 21:00:00就是 UTC 时区的时间, 转化为 Date 就是Thu Jan 25 21:00:00 UTC 2024. 即 UTC 时间对应的时间. 如果转化为 CST(UTC+8), 对应的时间应该是Thu Jan 26 05:00:00 CST 2024.

# @JsonFormat

@JsonFormat则是用来约束 前端以 JSON 格式传递的数据中的时间字符串的格式(请求体为 JSON 数据时.) 当时间字符串的格式不对时就会报错.

并且还用于标识前端传来的时间字符串的时区, 比如说@JsonFormat(timeZone="GMT+8"), SpringMVC, 就会认为传来的时间是东八区的时间. 如果此时 JVM 是(GMT+0/UTC),当输出 Date 类型时, 就会将这个GMT+8的时间,减去 8 小时转化为 GMT+0/UTC+0

反序列时, 当返回Date类型时, 按照 jackson 配置的时区以及设置好的pattern进行转化.

TIP

Date 存储的数据本质就是一个时间戳. 转化为字符串输出时, 会获取 jvm 的时区信息. 然后按照时区转化这个时间戳.

# json 序列化时,设置的时区.

可以通过如下配置, 全局设置 jackson 的时区.

spring:
  jackson:
    time-zone: GMT+8
    date-format: "yyyy-MM-dd HH:mm:ss"

# 案例 1: 前端传递非 json 格式的时间

# 数据格式

img

# 后端代码

img

# 输出结果

img

# Jvm 的 Date 时间与 Mysql 的 Date 时间的转化.

# Mysql 时区

首先需要知道 Mysql 的时区设置

show variables like "%time_zone%"

img

含义解读

TIP

  1. system_time_zone: 这个变量的值指的是 Mysql 所在的宿主机的系统的时区.
  2. time_zone: mysql 软件使用的时区. 当值为 SYSTEM 时, 表示使用系统的时区.

也就是 Mysql, 现在使用的是容器里面的时区为 UTC

# 测试案例

前端传来一个时间'2022-01-25 12:00:00'. JVM 的读取到的时区是 UTC. java 后端没有给这个时间通过@JsonFormat或者配置全局 jackson 来设置时区. 因此默认时间'2022-01-25 12:00:00'是 UTC 的时间.

数据库的时区配置如下: img

java 后端服务器与数据库的连接 url 为:jdbc:mysql://centos:3306/backend?serverTimezone=GMT%2B8. 即临时指定了Mysql的时区为 GMT+8.

现在我们将时间'2022-01-25 12:00:00'即 Date 对象Thu Jan 25 12:00:00 UTC 2024插入数据库. img 可以看到 java 服务器给 Mysql 发送的是时间戳. 转化为 UTC 时区时间是2022-01-25 12:00:00. 但是我们去查看数据库实际保存的时间值: img2024-01-25 20:00:00.

这是因为后端服务器和数据库连接时,通过severTimeZone指定了 Mysql 的时区为 GMT+8. 因此实际保存的数据需要转化为 GMT+8 的时间.即 Java 后端数据库将这个时间加 8 个小时后,给 Mysql.

当 java 服务器进行查询时, 也知道 Mysql 的时区为 GMT+8. 而自己的为 GMT+0/UTC+0. 因此会将数据库的时间减去 8 个小时.

# 永久修改 Mysql 时区为 GMT+8

在 mysql 的配置文件中加入下行, 然后重启 mysql

[mysqld]
default-time-zone='+8:00'

如果指定了 Mysql 的时区后,只要是在中国运营,以后的 java 服务器连接时,都不需要指定serverTimeZone为 GMT+8 了.

TIP

需要注意的是, 即使指定了 Mysql 的时区为 GMT+8. 但是如果依然发现前端获取到的时间与当前时间差 8 个小时. 那就说明服务器的时区为 UTC. 它将 Mysql 的时间减去 8,转化为 UTC 时间后返回给了前端.

如果我们返回的是 json 数据, 则我们需要通过@JacksonFormat注解,将时间转化为 GMT+8, 或则全局配置 jackson 的时间为 GMT+8. 如果没有配置,则 jackson 一律认为返回给前端的时间应该是 GMT. 即少了 8 个小时.

# 配置 jacksonFormat 注解以及spring.jackson.time-zone没有效果.

看看是否不是有地方配置了一个 Jackson2ObjectMapperBuilderCustomizerBean, 如果指定了 timeZone 属性. 就会导致配置的时区没有效果. 如果值为TimeZone.getDefault(), 则 jackson 序列化和反序列化的时间都会以系统的时区为准. 如果正好部署到了 Linux 系统,如果时区为 UTC. 机会导致返回给前端用户的时间小 8 个小时.

 /**
     * 时区配置.影响jackson序列化和反序列化时的Date的时间, 会覆盖配置文件中时区的配置
     * TimeZone.getDefault(), 返回值是系统的时区.
     * 如果是容器内部署, 大部分情况都是utc. 这将导致前端接收的时间小8个小时.
     */
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jacksonObjectMapperCustomization()
    {
        return jacksonObjectMapperBuilder -> jacksonObjectMapperBuilder.timeZone("GMT+8");
    }

TIP

继承 WebMvcConfigurationSupport 后会覆盖@EnableAutoConfiguration 关于 WebMvcAutoConfiguration 的配置! 导致我们通过配置文件, 设置的一些属性都会失效, 因此需要在 WebMvcConfigurationSupport 的配置类上, 添加我们需要的属性, 要么就删除掉这个类

参考:

  1. jackson.date-format 等配置不生效的问题 (opens new window)
  2. @EnableWebMvc,WebMvcConfigurationSupport,WebMvcConfigurer 和 WebMvcConfigurationAdapter 区别 (opens new window)

# 思考问题

如果多个 java 服务器时区都是 GMT,但是同时连接一个数据库.指定 Mysql 使用不同的时区. 比如服务器 A 使用的是 GMT,服务器 B 使用的是 GMT+8. 此时查询数据会出现什么情况? 服务器 A 保存的到 Mysql 的时间, 都是 UTC 时间. 而服务器 B 保存到 Mysql 的时间,会大 8 个小时., 查询时还需要减去 8 个小时. 如果服务器 B 查询到了服务器 A 的保存的记录,因为减去了 8 个小时, 因此该条记录的时间,比实际上小 8 个小时.

只有 Date 类型,在 Web 服务器中会根据时间变化对应的值, 而其他的时间类型从数据库中查询出是多少就是多少.

# 16. @Mapper 注解和@MapperScan 注解的作用

@Mapper 添加在 Mapper 的接口类上, 作用是表示这是一个 Mapper. 会生成接口的代理类. 被 SpringBoot 启动类扫描到即可.

而@MapperScan 则指定扫描某一个包, 不管这个包下面的接口有没有@Mapper 注解, 只要是接口就会被当 Mapper 产生代理类.

# 17. 通过指定类加载路径的方式启动 Springboot 项目

如何启动一个没有被制作成 Jar 包的 SpringBoot 项目? 通过观察 Seata 这个服务的制作成镜像并启动的方式. 发现它并不是传统的制作成一个 jar 包后启动. 而是直接将源代码进行编译成 class 文件, 将依赖的 jar 包放到指定的位置. 通过使用 Java 的cp参数指定类加载路径启动.

在这个镜像中, 我们可以对 resources 文件夹里面的文件进行映射. 添加一些配置文件啥的. 参考这种格式.

我们自己的 SpringBoot 项目也可以这么启动. 将一些配置文件的文件夹映射出去. 将一个 SpringBoot 项目的 Jar 包解压后,可以得到如下的结构: img

在该路径执行如下的命令可以启动项目.

java -cp "BOOT-INF\classes;BOOT-INF\lib\*;META-INF" com.example.demo.DemoApplication

# 18. 经过测试, RabbitMq 错误消息队列的异常信息可能和实际异常信息不符合.

# 19. 模块的父工程不是父项目而是spring-boot-starer-parent, 出现'parent.relativePath' of xxx报错

parent标签中添relativePath属性.

案例:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.6.RELEASE</version>
    <!--解决父项目不是parent的问题-->
    <relativePath />
</parent>

# 20. Nocas 指定配置文件 id 时, 除了 id 还可以制定 group,Refresh 属性

config 下的 group 指的是应用名开头的配置文件的所在组, 而不是 dataId 中指定的配置文件的所在组.

spring:
  application:
    name: gulimall-product
  cloud:
    nacos:
      server-addr: micro:8848
      config:
        namespace: cd012fad-b656-4999-8434-12f6e1353560
        group: dev
        file-extension: yaml
        shared-configs:
          - dataId: datasource-${spring.cloud.nacos.config.group}.yaml
            group: ${spring.cloud.nacos.config.group}
          - dataId: mybatis-plus-${spring.cloud.nacos.config.group}.yaml
            group: ${spring.cloud.nacos.config.group}

TIP

配置文件 config 和服务 discovery, 可以不在一个命名空间. 比如说各个微服务模块都在 public 组, 而配置文件在各自的命名空间

# 21. 注意跨域配置是否重复!, Gateway 如果配置了跨域, 就不要在服务模块配置跨域!

如果重复, 可能会导致跨域失效, 状态码是 200, 但是响应体没有任何的数据的现象.

# 22. Java 中的 TreeSet 保证元素的唯一和有序的问题

TreeSet 不依靠 Equals 和 HashCode 来判断元素是否相等. 而是基于 CompareTo 方法. 如果比较值为 0, 就会认为两个元素相等. 而不会重复插入.

案例: 一个对象中有一个 Sort 字段和 Id 字段. 如果 Sort 字段相等时, 依然让元素插入到 TreeSet. 那么再比较时不能只比较 Sort, 还需要比较 Id. 否则 Sort 相等时, 不会因为 Id 不同,而让其插入到集合中.

# 23. Spring 的循环依赖问题.

开发业务功能中, 产生的循环依赖问题一般只发生在 Service 之间.

  • Controller 一般不会被别的类所注入. 也就是说没人会依赖 Controller.
  • DAO/Mapper, 专门用来操作自己的数据库,一般来说不会去注入其他的 DAO/Mapper 去完成一些操作,更不会说注入其他的 Service, 因此 Service 里面可以放心大胆的注入 Dao/Mapper, 他们不会产生循环依赖问题.
  • Service: 一般注入的类型是 DAO/Mapper 或者是其他的 Service. 因为注入 DAO/Mapper 大部分情况不会产生循环依赖的问题. 因此产生循环依赖时, 一般都是 Service 之间.

首先注入方式分为:

  • set 注入
  • 构造注入(构造方法,构造器方法)
  • 字段注入.

Service 之间循环依赖的解决方式:

  • 如果仅仅是操作数据库,增删改查,可以改为注入对应的 XXXDao/Mapper 去完成, 而不是 Service
  • 从构造注入,变为 Set 注入. 利用 Spring 的三级缓存机制.
  • 从构造注入,变为字段注入. 利用 Spring 的三级缓存机制.
  • 修改方法的所在的 Service, 破坏掉循环.

# 24. 为什么 Spring 推荐使用构造器注入. 而不推荐使用字段注入?

Spring 推荐使用构造器注入. 而不推荐使用字段注入. 因为字段注入破坏了对象创建逻辑.

以这行代码为例子:User user = new User(1,"admin",12)一个对象在创建时经过如下五个步骤:

  1. 栈内存空间分配名字为 user 的内存(保存 user 这个变量)
  2. 堆内存空间以 User 类为模板分配内存空间(堆空间用来存储这个对象的实际数据)
  3. 成员变量初始化,基本类型boolean:false,int,long即为0...,引用类型设置为null
  4. 成员变量初始化: 在类中会为属性赋值, 这个例子就是给变量设置为 12.
     private Integer age = 12;
    ``
    也就是时候age先被弄成0, 再被弄成12.
    
  5. 执行构造方法里面的初始化
     public User(Long userId, String username, Integer age){
         this.userId = userId;
         this.username = username;
         this.age = age;
     }
    
    img

当我们使用@Autowired或者@Resource来实现属性注入时, 它的时机发生在对象已经创建完毕, 并且执行了这些初始化步骤之后. 破坏了在构造器中进行初始化的理念. 比如说我们在构造器中, 需要根据字段的值, 进行一些初始化. 而@Autowired或者@Resource的方式设置属性值却发生在这之后. 因此Spring不推荐发生在对象初始化完毕之后的注入方式.

因此我们应该使用构造器注入的方式, 在执行构造器的初始化之前, 就已经为字段准备好了值.

不过实际上 Spring 也想到了使用属性注入的方式可能引起的问题. 因此提供了一个@PostConstruct注解, 来实现对属性注入完毕, 以及 Set 方法执行完毕后, 执行的一个初始化的方法. 如果我们将一些初始化的逻辑放在这里面, 就可以避免这个问题

# 25. 一个 SpringBoot 项目的 Parent 需要指定为Spring-boot-stater-parent

否则无法通过 Properties 来覆盖 Spring-boot-stater-parent 里面指定的版本.

在原本的父项目工程中, 我以如下的方式, 引入 SpringBoot 的依赖管理, 其父项目的子模块确实可以收到 SpirngBoot 的依赖版本控制, 但是我想要覆盖 SpringBoot 依赖管理中的 ES 的般般时, 发现在父项目工程中添加elasticsearch.version没有效果.

如下方式设置 Elasticsearch 的版本无效果:

<properties>
    <spring-boot.version>2.7.12</spring-boot.version>
    <spring-cloud.version>2021.0.3</spring-cloud.version>
    <spring-cloud-alibaba.version>2021.0.4.0</spring-cloud-alibaba.version>ion>
    <elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
<dependencyManagement>
    <dependencies>
        <!--spring cloud-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!-- SpringCloud Alibaba 微服务 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>${spring-cloud-alibaba.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <!-- SpringBoot 依赖配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>${spring-boot.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

不知道是否因为 SpringBoot 的依赖管理因为与父工程同级别, 无法直接覆盖掉它的版本. 而子模块中添加Properties来制定版本也无效.

因此该为将父工程的Parent设置为 SpringBoot 工程,然后再覆盖就行了.

# 26. 在使用 Nginx 代理微服务网关时, 最好需要带上原请求头中的 Host

如果网关配置的路由匹配规则中, 包含具有指定的 Host 值, 即对应的域名访问到对应的服务时, 默认的 Nginx 代理时,会丢弃原请求的域名信息(Host), 我们需要配置成携带. 对于其他的断言规则同理, 也有可能使用的是其他的请求头, 我们都需要在 Nginx 中配置好.

upstream gulimall {
    http://192.168.233.1:88
}
server {
    listen       80;
    server_name  micro;

    location / {
        proxy_set_header Host $host; #携带原请求的Host(域名信息)
        proxy_pass http://192.168.233.1:88;
    }
}

# 27. 堆内存和垃圾回收

Java 中的对象的实际数据都保存在 JVM 中的堆内存空间中. 垃圾回收也指的是回收堆这部分的数据. 每次新创建对象, 为它开辟空间, 意思就是在 JVM 已经申请到的堆空间中选择一个地方给它存放数据.

  • JVM 的堆空间被划分为二个大部分:Young Generation(新生代),Tenured Generation(老年代区).
  • 其中新生代又可以被划分为: Eden(伊甸园),Survival(幸存区)
  • 而幸存区,又划分为 0,1 两个部分.

img

一个新对象创建, 为其分配空间的流程如下:

  • 判断 Eden 区域是否放的下?
    • 如果放的下,则最好, 直接放入后结束
    • 如果放不下, 则进行一次 YGC (Young GC): YGC 就是将 Eden 和 Survival 幸存区两个区域中的未被引用的对象删除, Eden 中没有被删除的对象,移动到幸存区.
  • 进行一次 YGC 后, 是否放的的下?
    • 如果放的下,则最好, 直接放入后结束
    • 放不下, 表明新创建的对象是一个大对象, 执行一次 FULL GC: 被称为大屠杀, 会查找新生代和老年代中所有没有被引用的对象, 然后删除. 之后判断是否放的下?
  • 进行一次 FULL GC 后, 是否放的下?
    • 如果放的下,则最好, 直接放入后结束
    • 放不下, 抛出 OOM(Out of Memory)超出内存大小的错误, 程序结束.

img

补充细节:

  • 在 S0,S1(幸存区)的对象, 每经历一次 GC 没有被删除(其年龄加 1),达到指定的阈值(年纪)后, 被移动到老年代区域.
  • 在 Eden 区的对象经过一次 GC 后, 按理来说会进入幸存区, 但是如果说幸存区放不下, 则会放入到老年代区域.

# 28. 如何优化一个接口?

一个接口首先由如下的性能指标:

  1. 响应时间: 从发送请求到, 接收到所有的响应的耗时
  2. 最小响应时间: 并发多次发送请求中, 最小的响应时间
  3. 最大响应时间: 并发多次发送请求中, 最大的响应时间
  4. TPS: 每秒事务处理数, 这里的事务可以理解为一个完整的业务的完成, 比如说下订单整个业务完成, 你的系统每秒钟可以下多少个订单?
  5. QPS: 每秒查询请求处理数, 每秒钟接口的处理后返回的次数
  6. 90%位: 并发测试中, 按照响应时间升序排序, 处于第 90%位置的请求的响应时间, 如果这个时间符合了我们的性能要求, 就表示百分之 90 的用户的请求的性能要求达标.
  7. 95%,99%位, 与上面同理

一个接口可以划分为: CPU 密集型, IO 密集型.

  • CPU 密集型: 处于耗时在 CPU 的运算, 比如说筛选数据,处理数据.排序等等操作.
  • IO 密集型: 数据读写, 数据库查询, 网络 IO 等.

针对于 CPU 密集型: 我们可以优化算法, 加新的服务器, 并发处理, 针对于 IO 密集型: 升级硬盘(换成 SSD),升级内存, 加大网卡带宽等.

加快一个接口的速度, 五种方式:

  1. 缓存: 将磁盘中的数据缓存到内存, 因为内存具有更快的 IO, 读取的速度, 降低了 IO 的复杂度.
  2. 分页: 数据分页后, 减少了每次请求的数据量, 降低的读写的 IO 的时间, 提高了速度.
  3. 异步: 将该逻辑与该接口解耦合, 提前返回.
  4. 多线程(并发): 将一个任务分解为多个小任务, 并发多线程的解决, 本质上是提高的 CPU 的利用率. 如果本来 CPU 就吃紧, 可能效果就差.
  5. 压缩: 减少网络 IO 的数据量, 减少耗时, 稍微提高了 CPU 的计算.

# 29. JVM 监控工具 jconsolejvisualvm

这两个工具 Java 自带, 直接在控制台输入对应的命令, 然后打开即可. 里面选择链接某个 JVM, 然后就可以看到 CPU,堆内存, 线程等数据了, jvisualvm 功能更加全面, 推荐使用, 已知 java8 版本带这两个工具, 更高的版本似乎会将其移除.

在进行压力测试时, 适合通过此工具进行观察.

# 30. 缓存使用时需要注意的三个问题: 缓存穿透, 缓存雪崩, 缓存击穿.

  1. 缓存穿透
    • 缓存和数据库中, 都没有对应的数据, 客户端不断的请求, 不断的返回 null. 此时数据库的压力就会提高.
    • 解决方案: 将 null 值也缓存到 Redis 中, 可以设置一个过期时间.
  2. 缓存雪崩
    • 设置过期时间不合理. 大量的缓存 key 同一时刻(或者间隔时刻过短)过期, 大量请求由 Redis 转到数据库, 导致数据库宕机.
    • 设置不同 Key 的过期时间时, 追加 1~5 分钟的随机值.
  3. 缓存击穿
    • 通常是热点 Key, 大量请求需要查询这个 key 的数据, 但是某个时刻这个 key 过期了, 接下来的大量针对该 key 的请求打到数据库,导致数据库出来不过来宕机.
    • 加锁: 只允许一个线程去查询, 其他的线程全部等待, 查询完数据后更新到缓存中再释放锁, 其他的现场先查询缓存,有数据后直接返回.

# 31. 本地锁.

给单台服务实例加锁. 下列案例场景: 单台服务机器,缓存击穿锁, 单例模式双检查. 主要思路就是: 在查询完缓存, 发现缓存中没有所要数据时, 准备开始执行调用数据库查询的代码, 但是这里先加上一把锁.锁的对象值是this(如果该方法根据不同 id 查询的是不同的数据, 可以将锁值设置为 id 值), 这样针对同一数据的查询的现场都会被锁住, 只有一个线程可以进入. 在进入后依然需要检查一下缓存中是否有了(防止后序进入的线程重复查询数据库), 没有则是进行查询数据库, 查询完毕后,释放锁. 其他线程获取锁后,有查询一次缓存,发现缓存中有, 则直接返回了.

代码框架:

Data data = getByCache(); //从缓存获取数据
if(data == null){
    synchronized('锁对象'){
        data = getByCache();
        if(data == null){
            data == getByDB();
            setCache(data);
        }
        return data;
    }
}

本地锁存在的问题 : 分布式模式下, 各个服务为集群模式: 本地锁只能锁住自己, 多个服务实例, 就会产生多个请求

# 32. 基于 Redis 的分布式锁

分布式锁来解决微服务集群模式下, 获取到同一把锁. 使得只有一个线程能够执行某个操作.

# 手动实现基于 Redis.

代码模板:

public Data getData(){
    Data data = getByCache(); //从缓存获取数据
    if(data == null){
        //1. 获取分布式锁
        String uuid = UUID.random().toString();
        Boolean isLock = stringRedisTemplate.opsForValue().setIfAbsent(
                    "Lock:xxx", //锁名, 不同的锁名关系到锁的作用域, 比如不同的方法参数作为锁名
                    uuid, //不同线程生成的UUID不同, 释放者只能释放自己的锁, 防止因为业务超时才完成,导致锁自动释放(expire), 而自己主动释放了别人的锁的现象.
                    60, //需要注意: 锁的自动释放时间  >= 业务完成时间,
                    TimeUnit.SECONDS); //时间单位
        if(isLock != null && isLock){
            //获取锁成功,
            try{
                //获取锁成功, 查询数据库
                data = getByCache();
                //设置缓存
                setCache(data);
            }catch(Exception e){
                log.error("业务出现异常: ".e)
            }finally{
                //不管怎样,都要释放锁
                //释放锁: 判断锁是否存在以及是否是自己的(查询操作) 与 释放锁(删除)操作必须是原子性的.
                //防止出现刚判断是自己的锁,redis那边锁就自己释放了, 导致删除操作删除的是别人的锁.
                //需要使用lua脚本, 保证原子性
                String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
                Long executedSuccess = stringRedisTemplate.execute(
                        new DefaultRedisScript<>(script, Long.class), //指定脚本,以及脚本的返回值类型
                        Collections.singletonList("Lock:xxx"), //key参数列表
                        uuid);//value参数列表
                if(executedSuccess != null && executedSuccess == 1L){
                    System.out.println("释放锁成功");
                }else{
                    System.out.println("锁已经释放, 无需再次释放");
                }
            }
            return data;
        }else{
            //获取锁失败, 开始重试(自旋方式), 但是需要防止递归爆栈, 使用休眠来缓解
            try {
                Thread.sleep(10000); //自己指定一个方式
                return getTwoCategoryWithChild();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

以上的锁具有, 不可重入的缺点:该线程A方法获取到了锁, 调用需要相同的锁的B方法时, 因为具有锁已被自己持有,无法获取,导致出现死锁的现象.

# 最佳方式: 基于Redisson

Redisson也是Redis的客户端(也是使用了RedisTemplate), 它基于Redis实现了许多的具有分布式锁特性的List,Set等集合, 也提供了分布式锁的Lock的实现.

# 快速使用

  1. 引入依赖:
<!--Redisson-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.32.0</version>
</dependency>
  1. 添加Bean
@Bean
public RedissonClient redissonClient(){
    Config config = new Config();
    config.useSingleServer().setAddress("redis://localhost:6379");
    return Redisson.create(config);
}

# Lock(),阻塞式上锁, 类似于本地的synchronized

Redisson实现的Lock(),(注意是无参的Lock) 具有看门狗机制, 当服务宕机时, 无法调用看门狗机制的续期方法, 达到30秒货Lock就会过期. 如果一切顺利, 主要业务没有执行完毕, 锁就不会被释放.

如果自己指定了Lock的参数, 设置了过期时间,那么, 达到指定的时间后,锁就会过期, 不具有看门狗机制. 最佳实践方式是, 自己指定Lock时间, 需要注意锁释放时间 >= 业务完成时间。 自己指定的时间的方式,可以剩余看门狗机制的性能消耗, 并且可以设置锁释放时间足够的大, 一旦发现超过了指定的时间,锁释放了, 业务也没有完成, 说明肯定是哪里宕机了。

 //先从缓存获取
String json = stringRedisTemplate.opsForValue().get("TwoCategoryWithChild");
List<CategoryEntity> twoLevelCategoryList = null;
if (StrUtil.isNotBlank(json)) {
    System.out.println("缓存命中, 不查询数据库");
    try {
        twoLevelCategoryList = objectMapper.readValue(json, new TypeReference<List<CategoryEntity>>() {
        });
    } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
    }
} else {
    //1. 获取锁
    Lock lock = redissonClient.getLock("Lock:TwoCategoryWithChild");
    try {
        //阻塞式锁, 在获取到锁之前停在这里.
        lock.lock();
        //双检测
        json = stringRedisTemplate.opsForValue().get("TwoCategoryWithChild");
        if (StrUtil.isNotBlank(json)) {
            twoLevelCategoryList = objectMapper.readValue(json, new TypeReference<List<CategoryEntity>>(){});
        }else{
            twoLevelCategoryList = getCategoryEntities();
            stringRedisTemplate.opsForValue().set("TwoCategoryWithChild", JSONUtil.toJsonStr(twoLevelCategoryList));
        }
    } catch (Exception e) {
        log.error("业务异常: {}", e);
    } finally {
        //释放锁
        lock.unlock();
    }
}
return twoLevelCategoryList;

# 读写锁

用于保证数据的一致性, 在数据写入期间, 读锁就上锁, 其他线程无法读取. 当写入完毕后, 就可以继续读取

  • 写锁: 是一把排他锁, 只有一个线程可以进行写入.
  • 读锁: 是共享锁, 所有的线程都可以读取,就跟没有加锁似的, 当写锁等待时, 读锁无法获取.
//测试写锁
@GetMapping("/write")
public String writeLock() throws InterruptedException {
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("my-lock");
    // 写入锁是, 排它锁, 多个线程只有一个能够获取
    RLock rLock = readWriteLock.writeLock();
    rLock.lock();
    System.out.println("写入");
    Thread.sleep(30000);
    rLock.unlock();
    return "写入成功";
}

//测试读锁
@GetMapping("/read")
public String readLock() throws InterruptedException {
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("my-lock");
    RLock rLock = readWriteLock.readLock();
    rLock.lock();
    rLock.unlock();
    return "读取数据";
}

# 闭锁

只有等指定数量的请求,返回后,添加闭锁 才能停止等待.

//闭锁, 达到指定计数值后, 上锁
@GetMapping("/close")
public String close() throws InterruptedException {
    //当计数值为0时, 锁自动释放
    RCountDownLatch countDownLatch = redissonClient.getCountDownLatch("close-lock");
    //设置计数器的值
    countDownLatch.trySetCount(5);
    countDownLatch.await();
    return "闭锁";
}

@GetMapping("/count")
public String count(){
    RCountDownLatch countDownLatch = redissonClient.getCountDownLatch("close-lock");
    //递减计数器
    countDownLatch.countDown();
    return "计数器减1, 目前值"+countDownLatch.getCount();
}

# 信号量

/**
 * 信号量测试: 信号量可以类比为 生产者/消费者.
 * 这里产生一个信号, 另一个地方才能获取到一个信号,去执行代码.
 * @return
 */
@GetMapping("/semaphore")
public String semaphore() {
    //信号量测试
    RSemaphore semaphore = redissonClient.getSemaphore("semaphore");
    //添加一个信号. (key对应的值加1)
    semaphore.release();
    return "信号量+1";
}

@GetMapping("/take")
public String take() throws InterruptedException {
    //获取信号量
    RSemaphore semaphore = redissonClient.getSemaphore("semaphore");
    //尝试拿取一个信号量,拿不到会阻塞 (减少一个信号量, key对应的值减1)
    semaphore.acquire();
    return "信号量-1";
}

# 33. 并发编程注意的问题

  1. SimpleDateFormat在格式化时间时, 不要使用成员变量, 并发时不安全. 可以改为局部变量, 或者改用DateTimeFormat. 亦或是使用ThreadLocal存储, 每一个线程有专属的一份.
  2. 实现懒汉的单例模式时,光使用双检查锁不行, 还需要使用volatile关键字修改成员变量. 防止因为指令重排导致的双检查锁失效.
  3. 异步功能调用问题: Spring提供的@Async注解, 需要自己提供一个线程池, 否则就会使用默认的线程池,可能出现问题(默认情况下走else,新建立一个线程)
  4. 线程池问题: Executors提供的静态方法创建大部分的线程池, 其最大的线程数量默认是Integer.MAX_VALUE. 在高并发情况下导致OOM

# 34. 缓存一致性的问题

最终一致 还是 强一致?

  • 最终一致: 要求数据最终能够达到一致即可, 允许短暂的不一致.
  • 强一致: 要求数据每时每刻确保查询的永远是最新的数据.

需要根据具体的业务场景分析, 这里需要的是最终一致, 还是强一致. 来选择对应的方案.

# 确保最终一致.

不同的最终一致, 还可以按照允许的短暂的不一致时间继续划分. 允许的是1秒钟的不一致? 1小时的不一致? 1天的不一致? 根据不同的一致的时间, 可以设置对应的过期时间, 这样就算数据出现了不一致, 只要数据过期后, 下次查询就会更新为正确的数据.

# 缓存一致性的两种方案

  1. 双写模式: 更新数据时, 对数据库和缓存都进行更新操作.
  2. 失效模式: 更新数据时, 对数据库进行更新操作, 对缓存进行删除.

双写模式和失效模式在高并发场景下都存在问题, 因为错误的执行顺序, 会出现数据的不一致的现象. 但是当请求稳定下来, 也会达到数据最终一致.

# 确保强一致.

业务对数据的准确性极其敏感. 要求数据必须一致.

通过加锁来实现, 并且我们可以使用读写锁.

  • 失效模式: 在更新数据时, 不允许读取, 等数据更新完毕, 解锁, 读取请求发现缓存为null,则会查询数据库,然后更新缓存(高并发下可以给更新缓存加锁), 保证读取的永远是最新的数据.
  • 双写模式: 在更新数据时,在数据库和缓存都没有更新完毕之前, 不允许读取. 保证读取的数据永远是最新的数据.

读写锁在: 经常读写的场景对性能影响很大, 但是案例说经常读写的场景下, 不应该使用缓存技术. 不如直接查询数据库.

# 总结

img

# 35. 写一个接口时如何判断是否需要加上缓存?

  1. 这个接口要求速度很快吗? | 这个接口很吃数据库/应用服务器的性能吗?
    • 这个接口很吃资源, 想要缓存起来, 省掉下次计算, 使得整个系统的并发程度更高.
    • 这个接口太慢了(中间件太多, 很慢, 计算耗时很慢, IO请求很慢), 要求提高速度.
  2. 这个数据如何缓存了, 会非常多吗, 很耗费资源吗?
    1. 用户数特别多: 千万级用户的个人信息量非常多, 如果加入缓存, 还没有设置过期时间, 导致Redis数据量太多.
  3. 这个接口的数据经常修改吗?
    • 这是一个用户个人信息接口, 很少去修改: 适合使用
    • 则是一篇文章, 文档信息, 适合使用.
    • 用户的观看历史列表, 点赞量等, 经常变化: 不适合
  4. 要求强一致吗?
    • 用户的支付余额, 等级信息: 强一致, 不能与实际不符和: 加读写锁
    • 允许短暂的不一致, 最终达到一致即可: 缓存设置过期时间, 读时更新

# 36. SpringCache

Spring通过提供注解, 快速的帮助我们实现为接口添加注解, 就不用写哪些判断缓存是否存在的代码了. 支持多种缓存的实现方式: Jvm的CurrentHashMap缓存, Redis缓存...

# 使用

  1. 引入依赖
    <!-- cahce -->
    <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-cache</artifactId>
     </dependency>
     <!-- 使用Redis -->
     <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-data-redis</artifactId>
     </dependency>
    
  2. 自定义配置RedisCache: 不配的话也可以用, 但是Redis缓存默认使用的是JDK的序列化器
@Bean
public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
    RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
    //指定值的 序列化器为json, 而不是默认的jdk
    config = config .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
    //获取Redis的配置, 从Redis的配置文件中设置前缀, 是否存储控制等等
    CacheProperties.Redis redisProperties = cacheProperties.getRedis();
    if (redisProperties.getTimeToLive() != null) {
        config = config.entryTtl(redisProperties.getTimeToLive());
    }
    if (redisProperties.getKeyPrefix() != null) {
        config = config.prefixCacheNameWith(redisProperties.getKeyPrefix());
    }
    if (!redisProperties.isCacheNullValues()) {
        config = config.disableCachingNullValues();
    }
    if (!redisProperties.isUseKeyPrefix()) {
        config = config.disableKeyPrefix();
    }
    return config;
}

# 相关注解

  1. @Cacheable: 触发将数据保存到缓存的操作.
  2. @CacheEvict: 从缓存中删除对应的缓存数据
  3. @CachePut: 将方法的执行结果作为缓存数据, 保存的缓存中.
  4. @Caching: 组合以上多种操作
  5. @CacheConfig: 在类上级别上, 配置缓存通用的配置. 这个类中的缓存注解使用哪个CacheManager..

自动会根据参数值,来匹配同一个方法的不同缓存. 比如说, 相同的方法, 具有不同的参数值时, 会查询不同的缓存数据. 更新时也是同理.

# 不足之处

  1. 读取模式:
    • 缓存穿透: 可以通过配置缓存Null,cache-null-values: true, 来解决缓存穿透
    • 缓存击穿: 大量并发查询一个正好过期的key, 通过加锁的方式, 默认是无加锁的, 通过设置sync=true, 来设置同步锁.
    • 缓存雪崩: 加上随机的过期时间. 目前好像只能设置一个统一的过期时间,但是随机时间的设置也是玄学. 可能正好随机到同一个时间.
  2. 写模式:
    • 读写我们手动加锁
    • 加入Canal服务, 由它去监听数据库变化更新数据库
    • 对于读取多,写多的, 直接去数据库查询.
更新时间: 2024年7月6日星期六上午10点55分