【Spring】Spring学习笔记

Spring数据库

Spring JDBC

环境准备

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

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

    properties 复制代码
    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. 之后启动测试类

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

    bash 复制代码
    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然后操作数据库

java 复制代码
    @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)
编程式事务
java 复制代码
@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");
    }
}

其输出结果为:

bash 复制代码
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类, 包含了数据库操作

    java 复制代码
    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在抛出异常之后事务就会自动回滚了, 下面的例子中不会插入数据

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

    java 复制代码
        // 只有被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. 创建自己的数据库访问异常类

    java 复制代码
    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 复制代码
    <?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. 编写测试用例, 断言抛出自定义的异常类

    java 复制代码
    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. 首先要定义一个实体类, 并用上述注解标注

    java 复制代码
    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:

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

    bash 复制代码
    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. 下面是咖啡对应的数据表

    sql 复制代码
    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. 下面是咖啡对应的实体类:

    java 复制代码
    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和结果映射

    java 复制代码
    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

      java 复制代码
      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扫描路径

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

    java 复制代码
    @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. 创建用户

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

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

    java 复制代码
    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容器中

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

    java 复制代码
    @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

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

    java 复制代码
    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连接

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

    java 复制代码
    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. 编写带缓存的服务

    java 复制代码
    @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的打印次数来判断缓存的使用情况

    java 复制代码
    @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, 用于快速查找

    java 复制代码
    @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

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

    java 复制代码
    // 写转换器
    @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. 在编写了转换器之后,还要注册

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

    java 复制代码
    @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. 引入依赖

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

    java 复制代码
        @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. 添加依赖

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

    java 复制代码
    @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. 尽管创建实体类并非必须步骤, 但是为了统一场景, 在这里还是创建了一个实体类, 模拟实际的业务场景

    java 复制代码
    @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, 所以这里没有配置连接工厂

    java 复制代码
    @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中配置连接串

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

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

    java 复制代码
    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操作数据库

    java 复制代码
    @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中配置连接串

    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

    java 复制代码
    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. 创建实体类

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

    java 复制代码
    @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支持

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

    java 复制代码
    @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

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

    java 复制代码
    @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容器当中

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

    java 复制代码
    @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对象打印出来了

    java 复制代码
    @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之后, 首先是编写非阻塞服务

    java 复制代码
    @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

    java 复制代码
    @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访问测试

    java 复制代码
    @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 Proxy AOP代理对象, 有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代理使切面生效**

    java 复制代码
    @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. 之后再对应的包下编写一个测试类, 测试几种通知类型

    java 复制代码
    @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. 最后执行测试, 观察打印结果

    java 复制代码
    @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

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

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

    java 复制代码
    @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. 再创建一个子容器, 它的父容器是上述容器

    xml 复制代码
    <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方法, 可以看到都被代理了

    java 复制代码
        @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();
        }
    bash 复制代码
    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. 而假设将切面移到子容器, 则只有子容器中的对象会被代理, 而父容器中的对象不会被代理

    java 复制代码
    @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();
    //    }
    }
    xml 复制代码
    <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>
    bash 复制代码
    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, @DeleteMapping Url映射器
    @RequestBody, @PathVariable,@RequestParam, @RequesHeader, @HttpEntity 请求参数
    @ResponseBody, @ResponseStatus @ResponseEntity 响应体/响应码

请求处理

