From e5bf1ac5e44dc26a484b75275414cbf5efc98d36 Mon Sep 17 00:00:00 2001 From: Mirro Mutth Date: Tue, 12 Mar 2024 15:01:08 +0900 Subject: [PATCH 1/2] Add support for multiple hosts configuration - Allow to use Mono for user and password - Add multiple hosts connection strategy - Add HA protocol support for multiple hosts - Add DNS SRV driver for HA protocol --- .../r2dbc/mysql/ConnectionStrategy.java | 155 ++++ .../io/asyncer/r2dbc/mysql/Credential.java | 70 ++ .../mysql/MultiHostsConnectionStrategy.java | 215 ++++++ .../r2dbc/mysql/MySqlBatchingBatch.java | 4 +- .../mysql/MySqlConnectionConfiguration.java | 723 ++++++++++-------- .../r2dbc/mysql/MySqlConnectionFactory.java | 151 +--- .../mysql/MySqlConnectionFactoryProvider.java | 158 ++-- .../r2dbc/mysql/MySqlSslConfiguration.java | 95 ++- .../r2dbc/mysql/MySqlStatementSupport.java | 5 +- .../io/asyncer/r2dbc/mysql/OptionMapper.java | 31 +- .../mysql/SingleHostConnectionStrategy.java | 52 ++ .../mysql/SocketClientConfiguration.java | 95 +++ .../r2dbc/mysql/SocketConfiguration.java | 28 + .../r2dbc/mysql/TcpSocketConfiguration.java | 235 ++++++ .../mysql/UnixDomainSocketConfiguration.java | 75 ++ .../UnixDomainSocketConnectionStrategy.java | 48 ++ .../io/asyncer/r2dbc/mysql/client/Client.java | 43 +- .../r2dbc/mysql/constant/HaProtocol.java | 93 +++ .../r2dbc/mysql/constant/ProtocolDriver.java | 80 ++ .../r2dbc/mysql/internal/NodeAddress.java | 76 ++ .../mysql/internal/util/AddressUtils.java | 102 ++- .../mysql/internal/util/InternalArrays.java | 2 +- .../mysql/HaProtocolIntegrationTest.java | 79 ++ .../MySqlConnectionConfigurationTest.java | 121 ++- .../MySqlConnectionFactoryProviderTest.java | 104 ++- .../mysql/MySqlSimpleConnectionTest.java | 2 - .../r2dbc/mysql/MySqlTestKitSupport.java | 15 +- .../asyncer/r2dbc/mysql/OptionMapperTest.java | 18 +- .../mysql/ProtocolDriverIntegrationTest.java | 58 ++ .../r2dbc/mysql/TimeZoneIntegrationTest.java | 23 +- .../mysql/internal/util/AddressUtilsTest.java | 220 ++++-- 31 files changed, 2416 insertions(+), 760 deletions(-) create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionStrategy.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/Credential.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MultiHostsConnectionStrategy.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SingleHostConnectionStrategy.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SocketClientConfiguration.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SocketConfiguration.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/TcpSocketConfiguration.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/UnixDomainSocketConfiguration.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/UnixDomainSocketConnectionStrategy.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/HaProtocol.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/ProtocolDriver.java create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/NodeAddress.java create mode 100644 r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/HaProtocolIntegrationTest.java create mode 100644 r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/ProtocolDriverIntegrationTest.java diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionStrategy.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionStrategy.java new file mode 100644 index 000000000..72ca836a9 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionStrategy.java @@ -0,0 +1,155 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +import io.asyncer.r2dbc.mysql.client.Client; +import io.asyncer.r2dbc.mysql.internal.util.StringUtils; +import io.netty.channel.ChannelOption; +import io.netty.resolver.AddressResolver; +import io.netty.resolver.AddressResolverGroup; +import io.netty.resolver.DefaultNameResolver; +import io.netty.resolver.RoundRobinInetAddressResolver; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; +import reactor.core.publisher.Mono; +import reactor.netty.resources.LoopResources; +import reactor.netty.tcp.TcpClient; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.time.ZoneId; + +/** + * An interface of a connection strategy that considers how to obtain a MySQL {@link Client} object. + * + * @since 1.2.0 + */ +@FunctionalInterface +interface ConnectionStrategy { + + InternalLogger logger = InternalLoggerFactory.getInstance(ConnectionStrategy.class); + + /** + * Establish a connection to a target server that is determined by this connection strategy. + * + * @return a logged-in {@link Client} object. + */ + Mono connect(); + + /** + * Creates a general-purpose {@link TcpClient} with the given {@link SocketClientConfiguration}. + *

+ * Note: Unix Domain Socket also uses this method to create a general-purpose {@link TcpClient client}. + * + * @param configuration socket client configuration. + * @return a general-purpose {@link TcpClient client}. + */ + static TcpClient createTcpClient(SocketClientConfiguration configuration, boolean balancedDns) { + LoopResources loopResources = configuration.getLoopResources(); + Duration connectTimeout = configuration.getConnectTimeout(); + TcpClient client = TcpClient.newConnection(); + + if (loopResources != null) { + client = client.runOn(loopResources); + } + + if (connectTimeout != null) { + client = client.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, Math.toIntExact(connectTimeout.toMillis())); + } + + if (balancedDns) { + client = client.resolver(BalancedResolverGroup.INSTANCE); + } + + return client; + } + + /** + * Logins to a MySQL server with the given {@link TcpClient}, {@link Credential} and configurations. + * + * @param tcpClient a TCP client to connect to a MySQL server. + * @param credential user and password to log in to a MySQL server. + * @param configuration a configuration that affects login behavior. + * @return a logged-in {@link Client} object. + */ + static Mono connectWithInit( + TcpClient tcpClient, + Credential credential, + MySqlConnectionConfiguration configuration + ) { + return Mono.fromSupplier(() -> { + String timeZone = configuration.getConnectionTimeZone(); + ZoneId connectionTimeZone; + if ("LOCAL".equalsIgnoreCase(timeZone)) { + connectionTimeZone = ZoneId.systemDefault().normalized(); + } else if ("SERVER".equalsIgnoreCase(timeZone)) { + connectionTimeZone = null; + } else { + connectionTimeZone = StringUtils.parseZoneId(timeZone); + } + + return new ConnectionContext( + configuration.getZeroDateOption(), + configuration.getLoadLocalInfilePath(), + configuration.getLocalInfileBufferSize(), + configuration.isPreserveInstants(), + connectionTimeZone + ); + }).flatMap(context -> Client.connect(tcpClient, configuration.getSsl(), context)).flatMap(client -> { + // Lazy init database after handshake/login + MySqlSslConfiguration ssl = configuration.getSsl(); + String loginDb = configuration.isCreateDatabaseIfNotExist() ? "" : configuration.getDatabase(); + + return InitFlow.initHandshake( + client, + ssl.getSslMode(), + loginDb, + credential.getUser(), + credential.getPassword(), + configuration.getCompressionAlgorithms(), + configuration.getZstdCompressionLevel() + ).then(Mono.just(client)).onErrorResume(e -> client.forceClose().then(Mono.error(e))); + }); + } +} + +/** + * Resolves the {@link InetSocketAddress} to IP address, randomly pick one if it resolves to multiple IP addresses. + * + * @since 1.2.0 + */ +final class BalancedResolverGroup extends AddressResolverGroup { + + BalancedResolverGroup() { + } + + public static final BalancedResolverGroup INSTANCE; + + static { + INSTANCE = new BalancedResolverGroup(); + Runtime.getRuntime().addShutdownHook(new Thread( + INSTANCE::close, + "R2DBC-MySQL-BalancedResolverGroup-ShutdownHook" + )); + } + + @Override + protected AddressResolver newResolver(EventExecutor executor) { + return new RoundRobinInetAddressResolver(executor, new DefaultNameResolver(executor)).asAddressResolver(); + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/Credential.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/Credential.java new file mode 100644 index 000000000..82cb1168d --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/Credential.java @@ -0,0 +1,70 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** + * A value object representing a user with an optional password. + */ +final class Credential { + + private final String user; + + @Nullable + private final CharSequence password; + + Credential(String user, @Nullable CharSequence password) { + this.user = user; + this.password = password; + } + + String getUser() { + return user; + } + + @Nullable + CharSequence getPassword() { + return password; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Credential)) { + return false; + } + + Credential that = (Credential) o; + + return user.equals(that.user) && Objects.equals(password, that.password); + } + + @Override + public int hashCode() { + return 31 * user.hashCode() + Objects.hashCode(password); + } + + @Override + public String toString() { + return "Credential{user=" + user + ", password=REDACTED}"; + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MultiHostsConnectionStrategy.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MultiHostsConnectionStrategy.java new file mode 100644 index 000000000..68eafa512 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MultiHostsConnectionStrategy.java @@ -0,0 +1,215 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +import io.asyncer.r2dbc.mysql.client.Client; +import io.asyncer.r2dbc.mysql.constant.ProtocolDriver; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; +import io.asyncer.r2dbc.mysql.internal.util.InternalArrays; +import io.netty.channel.ChannelOption; +import io.netty.resolver.DefaultNameResolver; +import io.netty.resolver.NameResolver; +import io.netty.util.concurrent.Future; +import io.r2dbc.spi.R2dbcNonTransientResourceException; +import org.jetbrains.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.resources.LoopResources; +import reactor.netty.tcp.TcpClient; +import reactor.netty.tcp.TcpResources; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.function.Function; + +/** + * An abstraction for {@link ConnectionStrategy} that consider multiple hosts. + */ +final class MultiHostsConnectionStrategy implements ConnectionStrategy { + + private final Mono client; + + MultiHostsConnectionStrategy( + TcpSocketConfiguration tcp, + MySqlConnectionConfiguration configuration, + boolean shuffle + ) { + this.client = Mono.defer(() -> { + if (ProtocolDriver.DNS_SRV.equals(tcp.getDriver())) { + LoopResources resources = configuration.getClient().getLoopResources(); + LoopResources loopResources = resources == null ? TcpResources.get() : resources; + + return resolveAllHosts(loopResources, tcp.getAddresses(), shuffle) + .flatMap(addresses -> connectHost(addresses, tcp, configuration, false, shuffle, 0)); + } else { + List availableHosts = copyAvailableAddresses(tcp.getAddresses(), shuffle); + int size = availableHosts.size(); + InetSocketAddress[] addresses = new InetSocketAddress[availableHosts.size()]; + + for (int i = 0; i < size; i++) { + NodeAddress address = availableHosts.get(i); + addresses[i] = InetSocketAddress.createUnresolved(address.getHost(), address.getPort()); + } + + return connectHost(InternalArrays.asImmutableList(addresses), tcp, configuration, true, shuffle, 0); + } + }); + } + + @Override + public Mono connect() { + return client; + } + + private Mono connectHost( + List addresses, + TcpSocketConfiguration tcp, + MySqlConnectionConfiguration configuration, + boolean balancedDns, + boolean shuffle, + int attempts + ) { + Iterator iter = addresses.iterator(); + + if (!iter.hasNext()) { + return Mono.error(fail("Fail to establish connection: no available host", null)); + } + + return configuration.getCredential().flatMap(credential -> attemptConnect( + iter.next(), credential, tcp, configuration, balancedDns + ).onErrorResume(t -> resumeConnect( + t, addresses, iter, credential, tcp, configuration, balancedDns, shuffle, attempts + ))); + } + + private Mono resumeConnect( + Throwable t, + List addresses, + Iterator iter, + Credential credential, + TcpSocketConfiguration tcp, + MySqlConnectionConfiguration configuration, + boolean balancedDns, + boolean shuffle, + int attempts + ) { + if (!iter.hasNext()) { + // The last host failed to connect + if (attempts >= tcp.getRetriesAllDown()) { + return Mono.error(fail( + "Fail to establish connection, retried " + attempts + " times: " + t.getMessage(), t)); + } + + logger.warn("All hosts failed to establish connections, auto-try again after 250ms."); + + // Ignore waiting error, e.g. interrupted, scheduler rejected + return Mono.delay(Duration.ofMillis(250)) + .onErrorComplete() + .then(Mono.defer(() -> connectHost(addresses, tcp, configuration, balancedDns, shuffle, attempts + 1))); + } + + return attemptConnect(iter.next(), credential, tcp, configuration, balancedDns).onErrorResume(tt -> + resumeConnect(tt, addresses, iter, credential, tcp, configuration, balancedDns, shuffle, attempts)); + } + + private Mono attemptConnect( + InetSocketAddress address, + Credential credential, + TcpSocketConfiguration tcp, + MySqlConnectionConfiguration configuration, + boolean balancedDns + ) { + TcpClient tcpClient = ConnectionStrategy.createTcpClient(configuration.getClient(), balancedDns) + .option(ChannelOption.SO_KEEPALIVE, tcp.isTcpKeepAlive()) + .option(ChannelOption.TCP_NODELAY, tcp.isTcpNoDelay()) + .remoteAddress(() -> address); + + return ConnectionStrategy.connectWithInit(tcpClient, credential, configuration) + .doOnError(e -> logger.warn("Fail to connect: ", e)); + } + + private static Mono> resolveAllHosts( + LoopResources loopResources, + List addresses, + boolean shuffle + ) { + // Or DnsNameResolver? It is non-blocking but requires native dependencies, hard configurations, and maybe + // behaves differently. Currently, we use DefaultNameResolver which is blocking but simple and easy to use. + @SuppressWarnings("resource") + DefaultNameResolver resolver = new DefaultNameResolver(loopResources.onClient(true).next()); + + return Flux.fromIterable(addresses) + .flatMap(address -> resolveAll(resolver, address.getHost()) + .flatMapIterable(Function.identity()) + .map(inet -> new InetSocketAddress(inet, address.getPort()))) + .doFinally(ignore -> resolver.close()) + .collectList() + .map(list -> { + if (shuffle) { + Collections.shuffle(list); + } + + return list; + }); + } + + private static Mono> resolveAll(NameResolver resolver, String host) { + Future> future = resolver.resolveAll(host); + + return Mono.>create(sink -> future.addListener(f -> { + if (f.isSuccess()) { + try { + @SuppressWarnings("unchecked") + List t = (List) f.getNow(); + + logger.debug("Resolve {} in DNS succeed, {} records", host, t.size()); + sink.success(t); + } catch (Throwable e) { + logger.warn("Resolve {} in DNS succeed but failed to get result", host, e); + sink.success(Collections.emptyList()); + } + } else { + logger.warn("Resolve {} in DNS failed", host, f.cause()); + sink.success(Collections.emptyList()); + } + })).doOnCancel(() -> future.cancel(false)); + } + + private static List copyAvailableAddresses(List addresses, boolean shuffle) { + if (shuffle) { + List copied = new ArrayList<>(addresses); + Collections.shuffle(copied); + return copied; + } + + return InternalArrays.asImmutableList(addresses.toArray(new NodeAddress[0])); + } + + private static R2dbcNonTransientResourceException fail(String message, @Nullable Throwable cause) { + return new R2dbcNonTransientResourceException( + message, + "H1000", + 9000, + cause + ); + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlBatchingBatch.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlBatchingBatch.java index a71c31986..c2517e29b 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlBatchingBatch.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlBatchingBatch.java @@ -27,8 +27,8 @@ import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonNull; /** - * An implementation of {@link MySqlBatch} for executing a collection of statements in a batch against the - * MySQL database. + * An implementation of {@link MySqlBatch} for executing a collection of statements in a batch against the MySQL + * database. */ final class MySqlBatchingBatch implements MySqlBatch { diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java index 3856b58bd..784c87467 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java @@ -17,13 +17,17 @@ package io.asyncer.r2dbc.mysql; import io.asyncer.r2dbc.mysql.constant.CompressionAlgorithm; +import io.asyncer.r2dbc.mysql.constant.HaProtocol; +import io.asyncer.r2dbc.mysql.constant.ProtocolDriver; import io.asyncer.r2dbc.mysql.constant.SslMode; import io.asyncer.r2dbc.mysql.constant.ZeroDateOption; import io.asyncer.r2dbc.mysql.extension.Extension; import io.asyncer.r2dbc.mysql.internal.util.InternalArrays; +import io.asyncer.r2dbc.mysql.internal.util.StringUtils; import io.netty.handler.ssl.SslContextBuilder; import org.jetbrains.annotations.Nullable; import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; import reactor.netty.resources.LoopResources; import reactor.netty.tcp.TcpResources; @@ -38,47 +42,28 @@ import java.util.EnumSet; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.ServiceLoader; import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; +import java.util.function.Supplier; import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.require; import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonEmpty; import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonNull; -import static io.asyncer.r2dbc.mysql.internal.util.InternalArrays.EMPTY_STRINGS; /** * A configuration of MySQL connection. */ public final class MySqlConnectionConfiguration { - /** - * Default MySQL port. - */ - private static final int DEFAULT_PORT = 3306; - - /** - * {@code true} if {@link #domain} is hostname, otherwise {@link #domain} is unix domain socket path. - */ - private final boolean isHost; - - /** - * Domain of connecting, may be hostname or unix domain socket path. - */ - private final String domain; + private final SocketClientConfiguration client; - private final int port; + private final SocketConfiguration socket; private final MySqlSslConfiguration ssl; - private final boolean tcpKeepAlive; - - private final boolean tcpNoDelay; - - @Nullable - private final Duration connectTimeout; - private final boolean preserveInstants; private final String connectionTimeZone; @@ -87,10 +72,9 @@ public final class MySqlConnectionConfiguration { private final ZeroDateOption zeroDateOption; - private final String user; + private final Mono user; - @Nullable - private final CharSequence password; + private final Mono> password; private final String database; @@ -120,42 +104,35 @@ public final class MySqlConnectionConfiguration { private final int zstdCompressionLevel; - private final LoopResources loopResources; - private final Extensions extensions; - @Nullable - private final Publisher passwordPublisher; - private MySqlConnectionConfiguration( - boolean isHost, String domain, int port, MySqlSslConfiguration ssl, - boolean tcpKeepAlive, boolean tcpNoDelay, @Nullable Duration connectTimeout, + SocketClientConfiguration client, + SocketConfiguration socket, + MySqlSslConfiguration ssl, ZeroDateOption zeroDateOption, boolean preserveInstants, String connectionTimeZone, boolean forceConnectionTimeZoneToSession, - String user, @Nullable CharSequence password, @Nullable String database, + Mono user, + Mono> password, + @Nullable String database, boolean createDatabaseIfNotExist, @Nullable Predicate preferPrepareStatement, List sessionVariables, @Nullable Duration lockWaitTimeout, @Nullable Duration statementTimeout, @Nullable Path loadLocalInfilePath, int localInfileBufferSize, int queryCacheSize, int prepareCacheSize, Set compressionAlgorithms, int zstdCompressionLevel, - @Nullable LoopResources loopResources, - Extensions extensions, @Nullable Publisher passwordPublisher + Extensions extensions ) { - this.isHost = isHost; - this.domain = domain; - this.port = port; - this.tcpKeepAlive = tcpKeepAlive; - this.tcpNoDelay = tcpNoDelay; - this.connectTimeout = connectTimeout; - this.ssl = ssl; + this.client = requireNonNull(client, "client must not be null"); + this.socket = requireNonNull(socket, "socket must not be null"); + this.ssl = requireNonNull(ssl, "ssl must not be null"); this.preserveInstants = preserveInstants; this.connectionTimeZone = requireNonNull(connectionTimeZone, "connectionTimeZone must not be null"); this.forceConnectionTimeZoneToSession = forceConnectionTimeZoneToSession; this.zeroDateOption = requireNonNull(zeroDateOption, "zeroDateOption must not be null"); this.user = requireNonNull(user, "user must not be null"); - this.password = password; + this.password = requireNonNull(password, "password must not be null"); this.database = database == null || database.isEmpty() ? "" : database; this.createDatabaseIfNotExist = createDatabaseIfNotExist; this.preferPrepareStatement = preferPrepareStatement; @@ -168,9 +145,7 @@ private MySqlConnectionConfiguration( this.prepareCacheSize = prepareCacheSize; this.compressionAlgorithms = compressionAlgorithms; this.zstdCompressionLevel = zstdCompressionLevel; - this.loopResources = loopResources == null ? TcpResources.get() : loopResources; this.extensions = extensions; - this.passwordPublisher = passwordPublisher; } /** @@ -182,35 +157,18 @@ public static Builder builder() { return new Builder(); } - boolean isHost() { - return isHost; - } - - String getDomain() { - return domain; + SocketClientConfiguration getClient() { + return client; } - int getPort() { - return port; - } - - @Nullable - Duration getConnectTimeout() { - return connectTimeout; + SocketConfiguration getSocket() { + return socket; } MySqlSslConfiguration getSsl() { return ssl; } - boolean isTcpKeepAlive() { - return this.tcpKeepAlive; - } - - boolean isTcpNoDelay() { - return this.tcpNoDelay; - } - ZeroDateOption getZeroDateOption() { return zeroDateOption; } @@ -223,17 +181,25 @@ String getConnectionTimeZone() { return connectionTimeZone; } - boolean isForceConnectionTimeZoneToSession() { - return forceConnectionTimeZoneToSession; + @Nullable + ZoneId retrieveConnectionZoneId() { + String timeZone = this.connectionTimeZone; + + if ("LOCAL".equalsIgnoreCase(timeZone)) { + return ZoneId.systemDefault().normalized(); + } else if ("SERVER".equalsIgnoreCase(timeZone)) { + return null; + } + + return StringUtils.parseZoneId(timeZone); } - String getUser() { - return user; + boolean isForceConnectionTimeZoneToSession() { + return forceConnectionTimeZoneToSession; } - @Nullable - CharSequence getPassword() { - return password; + Mono getCredential() { + return Mono.zip(user, password, (u, p) -> new Credential(u, p.orElse(null))); } String getDatabase() { @@ -288,19 +254,10 @@ int getZstdCompressionLevel() { return zstdCompressionLevel; } - LoopResources getLoopResources() { - return loopResources; - } - Extensions getExtensions() { return extensions; } - @Nullable - Publisher getPasswordPublisher() { - return passwordPublisher; - } - @Override public boolean equals(Object o) { if (this == o) { @@ -309,20 +266,18 @@ public boolean equals(Object o) { if (!(o instanceof MySqlConnectionConfiguration)) { return false; } + MySqlConnectionConfiguration that = (MySqlConnectionConfiguration) o; - return isHost == that.isHost && - domain.equals(that.domain) && - port == that.port && + + return client.equals(that.client) && + socket.equals(that.socket) && ssl.equals(that.ssl) && - tcpKeepAlive == that.tcpKeepAlive && - tcpNoDelay == that.tcpNoDelay && - Objects.equals(connectTimeout, that.connectTimeout) && preserveInstants == that.preserveInstants && - Objects.equals(connectionTimeZone, that.connectionTimeZone) && + connectionTimeZone.equals(that.connectionTimeZone) && forceConnectionTimeZoneToSession == that.forceConnectionTimeZoneToSession && zeroDateOption == that.zeroDateOption && user.equals(that.user) && - Objects.equals(password, that.password) && + password.equals(that.password) && database.equals(that.database) && createDatabaseIfNotExist == that.createDatabaseIfNotExist && Objects.equals(preferPrepareStatement, that.preferPrepareStatement) && @@ -335,16 +290,21 @@ public boolean equals(Object o) { prepareCacheSize == that.prepareCacheSize && compressionAlgorithms.equals(that.compressionAlgorithms) && zstdCompressionLevel == that.zstdCompressionLevel && - Objects.equals(loopResources, that.loopResources) && - extensions.equals(that.extensions) && - Objects.equals(passwordPublisher, that.passwordPublisher); + extensions.equals(that.extensions); } @Override public int hashCode() { - return Objects.hash(isHost, domain, port, ssl, tcpKeepAlive, tcpNoDelay, connectTimeout, - preserveInstants, connectionTimeZone, forceConnectionTimeZoneToSession, - zeroDateOption, user, password, database, createDatabaseIfNotExist, + return Objects.hash( + client, + socket, ssl, + preserveInstants, + connectionTimeZone, + forceConnectionTimeZoneToSession, + zeroDateOption, + user, password, + database, + createDatabaseIfNotExist, preferPrepareStatement, sessionVariables, lockWaitTimeout, @@ -352,40 +312,22 @@ public int hashCode() { loadLocalInfilePath, localInfileBufferSize, queryCacheSize, prepareCacheSize, compressionAlgorithms, zstdCompressionLevel, - loopResources, extensions, passwordPublisher); + extensions); } @Override public String toString() { - if (isHost) { - return "MySqlConnectionConfiguration{host='" + domain + "', port=" + port + ", ssl=" + ssl + - ", tcpNoDelay=" + tcpNoDelay + ", tcpKeepAlive=" + tcpKeepAlive + - ", connectTimeout=" + connectTimeout + - ", preserveInstants=" + preserveInstants + - ", connectionTimeZone=" + connectionTimeZone + - ", forceConnectionTimeZoneToSession=" + forceConnectionTimeZoneToSession + - ", zeroDateOption=" + zeroDateOption + ", user='" + user + "', password=" + password + - ", database='" + database + "', createDatabaseIfNotExist=" + createDatabaseIfNotExist + - ", preferPrepareStatement=" + preferPrepareStatement + - ", sessionVariables=" + sessionVariables + - ", lockWaitTimeout=" + lockWaitTimeout + - ", statementTimeout=" + statementTimeout + - ", loadLocalInfilePath=" + loadLocalInfilePath + - ", localInfileBufferSize=" + localInfileBufferSize + - ", queryCacheSize=" + queryCacheSize + ", prepareCacheSize=" + prepareCacheSize + - ", compressionAlgorithms=" + compressionAlgorithms + - ", zstdCompressionLevel=" + zstdCompressionLevel + - ", loopResources=" + loopResources + - ", extensions=" + extensions + ", passwordPublisher=" + passwordPublisher + '}'; - } - - return "MySqlConnectionConfiguration{unixSocket='" + domain + - "', connectTimeout=" + connectTimeout + + return "MySqlConnectionConfiguration{client=" + client + + ", socket=" + socket + + ", ssl=" + ssl + ", preserveInstants=" + preserveInstants + - ", connectionTimeZone=" + connectionTimeZone + + ", connectionTimeZone='" + connectionTimeZone + '\'' + ", forceConnectionTimeZoneToSession=" + forceConnectionTimeZoneToSession + - ", zeroDateOption=" + zeroDateOption + ", user='" + user + "', password=" + password + - ", database='" + database + "', createDatabaseIfNotExist=" + createDatabaseIfNotExist + + ", zeroDateOption=" + zeroDateOption + + ", user=" + user + + ", password=REDACTED" + + ", database='" + database + '\'' + + ", createDatabaseIfNotExist=" + createDatabaseIfNotExist + ", preferPrepareStatement=" + preferPrepareStatement + ", sessionVariables=" + sessionVariables + ", lockWaitTimeout=" + lockWaitTimeout + @@ -396,8 +338,8 @@ public String toString() { ", prepareCacheSize=" + prepareCacheSize + ", compressionAlgorithms=" + compressionAlgorithms + ", zstdCompressionLevel=" + zstdCompressionLevel + - ", loopResources=" + loopResources + - ", extensions=" + extensions + ", passwordPublisher=" + passwordPublisher + '}'; + ", extensions=" + extensions + + '}'; } /** @@ -405,24 +347,26 @@ public String toString() { */ public static final class Builder { - @Nullable - private String database; + private final SocketClientConfiguration.Builder client = new SocketClientConfiguration.Builder(); - private boolean createDatabaseIfNotExist; + @Nullable + private TcpSocketConfiguration.Builder tcpSocket; - private boolean isHost = true; + @Nullable + private UnixDomainSocketConfiguration.Builder unixSocket; - private String domain; + private final MySqlSslConfiguration.Builder ssl = new MySqlSslConfiguration.Builder(); @Nullable - private CharSequence password; + private String database; - private int port = DEFAULT_PORT; + private boolean createDatabaseIfNotExist; @Nullable - private Duration connectTimeout; + private Mono user; - private String user; + @Nullable + private Mono password; private ZeroDateOption zeroDateOption = ZeroDateOption.USE_NULL; @@ -432,33 +376,6 @@ public static final class Builder { private boolean forceConnectionTimeZoneToSession; - @Nullable - private SslMode sslMode; - - private String[] tlsVersion = EMPTY_STRINGS; - - @Nullable - private HostnameVerifier sslHostnameVerifier; - - @Nullable - private String sslCa; - - @Nullable - private String sslKey; - - @Nullable - private CharSequence sslKeyPassword; - - @Nullable - private String sslCert; - - @Nullable - private Function sslContextBuilderCustomizer; - - private boolean tcpKeepAlive; - - private boolean tcpNoDelay; - @Nullable private Predicate preferPrepareStatement; @@ -484,58 +401,65 @@ public static final class Builder { private int zstdCompressionLevel = 3; - @Nullable - private LoopResources loopResources; - private boolean autodetectExtensions = true; private final List extensions = new ArrayList<>(); - @Nullable - private Publisher passwordPublisher; - /** * Builds an immutable {@link MySqlConnectionConfiguration} with current options. * * @return the {@link MySqlConnectionConfiguration}. */ public MySqlConnectionConfiguration build() { - SslMode sslMode = requireSslMode(); - - if (isHost) { - requireNonNull(domain, "host must not be null when using TCP socket"); - require((sslCert == null && sslKey == null) || (sslCert != null && sslKey != null), - "sslCert and sslKey must be both null or both non-null"); + Mono user = requireNonNull(this.user, "User must be configured"); + Mono auth = this.password; + Mono> password = auth == null ? Mono.just(Optional.empty()) : auth.singleOptional(); + SocketConfiguration socket; + boolean preferredSsl; + + if (unixSocket == null) { + socket = requireNonNull(tcpSocket, "Connection must be either TCP/SSL or Unix Domain Socket").build(); + preferredSsl = true; } else { - requireNonNull(domain, "unixSocket must not be null when using unix domain socket"); - require(!sslMode.startSsl(), "sslMode must be disabled when using unix domain socket"); + // Since 1.2.0, we support SSL over Unix Domain Socket, default SSL mode is DISABLED. + // But, if a Unix Domain Socket can be listened to by someone, this indicates that the system itself + // has been compromised, and enabling SSL does not improve the security of the connection. + socket = unixSocket.build(); + preferredSsl = false; } int prepareCacheSize = preferPrepareStatement == null ? 0 : this.prepareCacheSize; - MySqlSslConfiguration ssl = MySqlSslConfiguration.create(sslMode, tlsVersion, sslHostnameVerifier, - sslCa, sslKey, sslKeyPassword, sslCert, sslContextBuilderCustomizer); - return new MySqlConnectionConfiguration(isHost, domain, port, ssl, tcpKeepAlive, tcpNoDelay, - connectTimeout, zeroDateOption, + return new MySqlConnectionConfiguration( + client.build(), + socket, + ssl.build(preferredSsl), + zeroDateOption, preserveInstants, connectionTimeZone, forceConnectionTimeZoneToSession, - user, password, database, - createDatabaseIfNotExist, preferPrepareStatement, + user.single(), + password, + database, + createDatabaseIfNotExist, + preferPrepareStatement, sessionVariables, lockWaitTimeout, statementTimeout, loadLocalInfilePath, - localInfileBufferSize, queryCacheSize, prepareCacheSize, - compressionAlgorithms, zstdCompressionLevel, loopResources, - Extensions.from(extensions, autodetectExtensions), passwordPublisher); + localInfileBufferSize, + queryCacheSize, + prepareCacheSize, + compressionAlgorithms, + zstdCompressionLevel, + Extensions.from(extensions, autodetectExtensions)); } /** * Configures the database. Default no database. * * @param database the database, or {@code null} if no database want to be login. - * @return this {@link Builder}. + * @return {@link Builder this} * @since 0.8.1 */ public Builder database(@Nullable String database) { @@ -548,7 +472,7 @@ public Builder database(@Nullable String database) { * {@code false}. * * @param enabled to discover and register extensions. - * @return this {@link Builder}. + * @return {@link Builder this} * @since 1.0.6 */ public Builder createDatabaseIfNotExist(boolean enabled) { @@ -558,58 +482,121 @@ public Builder createDatabaseIfNotExist(boolean enabled) { /** * Configures the Unix Domain Socket to connect to. + *

+ * Note: It will override all TCP and SSL configurations if configured. * - * @param unixSocket the socket file path. - * @return this {@link Builder}. - * @throws IllegalArgumentException if {@code unixSocket} is {@code null}. + * @param path the socket file path. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code path} is {@code null} or empty. * @since 0.8.1 */ - public Builder unixSocket(String unixSocket) { - this.domain = requireNonNull(unixSocket, "unixSocket must not be null"); - this.isHost = false; + public Builder unixSocket(String path) { + requireNonEmpty(path, "path must not be null"); + + requireUnixSocket().path(path); return this; } /** - * Configures the host. + * Configures the single-host. + *

+ * Note: Used only if the {@link #unixSocket(String)} and {@link #addHost multiple hosts} is not configured. * * @param host the host. - * @return this {@link Builder}. - * @throws IllegalArgumentException if {@code host} is {@code null}. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code host} is {@code null} or empty. * @since 0.8.1 */ public Builder host(String host) { - this.domain = requireNonNull(host, "host must not be null"); - this.isHost = true; + requireNonEmpty(host, "host must not be empty"); + + requireTcpSocket().host(host); return this; } /** - * Configures the password. Default login without password. + * Configures the port of {@link #host(String)}. Defaults to {@code 3306}. *

- * Note: for memory security, should not use intern {@link String} for password. + * Note: Used only if the {@link #unixSocket(String)} and {@link #addHost multiple hosts} is not configured. * - * @param password the password, or {@code null} when user has no password. - * @return this {@link Builder}. + * @param port the port. + * @return {@link Builder this} + * @throws IllegalArgumentException if the {@code port} is negative or bigger than {@literal 65535}. * @since 0.8.1 */ - public Builder password(@Nullable CharSequence password) { - this.password = password; + public Builder port(int port) { + require(port >= 0 && port <= 0xFFFF, "port must be between 0 and 65535"); + + requireTcpSocket().port(port); return this; } /** - * Configures the port. Defaults to {@code 3306}. + * Adds a host with default port 3306 to the list of multiple hosts to connect to. + *

+ * Note: Used only if the {@link #unixSocket(String)} and {@link #host single host} is not configured. * - * @param port the port. - * @return this {@link Builder}. - * @throws IllegalArgumentException if the {@code port} is negative or bigger than {@literal 65535}. - * @since 0.8.1 + * @param host the host to add. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code host} is {@code null} or empty. + * @since 1.2.0 */ - public Builder port(int port) { + public Builder addHost(String host) { + requireNonEmpty(host, "host must not be empty"); + + requireTcpSocket().addHost(host); + return this; + } + + /** + * Adds a host to the list of multiple hosts to connect to. + *

+ * Note: Used only if the {@link #unixSocket(String)} and {@link #host single host} is not configured. + * + * @param host the host to add. + * @param port the port of the host. + * @return {@link Builder this} + * @throws IllegalArgumentException if the {@code host} is empty or the {@code port} is not between 0 and + * 65535. + * @since 1.2.0 + */ + public Builder addHost(String host, int port) { + requireNonEmpty(host, "host must not be empty"); require(port >= 0 && port <= 0xFFFF, "port must be between 0 and 65535"); - this.port = port; + requireTcpSocket().addHost(host, port); + return this; + } + + /** + * Configures the failover and high availability protocol driver. Default to {@link ProtocolDriver#MYSQL}. Used + * only if the {@link #unixSocket(String)} is not configured. + * + * @param driver the protocol driver. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code driver} is {@code null}. + * @since 1.2.0 + */ + public Builder driver(ProtocolDriver driver) { + requireNonNull(driver, "driver must not be null"); + + requireTcpSocket().driver(driver); + return this; + } + + /** + * Configures the failover and high availability protocol. Default to {@link HaProtocol#DEFAULT}. Used only if + * the {@link #unixSocket(String)} is not configured. + * + * @param protocol the failover and high availability protocol. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code protocol} is {@code null}. + * @since 1.2.0 + */ + public Builder protocol(HaProtocol protocol) { + requireNonNull(protocol, "protocol must not be null"); + + requireTcpSocket().protocol(protocol); return this; } @@ -617,11 +604,11 @@ public Builder port(int port) { * Configures the connection timeout. Default no timeout. * * @param connectTimeout the connection timeout, or {@code null} if no timeout. - * @return this {@link Builder}. + * @return {@link Builder this} * @since 0.8.1 */ public Builder connectTimeout(@Nullable Duration connectTimeout) { - this.connectTimeout = connectTimeout; + this.client.connectTimeout(connectTimeout); return this; } @@ -629,20 +616,52 @@ public Builder connectTimeout(@Nullable Duration connectTimeout) { * Configures the user for login the database. * * @param user the user. - * @return this {@link Builder}. - * @throws IllegalArgumentException if {@code user} is {@code null}. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code user} is {@code null} or empty. * @since 0.8.2 */ public Builder user(String user) { - this.user = requireNonNull(user, "user must not be null"); + requireNonEmpty(user, "user must not be empty"); + + this.user = Mono.just(user); + return this; + } + + /** + * Configures the user for login the database. + * + * @param user a {@link Supplier} to retrieve user. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code user} is {@code null}. + * @since 1.2.0 + */ + public Builder user(Supplier user) { + requireNonNull(user, "user must not be null"); + + this.user = Mono.fromSupplier(user); + return this; + } + + /** + * Configures the user for login the database. + * + * @param user a {@link Publisher} to retrieve user. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code user} is {@code null}. + * @since 1.2.0 + */ + public Builder user(Publisher user) { + requireNonNull(user, "user must not be null"); + + this.user = Mono.from(user); return this; } /** - * An alias of {@link #user(String)}. + * Configures the user for login the database. Since 0.8.2, it is an alias of {@link #user(String)}. * * @param user the user. - * @return this {@link Builder}. + * @return {@link Builder this} * @throws IllegalArgumentException if {@code user} is {@code null}. * @since 0.8.1 */ @@ -651,8 +670,52 @@ public Builder username(String user) { } /** - * Configures the time zone conversion. Default to {@code true} means enable conversion between JVM - * and {@link #connectionTimeZone(String)}. + * Configures the password. Default login without password. + *

+ * Note: for memory security, should not use intern {@link String} for password. + * + * @param password the password, or {@code null} when user has no password. + * @return {@link Builder this} + * @since 0.8.1 + */ + public Builder password(@Nullable CharSequence password) { + this.password = Mono.justOrEmpty(password); + return this; + } + + /** + * Configures the password. Default login without password. + * + * @param password a {@link Supplier} to retrieve password. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code password} is {@code null}. + * @since 1.2.0 + */ + public Builder password(Supplier password) { + requireNonNull(password, "password must not be null"); + + this.password = Mono.fromSupplier(password); + return this; + } + + /** + * Configures the password. Default login without password. + * + * @param password a {@link Publisher} to retrieve password. + * @return {@link Builder this} + * @throws IllegalArgumentException if {@code password} is {@code null}. + * @since 1.2.0 + */ + public Builder password(Publisher password) { + requireNonNull(password, "password must not be null"); + + this.password = Mono.from(password); + return this; + } + + /** + * Configures the time zone conversion. Default to {@code true} means enable conversion between JVM and + * {@link #connectionTimeZone(String)}. *

* Note: disable it will ignore the time zone of connection, and use the JVM local time zone. * @@ -682,8 +745,8 @@ public Builder connectionTimeZone(String connectionTimeZone) { } /** - * Configures to force the connection time zone to session time zone. Default to {@code false}. Used - * only if the {@link #connectionTimeZone(String)} is not {@code "SERVER"}. + * Configures to force the connection time zone to session time zone. Default to {@code false}. Used only if + * the {@link #connectionTimeZone(String)} is not {@code "SERVER"}. *

* Note: alter the time zone of session will affect the results of MySQL date/time functions, e.g. * {@code NOW([n])}, {@code CURRENT_TIME([n])}, {@code CURRENT_DATE()}, etc. Please use with caution. @@ -711,11 +774,11 @@ public Builder serverZoneId(@Nullable ZoneId serverZoneId) { } /** - * Configures the {@link ZeroDateOption}. Default to {@link ZeroDateOption#USE_NULL}. It is a - * behavior option when this driver receives a value of zero-date. + * Configures the {@link ZeroDateOption}. Default to {@link ZeroDateOption#USE_NULL}. It is a behavior option + * when this driver receives a value of zero-date. * * @param zeroDate the {@link ZeroDateOption}. - * @return this {@link Builder}. + * @return {@link Builder this} * @throws IllegalArgumentException if {@code zeroDate} is {@code null}. * @since 0.8.1 */ @@ -726,46 +789,46 @@ public Builder zeroDateOption(ZeroDateOption zeroDate) { /** * Configures ssl mode. See also {@link SslMode}. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * * @param sslMode the SSL mode to use. - * @return this {@link Builder}. + * @return {@link Builder this} * @throws IllegalArgumentException if {@code sslMode} is {@code null}. * @since 0.8.1 */ public Builder sslMode(SslMode sslMode) { - this.sslMode = requireNonNull(sslMode, "sslMode must not be null"); + requireNonNull(sslMode, "sslMode must not be null"); + + this.ssl.sslMode(sslMode); return this; } /** * Configures TLS versions, see {@link io.asyncer.r2dbc.mysql.constant.TlsVersions TlsVersions}. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * * @param tlsVersion TLS versions. - * @return this {@link Builder}. + * @return {@link Builder this} * @throws IllegalArgumentException if the array {@code tlsVersion} is {@code null}. * @since 0.8.1 */ public Builder tlsVersion(String... tlsVersion) { requireNonNull(tlsVersion, "tlsVersion must not be null"); - int size = tlsVersion.length; - - if (size > 0) { - String[] versions = new String[size]; - System.arraycopy(tlsVersion, 0, versions, 0, size); - this.tlsVersion = versions; - } else { - this.tlsVersion = EMPTY_STRINGS; - } + this.ssl.tlsVersions(tlsVersion); return this; } /** * Configures SSL {@link HostnameVerifier}, it is available only set {@link #sslMode(SslMode)} as - * {@link SslMode#VERIFY_IDENTITY}. It is useful when server was using special Certificates or need - * special verification. + * {@link SslMode#VERIFY_IDENTITY}. It is useful when server was using special Certificates or need special + * verification. *

* Default is builtin {@link HostnameVerifier} which use RFC standards. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * * @param sslHostnameVerifier the custom {@link HostnameVerifier}. * @return this {@link Builder} @@ -773,8 +836,9 @@ public Builder tlsVersion(String... tlsVersion) { * @since 0.8.2 */ public Builder sslHostnameVerifier(HostnameVerifier sslHostnameVerifier) { - this.sslHostnameVerifier = requireNonNull(sslHostnameVerifier, - "sslHostnameVerifier must not be null"); + requireNonNull(sslHostnameVerifier, "sslHostnameVerifier must not be null"); + + this.ssl.sslHostnameVerifier(sslHostnameVerifier); return this; } @@ -783,41 +847,47 @@ public Builder sslHostnameVerifier(HostnameVerifier sslHostnameVerifier) { * {@link #sslMode(SslMode)} is configured for verify server certification. *

* Default is {@code null}, which means that the default algorithm is used for the trust manager. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * * @param sslCa an X.509 certificate chain file in PEM format. - * @return this {@link Builder}. + * @return {@link Builder this} * @since 0.8.1 */ public Builder sslCa(@Nullable String sslCa) { - this.sslCa = sslCa; + this.ssl.sslCa(sslCa); return this; } /** * Configures client SSL certificate for client authentication. *

- * The {@link #sslCert} and {@link #sslKey} must be both non-{@code null} or both {@code null}. + * It and {@link #sslKey} must be both non-{@code null} or both {@code null}. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * * @param sslCert an X.509 certificate chain file in PEM format, or {@code null} if no SSL cert. - * @return this {@link Builder}. + * @return {@link Builder this} * @since 0.8.2 */ public Builder sslCert(@Nullable String sslCert) { - this.sslCert = sslCert; + this.ssl.sslCert(sslCert); return this; } /** * Configures client SSL key for client authentication. *

- * The {@link #sslCert} and {@link #sslKey} must be both non-{@code null} or both {@code null}. + * It and {@link #sslCert} must be both non-{@code null} or both {@code null}. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * * @param sslKey a PKCS#8 private key file in PEM format, or {@code null} if no SSL key. - * @return this {@link Builder}. + * @return {@link Builder this} * @since 0.8.2 */ public Builder sslKey(@Nullable String sslKey) { - this.sslKey = sslKey; + this.ssl.sslKey(sslKey); return this; } @@ -825,39 +895,42 @@ public Builder sslKey(@Nullable String sslKey) { * Configures the password of SSL key file for client certificate authentication. *

* It will be used only if {@link #sslKey} and {@link #sslCert} non-null. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * - * @param sslKeyPassword the password of the {@link #sslKey}, or {@code null} if it's not - * password-protected. - * @return this {@link Builder}. + * @param sslKeyPassword the password of the {@link #sslKey}, or {@code null} if it's not password-protected. + * @return {@link Builder this} * @since 0.8.2 */ public Builder sslKeyPassword(@Nullable CharSequence sslKeyPassword) { - this.sslKeyPassword = sslKeyPassword; + this.ssl.sslKeyPassword(sslKeyPassword); return this; } /** - * Configures a {@link SslContextBuilder} customizer. The customizer gets applied on each SSL - * connection attempt to allow for just-in-time configuration updates. The {@link Function} gets - * called with the prepared {@link SslContextBuilder} that has all configuration options applied. The - * customizer may return the same builder or return a new builder instance to be used to build the SSL - * context. + * Configures a {@link SslContextBuilder} customizer. The customizer gets applied on each SSL connection attempt + * to allow for just-in-time configuration updates. The {@link Function} gets called with the prepared + * {@link SslContextBuilder} that has all configuration options applied. The customizer may return the same + * builder or return a new builder instance to be used to build the SSL context. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * * @param customizer customizer function * @return this {@link Builder} * @throws IllegalArgumentException if {@code customizer} is {@code null} * @since 0.8.1 */ - public Builder sslContextBuilderCustomizer( - Function customizer) { + public Builder sslContextBuilderCustomizer(Function customizer) { requireNonNull(customizer, "sslContextBuilderCustomizer must not be null"); - this.sslContextBuilderCustomizer = customizer; + this.ssl.sslContextBuilderCustomizer(customizer); return this; } /** * Configures TCP KeepAlive. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * * @param enabled whether to enable TCP KeepAlive * @return this {@link Builder} @@ -865,12 +938,14 @@ public Builder sslContextBuilderCustomizer( * @since 0.8.2 */ public Builder tcpKeepAlive(boolean enabled) { - this.tcpKeepAlive = enabled; + requireTcpSocket().tcpKeepAlive(enabled); return this; } /** * Configures TCP NoDelay. + *

+ * Note: It is used only if the {@link #unixSocket(String)} is not configured. * * @param enabled whether to enable TCP NoDelay * @return this {@link Builder} @@ -878,15 +953,14 @@ public Builder tcpKeepAlive(boolean enabled) { * @since 0.8.2 */ public Builder tcpNoDelay(boolean enabled) { - this.tcpNoDelay = enabled; + requireTcpSocket().tcpNoDelay(enabled); return this; } /** * Configures the protocol of parameterized statements to the text protocol. *

- * The text protocol is default protocol that's using client-preparing. See also MySQL - * documentations. + * The text protocol is default protocol that's using client-preparing. See also MySQL documentations. * * @return this {@link Builder} * @since 0.8.1 @@ -899,10 +973,9 @@ public Builder useClientPrepareStatement() { /** * Configures the protocol of parameterized statements to the binary protocol. *

- * The binary protocol is compact protocol that's using server-preparing. See also MySQL - * documentations. + * The binary protocol is compact protocol that's using server-preparing. See also MySQL documentations. * - * @return this {@link Builder}. + * @return {@link Builder this} * @since 0.8.1 */ public Builder useServerPrepareStatement() { @@ -910,19 +983,18 @@ public Builder useServerPrepareStatement() { } /** - * Configures the protocol of parameterized statements and prepare-preferred simple statements to the - * binary protocol. + * Configures the protocol of parameterized statements and prepare-preferred simple statements to the binary + * protocol. *

- * The {@code preferPrepareStatement} configures whether to prefer prepare execution on a - * statement-by-statement basis (simple statements). The {@link Predicate} accepts the simple SQL - * query string and returns a boolean flag indicating preference. {@code true} prepare-preferred, - * {@code false} prefers direct execution (text protocol). Defaults to direct execution. + * The {@code preferPrepareStatement} configures whether to prefer prepare execution on a statement-by-statement + * basis (simple statements). The {@link Predicate} accepts the simple SQL query string and returns a boolean + * flag indicating preference. {@code true} prepare-preferred, {@code false} prefers direct execution (text + * protocol). Defaults to direct execution. *

- * The binary protocol is compact protocol that's using server-preparing. See also MySQL - * documentations. + * The binary protocol is compact protocol that's using server-preparing. See also MySQL documentations. * * @param preferPrepareStatement the above {@link Predicate}. - * @return this {@link Builder}. + * @return {@link Builder this} * @throws IllegalArgumentException if {@code preferPrepareStatement} is {@code null}. * @since 0.8.1 */ @@ -934,8 +1006,8 @@ public Builder useServerPrepareStatement(Predicate preferPrepareStatemen } /** - * Configures the session variables, used to set session variables immediately after login. Default no - * session variables to set. It should be a list of key-value pairs. e.g. + * Configures the session variables, used to set session variables immediately after login. Default no session + * variables to set. It should be a list of key-value pairs. e.g. * {@code ["sql_mode='ANSI_QUOTES,STRICT_TRANS_TABLES'", "time_zone=00:00"]}. * * @param sessionVariables the session variables to set. @@ -951,7 +1023,7 @@ public Builder sessionVariables(String... sessionVariables) { } /** - * Configures the lock wait timeout. Default to use the server-side default value. + * <<<<<<< HEAD Configures the lock wait timeout. Default to use the server-side default value. * * @param lockWaitTimeout the lock wait timeout, or {@code null} to use the server-side default value. * @return {@link Builder this} @@ -975,8 +1047,8 @@ public Builder statementTimeout(@Nullable Duration statementTimeout) { } /** - * Configures to allow the {@code LOAD DATA LOCAL INFILE} statement in the given {@code path} or - * disallow the statement. Default to {@code null} which means not allow the statement. + * Configures to allow the {@code LOAD DATA LOCAL INFILE} statement in the given {@code path} or disallow the + * statement. Default to {@code null} which means not allow the statement. * * @param path which parent path are allowed to load file data, {@code null} means not be allowed. * @return {@link Builder this}. @@ -1007,14 +1079,14 @@ public Builder localInfileBufferSize(int localInfileBufferSize) { } /** - * Configures the maximum size of the {@link Query} parsing cache. Usually it should be power of two. - * Default to {@code 0}. Driver will use unbounded cache if size is less than {@code 0}. + * Configures the maximum size of the {@link Query} parsing cache. Usually it should be power of two. Default to + * {@code 0}. Driver will use unbounded cache if size is less than {@code 0}. *

- * Notice: the cache is using EL model (the PACELC theorem) which provider better performance. That - * means it is an elastic cache. So this size is not a hard-limit. It should be over 16 in average. + * Notice: the cache is using EL model (the PACELC theorem) which provider better performance. That means it is + * an elastic cache. So this size is not a hard-limit. It should be over 16 in average. * * @param queryCacheSize the above size, {@code 0} means no cache, {@code -1} means unbounded cache. - * @return this {@link Builder}. + * @return {@link Builder this} * @since 0.8.3 */ public Builder queryCacheSize(int queryCacheSize) { @@ -1023,19 +1095,17 @@ public Builder queryCacheSize(int queryCacheSize) { } /** - * Configures the maximum size of the server-preparing cache. Usually it should be power of two. - * Default to {@code 256}. Driver will use unbounded cache if size is less than {@code 0}. It is used - * only if using server-preparing parameterized statements, i.e. the {@link #useServerPrepareStatement} - * is set. + * Configures the maximum size of the server-preparing cache. Usually it should be power of two. Default to + * {@code 256}. Driver will use unbounded cache if size is less than {@code 0}. It is used only if using + * server-preparing parameterized statements, i.e. the {@link #useServerPrepareStatement} is set. *

- * Notice: the cache is using EC model (the PACELC theorem) for ensure consistency. Consistency is - * very important because MySQL contains a hard limit of all server-prepared statements which has been - * opened, see also {@code max_prepared_stmt_count}. And, the cache is one-to-one connection, which - * means it will not work on thread-concurrency. + * Notice: the cache is using EC model (the PACELC theorem) for ensure consistency. Consistency is very + * important because MySQL contains a hard limit of all server-prepared statements which has been opened, see + * also {@code max_prepared_stmt_count}. And, the cache is one-to-one connection, which means it will not work + * on thread-concurrency. * - * @param prepareCacheSize the above size, {@code 0} means no cache, {@code -1} means unbounded - * cache. - * @return this {@link Builder}. + * @param prepareCacheSize the above size, {@code 0} means no cache, {@code -1} means unbounded cache. + * @return {@link Builder this} * @since 0.8.3 */ public Builder prepareCacheSize(int prepareCacheSize) { @@ -1046,10 +1116,9 @@ public Builder prepareCacheSize(int prepareCacheSize) { /** * Configures the compression algorithms. Default to [{@link CompressionAlgorithm#UNCOMPRESSED}]. *

- * It will auto choose an algorithm that's contained in the list and supported by the server, - * preferring zstd, then zlib. If the list does not contain {@link CompressionAlgorithm#UNCOMPRESSED} - * and the server does not support any algorithm in the list, an exception will be thrown when - * connecting. + * It will auto choose an algorithm that's contained in the list and supported by the server, preferring zstd, + * then zlib. If the list does not contain {@link CompressionAlgorithm#UNCOMPRESSED} and the server does not + * support any algorithm in the list, an exception will be thrown when connecting. *

* Note: zstd requires a dependency {@code com.github.luben:zstd-jni}. * @@ -1106,12 +1175,12 @@ public Builder zstdCompressionLevel(int level) { * {@link TcpResources#get() global tcp resources}. * * @param loopResources the {@link LoopResources}. - * @return this {@link Builder}. + * @return {@link Builder this} * @throws IllegalArgumentException if {@code loopResources} is {@code null}. * @since 1.1.2 */ public Builder loopResources(LoopResources loopResources) { - this.loopResources = requireNonNull(loopResources, "loopResources must not be null"); + this.client.loopResources(loopResources); return this; } @@ -1120,7 +1189,7 @@ public Builder loopResources(LoopResources loopResources) { * {@code true}. * * @param enabled to discover and register extensions. - * @return this {@link Builder}. + * @return {@link Builder this} * @since 0.8.2 */ public Builder autodetectExtensions(boolean enabled) { @@ -1131,12 +1200,12 @@ public Builder autodetectExtensions(boolean enabled) { /** * Registers a {@link Extension} to extend driver functionality and manually. *

- * Notice: the driver will not deduplicate {@link Extension}s of autodetect discovered and manually - * extended. So if a {@link Extension} is registered by this function and autodetect discovered, it - * will get two {@link Extension} as same. + * Notice: the driver will not deduplicate {@link Extension}s of autodetect discovered and manually extended. So + * if a {@link Extension} is registered by this function and autodetect discovered, it will get two + * {@link Extension} as same. * * @param extension extension to extend driver functionality. - * @return this {@link Builder}. + * @return {@link Builder this} * @throws IllegalArgumentException if {@code extension} is {@code null}. * @since 0.8.2 */ @@ -1146,26 +1215,36 @@ public Builder extendWith(Extension extension) { } /** - * Registers a password publisher function. + * Registers a password publisher function. Since 1.2.0, it is an alias of {@link #password(Publisher)}. * - * @param passwordPublisher function to retrieve password before making connection. - * @return this {@link Builder}. + * @param password a {@link Publisher} to retrieve password before making connection. + * @return {@link Builder this} */ - public Builder passwordPublisher(Publisher passwordPublisher) { - this.passwordPublisher = passwordPublisher; - return this; + public Builder passwordPublisher(Publisher password) { + return password(password); } - private SslMode requireSslMode() { - SslMode sslMode = this.sslMode; + private TcpSocketConfiguration.Builder requireTcpSocket() { + TcpSocketConfiguration.Builder tcpSocket = this.tcpSocket; - if (sslMode == null) { - sslMode = isHost ? SslMode.PREFERRED : SslMode.DISABLED; + if (tcpSocket == null) { + this.tcpSocket = tcpSocket = new TcpSocketConfiguration.Builder(); } - return sslMode; + return tcpSocket; } - private Builder() { } + private UnixDomainSocketConfiguration.Builder requireUnixSocket() { + UnixDomainSocketConfiguration.Builder unixSocket = this.unixSocket; + + if (unixSocket == null) { + this.unixSocket = unixSocket = new UnixDomainSocketConfiguration.Builder(); + } + + return unixSocket; + } + + private Builder() { + } } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java index d003db2b0..478ff7794 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactory.java @@ -19,19 +19,11 @@ import io.asyncer.r2dbc.mysql.api.MySqlConnection; import io.asyncer.r2dbc.mysql.cache.Caches; import io.asyncer.r2dbc.mysql.cache.QueryCache; -import io.asyncer.r2dbc.mysql.client.Client; -import io.asyncer.r2dbc.mysql.internal.util.StringUtils; -import io.netty.channel.unix.DomainSocketAddress; import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.ConnectionFactoryMetadata; import org.jetbrains.annotations.Nullable; -import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.time.ZoneId; -import java.util.Objects; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Supplier; @@ -69,127 +61,28 @@ public static MySqlConnectionFactory from(MySqlConnectionConfiguration configura LazyQueryCache queryCache = new LazyQueryCache(configuration.getQueryCacheSize()); - return new MySqlConnectionFactory(Mono.defer(() -> { - MySqlSslConfiguration ssl; - SocketAddress address; - - if (configuration.isHost()) { - ssl = configuration.getSsl(); - address = InetSocketAddress.createUnresolved(configuration.getDomain(), - configuration.getPort()); - } else { - ssl = MySqlSslConfiguration.disabled(); - address = new DomainSocketAddress(configuration.getDomain()); - } - - String user = configuration.getUser(); - CharSequence password = configuration.getPassword(); - Publisher passwordPublisher = configuration.getPasswordPublisher(); - - if (Objects.nonNull(passwordPublisher)) { - return Mono.from(passwordPublisher).flatMap(token -> getMySqlConnection( - configuration, ssl, - queryCache, - address, - user, - token - )); - } - - return getMySqlConnection( - configuration, ssl, - queryCache, - address, - user, - password - ); - })); - } - - /** - * Gets an initialized {@link MySqlConnection} from authentication credential and configurations. - *

- * It contains following steps: - *

  1. Create connection context
  2. - *
  3. Connect to MySQL server with TCP or Unix Domain Socket
  4. - *
  5. Handshake/login and init handshake states
  6. - *
  7. Init session states
- * - * @param configuration the connection configuration. - * @param ssl the SSL configuration. - * @param queryCache lazy-init query cache, it is shared among all connections from the same factory. - * @param address TCP or Unix Domain Socket address. - * @param user the user of the authentication. - * @param password the password of the authentication. - * @return a {@link MySqlConnection}. - */ - private static Mono getMySqlConnection( - final MySqlConnectionConfiguration configuration, - final MySqlSslConfiguration ssl, - final LazyQueryCache queryCache, - final SocketAddress address, - final String user, - @Nullable final CharSequence password - ) { - return Mono.fromSupplier(() -> { - ZoneId connectionTimeZone = retrieveZoneId(configuration.getConnectionTimeZone()); - return new ConnectionContext( - configuration.getZeroDateOption(), - configuration.getLoadLocalInfilePath(), - configuration.getLocalInfileBufferSize(), - configuration.isPreserveInstants(), - connectionTimeZone - ); - }).flatMap(context -> Client.connect( - ssl, - address, - configuration.isTcpKeepAlive(), - configuration.isTcpNoDelay(), - context, - configuration.getConnectTimeout(), - configuration.getLoopResources() - )).flatMap(client -> { - // Lazy init database after handshake/login - boolean deferDatabase = configuration.isCreateDatabaseIfNotExist(); - String database = configuration.getDatabase(); - String loginDb = deferDatabase ? "" : database; - String sessionDb = deferDatabase ? database : ""; - - return InitFlow.initHandshake( - client, - ssl.getSslMode(), - loginDb, - user, - password, - configuration.getCompressionAlgorithms(), - configuration.getZstdCompressionLevel() - ).then(InitFlow.initSession( - client, - sessionDb, - configuration.getPrepareCacheSize(), - configuration.getSessionVariables(), - configuration.isForceConnectionTimeZoneToSession(), - configuration.getLockWaitTimeout(), - configuration.getStatementTimeout(), - configuration.getExtensions() - )).map(codecs -> new MySqlSimpleConnection( - client, - codecs, - queryCache.get(), - configuration.getPreferPrepareStatement() - )).onErrorResume(e -> client.forceClose().then(Mono.error(e))); - }); - } - - @Nullable - private static ZoneId retrieveZoneId(String timeZone) { - if ("LOCAL".equalsIgnoreCase(timeZone)) { - return ZoneId.systemDefault().normalized(); - } else if ("SERVER".equalsIgnoreCase(timeZone)) { - return null; - } - - return StringUtils.parseZoneId(timeZone); + return new MySqlConnectionFactory(Mono.defer(() -> configuration.getSocket() + .strategy(configuration) + .connect() + .flatMap(client -> { + String sessionDb = configuration.isCreateDatabaseIfNotExist() ? configuration.getDatabase() : ""; + + return InitFlow.initSession( + client, + sessionDb, + configuration.getPrepareCacheSize(), + configuration.getSessionVariables(), + configuration.isForceConnectionTimeZoneToSession(), + configuration.getLockWaitTimeout(), + configuration.getStatementTimeout(), + configuration.getExtensions() + ).map(codecs -> new MySqlSimpleConnection( + client, + codecs, + queryCache.get(), + configuration.getPreferPrepareStatement() + )).onErrorResume(e -> client.close().then(Mono.error(e))); + }))); } private static final class LazyQueryCache implements Supplier { diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java index f6dc1a57a..c26469aec 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java @@ -17,8 +17,12 @@ package io.asyncer.r2dbc.mysql; import io.asyncer.r2dbc.mysql.constant.CompressionAlgorithm; +import io.asyncer.r2dbc.mysql.constant.ProtocolDriver; +import io.asyncer.r2dbc.mysql.constant.HaProtocol; import io.asyncer.r2dbc.mysql.constant.SslMode; import io.asyncer.r2dbc.mysql.constant.ZeroDateOption; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; +import io.asyncer.r2dbc.mysql.internal.util.AddressUtils; import io.netty.handler.ssl.SslContextBuilder; import io.r2dbc.spi.ConnectionFactory; import io.r2dbc.spi.ConnectionFactoryOptions; @@ -45,6 +49,7 @@ import static io.r2dbc.spi.ConnectionFactoryOptions.LOCK_WAIT_TIMEOUT; import static io.r2dbc.spi.ConnectionFactoryOptions.PASSWORD; import static io.r2dbc.spi.ConnectionFactoryOptions.PORT; +import static io.r2dbc.spi.ConnectionFactoryOptions.PROTOCOL; import static io.r2dbc.spi.ConnectionFactoryOptions.SSL; import static io.r2dbc.spi.ConnectionFactoryOptions.STATEMENT_TIMEOUT; import static io.r2dbc.spi.ConnectionFactoryOptions.USER; @@ -54,11 +59,6 @@ */ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryProvider { - /** - * The name of the driver used for discovery, should not be changed. - */ - public static final String MYSQL_DRIVER = "mysql"; - /** * Option to set the Unix Domain Socket. * @@ -67,8 +67,8 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr public static final Option UNIX_SOCKET = Option.valueOf("unixSocket"); /** - * Option to set the time zone conversion. Default to {@code true} means enable conversion between JVM - * and {@link #CONNECTION_TIME_ZONE}. + * Option to set the time zone conversion. Default to {@code true} means enable conversion between JVM and + * {@link #CONNECTION_TIME_ZONE}. *

* Note: disable it will ignore the time zone of connection, and use the JVM local time zone. * @@ -77,9 +77,9 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr public static final Option PRESERVE_INSTANTS = Option.valueOf("preserveInstants"); /** - * Option to set the time zone of connection. Default to {@code LOCAL} means use JVM local time zone. - * It should be {@code "LOCAL"}, {@code "SERVER"}, or a valid ID of {@code ZoneId}. {@code "SERVER"} means - * querying the server-side timezone during initialization. + * Option to set the time zone of connection. Default to {@code LOCAL} means use JVM local time zone. It should be + * {@code "LOCAL"}, {@code "SERVER"}, or a valid ID of {@code ZoneId}. {@code "SERVER"} means querying the + * server-side timezone during initialization. * * @since 1.1.2 */ @@ -88,8 +88,8 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr /** * Option to force the time zone of connection to session time zone. Default to {@code false}. *

- * Note: alter the time zone of session will affect the results of MySQL date/time functions, e.g. - * {@code NOW([n])}, {@code CURRENT_TIME([n])}, {@code CURRENT_DATE()}, etc. Please use with caution. + * Note: alter the time zone of session will affect the results of MySQL date/time functions, e.g. {@code NOW([n])}, + * {@code CURRENT_TIME([n])}, {@code CURRENT_DATE()}, etc. Please use with caution. * * @since 1.1.2 */ @@ -97,8 +97,7 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr Option.valueOf("forceConnectionTimeZoneToSession"); /** - * Option to set {@link ZoneId} of server. If it is set, driver will ignore the real time zone of - * server-side. + * Option to set {@link ZoneId} of server. If it is set, driver will ignore the real time zone of server-side. * * @since 0.8.2 * @deprecated since 1.1.2, use {@link #CONNECTION_TIME_ZONE} instead. @@ -122,8 +121,8 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr /** * Option to configure {@link HostnameVerifier}. It is available only if the {@link #SSL_MODE} set to - * {@link SslMode#VERIFY_IDENTITY}. It can be an implementation class name of {@link HostnameVerifier} - * with a public no-args constructor. + * {@link SslMode#VERIFY_IDENTITY}. It can be an implementation class name of {@link HostnameVerifier} with a public + * no-args constructor. * * @since 0.8.2 */ @@ -131,17 +130,17 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr Option.valueOf("sslHostnameVerifier"); /** - * Option to TLS versions for SslContext protocols, see also {@code TlsVersions}. Usually sorted from - * higher to lower. It can be a {@code Collection}. It can be a {@link String}, protocols will be - * split by {@code ,}. e.g. "TLSv1.2,TLSv1.1,TLSv1". + * Option to TLS versions for SslContext protocols, see also {@code TlsVersions}. Usually sorted from higher to + * lower. It can be a {@code Collection}. It can be a {@link String}, protocols will be split by {@code ,}. + * e.g. "TLSv1.2,TLSv1.1,TLSv1". * * @since 0.8.1 */ public static final Option TLS_VERSION = Option.valueOf("tlsVersion"); /** - * Option to set a PEM file of server SSL CA. It will be used to verify server certificates. And it will - * be used only if {@link #SSL_MODE} set to {@link SslMode#VERIFY_CA} or higher level. + * Option to set a PEM file of server SSL CA. It will be used to verify server certificates. And it will be used + * only if {@link #SSL_MODE} set to {@link SslMode#VERIFY_CA} or higher level. * * @since 0.8.1 */ @@ -170,8 +169,8 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr public static final Option SSL_CERT = Option.valueOf("sslCert"); /** - * Option to custom {@link SslContextBuilder}. It can be an implementation class name of {@link Function} - * with a public no-args constructor. + * Option to custom {@link SslContextBuilder}. It can be an implementation class name of {@link Function} with a + * public no-args constructor. * * @since 0.8.2 */ @@ -203,18 +202,17 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr /** * Enable server preparing for parameterized statements and prefer server preparing simple statements. *

- * The value can be a {@link Boolean}. If it is {@code true}, driver will use server preparing for - * parameterized statements and text query for simple statements. If it is {@code false}, driver will use - * client preparing for parameterized statements and text query for simple statements. + * The value can be a {@link Boolean}. If it is {@code true}, driver will use server preparing for parameterized + * statements and text query for simple statements. If it is {@code false}, driver will use client preparing for + * parameterized statements and text query for simple statements. *

- * The value can be a {@link Predicate}{@code <}{@link String}{@code >}. If it is set, driver will server - * preparing for parameterized statements, it configures whether to prefer prepare execution on a - * statement-by-statement basis (simple statements). The {@link Predicate}{@code <}{@link String}{@code >} - * accepts the simple SQL query string and returns a {@code boolean} flag indicating preference. + * The value can be a {@link Predicate}{@code <}{@link String}{@code >}. If it is set, driver will server preparing + * for parameterized statements, it configures whether to prefer prepare execution on a statement-by-statement basis + * (simple statements). The {@link Predicate}{@code <}{@link String}{@code >} accepts the simple SQL query string + * and returns a {@code boolean} flag indicating preference. *

- * The value can be a {@link String}. If it is set, driver will try to convert it to {@link Boolean} or an - * instance of {@link Predicate}{@code <}{@link String}{@code >} which use reflection with a public - * no-args constructor. + * The value can be a {@link String}. If it is set, driver will try to convert it to {@link Boolean} or an instance + * of {@link Predicate}{@code <}{@link String}{@code >} which use reflection with a public no-args constructor. * * @since 0.8.1 */ @@ -248,9 +246,9 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr /** * Option to set compression algorithms. Default to [{@link CompressionAlgorithm#UNCOMPRESSED}]. *

- * It will auto choose an algorithm that's contained in the list and supported by the server, preferring - * zstd, then zlib. If the list does not contain {@link CompressionAlgorithm#UNCOMPRESSED} and the server - * does not support any algorithm in the list, an exception will be thrown when connecting. + * It will auto choose an algorithm that's contained in the list and supported by the server, preferring zstd, then + * zlib. If the list does not contain {@link CompressionAlgorithm#UNCOMPRESSED} and the server does not support any + * algorithm in the list, an exception will be thrown when connecting. *

* Note: zstd requires a dependency {@code com.github.luben:zstd-jni}. * @@ -264,8 +262,7 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr *

* It is only used if zstd is chosen for the connection. *

- * Note: MySQL protocol does not allow to set the zlib compression level of the server, only zstd is - * configurable. + * Note: MySQL protocol does not allow to set the zlib compression level of the server, only zstd is configurable. * * @since 1.1.2 */ @@ -302,9 +299,9 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr public static final Option AUTODETECT_EXTENSIONS = Option.valueOf("autodetectExtensions"); /** - * Password Publisher function can be used to retrieve password before creating a connection. This can be - * used with Amazon RDS Aurora IAM authentication, wherein it requires token to be generated. The token is - * valid for 15 minutes, and this token will be used as password. + * Password Publisher function can be used to retrieve password before creating a connection. This can be used with + * Amazon RDS Aurora IAM authentication, wherein it requires token to be generated. The token is valid for 15 + * minutes, and this token will be used as password. */ public static final Option> PASSWORD_PUBLISHER = Option.valueOf("passwordPublisher"); @@ -318,12 +315,14 @@ public ConnectionFactory create(ConnectionFactoryOptions options) { @Override public boolean supports(ConnectionFactoryOptions options) { requireNonNull(options, "connectionFactoryOptions must not be null"); - return MYSQL_DRIVER.equals(options.getValue(DRIVER)); + + Object driver = options.getValue(DRIVER); + return driver instanceof String && ProtocolDriver.supports((String) driver); } @Override public String getDriver() { - return MYSQL_DRIVER; + return ProtocolDriver.standardDriver(); } /** @@ -340,16 +339,26 @@ static MySqlConnectionConfiguration setup(ConnectionFactoryOptions options) { .to(builder::user); mapper.optional(PASSWORD).asPassword() .to(builder::password); - mapper.optional(UNIX_SOCKET).asString() - .to(builder::unixSocket) - .otherwise(() -> setupHost(builder, mapper)); + + boolean unixSocket = mapper.optional(UNIX_SOCKET).asString() + .to(builder::unixSocket); + + if (!unixSocket) { + setupHost(builder, mapper); + } + mapper.optional(PRESERVE_INSTANTS).asBoolean() .to(builder::preserveInstants); - mapper.optional(CONNECTION_TIME_ZONE).asString() - .to(builder::connectionTimeZone) - .otherwise(() -> mapper.optional(SERVER_ZONE_ID) + + boolean connectionTimeZone = mapper.optional(CONNECTION_TIME_ZONE).asString() + .to(builder::connectionTimeZone); + + if (!connectionTimeZone) { + mapper.optional(SERVER_ZONE_ID) .as(ZoneId.class, id -> ZoneId.of(id, ZoneId.SHORT_IDS)) - .to(builder::serverZoneId)); + .to(builder::serverZoneId); + } + mapper.optional(FORCE_CONNECTION_TIME_ZONE_TO_SESSION).asBoolean() .to(builder::forceConnectionTimeZoneToSession); mapper.optional(TCP_KEEP_ALIVE).asBoolean() @@ -388,7 +397,7 @@ static MySqlConnectionConfiguration setup(ConnectionFactoryOptions options) { mapper.optional(LOOP_RESOURCES).as(LoopResources.class) .to(builder::loopResources); mapper.optional(PASSWORD_PUBLISHER).as(Publisher.class) - .to(builder::passwordPublisher); + .to(builder::password); mapper.optional(SESSION_VARIABLES).asArray( String[].class, Function.identity(), @@ -404,17 +413,44 @@ static MySqlConnectionConfiguration setup(ConnectionFactoryOptions options) { } /** - * Set builder of {@link MySqlConnectionConfiguration} for hostname-based address with SSL - * configurations. + * Set builder of {@link MySqlConnectionConfiguration} for hostname-based path with SSL configurations. * * @param builder the builder of {@link MySqlConnectionConfiguration}. * @param mapper the {@link OptionMapper} of {@code options}. */ private static void setupHost(MySqlConnectionConfiguration.Builder builder, OptionMapper mapper) { - mapper.requires(HOST).asString() - .to(builder::host); - mapper.optional(PORT).asInt() + boolean port = mapper.optional(PORT).asInt() .to(builder::port); + + if (port) { + // If port is set, host must be a single host. + mapper.requires(HOST).asString() + .to(builder::host); + } else { + // If port is not set, host can be a single host or multiple hosts. + // If the URI contains an underscore in the host, it will produce an incorrectly resolved host and port. + // e.g. "r2dbc:mysql://my_db:3306" will be resolved to "my_db:3306" as host and null as port. + // See https://github.com/asyncer-io/r2dbc-mysql/issues/255 + mapper.requires(HOST) + .asArray(String[].class, Function.identity(), it -> it.split(","), String[]::new) + .to(hosts -> { + if (hosts.length == 1) { + builder.host(hosts[0]); + return; + } + + for (String host : hosts) { + NodeAddress address = AddressUtils.parseAddress(host); + + builder.addHost(address.getHost(), address.getPort()); + } + }); + } + + mapper.requires(DRIVER).as(ProtocolDriver.class, ProtocolDriver::from) + .to(builder::driver); + mapper.optional(PROTOCOL).as(HaProtocol.class, HaProtocol::from) + .to(builder::protocol); mapper.optional(SSL).asBoolean() .to(isSsl -> builder.sslMode(isSsl ? SslMode.REQUIRED : SslMode.DISABLED)); mapper.optional(SSL_MODE).as(SslMode.class, id -> SslMode.valueOf(id.toUpperCase())) @@ -437,12 +473,12 @@ private static void setupHost(MySqlConnectionConfiguration.Builder builder, Opti } /** - * Splits session variables from user input. e.g. {@code sql_mode='ANSI_QUOTE,STRICT',c=d;e=f} will be - * split into {@code ["sql_mode='ANSI_QUOTE,STRICT'", "c=d", "e=f"]}. + * Splits session variables from user input. e.g. {@code sql_mode='ANSI_QUOTE,STRICT',c=d;e=f} will be split into + * {@code ["sql_mode='ANSI_QUOTE,STRICT'", "c=d", "e=f"]}. *

- * It supports escaping characters with backslash, quoted values with single or double quotes, and nested - * brackets. Priorities are: backslash in quoted > single quote = double quote > bracket, backslash - * will not be a valid escape character if it is not in a quoted value. + * It supports escaping characters with backslash, quoted values with single or double quotes, and nested brackets. + * Priorities are: backslash in quoted > single quote = double quote > bracket, backslash will not be a valid + * escape character if it is not in a quoted value. *

* Note that it does not strictly check syntax validity, so it will not throw syntax exceptions. * diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlSslConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlSslConfiguration.java index d76662f40..00dd10cfb 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlSslConfiguration.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlSslConfiguration.java @@ -17,6 +17,7 @@ package io.asyncer.r2dbc.mysql; import io.asyncer.r2dbc.mysql.constant.SslMode; +import io.asyncer.r2dbc.mysql.internal.util.InternalArrays; import io.netty.handler.ssl.SslContextBuilder; import org.jetbrains.annotations.Nullable; @@ -25,7 +26,7 @@ import java.util.Objects; import java.util.function.Function; -import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonNull; +import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.require; import static io.asyncer.r2dbc.mysql.internal.util.InternalArrays.EMPTY_STRINGS; /** @@ -106,8 +107,8 @@ public String getSslCert() { } /** - * Customizes a {@link SslContextBuilder} that customizer was specified by configuration, or do nothing if - * the customizer was not set. + * Customizes a {@link SslContextBuilder} that customizer was specified by configuration, or do nothing if the + * customizer was not set. * * @param builder the {@link SslContextBuilder}. * @return the {@code builder}. @@ -162,19 +163,87 @@ static MySqlSslConfiguration disabled() { return DISABLED; } - static MySqlSslConfiguration create(SslMode sslMode, String[] tlsVersion, - @Nullable HostnameVerifier sslHostnameVerifier, @Nullable String sslCa, @Nullable String sslKey, - @Nullable CharSequence sslKeyPassword, @Nullable String sslCert, - @Nullable Function sslContextBuilderCustomizer) { - requireNonNull(sslMode, "sslMode must not be null"); + static final class Builder { - if (sslMode == SslMode.DISABLED) { - return DISABLED; + @Nullable + private SslMode sslMode; + + private String[] tlsVersions = InternalArrays.EMPTY_STRINGS; + + @Nullable + private HostnameVerifier sslHostnameVerifier; + + @Nullable + private String sslCa; + + @Nullable + private String sslKey; + + @Nullable + private CharSequence sslKeyPassword; + + @Nullable + private String sslCert; + + @Nullable + private Function sslContextBuilderCustomizer; + + void sslMode(SslMode sslMode) { + this.sslMode = sslMode; } - requireNonNull(tlsVersion, "tlsVersion must not be null"); + void tlsVersions(String[] tlsVersions) { + int size = tlsVersions.length; + + if (size > 0) { + String[] versions = new String[size]; + System.arraycopy(tlsVersions, 0, versions, 0, size); + this.tlsVersions = versions; + } else { + this.tlsVersions = EMPTY_STRINGS; + } + } + + void sslHostnameVerifier(HostnameVerifier sslHostnameVerifier) { + this.sslHostnameVerifier = sslHostnameVerifier; + } - return new MySqlSslConfiguration(sslMode, tlsVersion, sslHostnameVerifier, sslCa, sslKey, - sslKeyPassword, sslCert, sslContextBuilderCustomizer); + void sslCa(@Nullable String sslCa) { + this.sslCa = sslCa; + } + + void sslCert(@Nullable String sslCert) { + this.sslCert = sslCert; + } + + void sslKey(@Nullable String sslKey) { + this.sslKey = sslKey; + } + + void sslKeyPassword(@Nullable CharSequence sslKeyPassword) { + this.sslKeyPassword = sslKeyPassword; + } + + void sslContextBuilderCustomizer(Function customizer) { + this.sslContextBuilderCustomizer = customizer; + } + + MySqlSslConfiguration build(boolean preferred) { + SslMode sslMode = this.sslMode; + + if (sslMode == null) { + sslMode = preferred ? SslMode.PREFERRED : SslMode.DISABLED; + } + + if (sslMode == SslMode.DISABLED) { + return DISABLED; + } + + require((sslCert == null && sslKey == null) || (sslCert != null && sslKey != null), + "sslCert and sslKey must be both null or both non-null"); + + return new MySqlSslConfiguration(sslMode, tlsVersions, sslHostnameVerifier, sslCa, sslKey, + sslKeyPassword, sslCert, sslContextBuilderCustomizer); + } } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlStatementSupport.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlStatementSupport.java index 5b40500ee..7fd8cfbc1 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlStatementSupport.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlStatementSupport.java @@ -46,12 +46,11 @@ abstract class MySqlStatementSupport implements MySqlStatement { public final MySqlStatement returnGeneratedValues(String... columns) { requireNonNull(columns, "columns must not be null"); - ConnectionContext context = client.getContext(); int len = columns.length; if (len == 0) { this.generatedColumns = InternalArrays.EMPTY_STRINGS; - } else if (len == 1 || supportReturning(context)) { + } else if (len == 1 || supportReturning(client.getContext())) { String[] result = new String[len]; for (int i = 0; i < len; ++i) { @@ -61,7 +60,7 @@ public final MySqlStatement returnGeneratedValues(String... columns) { this.generatedColumns = result; } else { - String db = context.isMariaDb() ? "MariaDB 10.5.0 or below" : "MySQL"; + String db = client.getContext().isMariaDb() ? "MariaDB 10.5.0 or below" : "MySQL"; throw new IllegalArgumentException(db + " can have only one column"); } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/OptionMapper.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/OptionMapper.java index f75a913f1..afc67a8bb 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/OptionMapper.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/OptionMapper.java @@ -60,14 +60,13 @@ private Source(@Nullable T value) { this.value = value; } - Otherwise to(Consumer consumer) { + boolean to(Consumer consumer) { if (value == null) { - return Otherwise.FALL; + return false; } consumer.accept(value); - - return Otherwise.NOOP; + return true; } Source as(Class type) { @@ -268,27 +267,3 @@ private static O[] mapArray(String[] input, Function mapper, IntF return output; } } - -enum Otherwise { - - NOOP { - @Override - void otherwise(Runnable runnable) { - // Do nothing - } - }, - - FALL { - @Override - void otherwise(Runnable runnable) { - runnable.run(); - } - }; - - /** - * Invoked if the previous {@link Source} outcome did not match. - * - * @param runnable the {@link Runnable} that should be invoked. - */ - abstract void otherwise(Runnable runnable); -} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SingleHostConnectionStrategy.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SingleHostConnectionStrategy.java new file mode 100644 index 000000000..cbbb2d029 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SingleHostConnectionStrategy.java @@ -0,0 +1,52 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +import io.asyncer.r2dbc.mysql.client.Client; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; +import io.netty.channel.ChannelOption; +import reactor.core.publisher.Mono; +import reactor.netty.tcp.TcpClient; + +/** + * An implementation of {@link ConnectionStrategy} that connects to a single host. It can be wrapped to a + * FailoverClient. + */ +final class SingleHostConnectionStrategy implements ConnectionStrategy { + + private final Mono client; + + SingleHostConnectionStrategy(TcpSocketConfiguration socket, MySqlConnectionConfiguration configuration) { + this.client = configuration.getCredential().flatMap(credential -> { + NodeAddress address = socket.getFirstAddress(); + + logger.debug("Connect to a single host: {}", address); + + TcpClient tcpClient = ConnectionStrategy.createTcpClient(configuration.getClient(), true) + .option(ChannelOption.SO_KEEPALIVE, socket.isTcpKeepAlive()) + .option(ChannelOption.TCP_NODELAY, socket.isTcpNoDelay()) + .remoteAddress(address::toUnresolved); + + return ConnectionStrategy.connectWithInit(tcpClient, credential, configuration); + }); + } + + @Override + public Mono connect() { + return client; + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SocketClientConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SocketClientConfiguration.java new file mode 100644 index 000000000..3102e345b --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SocketClientConfiguration.java @@ -0,0 +1,95 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +import org.jetbrains.annotations.Nullable; +import reactor.netty.resources.LoopResources; + +import java.time.Duration; +import java.util.Objects; + +/** + * A general-purpose configuration for a socket client. The client can be a TCP client or a Unix Domain Socket client. + */ +final class SocketClientConfiguration { + + @Nullable + private final Duration connectTimeout; + + @Nullable + private final LoopResources loopResources; + + SocketClientConfiguration(@Nullable Duration connectTimeout, @Nullable LoopResources loopResources) { + this.connectTimeout = connectTimeout; + this.loopResources = loopResources; + } + + @Nullable + Duration getConnectTimeout() { + return connectTimeout; + } + + @Nullable + LoopResources getLoopResources() { + return loopResources; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SocketClientConfiguration)) { + return false; + } + + SocketClientConfiguration that = (SocketClientConfiguration) o; + + return Objects.equals(connectTimeout, that.connectTimeout) && Objects.equals(loopResources, that.loopResources); + } + + @Override + public int hashCode() { + return 31 * Objects.hashCode(connectTimeout) + Objects.hashCode(loopResources); + } + + @Override + public String toString() { + return "Client{connectTimeout=" + connectTimeout + ", loopResources=" + loopResources + '}'; + } + + static final class Builder { + + @Nullable + private Duration connectTimeout; + + @Nullable + private LoopResources loopResources; + + void connectTimeout(@Nullable Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + + void loopResources(@Nullable LoopResources loopResources) { + this.loopResources = loopResources; + } + + SocketClientConfiguration build() { + return new SocketClientConfiguration(connectTimeout, loopResources); + } + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SocketConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SocketConfiguration.java new file mode 100644 index 000000000..de317ddde --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SocketConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +/** + * A sealed interface for socket configuration, it is also a factory for creating {@link ConnectionStrategy}. + * + * @see TcpSocketConfiguration + * @see UnixDomainSocketConfiguration + */ +interface SocketConfiguration { + + ConnectionStrategy strategy(MySqlConnectionConfiguration configuration); +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/TcpSocketConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/TcpSocketConfiguration.java new file mode 100644 index 000000000..f100a687f --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/TcpSocketConfiguration.java @@ -0,0 +1,235 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +import io.asyncer.r2dbc.mysql.constant.HaProtocol; +import io.asyncer.r2dbc.mysql.constant.ProtocolDriver; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; +import io.asyncer.r2dbc.mysql.internal.util.InternalArrays; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.require; +import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonEmpty; + +/** + * A configuration for a TCP/SSL socket. + */ +final class TcpSocketConfiguration implements SocketConfiguration { + + private static final int DEFAULT_PORT = 3306; + + private final ProtocolDriver driver; + + private final HaProtocol protocol; + + private final List addresses; + + private final int retriesAllDown; + + private final boolean tcpKeepAlive; + + private final boolean tcpNoDelay; + + TcpSocketConfiguration( + ProtocolDriver driver, + HaProtocol protocol, + List addresses, + int retriesAllDown, + boolean tcpKeepAlive, + boolean tcpNoDelay + ) { + this.driver = driver; + this.protocol = protocol; + this.addresses = addresses; + this.retriesAllDown = retriesAllDown; + this.tcpKeepAlive = tcpKeepAlive; + this.tcpNoDelay = tcpNoDelay; + } + + ProtocolDriver getDriver() { + return driver; + } + + HaProtocol getProtocol() { + return protocol; + } + + NodeAddress getFirstAddress() { + if (addresses.isEmpty()) { + throw new IllegalStateException("No endpoints configured"); + } + return addresses.get(0); + } + + List getAddresses() { + return addresses; + } + + int getRetriesAllDown() { + return retriesAllDown; + } + + boolean isTcpKeepAlive() { + return tcpKeepAlive; + } + + boolean isTcpNoDelay() { + return tcpNoDelay; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TcpSocketConfiguration)) { + return false; + } + + TcpSocketConfiguration that = (TcpSocketConfiguration) o; + + return tcpKeepAlive == that.tcpKeepAlive && + tcpNoDelay == that.tcpNoDelay && + driver == that.driver && + protocol == that.protocol && + retriesAllDown == that.retriesAllDown && + addresses.equals(that.addresses); + } + + @Override + public int hashCode() { + int result = driver.hashCode(); + + result = 31 * result + protocol.hashCode(); + result = 31 * result + addresses.hashCode(); + result = 31 * result + retriesAllDown; + result = 31 * result + (tcpKeepAlive ? 1 : 0); + + return 31 * result + (tcpNoDelay ? 1 : 0); + } + + @Override + public String toString() { + return "TCP{driver=" + driver + + ", protocol=" + protocol + + ", addresses=" + addresses + + ", retriesAllDown=" + retriesAllDown + + ", tcpKeepAlive=" + tcpKeepAlive + + ", tcpNoDelay=" + tcpNoDelay + + '}'; + } + + static final class Builder { + + private ProtocolDriver driver = ProtocolDriver.MYSQL; + + private HaProtocol protocol = HaProtocol.DEFAULT; + + private final List addresses = new ArrayList<>(); + + private String host = ""; + + private int port = DEFAULT_PORT; + + private boolean tcpKeepAlive = false; + + private boolean tcpNoDelay = true; + + private int retriesAllDown = 10; + + void driver(ProtocolDriver driver) { + this.driver = driver; + } + + void protocol(HaProtocol protocol) { + this.protocol = protocol; + } + + void host(String host) { + this.host = host; + } + + void port(int port) { + this.port = port; + } + + void addHost(String host, int port) { + this.addresses.add(new NodeAddress(host, port)); + } + + void addHost(String host) { + this.addresses.add(new NodeAddress(host)); + } + + void retriesAllDown(int retriesAllDown) { + this.retriesAllDown = retriesAllDown; + } + + void tcpKeepAlive(boolean tcpKeepAlive) { + this.tcpKeepAlive = tcpKeepAlive; + } + + void tcpNoDelay(boolean tcpNoDelay) { + this.tcpNoDelay = tcpNoDelay; + } + + TcpSocketConfiguration build() { + List addresses; + + if (this.addresses.isEmpty()) { + requireNonEmpty(host, "Either single host or multiple hosts must be configured"); + + addresses = Collections.singletonList(new NodeAddress(host, port)); + } else { + require(host.isEmpty(), "Either single host or multiple hosts must be configured"); + + addresses = InternalArrays.asImmutableList(this.addresses.toArray(new NodeAddress[0])); + } + + return new TcpSocketConfiguration( + driver, + protocol, + addresses, + retriesAllDown, + tcpKeepAlive, + tcpNoDelay); + } + } + + @Override + public ConnectionStrategy strategy(MySqlConnectionConfiguration configuration) { + switch (protocol) { + case REPLICATION: + ConnectionStrategy.logger.warn( + "R2DBC Connection cannot be set to read-only, replication protocol will use the first host"); + return new SingleHostConnectionStrategy(this, configuration); + case SEQUENTIAL: + return new MultiHostsConnectionStrategy(this, configuration, false); + case LOAD_BALANCE: + return new MultiHostsConnectionStrategy(this, configuration, true); + default: + if (ProtocolDriver.MYSQL == driver && addresses.size() == 1) { + return new SingleHostConnectionStrategy(this, configuration); + } else { + return new MultiHostsConnectionStrategy(this, configuration, false); + } + } + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/UnixDomainSocketConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/UnixDomainSocketConfiguration.java new file mode 100644 index 000000000..7d71f1d85 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/UnixDomainSocketConfiguration.java @@ -0,0 +1,75 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +/** + * A configuration for a Unix Domain Socket. + */ +final class UnixDomainSocketConfiguration implements SocketConfiguration { + + private final String path; + + UnixDomainSocketConfiguration(String path) { + this.path = path; + } + + String getPath() { + return this.path; + } + + @Override + public ConnectionStrategy strategy(MySqlConnectionConfiguration configuration) { + return new UnixDomainSocketConnectionStrategy(this, configuration); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof UnixDomainSocketConfiguration)) { + return false; + } + + UnixDomainSocketConfiguration that = (UnixDomainSocketConfiguration) o; + + return path.equals(that.path); + } + + @Override + public int hashCode() { + return path.hashCode(); + } + + @Override + public String toString() { + return "UnixDomainSocket{path='" + path + "'}"; + } + + static final class Builder { + + private String path; + + void path(String path) { + this.path = path; + } + + UnixDomainSocketConfiguration build() { + return new UnixDomainSocketConfiguration(path); + } + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/UnixDomainSocketConnectionStrategy.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/UnixDomainSocketConnectionStrategy.java new file mode 100644 index 000000000..f9ced48e3 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/UnixDomainSocketConnectionStrategy.java @@ -0,0 +1,48 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +import io.asyncer.r2dbc.mysql.client.Client; +import io.netty.channel.unix.DomainSocketAddress; +import reactor.core.publisher.Mono; +import reactor.netty.tcp.TcpClient; + +/** + * An implementation of {@link ConnectionStrategy} that connects to a Unix Domain Socket. + */ +final class UnixDomainSocketConnectionStrategy implements ConnectionStrategy { + + private final Mono client; + + UnixDomainSocketConnectionStrategy( + UnixDomainSocketConfiguration socket, + MySqlConnectionConfiguration configuration + ) { + this.client = configuration.getCredential().flatMap(credential -> { + String path = socket.getPath(); + TcpClient tcpClient = ConnectionStrategy.createTcpClient(configuration.getClient(), false) + .remoteAddress(() -> new DomainSocketAddress(path)); + + return ConnectionStrategy.connectWithInit(tcpClient, credential, configuration); + }); + } + + @Override + public Mono connect() { + return client; + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/Client.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/Client.java index d7c3ac28a..6ac6e93a5 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/Client.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/Client.java @@ -21,19 +21,13 @@ import io.asyncer.r2dbc.mysql.message.client.ClientMessage; import io.asyncer.r2dbc.mysql.message.server.ServerMessage; import io.netty.buffer.ByteBufAllocator; -import io.netty.channel.ChannelOption; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; -import org.jetbrains.annotations.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.SynchronousSink; -import reactor.netty.resources.LoopResources; import reactor.netty.tcp.TcpClient; -import java.net.InetSocketAddress; -import java.net.SocketAddress; -import java.time.Duration; import java.util.function.BiConsumer; import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonNull; @@ -117,40 +111,19 @@ public interface Client { void loginSuccess(); /** - * Connects to {@code address} with configurations. Normally, should log-in after connected. + * Connects to a MySQL server using the provided {@link TcpClient} and {@link MySqlSslConfiguration}. * - * @param ssl the SSL configuration - * @param address socket address, may be host address, or Unix Domain Socket address - * @param tcpKeepAlive if enable the {@link ChannelOption#SO_KEEPALIVE} - * @param tcpNoDelay if enable the {@link ChannelOption#TCP_NODELAY} - * @param context the connection context - * @param connectTimeout connect timeout, or {@code null} if it has no timeout - * @param loopResources the loop resources to use + * @param tcpClient the configured TCP client + * @param ssl the SSL configuration + * @param context the connection context * @return A {@link Mono} that will emit a connected {@link Client}. - * @throws IllegalArgumentException if {@code ssl}, {@code address} or {@code context} is {@code null}. - * @throws ArithmeticException if {@code connectTimeout} milliseconds overflow as an int + * @throws IllegalArgumentException if {@code tcpClient}, {@code ssl} or {@code context} is {@code null}. */ - static Mono connect(MySqlSslConfiguration ssl, SocketAddress address, boolean tcpKeepAlive, - boolean tcpNoDelay, ConnectionContext context, @Nullable Duration connectTimeout, - LoopResources loopResources) { + static Mono connect(TcpClient tcpClient, MySqlSslConfiguration ssl, ConnectionContext context) { + requireNonNull(tcpClient, "tcpClient must not be null"); requireNonNull(ssl, "ssl must not be null"); - requireNonNull(address, "address must not be null"); requireNonNull(context, "context must not be null"); - TcpClient tcpClient = TcpClient.newConnection() - .runOn(loopResources); - - if (connectTimeout != null) { - tcpClient = tcpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, - Math.toIntExact(connectTimeout.toMillis())); - } - - if (address instanceof InetSocketAddress) { - tcpClient = tcpClient.option(ChannelOption.SO_KEEPALIVE, tcpKeepAlive); - tcpClient = tcpClient.option(ChannelOption.TCP_NODELAY, tcpNoDelay); - } - - return tcpClient.remoteAddress(() -> address).connect() - .map(conn -> new ReactorNettyClient(conn, ssl, context)); + return tcpClient.connect().map(conn -> new ReactorNettyClient(conn, ssl, context)); } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/HaProtocol.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/HaProtocol.java new file mode 100644 index 000000000..c54cd8923 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/HaProtocol.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql.constant; + +import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonNull; + +/** + * Failover and High-availability protocol. + *

+ * The reconnect behavior is affected by the {@code autoReconnect} option. + */ +public enum HaProtocol { + + /** + * Connecting: I want to connect sequentially until the first available node is found if multiple nodes are + * provided, otherwise connect to the single node. + *

+ * Using: I want to get back to the first node if either {@code secondsBeforeRetryPrimaryHost} or + * {@code queriesBeforeRetryPrimaryHost} is set, and multiple nodes are provided. + *

+ * Reconnect: I want to reconnect in the same order if the current node is not available and + * {@code autoReconnect=true}. + */ + DEFAULT(""), + + /** + * Connecting: I want to connect sequentially until the first available node is found. + *

+ * Using: I want to keep using the current node until it is not available. + *

+ * Reconnect: I want to reconnect in the same order if the current node is not available and + * {@code autoReconnect=true}. + */ + SEQUENTIAL("sequential"), + + /** + * Connecting: I want to connect in random order until the first available node is found. + *

+ * Using: I want to keep using the current node until it is not available. + *

+ * Reconnect: I want to re-randomize the order to reconnect if the current node is not available and + * {@code autoReconnect=true}. + */ + LOAD_BALANCE("loadbalance"), + + /** + * Connecting: I want to use read-write connection for the first node, and read-only connections for other nodes. + *

+ * Using: I want to use the first node for read-write if connection is set to read-write, and other nodes if + * connection is set to read-only. R2DBC can not set a {@link io.r2dbc.spi.Connection Connection} to read-only mode. + * So it will always use the first host. R2DBC does not recommend this mutability. Perhaps in the future, R2DBC will + * support using read-only mode to create a connection instead of modifying an existing connection. + *

+ * Reconnect: I want to reconnect to the current node if the current node is unavailable and + * {@code autoReconnect=true}. + * + * @see Proposal: add Connection.setReadonly(boolean) + */ + REPLICATION("replication"), + ; + + private final String name; + + HaProtocol(String name) { + this.name = name; + } + + public static HaProtocol from(String protocol) { + requireNonNull(protocol, "HA protocol must not be null"); + + for (HaProtocol haProtocol : HaProtocol.values()) { + if (haProtocol.name.equalsIgnoreCase(protocol)) { + return haProtocol; + } + } + + throw new IllegalArgumentException("Unknown HA protocol: " + protocol); + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/ProtocolDriver.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/ProtocolDriver.java new file mode 100644 index 000000000..b8774f382 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/ProtocolDriver.java @@ -0,0 +1,80 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql.constant; + +import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonNull; + +/** + * Enumeration of driver connection schemes. + */ +public enum ProtocolDriver { + + /** + * I want to use failover and high availability protocols for each host I set up. If I set a hostname that resolves + * to multiple IP addresses, the driver should pick one randomly. + *

+ * Recommended in most cases. The hostname is resolved when high availability protocols are applied. + */ + MYSQL, + + /** + * I want to use failover and high availability protocols for each IP address. If I set a hostname that resolves to + * multiple IP addresses, the driver should flatten the list and try to connect to all of IP addresses. + *

+ * The hostname is resolved before high availability protocols are applied. + */ + DNS_SRV; + + /** + * Default protocol driver name. + */ + private static final String STANDARD_NAME = "mysql"; + + /** + * DNS SRV protocol driver name. + */ + private static final String DNS_SRV_NAME = "mysql+srv"; + + public static String standardDriver() { + return STANDARD_NAME; + } + + public static boolean supports(String driverName) { + requireNonNull(driverName, "driverName must not be null"); + + switch (driverName) { + case STANDARD_NAME: + case DNS_SRV_NAME: + return true; + default: + return false; + } + } + + public static ProtocolDriver from(String driverName) { + requireNonNull(driverName, "driverName must not be null"); + + switch (driverName) { + case STANDARD_NAME: + return MYSQL; + case DNS_SRV_NAME: + return DNS_SRV; + default: + throw new IllegalArgumentException("Unknown driver name: " + driverName); + } + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/NodeAddress.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/NodeAddress.java new file mode 100644 index 000000000..cb1afccd8 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/NodeAddress.java @@ -0,0 +1,76 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql.internal; + +import java.net.InetSocketAddress; + +/** + * A value object representing a host and port. It will use the default port {@code 3306} if not specified. + */ +public final class NodeAddress { + + private static final int DEFAULT_PORT = 3306; + + private final String host; + + private final int port; + + public NodeAddress(String host) { + this(host, DEFAULT_PORT); + } + + public NodeAddress(String host, int port) { + this.host = host; + this.port = port; + } + + public String getHost() { + return host; + } + + public int getPort() { + return port; + } + + public InetSocketAddress toUnresolved() { + return InetSocketAddress.createUnresolved(this.host, this.port); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof NodeAddress)) { + return false; + } + + NodeAddress that = (NodeAddress) o; + + return port == that.port && host.equals(that.host); + } + + @Override + public int hashCode() { + return 31 * host.hashCode() + port; + } + + @Override + public String toString() { + return host + ":" + port; + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/AddressUtils.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/AddressUtils.java index 82e41d522..422faf725 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/AddressUtils.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/AddressUtils.java @@ -16,10 +16,13 @@ package io.asyncer.r2dbc.mysql.internal.util; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; + +import java.net.InetSocketAddress; import java.util.regex.Pattern; /** - * A utility for matching host/address. + * A utility for processing host/address. */ public final class AddressUtils { @@ -31,32 +34,104 @@ public final class AddressUtils { private static final Pattern IPV6_PATTERN = Pattern.compile("^[0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){7}$"); private static final Pattern IPV6_COMPRESSED_PATTERN = Pattern.compile( - "^(([0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){0,5})?)::(([0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){0,5})?)$"); + "^((([0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){0,5})?)::(([0-9a-fA-F]{1,4}(:[0-9a-fA-F]{1,4}){0,5})?))$"); private static final int IPV6_COLONS = 7; /** * Checks if the host is an address of IP version 4. * - * @param host the host should be check. + * @param host the host should be checked. * @return if is IPv4. */ public static boolean isIpv4(String host) { - // TODO: Use faster matches instead of regex. + // Maybe use faster matches instead of regex? return IPV4_PATTERN.matcher(host).matches(); } /** * Checks if the host is an address of IP version 6. * - * @param host the host should be check. + * @param host the host should be checked. * @return if is IPv6. */ public static boolean isIpv6(String host) { - // TODO: Use faster matches instead of regex. + // Maybe use faster matches instead of regex? return IPV6_PATTERN.matcher(host).matches() || isIpv6Compressed(host); } + /** + * Parses a host to an {@link NodeAddress}, the {@code host} may contain port or not. If the {@code host} does + * not contain a valid port, the default port {@code 3306} will be used. The {@code host} can be an IPv6, IPv4 or + * host address. e.g. [::1]:3301, [::1], 127.0.0.1, host-name:3302 + *

+ * Note: It will not check if the host is a valid address. e.g. IPv6 address should be enclosed in square brackets, + * hostname should not contain an underscore, etc. + * + * @param host the {@code host} should be parsed as socket address. + * @return the parsed and unresolved {@link InetSocketAddress} + */ + public static NodeAddress parseAddress(String host) { + int len = host.length(); + int index; + + for (index = len - 1; index > 0; --index) { + char ch = host.charAt(index); + + if (ch == ':') { + break; + } else if (ch < '0' || ch > '9') { + return new NodeAddress(host); + } + } + + if (index == 0) { + // index == 0, no host before number whatever host[0] is a colon or not, may be a hostname "a1234" + return new NodeAddress(host); + } + + int colonLen = len - index; + + if (colonLen < 2 || colonLen > 6) { + // 1. no port after colon, not a port, may be an IPv6 address like "::" + // 2. length of port > 5, max port is 65535, invalid port + return new NodeAddress(host); + } + + if (host.charAt(index - 1) == ']' && host.charAt(0) == '[') { + // Seems like an IPv6 with port + if (index <= 2) { + // Host/Address must not be empty + return new NodeAddress(host); + } + + int port = parsePort(host, index + 1, len); + + if (port > 0xFFFF) { + return new NodeAddress(host); + } + + return new NodeAddress(host.substring(0, index), port); + } + + int colonIndex = index; + + // IPv4 or host should not contain a colon, IPv6 should be enclosed in square brackets + for (--index; index >= 0; --index) { + if (host.charAt(index) == ':') { + return new NodeAddress(host); + } + } + + int port = parsePort(host, colonIndex + 1, len); + + if (port > 0xFFFF) { + return new NodeAddress(host); + } + + return new NodeAddress(host.substring(0, colonIndex), port); + } + private static boolean isIpv6Compressed(String host) { int length = host.length(); int colons = 0; @@ -67,9 +142,20 @@ private static boolean isIpv6Compressed(String host) { } } - // TODO: Use faster matches instead of regex. + // Maybe use faster matches instead of regex? return colons <= IPV6_COLONS && IPV6_COMPRESSED_PATTERN.matcher(host).matches(); } - private AddressUtils() { } + private static int parsePort(String input, int start, int end) { + int r = 0; + + for (int i = start; i < end; ++i) { + r = r * 10 + (input.charAt(i) - '0'); + } + + return r; + } + + private AddressUtils() { + } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/InternalArrays.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/InternalArrays.java index 7b73186d0..d78009116 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/InternalArrays.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/internal/util/InternalArrays.java @@ -252,7 +252,7 @@ public T[] toArray(T[] a) { return (T[]) Arrays.copyOf(source, source.length, a.getClass()); } - System.arraycopy(source, 0, a, 0, this.a.length); + System.arraycopy(source, 0, a, 0, source.length); if (a.length > source.length) { a[source.length] = null; diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/HaProtocolIntegrationTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/HaProtocolIntegrationTest.java new file mode 100644 index 000000000..74e03c826 --- /dev/null +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/HaProtocolIntegrationTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +import io.asyncer.r2dbc.mysql.constant.HaProtocol; +import io.asyncer.r2dbc.mysql.internal.util.TestContainerExtension; +import io.asyncer.r2dbc.mysql.internal.util.TestServerUtil; +import io.r2dbc.spi.ValidationDepth; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link HaProtocol}. + */ +@ExtendWith(TestContainerExtension.class) +class HaProtocolIntegrationTest { + + @ParameterizedTest + @ValueSource(strings = { "sequential", "loadbalance" }) + void anyAvailable(String protocol) { + MySqlConnectionFactory.from(configuration(HaProtocol.from(protocol), true)).create() + .flatMapMany(connection -> connection.validate(ValidationDepth.REMOTE) + .onErrorReturn(false) + .concatWith(connection.close().then(Mono.empty()))) + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + } + + @ParameterizedTest + @ValueSource(strings = { "replication", "" }) + void firstAvailable(String protocol) { + MySqlConnectionFactory.from(configuration(HaProtocol.from(protocol), false)).create() + .flatMapMany(connection -> connection.validate(ValidationDepth.REMOTE) + .onErrorReturn(false) + .concatWith(connection.close().then(Mono.empty()))) + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + } + + private MySqlConnectionConfiguration configuration(HaProtocol protocol, boolean badFirst) { + MySqlConnectionConfiguration.Builder builder = MySqlConnectionConfiguration.builder() + .protocol(protocol) + .connectTimeout(Duration.ofSeconds(3)) + .user(TestServerUtil.getUsername()) + .password(TestServerUtil.getPassword()) + .database(TestServerUtil.getDatabase()); + + if (badFirst) { + builder.addHost(TestServerUtil.getHost(), 3310).addHost(TestServerUtil.getHost(), TestServerUtil.getPort()); + } else { + builder.addHost(TestServerUtil.getHost(), TestServerUtil.getPort()).addHost(TestServerUtil.getHost(), 3310); + } + + return builder.build(); + } +} diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfigurationTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfigurationTest.java index f050f4e4a..a2e9dc82f 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfigurationTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfigurationTest.java @@ -17,23 +17,29 @@ package io.asyncer.r2dbc.mysql; import io.asyncer.r2dbc.mysql.constant.CompressionAlgorithm; +import io.asyncer.r2dbc.mysql.constant.HaProtocol; +import io.asyncer.r2dbc.mysql.constant.ProtocolDriver; import io.asyncer.r2dbc.mysql.constant.SslMode; import io.asyncer.r2dbc.mysql.constant.TlsVersions; import io.asyncer.r2dbc.mysql.constant.ZeroDateOption; import io.asyncer.r2dbc.mysql.extension.Extension; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; import io.netty.handler.ssl.SslContextBuilder; -import org.assertj.core.api.ObjectAssert; +import io.r2dbc.spi.ConnectionFactoryOptions; import org.assertj.core.api.ThrowableTypeAssert; import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import java.time.Duration; -import java.time.ZoneId; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; -import java.util.Objects; +import java.util.Optional; import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; @@ -67,24 +73,20 @@ void invalid() { @Test void unixSocket() { for (SslMode mode : SslMode.values()) { - if (mode.startSsl()) { - assertThatIllegalArgumentException().isThrownBy(() -> unixSocketSslMode(mode)) - .withMessageContaining("sslMode"); - } else { - assertThat(unixSocketSslMode(SslMode.DISABLED)).isNotNull(); - } + assertThat(unixSocketSslMode(mode)).isNotNull(); } MySqlConnectionConfiguration configuration = MySqlConnectionConfiguration.builder() .unixSocket(UNIX_SOCKET) .user(USER) .build(); - ObjectAssert asserted = assertThat(configuration); - asserted.extracting(MySqlConnectionConfiguration::getDomain).isEqualTo(UNIX_SOCKET); - asserted.extracting(MySqlConnectionConfiguration::getUser).isEqualTo(USER); - asserted.extracting(MySqlConnectionConfiguration::isHost).isEqualTo(false); - asserted.extracting(MySqlConnectionConfiguration::getSsl) - .extracting(MySqlSslConfiguration::getSslMode).isEqualTo(SslMode.DISABLED); + + assertThat(((UnixDomainSocketConfiguration) configuration.getSocket()).getPath()).isEqualTo(UNIX_SOCKET); + assertThat(configuration.getSsl().getSslMode()).isEqualTo(SslMode.DISABLED); + configuration.getCredential() + .as(StepVerifier::create) + .expectNext(new Credential(USER, null)) + .verifyComplete(); } @Test @@ -93,12 +95,12 @@ void hosted() { .host(HOST) .user(USER) .build(); - ObjectAssert asserted = assertThat(configuration); - asserted.extracting(MySqlConnectionConfiguration::getDomain).isEqualTo(HOST); - asserted.extracting(MySqlConnectionConfiguration::getUser).isEqualTo(USER); - asserted.extracting(MySqlConnectionConfiguration::isHost).isEqualTo(true); - asserted.extracting(MySqlConnectionConfiguration::getSsl) - .extracting(MySqlSslConfiguration::getSslMode).isEqualTo(SslMode.PREFERRED); + assertThat(((TcpSocketConfiguration) configuration.getSocket()).getAddresses()) + .isEqualTo(Collections.singletonList(new NodeAddress(HOST))); + assertThat(configuration.getSsl().getSslMode()).isEqualTo(SslMode.PREFERRED); + configuration.getCredential().as(StepVerifier::create) + .expectNext(new Credential(USER, null)) + .verifyComplete(); } @Test @@ -106,18 +108,20 @@ void allSslModeHosted() { String sslCa = "/path/to/ca.pem"; for (SslMode mode : SslMode.values()) { - ObjectAssert asserted = assertThat(hostedSslMode(mode, sslCa)); + MySqlConnectionConfiguration configuration = hostedSslMode(mode, sslCa); - asserted.extracting(MySqlConnectionConfiguration::getDomain).isEqualTo(HOST); - asserted.extracting(MySqlConnectionConfiguration::getUser).isEqualTo(USER); - asserted.extracting(MySqlConnectionConfiguration::isHost).isEqualTo(true); - asserted.extracting(MySqlConnectionConfiguration::getSsl) - .extracting(MySqlSslConfiguration::getSslMode).isEqualTo(mode); + assertThat(configuration.getSsl().getSslMode()).isEqualTo(mode); + assertThat(((TcpSocketConfiguration) configuration.getSocket()).getAddresses()) + .isEqualTo(Collections.singletonList(new NodeAddress(HOST))); if (mode.startSsl()) { - asserted.extracting(MySqlConnectionConfiguration::getSsl) - .extracting(MySqlSslConfiguration::getSslCa).isSameAs(sslCa); + assertThat(configuration.getSsl().getSslCa()).isSameAs(sslCa); } + + configuration.getCredential() + .as(StepVerifier::create) + .expectNext(new Credential(USER, null)) + .verifyComplete(); } } @@ -131,13 +135,7 @@ void invalidPort() { @Test void allFillUp() { - assertThat(filledUp()).extracting(MySqlConnectionConfiguration::getSsl).isNotNull(); - } - - @Test - void isEquals() { - assertThat(filledUp()).isEqualTo(filledUp()).extracting(Objects::hashCode) - .isEqualTo(filledUp().hashCode()); + assertThat(filledUp().getSsl()).isNotNull(); } @Test @@ -194,19 +192,63 @@ void nonAutodetectExtensions() { @Test void validPasswordSupplier() { - final Mono passwordSupplier = Mono.just("123456"); + Mono passwordSupplier = Mono.just("123456"); + Mono.from(MySqlConnectionConfiguration.builder() .host(HOST) .user(USER) .passwordPublisher(passwordSupplier) .autodetectExtensions(false) .build() - .getPasswordPublisher()) + .getCredential()) .as(StepVerifier::create) - .expectNext("123456") + .expectNext(new Credential(USER, "123456")) .verifyComplete(); } + @ParameterizedTest + @ValueSource(strings = { + "r2dbc:mysql://my-db1:3309,my-db2:3310/r2dbc", + "r2dbcs:mysql://my-db1:3309,my-db2:3310/r2dbc", + "r2dbc:mysql+srv://my-db1:3309,my-db2:3310/r2dbc", + "r2dbcs:mysql+srv://my-db1:3309,my-db2:3310/r2dbc", + "r2dbc:mysql:replication://my-db1:3309,my-db2:3310/r2dbc", + "r2dbcs:mysql:replication://my-db1:3309,my-db2:3310/r2dbc", + "r2dbc:mysql+srv:replication://my-db1:3309,my-db2:3310/r2dbc", + "r2dbcs:mysql+srv:replication://my-db1:3309,my-db2:3310/r2dbc", + "r2dbc:mysql:loadbalance://my-db1:3309,my-db2:3310/r2dbc", + "r2dbcs:mysql:loadbalance://my-db1:3309,my-db2:3310/r2dbc", + "r2dbc:mysql+srv:loadbalance://my-db1:3309,my-db2:3310/r2dbc", + "r2dbcs:mysql+srv:loadbalance://my-db1:3309,my-db2:3310/r2dbc", + "r2dbc:mysql:sequential://my-db1:3309,my-db2:3310/r2dbc", + "r2dbcs:mysql:sequential://my-db1:3309,my-db2:3310/r2dbc", + "r2dbc:mysql+srv:sequential://my-db1:3309,my-db2:3310/r2dbc", + "r2dbcs:mysql+srv:sequential://my-db1:3309,my-db2:3310/r2dbc", + }) + void multipleHosts(String url) { + ConnectionFactoryOptions options = ConnectionFactoryOptions.parse(url) + .mutate() + .option(ConnectionFactoryOptions.USER, "root") + .build(); + MySqlConnectionConfiguration configuration = MySqlConnectionFactoryProvider.setup(options); + + assertThat(configuration.getSocket()).isInstanceOf(TcpSocketConfiguration.class); + + TcpSocketConfiguration tcp = (TcpSocketConfiguration) configuration.getSocket(); + + assertThat(tcp.getAddresses()).isEqualTo(Arrays.asList( + new NodeAddress("my-db1", 3309), + new NodeAddress("my-db2", 3310) + )); + assertThat(tcp.getDriver()).isEqualTo( + ProtocolDriver.from(options.getRequiredValue(ConnectionFactoryOptions.DRIVER).toString())); + assertThat(tcp.getProtocol()).isEqualTo( + HaProtocol.from(Optional.ofNullable(options.getValue(ConnectionFactoryOptions.PROTOCOL)) + .map(Object::toString) + .orElse("")) + ); + } + private static MySqlConnectionConfiguration unixSocketSslMode(SslMode sslMode) { return MySqlConnectionConfiguration.builder() .unixSocket(UNIX_SOCKET) @@ -225,6 +267,7 @@ private static MySqlConnectionConfiguration hostedSslMode(SslMode sslMode, @Null } private static MySqlConnectionConfiguration filledUp() { + // Since 1.0.5, the passwordPublisher is Mono, equals() and hashCode() are not reliable. return MySqlConnectionConfiguration.builder() .host(HOST) .user(USER) diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java index ab75161c1..2b65768f2 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProviderTest.java @@ -17,8 +17,10 @@ package io.asyncer.r2dbc.mysql; import io.asyncer.r2dbc.mysql.constant.CompressionAlgorithm; +import io.asyncer.r2dbc.mysql.constant.HaProtocol; import io.asyncer.r2dbc.mysql.constant.SslMode; import io.asyncer.r2dbc.mysql.constant.ZeroDateOption; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; import io.netty.handler.ssl.SslContextBuilder; import io.r2dbc.spi.ConnectionFactories; import io.r2dbc.spi.ConnectionFactoryOptions; @@ -31,6 +33,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLSession; @@ -39,7 +42,6 @@ import java.lang.reflect.Modifier; import java.net.URLEncoder; import java.time.Duration; -import java.time.ZoneId; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -57,6 +59,7 @@ import static io.r2dbc.spi.ConnectionFactoryOptions.HOST; import static io.r2dbc.spi.ConnectionFactoryOptions.PASSWORD; import static io.r2dbc.spi.ConnectionFactoryOptions.PORT; +import static io.r2dbc.spi.ConnectionFactoryOptions.PROTOCOL; import static io.r2dbc.spi.ConnectionFactoryOptions.SSL; import static io.r2dbc.spi.ConnectionFactoryOptions.USER; import static org.assertj.core.api.Assertions.assertThat; @@ -94,6 +97,25 @@ void validUrl() throws UnsupportedEncodingException { "sslKeyPassword=ssl123456")).isExactlyInstanceOf(MySqlConnectionFactory.class); } + @ParameterizedTest + @ValueSource(strings = { + "r2dbc:mysql://localhost:3306", + "r2dbcs:mysql://root@localhost:3306", + "r2dbc:mysql://root@localhost:3306?unixSocket=/path/to/mysql.sock", + "r2dbcs:mysql://mysql-region-1.some-cloud.com,mysql-region-2.some-cloud.com:3307", + "r2dbc:mysql:loadbalance://mysql-region-1.some-cloud.com,mysql-region-2.some-cloud.com:3307", + "r2dbc:mysql:sequential://mysql-region-1.some-cloud.com:3306,mysql-region-2.some-cloud.com:3307", + "r2dbcs:mysql:replication://mysql-region-1.some-cloud.com:3305,mysql-region-2.some-cloud.com:3307", + "r2dbc:mysql+srv:loadbalance://mysql-region-1.some-cloud.com,mysql-region-2.some-cloud.com:3307", + "r2dbc:mysql+srv:sequential://mysql-region-1.some-cloud.com:3306,mysql-region-2.some-cloud.com:3307", + "r2dbcs:mysql+srv:replication://mysql-region-1.some-cloud.com:3305,mysql-region-2.some-cloud.com:3307", + }) + void supports(String url) { + MySqlConnectionFactoryProvider provider = new MySqlConnectionFactoryProvider(); + + assertThat(provider.supports(ConnectionFactoryOptions.parse(url))).isTrue(); + } + @Test void urlSslModeInUnixSocket() throws UnsupportedEncodingException { Assert that = assertThat(SslMode.DISABLED); @@ -135,6 +157,7 @@ void validProgrammaticHost() { options = ConnectionFactoryOptions.builder() .option(DRIVER, "mysql") + .option(PROTOCOL, "replication") .option(HOST, "127.0.0.1") .option(PORT, 3307) .option(USER, "root") @@ -161,16 +184,25 @@ void validProgrammaticHost() { MySqlConnectionConfiguration configuration = MySqlConnectionFactoryProvider.setup(options); - assertThat(configuration.getDomain()).isEqualTo("127.0.0.1"); - assertThat(configuration.isHost()).isTrue(); - assertThat(configuration.getPort()).isEqualTo(3307); - assertThat(configuration.getUser()).isEqualTo("root"); - assertThat(configuration.getPassword()).isEqualTo("123456"); - assertThat(configuration.getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(configuration.getSocket()).isInstanceOf(TcpSocketConfiguration.class); + + TcpSocketConfiguration tcp = (TcpSocketConfiguration) configuration.getSocket(); + + assertThat(tcp.getProtocol()) + .isEqualTo(HaProtocol.REPLICATION); + assertThat(tcp.getAddresses()) + .isEqualTo(Collections.singletonList(new NodeAddress("127.0.0.1", 3307))); + assertThat(tcp.isTcpKeepAlive()).isTrue(); + assertThat(tcp.isTcpNoDelay()).isTrue(); + + configuration.getCredential() + .as(StepVerifier::create) + .expectNext(new Credential("root", "123456")) + .verifyComplete(); + + assertThat(configuration.getClient().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); assertThat(configuration.getDatabase()).isEqualTo("r2dbc"); assertThat(configuration.getZeroDateOption()).isEqualTo(ZeroDateOption.USE_ROUND); - assertThat(configuration.isTcpKeepAlive()).isTrue(); - assertThat(configuration.isTcpNoDelay()).isTrue(); assertThat(configuration.getConnectionTimeZone()).isEqualTo("Asia/Tokyo"); assertThat(configuration.getPreferPrepareStatement()).isExactlyInstanceOf(AllTruePredicate.class); assertThat(configuration.getExtensions()).isEqualTo(Extensions.from(Collections.emptyList(), true)); @@ -248,9 +280,7 @@ void invalidProgrammatic() { @Test void validProgrammaticUnixSocket() { - Assert domain = assertThat("/path/to/mysql.sock"); - Assert isHost = assertThat(false); - Assert sslMode = assertThat(SslMode.DISABLED); + Assert path = assertThat("/path/to/mysql.sock"); ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() .option(DRIVER, "mysql") @@ -260,9 +290,11 @@ void validProgrammaticUnixSocket() { .build(); MySqlConnectionConfiguration configuration = MySqlConnectionFactoryProvider.setup(options); - domain.isEqualTo(configuration.getDomain()); - isHost.isEqualTo(configuration.isHost()); - sslMode.isEqualTo(configuration.getSsl().getSslMode()); + assertThat(configuration.getSocket()).isInstanceOf(UnixDomainSocketConfiguration.class); + + UnixDomainSocketConfiguration unix = (UnixDomainSocketConfiguration) configuration.getSocket(); + + path.isEqualTo(unix.getPath()); for (SslMode mode : SslMode.values()) { configuration = MySqlConnectionFactoryProvider.setup(ConnectionFactoryOptions.builder() @@ -272,9 +304,11 @@ void validProgrammaticUnixSocket() { .option(Option.valueOf("sslMode"), mode.name().toLowerCase()) .build()); - domain.isEqualTo(configuration.getDomain()); - isHost.isEqualTo(configuration.isHost()); - sslMode.isEqualTo(configuration.getSsl().getSslMode()); + assertThat(configuration.getSocket()).isInstanceOf(UnixDomainSocketConfiguration.class); + + unix = (UnixDomainSocketConfiguration) configuration.getSocket(); + + path.isEqualTo(unix.getPath()); } configuration = MySqlConnectionFactoryProvider.setup(ConnectionFactoryOptions.builder() @@ -303,31 +337,24 @@ void validProgrammaticUnixSocket() { .option(Option.valueOf("tcpNoDelay"), "true") .build()); - assertThat(configuration.getDomain()).isEqualTo("/path/to/mysql.sock"); - assertThat(configuration.isHost()).isFalse(); - assertThat(configuration.getPort()).isEqualTo(3306); - assertThat(configuration.getUser()).isEqualTo("root"); - assertThat(configuration.getPassword()).isEqualTo("123456"); - assertThat(configuration.getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(configuration.getSocket()).isInstanceOf(UnixDomainSocketConfiguration.class); + + unix = (UnixDomainSocketConfiguration) configuration.getSocket(); + + assertThat(unix.getPath()).isEqualTo("/path/to/mysql.sock"); + + configuration.getCredential() + .as(StepVerifier::create) + .expectNext(new Credential("root", "123456")) + .verifyComplete(); + + assertThat(configuration.getClient().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); assertThat(configuration.getDatabase()).isEqualTo("r2dbc"); assertThat(configuration.isCreateDatabaseIfNotExist()).isTrue(); assertThat(configuration.getZeroDateOption()).isEqualTo(ZeroDateOption.USE_ROUND); - assertThat(configuration.isTcpKeepAlive()).isTrue(); - assertThat(configuration.isTcpNoDelay()).isTrue(); assertThat(configuration.getConnectionTimeZone()).isEqualTo("Asia/Tokyo"); assertThat(configuration.getPreferPrepareStatement()).isExactlyInstanceOf(AllTruePredicate.class); assertThat(configuration.getExtensions()).isEqualTo(Extensions.from(Collections.emptyList(), true)); - - assertThat(configuration.getSsl().getSslMode()).isEqualTo(SslMode.DISABLED); - assertThat(configuration.getSsl().getTlsVersion()).isEmpty(); - assertThat(configuration.getSsl().getSslCa()).isNull(); - assertThat(configuration.getSsl().getSslKey()).isNull(); - assertThat(configuration.getSsl().getSslCert()).isNull(); - assertThat(configuration.getSsl().getSslKeyPassword()).isNull(); - assertThat(configuration.getSsl().getSslHostnameVerifier()).isNull(); - SslContextBuilder sslContextBuilder = SslContextBuilder.forClient(); - assertThat(sslContextBuilder) - .isSameAs(configuration.getSsl().customizeSslContext(sslContextBuilder)); } @Test @@ -458,11 +485,10 @@ void allConfigurationOptions() { List exceptConfigs = Arrays.asList( "extendWith", "username", + "addHost", "zeroDateOption"); List exceptOptions = Arrays.asList( - "driver", "ssl", - "protocol", "zeroDate"); Set allOptions = Stream.concat( Arrays.stream(ConnectionFactoryOptions.class.getFields()), diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlSimpleConnectionTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlSimpleConnectionTest.java index b2847c20d..5839a7f43 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlSimpleConnectionTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlSimpleConnectionTest.java @@ -37,8 +37,6 @@ import reactor.test.StepVerifier; import java.time.Duration; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import static org.assertj.core.api.Assertions.assertThat; diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlTestKitSupport.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlTestKitSupport.java index e1760cef6..fde0802b3 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlTestKitSupport.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/MySqlTestKitSupport.java @@ -18,6 +18,7 @@ import com.zaxxer.hikari.HikariDataSource; import io.asyncer.r2dbc.mysql.internal.util.TestContainerExtension; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; import io.r2dbc.spi.test.TestKit; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.jdbc.core.JdbcTemplate; @@ -78,15 +79,19 @@ public String clobType() { } private static JdbcTemplate jdbc(MySqlConnectionConfiguration configuration) { + TcpSocketConfiguration socket = (TcpSocketConfiguration) configuration.getSocket(); + NodeAddress address = socket.getFirstAddress(); + Credential credential = configuration.getCredential().blockOptional().orElseThrow(() -> + new IllegalStateException("Credential must be present")); HikariDataSource source = new HikariDataSource(); - source.setJdbcUrl(String.format("jdbc:mysql://%s:%d/%s", configuration.getDomain(), - configuration.getPort(), configuration.getDatabase())); - source.setUsername(configuration.getUser()); - source.setPassword(Optional.ofNullable(configuration.getPassword()) + source.setJdbcUrl(String.format("jdbc:mysql://%s:%d/%s", + address.getHost(), address.getPort(), configuration.getDatabase())); + source.setUsername(credential.getUser()); + source.setPassword(Optional.ofNullable(credential.getPassword()) .map(Object::toString).orElse(null)); source.setMaximumPoolSize(1); - source.setConnectionTimeout(Optional.ofNullable(configuration.getConnectTimeout()) + source.setConnectionTimeout(Optional.ofNullable(configuration.getClient().getConnectTimeout()) .map(Duration::toMillis).orElse(0L)); source.addDataSourceProperty("preserveInstants", configuration.isPreserveInstants()); diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/OptionMapperTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/OptionMapperTest.java index 0952c95a5..e4786bb6a 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/OptionMapperTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/OptionMapperTest.java @@ -92,12 +92,15 @@ void otherwiseNoop() { AtomicReference ref = new AtomicReference<>(fill); AtomicReference other = new AtomicReference<>(fill); - new OptionMapper(ConnectionFactoryOptions.builder() + boolean set = new OptionMapper(ConnectionFactoryOptions.builder() .option(USER, "no-root") .build()) .requires(USER) - .to(ref::set) - .otherwise(() -> other.set(8)); + .to(ref::set); + + if (!set) { + other.set(8); + } assertThat(ref.get()).isEqualTo("no-root"); assertThat(other.get()).isSameAs(fill); @@ -109,11 +112,14 @@ void otherwiseFall() { AtomicReference ref = new AtomicReference<>(fill); AtomicReference other = new AtomicReference<>(fill); - new OptionMapper(ConnectionFactoryOptions.builder() + boolean set = new OptionMapper(ConnectionFactoryOptions.builder() .build()) .optional(USER) - .to(ref::set) - .otherwise(() -> other.set(8)); + .to(ref::set); + + if (!set) { + other.set(8); + } assertThat(ref.get()).isSameAs(fill); assertThat(other.get()).isEqualTo(8); diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/ProtocolDriverIntegrationTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/ProtocolDriverIntegrationTest.java new file mode 100644 index 000000000..6375ae6db --- /dev/null +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/ProtocolDriverIntegrationTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql; + +import io.asyncer.r2dbc.mysql.internal.util.TestContainerExtension; +import io.asyncer.r2dbc.mysql.internal.util.TestServerUtil; +import io.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.ValidationDepth; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; + +/** + * Integration tests for DNS SRV records. + */ +@ExtendWith(TestContainerExtension.class) +class ProtocolDriverIntegrationTest { + + @Test + void anyAvailable() { + // Force to use localhost for DNS SRV testing + ConnectionFactoryOptions options = ConnectionFactoryOptions.builder() + .option(ConnectionFactoryOptions.DRIVER, "mysql+srv") + .option(ConnectionFactoryOptions.PROTOCOL, "loadbalance") + .option(ConnectionFactoryOptions.HOST, "localhost") + .option(ConnectionFactoryOptions.PORT, TestServerUtil.getPort()) + .option(ConnectionFactoryOptions.USER, TestServerUtil.getUsername()) + .option(ConnectionFactoryOptions.PASSWORD, TestServerUtil.getPassword()) + .option(ConnectionFactoryOptions.CONNECT_TIMEOUT, Duration.ofSeconds(3)) + .build(); + // localhost should be resolved to 127.0.0.1 and [::1], but I can't make sure GitHub Actions support IPv6 + MySqlConnectionFactory.from(MySqlConnectionFactoryProvider.setup(options)) + .create() + .flatMapMany(connection -> connection.validate(ValidationDepth.REMOTE) + .onErrorReturn(false) + .concatWith(connection.close().then(Mono.empty()))) + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + } +} diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTest.java index bf4a0e1f0..2a178c59a 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/TimeZoneIntegrationTest.java @@ -4,6 +4,7 @@ import io.asyncer.r2dbc.mysql.api.MySqlResult; import io.asyncer.r2dbc.mysql.internal.util.TestContainerExtension; import io.asyncer.r2dbc.mysql.internal.util.TestServerUtil; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; import org.assertj.core.data.TemporalUnitOffset; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; @@ -306,22 +307,26 @@ private static MySqlConnectionConfiguration configuration( return customizer.apply(builder).build(); } - private static JdbcTemplate jdbc(MySqlConnectionConfiguration config) { + private static JdbcTemplate jdbc(MySqlConnectionConfiguration configuration) { + TcpSocketConfiguration socket = (TcpSocketConfiguration) configuration.getSocket(); + NodeAddress address = socket.getFirstAddress(); + Credential credential = configuration.getCredential().blockOptional().orElseThrow(() -> + new IllegalStateException("Credential must be present")); HikariDataSource source = new HikariDataSource(); - source.setJdbcUrl(String.format("jdbc:mysql://%s:%d/%s", config.getDomain(), - config.getPort(), config.getDatabase())); - source.setUsername(config.getUser()); - source.setPassword(Optional.ofNullable(config.getPassword()) + source.setJdbcUrl(String.format("jdbc:mysql://%s:%d/%s", + address.getHost(), address.getPort(), configuration.getDatabase())); + source.setUsername(credential.getUser()); + source.setPassword(Optional.ofNullable(credential.getPassword()) .map(Object::toString).orElse(null)); source.setMaximumPoolSize(1); - source.setConnectionTimeout(Optional.ofNullable(config.getConnectTimeout()) + source.setConnectionTimeout(Optional.ofNullable(configuration.getClient().getConnectTimeout()) .map(Duration::toMillis).orElse(0L)); - source.addDataSourceProperty("preserveInstants", config.isPreserveInstants()); - source.addDataSourceProperty("connectionTimeZone", config.getConnectionTimeZone()); + source.addDataSourceProperty("preserveInstants", configuration.isPreserveInstants()); + source.addDataSourceProperty("connectionTimeZone", configuration.getConnectionTimeZone()); source.addDataSourceProperty("forceConnectionTimeZoneToSession", - config.isForceConnectionTimeZoneToSession()); + configuration.isForceConnectionTimeZoneToSession()); return new JdbcTemplate(source); } diff --git a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/internal/util/AddressUtilsTest.java b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/internal/util/AddressUtilsTest.java index da22bcc14..e9b1d4f64 100644 --- a/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/internal/util/AddressUtilsTest.java +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/internal/util/AddressUtilsTest.java @@ -16,76 +16,180 @@ package io.asyncer.r2dbc.mysql.internal.util; -import org.junit.jupiter.api.Test; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; /** * Unit tests for {@link AddressUtils}. */ class AddressUtilsTest { - @Test - void isIpv4() { - assertTrue(AddressUtils.isIpv4("1.0.0.0")); - assertTrue(AddressUtils.isIpv4("127.0.0.1")); - assertTrue(AddressUtils.isIpv4("10.11.12.13")); - assertTrue(AddressUtils.isIpv4("192.168.0.0")); - assertTrue(AddressUtils.isIpv4("255.255.255.255")); + @ParameterizedTest + @ValueSource(strings = { + "1.0.0.0", + "127.0.0.1", + "10.11.12.13", + "192.168.0.0", + "255.255.255.255", + }) + void isIpv4(String address) { + assertThat(AddressUtils.isIpv4(address)).isTrue(); + } - assertFalse(AddressUtils.isIpv4("0.0.0.0")); - assertFalse(AddressUtils.isIpv4(" 127.0.0.1 ")); - assertFalse(AddressUtils.isIpv4("01.11.12.13")); - assertFalse(AddressUtils.isIpv4("092.168.0.1")); - assertFalse(AddressUtils.isIpv4("055.255.255.255")); - assertFalse(AddressUtils.isIpv4("g.ar.ba.ge")); - assertFalse(AddressUtils.isIpv4("192.168.0")); - assertFalse(AddressUtils.isIpv4("192.168.0a.0")); - assertFalse(AddressUtils.isIpv4("256.255.255.255")); - assertFalse(AddressUtils.isIpv4("0.255.255.255")); + @ParameterizedTest + @ValueSource(strings = { + "0.0.0.0", + " 127.0.0.1 ", + "01.11.12.13", + "092.168.0.1", + "055.255.255.255", + "g.ar.ba.ge", + "192.168.0", + "192.168.0a.0", + "256.255.255.255", + "0.255.255.255", + "::", + "::1", + "0:0:0:0:0:0:0:0", + "0:0:0:0:0:0:0:1", + "2001:0acd:0000:0000:0000:0000:3939:21fe", + "2001:acd:0:0:0:0:3939:21fe", + "2001:0acd:0:0::3939:21fe", + "2001:0acd::3939:21fe", + "2001:acd::3939:21fe", + }) + void isNotIpv4(String address) { + assertThat(AddressUtils.isIpv4(address)).isFalse(); + } - assertFalse(AddressUtils.isIpv4("::")); - assertFalse(AddressUtils.isIpv4("::1")); - assertFalse(AddressUtils.isIpv4("0:0:0:0:0:0:0:0")); - assertFalse(AddressUtils.isIpv4("0:0:0:0:0:0:0:1")); - assertFalse(AddressUtils.isIpv4("2001:0acd:0000:0000:0000:0000:3939:21fe")); - assertFalse(AddressUtils.isIpv4("2001:acd:0:0:0:0:3939:21fe")); - assertFalse(AddressUtils.isIpv4("2001:0acd:0:0::3939:21fe")); - assertFalse(AddressUtils.isIpv4("2001:0acd::3939:21fe")); - assertFalse(AddressUtils.isIpv4("2001:acd::3939:21fe")); + @ParameterizedTest + @ValueSource(strings = { + "::", + "::1", + "0:0:0:0:0:0:0:0", + "0:0:0:0:0:0:0:1", + "2001:0acd:0000:0000:0000:0000:3939:21fe", + "2001:acd:0:0:0:0:3939:21fe", + "2001:0acd:0:0::3939:21fe", + "2001:0acd::3939:21fe", + "2001:acd::3939:21fe", + }) + void isIpv6(String address) { + assertThat(AddressUtils.isIpv6(address)).isTrue(); } - @Test - void isIpv6() { - assertTrue(AddressUtils.isIpv6("::")); - assertTrue(AddressUtils.isIpv6("::1")); - assertTrue(AddressUtils.isIpv6("0:0:0:0:0:0:0:0")); - assertTrue(AddressUtils.isIpv6("0:0:0:0:0:0:0:1")); - assertTrue(AddressUtils.isIpv6("2001:0acd:0000:0000:0000:0000:3939:21fe")); - assertTrue(AddressUtils.isIpv6("2001:acd:0:0:0:0:3939:21fe")); - assertTrue(AddressUtils.isIpv6("2001:0acd:0:0::3939:21fe")); - assertTrue(AddressUtils.isIpv6("2001:0acd::3939:21fe")); - assertTrue(AddressUtils.isIpv6("2001:acd::3939:21fe")); + @ParameterizedTest + @ValueSource(strings = { + "", + ":1", + "0:0:0:0:0:0:0", + "0:0:0:0:0:0:0:0:0", + "2001:0acd:0000:garb:age0:0000:3939:21fe", + "2001:0agd:0000:0000:0000:0000:3939:21fe", + "2001:0acd::0000::21fe", + "1:2:3:4:5:6:7::9", + "1::3:4:5:6:7:8:9", + "::3:4:5:6:7:8:9", + "1:2::4:5:6:7:8:9", + "1:2:3:4:5:6::8:9", + "0.0.0.0", + "1.0.0.0", + "127.0.0.1", + "10.11.12.13", + "192.168.0.0", + "255.255.255.255", + }) + void isNotIpv6(String address) { + assertThat(AddressUtils.isIpv6(address)).isFalse(); + } - assertFalse(AddressUtils.isIpv6("")); - assertFalse(AddressUtils.isIpv6(":1")); - assertFalse(AddressUtils.isIpv6("0:0:0:0:0:0:0")); - assertFalse(AddressUtils.isIpv6("0:0:0:0:0:0:0:0:0")); - assertFalse(AddressUtils.isIpv6("2001:0acd:0000:garb:age0:0000:3939:21fe")); - assertFalse(AddressUtils.isIpv6("2001:0agd:0000:0000:0000:0000:3939:21fe")); - assertFalse(AddressUtils.isIpv6("2001:0acd::0000::21fe")); - assertFalse(AddressUtils.isIpv6("1:2:3:4:5:6:7::9")); - assertFalse(AddressUtils.isIpv6("1::3:4:5:6:7:8:9")); - assertFalse(AddressUtils.isIpv6("::3:4:5:6:7:8:9")); - assertFalse(AddressUtils.isIpv6("1:2::4:5:6:7:8:9")); - assertFalse(AddressUtils.isIpv6("1:2:3:4:5:6::8:9")); + @ParameterizedTest + @MethodSource + void parseAddress(String host, NodeAddress except) { + assertThat(AddressUtils.parseAddress(host)).isEqualTo(except); + } - assertFalse(AddressUtils.isIpv6("0.0.0.0")); - assertFalse(AddressUtils.isIpv6("1.0.0.0")); - assertFalse(AddressUtils.isIpv6("127.0.0.1")); - assertFalse(AddressUtils.isIpv6("10.11.12.13")); - assertFalse(AddressUtils.isIpv6("192.168.0.0")); - assertFalse(AddressUtils.isIpv6("255.255.255.255")); + static Stream parseAddress() { + return Stream.of( + Arguments.of("localhost", new NodeAddress("localhost")), + Arguments.of("localhost:", new NodeAddress("localhost:")), + Arguments.of("localhost:1", new NodeAddress("localhost", 1)), + Arguments.of("localhost:3307", new NodeAddress("localhost", 3307)), + Arguments.of("localhost:65535", new NodeAddress("localhost", 65535)), + Arguments.of("localhost:65536", new NodeAddress("localhost:65536")), + Arguments.of("localhost:165536", new NodeAddress("localhost:165536")), + Arguments.of("a1234", new NodeAddress("a1234")), + Arguments.of(":1234", new NodeAddress(":1234")), + Arguments.of("[]:3305", new NodeAddress("[]:3305")), + Arguments.of("[::1]", new NodeAddress("[::1]")), + Arguments.of("[::1]:2", new NodeAddress("[::1]", 2)), + Arguments.of("[::1]:567", new NodeAddress("[::1]", 567)), + Arguments.of("[::1]:65535", new NodeAddress("[::1]", 65535)), + Arguments.of("[::1]:65536", new NodeAddress("[::1]:65536")), + Arguments.of("[::]", new NodeAddress("[::]")), + Arguments.of("[1::]", new NodeAddress("[1::]")), + Arguments.of("[::]:3", new NodeAddress("[::]", 3)), + Arguments.of("[::]:65536", new NodeAddress("[::]:65536")), + Arguments.of( + "[2001::2:3307]", + new NodeAddress("[2001::2:3307]") + ), + Arguments.of( + "[2001::2]:3307", + new NodeAddress("[2001::2]", 3307) + ), + Arguments.of( + "[a772:8380:7adf:77fd:4d58:d629:a237:0b5e]", + new NodeAddress("[a772:8380:7adf:77fd:4d58:d629:a237:0b5e]") + ), + Arguments.of( + "[ff19:7c3d:8ddb:c86c:647b:17d6:b64a:7930]:4", + new NodeAddress("[ff19:7c3d:8ddb:c86c:647b:17d6:b64a:7930]", 4) + ), + Arguments.of( + "[1234:fd2:5621:1:89::45]:567", + new NodeAddress("[1234:fd2:5621:1:89::45]", 567) + ), + Arguments.of( + "[2001:470:26:12b:9a65:b818:6c96:4271]:65535", + new NodeAddress("[2001:470:26:12b:9a65:b818:6c96:4271]", 65535) + ), + Arguments.of("168.10.0.9", new NodeAddress("168.10.0.9")), + Arguments.of("168.10.0.9:5", new NodeAddress("168.10.0.9", 5)), + Arguments.of("168.10.0.9:1234", new NodeAddress("168.10.0.9", 1234)), + Arguments.of("168.10.0.9:65535", new NodeAddress("168.10.0.9", 65535)), + // See also https://github.com/asyncer-io/r2dbc-mysql/issues/255 + Arguments.of("my_db", new NodeAddress("my_db")), + Arguments.of("my_db:6", new NodeAddress("my_db", 6)), + Arguments.of("my_db:3307", new NodeAddress("my_db", 3307)), + Arguments.of("my_db:65535", new NodeAddress("my_db", 65535)), + Arguments.of("db-service", new NodeAddress("db-service")), + Arguments.of("db-service:7", new NodeAddress("db-service", 7)), + Arguments.of("db-service:3307", new NodeAddress("db-service", 3307)), + Arguments.of("db-service:65535", new NodeAddress("db-service", 65535)), + Arguments.of( + "region_asia.rds3.some-cloud.com", + new NodeAddress("region_asia.rds3.some-cloud.com") + ), + Arguments.of( + "region_asia.rds4.some-cloud.com:8", + new NodeAddress("region_asia.rds4.some-cloud.com", 8) + ), + Arguments.of( + "region_asia.rds5.some-cloud.com:425", + new NodeAddress("region_asia.rds5.some-cloud.com", 425) + ), + Arguments.of( + "region_asia.rds6.some-cloud.com:65535", + new NodeAddress("region_asia.rds6.some-cloud.com", 65535) + ) + ); } } From 7c9144af359f9bcb77063647b51a649a54a6560c Mon Sep 17 00:00:00 2001 From: Mirro Mutth Date: Fri, 22 Mar 2024 19:28:29 +0900 Subject: [PATCH 2/2] Temp commit: Add support for failover --- .../r2dbc/mysql/ConnectionStrategy.java | 107 ++++++++--- .../java/io/asyncer/r2dbc/mysql/InitFlow.java | 13 +- .../mysql/MultiHostsConnectionStrategy.java | 171 +++++++++++------- .../mysql/MySqlConnectionConfiguration.java | 31 ++++ .../mysql/MySqlConnectionFactoryProvider.java | 14 ++ .../mysql/SingleHostConnectionStrategy.java | 64 ++++++- .../r2dbc/mysql/TcpSocketConfiguration.java | 42 ++++- .../io/asyncer/r2dbc/mysql/client/Client.java | 31 ---- .../r2dbc/mysql/client/FailoverClient.java | 99 ++++++++++ .../mysql/client/ReactorNettyClient.java | 34 +++- .../r2dbc/mysql/constant/HaProtocol.java | 4 +- 11 files changed, 464 insertions(+), 146 deletions(-) create mode 100644 r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/FailoverClient.java diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionStrategy.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionStrategy.java index 72ca836a9..d90acd054 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionStrategy.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionStrategy.java @@ -17,6 +17,7 @@ package io.asyncer.r2dbc.mysql; import io.asyncer.r2dbc.mysql.client.Client; +import io.asyncer.r2dbc.mysql.client.ReactorNettyClient; import io.asyncer.r2dbc.mysql.internal.util.StringUtils; import io.netty.channel.ChannelOption; import io.netty.resolver.AddressResolver; @@ -26,6 +27,8 @@ import io.netty.util.concurrent.EventExecutor; import io.netty.util.internal.logging.InternalLogger; import io.netty.util.internal.logging.InternalLoggerFactory; +import io.r2dbc.spi.R2dbcNonTransientResourceException; +import org.jetbrains.annotations.Nullable; import reactor.core.publisher.Mono; import reactor.netty.resources.LoopResources; import reactor.netty.tcp.TcpClient; @@ -33,6 +36,8 @@ import java.net.InetSocketAddress; import java.time.Duration; import java.time.ZoneId; +import java.util.function.Function; +import java.util.function.Supplier; /** * An interface of a connection strategy that considers how to obtain a MySQL {@link Client} object. @@ -49,7 +54,7 @@ interface ConnectionStrategy { * * @return a logged-in {@link Client} object. */ - Mono connect(); + Mono connect(); /** * Creates a general-purpose {@link TcpClient} with the given {@link SocketClientConfiguration}. @@ -87,7 +92,7 @@ static TcpClient createTcpClient(SocketClientConfiguration configuration, boolea * @param configuration a configuration that affects login behavior. * @return a logged-in {@link Client} object. */ - static Mono connectWithInit( + static Mono connectWithInit( TcpClient tcpClient, Credential credential, MySqlConnectionConfiguration configuration @@ -110,7 +115,7 @@ static Mono connectWithInit( configuration.isPreserveInstants(), connectionTimeZone ); - }).flatMap(context -> Client.connect(tcpClient, configuration.getSsl(), context)).flatMap(client -> { + }).flatMap(ctx -> ReactorNettyClient.connect(tcpClient, configuration.getSsl(), ctx)).flatMap(client -> { // Lazy init database after handshake/login MySqlSslConfiguration ssl = configuration.getSsl(); String loginDb = configuration.isCreateDatabaseIfNotExist() ? "" : configuration.getDatabase(); @@ -126,30 +131,88 @@ static Mono connectWithInit( ).then(Mono.just(client)).onErrorResume(e -> client.forceClose().then(Mono.error(e))); }); } -} - -/** - * Resolves the {@link InetSocketAddress} to IP address, randomly pick one if it resolves to multiple IP addresses. - * - * @since 1.2.0 - */ -final class BalancedResolverGroup extends AddressResolverGroup { - BalancedResolverGroup() { + /** + * Creates an exception that indicates a retry failure. + * + * @param message the message of the exception. + * @param cause the last exception that caused the retry. + * @return a retry failure exception. + */ + static R2dbcNonTransientResourceException retryFail(String message, @Nullable Throwable cause) { + return new R2dbcNonTransientResourceException( + message, + "H1000", + 9000, + cause + ); } - public static final BalancedResolverGroup INSTANCE; + /** + * Connect and login to a MySQL server with a specific TCP socket address. + * + * @since 1.2.0 + */ + final class InetConnectFunction implements Function, Mono> { + + private final boolean balancedDns; + + private final boolean tcpKeepAlive; + + private final boolean tcpNoDelay; + + private final Credential credential; - static { - INSTANCE = new BalancedResolverGroup(); - Runtime.getRuntime().addShutdownHook(new Thread( - INSTANCE::close, - "R2DBC-MySQL-BalancedResolverGroup-ShutdownHook" - )); + private final MySqlConnectionConfiguration configuration; + + InetConnectFunction( + boolean balancedDns, + boolean tcpKeepAlive, + boolean tcpNoDelay, + Credential credential, + MySqlConnectionConfiguration configuration + ) { + this.balancedDns = balancedDns; + this.tcpKeepAlive = tcpKeepAlive; + this.tcpNoDelay = tcpNoDelay; + this.credential = credential; + this.configuration = configuration; + } + + @Override + public Mono apply(Supplier address) { + TcpClient client = ConnectionStrategy.createTcpClient(configuration.getClient(), balancedDns) + .option(ChannelOption.SO_KEEPALIVE, tcpKeepAlive) + .option(ChannelOption.TCP_NODELAY, tcpNoDelay) + .remoteAddress(address); + + return ConnectionStrategy.connectWithInit(client, credential, configuration); + } } - @Override - protected AddressResolver newResolver(EventExecutor executor) { - return new RoundRobinInetAddressResolver(executor, new DefaultNameResolver(executor)).asAddressResolver(); + /** + * Resolves the {@link InetSocketAddress} to IP address, randomly pick one if it resolves to multiple IP addresses. + * + * @since 1.2.0 + */ + final class BalancedResolverGroup extends AddressResolverGroup { + + BalancedResolverGroup() { + } + + public static final BalancedResolverGroup INSTANCE; + + static { + INSTANCE = new BalancedResolverGroup(); + Runtime.getRuntime().addShutdownHook(new Thread( + INSTANCE::close, + "R2DBC-MySQL-BalancedResolverGroup-ShutdownHook" + )); + } + + @Override + protected AddressResolver newResolver(EventExecutor executor) { + return new RoundRobinInetAddressResolver(executor, new DefaultNameResolver(executor)).asAddressResolver(); + } } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/InitFlow.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/InitFlow.java index a7c13c596..8e1b06f1f 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/InitFlow.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/InitFlow.java @@ -22,6 +22,7 @@ import io.asyncer.r2dbc.mysql.cache.PrepareCache; import io.asyncer.r2dbc.mysql.client.Client; import io.asyncer.r2dbc.mysql.client.FluxExchangeable; +import io.asyncer.r2dbc.mysql.client.ReactorNettyClient; import io.asyncer.r2dbc.mysql.codec.Codecs; import io.asyncer.r2dbc.mysql.codec.CodecsBuilder; import io.asyncer.r2dbc.mysql.constant.CompressionAlgorithm; @@ -75,7 +76,7 @@ import java.util.function.Function; /** - * A message flow utility that can initializes the session of {@link Client}. + * A message flow utility that can initializes the session of {@link ReactorNettyClient}. *

* It should not use server-side prepared statements, because {@link PrepareCache} will be initialized after the session * is initialized. @@ -117,9 +118,9 @@ final class InitFlow { }; /** - * Initializes handshake and login a {@link Client}. + * Initializes handshake and login a {@link ReactorNettyClient}. * - * @param client the {@link Client} to exchange messages with. + * @param client the {@link ReactorNettyClient} to exchange messages with. * @param sslMode the {@link SslMode} defines SSL capability and behavior. * @param database the database that will be connected. * @param user the user that will be login. @@ -128,7 +129,7 @@ final class InitFlow { * @param zstdCompressionLevel the zstd compression level. * @return a {@link Mono} that indicates the initialization is done, or an error if the initialization failed. */ - static Mono initHandshake(Client client, SslMode sslMode, String database, String user, + static Mono initHandshake(ReactorNettyClient client, SslMode sslMode, String database, String user, @Nullable CharSequence password, Set compressionAlgorithms, int zstdCompressionLevel) { return client.exchange(new HandshakeExchangeable( client, @@ -488,7 +489,7 @@ final class HandshakeExchangeable extends FluxExchangeable { private final Sinks.Many requests = Sinks.many().unicast() .onBackpressureBuffer(Queues.one().get()); - private final Client client; + private final ReactorNettyClient client; private final SslMode sslMode; @@ -511,7 +512,7 @@ final class HandshakeExchangeable extends FluxExchangeable { private boolean sslCompleted; - HandshakeExchangeable(Client client, SslMode sslMode, String database, String user, + HandshakeExchangeable(ReactorNettyClient client, SslMode sslMode, String database, String user, @Nullable CharSequence password, Set compressions, int zstdCompressionLevel) { this.client = client; diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MultiHostsConnectionStrategy.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MultiHostsConnectionStrategy.java index 68eafa512..fc7071505 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MultiHostsConnectionStrategy.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MultiHostsConnectionStrategy.java @@ -17,19 +17,17 @@ package io.asyncer.r2dbc.mysql; import io.asyncer.r2dbc.mysql.client.Client; +import io.asyncer.r2dbc.mysql.client.FailoverClient; +import io.asyncer.r2dbc.mysql.client.ReactorNettyClient; import io.asyncer.r2dbc.mysql.constant.ProtocolDriver; import io.asyncer.r2dbc.mysql.internal.NodeAddress; import io.asyncer.r2dbc.mysql.internal.util.InternalArrays; -import io.netty.channel.ChannelOption; import io.netty.resolver.DefaultNameResolver; import io.netty.resolver.NameResolver; import io.netty.util.concurrent.Future; -import io.r2dbc.spi.R2dbcNonTransientResourceException; -import org.jetbrains.annotations.Nullable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.netty.resources.LoopResources; -import reactor.netty.tcp.TcpClient; import reactor.netty.tcp.TcpResources; import java.net.InetAddress; @@ -46,105 +44,153 @@ */ final class MultiHostsConnectionStrategy implements ConnectionStrategy { - private final Mono client; + private final Mono client; MultiHostsConnectionStrategy( - TcpSocketConfiguration tcp, MySqlConnectionConfiguration configuration, - boolean shuffle + List addresses, + ProtocolDriver driver, + int retriesAllDown, + boolean shuffle, + boolean tcpKeepAlive, + boolean tcpNoDelay ) { - this.client = Mono.defer(() -> { - if (ProtocolDriver.DNS_SRV.equals(tcp.getDriver())) { + Mono client = configuration.getCredential().flatMap(credential -> { + if (ProtocolDriver.DNS_SRV.equals(driver)) { + logger.debug("Resolve hosts via DNS SRV: {}", addresses); + LoopResources resources = configuration.getClient().getLoopResources(); LoopResources loopResources = resources == null ? TcpResources.get() : resources; - - return resolveAllHosts(loopResources, tcp.getAddresses(), shuffle) - .flatMap(addresses -> connectHost(addresses, tcp, configuration, false, shuffle, 0)); + InetConnectFunction login = new InetConnectFunction( + false, + tcpKeepAlive, + tcpNoDelay, + credential, + configuration + ); + + return resolveAllHosts(loopResources, addresses, shuffle).flatMap(addrs -> { + logger.debug("Connect to multiple addresses: {}", addrs); + + return connectHost( + addrs, + login, + shuffle, + 0, + retriesAllDown + ); + }); } else { - List availableHosts = copyAvailableAddresses(tcp.getAddresses(), shuffle); + List availableHosts = copyAvailableAddresses(addresses, shuffle); + logger.debug("Connect to multiple hosts: {}", availableHosts); + int size = availableHosts.size(); - InetSocketAddress[] addresses = new InetSocketAddress[availableHosts.size()]; + InetSocketAddress[] array = new InetSocketAddress[availableHosts.size()]; for (int i = 0; i < size; i++) { - NodeAddress address = availableHosts.get(i); - addresses[i] = InetSocketAddress.createUnresolved(address.getHost(), address.getPort()); + array[i] = availableHosts.get(i).toUnresolved(); } - return connectHost(InternalArrays.asImmutableList(addresses), tcp, configuration, true, shuffle, 0); + List addrs = InternalArrays.asImmutableList(array); + InetConnectFunction login = new InetConnectFunction( + true, + tcpKeepAlive, + tcpNoDelay, + credential, + configuration + ); + + return connectHost( + addrs, + login, + shuffle, + 0, + retriesAllDown + ); } }); + + this.client = client.map(c -> new FailoverClient(c, client)); } @Override - public Mono connect() { + public Mono connect() { return client; } - private Mono connectHost( + private static Mono connectHost( List addresses, - TcpSocketConfiguration tcp, - MySqlConnectionConfiguration configuration, - boolean balancedDns, + InetConnectFunction login, boolean shuffle, - int attempts + int attempts, + int maxAttempts ) { Iterator iter = addresses.iterator(); if (!iter.hasNext()) { - return Mono.error(fail("Fail to establish connection: no available host", null)); + return Mono.error(ConnectionStrategy.retryFail("Fail to establish connection: no available host", null)); } - return configuration.getCredential().flatMap(credential -> attemptConnect( - iter.next(), credential, tcp, configuration, balancedDns - ).onErrorResume(t -> resumeConnect( - t, addresses, iter, credential, tcp, configuration, balancedDns, shuffle, attempts - ))); + + InetSocketAddress address = iter.next(); + + return login.apply(() -> address).onErrorResume(error -> resumeConnect( + error, + address, + addresses, + iter, + login, + shuffle, + attempts, + maxAttempts + )); } - private Mono resumeConnect( + private static Mono resumeConnect( Throwable t, + InetSocketAddress failed, List addresses, Iterator iter, - Credential credential, - TcpSocketConfiguration tcp, - MySqlConnectionConfiguration configuration, - boolean balancedDns, + InetConnectFunction login, boolean shuffle, - int attempts + int attempts, + int maxAttempts ) { + logger.warn("Fail to connect to {}", failed, t); + if (!iter.hasNext()) { // The last host failed to connect - if (attempts >= tcp.getRetriesAllDown()) { - return Mono.error(fail( - "Fail to establish connection, retried " + attempts + " times: " + t.getMessage(), t)); + if (attempts >= maxAttempts) { + return Mono.error(ConnectionStrategy.retryFail( + "Fail to establish connections, retried " + attempts + " times", t)); } - logger.warn("All hosts failed to establish connections, auto-try again after 250ms."); + logger.warn("All hosts failed to establish connections, auto-try again after 250ms.", t); // Ignore waiting error, e.g. interrupted, scheduler rejected return Mono.delay(Duration.ofMillis(250)) .onErrorComplete() - .then(Mono.defer(() -> connectHost(addresses, tcp, configuration, balancedDns, shuffle, attempts + 1))); + .then(Mono.defer(() -> connectHost( + addresses, + login, + shuffle, + attempts + 1, + maxAttempts + ))); } - return attemptConnect(iter.next(), credential, tcp, configuration, balancedDns).onErrorResume(tt -> - resumeConnect(tt, addresses, iter, credential, tcp, configuration, balancedDns, shuffle, attempts)); - } - - private Mono attemptConnect( - InetSocketAddress address, - Credential credential, - TcpSocketConfiguration tcp, - MySqlConnectionConfiguration configuration, - boolean balancedDns - ) { - TcpClient tcpClient = ConnectionStrategy.createTcpClient(configuration.getClient(), balancedDns) - .option(ChannelOption.SO_KEEPALIVE, tcp.isTcpKeepAlive()) - .option(ChannelOption.TCP_NODELAY, tcp.isTcpNoDelay()) - .remoteAddress(() -> address); - - return ConnectionStrategy.connectWithInit(tcpClient, credential, configuration) - .doOnError(e -> logger.warn("Fail to connect: ", e)); + InetSocketAddress address = iter.next(); + + return login.apply(() -> address).onErrorResume(error -> resumeConnect( + error, + address, + addresses, + iter, + login, + shuffle, + attempts, + maxAttempts + )); } private static Mono> resolveAllHosts( @@ -203,13 +249,4 @@ private static List copyAvailableAddresses(List addres return InternalArrays.asImmutableList(addresses.toArray(new NodeAddress[0])); } - - private static R2dbcNonTransientResourceException fail(String message, @Nullable Throwable cause) { - return new R2dbcNonTransientResourceException( - message, - "H1000", - 9000, - cause - ); - } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java index 784c87467..fb4ba179a 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionConfiguration.java @@ -64,6 +64,8 @@ public final class MySqlConnectionConfiguration { private final MySqlSslConfiguration ssl; + private final boolean autoReconnect; + private final boolean preserveInstants; private final String connectionTimeZone; @@ -110,6 +112,7 @@ private MySqlConnectionConfiguration( SocketClientConfiguration client, SocketConfiguration socket, MySqlSslConfiguration ssl, + boolean autoReconnect, ZeroDateOption zeroDateOption, boolean preserveInstants, String connectionTimeZone, @@ -127,6 +130,7 @@ private MySqlConnectionConfiguration( this.client = requireNonNull(client, "client must not be null"); this.socket = requireNonNull(socket, "socket must not be null"); this.ssl = requireNonNull(ssl, "ssl must not be null"); + this.autoReconnect = autoReconnect; this.preserveInstants = preserveInstants; this.connectionTimeZone = requireNonNull(connectionTimeZone, "connectionTimeZone must not be null"); this.forceConnectionTimeZoneToSession = forceConnectionTimeZoneToSession; @@ -169,6 +173,10 @@ MySqlSslConfiguration getSsl() { return ssl; } + boolean isAutoReconnect() { + return autoReconnect; + } + ZeroDateOption getZeroDateOption() { return zeroDateOption; } @@ -272,6 +280,7 @@ public boolean equals(Object o) { return client.equals(that.client) && socket.equals(that.socket) && ssl.equals(that.ssl) && + autoReconnect == that.autoReconnect && preserveInstants == that.preserveInstants && connectionTimeZone.equals(that.connectionTimeZone) && forceConnectionTimeZoneToSession == that.forceConnectionTimeZoneToSession && @@ -298,6 +307,7 @@ public int hashCode() { return Objects.hash( client, socket, ssl, + autoReconnect, preserveInstants, connectionTimeZone, forceConnectionTimeZoneToSession, @@ -320,6 +330,7 @@ public String toString() { return "MySqlConnectionConfiguration{client=" + client + ", socket=" + socket + ", ssl=" + ssl + + ", autoReconnect=" + autoReconnect + ", preserveInstants=" + preserveInstants + ", connectionTimeZone='" + connectionTimeZone + '\'' + ", forceConnectionTimeZoneToSession=" + forceConnectionTimeZoneToSession + @@ -357,6 +368,8 @@ public static final class Builder { private final MySqlSslConfiguration.Builder ssl = new MySqlSslConfiguration.Builder(); + private boolean autoReconnect; + @Nullable private String database; @@ -434,6 +447,7 @@ public MySqlConnectionConfiguration build() { client.build(), socket, ssl.build(preferredSsl), + autoReconnect, zeroDateOption, preserveInstants, connectionTimeZone, @@ -600,6 +614,23 @@ public Builder protocol(HaProtocol protocol) { return this; } + /** + * Configures whether to perform failover reconnection. Default is {@code false}. + *

+ * It is not recommended due to it may lead to unexpected results. For example, it may recover a transaction + * state from a failed server node to an available node, the user can not aware of it, and continuing to execute + * more queries in the transaction will lead to unexpected inconsistencies. + * + * @param enabled {@code true} to enable failover reconnection. + * @return {@link Builder this} + * @see JDBC Failover + * @since 1.2.0 + */ + public Builder autoReconnect(boolean enabled) { + this.autoReconnect = enabled; + return this; + } + /** * Configures the connection timeout. Default no timeout. * diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java index c26469aec..ad71be06d 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MySqlConnectionFactoryProvider.java @@ -66,6 +66,18 @@ public final class MySqlConnectionFactoryProvider implements ConnectionFactoryPr */ public static final Option UNIX_SOCKET = Option.valueOf("unixSocket"); + /** + * Option to whether to perform failover reconnection. Default to {@code false}. + *

+ * It is not recommended due to it may lead to unexpected results. For example, it may recover a transaction state + * from a failed server node to an available node, the user can not aware of it, and continuing to execute more + * queries in the transaction will lead to unexpected inconsistencies or errors. Or, user set a self-defined + * variable in the session, it may not be recovered to the new node due to the driver can not aware of it. + * + * @since 1.2.0 + */ + public static final Option AUTO_RECONNECT = Option.valueOf("autoReconnect"); + /** * Option to set the time zone conversion. Default to {@code true} means enable conversion between JVM and * {@link #CONNECTION_TIME_ZONE}. @@ -361,6 +373,8 @@ static MySqlConnectionConfiguration setup(ConnectionFactoryOptions options) { mapper.optional(FORCE_CONNECTION_TIME_ZONE_TO_SESSION).asBoolean() .to(builder::forceConnectionTimeZoneToSession); + mapper.optional(AUTO_RECONNECT).asBoolean() + .to(builder::autoReconnect); mapper.optional(TCP_KEEP_ALIVE).asBoolean() .to(builder::tcpKeepAlive); mapper.optional(TCP_NO_DELAY).asBoolean() diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SingleHostConnectionStrategy.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SingleHostConnectionStrategy.java index cbbb2d029..5790e21c4 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SingleHostConnectionStrategy.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/SingleHostConnectionStrategy.java @@ -17,10 +17,11 @@ package io.asyncer.r2dbc.mysql; import io.asyncer.r2dbc.mysql.client.Client; +import io.asyncer.r2dbc.mysql.client.ReactorNettyClient; import io.asyncer.r2dbc.mysql.internal.NodeAddress; -import io.netty.channel.ChannelOption; import reactor.core.publisher.Mono; -import reactor.netty.tcp.TcpClient; + +import java.time.Duration; /** * An implementation of {@link ConnectionStrategy} that connects to a single host. It can be wrapped to a @@ -30,18 +31,24 @@ final class SingleHostConnectionStrategy implements ConnectionStrategy { private final Mono client; - SingleHostConnectionStrategy(TcpSocketConfiguration socket, MySqlConnectionConfiguration configuration) { + SingleHostConnectionStrategy( + MySqlConnectionConfiguration configuration, + NodeAddress address, + boolean tcpKeepAlive, + boolean tcpNoDelay + ) { this.client = configuration.getCredential().flatMap(credential -> { - NodeAddress address = socket.getFirstAddress(); - logger.debug("Connect to a single host: {}", address); - TcpClient tcpClient = ConnectionStrategy.createTcpClient(configuration.getClient(), true) - .option(ChannelOption.SO_KEEPALIVE, socket.isTcpKeepAlive()) - .option(ChannelOption.TCP_NODELAY, socket.isTcpNoDelay()) - .remoteAddress(address::toUnresolved); + InetConnectFunction login = new InetConnectFunction( + true, + tcpKeepAlive, + tcpNoDelay, + credential, + configuration + ); - return ConnectionStrategy.connectWithInit(tcpClient, credential, configuration); + return connectHost(login, address, 0, 3); }); } @@ -49,4 +56,41 @@ final class SingleHostConnectionStrategy implements ConnectionStrategy { public Mono connect() { return client; } + + private static Mono connectHost( + InetConnectFunction login, + NodeAddress address, + int attempts, + int maxAttempts + ) { + return login.apply(address::toUnresolved) + .onErrorResume(t -> resumeConnect(t, address, login, attempts, maxAttempts)); + } + + private static Mono resumeConnect( + Throwable t, + NodeAddress address, + InetConnectFunction login, + int attempts, + int maxAttempts + ) { + logger.warn("Fail to connect to {}", address, t); + + if (attempts >= maxAttempts) { + return Mono.error(ConnectionStrategy.retryFail( + "Fail to establish connection, retried " + attempts + " times", t)); + } + + logger.warn("Failed to establish connection, auto-try again after 250ms.", t); + + // Ignore waiting error, e.g. interrupted, scheduler rejected + return Mono.delay(Duration.ofMillis(250)) + .onErrorComplete() + .then(Mono.defer(() -> connectHost( + login, + address, + attempts + 1, + maxAttempts + ))); + } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/TcpSocketConfiguration.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/TcpSocketConfiguration.java index f100a687f..e3ea6bb1f 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/TcpSocketConfiguration.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/TcpSocketConfiguration.java @@ -219,16 +219,48 @@ public ConnectionStrategy strategy(MySqlConnectionConfiguration configuration) { case REPLICATION: ConnectionStrategy.logger.warn( "R2DBC Connection cannot be set to read-only, replication protocol will use the first host"); - return new SingleHostConnectionStrategy(this, configuration); + return new MultiHostsConnectionStrategy( + configuration, + Collections.singletonList(getFirstAddress()), + driver, + retriesAllDown, + false, + tcpKeepAlive, + tcpNoDelay + ); case SEQUENTIAL: - return new MultiHostsConnectionStrategy(this, configuration, false); + return new MultiHostsConnectionStrategy( + configuration, + addresses, + driver, + retriesAllDown, + false, + tcpKeepAlive, + tcpNoDelay + ); case LOAD_BALANCE: - return new MultiHostsConnectionStrategy(this, configuration, true); + return new MultiHostsConnectionStrategy( + configuration, + addresses, + driver, + retriesAllDown, + true, + tcpKeepAlive, + tcpNoDelay + ); default: if (ProtocolDriver.MYSQL == driver && addresses.size() == 1) { - return new SingleHostConnectionStrategy(this, configuration); + return new SingleHostConnectionStrategy(configuration, getFirstAddress(), tcpKeepAlive, tcpNoDelay); } else { - return new MultiHostsConnectionStrategy(this, configuration, false); + return new MultiHostsConnectionStrategy( + configuration, + addresses, + driver, + retriesAllDown, + false, + tcpKeepAlive, + tcpNoDelay + ); } } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/Client.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/Client.java index 6ac6e93a5..1b570a933 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/Client.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/Client.java @@ -17,7 +17,6 @@ package io.asyncer.r2dbc.mysql.client; import io.asyncer.r2dbc.mysql.ConnectionContext; -import io.asyncer.r2dbc.mysql.MySqlSslConfiguration; import io.asyncer.r2dbc.mysql.message.client.ClientMessage; import io.asyncer.r2dbc.mysql.message.server.ServerMessage; import io.netty.buffer.ByteBufAllocator; @@ -26,12 +25,9 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.SynchronousSink; -import reactor.netty.tcp.TcpClient; import java.util.function.BiConsumer; -import static io.asyncer.r2dbc.mysql.internal.util.AssertUtils.requireNonNull; - /** * An abstraction that wraps the networking part of exchanging methods. */ @@ -99,31 +95,4 @@ public interface Client { * @return if connection is valid */ boolean isConnected(); - - /** - * Sends a signal to the connection, which means server does not support SSL. - */ - void sslUnsupported(); - - /** - * Sends a signal to {@link Client this}, which means login has succeeded. - */ - void loginSuccess(); - - /** - * Connects to a MySQL server using the provided {@link TcpClient} and {@link MySqlSslConfiguration}. - * - * @param tcpClient the configured TCP client - * @param ssl the SSL configuration - * @param context the connection context - * @return A {@link Mono} that will emit a connected {@link Client}. - * @throws IllegalArgumentException if {@code tcpClient}, {@code ssl} or {@code context} is {@code null}. - */ - static Mono connect(TcpClient tcpClient, MySqlSslConfiguration ssl, ConnectionContext context) { - requireNonNull(tcpClient, "tcpClient must not be null"); - requireNonNull(ssl, "ssl must not be null"); - requireNonNull(context, "context must not be null"); - - return tcpClient.connect().map(conn -> new ReactorNettyClient(conn, ssl, context)); - } } diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/FailoverClient.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/FailoverClient.java new file mode 100644 index 000000000..539fbd072 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/FailoverClient.java @@ -0,0 +1,99 @@ +/* + * Copyright 2024 asyncer.io projects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.asyncer.r2dbc.mysql.client; + +import io.asyncer.r2dbc.mysql.ConnectionContext; +import io.asyncer.r2dbc.mysql.message.client.ClientMessage; +import io.asyncer.r2dbc.mysql.message.server.ServerMessage; +import io.netty.buffer.ByteBufAllocator; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.SynchronousSink; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + +/** + * An implementation of {@link Client} that supports failover. + */ +public final class FailoverClient implements Client { + + private final Mono failover; + + private final AtomicReference client; + + public FailoverClient(ReactorNettyClient client, Mono failover) { + this.client = new AtomicReference<>(client); + this.failover = failover; + } + + private Mono reconnectIfNecessary() { + return Mono.defer(() -> { + ReactorNettyClient client = this.client.get(); + + if (client.isChannelOpen() || client.isClosingOrClosed()) { + // Open, or closed by user + return Mono.just(client); + } + + return this.failover.flatMap(c -> { + if (this.client.compareAndSet(client, c)) { + // TODO: re-init session variables, transaction state, clear prepared cache, etc. + return Mono.just(c); + } + + // Reconnected by other thread, close this one and retry + return c.forceClose().then(reconnectIfNecessary()); + }); + }); + } + + @Override + public Flux exchange(ClientMessage request, BiConsumer> handler) { + return reconnectIfNecessary().flatMapMany(c -> c.exchange(request, handler)); + } + + @Override + public Flux exchange(FluxExchangeable exchangeable) { + return reconnectIfNecessary().flatMapMany(c -> c.exchange(exchangeable)); + } + + @Override + public Mono close() { + return Mono.fromSupplier(this.client::get).flatMap(ReactorNettyClient::close); + } + + @Override + public Mono forceClose() { + return Mono.fromSupplier(this.client::get).flatMap(ReactorNettyClient::forceClose); + } + + @Override + public ByteBufAllocator getByteBufAllocator() { + return this.client.get().getByteBufAllocator(); + } + + @Override + public ConnectionContext getContext() { + return this.client.get().getContext(); + } + + @Override + public boolean isConnected() { + return this.client.get().isConnected(); + } +} diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/ReactorNettyClient.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/ReactorNettyClient.java index 81cb5f21e..69bef4e93 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/ReactorNettyClient.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/client/ReactorNettyClient.java @@ -41,6 +41,7 @@ import reactor.core.publisher.SynchronousSink; import reactor.netty.Connection; import reactor.netty.FutureMono; +import reactor.netty.tcp.TcpClient; import reactor.util.context.Context; import reactor.util.context.ContextView; @@ -54,7 +55,7 @@ /** * An implementation of client based on the Reactor Netty project. */ -final class ReactorNettyClient implements Client { +public final class ReactorNettyClient implements Client { private static final InternalLogger logger = InternalLoggerFactory.getInstance(ReactorNettyClient.class); @@ -250,12 +251,10 @@ public boolean isConnected() { return state < ST_CLOSED && connection.channel().isOpen(); } - @Override public void sslUnsupported() { connection.channel().pipeline().fireUserEventTriggered(SslState.UNSUPPORTED); } - @Override public void loginSuccess() { if (context.getCapability().isCompression()) { connection.channel().pipeline().fireUserEventTriggered(PacketEvent.USE_COMPRESSION); @@ -264,6 +263,14 @@ public void loginSuccess() { } } + boolean isClosingOrClosed() { + return state >= ST_CLOSING; + } + + boolean isChannelOpen() { + return connection.channel().isOpen(); + } + private static void resetSequence(Connection connection) { connection.channel().pipeline().fireUserEventTriggered(PacketEvent.RESET_SEQUENCE); } @@ -324,6 +331,27 @@ private void handleClose() { } } + /** + * Connects to a MySQL server using the provided {@link TcpClient} and {@link MySqlSslConfiguration}. + * + * @param tcpClient the configured TCP client + * @param ssl the SSL configuration + * @param context the connection context + * @return A {@link Mono} that will emit a connected {@link Client}. + * @throws IllegalArgumentException if {@code tcpClient}, {@code ssl} or {@code context} is {@code null}. + */ + public static Mono connect( + TcpClient tcpClient, + MySqlSslConfiguration ssl, + ConnectionContext context + ) { + requireNonNull(tcpClient, "tcpClient must not be null"); + requireNonNull(ssl, "ssl must not be null"); + requireNonNull(context, "context must not be null"); + + return tcpClient.connect().map(conn -> new ReactorNettyClient(conn, ssl, context)); + } + private final class ResponseSubscriber implements CoreSubscriber { private final ResponseSink sink; diff --git a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/HaProtocol.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/HaProtocol.java index c54cd8923..3adc296ab 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/HaProtocol.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/constant/HaProtocol.java @@ -62,8 +62,8 @@ public enum HaProtocol { *

* Using: I want to use the first node for read-write if connection is set to read-write, and other nodes if * connection is set to read-only. R2DBC can not set a {@link io.r2dbc.spi.Connection Connection} to read-only mode. - * So it will always use the first host. R2DBC does not recommend this mutability. Perhaps in the future, R2DBC will - * support using read-only mode to create a connection instead of modifying an existing connection. + * So it will always use the first host. Perhaps in the future, R2DBC will support using read-only mode to create a + * connection instead of modifying an existing connection. *

* Reconnect: I want to reconnect to the current node if the current node is unavailable and * {@code autoReconnect=true}.