写作时间:2019-10-05
Spring Boot: 2.1 ,JDK: 1.8, IDE: IntelliJ IDEA
SpringBoot中默认用Redis替换Jedis来访问Redis内存数据库。
说明Redis操作数据的效率高,本章记录Redis Template, Redis Repository的用法。
package org.springframework.data.redis.connection;
// import ...
public class RedisStandaloneConfiguration
implements RedisConfiguration, WithHostAndPort, WithPassword, WithDatabaseIndex {
private static final String DEFAULT_HOST = "localhost";
private static final int DEFAULT_PORT = 6379;
private String hostName = DEFAULT_HOST;
private int port = DEFAULT_PORT;
private int database;
private RedisPassword password = RedisPassword.none();
/**
* Create a new default {@link RedisStandaloneConfiguration}.
*/
public RedisStandaloneConfiguration() {}
/**
* Create a new {@link RedisStandaloneConfiguration} given {@code hostName}.
*
* @param hostName must not be {@literal null} or empty.
*/
public RedisStandaloneConfiguration(String hostName) {
this(hostName, DEFAULT_PORT);
}
// ...
}
解析:主要配置Host, Port, password, database, 并且都有默认值,
还有灵活参数的构造函数。
package org.springframework.data.redis.connection;
// import ...
public class RedisSentinelConfiguration implements RedisConfiguration, SentinelConfiguration {
private static final String REDIS_SENTINEL_MASTER_CONFIG_PROPERTY = "spring.redis.sentinel.master";
private static final String REDIS_SENTINEL_NODES_CONFIG_PROPERTY = "spring.redis.sentinel.nodes";
private @Nullable NamedNode master;
private Set<RedisNode> sentinels;
private int database;
private RedisPassword password = RedisPassword.none();
/**
* Creates new {@link RedisSentinelConfiguration}.
*/
public RedisSentinelConfiguration() {
this(new MapPropertySource("RedisSentinelConfiguration", Collections.emptyMap()));
}
/**
* Creates {@link RedisSentinelConfiguration} looking up values in given {@link PropertySource}.
*
*
*
* spring.redis.sentinel.master=myMaster
* spring.redis.sentinel.nodes=127.0.0.1:23679,127.0.0.1:23680,127.0.0.1:23681
*
*
*
* @param propertySource must not be {@literal null}.
* @since 1.5
*/
public RedisSentinelConfiguration(PropertySource<?> propertySource) {
// ...
解析:master 存储为字符串名字,sentinels为多个对象的set,每个对象都包含host, port, database, 实际上master必须活跃,sentinels可以相互负载均衡。哨兵模式可以作为读写分离。
package org.springframework.data.redis.connection;
// import ...
public class RedisClusterConfiguration implements RedisConfiguration, ClusterConfiguration {
private static final String REDIS_CLUSTER_NODES_CONFIG_PROPERTY = "spring.redis.cluster.nodes";
private static final String REDIS_CLUSTER_MAX_REDIRECTS_CONFIG_PROPERTY = "spring.redis.cluster.max-redirects";
private Set<RedisNode> clusterNodes;
private @Nullable Integer maxRedirects;
private RedisPassword password = RedisPassword.none();
/**
* Creates {@link RedisClusterConfiguration} looking up values in given {@link PropertySource}.
*
*
*
* spring.redis.cluster.nodes=127.0.0.1:23679,127.0.0.1:23680,127.0.0.1:23681
* spring.redis.cluster.max-redirects=3
*
*
*
* @param propertySource must not be {@literal null}.
*/
public RedisClusterConfiguration(PropertySource<?> propertySource) {
// ...
解析:
package org.springframework.boot.autoconfigure.data.redis;
// import ...
@ConfigurationProperties(
prefix = "spring.redis"
)
public class RedisProperties {
private int database = 0;
private String url;
private String host = "localhost";
private String password;
private int port = 6379;
private boolean ssl;
private Duration timeout;
private RedisProperties.Sentinel sentinel;
private RedisProperties.Cluster cluster;
private final RedisProperties.Jedis jedis = new RedisProperties.Jedis();
private final RedisProperties.Lettuce lettuce = new RedisProperties.Lettuce();
public static class Pool {
/**
* Maximum number of "idle" connections in the pool. Use a negative value to
* indicate an unlimited number of idle connections.
*/
private int maxIdle = 8;
/**
* Target for the minimum number of idle connections to maintain in the pool. This
* setting only has an effect if it is positive.
*/
private int minIdle = 0;
/**
* Maximum number of connections that can be allocated by the pool at a given
* time. Use a negative value for no limit.
*/
private int maxActive = 8;
// ...
}
// ...
解析:SpringBoot通过application.yml配置文件配置RedisProperties,
比如host, port, password, pool 等。
Lettuce 内置支持读写分离
LettuceClientConfiguration
LettucePoolingClientConfiguration
LettuceClientConfigurationBuilderCustomizer
如果已经存在 Redis的镜像,直接启动就好
更多知识请参考:第廿五篇:SpringBoot之Jedis访问Redis
进入Redis bash 命令行
% docker exec -it redis bash
打开redis客户端
root@8eb8d32453bb:/data# redis-cli
查看Redis中的keys值, 查看内容,删除掉key (ke值需要带双引号)
127.0.0.1:6379> keys *
1) "starbucks-menu"
127.0.0.1:6379> hgetall starbucks-menu
1) "espresso"
2) "2000"
3) "latte"
4) "2500"
5) "capuccino"
6) "2500"
7) "mocha"
8) "3000"
9) "macchiato"
10) "3000"
127.0.0.1:6379> del "starbucks-menu"
(integer) 1
下载已经创建好的Starbucks项目,
重命名根文件夹名字的JedisDemo,用Idea打开工程,运行后实际为JPA操作数据。
接下来就改造为Jedis操作Redis数据。
pom.xml增加, Redis, Pool2 连接池配置
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
dependency>
这里要删掉下面的配置信息,否则会引起循环引用错误
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
循环引用报错
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
servletEndpointRegistrar defined in class path resource [org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfiguration$WebMvcServletEndpointManagementContextConfiguration.class]
↓
healthEndpoint defined in class path resource [org/springframework/boot/actuate/autoconfigure/health/HealthEndpointConfiguration.class]
↓
healthIndicatorRegistry defined in class path resource [org/springframework/boot/actuate/autoconfigure/health/HealthIndicatorAutoConfiguration.class]
↓
org.springframework.boot.actuate.autoconfigure.redis.RedisReactiveHealthIndicatorAutoConfiguration
┌─────┐
| redisConnectionFactory defined in class path resource [org/springframework/boot/autoconfigure/data/redis/LettuceConnectionConfiguration.class]
↑ ↓
| starbucksApplication (field private zgpeace.spring.starbucks.service.CoffeeService zgpeace.spring.starbucks.StarbucksApplication.coffeeService)
↑ ↓
| coffeeService (field private org.springframework.data.redis.core.RedisTemplate zgpeace.spring.starbucks.service.CoffeeService.redisTemplate)
↑ ↓
| redisTemplate defined in zgpeace.spring.starbucks.StarbucksApplication
└─────┘
RedisTemplate
StringRedisTemplate
切记:一定注意设置过期时间!!!
src > main > resources > application.yml
spring:
jpa:
hibernate:
ddl-auto: none
properties:
hibernate:
show_sql: true
format_sql: true
redis:
host: "localhost"
lettuce:
pool:
max-active: 5
max-idle: 5
management:
endpoints:
web:
exposure:
include: "*"
配置Redis的信息
zgpeace.spring.starbucks.service.CoffeeService
package zgpeace.spring.starbucks.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.ExampleMatcher;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import zgpeace.spring.starbucks.model.Coffee;
import zgpeace.spring.starbucks.repository.CoffeeRepository;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.springframework.data.domain.ExampleMatcher.GenericPropertyMatchers.exact;
@Slf4j
@Service
public class CoffeeService {
private static final String CACHE = "starbucks-coffee";
@Autowired
private CoffeeRepository coffeeRepository;
@Autowired
private RedisTemplate<String, Coffee> redisTemplate;
public List<Coffee> findAllCoffee() {
return coffeeRepository.findAll();
}
public Optional<Coffee> findOneCoffee(String name) {
HashOperations<String, String, Coffee> hashOperations = redisTemplate.opsForHash();
if (redisTemplate.hasKey(CACHE) && hashOperations.hasKey(CACHE, name)) {
log.info("Get coffee {} from Redis.", name);
return Optional.of(hashOperations.get(CACHE, name));
}
ExampleMatcher matcher = ExampleMatcher.matching()
.withMatcher("name", exact().ignoreCase());
Optional<Coffee> coffee = coffeeRepository.findOne(
Example.of(Coffee.builder().name(name).build(), matcher));
log.info("Coffee Found: {}", coffee);
if (coffee.isPresent()) {
log.info("Put coffee {} to Redis.", name);
hashOperations.put(CACHE, name, coffee.get());
redisTemplate.expire(CACHE, 1, TimeUnit.MINUTES);
}
return coffee;
}
}
解析:
zgpeace.spring.starbucks.StarbucksApplication
package zgpeace.spring.starbucks;
import io.lettuce.core.ReadFrom;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.data.redis.LettuceClientConfigurationBuilderCustomizer;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import zgpeace.spring.starbucks.model.Coffee;
import zgpeace.spring.starbucks.service.CoffeeService;
import java.util.Optional;
@Slf4j
@EnableTransactionManagement
@SpringBootApplication
@EnableJpaRepositories
public class StarbucksApplication implements ApplicationRunner {
@Autowired
private CoffeeService coffeeService;
public static void main(String[] args) {
SpringApplication.run(StarbucksApplication.class, args);
}
@Bean
public RedisTemplate<String, Coffee> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Coffee> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
@Bean
public LettuceClientConfigurationBuilderCustomizer customizer() {
return builder -> builder.readFrom(ReadFrom.MASTER_PREFERRED);
}
@Override
public void run(ApplicationArguments args) throws Exception {
Optional<Coffee> c = coffeeService.findOneCoffee("mocha");
log.info("Coffee {}", c);
for (int i = 0; i < 5; i++) {
c = coffeeService.findOneCoffee("mocha");
}
log.info("Value from Redis: {}", c);
}
}
解析:
ReadFrom.MASTER_PREFERRED 表示从主节点读取Redis。
运行结果如下:第一次读取数据库,然后存入Redis,后面5次查询读取Redis
Hibernate:
select
coffee0_.id as id1_0_,
coffee0_.create_time as create_t2_0_,
coffee0_.update_time as update_t3_0_,
coffee0_.name as name4_0_,
coffee0_.price as price5_0_
from
t_coffee coffee0_
where
lower(coffee0_.name)=?
Coffee Found: Optional[Coffee(super=BaseEntity(id=4, createTime=2019-10-05 11:39:45.379, updateTime=2019-10-05 11:39:45.379), name=mocha, price=CNY 30.00)]
Put coffee mocha to Redis.
Coffee Optional[Coffee(super=BaseEntity(id=4, createTime=2019-10-05 11:39:45.379, updateTime=2019-10-05 11:39:45.379), name=mocha, price=CNY 30.00)]
Get coffee mocha from Redis.
Get coffee mocha from Redis.
Get coffee mocha from Redis.
Get coffee mocha from Redis.
Get coffee mocha from Redis.
Value from Redis: Optional[Coffee(super=BaseEntity(id=4, createTime=2019-10-05 11:39:45.379, updateTime=2019-10-05 11:39:45.379), name=mocha, price=CNY 30.00)]
Closing JPA EntityManagerFactory for persistence unit 'default'
因为设置1分钟过期时间,所以1分钟后就查不到内容
127.0.0.1:6379> keys *
1) "\xac\xed\x00\x05t\x00\x12starbucks-coffee"
127.0.0.1:6379> hgetall "\xac\xed\x00\x05t\x00\x12starbucks-coffee"
1) "\xac\xed\x00\x05t\x00\x05mocha"
2) "\xac\xed\x00\x05sr\x00%zgpeace.spring.starbucks.model.Coffee\xfc|\x99\x99\xe0\x87\\H\x02\x00\x02L\x00\x04namet\x00\x12Ljava/lang/String;L\x00\x05pricet\x00\x16Lorg/joda/money/Money;xr\x00)zgpeace.spring.starbucks.model.BaseEntity\x15\xa6\x90\x00\x89\x0e\xff\x88\x02\x00\x03L\x00\ncreateTimet\x00\x10Ljava/util/Date;L\x00\x02idt\x00\x10Ljava/lang/Long;L\x00\nupdateTimeq\x00~\x00\x04xpsr\x00\x12java.sql.Timestamp&\x18\xd5\xc8\x01S\xbfe\x02\x00\x01I\x00\x05nanosxr\x00\x0ejava.util.Datehj\x81\x01KYt\x19\x03\x00\x00xpw\b\x00\x00\x01m\x9a\x00w\xe8x\x16\x97\x14\xc0sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x00\x00\x00\x00\x00\x04sq\x00~\x00\aw\b\x00\x00\x01m\x9a\x00w\xe8x\x16\x97\x14\xc0t\x00\x05mochasr\x00\x12org.joda.money.Serq\xd7\xfe\x1b\x88\xed\x97\x9c\x0c\x00\x00xpw\x14M\x00\x03CNY\x00\x9c\x00\x02\x00\x00\x00\x02\x0b\xb8\x00\x00\x00\x02x"
实体注解
处理不同类型数据源的 Repository
如何区分这些 Repository
跟上面 1.1 RedisTemplate一模一样
跟上面 1.2 application配置一模一样
package zgpeace.spring.starbucks.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.joda.money.Money;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;
@RedisHash(value = "starbucks-coffee", timeToLive = 60)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CoffeeCache {
@Id
private Long id;
@Indexed
private String name;
private Money price;
}
解析:
从Redis中读数据
zgpeace.spring.starbucks.converter.BytesToMoneyConverter
package zgpeace.spring.starbucks.converter;
import org.joda.money.CurrencyUnit;
import org.joda.money.Money;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.ReadingConverter;
import java.nio.charset.StandardCharsets;
@ReadingConverter
public class BytesToMoneyConverter implements Converter<byte[], Money> {
@Override
public Money convert(byte[] bytes) {
String value = new String(bytes, StandardCharsets.UTF_8);
return Money.of(CurrencyUnit.of("CNY"), Long.parseLong(value));
}
}
从Redis中写数据
zgpeace.spring.starbucks.converter.MoneyToBytesConverter
package zgpeace.spring.starbucks.converter;
import org.joda.money.Money;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.WritingConverter;
import java.nio.charset.StandardCharsets;
@WritingConverter
public class MoneyToBytesConverter implements Converter<Money, byte[]> {
@Override
public byte[] convert(Money money) {
String value = Long.toString(money.getAmountMinorLong());
return value.getBytes(StandardCharsets.UTF_8);
}
}
zgpeace.spring.starbucks.repository.CoffeeCacheRepository
package zgpeace.spring.starbucks.repository;
import org.springframework.data.repository.CrudRepository;
import zgpeace.spring.starbucks.model.CoffeeCache;
import java.util.Optional;
public interface CoffeeCacheRepository extends CrudRepository<CoffeeCache, Long> {
Optional<CoffeeCache> findOneByName(String name);
}
zgpeace.spring.starbucks.service.CoffeeService
package zgpeace.spring.starbucks.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.ExampleMatcher;
import org.springframework.stereotype.Service;
import zgpeace.spring.starbucks.model.Coffee;
import zgpeace.spring.starbucks.model.CoffeeCache;
import zgpeace.spring.starbucks.repository.CoffeeCacheRepository;
import zgpeace.spring.starbucks.repository.CoffeeRepository;
import java.util.Optional;
import static org.springframework.data.domain.ExampleMatcher.GenericPropertyMatchers.exact;
@Slf4j
@Service
public class CoffeeService {
@Autowired
private CoffeeRepository coffeeRepository;
@Autowired
private CoffeeCacheRepository cacheRepository;
public Optional<Coffee> findSimpleCoffeeFromCache(String name) {
Optional<CoffeeCache> cached = cacheRepository.findOneByName(name);
if (cached.isPresent()) {
CoffeeCache coffeeCache = cached.get();
Coffee coffee = Coffee.builder()
.name(coffeeCache.getName())
.price(coffeeCache.getPrice())
.build();
log.info("Coffee {} found in cache.", coffeeCache);
return Optional.of(coffee);
} else {
Optional<Coffee> raw = findOneCoffee(name);
raw.ifPresent(c -> {
CoffeeCache coffeeCache = CoffeeCache.builder()
.id(c.getId())
.name(c.getName())
.price(c.getPrice())
.build();
log.info("Save Coffee {} to cache.", coffeeCache);
cacheRepository.save(coffeeCache);
});
return raw;
}
}
public Optional<Coffee> findOneCoffee(String name) {
ExampleMatcher matcher = ExampleMatcher.matching()
.withMatcher("name", exact().ignoreCase());
Optional<Coffee> coffee = coffeeRepository.findOne(
Example.of(Coffee.builder().name(name).build(), matcher));
log.info("Coffee Found: {}", coffee);
return coffee;
}
}
解析:
zgpeace.spring.starbucks.StarbucksApplication
package zgpeace.spring.starbucks;
import io.lettuce.core.ReadFrom;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.data.redis.LettuceClientConfigurationBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.redis.core.convert.RedisCustomConversions;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import zgpeace.spring.starbucks.converter.BytesToMoneyConverter;
import zgpeace.spring.starbucks.converter.MoneyToBytesConverter;
import zgpeace.spring.starbucks.model.Coffee;
import zgpeace.spring.starbucks.model.CoffeeOrder;
import zgpeace.spring.starbucks.model.OrderState;
import zgpeace.spring.starbucks.repository.CoffeeRepository;
import zgpeace.spring.starbucks.service.CoffeeOrderService;
import zgpeace.spring.starbucks.service.CoffeeService;
import java.util.Arrays;
import java.util.Optional;
@Slf4j
@EnableTransactionManagement
@EnableJpaRepositories
@EnableRedisRepositories
@SpringBootApplication
public class StarbucksApplication implements ApplicationRunner {
@Autowired
private CoffeeService coffeeService;
@Bean
public LettuceClientConfigurationBuilderCustomizer customizer() {
return builder -> builder.readFrom(ReadFrom.MASTER_PREFERRED);
}
@Bean
public RedisCustomConversions redisCustomConversions() {
return new RedisCustomConversions(
Arrays.asList(new MoneyToBytesConverter(), new BytesToMoneyConverter())
);
}
@Override
public void run(ApplicationArguments args) throws Exception {
Optional<Coffee> c = coffeeService.findSimpleCoffeeFromCache("mocha");
log.info("Coffee: {}", c);
for (int i = 0; i < 5; i++) {
c = coffeeService.findSimpleCoffeeFromCache("mocha");
}
log.info("Value from Redis: {}", c);
}
public static void main(String[] args) {
SpringApplication.run(StarbucksApplication.class, args);
}
}
解析:
源码解析
Money Converter可以自定义是因为RegisterBeansForRoot预留了定制方法registerIfNotAlreadyRegistered(…)
package org.springframework.data.redis.repository.configuration;
// import ...
public class RedisRepositoryConfigurationExtension extends KeyValueRepositoryConfigurationExtension {
private static final String REDIS_CONVERTER_BEAN_NAME = "redisConverter";
private static final String REDIS_REFERENCE_RESOLVER_BEAN_NAME = "redisReferenceResolver";
private static final String REDIS_ADAPTER_BEAN_NAME = "redisKeyValueAdapter";
private static final String REDIS_CUSTOM_CONVERSIONS_BEAN_NAME = "redisCustomConversions";
// ...
@Override
public void registerBeansForRoot(BeanDefinitionRegistry registry, RepositoryConfigurationSource configurationSource) {
// ...
// register coustom conversions
RootBeanDefinition customConversions = new RootBeanDefinition(RedisCustomConversions.class);
registerIfNotAlreadyRegistered(customConversions, registry, REDIS_CUSTOM_CONVERSIONS_BEAN_NAME,
configurationSource);
查询keys值
127.0.0.1:6379> keys *
1) "starbucks-coffee:name:mocha"
2) "starbucks-coffee:4"
3) "starbucks-coffee:4:phantom"
4) "starbucks-coffee"
5) "starbucks-coffee:4:idx"
查看key对应value的类型,并用对应的数据类型查询
127.0.0.1:6379> type "starbucks-coffee:4"
hash
127.0.0.1:6379> hgetall "starbucks-coffee:4"
1) "_class"
2) "zgpeace.spring.starbucks.model.CoffeeCache"
3) "id"
4) "4"
5) "name"
6) "mocha"
7) "price"
8) "3000"
127.0.0.1:6379> type "starbucks-coffee:name:mocha"
set
127.0.0.1:6379> smembers "starbucks-coffee:name:mocha"
1) "4"
127.0.0.1:6379> hgetall "starbucks-coffee"
(error) WRONGTYPE Operation against a key holding the wrong kind of value
解析: 如果查询方法与真实数据不符,就会报错:
(error) WRONGTYPE Operation against a key holding the wrong kind of value
恭喜你,学会了Redis template,Redis Repository操作数据。
代码下载:
https://github.com/zgpeace/Spring-Boot2.1/tree/master/Nosql/RedisTemplate
https://github.com/zgpeace/Spring-Boot2.1/tree/master/Nosql/RedisRepository
https://redis.io
http://try.redis.io/
https://hub.docker.com/_/redis
https://www.runoob.com/redis/redis-tutorial.html
https://www.runoob.com/docker/docker-install-redis.html
https://github.com/zgpeace/Spring-Boot2.1/tree/master/db/DemoDBStarbucks
https://github.com/geektime-geekbang/geektime-spring-family/tree/master/Chapter%204/redis-demo
https://github.com/geektime-geekbang/geektime-spring-family/tree/master/Chapter%204/redis-repository-demo