【Spring】Spring学习笔记

news2024/11/25 10:08:46

在这里插入图片描述

Spring数据库

Spring JDBC

环境准备

  1. 创建Spring项目, 添加以下依赖

    1. H2 Database: 用于充当嵌入式测试数据库
    2. JDBC API: 用于连接数据库
    3. Lombok: 用于简化pojo的编写
  2. 然后添加配置文件:

    spring.output.ansi.enabled=ALWAYS
    spring.datasource.username=***********
    spring.datasource.password=***********
    spring.datasource.url=jdbc:mysql://localhost:3306/test?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.datasource.hikari.maximumPoolSize=5
    spring.datasource.hikari.minimumIdle=5
    spring.datasource.hikari.idleTimeout=600000
    spring.datasource.hikari.connectionTimeout=30000
    spring.datasource.hikari.maxLifetime=1800000
    
  3. 之后启动测试类

        @Test
        public void connectionBuild() throws SQLException {
            Connection conn = dataSource.getConnection();
            log.warn(dataSource.toString());
            log.warn(conn.toString());
            conn.close();
        }
    
  4. 便可以在控制台中看到数据库信息了

    2024-03-02 22:12:22.538  WARN 1592035 --- [           main] c.p.database.jdbc.JdbcApplicationTests   : HikariDataSource (HikariPool-1)
    2024-03-02 22:12:22.538  WARN 1592035 --- [           main] c.p.database.jdbc.JdbcApplicationTests   : HikariProxyConnection@1740328397 wrapping com.mysql.cj.jdbc.ConnectionImpl@738d37fc
    
  5. 假设没有SpringBoot依赖的话, 需要自己配置以下bean

    1. DataSource: 用于管理数据源, 由DataSourceAutoConfiguration配置
    2. TransactionManager: 用于管理事务, 由DataSourceTransactionManagerAutoConfiguration配置
    3. JdbcTempalte: Spring用于访问数据库的工具类, 由JdbcTemplateAutoConfiguraiotn配置
  6. 之后我们可以添加以下两个配置来添加自动初始化

    1. spring.datasource.initialization-mode=embedded: 配置自动初始化的模式
    2. spring.datasource.schema=schema.sql: 自动初始化的建表脚本
    3. spring.datasource.data=data.sql: 自动初始化的数据脚本
  7. 多数据源问题

    1. 通过@Primary来指定主要Bean, 进而指定主要的DataSource注入
    2. exclude掉SpringBoot自带的上述自动装配的数据库相关的Bean, 然后创建自己的

数据库连接池

HikariCP
  1. HikariCP是一个高性能的数据库连接池, 原因在于:
    1. 大量字节码级别的优化 很多方法通过JavaAssist生成
    2. 大量小改进, 如使用FastStatementList代替ArrayList, 使用无锁集合ConcurrentBag使用invokestatic代替invokevirtual
  2. HikariCP是Spring2.x默认的数据库连接池, 可以通过spirng.datasource.hikari.*进行配置
Druid
  1. Druid是阿里巴巴开源的数据库连接池, 具备强大的监控性能, 并且能防止SQL注入
  2. 实用功能: 详细的监控/防SQL注入/数据库密码加密等小功能
  3. Druid扩展:
    1. 继承FilterEventAdapter
      1. 并修改META-INFO/druid-filter.properties增加filter配置

Spring JDBC

  1. 使用SpringJDBC: 使用@Repository标注Bean
  2. Spring操作JDBC: 使用JdbcTemplate, 它与Spring自带的数据库连接池有集成

在Spring中, 可以直接通过@Autowired获得JdbcTemplate然后操作数据库

    @Test
    public void testUpdate() {
        log.warn(jdbcTemplate.update("UPDATE foo SET bar = 'a1' WHERE id=1"));
    }

    @Test
    public void testInsert() {
        log.warn(jdbcTemplate.update("INSERT INTO foo (bar) VALUES ('c0');"));
    }

    @Test
    public void testSelect() {
        log.warn(jdbcTemplate.queryForList("SELECT * FROM foo WHERE id < 3;"));
    }

    @Test
    public void testDelete() {
        log.warn(jdbcTemplate.update("DELETE FROM foo WHERE id = 3;"));
    }

    // 批量插入, 可以一次性插入5条数据, 分别为`batch-1`, `batch-2`到`batch-5`
    @Test
    public void batchInsert() {
        int[] ret = jdbcTemplate.batchUpdate("INSERT INTO foo (bar) VALUES (?);", new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                ps.setString(1, String.format("batch-%d", i));
            }

            @Override
            public int getBatchSize() {
                return 5;
            }
        });
        log.warn(Arrays.toString(ret));
    }

Spring事务

  1. Spring事务提供了一个抽象, 可以支持多种数据源
  2. 事务定义:
    1. 传播性(propagation):
    2. 隔离性(Isolation)
    3. 超时(Timeout)
    4. 只读(Read only status)
编程式事务
@Log4j2
@SpringBootTest
public class TransactionTest {
    @Autowired
    private TransactionTemplate transactionTemplate;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    public void interceptTransactionTest() {
        log.info("Before Transaction: {}", getCount());
        transactionTemplate.execute(new TransactionCallbackWithoutResult() {
            @Override
            protected void doInTransactionWithoutResult(@NonNull TransactionStatus status) {
                jdbcTemplate.execute("INSERT INTO foo (bar) VALUE ('Transaction-1')");
                // 事务执行中, 值为事务执行前+1
                log.info("Count in Transaction: {}", getCount());
                status.setRollbackOnly();
            }
        });
        // 因为回滚了, 所以值等于事务执行前的值
        log.info("After Transaction: {}", getCount());
    }

    private long getCount() {
        return (long) jdbcTemplate.queryForList("SELECT COUNT(*) AS cnt FROM foo").get(0).get("cnt");
    }
}

其输出结果为:

2024-03-07 21:47:44.559  INFO 1707266 --- [           main] c.p.database.jdbc.TransactionTest        : Before Transaction: 23
2024-03-07 21:47:44.563  INFO 1707266 --- [           main] c.p.database.jdbc.TransactionTest        : Count in Transaction: 24
2024-03-07 21:47:44.565  INFO 1707266 --- [           main] c.p.database.jdbc.TransactionTest        : After Transaction: 23

可以看到回滚后Count的值和执行insert前一样

声明式事务

在这里插入图片描述

  1. 开启注解配置

    1. @EnableTransactionManagement
    2. <tx:annotation-driver/>
  2. 在开启事务注解支持之后, 就可以添加@Transactional来实现声明式事务

  3. 首先编写一个Service类, 包含了数据库操作

    package com.passnight.database.jdbc;
    
    import lombok.RequiredArgsConstructor;
    import org.springframework.jdbc.core.JdbcTemplate;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    @Service
    @RequiredArgsConstructor
    public class FooService {
        private final JdbcTemplate jdbcTemplate;
    
        @Transactional
        public void insertRecord() {
            jdbcTemplate.execute("INSERT INTO foo (bar) VALUE ('Transaction-2')");
        }
    
    
        public void insertWithRollbackException() throws Exception {
            jdbcTemplate.execute("INSERT INTO foo (bar) VALUE ('Transaction-3')");
            throw new Exception("Unexpected Exception occurred");
        }
    }
    
  4. 在加上TransactionalinsertWithRollbackException在抛出异常之后事务就会自动回滚了, 下面的例子中不会插入数据

        @Test
        @Transactional(rollbackFor = Exception.class)
        public void shouldRollback() {
            Assertions.assertThrows(Exception.class, () -> fooService.insertWithRollbackException());
        }
    
  5. Spring事务是通过AOP实现的, 只有直接或间接添加了@Transactional才能生成事务代理对象, 以下例子中没有添加注解, 因此也不会生成事务代理对象, 自然就不会回滚, 因此会插入数据

        // 只有被Spring代理的方法会自动回滚
        @Test
        public void shouldNotRollback() {
            Assertions.assertThrows(Exception.class, () -> fooService.insertWithRollbackException());
        }
    

JDBC 异常

如下图, Spring会将所有的异常转化为DataAccessExceptin, 他是Spring数据库操作异常的基类1

在这里插入图片描述

  1. Spring对异常的统一本质上是对不同数据库错误码的统一, Spring通过SQLErrorCodeSQLExceptionTranslator解析错误码并归类成对应的异常
  2. ErrorCode的定义开一在org/springframework/jdbc/support/sql-error-codes.xml; 也可以自己在Classpaht下变下sql-error-codes.xml中覆盖Spring的默认配置
自定义数据库异常映射

如上问所说, 要自定义数据库异常映射主要通过配置sql-error-codes

  1. 创建自己的数据库访问异常类

    package com.passnight.database.jdbc.exception;
    
    import org.springframework.dao.DataAccessException;
    
    /**
     * 自定义的数据访问异常类, 对应MySQL的{@code 1062}错误码
     * 必须继承{@link org.springframework.dao.DataAccessException}`
     * 否则无法正常创建Bean, 会抛出{@link IllegalArgumentException}
     */
    public class CustomerDuplicateKeyException extends DataAccessException {
        public CustomerDuplicateKeyException(String msg) {
            super(msg);
        }
    }
    
    
  2. 在Classpath下添加自己的sql-error-codes.xml; 并填写相关信息:

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN 2.0//EN" "https://www.springframework.org/dtd/spring-beans-2.0.dtd">
    
    <beans>
    
        <bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes">
            <property name="databaseProductNames">
                <list>
                    <value>MySQL</value>
                    <value>MariaDB</value>
                </list>
            </property>
            <property name="badSqlGrammarCodes">
                <value>1054,1064,1146</value>
            </property>
            <property name="duplicateKeyCodes">
                <value>1062</value>
            </property>
            <property name="dataIntegrityViolationCodes">
                <value>630,839,840,893,1169,1215,1216,1217,1364,1451,1452,1557</value>
            </property>
            <property name="dataAccessResourceFailureCodes">
                <value>1</value>
            </property>
            <property name="cannotAcquireLockCodes">
                <value>1205,3572</value>
            </property>
            <property name="deadlockLoserCodes">
                <value>1213</value>
            </property>
            <!--        使用Spring JDBC用于扩展的Translator-->
            <property name="customTranslations">
                <!--            自定义对重复主键的错误码-异常映射-->
                <bean class="org.springframework.jdbc.support.CustomSQLErrorCodesTranslation">
                    <property name="errorCodes" value="1062"/>
                    <property name="exceptionClass"
                              value="com.passnight.database.jdbc.exception.CustomerDuplicateKeyException"/>
                </bean>
            </property>
        </bean>
    </beans>
    
    
  3. 编写测试用例, 断言抛出自定义的异常类

    package com.passnight.database.jdbc;
    
    import com.passnight.database.jdbc.exception.CustomerDuplicateKeyException;
    import org.junit.jupiter.api.Assertions;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.jdbc.core.JdbcTemplate;
    
    @SpringBootTest
    public class ExceptionTest {
        @Autowired
        private JdbcTemplate jdbcTemplate;
    
        @Test
        public void duplicateKeyInsert() {
            Assertions.assertThrows(CustomerDuplicateKeyException.class, () -> jdbcTemplate.update("INSERT INTO foo (id, bar) VALUES (1, 'duplicate key')"));
        }
    }
    

ORM

  1. ORM概念:
    1. 在Java代码中, 存储的是对象, 访问的是对象的引用; 而在RDBMS中, 存储的是表, 访问的是行的数据;
    2. 除此之外, Java对象还存在继承/属性等特性, 这些特性也需要和数据库的表映射
    3. 因此需要有一个映射将他们关联起来
  2. Hibernate:
    1. Hibernate是一个开源关系框架, 可以将开发者从95%的数据持久化工作中解放出来
    2. Hibernate屏蔽了数据库的底层细节提供了统一的访问模式
  3. JPA为对象关系映射提供了一种基于pojo的持久化模型, 可以屏蔽数据库间的差异, 用于简化数据持久化的工作
  4. Spring Data JPA是Spring实现的JPA

JPA常用注解

类型常用注解
实体@Entity, @MappedSuperClass, @Table
列生成@Id, @GeneratedValue(strategy, generator), @SequenceGenerator(name, sequenceName)
映射@Column(name, nulable, length, insertable, updatable), @joinTable(name), @JoinColumn(name)
关系@OneToOne, @OneToMany, @ManyToOne, @ManyToMany
列属性@OrderBy

下面是Spring Data JPA的基本使用

  1. 首先要定义一个实体类, 并用上述注解标注

    package com.passnight.toy.buck.entity;
    
    import lombok.AllArgsConstructor;
    import lombok.Builder;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.hibernate.annotations.CreationTimestamp;
    import org.hibernate.annotations.Type;
    import org.hibernate.annotations.UpdateTimestamp;
    import org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyAmount;
    import org.joda.money.Money;
    
    import javax.persistence.*;
    import java.util.Date;
    
    @Data
    @Table(name = "t_menu")
    @Entity
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class Coffee {
        @Id
        @GeneratedValue
        private Long id;
        private String name;
        @Column
        @Type(type = "org.jadira.usertype.moneyandcurrency.joda.PersistentMoneyAmount",
                parameters = {@org.hibernate.annotations.Parameter(name = "currencyCode", value = "CNY")})
        private Money price;
    
        @Column(updatable = false)
        @CreationTimestamp
        private Date createTime;
    
        @UpdateTimestamp
        private Date updateTime;
    }
    
  2. 然后配置自动建表及打印SQL:

    # 自动创建表
    spring.jpa.hibernate.ddl-auto=update
    # 控制是否打印运行时的SQL语句与参数信息
    spring.jpa.properties.hibernate.show_sql=true
    spring.jpa.properties.hibernate.format_sql=true
    
  3. 之后启动应用, 就可以看到表被自动创建了

    Hibernate: 
        
        create table t_coffee_menu (
           id bigint not null,
            create_time datetime(6),
            name varchar(255),
            price decimal(19,2),
            update_time datetime(6),
            primary key (id)
        ) engine=InnoDB
    

Mybatis

  1. 相比于Spring Data JPA, Mybatis通过在XML文件中编写SQL来实现与Java对象的映射, 因此具有更高的灵活性, 可以编写更复杂的SQL如聚合,窗口函数等, 也更利于SQL的优化
  2. 常用的注解:
    1. @MapperScan: 配置扫描的位置
    2. @Mapper: 定义接口

基本使用

  1. 类似与上面的Coffee, Mybatis无需在类上定义任何注解, 需要通过编写SQL来实现与数据库的交互

  2. 下面是咖啡对应的数据表

    DROP TABLE IF EXISTS t_coffee_menu;
    CREATE TABLE t_coffee_menu
    (
        id          BIGINT PRIMARY KEY NOT NULL AUTO_INCREMENT,
        name        VARCHAR(255),
        price       BIGINT,
        create_time TIMESTAMP,
        update_time TIMESTAMP
    )
    
  3. 下面是咖啡对应的实体类:

    package com.passnight.springboot.mybatis.entity;
    
    import lombok.AllArgsConstructor;
    import lombok.Builder;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import lombok.experimental.Accessors;
    import org.joda.money.Money;
    
    import java.io.Serializable;
    import java.util.Date;
    
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @Accessors(chain = true)
    public class Coffee implements Serializable {
    
        private Long id;
    
        private String name;
    
        private Money price;
    
        private Date createTime;
    
        private Date updateTime;
    }
    
  4. 之后编写一个Mapper, 使用注解方式来编写SQL和结果映射

    package com.passnight.springboot.mybatis.mapper;
    
    import com.passnight.springboot.mybatis.entity.Coffee;
    import org.apache.ibatis.annotations.*;
    
    @Mapper
    public interface CoffeeMapper {
    
        @Insert("INSERT INTO t_coffee_menu ( name, price, create_time, update_time) VALUES (#{name}, #{price}, NOW(), NOW())")
        // 添加后可以自动回填id
        @Options(useGeneratedKeys = true, keyProperty = "id")
        int save(Coffee coffee);
    
        @Select("SELECT id, name, price,create_time, update_time FROM t_coffee_menu WHERE id = #{id}")
        @Results({
                @Result(id = true, column = "id", property = "id"),
                @Result(column = "create_time", property = "createTime")
                // 一般不需要自己配置, 配置了`map-underscore-to-camel=true之后可以自动映射java自带的类型
        })
        Coffee findById(@Param("id") Long id);
    }
    
    
  5. 因为Coffee中的Money是自定义类型, 需要自己写类型转换器, 数据库中存储金钱分为单位的bigint, 然后映射回Money对象

    1. 编写TypeHandler

      package com.passnight.springboot.mybatis.handler;
      
      import org.apache.ibatis.type.BaseTypeHandler;
      import org.apache.ibatis.type.JdbcType;
      import org.joda.money.CurrencyUnit;
      import org.joda.money.Money;
      
      import java.sql.CallableStatement;
      import java.sql.PreparedStatement;
      import java.sql.ResultSet;
      import java.sql.SQLException;
      
      public class MoneyTypeHandler extends BaseTypeHandler<Money> {
          @Override
          public void setNonNullParameter(PreparedStatement ps, int i, Money parameter, JdbcType jdbcType) throws SQLException {
              ps.setLong(i, parameter.getAmountMinorLong());
          }
      
          @Override
          public Money getNullableResult(ResultSet rs, String columnName) throws SQLException {
              return parseMoney(rs.getLong(columnName));
          }
      
          @Override
          public Money getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
              return parseMoney(rs.getLong(columnIndex));
          }
      
          @Override
          public Money getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
              return parseMoney(cs.getLong(columnIndex));
          }
      
          private Money parseMoney(Long value) {
              return Money.ofMinor(CurrencyUnit.of("CNY"), value);
          }
      }
      
      
    2. application.properties中配置TypeHandler扫描路径

      mybatis.type-handlers-package=com.passnight.springboot.mybatis.handler
      
  6. 在之后就可以编写测试用例验证了

    @SpringBootTest
    public class CoffeeMapperTest {
        @Autowired
        private CoffeeMapper coffeeMapper;
    
        @Test
        public void insertTest() {
            Coffee coffee = Coffee.builder()
                    .name("Coffee-Name")
                    .price(Money.of(CurrencyUnit.of("CNY"), 10))
                    .build();
    
            int num = coffeeMapper.save(coffee);
            // 保存了一条数据
            Assertions.assertEquals(1, num);
            Assertions.assertEquals(coffee, coffeeMapper.findById(coffee.getId()).setCreateTime(null).setUpdateTime(null));
        }
    }
    

MongoDB

  1. Mongodb是一款开源的文档型数据库, spring对MongoDB的支持主要是通过Spring Data MongoDB这个项目实现的, 类似与jdbc, 该项目也有MongoTemplateRepository的支持\

基本使用

  1. 创建用户

    db.createUser({
      user: "test",
      pwd: "*********",
      roles: [{ role: "readWrite", db: "test" }],
    });
    
  2. 创建对象

    com.passnight.database.mongo.MongoTemplateTest
    
  3. 因为Money是自定义类型, 所以需要创建Converter进行类型映射

    package com.passnight.database.mongo.converter;
    
    import org.bson.Document;
    import org.joda.money.CurrencyUnit;
    import org.joda.money.Money;
    import org.springframework.core.convert.converter.Converter;
    
    public class MoneyReadConverter implements Converter<Document, Money> {
        @Override
        public Money convert(Document source) {
            Document money = (Document) source.get("money");
            double amount = Double.parseDouble(money.getString("amount"));
            String currency = ((Document) money.get("currency")).getString("code");
            return Money.of(CurrencyUnit.of(currency), amount);
        }
    }
    
  4. 为了使用该Converter, 需要注册到Spring容器中

        @Bean
        public MongoCustomConversions mongoCustomConversions() {
            return new MongoCustomConversions(Collections.singletonList(new MoneyReadConverter()));
        }
    
  5. 之后就可以通过MongoTemplate操作MongoDB了

    @Log4j2
    @SpringBootTest
    public class MongoTemplateTest {
        @Autowired
        private MongoTemplate mongoTemplate;
    
        @Test
        public void saveTest() {
            Coffee savedCoffee = mongoTemplate.save(Coffee.builder()
                    .name("Mongo-save")
                    .price(Money.of(CurrencyUnit.of("CNY"), 20.0))
                    .createTime(new Date())
                    .updateTime(new Date())
                    .build());
            // 打印插入的对象, 并且会回填ID
            log.warn(savedCoffee);
        }
    
        @Test
        public void findTest() {
            List<Coffee> list = mongoTemplate.find(Query.query(Criteria.where("name").is("Mongo-save")), Coffee.class);
            log.warn("find: {} Coffee", list);
        }
    
        @Test
        public void updateTest() throws InterruptedException {
            List<Coffee> list = mongoTemplate.find(Query.query(Criteria.where("name").is("Mongo-save")), Coffee.class);
            log.warn("find: {} Coffee", list);
    
            UpdateResult result = mongoTemplate.updateFirst(Query.query(Criteria.where("name").is("Mongo-save")),
                    new Update().set("price", Money.ofMajor(CurrencyUnit.of("CNY"), 30)).currentDate("updateTime"), Coffee.class);
            log.warn("Modify Count: {}", result.getModifiedCount());
            TimeUnit.SECONDS.sleep(1);
            list = mongoTemplate.find(Query.query(Criteria.where("name").is("Mongo-save")), Coffee.class);
            log.warn("find: {} Coffee", list);
    
        }
    }
    

