diff --git a/build.gradle b/build.gradle index 0fe4f81..073817f 100644 --- a/build.gradle +++ b/build.gradle @@ -78,6 +78,7 @@ subprojects { project -> compileOnly "io.vertx:vertx-codegen:$vertxVersion:processor" compileOnly "io.vertx:vertx-web:${vertxVersion}" compileOnly "io.vertx:vertx-pg-client:$vertxVersion" + compileOnly ("io.vertx:vertx-redis-client:$vertxVersion") // lombok diff --git a/settings.gradle b/settings.gradle index 28df181..06978fa 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,3 +7,5 @@ include ':vertx-serviceproxy-spring-boot-starter' include ':vertx-web-spring-boot-starter' include ':vertx-web-swagger-spring-boot-starter' include ':vertx-pgclient-spring-boot-starter' +include 'vertx-redis-spring-boot-starter' + diff --git a/vertx-redis-spring-boot-starter/build.gradle b/vertx-redis-spring-boot-starter/build.gradle new file mode 100644 index 0000000..fafbd7f --- /dev/null +++ b/vertx-redis-spring-boot-starter/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'java' +} + +group 'com.github.Project5E' +version = projectVersion + +repositories { + mavenCentral() +} + +dependencies { + api project(":vertx-spring-boot-starter") + api("io.vertx:vertx-redis-client:$vertxVersion") + + testCompile group: 'junit', name: 'junit', version: '4.12' +} diff --git a/vertx-spring-boot-autoconfigure/build.gradle b/vertx-spring-boot-autoconfigure/build.gradle index baa15ea..5bbbf55 100644 --- a/vertx-spring-boot-autoconfigure/build.gradle +++ b/vertx-spring-boot-autoconfigure/build.gradle @@ -6,4 +6,5 @@ bootJar { } jar { enabled = true -} \ No newline at end of file +} +description = "vertx Spring Boot AutoConfigure" \ No newline at end of file diff --git a/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/autoconfigure/ReconnectHandler.java b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/autoconfigure/ReconnectHandler.java new file mode 100644 index 0000000..874950a --- /dev/null +++ b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/autoconfigure/ReconnectHandler.java @@ -0,0 +1,15 @@ +package com.project5e.vertx.data.redis.autoconfigure; + +/** + * @author: tk + * @since: 2020/11/1 + */ +public interface ReconnectHandler { + + /** + * reconnect when previous connection was failed + * @see io.vertx.redis.client.impl.RedisStandaloneConnection#fail(Throwable) + * @param error the error when previous connection failed + */ + void handleReconnect(Throwable error); +} diff --git a/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/autoconfigure/VertxRedisAutoConfiguration.java b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/autoconfigure/VertxRedisAutoConfiguration.java new file mode 100644 index 0000000..87e9b48 --- /dev/null +++ b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/autoconfigure/VertxRedisAutoConfiguration.java @@ -0,0 +1,237 @@ +package com.project5e.vertx.data.redis.autoconfigure; + +import com.project5e.vertx.data.redis.exception.IllegalRedisPropertiesException; +import com.project5e.vertx.data.redis.exception.RedisConnectionCreateTimeoutException; +import com.project5e.vertx.data.redis.exception.RedisNodeEmptyException; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.redis.client.*; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** + * @author: tk + * @since: 2020/10/31 + */ +@Slf4j +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(Redis.class) +@EnableConfigurationProperties(VertxRedisProperties.class) +public class VertxRedisAutoConfiguration implements ApplicationContextAware { + private static final long CREATE_CLIENT_TIMEOUT = 10000; + private static final String CONNECTION_PROTOCOL = "redis://"; + private static final String COLON = ":"; + private static final String DIAGONAL = "/"; + + private final VertxRedisProperties properties; + private ApplicationContext applicationContext; + private final List reconnectHandlerPipeline = new ArrayList<>(); + + public VertxRedisAutoConfiguration(VertxRedisProperties properties) { + if (BooleanUtils.isFalse(validate(properties))) { + throw new IllegalRedisPropertiesException(); + } + this.properties = properties; + } + + @PostConstruct + public void init() { + String[] handlerNames = BeanFactoryUtils.beanNamesForTypeIncludingAncestors(applicationContext, ReconnectHandler.class, true, false); + for (String name : handlerNames) { + if (name.equals(ReconnectHandler.class.getName())) { + continue; + } + ReconnectHandler bean = applicationContext.getBean(name, ReconnectHandler.class); + reconnectHandlerPipeline.add(bean); + } + } + + @Bean + public RedisOptions redisOptions() { + RedisOptions options = new RedisOptions(); + + if (StringUtils.isNotBlank(properties.getUrl())) { + options.setConnectionString(properties.getUrl() + DIAGONAL + properties.getDatabase()); + } else { + options.setConnectionString(CONNECTION_PROTOCOL + properties.getHost() + + COLON + properties.getPort() + DIAGONAL + properties.getDatabase()); + options.setPassword(properties.getPassword()); + } + options.setPassword(properties.getPassword()); + + resolveSentinel(options); + resolveCluster(options); + + options.setRole(getRole()); + options.setType(getClientType()); + options.setUseSlave(getSlaves()); + + //connection pool initialization + if (this.properties.getPool().isEnable()) { + options.setMaxPoolSize(this.properties.getPool().getMaxPoolSize()); + options.setMaxWaitingHandlers(this.properties.getPool().getMaxWaitingHandlers()); + options.setMaxPoolWaiting(this.properties.getPool().getMaxPoolWaiting()); + options.setPoolCleanerInterval(this.properties.getPool().getPoolCleanerInterval()); + options.setMaxPoolWaiting(this.properties.getPool().getMaxPoolWaiting()); + } + + return options; + } + + @SneakyThrows + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(RedisOptions.class) + @ConditionalOnProperty(prefix = VertxRedisProperties.PREFIX, name = "singleConnection", havingValue = "true") + public RedisConnection connection(Vertx vertx, RedisOptions options) { + Promise promise = Promise.promise(); + Semaphore semaphore = new Semaphore(1); + semaphore.acquire(); + + Redis.createClient(vertx, options) + .connect(onConnect -> { + if (onConnect.succeeded()) { + promise.complete(onConnect.result()); + semaphore.release(); + + resolveReconnect(onConnect.result()); + } + }); + //wait 10s for redis connection complete + semaphore.tryAcquire(CREATE_CLIENT_TIMEOUT, TimeUnit.SECONDS); + RedisConnection client = promise.future().result(); + if (client == null) { + log.error("timeout when create redis client"); + throw new RedisConnectionCreateTimeoutException(); + } + return client; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(RedisOptions.class) + public Redis client(Vertx vertx, RedisOptions options) { + + return Redis.createClient(vertx, options) + .connect(onConnect -> { + if (onConnect.succeeded()) { + log.info("connection in redis client was established successfully"); + + resolveReconnect(onConnect.result()); + } + }); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(Redis.class) + public RedisAPI redisApi(Redis client) { + return RedisAPI.api(client); + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + private void resolveReconnect(RedisConnection connection) { + if (CollectionUtils.isNotEmpty(reconnectHandlerPipeline)) { + connection.exceptionHandler(event -> { + reconnectHandlerPipeline.forEach(reconnectHandler -> reconnectHandler.handleReconnect(event)); + }); + } + } + + private RedisClientType getClientType() { + if (this.properties.getSentinel() != null) { + return RedisClientType.SENTINEL; + } + if (this.properties.getCluster() != null) { + return RedisClientType.CLUSTER; + } + + return RedisClientType.STANDALONE; + } + + private RedisRole getRole() { + if (this.properties.getSentinel() != null && this.properties.getSentinel().isReadonly()) { + return RedisRole.SLAVE; + } + + return RedisRole.MASTER; + } + + private RedisSlaves getSlaves() { + VertxRedisProperties.Cluster cluster = this.properties.getCluster(); + if (cluster != null) { + if (cluster.isAlways()) { + return RedisSlaves.ALWAYS; + } + if (cluster.isShare()) { + return RedisSlaves.SHARE; + } + } + + return RedisSlaves.NEVER; + } + + private void resolveSentinel(RedisOptions options) { + VertxRedisProperties.Sentinel sentinel = this.properties.getSentinel(); + if (sentinel != null) { + if (ArrayUtils.isEmpty(sentinel.getNodes())) { + throw new RedisNodeEmptyException(); + } + + List endpoints = new ArrayList<>(sentinel.getNodes().length); + for (String node : sentinel.getNodes()) { + endpoints.add(CONNECTION_PROTOCOL + node); + } + options.setEndpoints(endpoints); + + options.setMasterName(sentinel.getMasterName()); + } + } + + private void resolveCluster(RedisOptions options) { + VertxRedisProperties.Cluster cluster = this.properties.getCluster(); + if (cluster != null) { + if (ArrayUtils.isEmpty(cluster.getNodes())) { + throw new RedisNodeEmptyException(); + } + for (String node : cluster.getNodes()) { + options.addConnectionString(node); + } + } + } + + /** + * check properties + * + * @return false:throw {@link com.project5e.vertx.data.redis.exception.IllegalRedisPropertiesException} + */ + private boolean validate(VertxRedisProperties properties) { + + return Boolean.TRUE; + } +} diff --git a/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/autoconfigure/VertxRedisProperties.java b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/autoconfigure/VertxRedisProperties.java new file mode 100644 index 0000000..8a0c8db --- /dev/null +++ b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/autoconfigure/VertxRedisProperties.java @@ -0,0 +1,135 @@ +package com.project5e.vertx.data.redis.autoconfigure; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * @author: tk + * @since: 2020/10/31 + */ +@Data +@ConfigurationProperties(prefix = VertxRedisProperties.PREFIX) +public class VertxRedisProperties { + public static final String PREFIX = "vertx.redis"; + + /** + * Connection URL. Overrides host, port, and password. User is ignored. Example: + * redis://user:password@example.com:6379 + */ + private String url; + + /** + * if choose sentinel or cluster,it can be null + */ + private String host = "localhost"; + + private int port = 6379; + + /** + * Database index used by the connection factory. + */ + private int database = 0; + + private String password; + + private Sentinel sentinel; + + private Cluster cluster; + + private Pool pool = Pool.DEFAULT; + + /** + * aware a redis connection to beanFactory + */ + private boolean singleConnection = false; + + @Data + public static class Sentinel { + /** + * Comma-separated list of "host:port" pairs. + */ + private String[] nodes; + + /** + * Name of the Redis server. + */ + private String masterName = "myMaster"; + + /** + * Password for authenticating with sentinel(s). + */ + private String password; + + /** + * if true means just Use a SLAVE node connection. + */ + private boolean readonly = false; + } + + @Data + public static class Cluster { + + /** + * Comma-separated list of "host:port" pairs to bootstrap from. This represents an + * "initial" list of cluster nodes and is required to have at least one entry. + */ + private String[] nodes; + + /** + * Never use SLAVES, queries are always run on a MASTER node. + */ + private boolean never = true; + + /** + * Queries can be randomly run on both MASTER and SLAVE nodes. + * + * @see io.vertx.redis.client.impl.RedisClusterConnection#selectMasterOrSlaveEndpoint + */ + private boolean share = false; + + /** + * Queries are always run on SLAVE nodes (never on MASTER node). + */ + private boolean always = false; + } + + + /** + * redis connection pool properties + */ + @Data + public static class Pool { + private static final Pool DEFAULT = new Pool(); + + /** + * whether enable redis pool(default true) + */ + private boolean enable = true; + + /** + * the maximum number of connections on the pool(default 6) + */ + private int maxPoolSize = 6; + + /** + * the maximum waiting handlers to get a connection on a queue (default 24) + */ + private int maxPoolWaiting = 24; + + /** + * allow how much connection requests to queue waiting + * for a connection to be available.(default 2048) + */ + private int maxWaitingHandlers = 2048; + + /** + * the interval when connections will be clean default is -1 (disabled) + */ + private int poolCleanerInterval = -1; + + /** + * the timeout to keep an open connection on the pool waiting and then close (default 15_000) + */ + private int poolRecycleTimeout = 15_000; + } +} diff --git a/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/exception/IllegalRedisPropertiesException.java b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/exception/IllegalRedisPropertiesException.java new file mode 100644 index 0000000..c83a634 --- /dev/null +++ b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/exception/IllegalRedisPropertiesException.java @@ -0,0 +1,8 @@ +package com.project5e.vertx.data.redis.exception; + +/** + * @author: tk + * @since: 2020/11/1 + */ +public class IllegalRedisPropertiesException extends IllegalArgumentException { +} diff --git a/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/exception/RedisConnectionCreateTimeoutException.java b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/exception/RedisConnectionCreateTimeoutException.java new file mode 100644 index 0000000..db2d77f --- /dev/null +++ b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/exception/RedisConnectionCreateTimeoutException.java @@ -0,0 +1,8 @@ +package com.project5e.vertx.data.redis.exception; + +/** + * @author: tk + * @since: 2020/11/1 + */ +public class RedisConnectionCreateTimeoutException extends RuntimeException { +} diff --git a/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/exception/RedisNodeEmptyException.java b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/exception/RedisNodeEmptyException.java new file mode 100644 index 0000000..3d2a006 --- /dev/null +++ b/vertx-spring-boot-autoconfigure/src/main/java/com/project5e/vertx/data/redis/exception/RedisNodeEmptyException.java @@ -0,0 +1,8 @@ +package com.project5e.vertx.data.redis.exception; + +/** + * @author: tk + * @since: 2020/11/1 + */ +public class RedisNodeEmptyException extends IllegalArgumentException{ +}