diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java index 0adc250ed3d..17819a5c993 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java @@ -36,6 +36,7 @@ import io.helidon.nima.http2.Http2Headers; import io.helidon.nima.webclient.ClientConnection; import io.helidon.nima.webclient.ClientRequest; +import io.helidon.nima.webclient.ConnectionKey; import io.helidon.nima.webclient.UriHelper; class ClientRequestImpl implements Http2ClientRequest { @@ -217,7 +218,12 @@ private Http2Headers prepareHeaders(WritableHeaders headers) { private Http2ClientStream reserveStream() { if (explicitConnection == null) { - ConnectionKey connectionKey = new ConnectionKey(uri.scheme(), uri.authority(), uri.host(), uri.port(), tls); + ConnectionKey connectionKey = new ConnectionKey(uri.scheme(), + uri.host(), + uri.port(), + tls, + client.dnsResolver(), + client.dnsAddressLookup()); // this statement locks all threads - must not do anything complicated (just create a new instance) return CHANNEL_CACHE.computeIfAbsent(connectionKey, diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnection.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnection.java index 0ce81c63527..68fa36f01d1 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnection.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnection.java @@ -19,6 +19,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; import java.security.cert.Certificate; @@ -37,6 +38,8 @@ import io.helidon.common.socket.SocketWriter; import io.helidon.common.socket.TlsSocket; import io.helidon.nima.http2.Http2ConnectionWriter; +import io.helidon.nima.webclient.ConnectionKey; +import io.helidon.nima.webclient.spi.DnsResolver; import static java.lang.System.Logger.Level.DEBUG; import static java.lang.System.Logger.Level.TRACE; @@ -109,9 +112,14 @@ private void doConnect() throws IOException { socket = sslSocket == null ? new Socket() : sslSocket; socketOptions.configureSocket(socket); - socket.connect(new InetSocketAddress(connectionKey.host(), - connectionKey.port()), - (int) socketOptions.connectTimeout().toMillis()); + DnsResolver dnsResolver = connectionKey.dnsResolver(); + if (dnsResolver.useDefaultJavaResolver()) { + socket.connect(new InetSocketAddress(connectionKey.host(), connectionKey.port()), + (int) socketOptions.connectTimeout().toMillis()); + } else { + InetAddress address = dnsResolver.resolveAddress(connectionKey.host(), connectionKey.dnsAddressLookup()); + socket.connect(new InetSocketAddress(address, connectionKey.port()), (int) socketOptions.connectTimeout().toMillis()); + } channelId = "0x" + HexFormat.of().toHexDigits(System.identityHashCode(socket)); helidonSocket = sslSocket == null diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnectionHandler.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnectionHandler.java index c77a467ddea..8d7eed678b9 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnectionHandler.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnectionHandler.java @@ -23,6 +23,7 @@ import java.util.concurrent.atomic.AtomicReference; import io.helidon.common.socket.SocketOptions; +import io.helidon.nima.webclient.ConnectionKey; // a representation of a single remote endpoint // this may use one or more connections (depending on parallel streams) diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ConnectionKey.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ConnectionKey.java new file mode 100644 index 00000000000..1c26859a261 --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ConnectionKey.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import io.helidon.nima.common.tls.Tls; +import io.helidon.nima.webclient.spi.DnsResolver; + +/** + * Connection key instance contains all needed connection related information. + * + * @param scheme uri address scheme + * @param host uri address host + * @param port uri address port + * @param tls TLS to be used in connection + * @param dnsResolver DNS resolver to be used + * @param dnsAddressLookup DNS address lookup strategy + */ +public record ConnectionKey(String scheme, + String host, + int port, + Tls tls, + DnsResolver dnsResolver, + DnsAddressLookup dnsAddressLookup) { } diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DefaultDnsAddressLookupFinder.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DefaultDnsAddressLookupFinder.java new file mode 100644 index 00000000000..f78feedaa1b --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DefaultDnsAddressLookupFinder.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; + +import io.helidon.common.LazyValue; + +/** + * Heavily inspired by Netty. + */ +final class DefaultDnsAddressLookupFinder { + + private static final System.Logger LOGGER = System.getLogger(DefaultDnsAddressLookupFinder.class.getName()); + + /** + * {@code true} if IPv4 should be used even if the system supports both IPv4 and IPv6. + */ + private static final LazyValue IPV4_PREFERRED = LazyValue.create(() -> { + return Boolean.getBoolean("java.net.preferIPv4Stack"); + }); + + /** + * {@code true} if an IPv6 address should be preferred when a host has both an IPv4 address and an IPv6 address. + */ + private static final LazyValue IPV6_PREFERRED = LazyValue.create(() -> { + return Boolean.getBoolean("java.net.preferIPv6Addresses"); + }); + + private static final LazyValue DEFAULT_IP_VERSION = LazyValue.create(() -> { + if (IPV4_PREFERRED.get() || !anyInterfaceSupportsIpV6()) { + return DnsAddressLookup.IPV4; + } else { + if (IPV6_PREFERRED.get()) { + return DnsAddressLookup.IPV6_PREFERRED; + } else { + return DnsAddressLookup.IPV4_PREFERRED; + } + } + }); + + private DefaultDnsAddressLookupFinder() { + throw new IllegalStateException("This class should not be instantiated"); + } + + static DnsAddressLookup defaultDnsAddressLookup() { + return DEFAULT_IP_VERSION.get(); + } + + /** + * Returns {@code true} if any {@link NetworkInterface} supports {@code IPv6}, {@code false} otherwise. + */ + private static boolean anyInterfaceSupportsIpV6() { + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface networkInterface = interfaces.nextElement(); + Enumeration addresses = networkInterface.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress inetAddress = addresses.nextElement(); + if (inetAddress instanceof Inet6Address + && !inetAddress.isAnyLocalAddress() + && !inetAddress.isLoopbackAddress() + && !inetAddress.isLinkLocalAddress()) { + return true; + } + } + } + } catch (SocketException ignore) { + if (LOGGER.isLoggable(System.Logger.Level.INFO)) { + LOGGER.log(System.Logger.Level.INFO, + "Unable to detect if any interface supports IPv6, assuming IPv4-only", + ignore); + } + } + return false; + } + +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DefaultDnsResolver.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DefaultDnsResolver.java new file mode 100644 index 00000000000..3b933f35f1e --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DefaultDnsResolver.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.Map; + +import io.helidon.nima.webclient.spi.DnsResolver; + +final class DefaultDnsResolver implements DnsResolver { + + private final Map hostnameAddresses = new HashMap<>(); + + @Override + public InetAddress resolveAddress(String hostname, DnsAddressLookup dnsAddressLookup) { + return hostnameAddresses.computeIfAbsent(hostname, host -> { + try { + InetAddress[] processed = dnsAddressLookup.filter(InetAddress.getAllByName(hostname)); + if (processed.length == 0) { + throw new RuntimeUnknownHostException("No IP version " + dnsAddressLookup.name() + " found for host " + host); + } + return processed[0]; + } catch (UnknownHostException e) { + throw new RuntimeUnknownHostException(e); + } + }); + } + +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DefaultDnsResolverProvider.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DefaultDnsResolverProvider.java new file mode 100644 index 00000000000..6489abb4f3c --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DefaultDnsResolverProvider.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import io.helidon.common.Weight; +import io.helidon.common.Weighted; +import io.helidon.nima.webclient.spi.DnsResolver; +import io.helidon.nima.webclient.spi.DnsResolverProvider; + +/** + * Provider of the {@link DefaultDnsResolver} instance. + */ +@Weight(Weighted.DEFAULT_WEIGHT) +public class DefaultDnsResolverProvider implements DnsResolverProvider { + + /** + * Create new instance of the {@link DefaultDnsResolverProvider}. + * This should be used only for purposes of SPI. + */ + public DefaultDnsResolverProvider() { + } + + @Override + public String resolverName() { + return "default"; + } + + @Override + public DnsResolver createDnsResolver() { + return new DefaultDnsResolver(); + } +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DnsAddressLookup.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DnsAddressLookup.java new file mode 100644 index 00000000000..ac6281168c7 --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/DnsAddressLookup.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import java.io.Serializable; +import java.net.InetAddress; +import java.util.Arrays; +import java.util.Comparator; +import java.util.function.Function; + +/** + * DNS address lookup strategy. + */ +public enum DnsAddressLookup { + + /** + * Only IPv4 addresses will be used. + */ + IPV4(new Ipv4Only()), + + /** + * Only IPv6 addresses will be used. + */ + IPV6(new Ipv6Only()), + + /** + * Both IPv4 and IPv6 addresses will be used, but if there are any IPv4, they take precedence. + */ + IPV4_PREFERRED(new Ipv4Preferred()), + + /** + * Both IPv4 and IPv6 addresses will be used, but if there are any IPv6, they take precedence. + */ + IPV6_PREFERRED(new Ipv6Preferred()); + + private static final InetAddressComparator COMPARATOR = new InetAddressComparator(); + private final Function function; + + DnsAddressLookup(Function function) { + this.function = function; + } + + /** + * Process obtained {@link InetAddress} array according to the selected lookup strategy. + * + * @param addresses addresses to be processed + * @return processed array + */ + public InetAddress[] filter(InetAddress[] addresses) { + return function.apply(addresses); + } + + private static class Ipv4Only implements Function { + + @Override + public InetAddress[] apply(InetAddress[] addresses) { + return Arrays.stream(addresses) + .filter(DnsAddressLookup::isIPv4) + .toArray(InetAddress[]::new); + } + } + + private static class Ipv6Only implements Function { + + @Override + public InetAddress[] apply(InetAddress[] addresses) { + return Arrays.stream(addresses) + .filter(DnsAddressLookup::isIPv6) + .toArray(InetAddress[]::new); + } + } + + private static class Ipv4Preferred implements Function { + + @Override + public InetAddress[] apply(InetAddress[] addresses) { + InetAddress[] copy = Arrays.copyOfRange(addresses, 0, addresses.length); + Arrays.sort(copy, COMPARATOR); + return copy; + } + } + + private static class Ipv6Preferred implements Function { + + @Override + public InetAddress[] apply(InetAddress[] addresses) { + InetAddress[] copy = Arrays.copyOfRange(addresses, 0, addresses.length); + Arrays.sort(copy, (o1, o2) -> COMPARATOR.compare(o1, o2) * -1); + return copy; + } + } + + private static final class InetAddressComparator implements Comparator, Serializable { + @Override + public int compare(InetAddress o1, InetAddress o2) { + //sorts IPv4 to be the first by default + if (isIPv4(o1)) { + if (isIPv4(o2)) { + return 0; + } else { + return -1; + } + } else { + if (isIPv6(o2)) { + return 0; + } else { + return 1; + } + } + } + } + + private static boolean isIPv4(InetAddress address) { + return address.getAddress().length == 4; + } + + private static boolean isIPv6(InetAddress address) { + return address.getAddress().length == 16; + } + +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/LoomClient.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/LoomClient.java index 806f36ab252..33dedb467a8 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/LoomClient.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/LoomClient.java @@ -17,17 +17,25 @@ package io.helidon.nima.webclient; import java.net.URI; +import java.util.List; +import java.util.ServiceLoader; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import io.helidon.common.HelidonServiceLoader; import io.helidon.common.LazyValue; import io.helidon.common.socket.SocketOptions; import io.helidon.nima.common.tls.Tls; +import io.helidon.nima.webclient.spi.DnsResolver; +import io.helidon.nima.webclient.spi.DnsResolverProvider; /** * Base class for HTTP implementations of {@link io.helidon.nima.webclient.WebClient}. */ public class LoomClient implements WebClient { + + private static final List DNS_RESOLVER_PROVIDERS = HelidonServiceLoader + .builder(ServiceLoader.load(DnsResolverProvider.class)).build().asList(); private static final LazyValue EMPTY_TLS = LazyValue.create(() -> Tls.builder().build()); private static final SocketOptions EMPTY_OPTIONS = SocketOptions.builder().build(); private static final LazyValue EXECUTOR = LazyValue.create(() -> { @@ -38,6 +46,8 @@ public class LoomClient implements WebClient { private final URI uri; private final Tls tls; private final SocketOptions channelOptions; + private final DnsResolver dnsResolver; + private final DnsAddressLookup dnsAddressLookup; /** * Construct this instance from a subclass of builder. @@ -48,6 +58,17 @@ protected LoomClient(WebClient.Builder builder) { this.uri = builder.baseUri(); this.tls = builder.tls() == null ? EMPTY_TLS.get() : builder.tls(); this.channelOptions = builder.channelOptions() == null ? EMPTY_OPTIONS : builder.channelOptions(); + if (builder.dnsResolver() == null) { + this.dnsResolver = DNS_RESOLVER_PROVIDERS.stream() + .findFirst() + .orElse(new DefaultDnsResolverProvider()) + .createDnsResolver(); + } else { + this.dnsResolver = builder.dnsResolver(); + } + this.dnsAddressLookup = builder.dnsAddressLookup() == null + ? DefaultDnsAddressLookupFinder.defaultDnsAddressLookup() + : builder.dnsAddressLookup(); } /** @@ -85,4 +106,22 @@ public Tls tls() { public SocketOptions socketOptions() { return channelOptions; } + + /** + * DNS resolver instance to be used for this client. + * + * @return dns resolver instance + */ + public DnsResolver dnsResolver() { + return dnsResolver; + } + + /** + * + * + * @return DNS address lookup instance type + */ + public DnsAddressLookup dnsAddressLookup() { + return dnsAddressLookup; + } } diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/NoDnsResolver.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/NoDnsResolver.java new file mode 100644 index 00000000000..284798a4fda --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/NoDnsResolver.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import java.net.InetAddress; + +import io.helidon.nima.webclient.spi.DnsResolver; + +/** + * No Nima specific DNS resolver to be used. Connection creation fallbacks to the standard Java approach. + */ +public final class NoDnsResolver implements DnsResolver { + + private NoDnsResolver() { + } + + /** + * Create new instance. + * + * @return new instance + */ + public static NoDnsResolver create() { + return new NoDnsResolver(); + } + + @Override + public boolean useDefaultJavaResolver() { + return true; + } + + @Override + public InetAddress resolveAddress(String hostname, DnsAddressLookup dnsAddressLookup) { + throw new IllegalStateException("This DNS resolver is not meant to be used."); + } + +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/NoDnsResolverProvider.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/NoDnsResolverProvider.java new file mode 100644 index 00000000000..50b8cec0163 --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/NoDnsResolverProvider.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import io.helidon.common.Weight; +import io.helidon.nima.webclient.spi.DnsResolver; +import io.helidon.nima.webclient.spi.DnsResolverProvider; + +/** + * Provider of the {@link NoDnsResolver} instance. + */ +@Weight(10) +public class NoDnsResolverProvider implements DnsResolverProvider { + + /** + * Create new instance of the {@link NoDnsResolverProvider}. + * This should be used only for purposes of SPI. + */ + public NoDnsResolverProvider() { + } + + @Override + public String resolverName() { + return "no-resolver"; + } + + @Override + public DnsResolver createDnsResolver() { + return NoDnsResolver.create(); + } +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/RoundRobinDnsResolver.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/RoundRobinDnsResolver.java new file mode 100644 index 00000000000..2918844474f --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/RoundRobinDnsResolver.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import io.helidon.nima.webclient.spi.DnsResolver; + +/** + * Round-robin DNS resolver implementation. + */ +public final class RoundRobinDnsResolver implements DnsResolver { + + private final Map hostnameAddresses = new HashMap<>(); + + private RoundRobinDnsResolver() { + } + + /** + * Create new instance. + * + * @return new instance + */ + public static RoundRobinDnsResolver create() { + return new RoundRobinDnsResolver(); + } + + @Override + public InetAddress resolveAddress(String hostname, DnsAddressLookup dnsAddressLookup) { + HostnameAddresses hostnameAddress = hostnameAddresses.computeIfAbsent(hostname, host -> { + try { + InetAddress[] processed = dnsAddressLookup.filter(InetAddress.getAllByName(host)); + if (processed.length == 0) { + throw new RuntimeUnknownHostException("No IP version " + dnsAddressLookup.name() + " found for host " + host); + } + return new HostnameAddresses(new AtomicInteger(), processed); + } catch (UnknownHostException e) { + throw new RuntimeUnknownHostException(e); + } + }); + return hostnameAddress.addresses[hostnameAddress.count.getAndIncrement() % hostnameAddress.addresses.length]; + } + + private record HostnameAddresses(AtomicInteger count, InetAddress[] addresses) { + } + +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/RoundRobinDnsResolverProvider.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/RoundRobinDnsResolverProvider.java new file mode 100644 index 00000000000..5f8b30db9bf --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/RoundRobinDnsResolverProvider.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import io.helidon.common.Weight; +import io.helidon.nima.webclient.spi.DnsResolver; +import io.helidon.nima.webclient.spi.DnsResolverProvider; + +/** + * Provider of the {@link RoundRobinDnsResolver} instance. + */ +@Weight(50) +public final class RoundRobinDnsResolverProvider implements DnsResolverProvider { + + /** + * Create new instance of the {@link RoundRobinDnsResolverProvider}. + * This should be used only for purposes of SPI. + */ + public RoundRobinDnsResolverProvider() { + } + + @Override + public String resolverName() { + return "round-robin"; + } + + @Override + public DnsResolver createDnsResolver() { + return RoundRobinDnsResolver.create(); + } +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/RuntimeUnknownHostException.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/RuntimeUnknownHostException.java new file mode 100644 index 00000000000..22bb86c2c28 --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/RuntimeUnknownHostException.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient; + +import java.net.UnknownHostException; + +/** + * Runtime variant of the {@link UnknownHostException} exception. + */ +public class RuntimeUnknownHostException extends RuntimeException { + + /** + * Create new instance based on the {@link UnknownHostException} exception. + * @param e unknown host exception + */ + RuntimeUnknownHostException(UnknownHostException e) { + super(e); + } + + /** + * Create new instance based on the {@link String} message. + * @param message exception message + */ + RuntimeUnknownHostException(String message) { + super(message); + } +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/WebClient.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/WebClient.java index d55d77ec83c..30d56dde476 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/WebClient.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/WebClient.java @@ -23,6 +23,7 @@ import io.helidon.nima.common.tls.Tls; import io.helidon.nima.webclient.http1.Http1; import io.helidon.nima.webclient.http1.Http1Client; +import io.helidon.nima.webclient.spi.DnsResolver; import io.helidon.nima.webclient.spi.Protocol; /** @@ -61,6 +62,8 @@ abstract class Builder, C extends WebClient> implements private URI baseUri; private Tls tls; private SocketOptions channelOptions; + private DnsResolver dnsResolver; + private DnsAddressLookup dnsAddressLookup; /** * Common builder base for all the client builder. @@ -126,6 +129,28 @@ public B channelOptions(SocketOptions channelOptions) { return (B) this; } + /** + * DNS resolver to be used by this client. + * + * @param dnsResolver dns resolver + * @return updated builder + */ + public B dnsResolver(DnsResolver dnsResolver) { + this.dnsResolver = dnsResolver; + return (B) this; + } + + /** + * DNS address lookup preferences to be used by this client. + * + * @param dnsAddressLookup dns address lookup strategy + * @return updated builder + */ + public B dnsAddressLookup(DnsAddressLookup dnsAddressLookup) { + this.dnsAddressLookup = dnsAddressLookup; + return (B) this; + } + /** * Channel options. * @@ -152,5 +177,14 @@ Tls tls() { URI baseUri() { return baseUri; } + + DnsResolver dnsResolver() { + return dnsResolver; + } + + DnsAddressLookup dnsAddressLookup() { + return dnsAddressLookup; + } + } } diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java index d9c266ec027..1b811a262ed 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java @@ -48,6 +48,7 @@ import io.helidon.nima.http.media.EntityWriter; import io.helidon.nima.http.media.MediaContext; import io.helidon.nima.webclient.ClientConnection; +import io.helidon.nima.webclient.ConnectionKey; import io.helidon.nima.webclient.UriHelper; import static java.lang.System.Logger.Level.DEBUG; @@ -386,9 +387,12 @@ private ClientConnection getConnection(boolean keepAlive) { if (connection == null) { connection = new Http1ClientConnection(channelOptions, connectionQueue, - uri.host(), - uri.port(), - tls).connect(); + new ConnectionKey(uri.scheme(), + uri.host(), + uri.port(), + tls, + client.dnsResolver(), + client.dnsAddressLookup())).connect(); } else { if (LOGGER.isLoggable(DEBUG)) { LOGGER.log(DEBUG, String.format("[%s] client connection obtained %s", @@ -397,7 +401,12 @@ private ClientConnection getConnection(boolean keepAlive) { } } } else { - connection = new Http1ClientConnection(channelOptions, uri.host(), uri.port(), tls).connect(); + connection = new Http1ClientConnection(channelOptions, new ConnectionKey(uri.scheme(), + uri.host(), + uri.port(), + tls, + client.dnsResolver(), + client.dnsAddressLookup())).connect(); } return connection; } diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java index a90b8bd0fd0..f7c15b8e2e9 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; import java.security.cert.Certificate; @@ -36,8 +37,9 @@ import io.helidon.common.socket.PlainSocket; import io.helidon.common.socket.SocketOptions; import io.helidon.common.socket.TlsSocket; -import io.helidon.nima.common.tls.Tls; import io.helidon.nima.webclient.ClientConnection; +import io.helidon.nima.webclient.ConnectionKey; +import io.helidon.nima.webclient.spi.DnsResolver; import static java.lang.System.Logger.Level.DEBUG; import static java.lang.System.Logger.Level.TRACE; @@ -46,32 +48,26 @@ class Http1ClientConnection implements ClientConnection { private static final System.Logger LOGGER = System.getLogger(Http1ClientConnection.class.getName()); private final Queue connectionQueue; - private final String host; - private final int port; - private final Tls tls; + private final ConnectionKey connectionKey; private final io.helidon.common.socket.SocketOptions options; + private final boolean keepAlive; private String channelId; - private boolean keepAlive; private Socket socket; private HelidonSocket helidonSocket; private DataReader reader; private DataWriter writer; - Http1ClientConnection(SocketOptions options, String host, int port, Tls tls) { - this(options, null, host, port, tls); + Http1ClientConnection(SocketOptions options, ConnectionKey connectionKey) { + this(options, null, connectionKey); } Http1ClientConnection(SocketOptions options, Queue connectionQueue, - String host, - int port, - Tls tls) { + ConnectionKey connectionKey) { this.options = options; this.connectionQueue = connectionQueue; - this.host = host; - this.port = port; this.keepAlive = (connectionQueue != null); - this.tls = tls; + this.connectionKey = connectionKey; } @Override @@ -109,13 +105,19 @@ boolean isConnected() { Http1ClientConnection connect() { try { - SSLSocket sslSocket = tls == null ? null : tls.createSocket("http/1.1"); + SSLSocket sslSocket = connectionKey.tls() == null ? null : connectionKey.tls().createSocket("http/1.1"); socket = sslSocket == null ? new Socket() : sslSocket; socket.setSoTimeout((int) options.readTimeout().toMillis()); options.configureSocket(socket); - socket.connect(new InetSocketAddress(host, port), - (int) options.connectTimeout().toMillis()); + DnsResolver dnsResolver = connectionKey.dnsResolver(); + if (dnsResolver.useDefaultJavaResolver()) { + socket.connect(new InetSocketAddress(connectionKey.host(), connectionKey.port()), + (int) options.connectTimeout().toMillis()); + } else { + InetAddress address = dnsResolver.resolveAddress(connectionKey.host(), connectionKey.dnsAddressLookup()); + socket.connect(new InetSocketAddress(address, connectionKey.port()), (int) options.connectTimeout().toMillis()); + } channelId = "0x" + HexFormat.of().toHexDigits(System.identityHashCode(socket)); @@ -129,7 +131,7 @@ Http1ClientConnection connect() { } } } catch (IOException e) { - throw new UncheckedIOException("Could not connect to " + host + ":" + port, e); + throw new UncheckedIOException("Could not connect to " + connectionKey.host() + ":" + connectionKey.port(), e); } if (LOGGER.isLoggable(DEBUG)) { diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/spi/DnsResolver.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/spi/DnsResolver.java new file mode 100644 index 00000000000..bc3ce4d0c8d --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/spi/DnsResolver.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * 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 + * + * http://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.helidon.nima.webclient.spi; + +import java.net.InetAddress; + +import io.helidon.nima.webclient.DnsAddressLookup; + +/** + * DNS resolving interface. + */ +public interface DnsResolver { + + /** + * Whether to use standard Java DNS resolver. + * If this method returns true, {@link #resolveAddress(String, DnsAddressLookup)} method is not invoked and + * no {@link DnsAddressLookup} preferences will be applied. + * + * @return use standard Java resolver + */ + default boolean useDefaultJavaResolver() { + return false; + } + + /** + * Resolve hostname to {@link InetAddress}. + * + * @param hostname hostname to resolve + * @param dnsAddressLookup allowed version of the IP + * @return resolved InetAddress instance + */ + InetAddress resolveAddress(String hostname, DnsAddressLookup dnsAddressLookup); + +} diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ConnectionKey.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/spi/DnsResolverProvider.java similarity index 58% rename from nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ConnectionKey.java rename to nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/spi/DnsResolverProvider.java index cfb9e35aaad..8f55e137365 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ConnectionKey.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/spi/DnsResolverProvider.java @@ -14,12 +14,25 @@ * limitations under the License. */ -package io.helidon.nima.http2.webclient; +package io.helidon.nima.webclient.spi; -import io.helidon.nima.common.tls.Tls; +/** + * Provider interface for custom DNS resolvers. + */ +public interface DnsResolverProvider { + + /** + * Return name of the {@link DnsResolver}. + * + * @return dns resolver name + */ + String resolverName(); + + /** + * Create new instance of the {@link DnsResolver}. + * + * @return new instance of the dns resolver + */ + DnsResolver createDnsResolver(); -record ConnectionKey(String scheme, - String authority, - String host, - int port, - Tls tls) { } +} diff --git a/nima/webclient/webclient/src/main/java/module-info.java b/nima/webclient/webclient/src/main/java/module-info.java index da71728cdc0..bc42f764660 100644 --- a/nima/webclient/webclient/src/main/java/module-info.java +++ b/nima/webclient/webclient/src/main/java/module-info.java @@ -14,6 +14,11 @@ * limitations under the License. */ +import io.helidon.nima.webclient.DefaultDnsResolverProvider; +import io.helidon.nima.webclient.RoundRobinDnsResolverProvider; +import io.helidon.nima.webclient.NoDnsResolverProvider; +import io.helidon.nima.webclient.spi.DnsResolverProvider; + /** * WebClient API and HTTP/1.1 implementation. */ @@ -33,4 +38,7 @@ This module exposes two packages, as we (want to) have cyclic dependency. implementation available. The HTTP/1.1 client then implements the WebClient API... */ exports io.helidon.nima.webclient.http1; + + uses DnsResolverProvider; + provides DnsResolverProvider with RoundRobinDnsResolverProvider, DefaultDnsResolverProvider, NoDnsResolverProvider; } \ No newline at end of file