使用Repository操作MongoDB

  1. Mongo Repository有类似于Spring Data Jpa的操作

  2. 开启Repository操作功能: @EnableMongoRepositories

  3. 继承MongoRepository

    @Repository
    public interface CoffeeRepository extends MongoRepository<Coffee, String> {
        List<Coffee> findByName(String name);
    }
    
  4. 使用Repository操作MongoDB

    package com.passnight.database.mongo;
    
    import com.passnight.database.mongo.entity.Coffee;
    import com.passnight.database.mongo.repository.CoffeeRepository;
    import lombok.extern.log4j.Log4j2;
    import org.joda.money.CurrencyUnit;
    import org.joda.money.Money;
    import org.junit.jupiter.api.Assertions;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.data.domain.Sort;
    
    import java.util.Date;
    
    @Log4j2
    @SpringBootTest
    public class MongoRepositoryTest {
        @Autowired
        private CoffeeRepository coffeeRepository;
    
        @Test
        public void findTest() {
            log.warn(coffeeRepository.findByName("Mongo-save").toString());
        }
    
        @Test
        public void insertTest() {
            log.warn(coffeeRepository.insert(Coffee.builder()
                    .name("MongoRepository-save")
                    .price(Money.of(CurrencyUnit.of("CNY"), 20.0))
                    .createTime(new Date())
                    .updateTime(new Date())
                    .build()));
        }
    
        @Test
        public void sortTest() {
            coffeeRepository.findAll(Sort.by("name"))
                    .forEach(log::warn);
        }
    
        @Test
        public void updateTest() {
            Coffee coffee = coffeeRepository.findAll()
                    .stream()
                    .findAny()
                    .orElse(null);
            Assertions.assertNotNull(coffee);
            coffeeRepository.save(coffee.setName("MongoRepository-update"));
            coffeeRepository.findAll(Sort.by("name"))
                    .forEach(log::warn);
        }
    
        @Test
        public void deleteTest() {
            coffeeRepository.deleteAll();
            log.warn(coffeeRepository.findAll(Sort.by("name")));
        }
    }
    
    

Redis

  1. Redis是一款开源的内存KV数据库, 支持多种数据结构

Jedis

  1. Jedis是一款简单易用的Java操作Redis的客户端, 它有以下特点

    1. Jedis不是线程安全的
    2. 因为Jedis不是线程安全的, 所以一般通过JedisPool获取Jedis实例, 多个线程共享一个Jedis实例
  2. 配置Jedis连接

        @Bean
        public JedisConnectionFactory redisConnectionFactory() {
            return new JedisConnectionFactory();
        }
    
  3. 在有了连接之后就可以通过RedisTemplate操作Redis了

    package com.passnight.database.redis;
    
    import com.passnight.database.redis.entity.Coffee;
    import lombok.extern.log4j.Log4j2;
    import org.joda.money.CurrencyUnit;
    import org.joda.money.Money;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.data.redis.core.RedisTemplate;
    
    import java.util.Date;
    
    @Log4j2
    @SpringBootTest
    public class RedisTemplateTest {
        @Autowired
        private RedisTemplate<String, Object> redisTemplate;
    
        @Test
        public void insertTest() {
            Coffee coffee = Coffee.builder()
                    .name("Redis-save")
                    .price(Money.of(CurrencyUnit.of("CNY"), 20))
                    .createTime(new Date())
                    .updateTime(new Date())
                    .build();
            redisTemplate.opsForHash().put("t_coffee_menu", coffee.getName(), coffee.getPrice().getAmountMinorLong());
        }
    
        @Test
        public void selectTest() {
            Coffee coffee = Coffee.builder()
                    .name("Redis-save")
                    .price(Money.of(CurrencyUnit.of("CNY"), 20))
                    .createTime(new Date())
                    .updateTime(new Date())
                    .build();
            log.warn(redisTemplate.opsForHash().get("t_coffee_menu", coffee.getName()));
        }
    }
    

缓存

  1. Spring提供了缓存模块, 可以为Java方法增加缓存,缓存执行结果提高系统运行效率
  2. Spring Cache支持以下组件提供的缓存: ConcurrentMap, EhCache, Caffeine, JCache
  3. 假设在分布式系统中, 不同的节点要有一致的缓存访问, 则可以使用redis等中间件实现
  4. Spring对Cache的支持主要是通过org.springframework.cache.Cacheorg.springframework.cache.CacheManager实现的

基本使用

  1. 常用注解

    注解功能
    @EnableCacheing开启缓存
    @Cacheable缓存方法的执行结果
    @CacheEvict方法会触发清除缓存操作
    @CachePut刷新缓存, 但依旧执行方法
    @Caching缓存的批量操作
    @CacheConfig缓存配置
  2. 启动缓存: @EnableCaching(proxyTargetClass = true)

  3. 编写带缓存的服务

    @Service
    @RequiredArgsConstructor
    @CacheConfig(cacheNames = "coffee")
    public class CoffeeService {
        private final CoffeeRepository coffeeRepository;
    
        public List<Coffee> normalFindAll() {
            return coffeeRepository.findAll();
        }
    
        @Cacheable
        public List<Coffee> findAll() {
            return coffeeRepository.findAll();
        }
    
        @CacheEvict
        public void reloadCoffee() {
    
        }
    }
    
  4. 之后我们通过查看SQL的打印次数来判断缓存的使用情况

    @Log4j2
    @SpringBootTest
    public class CacheServiceTest {
        @Autowired
        private CoffeeService coffeeService;
    
        @Test
        public void normalFindAllTest() {
            coffeeService.normalFindAll()
                    .forEach(log::warn);
        }
    
        /**
         * 该测试用例理应值打印一次SQL
         */
        @Test
        public void findAllTest() {
            coffeeService.findAll();
            coffeeService.findAll();
            coffeeService.findAll();
        }
    
        /**
         * 该测试用例理应值打印两次SQL; 因为{@code CoffeeService.reloadCoffee()}会清除缓存, 因此之后就要重新从数据库中读取
         */
        @Test
        public void reloadTest() {
            coffeeService.findAll();
            coffeeService.findAll();
            coffeeService.findAll();
            coffeeService.reloadCoffee();
            coffeeService.findAll();
            coffeeService.findAll();
            coffeeService.findAll();
        }
    }
    

使用Repository操作Redis

  1. 常用注解

    注解功能
    @RedisHash实体类, 类似@Entity
    @Id主键
    @Indexed除了k-v外的二级索引
  2. 在完成redis template的配置之后, 第一步是配置实体类; 这里配置了@Indexed索引后, spring就会创建一个类似于t_coffee_menu:name:RedisRepository-1的索引, 里面保存有对应的Coffee的id, 用于快速查找

    @Data
    @Builder
    @RedisHash(value = "t_coffee_menu", timeToLive = 60)
    @NoArgsConstructor
    @AllArgsConstructor
    @Accessors(chain = true)
    public class Coffee implements Serializable {
    
        @Id
        private String id;
    
        @Indexed
        private String name;
    
        private Money price;
    
        private Date createTime;
    
        private Date updateTime;
    }
    
  3. 然后配置Repository

    public interface CoffeeRepository extends CrudRepository<Coffee, Long> {
        Optional<Coffee> findOneByName(String name);
    }
    
  4. 对于自定义类型, 需要自行编写Converter进行转换

    // 写转换器
    @WritingConverter
    public class BytesToMoneyConverter implements Converter<byte[], Money> {
        @Override
        public Money convert(@NonNull byte[] source) {
            String value = new String(source, StandardCharsets.UTF_8);
            return Money.ofMinor(CurrencyUnit.of("CNY"), Long.parseLong(value));
        }
    }
    // 读转换器
    @ReadingConverter
    public class MoneyToByteConverter implements Converter<Money, byte[]> {
        @Override
        public byte[] convert(Money source) {
            return Long.toString(source.getAmountMajorLong()).getBytes(StandardCharsets.UTF_8);
        }
    }
    
  5. 在编写了转换器之后,还要注册

        @Bean
        public RedisCustomConversions redisCustomConversions() {
            return new RedisCustomConversions(Arrays.asList(new MoneyToByteConverter(), new BytesToMoneyConverter()));
        }
    
  6. 之后就可以执行CRUD了

    @SpringBootTest
    public class CoffeeRepositoryTest {
    
        @Autowired
        CoffeeRepository coffeeRepository;
    
        @Test
        public void insertTest() {
            coffeeRepository.save(Coffee.builder()
                    .name("RedisRepository-1")
                    .price(Money.of(CurrencyUnit.of("CNY"), 30))
                    .updateTime(new Date())
                    .createTime(new Date())
                    .build());
        }
    
        @Test
        public void findTest() {
            System.out.println(coffeeRepository.findOneByName("RedisRepository-1"));
        }
    }
    

Spring Reactive

  1. 响应式编程: 响应式编程(反应式编程)是一种面向数据流变化传播编程范式
  2. Operators:
    1. subscribe: Nothing happens until you “subscribe”
    2. Flux[0:N]: onNext(), onComplete(), onError()
    3. Mono[0:1]: onNext(), onComplete(), onError()
  3. backpressure:
    1. subscription
    2. onRequest(), onCancle(), onDispose()
  4. scheduler线程调度
    1. 单线程操作: immediate(), single(), newSingle()
    2. 线程池操作: elastic(), parallel(), newParallel()
  5. 错误处理
    1. 异常处理: onError(), onErrorReturn(), onErrorResume()
    2. 最终处理: doOnError, doFinally

基本使用

  1. 引入依赖

            <dependency>
                <groupId>io.projectreactor</groupId>
                <artifactId>reactor-core</artifactId>
            </dependency>
    
  2. 编写响应式代码

        @Test
        public void firstReactorApplication() {
            Flux.range(1, 5)
                    .doOnRequest(n -> log.debug("Request: {}", n))
                    .doOnComplete(() -> log.info("Publisher COMPLETE 1"))
                    .publishOn(Schedulers.elastic()) // 后续代码执行在Schedulers.elastic()线程池当中
                    .map(n -> {
                        log.debug("Publish {}", n);
    //                    int i = 10 / 0; // 创建异常
                        return n;
                    })
                    .doOnComplete(() -> log.info("Publisher COMPLETE 2"))
                    .publishOn(Schedulers.single()) // 后续代码在
                    .onErrorResume(e -> { // 异常恢复
                        log.warn(e);
                        return Mono.just(-1);
                    })
                    .subscribe(n -> log.debug("subscribe: {}", n), // 正常路径
                            log::warn, // 异常路径
                            () -> log.info("Subscriber COMPLETE"), // finally 路径
                            s -> s.request(2)); // 背压
    
        }
    

Reactive Redis

  1. Spring对Redis响应式的支持主要是通过ReactiveRedisConnection/ReactiveRedisConnectionFactoryReactiveRedisTemplate来支持的, 基本上和同步形态下的使用类似

  2. 添加依赖

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
            </dependency>
    
  3. 配置template和序列化器, 如果没有序列化器很多对象类型都无法序列化 包括Long类型

    @Configuration
    public class ReactiveRedisConfiguration {
        @Bean
        public ReactiveRedisTemplate<String, Object> reactiveStringRedisTemplate(ReactiveRedisConnectionFactory factory,
                                                                                 RedisSerializationContext<String, Object> redisSerializationContext) {
            return new ReactiveRedisTemplate<>(factory, redisSerializationContext);
        }
    
        @Bean
        public RedisSerializationContext<String, Object> redisSerializationContext() {
            return RedisSerializationContext.<String, Object>newSerializationContext()
                    .key(RedisSerializer.string())
                    .value(RedisSerializer.json())
                    .hashKey(RedisSerializer.string())
                    .hashValue(RedisSerializer.json())
                    .build();
        }
    }
    
  4. 尽管创建实体类并非必须步骤, 但是为了统一场景, 在这里还是创建了一个实体类, 模拟实际的业务场景

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @Accessors(chain = true)
    public class Coffee implements Serializable {
        private String id;
        private String name;
        private Long price;
    }
    
  5. 配置完成之后就可以直接通过template操作redis了 因为默认redis连接配置是localhost:6379, 所以这里没有配置连接工厂

    @SpringBootTest
    @Log4j2
    class ReactorRedisApplicationTest {
    
        @Autowired
        private ReactiveRedisTemplate<String, Object> redisTemplate;
    
        private final static String TABLE_NAME = "t_coffee_menu";
    
        @Test
        public void insertTest() throws InterruptedException {
            // 任务是在Schedulers.single()上执行的
            // 因此需要CountDownLatch保证任务完成后再退出主线程
            CountDownLatch latch = new CountDownLatch(1);
            Flux.just(Coffee.builder()
                            .name("Reactive-Redis-save")
                            .price(20L)
                            .build())
                    .publishOn(Schedulers.single())
                    .doOnComplete(() -> log.debug("list ok"))
                    .flatMap(coffee -> {
                        log.debug("Try to put coffee: {}", coffee);
                        return redisTemplate.opsForHash().put(TABLE_NAME, coffee.getName(), coffee.getPrice());
                    }).doOnComplete(() -> log.debug("Hash Put Complete"))
                    .concatWith(redisTemplate.expire(TABLE_NAME, Duration.ofMinutes(1)))
                    .doOnComplete(() -> log.debug("Expire Setting Complete"))
                    .onErrorResume(e -> {
                        log.warn(e);
                        return Mono.just(false);
                    })
                    .subscribe(log::info, log::warn, latch::countDown);
            log.info("Start insert Asynchronous");
            latch.await();
        }
    
        @Test
        public void selectTest() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            redisTemplate.opsForHash()
                    .get(TABLE_NAME, "Reactive-Redis-save")
                    .doOnSuccess(log::debug)
                    .doFinally(o -> latch.countDown())
                    .subscribe();
            latch.await();
        }
    
        @Test
        public void deleteTest() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            redisTemplate.opsForHash()
                    .delete(TABLE_NAME)
                    .doOnSuccess(log::debug)
                    .doFinally(o -> latch.countDown())
                    .subscribe();
            latch.await();
        }
    }
    

Reactive Mongo

  1. 与reactive redis和阻塞式mongo一样, spring通过了ReactiveMongoClientFactoryBean/ReactiveMongoDatabaseFactory/ReactiveMongoTemplate提供了对Mongo的响应式支持

  2. 首先在application.properties中配置连接串

    spring.data.mongodb.uri=mongodb://username:password@host:port/database
    
  3. 创建实体类

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    public class Coffee {
        private String id;
        private String name;
        private Money price;
        private Date createTime;
        private Date updateTime;
    }
    
  4. 配置自定义的转化器, 用于转化自定义类型

    public class MoneyReadConverter implements Converter<Long, Money> {
        @Override
        public Money convert(@NonNull Long aLong) {
            return Money.ofMinor(CurrencyUnit.of("CNY"), aLong);
        }
    }
    public class MoneyWriteConverter implements Converter<Money, Long> {
        @Override
        public Long convert(Money money) {
            return money.getAmountMinorLong();
        }
    }
    @Configuration
    public class MongoDbConfiguration {
    
        @Bean
        public MongoCustomConversions mongoCustomConversions() {
            return new MongoCustomConversions(
                    Arrays.asList(new MoneyReadConverter(), new MoneyWriteConverter()));
        }
    }
    
  5. 使用template操作数据库

    @Log4j2
    @SpringBootTest
    public class ReactorMongoApplicationTest {
        @Autowired
        private ReactiveMongoTemplate mongoTemplate;
    
        @Test
        public void insertTest() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            Coffee coffee = Coffee.builder()
                    .name("Reactive-Mongo-1")
                    .price(Money.of(CurrencyUnit.of("CNY"), 30.0))
                    .createTime(new Date())
                    .updateTime(new Date())
                    .build();
            mongoTemplate.insertAll(Collections.singleton(coffee))
                    .publishOn(Schedulers.elastic())
                    .doOnNext(c -> log.info("Next: {}", c))
                    .doOnComplete(() -> log.debug("Complete"))
                    .doFinally(s -> {
                        latch.countDown();
                        log.info("Finally, {}", s);
                    })
                    .count()
                    .subscribe(c -> log.info("Insert {} records", c));
            latch.await();
        }
    
        @Test
        public void updateTest() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            mongoTemplate.updateMulti(Query.query(Criteria.where("name").is("Reactive-Mongo-1")),
                            new Update().set("price", Money.of(CurrencyUnit.of("CNY"), 50.0)), Coffee.class)
                    .doFinally((s) -> {
                        latch.countDown();
                        log.debug(s);
                    })
                    .subscribe(log::debug);
    
            latch.await();
        }
    
        @Test
        public void selectTest() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            mongoTemplate.find(Query.query(Criteria.where("name").is("Reactive-Mongo-1")), Coffee.class)
                    .doOnEach(coffee -> log.debug("Select: {}", coffee))
                    .count()
                    .subscribe(c -> log.debug("find: {}", c), log::warn, latch::countDown);
    
            latch.await();
        }
    }
    

Reactive RDBMS

  1. 与nosql类似, spring也提供了对关系型数据库的响应式操作, 主要通过R2DBCReactive Relational Database Connective连接
  2. Spring对rdbms的支持主要通过了以下几个类实现: ConnectionFactory/DatabaseClient/R2dbcExceptionTranslator, 支持了连接/查询及异常处理

基本使用

  1. application.properties中配置连接串

    spring.r2dbc.username=*****
    spring.r2dbc.password=*****
    spring.r2dbc.url=r2dbcs:mysql://localhost:3306/test?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
    
  2. 创建并配置类型转换器, 用于类型映射; 注意: r2dbc默认对日期的映射是LocalDateTime, 见org.springframework.data.r2dbc.convert.MappingR2dbcConverter#readValue, 因此需要添加对应的Converter

    public class DateReadConverter implements Converter<LocalDateTime, Date> {
    
        @Override
        public Date convert(@NonNull LocalDateTime source) {
            return Date.from(source.atZone(ZoneId.systemDefault()).toInstant());
        }
    }
    public class DateWriteConverter implements Converter<Date, LocalDateTime> {
    
        @Override
        public LocalDateTime convert(@NonNull Date source) {
            return source.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
        }
    }
    public class MoneyReadConverter implements Converter<Long, Money> {
        @Override
        public Money convert(@NonNull Long aLong) {
            return Money.ofMinor(CurrencyUnit.of("CNY"), aLong);
        }
    }
    public class MoneyWriteConverter implements Converter<Money, Long> {
        @Override
        public Long convert(Money money) {
            return money.getAmountMinorLong();
        }
    }
    @Configuration
    public class ReactiveMySqlConfiguration {
        @Bean
        public R2dbcCustomConversions r2dbcCustomConversions() {
            return new R2dbcCustomConversions(Arrays.asList(
                    new MoneyReadConverter(),
                    new MoneyWriteConverter(),
                    new DateWriteConverter(),
                    new DateReadConverter()));
        }
    }
    
  3. 创建实体类

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class Coffee {
    
        private Long id;
    
        private String name;
    
        private Money price;
    
        private Date createTime;
    
        private Date updateTime;
    }
    
    
  4. 使用DatabaseClient对数据库增删查改

    @Log4j2
    @SpringBootTest
    @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
    class R2dbcApplicationTests {
    
    
        @Autowired
        private DatabaseClient client;
    
    
        @Order(3)
        @Test
        public void testUpdate() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            client.update()
                    .table("t_coffee_menu")
                    .using(Update.update("price", Money.of(CurrencyUnit.of("CNY"), 20))
                            .set("update_time", new Date()))
                    .matching(Criteria.where("name").is("R2dbc-DatabaseClient"))
                    .fetch()
                    .rowsUpdated()
                    .subscribe(n -> log.info("Update: {}", n), log::warn, latch::countDown);
            latch.await();
        }
    
        @Order(1)
        @Test
        public void testInsert() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            Coffee coffee = Coffee.builder()
                    .name("R2dbc-DatabaseClient")
                    .price(Money.of(CurrencyUnit.of("CNY"), 20))
                    .createTime(new Date())
                    .updateTime(new Date())
                    .build();
            client.insert()
                    .into("t_coffee_menu")
                    .value("name", coffee.getName())
                    .value("price", coffee.getPrice())
                    .value("create_time", coffee.getCreateTime())
                    .value("update_time", coffee.getUpdateTime())
                    .then()
                    .doFinally(c -> latch.countDown())
                    .subscribe();
            latch.await();
        }
    
        @Order(2)
        @Test
        public void testSelect() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            client.execute("SELECT id, name, price, create_time, update_time FROM t_coffee_menu")
                    .as(Coffee.class)
                    .fetch()
                    .all()
                    .doOnEach(log::debug)
                    .doFinally(s -> latch.countDown())
                    .subscribe(c -> log.info("Fetch: {}", c));
            latch.await();
        }
    
        @Order(4)
        @Test
        public void testDelete() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            client.delete()
                    .from("t_coffee_menu")
                    .matching(Criteria.where("name").is("R2dbc-DatabaseClient"))
                    .fetch()
                    .rowsUpdated()
                    .subscribe(n -> log.info("Delete: {}", n), log::warn, latch::countDown);
            latch.await();
        }
    }
    
    