基本使用

  1. 编写实体类和服务类

    java 复制代码
    @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需要添加对应的转换器

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

    java 复制代码
    @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默认的配置如下

    java 复制代码
    // 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

    java 复制代码
    @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中添加该配置

    java 复制代码
    @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. 首先在实体类上添加校验规则

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

    java 复制代码
        @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

    java 复制代码
        @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只是为了区分其他格式的参数

    java 复制代码
        @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的对应上

    java 复制代码
        @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下添加一个静态资源

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

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

    java 复制代码
        @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状态码

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

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

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

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

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

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

    java 复制代码
    @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实现的

    java 复制代码
    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请求时间

    java 复制代码
    @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中配置该拦截器

    java 复制代码
    @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来指定

    java 复制代码
    @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

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

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

    java 复制代码
    @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实现的

    xml 复制代码
    <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>然后打包为模块

    java 复制代码
    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中实现的

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

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

    java 复制代码
    @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. 引入对应的依赖

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

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

    java 复制代码
    @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. 以咖啡为例

    URI HTTP方法 含义
    /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接口

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

    java 复制代码
    @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及相关资源的访问

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

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

    json 复制代码
    {
        "_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页的三个元素

    java 复制代码
        @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的咖啡

    java 复制代码
        @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. 最简单地方式是直接修改配置文件

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

    java 复制代码
    @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, 使其启动之后会打印信息

    java 复制代码
    @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

    java 复制代码
    @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中添加该自动配置类

    java 复制代码
    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等注解, 以下面类为例

    java 复制代码
    // 匹配前缀为`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值

    java 复制代码
    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. 添加自定义的配置文件

    properties 复制代码
    passnight.greeting=hello from passnight
  2. 创建自定义的EnvironmentPostProcessor

    java 复制代码
    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

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

    java 复制代码
    @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来监控数据源的状态

    java 复制代码
    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()就可以了

    java 复制代码
    @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, 否则没有详细信息

    java 复制代码
    @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的修改

    java 复制代码
    @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

    java 复制代码
        @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指定其为最先打印的

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

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

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

    java 复制代码
    @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`中完成解析的

    yml 复制代码
    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服务

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

    yml 复制代码
    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开启服务发现

    java 复制代码
    @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的连接路径之后就可以成功注册了

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

    bash 复制代码
    [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中读取, 获取域名+端口格式的配置项并解析为实例

    java 复制代码
    @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负载均衡使用的

    java 复制代码
    @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中添加对应的配置

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

    java 复制代码
    @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原有的请求实现的

    java 复制代码
    @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返回不同的信息; 用以区分

    java 复制代码
    @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注解表明客户端需要负载均衡请求

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

    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

    java 复制代码
    @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. 可以看到每次打印的数字都不一样, 表明请求的不是同一个服务

java 复制代码
@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

    yml 复制代码
    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的支持

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

    yml 复制代码
    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相同

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

    java 复制代码
    @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配置

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

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

    java 复制代码
    @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, 并返回其对应的结果:

    java 复制代码
    @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上添加对应的配置

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

    java 复制代码
    @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提供的断路器了

    java 复制代码
    @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. 之后可以使用注解或函数式两种方式来声明熔断,

    java 复制代码
    @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中配置

    yaml 复制代码
    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的结果

    java 复制代码
    @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也有两种使用方式, 分别是声明式和编程式

    java 复制代码
    @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. 之后大流量请求, 过多的请求就会被拦截了

    java 复制代码
    @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()里面就行

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

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

    java 复制代码
    @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 或获取集合

    java 复制代码
    	PropertySource<?> locate(Environment environment);

SpringCloudConfig

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

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

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

    java 复制代码
    @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即可

    properties 复制代码
    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连接配置

    yml 复制代码
    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. 之后分别创建生产者消费者; 消费者接收到消息后会打印一条日志

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

java 复制代码
@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. 之后再测试类中调用服务, 就可以看到对应的日志了

    java 复制代码
    @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

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

    bash 复制代码
     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) ↩︎

相关推荐
听忆.几秒前
手机屏幕上进行OCR识别方案
笔记
幼儿园老大*35 分钟前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
Selina K38 分钟前
shell脚本知识点记录
笔记·shell
鹿屿二向箔42 分钟前
基于SSM(Spring + Spring MVC + MyBatis)框架的汽车租赁共享平台系统
spring·mvc·mybatis
豪宇刘1 小时前
SpringBoot+Shiro权限管理
java·spring boot·spring
1 小时前
开源竞争-数据驱动成长-11/05-大专生的思考
人工智能·笔记·学习·算法·机器学习
ctrey_1 小时前
2024-11-4 学习人工智能的Day21 openCV(3)
人工智能·opencv·学习
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
霍格沃兹测试开发学社测试人社区2 小时前
软件测试学习笔记丨Flask操作数据库-数据库和表的管理
软件测试·笔记·测试开发·学习·flask
今天我又学废了2 小时前
Scala学习记录,List
学习