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..e62c5b3e9 --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/ConnectionStrategy.java @@ -0,0 +1,140 @@ +/* + * 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.CompressionAlgorithm; +import io.asyncer.r2dbc.mysql.constant.SslMode; +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.util.Set; + +/** + * 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 login( + TcpClient tcpClient, + Credential credential, + MySqlConnectionConfiguration configuration + ) { + MySqlSslConfiguration ssl = configuration.getSsl(); + SslMode sslMode = ssl.getSslMode(); + boolean createDbIfNotExist = configuration.isCreateDatabaseIfNotExist(); + String database = configuration.getDatabase(); + String loginDb = createDbIfNotExist ? "" : database; + Set compressionAlgorithms = configuration.getCompressionAlgorithms(); + int zstdLevel = configuration.getZstdCompressionLevel(); + ConnectionContext context = new ConnectionContext( + configuration.getZeroDateOption(), + configuration.getLoadLocalInfilePath(), + configuration.getLocalInfileBufferSize(), + configuration.isPreserveInstants(), + configuration.retrieveConnectionZoneId() + ); + + return Client.connect(tcpClient, ssl, context).flatMap(client -> + QueryFlow.login(client, sslMode, loginDb, credential, compressionAlgorithms, zstdLevel)); + } +} + +/** + * 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..b2f91195e --- /dev/null +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/MultiHostsConnectionStrategy.java @@ -0,0 +1,207 @@ +/* + * 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.channel.EventLoop; +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.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())) { + return resolveAllHosts(TcpResources.get().onClient(true).next(), 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 attemptConnect(iter.next(), tcp, configuration, balancedDns).onErrorResume(t -> + resumeConnect(t, addresses, iter, tcp, configuration, balancedDns, shuffle, attempts)); + } + + private Mono resumeConnect( + Throwable t, + List addresses, + Iterator iter, + 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(), tcp, configuration, balancedDns).onErrorResume(tt -> + resumeConnect(tt, addresses, iter, tcp, configuration, balancedDns, shuffle, attempts)); + } + + private Mono attemptConnect( + InetSocketAddress address, + TcpSocketConfiguration tcp, + MySqlConnectionConfiguration configuration, + boolean balancedDns + ) { + return configuration.getCredential().flatMap(credential -> { + TcpClient tcpClient = ConnectionStrategy.createTcpClient(configuration.getClient(), balancedDns) + .option(ChannelOption.SO_KEEPALIVE, tcp.isTcpKeepAlive()) + .option(ChannelOption.TCP_NODELAY, tcp.isTcpNoDelay()) + .remoteAddress(() -> address); + + return ConnectionStrategy.login(tcpClient, credential, configuration); + }).doOnError(e -> logger.warn("Fail to connect: ", e)); + } + + private static Mono> resolveAllHosts( + EventLoop eventLoop, + 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. + DefaultNameResolver resolver = new DefaultNameResolver(eventLoop); + + 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 6d76a8bed..947dc3746 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 @@ -20,29 +20,20 @@ import io.asyncer.r2dbc.mysql.cache.Caches; import io.asyncer.r2dbc.mysql.cache.PrepareCache; import io.asyncer.r2dbc.mysql.cache.QueryCache; -import io.asyncer.r2dbc.mysql.client.Client; import io.asyncer.r2dbc.mysql.codec.Codecs; import io.asyncer.r2dbc.mysql.codec.CodecsBuilder; -import io.asyncer.r2dbc.mysql.constant.CompressionAlgorithm; -import io.asyncer.r2dbc.mysql.constant.SslMode; import io.asyncer.r2dbc.mysql.extension.CodecRegistrar; import io.asyncer.r2dbc.mysql.internal.util.StringUtils; import io.netty.buffer.ByteBufAllocator; -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.time.ZoneOffset; import java.util.ArrayList; import java.util.List; -import java.util.Objects; -import java.util.Set; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; @@ -77,99 +68,33 @@ public ConnectionFactoryMetadata getMetadata() { */ public static MySqlConnectionFactory from(MySqlConnectionConfiguration configuration) { requireNonNull(configuration, "configuration must not be null"); - 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 database = configuration.getDatabase(); - boolean createDbIfNotExist = configuration.isCreateDatabaseIfNotExist(); - String user = configuration.getUser(); - CharSequence password = configuration.getPassword(); - SslMode sslMode = ssl.getSslMode(); - int zstdCompressionLevel = configuration.getZstdCompressionLevel(); - ZoneId connectionTimeZone = retrieveZoneId(configuration.getConnectionTimeZone()); - ConnectionContext context = new ConnectionContext( - configuration.getZeroDateOption(), - configuration.getLoadLocalInfilePath(), - configuration.getLocalInfileBufferSize(), - configuration.isPreserveInstants(), - connectionTimeZone - ); - Set compressionAlgorithms = configuration.getCompressionAlgorithms(); - Extensions extensions = configuration.getExtensions(); - Predicate prepare = configuration.getPreferPrepareStatement(); - int prepareCacheSize = configuration.getPrepareCacheSize(); - Publisher passwordPublisher = configuration.getPasswordPublisher(); - boolean forceTimeZone = configuration.isForceConnectionTimeZoneToSession(); - List sessionVariables = forceTimeZone && connectionTimeZone != null ? - mergeSessionVariables(configuration.getSessionVariables(), connectionTimeZone) : - configuration.getSessionVariables(); - - if (Objects.nonNull(passwordPublisher)) { - return Mono.from(passwordPublisher).flatMap(token -> getMySqlConnection( - configuration, queryCache, - ssl, address, - database, createDbIfNotExist, - user, sslMode, - compressionAlgorithms, zstdCompressionLevel, - context, extensions, sessionVariables, prepare, - prepareCacheSize, token - )); - } - - return getMySqlConnection( - configuration, queryCache, - ssl, address, - database, createDbIfNotExist, - user, sslMode, - compressionAlgorithms, zstdCompressionLevel, - context, extensions, sessionVariables, prepare, - prepareCacheSize, password - ); - })); + return new MySqlConnectionFactory(Mono.defer(() -> connectWithInit(configuration, queryCache))); } - private static Mono getMySqlConnection( - final MySqlConnectionConfiguration configuration, - final LazyQueryCache queryCache, - final MySqlSslConfiguration ssl, - final SocketAddress address, - final String database, - final boolean createDbIfNotExist, - final String user, - final SslMode sslMode, - final Set compressionAlgorithms, - final int zstdLevel, - final ConnectionContext context, - final Extensions extensions, - final List sessionVariables, - @Nullable final Predicate prepare, - final int prepareCacheSize, - @Nullable final CharSequence password) { - return Client.connect(ssl, address, configuration.isTcpKeepAlive(), configuration.isTcpNoDelay(), - context, configuration.getConnectTimeout(), configuration.getLoopResources()) - .flatMap(client -> { - // Lazy init database after handshake/login - String db = createDbIfNotExist ? "" : database; - return QueryFlow.login(client, sslMode, db, user, password, compressionAlgorithms, zstdLevel); - }) + private static Mono connectWithInit( + MySqlConnectionConfiguration configuration, + LazyQueryCache queryCache + ) { + return configuration.getSocket() + .strategy(configuration) + .connect() .flatMap(client -> { + String database = configuration.getDatabase(); + boolean createDbIfNotExist = configuration.isCreateDatabaseIfNotExist(); + ZoneId connectionTimeZone = retrieveZoneId(configuration.getConnectionTimeZone()); + Predicate prepare = configuration.getPreferPrepareStatement(); + int prepareCacheSize = configuration.getPrepareCacheSize(); + boolean forceTimeZone = configuration.isForceConnectionTimeZoneToSession(); + List sessionVariables = forceTimeZone && connectionTimeZone != null ? + mergeSessionVariables(configuration.getSessionVariables(), connectionTimeZone) : + configuration.getSessionVariables(); ByteBufAllocator allocator = client.getByteBufAllocator(); CodecsBuilder builder = Codecs.builder(); PrepareCache prepareCache = Caches.createPrepareCache(prepareCacheSize); String db = createDbIfNotExist ? database : ""; + Extensions extensions = configuration.getExtensions(); extensions.forEach(CodecRegistrar.class, registrar -> registrar.register(allocator, builder)); 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/QueryFlow.java b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/QueryFlow.java index e7a5de4bc..f2e2edf7c 100644 --- a/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/QueryFlow.java +++ b/r2dbc-mysql/src/main/java/io/asyncer/r2dbc/mysql/QueryFlow.java @@ -201,20 +201,23 @@ static Flux> execute(Client client, List statements) * @param client the {@link Client} 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. - * @param password the password of the {@code user}. + * @param credential the {@link Credential} for login. * @param compressionAlgorithms the list of compression algorithms. * @param zstdCompressionLevel the zstd compression level. - * @param context the {@link ConnectionContext} for initialization. * @return the messages received in response to the login exchange. */ - static Mono login(Client client, SslMode sslMode, String database, String user, - @Nullable CharSequence password, + + static Mono login(Client client, SslMode sslMode, String database, Credential credential, Set compressionAlgorithms, int zstdCompressionLevel) { - return client.exchange(new LoginExchangeable(client, sslMode, database, user, password, - compressionAlgorithms, zstdCompressionLevel)) - .onErrorResume(e -> client.forceClose().then(Mono.error(e))) - .then(Mono.just(client)); + return client.exchange(new LoginExchangeable( + client, + sslMode, + database, + credential.getUser(), + credential.getPassword(), + compressionAlgorithms, + zstdCompressionLevel + )).onErrorResume(e -> client.forceClose().then(Mono.error(e))).then(Mono.just(client)); } /** 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..59f83e457 --- /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.login(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..60ef8f58d --- /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.login(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..9c228cecb --- /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 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..b2ead6cb5 --- /dev/null +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/HaProtocolIntegrationTest.java @@ -0,0 +1,81 @@ +/* + * 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.r2dbc.spi.ValidationDepth; +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}. + */ +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) { + String password = System.getProperty("test.mysql.password"); + + assertThat(password).withFailMessage("Property test.mysql.password must exists and not be empty") + .isNotNull() + .isNotEmpty(); + + MySqlConnectionConfiguration.Builder builder = MySqlConnectionConfiguration.builder() + .protocol(protocol) + .connectTimeout(Duration.ofSeconds(3)) + .user("root") + .password(password) + .database("r2dbc"); + + if (badFirst) { + builder.addHost("127.0.0.1", 3310).addHost("127.0.0.1"); + } else { + builder.addHost("127.0.0.1").addHost("127.0.0.1", 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 c8d50c633..e10c2f187 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 @@ -55,9 +55,8 @@ class MySqlSimpleConnectionTest { when(client.getContext()).thenReturn(ConnectionContextTest.mock()); this.client = client; - this.noPrepare = new MySqlSimpleConnection(client, - codecs, level, 50, Caches.createQueryCache(0), - Caches.createPrepareCache(0), product, null); + this.noPrepare = new MySqlSimpleConnection(client, codecs, level, 50, + Caches.createQueryCache(0), Caches.createPrepareCache(0), product, null); } @Test 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 7b85d4150..832e56d64 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 @@ -17,6 +17,7 @@ package io.asyncer.r2dbc.mysql; import com.zaxxer.hikari.HikariDataSource; +import io.asyncer.r2dbc.mysql.internal.NodeAddress; import io.r2dbc.spi.test.TestKit; import org.springframework.jdbc.core.JdbcTemplate; @@ -75,15 +76,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..940d89335 --- /dev/null +++ b/r2dbc-mysql/src/test/java/io/asyncer/r2dbc/mysql/ProtocolDriverIntegrationTest.java @@ -0,0 +1,64 @@ +/* + * 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.r2dbc.spi.ConnectionFactoryOptions; +import io.r2dbc.spi.ValidationDepth; +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 DNS SRV records. + */ +class ProtocolDriverIntegrationTest { + + @ParameterizedTest + @ValueSource(strings = { + "r2dbc:mysql+srv:loadbalance://localhost:3306/r2dbc", + }) + void anyAvailable(String url) { + // 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(setupUrlAndCredentials(url))) + .create() + .flatMapMany(connection -> connection.validate(ValidationDepth.REMOTE) + .onErrorReturn(false) + .concatWith(connection.close().then(Mono.empty()))) + .as(StepVerifier::create) + .expectNext(true) + .verifyComplete(); + } + + private static ConnectionFactoryOptions setupUrlAndCredentials(String url) { + String password = System.getProperty("test.mysql.password"); + + assertThat(password).withFailMessage("Property test.mysql.password must exists and not be empty") + .isNotNull() + .isNotEmpty(); + + return ConnectionFactoryOptions.parse(url).mutate() + .option(ConnectionFactoryOptions.USER, "root") + .option(ConnectionFactoryOptions.PASSWORD, password) + .option(ConnectionFactoryOptions.CONNECT_TIMEOUT, Duration.ofSeconds(3)) + .build(); + } +} 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 99da15e3c..3ad60648c 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 @@ -2,6 +2,7 @@ import com.zaxxer.hikari.HikariDataSource; import io.asyncer.r2dbc.mysql.api.MySqlResult; +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; @@ -307,22 +308,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) + ) + ); } }