Repository使用

  1. 在开启了@EnableR2dbcRepositories之后, 就可以通过ReactiveCrudRepository来访问数据库了, 基本的使用和jpa类似, 除了返回值都是MonoFlux类型

  2. 开启r2dbc Repository支持

    @EnableR2dbcRepositories
    @SpringBootApplication
    public class ReactorRdbmsApplication {
        public static void main(String[] args) {
            SpringApplication.run(ReactorRdbmsApplication.class, args);
        }
    }
    
  3. 在实体类上添加相应的注解

    @Data
    @Table("t_coffee_menu")
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class Coffee {
    
        @Id
        private Long id;
    
        private String name;
    
        private Money price;
    
        private Date createTime;
    
        private Date updateTime;
    }
    
  4. 继承Repository

    public interface CoffeeRepository extends R2dbcRepository<Coffee, Long> {
    }
    
  5. 使用Repository查询数据库

    @Log4j2
    @SpringBootTest
    public class R2dbcRepositoryTest {
        @Autowired
        private CoffeeRepository coffeeRepository;
    
        @Test
        public void testSelect() throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            coffeeRepository.findAll()
                    .doOnEach(log::debug)
                    .doFinally(s -> latch.countDown())
                    .subscribe(c -> log.info("Fetch: {}", c));
            latch.await();
        }
    }
    

Reactive Web Client

  1. 类似于同步版的RestTemplate, Spring Reactive提供了WebClient用于以Reactive的方式处理HTTP请求, 其支持以下底层http库

    1. Reactor Netty: ReactorClientHttpConnector
    2. Jetty ReactiveStream HttpClient: jettyClientHttpConnector
  2. WebClient主要包含以下内容

    API功能
    WebClient.create()/WebClient.builder()创建WebClient
    get()/post()/put()/delete()/patch()发起请求
    retrieve()/exchagne获得结果
    onStatus()处理Http Status
    bodyToMono()/bodyToFlux()应答正文

基本使用

  1. Reactive Web Client和RestTemplate的使用非常相似, 同样要配置Money的转化类/不启动Web容器以及Spring只提供了Builder, 配置Money的转化类和Web容器的关闭见RestTemplate基本使用

  2. 对于WebClient首先要注册到Spring容器当中

        @Bean
        public WebClient webClient(WebClient.Builder builder) {
            return builder.baseUrl("http://localhost:8080/springboot/mvc").build();
        }
    
  3. 之后编写一个简单的请求类, 入参是一个Consumer

    @Log4j2
    @Service
    @RequiredArgsConstructor
    public class CustomerClient {
        private final WebClient webClient;
    
        public void getById(Consumer<Coffee> consumer) throws InterruptedException {
            CountDownLatch latch = new CountDownLatch(1);
            webClient.get()
                    .uri("/coffee/{id}", "1")
                    .accept(MediaType.APPLICATION_JSON)
                    .retrieve()
                    .bodyToMono(Coffee.class)
                    .doOnError(log::warn)
                    .doFinally(signalType -> latch.countDown())
                    .subscribeOn(Schedulers.single())
                    .subscribe(consumer);
            latch.await();
        }
    }
    
  4. 之后传一个打印, 就可以将Coffee对象打印出来了

    @Log4j2
    @SpringBootTest
    public class CustomerClientTest {
    
        @Autowired
        CustomerClient customerClient;
    
        @Test
        public void getByIdTest() throws InterruptedException {
            customerClient.getById(coffee -> log.info("Subscribe: {}", coffee));
        }
    }
    

WebFlux

  1. Spring WebFlux是基于reactive技术之上的基于函数式编程的应用程序, 运行在非阻塞的服务器上

基本使用

  1. WebFlux和MVC的使用非常类似, 也是那几个注解, 只是变成了异步, 操作对象变成了MonoFlux罢了

  2. 在模仿Reactive RDBMS创建了实体类和对应的Repository及Converter之后, 首先是编写非阻塞服务

    @Service
    @RequiredArgsConstructor
    public class CoffeeService {
        private final CoffeeRepository coffeeRepository;
    
        public Flux<Coffee> getByName(String name) {
            return coffeeRepository.findByName(name);
        }
    
        public Mono<Coffee> getById(Long id) {
            return coffeeRepository.findById(id);
        }
    
        public Flux<Coffee> getAll() {
            return coffeeRepository.findAll();
        }
    
        public Mono<Coffee> save(Coffee newCoffee) {
            return coffeeRepository.save(newCoffee);
        }
    }
    
  3. 然后再使用和MVC类似的方式编写响应式Controller

    @Log4j2
    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/coffee")
    public class CoffeeController {
        private final CoffeeService coffeeService;
    
        @GetMapping(value = "/", params = "!name")
        public Flux<Coffee> getAll() {
            return coffeeService.getAll();
        }
    
        @GetMapping(value = "/", params = "name")
        public Flux<Coffee> getByName(@RequestParam String name) {
            return coffeeService.getByName(name);
        }
    
        @GetMapping(value = "/{id}")
        public Mono<Coffee> getById(@PathVariable Long id) {
            return coffeeService.getById(id);
        }
    
        @PostMapping("/")
        public Mono<Coffee> save(@RequestBody Coffee newCoffee) {
            return coffeeService.save(newCoffee);
        }
    }
    
  4. 之后就可以使用WebClient访问测试

    @Log4j2
    @SpringBootTest
    @AutoConfigureWebTestClient
    public class CoffeeControllerTest {
        @Autowired
        private WebTestClient webTestClient;
        @Autowired
        private CoffeeService coffeeService;
        @Autowired
        private ObjectMapper objectMapper;
    
        @Test
        public void getByNameTest() {
            webTestClient.get()
                    .uri(UriComponentsBuilder.fromPath("/coffee/").queryParam("name", "Coffee-Name").toUriString())
                    .header(MediaType.APPLICATION_JSON_VALUE)
                    .exchange()
                    .expectStatus().isOk()
                    .returnResult(new ParameterizedTypeReference<List<Coffee>>() {
                    })
                    .getResponseBody()
                    .publishOn(Schedulers.elastic())
                    .flatMap(Flux::fromIterable)
                    .doOnEach(log::info)
                    .doOnEach(coffeeSignal -> Assertions.assertEquals("Coffee-Name", Optional.of(coffeeSignal).map(Signal::get).map(Coffee::getName).orElse("")))
                    .subscribe();
    
        }
    
        @Test
        public void getByIdTest() {
            webTestClient.get()
                    .uri(UriComponentsBuilder.fromPath("/coffee/{id}").build(1L))
                    .header(MediaType.APPLICATION_JSON_VALUE)
                    .exchange()
                    .expectStatus().isOk()
                    .returnResult(new ParameterizedTypeReference<Coffee>() {
                    })
                    .getResponseBody()
                    .doOnEach(log::info)
                    .doOnEach(coffeeSignal -> Assertions.assertEquals(1L, Optional.of(coffeeSignal).map(Signal::get).map(Coffee::getId).orElseThrow(NullPointerException::new)))
                    .subscribe();
        }
    
        @Test
        public void getAllTest() {
            webTestClient.get()
                    .uri(UriComponentsBuilder.fromPath("/coffee/").toUriString())
                    .header(MediaType.APPLICATION_JSON_VALUE)
                    .exchange()
                    .expectStatus().isOk()
                    .returnResult(String.class)
                    .getResponseBody()
                    // 这里手动序列化, 用`returnResult序列化报错
                    .<List<Coffee>>handle((string, sink) -> {
                        try {
                            sink.next(objectMapper.readValue(string, new TypeReference<>() {
                            }));
                        } catch (JsonProcessingException e) {
                            sink.error(new RuntimeException(e));
                        }
                    })
                    .publishOn(Schedulers.elastic())
                    .flatMap(Flux::fromIterable)
                    .doOnError(log::warn)
                    .doOnEach(log::info)
                    .subscribe();
        }
    
        @Test
        public void saveTest() {
            Coffee coffee = Coffee.builder()
                    .name("Coffee-Name-Webflux")
                    .price(Money.of(CurrencyUnit.of("CNY"), 10))
                    .createTime(new Date())
                    .updateTime(new Date())
                    .build();
    
            webTestClient.post()
                    .uri(UriComponentsBuilder.fromPath("/coffee/").toUriString())
                    .contentType(MediaType.APPLICATION_JSON)
                    .bodyValue(coffee)
                    .header(MediaType.APPLICATION_JSON_VALUE)
                    .exchange()
                    .expectStatus().isOk()
                    .returnResult(Coffee.class)
                    .getResponseBody()
                    .publishOn(Schedulers.elastic())
                    .doOnEach(log::info)
                    .doOnNext(c1 -> coffeeService.getById(coffee.getId()).doOnNext(c2 -> Assertions.assertEquals(coffee, c2)).subscribe())
                    .doOnEach(log::warn)
                    .subscribe();
        }
    }
    

Spring Core

Spring AOP

基本概念

基本概念

概念含义
Aspect切面
Joint Point连接点, 在Spring AOP中代表一次方法的执行
Advice通知, 在连接点执行的操作
Pointcut切入点, 表明如何匹配连接点
Introduction引入, 为现有类型声明额外的方法和属性
Target object目标对象
Aop ProxyAOP代理对象, 有JDK动态代理和CGLIB代理两种实现方式
Weaving织入, 连接切面与目标对象或类型创建代理的过程

常用注解

注解功能
@EnableAspectJAutoProxy开启AspectJ的支持
@Aspect声明当前类是一个切面注意: 仅有该注解还不是一个bean, 因此无法注入到IOC容器当中, 因为AspectJ模式也不需要注入到IOC中
@Pointcut切点
@Before方法执行前执行
@After/@AfterReturning/@AfterThrowing方法执行后执行
@Around环绕执行
@Order指定执行顺序, 数字越小优先级越高

基本使用

  1. 定义一个切面, 声明切点是com.passnight.springboot.aop.service包下的所有方法; 它即需要用**@Acpect标注表明是一个切面, 还需要用@Component标注, 以被Spring代理使切面生效**

    @Log4j2
    @Component
    @Aspect
    public class FooAspect {
    
        /**
         * 定义Pointcut
         */
        @Pointcut("execution(* com.passnight.springboot.aop.service.*.*(..))")
        private void pointCutMethod() {
        }
    
        /**
         * 环绕通知.
         */
        @Around("pointCutMethod()")
        public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
            log.debug("环绕通知: 进入方法");
            Object o = pjp.proceed();
            log.debug("环绕通知: 退出方法");
            return o;
        }
    
        /**
         * 前置通知.
         * 切点既可以使用方法指定, 也可以直接指定
         */
        @Before("execution(* com.passnight.springboot.aop.service.*.*(..))")
        public void doBefore() {
            log.debug("前置通知");
        }
    
        /**
         * 后置通知.
         * <a href="https://docs.spring.io/spring-framework/reference/core/aop/ataspectj/advice.html">官方文档</a>中说
         * The name used in the returning attribute must correspond to the name of a parameter in the advice method. When a method execution returns,
         * the return value is passed to the advice method as the corresponding argument value.
         */
        @AfterReturning(value = "pointCutMethod()", returning = "result")
        public void doAfterReturning(String result) {
            log.debug("After Returning, 返回值: {}", result);
        }
    
    
        /**
         * 异常通知.
         * <a href="https://docs.spring.io/spring-framework/reference/core/aop/ataspectj/advice.html">官方文档</a>中说
         * you want the advice to run only when exceptions of a given type are thrown, and you also often need access to the thrown exception in the advice body
         */
        @AfterThrowing(value = "pointCutMethod()", throwing = "e")
        public void doAfterThrowing(Exception e) {
            log.debug("异常通知, 异常: {}", e.getMessage());
        }
    
        /**
         * 最终通知.
         * 类似于{@code finally}
         */
        @After("pointCutMethod()")
        public void doAfter() {
            log.debug("After");
        }
    }
    
  2. 之后再对应的包下编写一个测试类, 测试几种通知类型

    @Log4j2
    @Service
    public class FooService {
        public void normalMethod() {
            log.debug("FooService.normalMethod()");
        }
    
        public String methodWithReturnValue() {
            log.debug("FooService.methodWithReturnValue()");
            return "Return value of FooService.methodWithReturnValue()";
        }
    
        public String methodWithException() throws Exception {
            log.debug("FooService.methodWithException()");
            throw new Exception("Exception in FooService.methodWithException()");
        }
    
        @RunningTime
        public void methodAnnotatedWithRunningTime() {
            log.debug("FooService.methodAnnotatedWithRunningTime");
        }
    }
    
  3. 最后执行测试, 观察打印结果

    @SpringBootTest
    public class FooServiceTest {
        @Autowired
        private FooService fooService;
    
        @Test
        public void normalMethodAopTest() {
            fooService.normalMethod();
        }
    
        @Test
        public void methodWithReturnValueAopTest() {
            fooService.methodWithReturnValue();
        }
    
        @Test
        public void methodWithException() {
            Assertions.assertThrows(Exception.class, () -> fooService.methodWithException());
        }
    
        @Test
        public void methodAnnotatedWithRunningTimeTest(){
            fooService.methodAnnotatedWithRunningTime();
        }
    }
    
  4. 打印顺序大致为如下图, 其中环绕通知在最外围, @After类似于finally语句

    前-环绕通知
    前置通知
    方法体
    返回值
    后置通知
    后-环绕通知

Spring容器

he root WebApplicationContext typically contains infrastructure beans, such as data repositories and business services that need to be shared across multiple Servlet instances. Those beans are effectively inherited and can be overridden (that is, re-declared) in the Servlet-specific child WebApplicationContext, which typically contains beans local to the given Servlet. The following image shows this relationship:2

在这里插入图片描述

  1. 从上图可以看到,Servlet的上下文和Root上下文并不是一个上下文, 它有自己独立的配置类: AbstractAnnotationConfigDispatcherServletInitializer, 我们可以通过继承这个类实现单独的配置

Spring父子容器

  1. 父容器中的Bean可以在子容器中生效, 而子容器中的bean无法再父容器中生效

  2. 现在创建一个切面, 它在注册到Spring容器后, 可以在目标方法执行结束后打印Enhanced By AOP

    @Slf4j
    @Aspect
    public class FooAspect {
        @AfterReturning("bean(fooService*)")
        public void printAfter() {
            log.info("Enhanced By AOP");
        }
    }
    
  3. 再创建一个服务, 它可以打印当前的容器, 用于测试

    @Log4j2
    @RequiredArgsConstructor
    public class FooService {
        private final String context;
    
        public void hello() {
            log.info("hello {}", context);
        }
    }
    
  4. 首先创建一个容器, 它包含了切面类, 并作为父容器

    @Configuration
    @EnableAspectJAutoProxy
    public class FooConfig {
        @Bean
        public FooService fooService1() {
            return new FooService("foo");
        }
    
        @Bean
        public FooService fooService2() {
            return new FooService("foo");
        }
    
        @Bean
        public FooAspect fooAspect() {
            return new FooAspect();
        }
    }
    
  5. 再创建一个子容器, 它的父容器是上述容器

    <beans xmlns="http://www.springframework.org/schema/beans"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:aop="http://www.springframework.org/schema/aop"
           xsi:schemaLocation="http://www.springframework.org/schema/beans
            http://www.springframework.org/schema/beans/spring-beans.xsd
            http://www.springframework.org/schema/aop
            http://www.springframework.org/schema/aop/spring-aop.xsd">
    
        <aop:aspectj-autoproxy/>
    
        <bean id="fooService1" class="com.passnight.springboot.mvc.service.FooService">
            <constructor-arg name="context" value="Bar"/>
        </bean>
    
    <!--    <bean id="fooAspect" class="com.passnight.springboot.mvc.acpect.FooAspect"/>-->
    </beans>
    
  6. 然后分别执行父子容器的FooService中的hello方法, 可以看到都被代理了

        @Test
        public void parentContextTest() {
            ApplicationContext fooContext = new AnnotationConfigApplicationContext(FooConfig.class);
    
            FooService bean = fooContext.getBean("fooService1", FooService.class);
            bean.hello();
    
            log.info("=".repeat(100));
    
            ClassPathXmlApplicationContext barContext = new ClassPathXmlApplicationContext(
                    new String[]{"applicationContext.xml"}, fooContext);
            bean = barContext.getBean("fooService1", FooService.class);
            bean.hello();
    
            bean = barContext.getBean("fooService2", FooService.class);
            bean.hello();
        }
    
    2024-03-21 22:45:37.118 INFO  [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello foo
    2024-03-21 22:45:37.123 INFO  [main] com.passnight.springboot.mvc.acpect.FooAspect#[printAfter:12] - Enhanced By AOP
    2024-03-21 22:45:37.124 INFO  [main] com.passnight.springboot.mvc.aop.FooAspectTest#[parentContextTest:21] - ====================================================================================================
    2024-03-21 22:45:37.252 INFO  [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello Bar
    2024-03-21 22:45:37.252 INFO  [main] com.passnight.springboot.mvc.acpect.FooAspect#[printAfter:12] - Enhanced By AOP
    2024-03-21 22:45:37.252 INFO  [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello foo
    2024-03-21 22:45:37.252 INFO  [main] com.passnight.springboot.mvc.acpect.FooAspect#[printAfter:12] - Enhanced By AOP
    
  7. 而假设将切面移到子容器, 则只有子容器中的对象会被代理, 而父容器中的对象不会被代理

    @Configuration
    @EnableAspectJAutoProxy
    public class FooConfig {
        @Bean
        public FooService fooService1() {
            return new FooService("foo");
        }
    
        @Bean
        public FooService fooService2() {
            return new FooService("foo");
        }
    
    //    @Bean
    //    public FooAspect fooAspect() {
    //        return new FooAspect();
    //    }
    }
    
    
    <beans xmlns="http://www.springframework.org/schema/beans"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xmlns:aop="http://www.springframework.org/schema/aop"
           xsi:schemaLocation="http://www.springframework.org/schema/beans
            http://www.springframework.org/schema/beans/spring-beans.xsd
            http://www.springframework.org/schema/aop
            http://www.springframework.org/schema/aop/spring-aop.xsd">
    
        <aop:aspectj-autoproxy/>
    
        <bean id="fooService1" class="com.passnight.springboot.mvc.service.FooService">
            <constructor-arg name="context" value="Bar"/>
        </bean>
    
        <bean id="fooAspect" class="com.passnight.springboot.mvc.acpect.FooAspect"/>
    </beans>
    
    2024-03-21 22:47:41.092 INFO  [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello foo
    2024-03-21 22:47:41.095 INFO  [main] com.passnight.springboot.mvc.aop.FooAspectTest#[parentContextTest:21] - ====================================================================================================
    2024-03-21 22:47:41.267 INFO  [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello Bar
    2024-03-21 22:47:41.269 INFO  [main] com.passnight.springboot.mvc.acpect.FooAspect#[printAfter:12] - Enhanced By AOP
    2024-03-21 22:47:41.269 INFO  [main] com.passnight.springboot.mvc.service.FooService#[hello:12] - hello foo
    

Spring MVC

  1. 核心组件:

    1. DispatcherServlet: SpringMVC的入口
    2. ViewResolver: 视图解析器
    3. HandlerExceptionResolver: 异常解析器
    4. MultipartResolver: MultipartFile解析
    5. HandlerMapping: 请求映射器
    6. Controller: 请求控制器
  2. 常用注解

    注解功能
    @Controller / @RestController标注一个类是控制器
    @RequestMapping, @GetMapping, @PutMapping, @DeleteMappingUrl映射器
    @RequestBody, @PathVariable,@RequestParam, @RequesHeader, @HttpEntity请求参数
    @ResponseBody, @ResponseStatus @ResponseEntity响应体/响应码

请求处理

基本使用

  1. 编写实体类和服务类

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    @Accessors(chain = true)
    public class Coffee implements Serializable {
    
        private String id;
    
        private String name;
    
        private Money price;
    
        private Date createTime;
    
        private Date updateTime;
    }
    
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class CoffeeOrder implements Serializable {
    
        private Long id;
    
        private String customer;
    
        private List<Coffee> coffees;
    
        private OrderStatus state;
    
        private Date createTime;
    
        private Date updateTime;
    
        public enum OrderStatus {
            INIT, PAID, BREWING, BREWED, TAKEN, CANCELLED
        }
    }
    @Service
    public class CoffeeOrderService {
        public final static List<CoffeeOrder> coffeeOrderRepository = new ArrayList<>();
    
        public CoffeeOrder createOrder(String customerName, List<Coffee> coffees) {
            CoffeeOrder coffeeOrder = CoffeeOrder.builder()
                    .coffees(coffees)
                    .customer(customerName)
                    .build();
            coffeeOrderRepository.add(coffeeOrder);
            return coffeeOrder;
        }
    }
    @Service
    public class CoffeeService {
    
        public final static List<Coffee> coffeeRepository = Arrays.asList(
                Coffee.builder()
                        .name("Controller-Coffee1")
                        .price(Money.of(CurrencyUnit.of("CNY"), 20.0))
                        .createTime(new Date())
                        .updateTime(new Date())
                        .build(),
                Coffee.builder()
                        .name("Controller-Coffee2")
                        .price(Money.of(CurrencyUnit.of("CNY"), 10.0))
                        .createTime(new Date())
                        .updateTime(new Date())
                        .build());
    
        public List<Coffee> findCoffees() {
            return Collections.unmodifiableList(coffeeRepository);
        }
    
        public List<Coffee> findCoffeeByNamContain(String coffeeName) {
            return coffeeRepository.stream()
                    .filter(coffee -> Optional.ofNullable(coffee).map(Coffee::getName).orElse("").contains(coffeeName))
                    .collect(Collectors.toList());
        }
    }
    
  2. 注意转换Money到Json需要添加对应的转换器

    @Configuration
    public class JacksonConfig {
        @Bean
        public ObjectMapper objectMapper() {
            return new ObjectMapper()
                    .registerModule(new JodaMoneyModule());
        }
    }
    
            <dependency>
                <groupId>com.fasterxml.jackson.datatype</groupId>
                <artifactId>jackson-datatype-joda-money</artifactId>
                <version>2.11.0</version>
            </dependency>
    
  3. 通过RestControllerRequestMaping标注请求控制器; 这里使用RequestBody标注请求体参数

    @RestController
    @RequestMapping("/coffee")
    @RequiredArgsConstructor
    public class CoffeeController {
        private final CoffeeService coffeeService;
    
        @GetMapping("/")
        public List<Coffee> getAll() {
            return coffeeService.findCoffees();
        }
    }
    @Log4j2
    @RestController
    @RequestMapping("/order")
    @RequiredArgsConstructor
    public class CoffeeOrderController {
        private final CoffeeService coffeeService;
        private final CoffeeOrderService coffeeOrderService;
    
        @PostMapping("/")
        @ResponseStatus(HttpStatus.CREATED)
        public CoffeeOrder create(@RequestBody NewOrderRequest newOrder) {
            log.info("Receive new order {}", newOrder);
            List<Coffee> coffees = coffeeService.findCoffeeByNamContain(newOrder.getCoffee());
            return coffeeOrderService.createOrder(newOrder.getCustomer(), coffees);
        }
    }
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class NewOrderRequest {
        String customer;
        String coffee;
    }
    
    
  4. 之后通过MockMvc进行请求测试

请求处理机制

在这里插入图片描述

视图解析
  1. SpringMVC中的视图解析主要是通过ViewResolverView接口实现的, 主要包括
    1. AbstractCachingViewResolver: 基于缓存的View Resolver的基类
    2. UrlBasedViewResolver
    3. FreeMarkerViewResolver: 用于解析free marker框架的视图解析器
    4. ContentNegotiatingViewResolver: 根据返回类型解析的视图解析器 如会转发接收xml和接收json的请求到不同的视图解析器
    5. InternalResourceViewResolver: 默认最后的用于解析JSP/JSTL的解析器
  2. ResponseBody视图解析
    1. HandlerAdapter中的handle()中完成Reponse的输出
    2. 之后不走ViewResolver而是直接创建输出流并将内容写到流当中
  3. 重定向视图redirectforward

类型转换

  1. Spring的类型转换主要是通过ConverterFormatter来实现的, 因此要实现自定义类型转换可以通过在SpringBoot 的WebMvcAutoConfiguration中添加自定义的Converter和自定义的Formatter来实现

  2. SpringBoot默认的配置如下

    // WebMvcAutoConfiguration
    @Override
    		public void addFormatters(FormatterRegistry registry) {
    			ApplicationConversionService.addBeans(registry, this.beanFactory);
    		}
    // ApplicationConversionService
    	public static void addBeans(FormatterRegistry registry, ListableBeanFactory beanFactory) {
    		Set<Object> beans = new LinkedHashSet<>();
    		beans.addAll(beanFactory.getBeansOfType(GenericConverter.class).values());
    		beans.addAll(beanFactory.getBeansOfType(Converter.class).values());
    		beans.addAll(beanFactory.getBeansOfType(Printer.class).values());
    		beans.addAll(beanFactory.getBeansOfType(Parser.class).values());
    		for (Object bean : beans) {
    			if (bean instanceof GenericConverter) {
    				registry.addConverter((GenericConverter) bean);
    			}
    			else if (bean instanceof Converter) {
    				registry.addConverter((Converter<?, ?>) bean);
    			}
    			else if (bean instanceof Formatter) {
    				registry.addFormatter((Formatter<?>) bean);
    			}
    			else if (bean instanceof Printer) {
    				registry.addPrinter((Printer<?>) bean);
    			}
    			else if (bean instanceof Parser) {
    				registry.addParser((Parser<?>) bean);
    			}
    		}
    	}
    
  3. 添加自定义类型转换首先要添加一个Formatter

    @Component
    public class MoneyFormatter implements Formatter<Money> {
        @Override
        @NonNull
        public Money parse(@NonNull String text, @NonNull Locale locale) throws ParseException {
            if (NumberUtil.isNumber(text)) {
                return Money.of(CurrencyUnit.of("CNY"), new BigDecimal(text));
            } else if (StrUtil.isAllNotBlank(text)) {
                String[] split = text.split(" ");
                Assert.isTrue(split.length == 2 && NumberUtil.isNumber(split[1]), () -> new ParseException(text, 0));
                return Money.of(CurrencyUnit.of(split[0]), new BigDecimal(split[1]));
            }
            throw new ParseException(text, 0);
        }
    
        @NonNull
        @Override
        public String print(@NonNull Money money, @NonNull Locale locale) {
            return String.format(Locale.ROOT, "%s %s", money.getCurrencyUnit().getCode(), money.getAmount());
        }
    }
    
  4. 然后在在WebMvcConfigurer中添加该配置

    @Configuration
    @RequiredArgsConstructor
    public class WebMvcConfig implements WebMvcConfigurer {
        private final MoneyFormatter moneyFormatter;
    
        @Override
        public void addFormatters(FormatterRegistry registry) {
            registry.addFormatter(moneyFormatter);
        }
    }
    
  5. 之后Money类型的数据就可以正常转换了, 测试用例同上

校验

  1. SpringBoot通过Validator对绑定的结果进行校验, 如Hibernate Validator, 然后添加@Valid注解标注需要校验的类

  2. 首先在实体类上添加校验规则

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class NewOrderRequest {
        @NotEmpty
        String customer;
        @NotNull
        String coffee;
    }
    
  3. 然后在对应的接口上添加@Valid启动校验

        @PostMapping("/")
        @ResponseStatus(HttpStatus.CREATED)
        public CoffeeOrder create(@Valid @RequestBody NewOrderRequest newOrder) {
            log.info("Receive new order {}", newOrder);
            List<Coffee> coffees = coffeeService.findCoffeeByNamContain(newOrder.getCoffee());
            return coffeeOrderService.createOrder(newOrder.getCustomer(), coffees);
        }
    
  4. 之后未通过校验的请求都返回400

        @Test
        public void createInvalidOrderTest() throws Exception {
            mockMvc.perform(MockMvcRequestBuilders.post("/order/")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsBytes(NewOrderRequest.builder()
                                    // Null Coffee
                                    .customer("Customer1")
                                    .build())))
                    .andExpect(MockMvcResultMatchers.status().isBadRequest())
                    .andReturn()
                    .getResponse()
                    .getContentAsString();
    
            mockMvc.perform(MockMvcRequestBuilders.post("/order/")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsBytes(NewOrderRequest.builder()
                                    .coffee("Coffee1")
                                    .customer("") // Empty Customer
                                    .build())))
                    .andExpect(MockMvcResultMatchers.status().isBadRequest())
                    .andReturn()
                    .getResponse()
                    .getContentAsString();
        }
    

文件上传

  1. Multipart上传是通过MultipartResolver实现的MultipartAutoConfiguration中配置, 支持multipart/form-data(MultipartFile)类型

  2. 定义一个接口, 接收MultipartFile类型, 这里设置consumes = MediaType.MULTIPART_FORM_DATA_VALUE只是为了区分其他格式的参数

        @PostMapping(value = "/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
        @ResponseStatus(HttpStatus.CREATED)
        public CoffeeOrder importOrders(@RequestParam("file") MultipartFile file) throws IOException {
            if (file.isEmpty()) {
                return null;
            }
            NewOrderRequest newOrder = objectMapper.readValue(file.getBytes(), NewOrderRequest.class);
            return coffeeOrderService.createOrder(newOrder.getCustomer(), coffeeService.findCoffeeByNamContain(newOrder.getCoffee()));
        }
    
  3. 然后就可以上传文件了, 注意请求的文件名要和RequestParameter的对应上

        @Test
        public void importTest() throws Exception {
            CoffeeOrder expected = CoffeeOrder.builder()
                    .customer("Customer1")
                    .coffees(CoffeeService.coffeeRepository.subList(0, 1))
                    .build();
    
            String response = mockMvc.perform(MockMvcRequestBuilders.multipart("/order/")
                            .file("file", objectMapper.writeValueAsBytes(NewOrderRequest.builder()
                                    .coffee("Coffee1")
                                    .customer("Customer1")
                                    .build())))
                    .andExpect(MockMvcResultMatchers.status().isCreated())
                    .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON))
                    .andReturn()
                    .getResponse()
                    .getContentAsString();
            CoffeeOrder actual = objectMapper.readValue(response, CoffeeOrder.class);
            Assertions.assertEquals(expected, actual);
        }
    

静态资源及缓存

  1. 静态资源的配置可以通过WebMvcConfigurer.addResourcehandlers()来实现

  2. 具体可以通过以下配置

    1. spring.mvc.static-path-pattern=/**添加静态资源路径模式 默认从根路径下开始匹配
    2. spring.resource.static-locations=classpath:/META-INF/resources/classpath:/resources/,classpath:/datic/,classpath:/public/添加静态资源路径
  3. 缓存相关的配置是在ResouceProperties.Cache中配置的, 主要包含以下内容

    1. spring.resources.cache.cachecontrol.max-age来配置最大缓存时间
    2. spirng.resource.cache.cachecontrol.no-cache=true/false来开启/关闭缓存
    3. spring.resources.cache.cachecontrol.s-max-age=来配置共享缓存的缓存时间 一个是cache, 一个是cached by shared caches
  4. 首先在resources/static下添加一个静态资源

    ls src/main/resources/static/
    img1.png
    
  5. 然后再application.properties中配置静态资源路径及缓存时间

    spring.mvc.static-path-pattern=/static/**
    spring.resources.cache.cachecontrol.max-age=20s
    
  6. 然后可以在请求头中看到max-age=20的缓存字段, 并且请求结果是一张图片; 并且第二次请求返回了304

        @Test
        public void staticImageTest() throws Exception {
            String lastModifyTime = mockMvc.perform(MockMvcRequestBuilders.get("/static/img1.png"))
                    .andExpect(MockMvcResultMatchers.status().isOk())
                    .andExpect(MockMvcResultMatchers.header().string(HttpHeaders.CACHE_CONTROL, "max-age=20"))
                    .andExpect(MockMvcResultMatchers.content().contentType(MediaType.IMAGE_PNG))
                    .andReturn()
                    .getResponse()
                    .getHeader(HttpHeaders.LAST_MODIFIED);
    
            mockMvc.perform(MockMvcRequestBuilders.get("/static/img1.png")
                            .header(HttpHeaders.IF_MODIFIED_SINCE, lastModifyTime))
                    .andExpect(MockMvcResultMatchers.status().isNotModified());
        }
    

异常处理

  1. SpringMvc中主要是通过HandlerExceptionResolver处理的, 它有以下几个主要的实现类
    1. SimpleMappingExceptionResolver:
    2. DefaultHandlerExceptionResolver: 默认实现, 用于将SpringMVC的异常转化为http状态码
    3. ResponseStatusExceptionResolver: 处理带有ResponseStatus注解的异常, 可以在异常类上添加该注解指定http状态码
    4. ExceptionHandlerExceptionResolver: 若异常被标注了@ExceptionHandler的方法处理, 会走该解析器
  2. 自定义异常处理方法主要通过@ExceptionHandler注解标注, 可以添加在
    1. Controller下: @Controller/@RestController
    2. 或ControllerAdvice下: @ControllerAdvice/@RestControllerAdvice 注意在Advice下的处理器优先级低于在Controller下的处理器
  3. spring添加异常处理有两个方式: 一个是在异常上添加@ResponseStatus, 这样Spring就会自动将该异常映射到对应的http状态码; 另外一个是添加@ExceptionHandler在方法上定义异常处理逻辑
使用状态码标注异常
  1. 定义异常, 映射到Http 400状态码

    @Getter
    @AllArgsConstructor
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public class MyBadRequestException extends RuntimeException {
        String request;
    }
    
  2. 添加一个接口抛出该异常

        @GetMapping("/bad-request")
        public String badRequest() {
            throw new MyBadRequestException("Bad Request in HelloController.badRequest");
        }
    
  3. 请求该路径, 返回htt400

        @Test
        public void customerExceptionWithHttpStatusTest() throws Exception {
            mockMvc.perform(MockMvcRequestBuilders.get("/HelloController/bad-request"))
                    .andExpect(MockMvcResultMatchers.status().isBadRequest());
        }
    
使用Advice处理异常
  1. 定义一个异常类

    public class MyInternalServerException extends RuntimeException {
    }
    
  2. 定义一个异常处理拦截器, 用@ExceptionHandler标注处理的异常, @ResponseStatus标注对应的状态码, 并在方法体内添加处理逻辑

    @RestControllerAdvice
    public class GlobalControllerAdvice {
        @ExceptionHandler(MyInternalServerException.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public String internalServerExceptionHandler(MyInternalServerException e) {
            return "MyInternalServerException Handler By GlobalControllerAdvice.internalServerExceptionHandler()";
        }
    }
    
  3. 声明一个接口抛出该异常

        @GetMapping("/internal-server-error-request")
        public String internalServerErrorRequest() {
            throw new MyInternalServerException();
        }
    
  4. 返回体会包含500状态码及Advice中定义的内容

    @Test
    public void controllerAdviceExceptionHandlerTest() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/HelloController/internal-server-error-request"))
            .andExpect(MockMvcResultMatchers.status().isInternalServerError())
            .andExpect(MockMvcResultMatchers.content().string("MyInternalServerException Handler By GlobalControllerAdvice.internalServerExceptionHandler()"));
    }
    

SpringMVC 拦截器

  1. SpringMVC的拦截器主要是通过HandlerInterceptor实现的

    public interface HandlerInterceptor {
    	// 进入执行器前做预处理, 返回值表明是否会进入下一步
        // 比如说可以在这里做权限验证, 有权限返回true
    	default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
    			throws Exception {
    
    		return true;
    	}
        
    	// 在视图呈现前执行
    	default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
    			@Nullable ModelAndView modelAndView) throws Exception {
    	}
    
        // 在视图呈现后执行
    	default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
    			@Nullable Exception ex) throws Exception {
    	}
    
    }
    
  2. 针对返回@ReponseBOdyResponseEntity的情况, Spring提供了ResponseBodyAdvice拦截

  3. 针对异步请求的接口, Spring也提供了类似的AsyncHandlerInterceptor

  4. 之后可以通过WebMvcConfigurer.addInterceptors()显示添加

基本使用
  1. 首先定义一个Interceptor; 用于统计MVC请求时间

    @Log4j2
    @Component
    public class PerformanceInterceptor implements HandlerInterceptor {
        private ThreadLocal<StopWatch> stopWatch = new ThreadLocal<>();
    
    
        @Override
        public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception {
            StopWatch watch = new StopWatch();
            stopWatch.set(watch);
            watch.start();
            return true;
        }
    
        @Override
        public void postHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, ModelAndView modelAndView) throws Exception {
            stopWatch.get().stop();
            stopWatch.get().start();
        }
    
        @Override
        public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, Object handler, Exception ex) throws Exception {
            StopWatch watch = stopWatch.get();
            watch.stop();
            String method = handler.getClass().getSimpleName();
            if (handler instanceof HandlerMethod) {
                String beanType = ((HandlerMethod) handler).getBeanType().getName();
                String methodName = ((HandlerMethod) handler).getMethod().getName();
                method = String.format(Locale.ROOT, "%s.%s", beanType, methodName);
            }
            log.info("{};{};{};{};{}ms;{}ms;{}ms", request.getRequestURI(),
                    method,
                    response.getStatus(),
                    Objects.isNull(ex) ? "-" : ex.getClass().getSimpleName(),
                    watch.getTotalTimeMillis(),
                    watch.getTotalTimeMillis() - watch.getLastTaskTimeMillis(),
                    watch.getLastTaskTimeMillis());
            stopWatch.remove();
        }
    }
    
  2. WebMvcConfigurer中配置该拦截器

    @Configuration
    @RequiredArgsConstructor
    public class WebMvcConfig implements WebMvcConfigurer {
        private final MoneyFormatter moneyFormatter;
        private final PerformanceInterceptor performanceInterceptor;
    
        @Override
        public void addFormatters(FormatterRegistry registry) {
            registry.addFormatter(moneyFormatter);
        }
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(performanceInterceptor);
        }
    }
    
  3. 然后执行任意一个端点, 就可以看到对应的日志了

资源访问

  1. Spring主要通过RestTemplateWebClient实现对web资源的访问

  2. Spring中没有为我们提供自动装配好的RestTemplate, 我们需要通过RestTemplateBuilder来创建, 它主要包含了以下几个类别的方法

    功能方法
    GET请求getForObject(), getForEntity()
    POST请求postForObject(), postForEntity()
    PUT请求put()
    DELETE请求delete()
    请求时带上http请求头exchange()/RequestEntity/ReponseEntity
    类型转换JsonSerializer/JsonDeserializer/@JsonComponent
    解析泛型对象exchange() + ParameterizedTypeReference<T>
  3. Spring在RestTemplateBuilder中配置了开箱即用的Converter, Customizer等组件

  4. RestTemplate中可能会遇到相对路径/URL参数等情况, 此时手写URL非常不方便, 因此Spring为我们提供了以下几个组件拼接URI

    1. UriComponentsBuilder: 构造URI
    2. ServletUriComponentsBuilder: 构造相对于当前请求的URI
    3. MvcUriComponentsBuilder: 构造指向Controller的URI
  5. 尽管使用UriBuilder构建URL已经非常方便, 但有的时候我们还需要保留URL的相对位置, 这个时候我们就可以使用UriBuilderFactory来构建UriBuilder; 它的默认实现是DefaultUriBuilderFactory

基本使用

  1. 因为主要使用SpringMVC实现web请求, 因此不需要启动web服务器, 这里可以通过WebApplicationType.NONE来指定

    @SpringBootApplication
    public class WebClientApplication {
    
        public static void main(String[] args) {
            new SpringApplicationBuilder()
                    .sources(WebClientApplication.class)
                    .bannerMode(Banner.Mode.OFF)
                    .web(WebApplicationType.NONE) // 不启动web容器
                    .run(args);
        }
    }
    
  2. 之后再配置项目的RestTemplate

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
        return restTemplateBuilder.build();
    }
    
  3. 然后就可以通过RestTemplate访问资源了, 这里以访问baidu.com为例

    public String ping() {
        URI uri = UriComponentsBuilder.fromUriString("https://baidu.com").build("");
        return restTemplate.getForEntity(uri, String.class).getBody();
    }
    
  4. 测试是否能够正常访问

    @Log4j2
    @SpringBootTest
    public class BaiduClientTest {
        @Autowired
        private BaiduClient baiduClient;
    
        @Test
        public void pingTest() {
            ResponseEntity<String> response = baiduClient.ping();
            Assertions.assertEquals(HttpStatus.FOUND, response.getStatusCode());
            Assertions.assertNotNull(response.getBody());
            log.debug(response.getBody());
        }
    }
    

自定义序列化器

  1. Coffee中的Money是复杂类型, 因此需要自定义序列化器才能序列化

  2. 这里使用的是jackson-datatype-joda-money实现的

    <dependency>
        <groupId>com.fasterxml.jackson.datatype</groupId>
        <artifactId>jackson-datatype-joda-money</artifactId>
        <version>2.11.0</version>
    </dependency>
    
  3. 它主要是定义了StdDeserializer<Money>JodaMoneySerializerBase<T> extends StdSerializer<T>然后打包为模块

    public class JodaMoneyModule extends Module
        implements java.io.Serializable
    {
        private static final long serialVersionUID = 1L;
    
        public JodaMoneyModule() { }
    
        @Override
        public String getModuleName() {
            return getClass().getName();
        }
    
        @Override
        public Version version() {
            return PackageVersion.VERSION;
        }
    
        @Override
        public void setupModule(SetupContext context)
        {
            final SimpleDeserializers desers = new SimpleDeserializers();
            desers.addDeserializer(CurrencyUnit.class, new CurrencyUnitDeserializer());
            desers.addDeserializer(Money.class, new MoneyDeserializer());
            context.addDeserializers(desers);
    
            final SimpleSerializers sers = new SimpleSerializers();
            sers.addSerializer(CurrencyUnit.class, new CurrencyUnitSerializer());
            sers.addSerializer(Money.class, new MoneySerializer());
            context.addSerializers(sers);
        }
    }
    
  4. 再注册到jackson中实现的

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new JodaMoneyModule());
    }
    
  5. 之后就可以照常创建Client类

    public ResponseEntity<Coffee> getById() {
    
        URI uri = UriComponentsBuilder
            .fromUriString("http://localhost:8080/springboot/mvc/coffee/{id}")
            .build(1);
        return restTemplate.getForEntity(uri, Coffee.class);
    }
    
  6. 并请求

    @Test
    public void getByIdTest() {
        ResponseEntity<Coffee> coffee = customerClient.getById();
        Assertions.assertEquals(HttpStatus.OK, coffee.getStatusCode());
        Assertions.assertEquals(MediaType.APPLICATION_JSON, coffee.getHeaders().getContentType());
        log.info(coffee.getBody());
    }
    

定制RestTemplate

  1. 定制底层的http库: RestTemplate通过ClientHttpRequestFactory创建请求, 主要有以下几种方式
    1. SimpleClientHttpRequestFactory: 默认使用的, 底层基于jdk自带的网络库
    2. HttpComponentsClientHttpRequestFactory: Apache HttpComponents
    3. Netty4ClientHttpRequestFactory: Netty
    4. OkHttp3ClientHttpRequestFactory: okhttp
  2. 连接管理:
    1. 连接池配置; PoolingHttpClientConnectionmanager
    2. KeepAlive策略
  3. 超时设置
    1. connectTimeout/readTimeout
  4. SSL校验
    1. 证书检查策略
使用Apache连接库代替jdk自带的连接库
  1. 引入对应的依赖

    <dependency>
        <groupId>org.apache.httpcomponents.client5</groupId>
        <artifactId>httpclient5</artifactId>
        <version>5.3.1</version>
    </dependency>
    
  2. 配置Keep-Alive时间

    @Configuration
    public class ConnectionKeepAliveStrategy implements org.apache.http.conn.ConnectionKeepAliveStrategy {
    
        @Override
        public long getKeepAliveDuration(HttpResponse response, HttpContext context) {
            return 10_000;
        }
    }
    
  3. 配置RequestFactory

    @Configuration
    public class HttpRequestFactoryConfiguration {
    
        @Bean
        public HttpComponentsClientHttpRequestFactory httpComponentsClientHttpRequestFactory(
                ConnectionKeepAliveStrategy connectionKeepAliveStrategy) {
            PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
            connectionManager.setMaxTotal(200);
            connectionManager.setDefaultMaxPerRoute(20);
            HttpClient httpClient = HttpClients.custom()
                    .setConnectionManager(connectionManager)
                    .evictIdleConnections(30, TimeUnit.SECONDS)
                    .disableAutomaticRetries()
                    .setKeepAliveStrategy(connectionKeepAliveStrategy)
                    .build();
            return new HttpComponentsClientHttpRequestFactory(httpClient);
        }
    }
    
  4. 之后就可以使用Apache提供的连接发起http请求

Rest 规范

  1. http方法分类

    动作安全幂等用途
    GET获取信息
    POST用途广泛, 可用于创建/更新/批量修改
    DELETE删除资源
    PUT更新或替换资源
    HEAD获取与GET一样的HTTP头信息, 但没有响应体
    OPTIONS获取资源支持的http方法列表
    TRACE让服务器返回其收到的http头
  2. 以咖啡为例

    URIHTTP方法含义
    /coffee/GET获取全部咖啡信息
    /coffee/POST添加新的咖啡信息
    /coffee/{id}GET获取特定咖啡信息
    /coffee/{id}DELETE删除特定咖啡信息
    /coffee/{id}PUT修改特定咖啡信息

HATEOAS

  1. HATEOAS: Hybermedia As The Engine Of Application State; 是REST统一接口的必要组成部分

  2. 常用的超链接类型 IANA协议规范中常用的超链接类型

    引用描述
    self指向当前资源本身的链接
    edit指向一个可以编辑当前资源的链接
    collection如果当前资源包含在某个集合当中, 指向该集合的链接
    search只想一个可以搜索当前资源与其相关资源的链接
    related指向一个与当前资源相关的链接
    first集合遍历相关的类型, 指向第一个资源的链接
    last集合遍历相关的类型, 指向最后一个资源的链接
    previous集合遍历相关的类型, 指向上一个资源的链接
    next集合遍历相关的类型, 指向下一个资源的链接

HAL

  1. HAL(Hypertext Application Language): 是一种简单的格式, 为API中的资源提供简单一致的链接
  2. HAL主要包含以下几个部分:
    1. 链接
    2. 内嵌资源
    3. 状态

Spirng Data Rest

  1. SpringBoot中有一个依赖spring-boot-starter-data-rest, 可以将常用的Repository转化为Rest接口

  2. 其中有以下常用的类和注解

    类/注解功能
    @RepositoryRestResource将Repository转化为Rest接口
    Resource<T>T类型的资源
    PagedResource<T>分页的T类型的资源
基本使用
  1. 类似于Spring data jpa的基本使用, 将注解从@Repository换成@RepositoryRestResource(path = "coffee")并添加path参数之后就可以自动生成对应的rest接口

    @RepositoryRestResource(path = "coffee")
    public interface CoffeeRepository extends JpaRepository<Coffee, Long> {
        List<Coffee> findByName(String name);
    }
    
  2. 生成的Rest接口可以通过访问根路径看到

    @Log4j2
    @SpringBootTest
    @AutoConfigureMockMvc
    public class SpringDataJpaRestTest {
        @Autowired
        private MockMvc mockMvc;
    
        @Test
        public void getLinksTest() throws Exception {
            String response = mockMvc.perform(MockMvcRequestBuilders.get("/"))
                    .andReturn()
                    .getResponse()
                    .getContentAsString();
            Assertions.assertTrue(response.contains("\"href\" : \"http://localhost/profile\""));
            Assertions.assertTrue(response.contains("\"href\" : \"http://localhost/coffee{?page,size,sort}\""));
        }
    }
    
  3. 其值包含了一个profile及相关资源的访问

    {
        "_links" : {
            "coffees" : {
                "href" : "http://localhost/coffee{?page,size,sort}",
                "templated" : true
            },
            "profile" : {
                "href" : "http://localhost/profile"
            }
        }
    }
    
  4. 类似的, 在访问资源的根路径也可以获得所有的资源的信息及相关的元数据

    @Test
    public void findByNameTest() throws Exception {
        String response = mockMvc.perform(MockMvcRequestBuilders.get("/coffee"))
            .andReturn()
            .getResponse()
            .getContentAsString();
        log.info(response);
    }
    
  5. 返回包含所有的咖啡以及咖啡访问相关的配置

    {
        "_embedded": {
            "coffees": [
                {
                    "name": "Coffee-Name",
                    "price": {
                        "zero": false,
                        "negative": false,
                        "positive": true,
                        "amount": 1000.00,
                        "amountMajor": 1000,
                        "amountMajorLong": 1000,
                        "amountMajorInt": 1000,
                        "amountMinor": 100000,
                        "amountMinorLong": 100000,
                        "amountMinorInt": 100000,
                        "minorPart": 0,
                        "positiveOrZero": true,
                        "negativeOrZero": false,
                        "currencyUnit": {
                            "code": "CNY",
                            "numericCode": 156,
                            "decimalPlaces": 2,
                            "symbol": "CNÂ¥",
                            "numeric3Code": "156",
                            "countryCodes": [
                                "CN"
                            ],
                            "pseudoCurrency": false
                        },
                        "scale": 2
                    },
                    "createTime": "2024-03-10T13:26:02.000+00:00",
                    "updateTime": "2024-03-10T13:26:02.000+00:00",
                    "_links": {
                        "self": {
                            "href": "http://localhost/coffee/1"
                        },
                        "coffee": {
                            "href": "http://localhost/coffee/1"
                        }
                    }
                },
                {
                    "name": "Coffee-Name",
                    "price": {
                        "zero": false,
                        "negative": false,
                        "positive": true,
                        "amount": 1000.00,
                        "amountMajor": 1000,
                        "amountMajorLong": 1000,
                        "amountMajorInt": 1000,
                        "amountMinor": 100000,
                        "amountMinorLong": 100000,
                        "amountMinorInt": 100000,
                        "minorPart": 0,
                        "positiveOrZero": true,
                        "negativeOrZero": false,
                        "currencyUnit": {
                            "code": "CNY",
                            "numericCode": 156,
                            "decimalPlaces": 2,
                            "symbol": "CNÂ¥",
                            "numeric3Code": "156",
                            "countryCodes": [
                                "CN"
                            ],
                            "pseudoCurrency": false
                        },
                        "scale": 2
                    },
                    "createTime": "2024-03-10T13:27:40.000+00:00",
                    "updateTime": "2024-03-10T13:27:40.000+00:00",
                    "_links": {
                        "self": {
                            "href": "http://localhost/coffee/2"
                        },
                        "coffee": {
                            "href": "http://localhost/coffee/2"
                        }
                    }
                }
            ]
        },
        "_links": {
            "self": {
                "href": "http://localhost/coffee"
            },
            "profile": {
                "href": "http://localhost/profile/coffee"
            },
            "search": {
                "href": "http://localhost/coffee/search"
            }
        },
        "page": {
            "size": 20,
            "totalElements": 9,
            "totalPages": 1,
            "number": 0
        }
    }
    
  6. 也可以直接在query parameter上面添加参数, 作查询; 下面根据id降序排序, 并取第1页的三个元素

        @Test
        public void findPageTest() throws Exception {
            String response = mockMvc.perform(MockMvcRequestBuilders.get("/coffee")
                            .queryParam("page", "1")
                            .queryParam("size", "3")
                            .queryParam("sort", "id,dec"))
                    .andReturn()
                    .getResponse()
                    .getContentAsString();
            log.info(response);
        }
    
  7. 方法拼接到路径+search上之后就可以直接通过URL调用查询; 下面就调用了CoffeeRepository.findByName()查询所有咖啡名为Coffee-Name的咖啡

        @Test
        public void findByNameTest() throws Exception {
            String response = mockMvc.perform(MockMvcRequestBuilders.get("/coffee/search/findByName")
                            .queryParam("name", "Coffee-Name"))
                    .andReturn()
                    .getResponse()
                    .getContentAsString();
            log.info(response);
        }
    

会话管理

  1. 对于分布式环境中, 请求由不同的机器完成, 因此需要保持统一, 常见的解决方案有
    1. 粘性会话: Load Balancer将会话转发到同一台机器上, 但若服务器下线则原先的请求被分配到其他机器, 会话就会失效
    2. 会话复制: 将集群中的机器会话都复制一份, 这样不论请求那一台服务器, 都由一样的会话, 但复制存在延迟且有资源消耗
    3. 集中会话: 将会话集中存储在中间件当中, 通过session id获取会话信息
  2. Spring Session则是Spring为我们提供的管理会话的组件, 它主要有以下功能:
    1. 简化集群中的用户会话管理
    2. 无需绑定容器特定解决方案
    3. 支持多种存储, 如Redis, MongoDB, JDBC等
  3. Spring Session的实现原理: Spring是通过定制HttpServletRequest和HttpSession来实现的, 主要包含以下几个组件
    1. SessionRepositoryRequestWrapper: 代理后的Request
    2. SessionRespositoryFilter: 代理Request和Response以支持Spring Session
    3. DelegatingFilterProxy

配置应用容器

  1. SpringBoot不仅仅支持Tomcat容器, 还支持其他容器, 可选的依赖有
    1. spring-boot-starter-tomcat
    2. spring-boot-starter-jetty
    3. spring-boot-starter-undertow
    4. spring-boot-starter-reactor-netty
  2. 容器的配置主要包含以下配置
    1. 最基本的配置则是端口和地址的配置, 他们可以通过以下配置项配置
      1. server.port: 配置端口
      2. server.address: 配置地址
    2. 除了端口地址之外, 还由压缩相关的配置
      1. server.compression.enable: 开启压缩
      2. server.compression.min-response-size=2k: 最小要压缩的大小
      3. server.compression.mime-types: 要压缩默认的类型
    3. Tomcat专属配置
      1. server.tomcat.max-connections=10000: 最大连接数
      2. server.tomcat.max-http-post-size=2MB: 最大http post请求大小
      3. server.tomcat.max-swallow-size=2MB: Tomcat在分批请求时最大能缓存的文件大小3
      4. server.tomcat.max-threads=200: 最大线程数
      5. server.tomcat.min-spare-threads=10: 最小空闲线程数
    4. 错误处理相关配置
      1. server.error.path=/error: 异常路径
      2. server.error.include-exception=false: 是否在错误页面显示异常信息
      3. server.error.include-stacktrace=never: 是否在错误页面上打印调用栈
      4. server.error.whitelabel.enabled=true: 是否使用SpringBoot默认的错误页面
    5. ssl相关配置
      1. server.ssl.key-store: 证书位置
      2. server.ssl.key-store-type: 证书类型
      3. server.ssl.key-store-password: 证书密码
    6. 其他配置
      1. server.use-forward-headers: 是否在转发之后将信息保存在头中 反向代理后可以获得真实源ip
      2. server.servlet.session.timeout: session超时时间
  3. 修改配置主要通过以下类实现WebServerFactoryCustomizer, 对Tomcat/Jetty/Undertow对应的配置类分别是TomcatServletWebServerFactory/JettyServletWebServerFactory/UndertowServletWebServerFactory

基本配置

  1. SpringBoot可以通过实现TomcatServletWebServerFactory或在application.properties中添加配置的方式来实现对Tomcat容器的配置

  2. 最简单地方式是直接修改配置文件

    server.compression.min-response-size=512B
    server.compression.enabled=true
    
  3. 其次还可以通过实现WebServerFactoryCustomizer达到修改的目的

    @Configuration
    public class TomcatConfig implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
        @Override
        public void customize(TomcatServletWebServerFactory factory) {
            Compression compression = new Compression();
            compression.setEnabled(true);
            compression.setMinResponseSize(DataSize.ofBytes(512));
            factory.setCompression(compression);
        }
    }
    

Spring Boot

  1. SpringBoot的四大核心
    1. Auto Configuration
    2. Starter Dependency
    3. Spring Boot CLI
    4. Actuator

Auto Configuration

  1. 自动装配: 指的是Spring有基于添加JAR依赖自动对SpringBoot程序配置的功能, 其主要包含在spring-boot-autoconfiguration

  2. 开启自动装配

    1. @EnableautoConfiguration/@SpringBootApplication: 开启自动配置 后者包含了前者
    2. 添加exclude=Class<?>[]等参数以排除/包括某些自动装配类
  3. 自动配置的实现原理

    1. 通过@EnableAutoConfiguration启动, 它会自动启动AutoConfigurationImportSelector, 它会自动加载META-INFO/spring.factories下的配置文件

    2. 常用的配置注解

      类别注解功能
      条件注解@Conditional根据条件后才自动装配
      类条件注解@ConditionalOnClass, @ConditionOnMissionClass当存在或不存在某个类才装配
      web应用条件注解@ConditionOnWebApplication, @ConditionalOnNotWebApplication在web环境下载状态
      属性条件注解@ConditionOnProperty特定的属性值为目标值
      Bean条件注解@ConditionalOnBean, @ConditionalOnMissingBean, @ConditionalOnSigleCandidate存在/不存在/只有一个候选Bean时装配
      资源条件注解ConditionalOnResource资源条件
      其他条件注解``ConditionalOnExpression, @ConditionalOnJava, ConditionalOnJndi`其他注解条件
      执行顺序@AutoConfigureBefore, @AutoConfigureAfter, @AutoConfigureOrder自动配置执行顺序
  4. 查看Spring自动装配结果: 在运行参数上加上--debug, 之后就可以看到所有装配的类

基本使用

  1. 创建一个Spring项目, 用于被装配, 实现ApplicationRunner, 使其启动之后会打印信息

    @Log4j2
    public class GreetingApplicationRunner implements ApplicationRunner {
        private final String name;
    
        public GreetingApplicationRunner(String name) {
            this.name = name;
            log.info("Initializing GreetingApplicationRunner for {}", name);
        }
    
        public GreetingApplicationRunner() {
            this("dummy spring application");
        }
    
        @Override
        public void run(ApplicationArguments args) throws Exception {
            log.info("Hello from dummy spring");
        }
    }
    
  2. 之后创建一个自动配置类, 在符合条件的情况下自动创建Bean

    @Configuration
    @ConditionalOnClass(GreetingApplicationRunner.class)
    public class DummyAutoConfiguration {
        @Bean
        @ConditionalOnMissingBean(GreetingApplicationRunner.class)
        @ConditionalOnProperty(name = "dummy.enable", havingValue = "true", matchIfMissing = true)
        public GreetingApplicationRunner greetingApplicationRunner() {
            return new GreetingApplicationRunner();
        }
    }
    
  3. 并在spring.factories中添加该自动配置类

    org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
      com.passnight.springboot.autoconfiguration.DummyAutoConfiguration
    
  4. 之后其他模块引入该自动配置模块之后就可以在控制台中看到打印的信息了

    注意

    1. 若在自动配置模块中, 被自动配置的模块的scope被设置为scope则需要在引用自动配置的模块中手动引入被自动配置的模块
    2. 若自己手动添加了Bean, 则不符合@ConditionalOnMissingBean(GreetingApplicationRunner.class)的条件, 则不会自动配置
    3. 若配置了dummy.enable, 且值不为true, 则不符合havingValue = "true", 不会自动配置
    4. 若未配置dummy.enable, 则符合matchIfMissing = true, 会自动配置
  5. 自动装配失败后会通过FailureAnalyzer分析

配置加载机制

  1. SpringBoot配置加载顺序

    1. 开启DevTools时, ~/.spring-boot-devtools.properteis
    2. 测试类上的@TestPropertySource注解
    3. @SpringBootTest#properties属性
    4. 命令行参数 --server.port=9000
    5. SPRING_APPLICATION_JSON中的属性 环境变量中的一个参数
    6. ServletConfig初始化参数
    7. ServletContext初始化参数
    8. java:comp/env中的JNDI属性
    9. System.getProperties()
    10. 操作系统的环境变量
    11. random.*涉及到RandomValuePropertySource
    12. jar包外部的application-{profile}.properties[.yml] jar包外部
    13. jar包内部的application-{profile}.properties[.yml] jar包内部
    14. jar包外部的application.properties[.yml] 先加载带*profile*的配置文件
    15. jar包内部的application.properties[.yml] 注意: 这四个文件都可能会被加载, 只是优先级覆盖, 默认外置在./config, ./config, classpath://, classpath://config, spring.config.name, spring.config.localtion, spirng.config.additional-localtion后面几个是配置的
  2. 配置文件加载: 通过配置@PropertySource@PropertySources, @ConfigurationProperties等注解, 以下面类为例

    // 匹配前缀为`spring.jdbc`的注解
    @ConfigurationProperties(prefix = "spring.jdbc")
    public class JdbcProperties {
    	// 匹配`spring.jdbc.template`
    	private final Template template = new Template();
    
    	public Template getTemplate() {
    		return this.template;
    	}
    
    	public static class Template {
    
            // 匹配`spring.jdbc.template.fetch-size`
    		private int fetchSize = -1;
    
    		private int maxRows = -1;
    		// spring会自动转换时间单位
    		@DurationUnit(ChronoUnit.SECONDS)
    		private Duration queryTimeout;
    	}
    
    }
    
  3. 在使用Spring支持的配置源之外, 还可以自定义配置源, 以RandomValuePropertySource为例, 它可以随机生成property值

    public class RandomValuePropertySource extends PropertySource<Random> {
        // private static final String PREFIX = "random.";
        @Override
        public Object getProperty(String name) {
            if (!name.startsWith(PREFIX)) {
                return null;
            }
            if (logger.isTraceEnabled()) {
                logger.trace("Generating random property for '" + name + "'");
            }
            return getRandomValue(name.substring(PREFIX.length()));
        }
    }
    
    1. 在实现了自定义的PropertySource<T>之后, 还要将其添加到Environment当中, 比较合适的切入位置有EnvironmentPostProcessorBeanFactoryPostProcessor

自定义PropertySource

  1. 添加自定义的配置文件

    passnight.greeting=hello from passnight
    
  2. 创建自定义的EnvironmentPostProcessor

    public class MyPropertySourceEnvironmentPostProcessor implements EnvironmentPostProcessor {
        private final PropertiesPropertySourceLoader loader = new PropertiesPropertySourceLoader();
    
        @SneakyThrows
        @Override
        public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
            MutablePropertySources propertySources = environment.getPropertySources();
            Resource resource = new ClassPathResource("my.properties");
            PropertySource<?> myPropertyFile = loader.load("MyPropertyFile", resource).get(0);
            propertySources.addFirst(myPropertyFile);
        }
    }
    
  3. 将其添加到spring.factories

    org.springframework.boot.env.EnvironmentPostProcessor=com.passnight.springboot.autoconfiguration.MyPropertySourceEnvironmentPostProcessor
    
  4. 之后该property source就会生效, 读取自定义配置文件中的配置

    @SpringBootTest
    class AutoConfigurationApplicationTest {
    
        @Value("${passnight.greeting}")
        private String greeting;
    
        @Test
        public void loadPropertyFromMyPropertySource() {
            Assertions.assertEquals("hello from passnight", greeting);
        }
    }
    

SpringBoot监控

Actuator

  1. SpringBoot Actuator是一个用于监控/管理应用程序的包, 可以通过HTTP/JMX等访问方式访问, 通过spring-boot-starter-actuator引入

  2. SpringBoot Actuator常用的Endpoint有:

    ID说明默认启动默认HTTP默认JMX
    beans显示容器中的bean列表
    caches显示应用中的缓存
    conditions显示配置条件的计算情况
    configprops显示@ConfigurationProperties的信息
    env显示ConfigurableEnvironment中的属性
    health显示健康检查信息
    httptrace显示HTTP trace信息
    info显示设置好的应用信息
    loggers显示并更新日志信息
    metriecs显示应用的度量信息
    mappings显示所有的@RequestMapping信息
    scheduledtasks显示应用的调度任务信息
    shutdown优雅的关闭程序
    treaddump执行Thread Dump
    heapdump返回Heap Dump文件, 格式为HPROF🕳️
    prometheus返回可供prometheus抓取的信息🕳️
  3. 在开启了actuator之后, 就可以通过/actuator/<id>访问对应的端点了, 也可以通过以下配置调整Actor的访问:

    1. management.server.address: 访问地址
    2. managerment.server.port: 访问端口
    3. management.endpoints.web.base-path=/actuator: 访问相对路径
    4. management.endpoints.web.path-mapping.<id>=对应端点的路径
基本使用
  1. SpringBoot可以自定义端点以用于监控程序的运行状态, SpringBoot提供了Health Indicator机制用于收集和展示相关信息

  2. SpringBoot通过HealthIndicatorRegistry收集信息, 通过HealthIndicator实现具体检查逻辑; 具体可以通过以下配置配置:

    1. management.health.defaults.enable=true|false: 开启或关闭Health Indicator
    2. management.health.<id>.enabled=true: 开启或关闭某一个health Indicator
    3. management.endpoint.health.show-details=never|when-authorized|always: 通过打开这个可以查看详细信息 而不是一个up/down的概要
  3. SpringBoot内置的HealthIndicator用于监控开源的基础设施, 如MongoHealthIndicator可以用于监控MongoDB的运行状态; 而DiskSpaceHealthIndicator可以用于监控磁盘的使用状态

  4. DataSourceIndicator为例; 他可以用于监控数据源连接情况; 它继承了AbstractHealthIndicator默认可以通过java.sql.Connection#isValid来监控数据源的状态

    public class DataSourceHealthIndicator extends AbstractHealthIndicator implements InitializingBean{
        @Override
        protected void doHealthCheck(Health.Builder builder) throws Exception {
            if (this.dataSource == null) {
                builder.up().withDetail("database", "unknown");
            }
            else {
                doDataSourceHealthCheck(builder);
            }
        }
        private void doDataSourceHealthCheck(Health.Builder builder) throws Exception {
            builder.up().withDetail("database", getProduct());
            String validationQuery = this.query;
            if (StringUtils.hasText(validationQuery)) {
                builder.withDetail("validationQuery", validationQuery);
                // Avoid calling getObject as it breaks MySQL on Java 7 and later
                List<Object> results = this.jdbcTemplate.query(validationQuery, new SingleColumnRowMapper());
                Object result = DataAccessUtils.requiredSingleResult(results);
                builder.withDetail("result", result);
            }
            else {
                builder.withDetail("validationQuery", "isValid()");
                boolean valid = isConnectionValid();
                builder.status((valid) ? Status.UP : Status.DOWN);
            }
        }
        private Boolean isConnectionValid() {
            return this.jdbcTemplate.execute((ConnectionCallback<Boolean>) this::isConnectionValid);
        }
        private Boolean isConnectionValid(Connection connection) throws SQLException {
            return connection.isValid(0);
        }
    }
    public interface Connection  extends Wrapper, AutoCloseable {
        boolean isValid(int timeout) throws SQLException;
    }
    
  5. 自定义Indicator也很容易, 只需要继承HealthIndicator然后实现health()就可以了

    @Component
    @RequiredArgsConstructor
    public class CoffeeIndicator implements HealthIndicator {
        private final CoffeeService coffeeService;
    
        @Override
        public Health health() {
            int count = coffeeService.findCoffees().size();
            return count > 0
                    ?
                    Health.up()
                            .withDetail("count", count)
                            .withDetail("message", "Enough Coffee")
                            .build()
                    :
                    Health.down()
                            .withDetail("count", count)
                            .withDetail("message", "Not Enough Coffee")
                            .build();
        }
    }
    
  6. 在实现了该接口之后, 就可以在SpringBoot Actuator中通过http请求到对应的信息了 这里主要要打开management.endpoint.health.show-details=always, 否则没有详细信息

    @Log4j2
    @SpringBootTest(properties = {"management.endpoint.health.show-details=always"})
    @AutoConfigureMockMvc
    public class HealthIndicatorTest {
        @Autowired
        private MockMvc mockMvc;
        @Autowired
        private ObjectMapper objectMapper;
    
        @Test
        public void getHealthIndicatorTest() throws Exception {
            String response = mockMvc.perform(MockMvcRequestBuilders.get("/actuator/health")
                                              .contentType(MediaType.APPLICATION_JSON))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andReturn()
                .getResponse()
                .getContentAsString();
            Assertions.assertTrue(response.contains("{\"coffeeIndicator\":{\"status\":\"UP\",\"details\":{\"count\":2,\"message\":\"Enough Coffee\"}}"));
        }
    }
    

Micrometer

  1. 除了使用HealthIndicator之外, 还可以使用Micrometer收集度量指标, 如jvm的运行状态等
  2. Micrometer提供了许多特性, 如
    1. 多维度度量, 因为Micrometer支持Tag
    2. 多内置探针, 如缓存/类加载器/GC/CPU利用率/线程池等
    3. 与Spring深度融合, 如可以与MVC/WebFlux集成
  3. 核心概念:
    1. 基本接口: Meter
    2. Gauge/TimeGauge: 单个值的对量
    3. Timer/LongTaskTimer/ FunctionTimer: 计时器
    4. Counter/FunctionCounter: 计数器
    5. DistributionSummary: 分布统计 如95线/99线
  4. Micrometer可以通过Actuator的端点访问: 如/actuator/metrics和针对Prometheus的/actuator/prometheus
  5. Micrometer提供了一些配置项用于配置其基本使用
    1. management.metrics.export.*: 输出配置 如向datadog输出
    2. management.metrics.tags.*: 标签配置 如添加区域标签
    3. management.metrics.enable.*: 是否开启
    4. management.metrics.web.server.auto-time.requests: 用于监控web服务器的请求时间
  6. Spring Micrometer提供了许多内置的度量项, 如
    1. 核心系统相关: JVM/CPU/文件句柄/日志/启动时间
    2. Web服务端相关: MVC/WebFlux/Tomcat/Jersey
    3. Web客户端相关:RestTemplate/WebClient
    4. 数据库相关: 缓存/数据源/Hibernate
    5. MQ相关: Kafka/RabbitMQ
基本使用
  1. 自定义度量指标有以下三种方式:

    1. 通过MeterRegistry注册Meter
    2. 通过MeterBinder 让SpringBoot自动绑定
    3. 通过MeterFilter进行定制
  2. 如下面通过实现MeterBinder来支持CoffeeOrderService的监控, ❗注意❗, 在使用MockMvc测试前需要打开management.endpoints.web.exposure.include=metrics

  3. 第一步要修改Service的代码, 通过实现MeterBindrCounter绑定到metrices中, 然后再在业务逻辑中加入Counter的修改

    @Service
    @RequiredArgsConstructor
    public class CoffeeOrderService implements MeterBinder {
        public final static List<CoffeeOrder> coffeeOrderRepository = new ArrayList<>();
        private Counter orderCounter;
    
        public CoffeeOrder createOrder(String customerName, List<Coffee> coffees) {
            CoffeeOrder coffeeOrder = CoffeeOrder.builder()
                    .coffees(coffees)
                    .customer(customerName)
                    .build();
            coffeeOrderRepository.add(coffeeOrder);
            orderCounter.increment();
            return coffeeOrder;
        }
    
        @Override
        public void bindTo(@NonNull MeterRegistry registry) {
            orderCounter = registry.counter("order.count");
        }
    }
    
  4. 在这之后就可以在metrics中看到order.count; 并且初始值为0, 调用了一次createOrder之后会变为1

        @Test
        public void getIndicatorTest() throws Exception {
            List<String> metrics = objectMapper.<Map<String, List<String>>>readValue(mockMvc.perform(MockMvcRequestBuilders.get("/actuator/metrics")
                                    .contentType(MediaType.APPLICATION_JSON))
                            .andExpect(MockMvcResultMatchers.status().isOk())
                            .andReturn()
                            .getResponse()
                            .getContentAsString(),
                    TypeFactory.defaultInstance().constructMapType(Map.class,
                            TypeFactory.defaultInstance().constructType(String.class),
                            TypeFactory.defaultInstance().constructCollectionType(List.class, String.class))).get("names");
            Assertions.assertTrue(metrics.contains("order.count"));
    
            Map<String, Object> orderCountMetric = objectMapper.readValue(mockMvc.perform(MockMvcRequestBuilders.get("/actuator/metrics/order.count")
                            .contentType(MediaType.APPLICATION_JSON))
                    .andExpect(MockMvcResultMatchers.status().isOk())
                    .andReturn()
                    .getResponse()
                    .getContentAsString(), TypeFactory.defaultInstance().constructMapType(Map.class, String.class, Object.class));
            Assertions.assertEquals(orderCountMetric.get("measurements"), List.of(Map.of("statistic", "COUNT", "value", 0d)));
            mockMvc.perform(MockMvcRequestBuilders.post("/order/")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content(objectMapper.writeValueAsBytes(NewOrderRequest.builder()
                                    .coffee("Coffee1")
                                    .customer("Customer1")
                                    .build())))
                    .andExpect(MockMvcResultMatchers.status().isCreated())
                    .andExpect(MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON));
            orderCountMetric = objectMapper.readValue(mockMvc.perform(MockMvcRequestBuilders.get("/actuator/metrics/order.count")
                            .contentType(MediaType.APPLICATION_JSON))
                    .andExpect(MockMvcResultMatchers.status().isOk())
                    .andReturn()
                    .getResponse()
                    .getContentAsString(), TypeFactory.defaultInstance().constructMapType(Map.class, String.class, Object.class));
            Assertions.assertEquals(orderCountMetric.get("measurements"), List.of(Map.of("statistic", "COUNT", "value", 1d)));
        }
    

SpringBoot Admin

  1. 在可以通过Actuator监控SpringBoot程序之后, 还需要一个可视化的界面将这些监控展示出来, Spring Boot Admin就是一个第三方的可视化工具
  2. 其主要功能为: 集中地展示Actuator相关的内容; 变更通知
基本使用
  1. 使用Spring Boot Admin分为服务端和客户端
    1. 开启服务端有两步: 第一步是引入对应的依赖spring-boot-admin-starter-server, 第二步是添加@EnableAdminServer注解开启服务端
    2. 开启客户端也是两步: 第一步是引入对应的依赖spring-boot-admin-starter-client, 第二步是添加对应的配置:
      1. spring.boot.admin.client.url=http://localhost:8080
      2. management.endpoints.web.exposure.include=*

命令行程序

  1. SpringBoot除了提供了Web应用的框架之外, 还提供了命令行程序的框架; 其主要的类有, 他们的功能都是在程序启动后执行一段代码:
    1. ApplicationRunner: 接收ApplicationArguments参数
    2. CommandLineRunner: 接收String[]参数
  2. 除了入参的格式是不同的之外, 其他都是类似的; 此外, 他们可以通过@Order来指定执行顺序
  3. 返回码的类型为ExitCodeGenerator

基本使用

  1. SpringBoot命令行程序的编写非常简单, 只需要实现Runner即可成功, 首先是实现一个CommandLineRunner, 它会在启动的时候打印一句话, 同时通过@Order指定其为最先打印的

    @Log4j2
    @Order(1)
    @Component
    public class FooCommandLineRunner implements CommandLineRunner {
        @Override
        public void run(String... args) {
            log.info("FooCommandLineRunner.run @Order(1)");
        }
    }
    
  2. ApplicationRunner的使用和CommandLineRunner类似, 只是接收的参数不同

    @Log4j2
    @Order(2)
    @Component
    public class BarCommandLineRunner implements ApplicationRunner {
    
        @Override
        public void run(ApplicationArguments args) {
            log.info("BarCommandLineRunner.run @Order(2)");
        }
    }
    
  3. 在程序结束之后, 我们可以通过实现ExitCodeGenerator来指定程序对出时的返回码, 下面指定返回码值为1

    @Component
    public class MyCodeGenerator implements ExitCodeGenerator {
        @Override
        public int getExitCode() {
            return 1;
        }
    }
    
  4. 之后通过调用SpringApplication.exit()就可以获得该返回码 注意, 这里得通过ApplicationContextAware来获取上下文信息; 使用@Autowired也能达到类似的效果

    @Log4j2
    @Order(3)
    @Component
    public class ExitCodeApplicationRunner implements ApplicationRunner, ApplicationContextAware {
        private ApplicationContext context;
    
        @Override
        public void run(ApplicationArguments args) {
            int code = SpringApplication.exit(context);
            log.info("ExitCodeApplicationRunner.run @Order(3) And Exit with code: {}", code);
            System.exit(code);
        }
    
        @Override
        public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
            context = applicationContext;
        }
    }
    

SpringCloud

  1. SpringCloud为常见的分布式系统提供了一套简单/便捷的编程模型, 它由多个组件组成:

    在这里插入图片描述

  2. SpringCloud组要由以下几个组件组成:

    1. 服务发现: Eureka, Zookeeper, Nacos
    2. 服务熔断: Hystrix, Sentinel, Resilience4j
    3. 配置服务: Git, Zookeeper, Consul, Nacos
    4. 服务安全: SpringCloudSecurity
    5. 服务网关: SpringCloudGateway, Zuul
    6. 分布式消息: SpringCloudStream
    7. 分布式跟踪: zipkin
    8. 云服务支持: GoogleCloud, Azure
  3. 使用SpringCloud同SpringBoot一样简单, 只需要引入spring-cloud-dependencies就可以自动管理相应的依赖

  4. SpringCloud除了application.yml之外还有bootstrap配置, 它是在SpringCloud应用程序启动的时候用于启动引导阶段加载的属性; 通常需要配置应用名/配置中心相关的基本配置项

服务注册与发现

  1. 为了管理复杂的分布式系统, 需要有一个中心用于管理这种复杂的关系网络, 这个过程被称为服务注册与发现4; 服务注册与发现分为了服务注册服务发现两个部分
  2. 服务注册: 就是将提供某个服务的模块信息(通常是这个服务的ip和端口)注册到1个公共的组件上去(比如: zookeeper\consul)。
  3. 服务发现: 就是新注册的这个服务模块能够及时的被其他调用者发现。不管是服务新增和服务删减都能实现自动发现。

Spring对服务注册与发现的抽象

  1. Spring将服务的注册与发现分为了以下几个部分:
    1. 服务注册: 通过ServiceRegistry抽象
    2. 服务发现: DiscoveryClient@EnableDiscoveryClient抽象
    3. 负载均衡(包含于服务发现): LoadBalancerClient抽象

Eureka

  1. Eureka 是一个Netflix开源的用于服务注册与发现的组件
  2. 对于SpringCloud对服务注册与发现的抽象, Eureka通过EurekaServiceRegistry, EurekaRegistration来实现服务注册发现, 通过EurekaClientAutoConfiguration, EurekaAutoServiceRegistration来实现自动配置
基本使用
  1. Eureka分为了Client和Server两个部分, Server是用于管理服务注册与发现的服务器, 而Client是需要注册到注册中心的工作节点

  2. 类似于Spring的其他组件的引入一样, 引入Eureka只需要引入spring-cloud-starter-netflix-eureka-clientspring-cloud-starter-netflix-eureka-server; 他们分别对应了服务注册的服务器和服务发现的客户端

  3. 服务端的启动需要通过@EnableEurekaServer开启, 而客户端也主需要配置@EnableDiscoveryClient@EnableEurekaClient

  4. Eureka常用的配置有:

    1. eureka.client.server-url.default-zone: 用于配置Eureka集群的地址, 用,分隔
    2. eureka.client.instance.prefer-ip-address=false: 优先使用hostname还是ip注册
  5. 在引入了对应的依赖, 第一步是配置一些必要的配置 这里使用了高可用配置, 因此defaultZone配置了三个节点; hostname是通过写在本地hosts`中完成解析的

    server:
      port: 8000
    eureka:
      instance:
        hostname: eureka.internal
      client:
        register-with-eureka: false
        fetch-registry: false
        service-url:
          defaultZone: http://eureka1.internal:8001/eureka/,http://eureka2.internal:8002/eureka/
    spring:
      application:
        name: eureka
    
  6. 完成配置之后, 只需要添加@EnableEurekaServer就可以启动Eureka服务

    @SpringBootApplication
    @EnableEurekaServer
    public class EurekaApplication {
        public static void main(String[] args) {
            SpringApplication.run(EurekaApplication.class, args);
        }
    }
    
    
  7. 之后编写一个服务, 添加类似的配置

    server:
      port: 8080
    #  servlet:
    #    context-path: /cloud/service
    eureka:
      client:
        service-url:
          defaultZone: http://eureka.internal:8000/eureka,http://eureka1.internal:8001/eureka,http://eureka2.internal:8002/eureka
      instance:
        instance-id: service
    spring:
      application:
        name: service
    
    
  8. @EnableEurekaClient开启服务发现

    @SpringBootApplication
    @EnableEurekaClient
    public class ServiceApplication {
        public static void main(String[] args) {
            SpringApplication.run(ServiceApplication.class, args);
        }
    }
    
    
  9. 就可以在Eureka的页面看到对应的服务了: 在这里插入图片描述

Zookeeper

  1. 除了使用Eureka作为注册中心之外, 还可以使用Zookeeper作为注册中心
  2. Zookeeper是一个分布式协调系统; 具有强一致性和使用简单等特点

基本使用

  1. 使用Zookeeper同Eureka类似, 只需要引入spring-cloud-starter-zookeeper-discovery并配置spring.cloud.zookeeper.connect-string就可以了

  2. 在引入对应的依赖, 并配置Zookeeper的连接路径之后就可以成功注册了

    spring:
      cloud:
        zookeeper:
          connect-string: server.passnight.local:20012,follower.passnight.local:20012,replica.passnight.local:20012
      application:
        name: zookeeper-server
    
  3. 我们可以在Zookeeper中看到对应的信息

    [zk: localhost:2181(CONNECTED) 0] ls /services
    [zookeeper-server]
    [zk: localhost:2181(CONNECTED) 1] ls /services/zookeeper-server
    [f95b757a-b08b-4291-b4ca-15701131918d] # 注册的节点信息
    [zk: localhost:2181(CONNECTED) 3] get /services/zookeeper-server/f95b757a-b08b-4291-b4ca-15701131918d
    {"name":"zookeeper-server","id":"f95b757a-b08b-4291-b4ca-15701131918d","address":"server.passnight.local.lan","port":8003,"sslPort":null,"payload":{"@class":"org.springframework.cloud.zookeeper.discovery.ZookeeperInstance","id":"application-1","name":"zookeeper-server","metadata":{"instance_status":"UP"}},"registrationTimeUTC":1712396286972,"serviceType":"DYNAMIC","uriSpec":{"parts":[{"value":"scheme","variable":true},{"value":"://","variable":false},{"value":"address","variable":true},{"value":":","variable":false},{"value":"port","variable":true}]}}
    
  4. 服务发现也是类似地修改配置文件和依赖就行了

Nacos

  1. Nacos是阿里巴巴开源的一款易于构建云原生应用的动态服务发现/配置管理服务管理平台
  2. 它提供了以下功能: 动态访问配置, 服务发现和管理, 动态DNS服务
基本使用
  1. 使用nacos之前需要提那几SpringCloud Alibaba的依赖: spring-cloud-alibaba-dependencies; 之后就可以类似Eureka一样使用SpringCloud Alibaba的组件了
  2. 在添加了BOM依赖之后, 还需要添加spring-cloud-starter-alibaba-nacos-discovery然后配置spring.cluod.nacos.discovery.server-addr的地址就可以
  3. 在配置了nacos服务注册与发现之后, 类似Eureka, 也可以使用Ribbon来做负载均衡

自定义服务注册与发现

  1. 第一步是需要实现DiscoveryClient, 它可以提供可用实例和服务; 这里直接从application.yml中读取, 获取域名+端口格式的配置项并解析为实例

    @Setter
    @Component
    @ConfigurationProperties("fix-discovery-client")
    public class FixedDiscoveryClient implements DiscoveryClient {
        public static final String SERVICE_ID = "SERVICE";
    
        private List<String> services;
    
    
        @Override
        public String description() {
            return "DiscoveryClient that uses service.list from application.yml.;";
        }
    
        @Override
        public List<ServiceInstance> getInstances(String serviceId) {
            if (!SERVICE_ID.equalsIgnoreCase(serviceId)) {
                return Collections.emptyList();
            }
            return services.stream()
                    .filter(service -> service.matches("[\\w.]+:\\d+"))
                    .map(service -> new DefaultServiceInstance(service, SERVICE_ID, service.split(":")[0], Integer.parseInt(service.split(":")[1]), false))
                    .collect(Collectors.toList());
        }
    
        @Override
        public List<String> getServices() {
            return Collections.singletonList(SERVICE_ID);
        }
    }
    
  2. 第二步是实现ServerList; 这个是用于ribbon负载均衡使用的

    @Component
    @RequiredArgsConstructor
    public class FixedServerList implements ServerList<Server> {
    
        private final FixedDiscoveryClient fixedDiscoveryClient;
    
        @Override
        public List<Server> getInitialListOfServers() {
            return fixedDiscoveryClient.getInstances(FixedDiscoveryClient.SERVICE_ID)
                    .stream()
                    .map(service -> new Server(service.getHost(), service.getPort()))
                    .collect(Collectors.toList());
        }
    
        @Override
        public List<Server> getUpdatedListOfServers() {
            return fixedDiscoveryClient.getInstances(FixedDiscoveryClient.SERVICE_ID)
                    .stream()
                    .map(service -> new Server(service.getHost(), service.getPort()))
                    .collect(Collectors.toList());
        }
    }
    
    1. 因为服务发现是通过在application.yaml中配置, 因此需要在yaml中添加对应的配置

      fix-discovery-client:
        services:
          - localhost:8080
      
  3. 之后照常配置RestTemplate并加上@LoadBalanced就可以正常实现负载均衡了

    @Log4j2
    @SpringBootTest
    public class HelloServiceRestTemplateImplTest {
        @Autowired
        private HelloServiceRestTemplateImpl helloService;
    
        @Test
        public void helloTest() {
            String response = helloService.hello();
            log.debug(response);
            Assertions.assertTrue(response.matches("hello from service [0-2]"));
        }
    }
    

服务调用

Spring Cloud LoadBalance

  1. 在使用Eureka注册完服务之后, 需要通过LoadBalance来根据注册的信息实现对服务的负载均衡地调用

  2. LoadBalance可以通过在RestTemplateWebClient的Bean上添加@LoadBalance来实现, 其原理是通过ClientHttpRequestInterceptor实现的; Spring中对它的实现为org.springframework.cloud.client.loadbalancer.LoadBalancerInterceptor; 它是通过LoadBalancer原有的请求实现的

    @Override
    public ClientHttpResponse intercept(final HttpRequest request, final byte[] body,
                                        final ClientHttpRequestExecution execution) throws IOException {
        final URI originalUri = request.getURI();
        String serviceName = originalUri.getHost();
        Assert.state(serviceName != null,
                     "Request URI does not contain a valid hostname: " + originalUri);
        return this.loadBalancer.execute(serviceName,
                                         this.requestFactory.createRequest(request, body, execution));
    }
    
  3. 常用的LoadBalance有Netflix开源的ribbon; 在ribbon中, RibbonLoadBalancerClient通过实现LoadBalancerClient提供了负载均衡的访问机制

基本使用
  1. 环境准备: 测试客户端负载均衡之前, 先编写三个访问, 使他们相同的Controller返回不同的信息; 用以区分

    @RestController
    @RequestMapping("/hello")
    public class HelloWorld {
        @GetMapping("world")
        public String hello() {
            return "hello from service 0";
        }
    }
    
    @RestController
    @RequestMapping("/hello")
    public class HelloWorld {
        @GetMapping("world")
        public String hello() {
            return "hello from service 1";
        }
    }
    
    @RestController
    @RequestMapping("/hello")
    public class HelloWorld {
        @GetMapping("world")
        public String hello() {
            return "hello from service 2";
        }
    }
    
  2. 使用Ribbon的第一步是导入相应的依赖spring-cloud-starter-netflix-ribbon

  3. 之后在配置RestTemplate的方法上添加@LoadBalanced注解表明客户端需要负载均衡请求

    @Bean
    @LoadBalanced
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
    
  4. 在配置了负载均衡请求之后, 还需要在application.yml中配置服务发现的相关信息, 供负载均衡器使用

    server:
      port: 10080
    eureka:
      client:
        service-url:
          defaultZone: http://eureka.internal:8000/eureka,http://eureka1.internal:8001/eureka,http://eureka2.internal:8002/eureka
    spring:
      application:
        name: gateway
    
  5. 之后编写对应的请求类即可完成请求, 它使用添加了@LoadBalanced注解的RestTemplate

    @Service
    @RequiredArgsConstructor
    public class HelloServiceRestTemplateImpl {
        private final static UriBuilderFactory uriFactory = new DefaultUriBuilderFactory("http://SERVICE/hello");
    
        private final RestTemplate restTemplate;
    
        public String hello() {
            return restTemplate.getForObject(uriFactory.uriString("/world").build(), String.class);
        }
    }
    
  6. 可以看到每次打印的数字都不一样, 表明请求的不是同一个服务

@Log4j2
@SpringBootTest
@AutoConfigureMockMvc
public class ServiceControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    public void helloTest() throws Exception {
        String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andReturn()
                .getResponse()
                .getContentAsString();
        log.debug(response);
        Assertions.assertTrue(response.matches("hello from service [0-2]"));
    }
}

Feign

  1. Feign是一个声明式的Rest Web服务客户端; 它的使用类似于RestTemplate; 只需要在编写的接口上添加@FeignClientFeign就会自动将其代理实例化为一个Feign接口
  2. Feign可以通过FeignClientsConfiguration/application/yml配置, 常见的配置项包括Encoder/Decoder/Logger/COntract/Client
基本使用
  1. Feign的使用也是通过@EnableFeignClients来开启的; 在配置了注册中心的信息之后就可以通过注册中心的信息访问到对应的服务, 同时配置接口超时为500ms

    eureka:
      client:
        service-url:
          defaultZone: http://eureka.internal:8000/eureka,http://eureka1.internal:8001/eureka,http://eureka2.internal:8002/eureka
    spring:
      application:
        name: feign-gateway
    feign:
      client:
        config:
          default:
            connect-timeout: 500
            read-timeout: 500
    
  2. 第一步是开启feign和Eureka Client的支持

    @SpringBootApplication
    @EnableEurekaClient
    @EnableFeignClients
    public class FeignGatewayApplication {
        public static void main(String[] args) {
            SpringApplication.run(FeignGatewayApplication.class, args);
        }
    }
    
  3. 之后需要添加Eureka相关的配置, 用于服务发现

    eureka:
      client:
        service-url:
          defaultZone: http://eureka.internal:8000/eureka,http://eureka1.internal:8001/eureka,http://eureka2.internal:8002/eureka
    spring:
      application:
        name: feign-gateway
    
  4. 然后编写一个FeignClient, 它会自动调用; 它使用的注解和SpringMVC相同

    @FeignClient(value = "service")
    @RequestMapping("/hello")
    public interface HelloService {
    
        @GetMapping("world")
        String hello();
    }
    
  5. 之后就可以通过Feign访问远程Http服务

    @Log4j2
    @SpringBootTest
    public class HelloServiceTest {
        @Autowired
        private HelloService helloService;
    
        @Test
        public void helloTest() {
            String response = helloService.hello();
            log.debug(response);
            Assertions.assertTrue(response.matches("hello from service [0-2]"));
        }
    }
    

服务熔断

  1. 服务熔断的核心思想在于当服务发生问题时, 不再实际调用, 而直接返回错误
  2. 核心思想: 使用断路器保护调用服务
    1. 在断路器对象中封装的方法调用是受保护的
    2. 断路器监控服务的调用和断路情况
    3. 调用失败出发阈值之后, 由断路器返回错误, 而不再实际进行调用
  3. 最简单的使用方式就是添加一个切面, 这个切面维护方法调用的失败情况, 若失败超过阈值, 则在这个切面中拦截所有的请求

Hystrix

  1. Hystrix是Netflix提供的一个实现服务熔断的组件
  2. Hystrix和Feign都是Netflix开发的, 因此Hystrix在Feign中有一些相关的配置, Hystrix主要的配置有以下几个:
    1. feign.hystrix.enabled=true: 是否打开Hystrix
    2. @FeignClient(fallback=, fallbackFactory): 指定fallback的类或fallback工厂函数的类
基本使用
  1. 第一步引入spring-cloud-starter-netflix-systrix依赖, 之后需要通过@EnableCircuitBreaker开启Hystrix配置

    @SpringBootApplication
    @EnableEurekaClient
    @EnableCircuitBreaker
    public class HystrixApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(HystrixApplication.class, args);
        }
    }
    
  2. Hystrix和Feign的组合使用非常简单, 直接将FallbackFactory配置在@FeignClient里面, 然后在断路时就可以使用了

    @FeignClient(value = "service", fallbackFactory = HelloFallbackFactory.class)
    @RequestMapping("/hello")
    public interface HelloService {
    
        @GetMapping("/world")
        String hello();
    }
    
  3. 第一种方法是使用@HystrixCommand指定fallback方法; 在调用失败后, 它会直接执行fallbackMethod中配置的方法

    @RestController
    @RequestMapping("/hello")
    public class Hello {
        @GetMapping("world")
        public String hello() {
            return "hello world";
        }
    
        @GetMapping("error")
        @HystrixCommand(fallbackMethod = "hystrixError")
        public String error() {
            throw new RuntimeException("an error occurred");
        }
    
        public String hystrixError() {
            return "hystrix intercept the error";
        }
    }
    
  4. 在上面的error()抛出异常之后, 会调用hystrixError, 并返回其对应的结果:

    @SpringBootTest
    @AutoConfigureMockMvc
    public class HelloControllerTest {
        @Autowired
        private MockMvc mockMvc;
    
        @Test
        public void errorTest() throws Exception {
            String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/error"))
                    .andReturn()
                    .getResponse()
                    .getContentAsString();
            Assertions.assertEquals("hystrix intercept the error", response);
        }
    }
    
  5. 使用@FeignClient指定fallbackfallbackFatory也类似, 只需要在Service上添加对应的配置

    @FeignClient(value = "service", fallbackFactory = HelloFallbackFactory.class)
    @RequestMapping("/hello")
    public interface HelloService {
    
        @GetMapping("/world")
        String hello();
    }
    
  6. 之后实现对应的类

    @Component
    public class HelloFallbackFactory implements FallbackFactory<HelloService> {
        @Override
        public HelloService create(Throwable throwable) {
            return new HelloService() {
                @Override
                public String hello() {
                    return "intercept by hystrix";
                }
            };
        }
    }
    
  7. 然后就可以在请求失败的时候走Hystrix提供的断路器了

    @Test
    public void helloBreakByHystrixTest() {
        String response = helloService.hello();
        log.debug(response);
        Assertions.assertEquals("intercept by hystrix", response);
    }
    

Resilience4J

  1. Resilience4j是一款类似于Hystrix的轻量级的容错库 轻量级在于它的依赖少
  2. Resilience4j主要包含以下几个组件
    1. resilience4j-circulitbreaker: 熔断保护
    2. resilience4j-ratelimiter: 频率控制
    3. resilience4j-bulkhead: 依赖隔离&负载保护
    4. resilience4j-retry: 自动重试
    5. resilience4j-cache: 应答缓存
    6. resilience4j-timelimiter: 超时控制
  3. 基于ConcurrentHashMap的内存断路器: CurcuitBreakerRegistryCircuitBreakerConfig
基本使用
  1. 使用resilience4j只需要在引入依赖之后, 然后在需要做断路保护的方法上加上CircuitBreaker即可

  2. 主要的配置可以通过CircuitBreakerPropertiesapplication.properties来配置, 常用的配置有

    1. resilience4j.circuitbreaker.backends.failure-rate-threshold: 断路阈值
    2. resilience4j.circuitbreaker.backends.wait-duration-in-open-state: 断路器打开需要等待的时间
  3. 第一步是引入依赖: resilience4j-spring-boot2; ❗注意❗若要使用注解模式, 还要引入aop的包: spring-boot-starter-aop

  4. 之后可以使用注解或函数式两种方式来声明熔断,

    @Log4j2
    @RestController
    @RequestMapping("/hello")
    public class HelloController {
        private final CircuitBreaker circuitBreaker;
    
        public HelloController(CircuitBreakerRegistry registry) {
            this.circuitBreaker = registry.circuitBreaker("hello");
        }
    
        @GetMapping("/functional-circuit-breaking")
        public String functionalCircuitBreaking(@RequestParam boolean errorFlag) {
            return Try.ofSupplier(
                            CircuitBreaker.decorateSupplier(circuitBreaker, () -> {
                                if (errorFlag) {
                                    throw new RuntimeException("Some thing wrong in functionalCircuitBreaking");
                                }
                                return "functional-circuit-breaking normal";
                            }))
                    .recover(RuntimeException.class, "functional-circuit-breaking broken by resilience4j")
                    .get();
        }
    
        @GetMapping("/annotation-circuit-breaking")
        @io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker(name = "annotation-circuit-breaking", fallbackMethod = "fallbackMethod")
        public String annotationCircuitBreaking(@RequestParam boolean errorFlag) {
            if (errorFlag) {
                throw new RuntimeException("Some thing wrong");
            }
            return "annotation-circuit-breaking normal";
        }
    
        public String fallbackMethod(RuntimeException e) {
            log.warn(e);
            return "annotation-circuit-breaking broken by resilience4j";
        }
    }
    
  5. 请求的阈值可以在application.yml中配置

    resilience4j:
      circuitbreaker:
        backends:
          annotation-circuit-breaking:
            failure-rate-threshold: 50
            wait-duration-in-open-state: 5000
            event-consumer-buffer-size: 10
            minimum-number-of-calls: 5
          functional-circuit-breaking:
            failure-rate-threshold: 50
            wait-duration-in-open-state: 5000
            event-consumer-buffer-size: 10
            minimum-number-of-calls: 5
    
  6. 之后请求, 若抛出异常则会走熔断器, 返回的是brokern by resilience4j的结果

    @Log4j2
    @SpringBootTest
    @AutoConfigureMockMvc
    public class HelloControllerTest {
        @Autowired
        private MockMvc mockMvc;
    
        @Test
        public void functionalCircuitBreakTest() throws Exception {
            for (int i = 0; i < 10; i++) {
                String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/functional-circuit-breaking")
                                .queryParam("errorFlag", "true"))
                        .andReturn()
                        .getResponse()
                        .getContentAsString();
                log.debug(response);
                Assertions.assertEquals("functional-circuit-breaking broken by resilience4j", response);
            }
        }
    
        @Test
        public void annotationCircuitBreakTest() throws Exception {
            for (int i = 0; i < 10; i++) {
                String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/annotation-circuit-breaking")
                                .queryParam("errorFlag", "true"))
                        .andReturn()
                        .getResponse()
                        .getContentAsString();
                log.debug(response);
                Assertions.assertEquals("annotation-circuit-breaking broken by resilience4j", response);
            }
        }
    }
    
BulkHead
  1. 在现网环境中, 流量并不是均匀的, 为了解决突发流量的问题, 甚至导致雪崩 resilience4j提供了BulkHead模式, 可以将请求储存在队列当中; 然后排队处理请求 超过某些阈值的请求也会被直接丢弃掉

  2. BulkHead模式和CircularBreak模式类似, 也有声明式和编程式两种实现方式, 对应的注解和类分别为: BulkheadRegistry@Bulkhead

  3. 在SpringBoot中, resilience通过BulkheadProperties为我们提供了接口级的配置, 常用的有:

    1. resilience4j.bulkhead.backends.<名称>.max-concurrent-call: 最大并发请求数
    2. resilience4j.bulkhead.backends.<名称>.max-wait-time: 最大等待时间
  4. bulkhead也有两种使用方式, 分别是声明式和编程式

    @GetMapping("/annotation-bulkhead")
    @io.github.resilience4j.bulkhead.annotation.Bulkhead(name = "annotation-bulkhead", fallbackMethod = "bulkheadFallbackMethod")
        public String annotationBulkhead(@RequestParam boolean errorFlag) {
        if (errorFlag) {
            throw new RuntimeException("Some thing wrong");
        }
        return "annotation-bulkhead normal";
    }
    
    public String bulkheadFallbackMethod(RuntimeException e) {
        log.warn(e);
        return "annotation-bulkhead broken by resilience4j";
    }
    @GetMapping("/function-bulkhead")
    public String functionBulkhead(@RequestParam boolean errorFlag) {
        return Try.ofSupplier(
            Bulkhead.decorateSupplier(bulkhead,
                                      CircuitBreaker.decorateSupplier(circuitBreaker, () -> {
                                          if (errorFlag) {
                                              throw new RuntimeException("Some thing wrong in functionalCircuitBreaking");
                                          }
                                          return "functional-circuit-breaking normal";
                                      }))).recover(RuntimeException.class, "functional-bulkhead broken by resilience4j")
            .get();
    }
    
  5. 之后大流量请求, 过多的请求就会被拦截了

    @Test
    public void functionalBulkhead() throws Exception {
        final AtomicInteger blockedCount = new AtomicInteger();
        final CountDownLatch latch = new CountDownLatch(200);
        IntStream.range(0, 200)
            .boxed()
            .map(String::valueOf)
            .map(name -> new Thread() {
                @SneakyThrows
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/function-bulkhead")
                                                          .queryParam("errorFlag", "false"))
                            .andReturn()
                            .getResponse()
                            .getContentAsString();
                        log.debug(response);
                        if (StrUtil.equals("functional-bulkhead broken by resilience4j", response)) {
                            blockedCount.incrementAndGet();
                        }
                        latch.countDown();
                    }
                }
            })
            .forEach(Thread::start);
        latch.await();
        Assertions.assertTrue(blockedCount.get() > 0);
    }
    
    @Test
    public void annotationBulkhead() throws Exception {
        final AtomicInteger blockedCount = new AtomicInteger();
        final CountDownLatch latch = new CountDownLatch(200);
        IntStream.range(0, 200)
            .boxed()
            .map(String::valueOf)
            .map(name -> new Thread() {
                @SneakyThrows
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        String response = mockMvc.perform(MockMvcRequestBuilders.get("/hello/annotation-bulkhead")
                                                          .queryParam("errorFlag", "false"))
                            .andReturn()
                            .getResponse()
                            .getContentAsString();
                        log.debug(response);
                        if (StrUtil.equals("annotation-bulkhead broken by resilience4j", response)) {
                            blockedCount.incrementAndGet();
                        }
                        latch.countDown();
                    }
                }
            })
            .forEach(Thread::start);
        latch.await();
        Assertions.assertTrue(blockedCount.get() > 0);
    }
    
RateLimite
  1. 除了隔仓模式的保护之外, resilience4还提供了限制特定时间段内执行次数的机制

  2. 类似其他模式, 也提供了声明式和编程式两种方式, 分别对应了@RateLimiterRateLimiterRegistry

  3. 对于SpringBoot, resilience4j在RateLimiterProperties中也提供了许多常用的配置, 如:

    1. resilience4j.ratelimiter.limiters.<名称>.limit-for-period: 能接受的次数 (和下面一个配置连起来)
    2. resilience4j.ratelimiter.limiters.<名称>.limit-refresh-period: 时间范围 (和上面一个配置连起来)
    3. resilience4j.ratelimiter.limiters.<名称>.timeout-duration: 超时时间
  4. RateLimiter使用非常简单, 只需要将执行的函数包裹在 rateLimiter.executeSupplier()里面就行

        @GetMapping("/function-ratelimiter")
        public String functionRateLimiter() {
            return rateLimiter.executeSupplier(() -> "function-ratelimiter normal");
        }
    
  5. application.yml中配置限流

    resilience4j:
      ratelimiter:
        limiters:
          hello:
            limit-for-period: 5
            limit-refresh-period: 3s
            timeout-duration: 5s
    
  6. 之后再连续调用会发现每3s只能调用5次

    @Test
    public void exceedRateLimit() throws Exception {
        for (int i = 0; i < 10; i++) {
            mockMvc.perform(MockMvcRequestBuilders.get("/hello/function-ratelimiter")
                            .queryParam("errorFlag", "false"))
                .andReturn()
                .getResponse()
                .getContentAsString();
        }
    }
    

服务配置

  1. SpringCloud提供了配置中心的功能, 可以通过请求HTTP API获取配置, 为分布式系统提供外置的配置支持; 他可以基于Git/SVN/JDBC等第三方平台提供配置服务

  2. SpringCloudConfig对配置的实现类似于SpringBoot对配置的支持, 也是实现类似PropertySource的类来实现的, 例如, 对于不同的平台:

    1. SpringCloudConfigClient: ConpositePropertySource
    2. Zookeeper: ZookeeperPropertySource
    3. Consul: ConsulPropertySource/ConsulFilesPropertySource
  3. SpringCloud通过PropertySourceLoacator实现了对PropertySource的定位功能; 它只有一个方法, 就是从Environment中获取PropertySource 或获取集合

    	PropertySource<?> locate(Environment environment);
    

SpringCloudConfig

  1. SpringCloudConfig类似于注册中心, 只需要引入spirng-cloud-config-server的依赖, 之后添加@EnableConfigServer就可以开启配置服务

  2. 使用Git作为配置的话, SpringCloudConfig是基于MultipleJGitEnvironmentProperties实现的, 主要的配置是spring.cloud.config.server.git.uri

    @SpringBootApplication
    @EnableConfigServer
    @EnableDiscoveryClient
    public class ConfigurationApplication {
        public static void main(String[] args) {
            SpringApplication.run(ConfigurationApplication.class, args);
        }
    }
    
  3. 之后就可以通过http请求获得配置了

    @Test
    public void configLoad() throws Exception {
        String response = mockMvc.perform(MockMvcRequestBuilders.get("/"))
            .andReturn()
            .getResponse()
            .getContentAsString();
        log.info(response);
    }
    

客户端

  1. 在有了服务端之后, 还要有一个客户端用于连接spring cloud config server获取配置
  2. 使用第一步是引入依赖spring-cloud-starter-config; 之后添加配置spring.cloud.config.uri配置配置中心的位置就可以了
  3. 也可以通过服务发现来配置, 具体需要启动spring.cloud.config.discovery.enabled然后配置对应的service idspring.cloud.config.discovery.service-id
  4. spring cloud 除了基本的提供配置的功能以外, 还可以支持配置的刷新, 只需要在properties添加@RefreshScope之后调用/actuator/refresh就可以实现配置的刷新

使用zookeeper作为配置中心

  1. SpringCloud除了使用git作为配置中心之外, 还可以使用zookeeper作为配置中心
  2. 第一步是引入zookeeper的依赖spring-cloud-starter-zookeeper-config; 之后通过spring.cloud.zookeeper.config.enabled开启即可
  3. 除此之外, 还可以通过以下配置修改配置的结构
    1. spring.cloud.zookeeper.config.root: config节点名
    2. spring.cloud.zookeeper.config.default-context: 默认的上下文
    3. spring.cloud.zookeeper.config.profile.separator: 应用名和profile的分隔符

Nacos

  1. SpringCloudAlibaba也提供了配置中心Nacos; 只需要引入spring-cloud-starter-alibaba-nacos-config并配置spring.cloud.nacos.config.server-addrspring.cloud.nacos.config.enabled即可

    spring.cloud.nacos.server-addr=localhost:8848
    spring.cloud.nacos.discovery.username=******
    spring.cloud.nacos.discovery.password=*******
    spring.cloud.nacos.discovery.namespace=public
    

消息队列

  1. SpringCloud中提供了SpringCloudStream用于构建消息驱动的微服务应用程序的轻量级框架; 它具有以下特性:

    1. 声明式编程模型
    2. 对消息队列的抽象: 发布订阅/消费组/分区
    3. 支持多种消息中间件: RabbitMQ, Kafka等
  2. 它主要通过Binder提供了消息队列的抽象, 为我们提供了中间件和应用程序的连接:

    在这里插入图片描述

  3. 对于生产者/消费者和消息系统之间的通信, SpringCloud提供了Binding的机制; 为我们提供了这三者之间的通信桥梁

  4. 核心概念:

    1. 消费组: 对于同一消息, 每个组中都会有消费者收到消息

      在这里插入图片描述

    2. 分区: 同一分区的数据只会被一个消费者消费

      在这里插入图片描述

  5. SpringCloudStream将消息通信分为了输入和输出两个部分, 因此提供了以下几个注解/接口供我们操作消息队列

    1. @EnableBinding:

    2. @Input/SubscribableChannel:

    3. @Output/MessageChannel:

  6. 消息队列的使用最重要的就是生产和消费, 在SpringCloud中, 消息的生产和消费分别使用以下方式实现

    1. 生产消息: @SendTo注解/MessageChannel#send()接口
    2. 消费消息: @StreamListener, 其中可以配置@Payload/@Headers/@Header
  7. 使用spring cloud stream kafka的第一步是引入依赖spring-cloud-starter-stream-kafka; 然后配置一些常用项

    1. spring.cloud.stream.kafka.binder.*: 与binder相关的配置
    2. spring.cloud.stream.kafka.bindings.<channelName>.consumer.*: 与binding相关的配置
    3. spring.kafka.*: kafka本身的配置
  8. 第一步配置kafka连接配置

    spring:
      main:
        web-application-type: none
      cloud:
        stream:
          kafka:
            binder:
              brokers:
                - server.passnight.local
                - replica.passnight.local
                - follower.passnight.local
              default-broker-port: 20015
          bindings:
            message:
              group: default-group
    
  9. 之后分别创建生产者消费者; 消费者接收到消息后会打印一条日志

    public interface KafkaProducer {
        String INPUT = "kafka-in";
        String OUTPUT = "kafka-out";
    
        @Input(INPUT)
        SubscribableChannel subscribableChannel();
    
        @Output(OUTPUT)
        MessageChannel messageChannel();
    }
    
    
    @Component
    @Slf4j
    public class KafkaConsumer {
        @StreamListener(KafkaProducer.OUTPUT)
        public void handleGreetings(@Payload String message) {
            log.info("Received message: {}", message);
        }
    }
    
  10. 编写一个服务类, 用于触发生产者生产消息

@Service
@RequiredArgsConstructor
public class KafkaProducerService {
    private final KafkaProducer kafkaProducer;

    public void sendMessage(String message) {
        kafkaProducer.messageChannel()
                .send((MessageBuilder
                        .withPayload(message)
                        .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON)
                        .build()));
    }
}
  1. 之后再测试类中调用服务, 就可以看到对应的日志了

    @SpringBootTest
    public class KafkaProducerServiceTest {
        @Autowired
        private KafkaProducerService kafkaProducerService;
    
        @Test
        public void messageTest() {
            kafkaProducerService.sendMessage("Test Message");
        }
    }
    // 输出为: INFO  [main] c.p.cloud.streamproducer.consumer.KafkaConsumer#[handleGreetings:14] - Received message: Test Message
    

服务治理

  1. 在微服务架构中, 服务之间的调用关系复杂度要远高于单体应用, 因此需要服务治理, 服务治理主要监控以下内容:
    1. 服务中的服务信息
    2. 服务之间的依赖关系
    3. 请求的执行路径; 及每个环节的耗时/状态

SpringCloud Sleuth

  1. SpringCloudSleuth是SpringCloud实现的一个分布式链路跟踪解决方案, 可以用于记录请求的开始/结束/耗时等信息
  2. 使用SpringCloudSleuth可以引入spring-cloud-starter-sleuth或引入和zipkin一同打包的spring-cloud-starter-zipkin zipkin和sleuth可以结合使用; 一起监控
  3. zipkin和sleuth常用的配置有:
    1. spring.zipkin.base-url: 配置zipkin路径
    2. spring.zipkin.discovery-clietn-enabled: 使用服务发现
    3. spring.zipkin.sender.type=web|rabbbit|kafka: 通过web请求/mq的方式埋点
    4. spring.zipkin.compression.enabled: 是否做压缩
    5. spring.sleuth.sampler.probability=0.1: 采样比例
基本使用
  1. 使用SpringCloudSleuth首先可以在客户端和服务端同时引入spring-cloud-starter-zipkin, 这里面包含了sleuth的依赖

  2. 之后在两个服务中同时配置项目, 这里将采样率设置为1以保证每次请求都被采样, 并配置sender.type为web

    spring:
      sleuth:
        sampler:
          probability: 1
      zipkin:
        base-url: http://server.passnight.local:20025
        sender:
          type: web
    
  3. 尝试发起请求; 这个请求最终会通过feign远程调用其他的服务

     curl localhost:9080/hello
    
  4. 然后就可以在zipkin的UI里面看到链路追踪信息了

    在这里插入图片描述

消息链路追踪
  1. Zipkin不仅可以追踪web形式的远程调用, 还能够追踪消息形式的远程调用

  2. 此时需要添加以下配置

引用


  1. DAO Support :: Spring Framework ↩︎

  2. Context Hierarchy :: Spring Framework ↩︎

  3. java - What does maxSwallowSize really do? - Stack Overflow ↩︎

  4. 深入了解服务注册与发现 - 知乎 (zhihu.com) ↩︎

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1870840.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Java-方法引用

方法引用概念 把已经有的方法拿过来用&#xff0c;当做函数式接口中抽象方法的方法体 前提条件 1、引用处必须是函数式接口 2、被引用的方法必须已经存在 3、被引用方法的形参和返回值 需要跟抽象方法保持一致 4、被引用方法的功能要满足当前需求 方法引用格式示例 方…

Micro-ROS是什么?

Micro-ROS是ROS&#xff08;Robot Operating System&#xff0c;机器人操作系统&#xff09;生态系统的一个重要组成部分&#xff0c;专为微控制器&#xff08;Microcontrollers&#xff09;设计的轻量级ROS版本。它的目标是在资源有限的嵌入式平台上实现ROS 2的功能&#xff0…

如何跑起来一个前后端项目

后端部署 第一步配置自己的maven 第二步优先导入自己本地jar包当本地没有在从远程下载 第三步找到配置文件 第四步成功运行后端部署完毕 前端部署 第一步看看项目node_modules有没有文件如果有就是已经安装好了对应的依赖&#xff0c;没有执行npm install 第二步运行即可

UE5 中的碰撞问题

文章目录 一、初始准备二、重叠和碰撞三、自定义碰撞 一、初始准备 首先我们创建一个 BP_ThirdPerson 项目&#xff0c;然后在项目中创建两个 Actor 的蓝图 Blueprint 首先是一个移动的 BP_Push&#xff0c;这里使用 time line 循环旋转 cube 的相对位置 得到效果如下 然后是…

用MySQL和navicatpremium做一个项目—(财务管理系统)。

1 ER图缩小的话怕你们看不清&#xff0c;所以截了两张图 2 vsdx绘图结果 3DDL和DML,都有点长分了好多次上传&#xff0c;慢慢看 DDL -- 用户表 CREATE TABLE users (user_id INT AUTO_INCREMENT PRIMARY KEY COMMENT 用户ID,username VARCHAR(50) NOT NULL UNIQUE COMMENT 用…

量化交易 - 策略回测

策略回测 1、什么是策略回测&#xff1f;2、策略回测的作用3、策略回测系统概述3.1策略回测中相关的指标介绍3.2量化交易策略的资金容量3.3 完整的策略回测系统包含哪些内容 1、什么是策略回测&#xff1f; 策略回测&#xff0c;也称之为策略回溯测试&#xff0c;是指利用交易…

002关于Geogebra软件的介绍及与MatLab的区别

为什么要学Geogebra&#xff1f; 因为和MatLab的科学计算相比&#xff0c;GeoGebra重点突出教学展示&#xff0c;对于教师、学生人群来讲再合适不过了&#xff0c;尤其是可以融入到PPT里边呈现交互式动画&#xff0c;想想听众的表情&#xff01;这不就弥补了看到PPT播放数学公…

AI 开发平台(Coze)搭建《美食推荐官》

前言 本文讲解如何从零开始&#xff0c;使用扣子平台去搭建《美食推荐官》 bot直达&#xff1a;美食推荐官 - 扣子 AI Bot (coze.cn) 欢迎大家体验一下&#xff01;&#xff01; 效果 正文 prompt 美食推荐官的首要任务就是推荐美食&#xff0c;基于这个我们要给他一个基…

高考志愿不知道怎么填?教你1招,用这款AI工具,立省4位数

高中的岁月&#xff0c;就像一本厚厚的书&#xff0c;我们一页页翻过&#xff0c;现在&#xff0c;终于翻到了最后一页。但这不是结束&#xff0c;这是新的开始&#xff0c;是人生的新篇章。 高考落幕&#xff0c;学子们在短暂的放松后&#xff0c;又迎来了紧张的志愿填报。 “…

强化学习:值函数近似【Deep Q-Network,DQN,Deep Q-learning】

强化学习笔记 主要基于b站西湖大学赵世钰老师的【强化学习的数学原理】课程&#xff0c;个人觉得赵老师的课件深入浅出&#xff0c;很适合入门. 第一章 强化学习基本概念 第二章 贝尔曼方程 第三章 贝尔曼最优方程 第四章 值迭代和策略迭代 第五章 强化学习实例分析:GridWorld…

开发自动回复信息的插件:代码的力量与智慧!

在信息爆炸的时代&#xff0c;自动回复信息的插件成为了许多用户和管理者的得力助手&#xff0c;这些插件能够根据预设的规则或算法&#xff0c;自动、快速、准确地回复用户的信息&#xff0c;极大地提高了沟通效率和用户体验。 而开发这样一款插件&#xff0c;离不开一系列精…

数字水产养殖中的鱼类追踪、计数和行为分析技术

随着全球人口增长和生态环境退化&#xff0c;传统捕捞已无法满足人类对水产品的需求&#xff0c;水产养殖成为主要的鱼类来源。数字水产养殖利用先进技术和数据驱动方法&#xff0c;对提高生产效率、改善鱼类福利和资源管理具有显著优势。 1 数字水产养殖的重要性 1.1 提高生…

汇聚荣做拼多多运营第一步是什么?

汇聚荣做拼多多运营第一步是什么?在众多电商平台中&#xff0c;拼多多凭借其独特的社交电商模式迅速崛起&#xff0c;吸引了大量消费者和商家的目光。对于希望在拼多多上开店的商家而言&#xff0c;了解如何进行有效运营是成功的关键。那么&#xff0c;汇聚荣做拼多多运营的第…

CSS的媒体查询:响应式布局的利器

关于CSS的媒体查询 CSS媒体查询是CSS层叠样式表(Cascading Style Sheets)中的一个核心功能&#xff0c;它使得开发者能够根据不同的设备特性和环境条件来应用不同的样式规则。这是实现响应式网页设计的关键技术&#xff0c;确保网站或应用能够在多种设备上&#xff0c;包括桌面…

flask 接收vuejs element el-upload传来的多个文件

el-upload通过action指定后端接口,并通过name指定传输的文件包裹在什么变量名中 <el-uploadclass="upload-demo"dragaction="https://ai.zscampus.com/toy/upload"multiplename="fileList":limit="10"accept=

World of Warcraft [CLASSIC] plugin lua

World of Warcraft [CLASSIC] plugin lua 魔兽世界lua脚本插件 World of Warcraft API - Wowpedia - Your wiki guide to the World of Warcraft D:\World of Warcraft\_classic_\Interface\AddOns zwf.lua function CountdownFunc()CountdownFrame CreateFrame("Fram…

【RedHat】使用VMware Workstation创建配置RedHat操作系统

目录 &#x1f31e;1.前言 &#x1f31e;2. 使用 VMware Workstation 创建配置RedHat &#x1f33c;2.1 VMware Workstation 创建虚拟机 &#x1f33c;2.2 安装RedHat 7.6 &#x1f30a;2.2.1 添加光盘 &#x1f30a;2.2.2 开始安装操作系统 &#x1f30a;2.2.3 系统初始…

大数据开发需要哪些职场知识

职场是个人情世故的江湖&#xff0c;除了专业技能&#xff0c;成功的大数据开发人员还需要掌握多种职场知识。以下是一些重要的职场知识和技能&#xff0c;结合实际例子详细说明。 目录 理论知识与工程实践理论知识工程实践例子 项目经验总结项目管理总结和反思例子 做事方式方…

指针并不是用来存储数据的,而是用来存储数据在内存中地址(内存操作/函数指针/指针函数)

推荐&#xff1a;1、4、5号书籍 1. 基本概念 首先&#xff0c;让小明了解指针的基本概念&#xff1a; 指针的定义&#xff1a;指针是一个变量&#xff0c;它存储的是另一个变量的地址。指针的声明&#xff1a;例如&#xff0c;int *p表示一个指向整数的指针变量p。 2. 形象…

RocketMQ:日常开发中有哪些使用MQ的场景

什么是消息队列&#xff1f; 消息队列是一种通信方法&#xff0c;允许应用程序通过发送和接收消息来互相通信。这些消息/任务/指令存储在一个中间介质中&#xff08;即队列&#xff09;&#xff0c;并由生产者发送&#xff0c;消费者接收。 使用场景 场景一&#xff1a;任务…