From 9d14af5a8b7a86e742764546d9d95b39e709c6cb Mon Sep 17 00:00:00 2001 From: Andrew Steinborn Date: Fri, 24 May 2019 01:01:33 -0400 Subject: [PATCH] Add Metrics to Velocity Velocity will send metrics to bStats.org. These statistics are publicly viewable at https://bstats.org/plugin/server-implementation/Velocity You can always opt-out by disabling metrics in your velocity.toml. There is no obligation to allow us to collect metrics, but you can show your support by leaving metrics on. --- .../com/velocitypowered/proxy/Metrics.java | 623 ++++++++++++++++++ .../velocitypowered/proxy/VelocityServer.java | 6 + .../proxy/config/VelocityConfiguration.java | 73 +- .../proxy/network/ConnectionManager.java | 4 + .../proxy/network/http/NettyHttpClient.java | 92 ++- 5 files changed, 780 insertions(+), 18 deletions(-) create mode 100644 proxy/src/main/java/com/velocitypowered/proxy/Metrics.java diff --git a/proxy/src/main/java/com/velocitypowered/proxy/Metrics.java b/proxy/src/main/java/com/velocitypowered/proxy/Metrics.java new file mode 100644 index 000000000..b5de66eec --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/Metrics.java @@ -0,0 +1,623 @@ +package com.velocitypowered.proxy; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.velocitypowered.proxy.config.VelocityConfiguration; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufOutputStream; +import io.netty.buffer.ByteBufUtil; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http.HttpHeaderNames; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.Callable; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPOutputStream; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * bStats collects some data for plugin authors. + *

+ * Check out https://bStats.org/ to learn more about bStats! + */ +public class Metrics { + + // The version of this bStats class + private static final int B_STATS_VERSION = 1; + + // The url to which the data is sent + private static final String URL = "https://bstats.org/submitData/server-implementation"; + + // Should failed requests be logged? + private static boolean logFailedRequests = false; + + // The logger for the failed requests + private static Logger logger = LogManager.getLogger(Metrics.class); + + // The name of the server software + private final String name; + + // The uuid of the server + private final String serverUuid; + + // A list with all custom charts + private final List charts = new ArrayList<>(); + + private final VelocityServer server; + + /** + * Class constructor. + * @param name The name of the server software. + * @param serverUuid The uuid of the server. + * @param logFailedRequests Whether failed requests should be logged or not. + * @param server The Velocity server instance. + */ + private Metrics(String name, String serverUuid, boolean logFailedRequests, + VelocityServer server) { + this.name = name; + this.serverUuid = serverUuid; + Metrics.logFailedRequests = logFailedRequests; + this.server = server; + + // Start submitting the data + startSubmitting(); + } + + /** + * Adds a custom chart. + * + * @param chart The chart to add. + */ + public void addCustomChart(CustomChart chart) { + if (chart == null) { + throw new IllegalArgumentException("Chart cannot be null!"); + } + charts.add(chart); + } + + /** + * Starts the Scheduler which submits our data every 30 minutes. + */ + private void startSubmitting() { + final Timer timer = new Timer(true); + timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + submitData(); + } + }, 1000, 1000 * 60 * 30); + // Submit the data every 30 minutes, first time after 5 minutes to give other plugins enough + // time to start. + // + // WARNING: Changing the frequency has no effect but your plugin WILL be blocked/deleted! + // WARNING: Just don't do it! + } + + /** + * Gets the plugin specific data. + * + * @return The plugin specific data. + */ + private JsonObject getPluginData() { + JsonObject data = new JsonObject(); + + data.addProperty("pluginName", name); // Append the name of the server software + JsonArray customCharts = new JsonArray(); + for (CustomChart customChart : charts) { + // Add the data of the custom charts + JsonObject chart = customChart.getRequestJsonObject(); + if (chart == null) { // If the chart is null, we skip it + continue; + } + customCharts.add(chart); + } + data.add("customCharts", customCharts); + + return data; + } + + /** + * Gets the server specific data. + * + * @return The server specific data. + */ + private JsonObject getServerData() { + // OS specific data + String osName = System.getProperty("os.name"); + String osArch = System.getProperty("os.arch"); + String osVersion = System.getProperty("os.version"); + int coreCount = Runtime.getRuntime().availableProcessors(); + + JsonObject data = new JsonObject(); + + data.addProperty("serverUUID", serverUuid); + + data.addProperty("osName", osName); + data.addProperty("osArch", osArch); + data.addProperty("osVersion", osVersion); + data.addProperty("coreCount", coreCount); + + return data; + } + + /** + * Collects the data and sends it afterwards. + */ + private void submitData() { + final JsonObject data = getServerData(); + + JsonArray pluginData = new JsonArray(); + pluginData.add(getPluginData()); + data.add("plugins", pluginData); + + try { + // We are still in the Thread of the timer, so nothing get blocked :) + sendData(data); + } catch (Exception e) { + // Something went wrong! :( + if (logFailedRequests) { + logger.warn("Could not submit stats of {}", name, e); + } + } + } + + /** + * Sends the data to the bStats server. + * + * @param data The data to send. + * @throws Exception If the request failed. + */ + private void sendData(JsonObject data) throws Exception { + if (data == null) { + throw new IllegalArgumentException("Data cannot be null!"); + } + + // Compress the data to save bandwidth + ByteBuf reqBody = createResponseBody(data); + + server.getHttpClient().post(new URL(URL), reqBody, request -> { + request.headers().add(HttpHeaderNames.CONTENT_ENCODING, "gzip"); + request.headers().add(HttpHeaderNames.ACCEPT, "application/json"); + request.headers().add(HttpHeaderNames.CONTENT_TYPE, "application/json"); + }) + .whenCompleteAsync((resp, exc) -> { + if (logFailedRequests) { + if (exc != null) { + logger.error("Unable to send metrics to bStats", exc); + } else if (resp.getCode() != 429) { + logger.error("Got HTTP status code {} when sending metrics to bStats", + resp.getCode()); + } + } + }); + } + + private static ByteBuf createResponseBody(JsonObject object) throws IOException { + ByteBuf buf = Unpooled.buffer(); + try (Writer writer = + new BufferedWriter( + new OutputStreamWriter( + new GZIPOutputStream(new ByteBufOutputStream(buf)), StandardCharsets.UTF_8 + ) + ) + ) { + VelocityServer.GSON.toJson(object, writer); + } catch (IOException e) { + buf.release(); + throw e; + } + return buf; + } + + /** + * Represents a custom chart. + */ + public abstract static class CustomChart { + + // The id of the chart + final String chartId; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + */ + CustomChart(String chartId) { + if (chartId == null || chartId.isEmpty()) { + throw new IllegalArgumentException("ChartId cannot be null or empty!"); + } + this.chartId = chartId; + } + + private JsonObject getRequestJsonObject() { + JsonObject chart = new JsonObject(); + chart.addProperty("chartId", chartId); + try { + JsonObject data = getChartData(); + if (data == null) { + // If the data is null we don't send the chart. + return null; + } + chart.add("data", data); + } catch (Throwable t) { + if (logFailedRequests) { + logger.warn("Failed to get data for custom chart with id {}", chartId, t); + } + return null; + } + return chart; + } + + protected abstract JsonObject getChartData() throws Exception; + + } + + /** + * Represents a custom simple pie. + */ + public static class SimplePie extends CustomChart { + + private final Callable callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public SimplePie(String chartId, Callable callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObject getChartData() throws Exception { + JsonObject data = new JsonObject(); + String value = callable.call(); + if (value == null || value.isEmpty()) { + // Null = skip the chart + return null; + } + data.addProperty("value", value); + return data; + } + } + + /** + * Represents a custom advanced pie. + */ + public static class AdvancedPie extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public AdvancedPie(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObject getChartData() throws Exception { + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + + JsonObject data = new JsonObject(); + JsonObject values = new JsonObject(); + boolean allSkipped = true; + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() == 0) { + continue; // Skip this invalid + } + allSkipped = false; + values.addProperty(entry.getKey(), entry.getValue()); + } + if (allSkipped) { + // Null = skip the chart + return null; + } + data.add("values", values); + return data; + } + } + + /** + * Represents a custom drilldown pie. + */ + public static class DrilldownPie extends CustomChart { + + private final Callable>> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public DrilldownPie(String chartId, Callable>> callable) { + super(chartId); + this.callable = callable; + } + + @Override + public JsonObject getChartData() throws Exception { + Map> map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean reallyAllSkipped = true; + JsonObject data = new JsonObject(); + JsonObject values = new JsonObject(); + for (Map.Entry> entryValues : map.entrySet()) { + JsonObject value = new JsonObject(); + boolean allSkipped = true; + for (Map.Entry valueEntry : map.get(entryValues.getKey()).entrySet()) { + value.addProperty(valueEntry.getKey(), valueEntry.getValue()); + allSkipped = false; + } + if (!allSkipped) { + reallyAllSkipped = false; + values.add(entryValues.getKey(), value); + } + } + if (reallyAllSkipped) { + // Null = skip the chart + return null; + } + data.add("values", values); + return data; + } + } + + /** + * Represents a custom single line chart. + */ + public static class SingleLineChart extends CustomChart { + + private final Callable callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public SingleLineChart(String chartId, Callable callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObject getChartData() throws Exception { + JsonObject data = new JsonObject(); + int value = callable.call(); + if (value == 0) { + // Null = skip the chart + return null; + } + data.addProperty("value", value); + return data; + } + + } + + /** + * Represents a custom multi line chart. + */ + public static class MultiLineChart extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public MultiLineChart(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObject getChartData() throws Exception { + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + JsonObject data = new JsonObject(); + JsonObject values = new JsonObject(); + boolean allSkipped = true; + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() == 0) { + continue; // Skip this invalid + } + allSkipped = false; + values.addProperty(entry.getKey(), entry.getValue()); + } + if (allSkipped) { + // Null = skip the chart + return null; + } + data.add("values", values); + return data; + } + + } + + /** + * Represents a custom simple bar chart. + */ + public static class SimpleBarChart extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public SimpleBarChart(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObject getChartData() throws Exception { + JsonObject data = new JsonObject(); + JsonObject values = new JsonObject(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + for (Map.Entry entry : map.entrySet()) { + JsonArray categoryValues = new JsonArray(); + categoryValues.add(entry.getValue()); + values.add(entry.getKey(), categoryValues); + } + data.add("values", values); + return data; + } + + } + + /** + * Represents a custom advanced bar chart. + */ + public static class AdvancedBarChart extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public AdvancedBarChart(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObject getChartData() throws Exception { + JsonObject data = new JsonObject(); + JsonObject values = new JsonObject(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean allSkipped = true; + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue().length == 0) { + continue; // Skip this invalid + } + allSkipped = false; + JsonArray categoryValues = new JsonArray(); + for (int categoryValue : entry.getValue()) { + categoryValues.add(categoryValue); + } + values.add(entry.getKey(), categoryValues); + } + if (allSkipped) { + // Null = skip the chart + return null; + } + data.add("values", values); + return data; + } + + } + + static class VelocityMetrics { + static void startMetrics(VelocityServer server, VelocityConfiguration.Metrics metricsConfig) { + if (!metricsConfig.isFromConfig()) { + // Log an informational message. + logger.info("Velocity collects metrics and sends them to bStats (https://bstats.org)."); + logger.info("bStats collects some basic information like how many people use Velocity and"); + logger.info("their player count. This has no impact on performance and this data does not"); + logger.info("identify your server in any way. However, you may opt-out by editing your"); + logger.info("velocity.toml and setting enabled = false in the [metrics] section."); + } + + // Load the data + String serverUuid = metricsConfig.getId(); + boolean logFailedRequests = metricsConfig.isLogFailure(); + // Only start Metrics, if it's enabled in the config + if (metricsConfig.isEnabled()) { + Metrics metrics = new Metrics("Velocity", serverUuid, logFailedRequests, server); + + metrics.addCustomChart( + new Metrics.SingleLineChart("players", server::getPlayerCount) + ); + metrics.addCustomChart( + new Metrics.SingleLineChart("managed_servers", () -> server.getAllServers().size()) + ); + metrics.addCustomChart( + new Metrics.SimplePie("online_mode", + () -> server.getConfiguration().isOnlineMode() ? "online" : "offline") + ); + metrics.addCustomChart(new Metrics.SimplePie("velocity_version", + () -> server.getVersion().getVersion())); + + metrics.addCustomChart(new Metrics.DrilldownPie("java_version", () -> { + Map> map = new HashMap<>(); + String javaVersion = System.getProperty("java.version"); + Map entry = new HashMap<>(); + entry.put(javaVersion, 1); + + // http://openjdk.java.net/jeps/223 + // Java decided to change their versioning scheme and in doing so modified the + // java.version system property to return $major[.$minor][.$security][-ea], as opposed to + // 1.$major.0_$identifier we can handle pre-9 by checking if the "major" is equal to "1", + // otherwise, 9+ + String majorVersion = javaVersion.split("\\.")[0]; + String release; + + int indexOf = javaVersion.lastIndexOf('.'); + + if (majorVersion.equals("1")) { + release = "Java " + javaVersion.substring(0, indexOf); + } else { + // of course, it really wouldn't be all that simple if they didn't add a quirk, now + // would it valid strings for the major may potentially include values such as -ea to + // denote a pre release + Matcher versionMatcher = Pattern.compile("\\d+").matcher(majorVersion); + if (versionMatcher.find()) { + majorVersion = versionMatcher.group(0); + } + release = "Java " + majorVersion; + } + map.put(release, entry); + + return map; + })); + } + + } + } +} diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 0ef9991a1..5b3b178aa 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -203,6 +203,8 @@ public class VelocityServer implements ProxyServer { if (configuration.isQueryEnabled()) { this.cm.queryBind(configuration.getBind().getHostString(), configuration.getQueryPort()); } + + Metrics.VelocityMetrics.startMetrics(this, configuration.getMetrics()); } @RequiresNonNull({"pluginManager", "eventManager"}) @@ -243,6 +245,10 @@ public class VelocityServer implements ProxyServer { logger.info("Loaded {} plugins", pluginManager.getPlugins().size()); } + public EventLoopGroup getWorkerGroup() { + return this.cm.getWorkerGroup(); + } + public Bootstrap initializeGenericBootstrap() { return this.cm.createWorker(); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java index 4ab5d9c84..0bf9a799b 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/config/VelocityConfiguration.java @@ -21,6 +21,7 @@ import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Random; +import java.util.UUID; import net.kyori.text.Component; import net.kyori.text.serializer.gson.GsonComponentSerializer; import net.kyori.text.serializer.legacy.LegacyComponentSerializer; @@ -88,6 +89,9 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi @Table("[query]") private final Query query; + @Table("[metrics]") + private final Metrics metrics; + @Ignore private @MonotonicNonNull Component motdAsComponent; @@ -95,16 +99,17 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi private @Nullable Favicon favicon; private VelocityConfiguration(Servers servers, ForcedHosts forcedHosts, Advanced advanced, - Query query) { + Query query, Metrics metrics) { this.servers = servers; this.forcedHosts = forcedHosts; this.advanced = advanced; this.query = query; + this.metrics = metrics; } private VelocityConfiguration(String bind, String motd, int showMaxPlayers, boolean onlineMode, boolean announceForge, PlayerInfoForwarding playerInfoForwardingMode, byte[] forwardingSecret, - Servers servers, ForcedHosts forcedHosts, Advanced advanced, Query query) { + Servers servers, ForcedHosts forcedHosts, Advanced advanced, Query query, Metrics metrics) { this.bind = bind; this.motd = motd; this.showMaxPlayers = showMaxPlayers; @@ -116,6 +121,7 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi this.forcedHosts = forcedHosts; this.advanced = advanced; this.query = query; + this.metrics = metrics; } /** @@ -359,6 +365,10 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi return advanced.isProxyProtocol(); } + public Metrics getMetrics() { + return metrics; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -389,7 +399,7 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi if (!path.toFile().exists()) { getLogger().info("No velocity.toml found, creating one for you..."); return new VelocityConfiguration(new Servers(), new ForcedHosts(), new Advanced(), - new Query()); + new Query(), new Metrics()); } else { try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { toml = new Toml().read(reader); @@ -400,13 +410,14 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi ForcedHosts forcedHosts = new ForcedHosts(toml.getTable("forced-hosts")); Advanced advanced = new Advanced(toml.getTable("advanced")); Query query = new Query(toml.getTable("query")); + Metrics metrics = new Metrics(toml.getTable("metrics")); byte[] forwardingSecret = toml.getString("forwarding-secret", "5up3r53cr3t") .getBytes(StandardCharsets.UTF_8); String forwardingModeName = toml.getString("player-info-forwarding-mode", "MODERN") .toUpperCase(Locale.US); - VelocityConfiguration configuration = new VelocityConfiguration( + return new VelocityConfiguration( toml.getString("bind", "0.0.0.0:25577"), toml.getString("motd", "&3A Velocity Server"), toml.getLong("show-max-players", 500L).intValue(), @@ -417,9 +428,9 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi servers, forcedHosts, advanced, - query + query, + metrics ); - return configuration; } private static String generateRandomString(int length) { @@ -699,4 +710,54 @@ public class VelocityConfiguration extends AnnotatedConfig implements ProxyConfi + '}'; } } + + public static class Metrics { + + @Comment({"Whether metrics will be reported to bStats (https://bstats.org).", + "bStats collects some basic information, like how many people use Velocity and their", + "player count. We recommend keeping bStats enabled, but if you're not comfortable with", + "this, you can turn this setting off. There is no performance penalty associated with", + "having metrics enabled, and data sent to bStats can't identify your server."}) + @ConfigKey("enabled") + private boolean enabled = true; + + @Comment("A unique, anonymous ID to identify this proxy with.") + @ConfigKey("id") + private String id = UUID.randomUUID().toString(); + + @ConfigKey("log-failure") + private boolean logFailure = false; + + @Ignore + private boolean fromConfig; + + public Metrics() { + this.fromConfig = false; + } + + public Metrics(Toml toml) { + if (toml != null) { + this.enabled = toml.getBoolean("enabled", false); + this.id = toml.getString("id", UUID.randomUUID().toString()); + this.logFailure = toml.getBoolean("log-failure", false); + this.fromConfig = true; + } + } + + public boolean isEnabled() { + return enabled; + } + + public String getId() { + return id; + } + + public boolean isLogFailure() { + return logFailure; + } + + public boolean isFromConfig() { + return fromConfig; + } + } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java b/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java index e74e3e893..97b0341c6 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/ConnectionManager.java @@ -164,6 +164,10 @@ public final class ConnectionManager { return bossGroup; } + public EventLoopGroup getWorkerGroup() { + return workerGroup; + } + public ServerChannelInitializerHolder getServerChannelInitializer() { return this.serverChannelInitializer; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/network/http/NettyHttpClient.java b/proxy/src/main/java/com/velocitypowered/proxy/network/http/NettyHttpClient.java index f64639957..53ce41b2b 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/network/http/NettyHttpClient.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/network/http/NettyHttpClient.java @@ -1,14 +1,18 @@ package com.velocitypowered.proxy.network.http; import com.velocitypowered.proxy.VelocityServer; +import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; +import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelInitializer; import io.netty.channel.EventLoop; import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpContentCompressor; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; @@ -16,6 +20,7 @@ import io.netty.handler.ssl.SslHandler; import java.net.InetSocketAddress; import java.net.URL; import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; import javax.net.ssl.SSLEngine; public class NettyHttpClient { @@ -33,13 +38,7 @@ public class NettyHttpClient { this.server = server; } - /** - * Attempts an HTTP GET request to the specified URL. - * @param url the URL to fetch - * @param loop the event loop to use - * @return a future representing the response - */ - public CompletableFuture get(URL url, EventLoop loop) { + private ChannelFuture establishConnection(URL url, EventLoop loop) { String host = url.getHost(); int port = url.getPort(); boolean ssl = url.getProtocol().equals("https"); @@ -48,8 +47,7 @@ public class NettyHttpClient { } InetSocketAddress address = InetSocketAddress.createUnresolved(host, port); - CompletableFuture reply = new CompletableFuture<>(); - server.initializeGenericBootstrap(loop) + return server.initializeGenericBootstrap(loop) .handler(new ChannelInitializer() { @Override protected void initChannel(Channel ch) throws Exception { @@ -65,16 +63,33 @@ public class NettyHttpClient { ch.pipeline().addLast("ssl", new SslHandler(engine)); } ch.pipeline().addLast("http", new HttpClientCodec()); - ch.pipeline().addLast("collector", new SimpleHttpResponseCollector(reply)); } }) - .connect(address) + .connect(address); + } + + /** + * Attempts an HTTP GET request to the specified URL. + * @param url the URL to fetch + * @param loop the event loop to use + * @return a future representing the response + */ + public CompletableFuture get(URL url, EventLoop loop) { + CompletableFuture reply = new CompletableFuture<>(); + establishConnection(url, loop) .addListener((ChannelFutureListener) future -> { if (future.isSuccess()) { Channel channel = future.channel(); + channel.pipeline().addLast("collector", new SimpleHttpResponseCollector(reply)); + + String pathAndQuery = url.getPath(); + if (url.getQuery() != null && url.getQuery().length() > 0) { + pathAndQuery += "?" + url.getQuery(); + } + DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, - HttpMethod.GET, url.getPath() + "?" + url.getQuery()); + HttpMethod.GET, pathAndQuery); request.headers().add(HttpHeaderNames.HOST, url.getHost()); request.headers().add(HttpHeaderNames.USER_AGENT, userAgent); channel.writeAndFlush(request, channel.voidPromise()); @@ -84,4 +99,57 @@ public class NettyHttpClient { }); return reply; } + + /** + * Attempts an HTTP POST request to the specified URL. + * @param url the URL to fetch + * @param body the body to post + * @param decorator a consumer that can modify the request as required + * @return a future representing the response + */ + public CompletableFuture post(URL url, ByteBuf body, + Consumer decorator) { + return post(url, server.getWorkerGroup().next(), body, decorator); + } + + /** + * Attempts an HTTP POST request to the specified URL. + * @param url the URL to fetch + * @param loop the event loop to use + * @param body the body to post + * @param decorator a consumer that can modify the request as required + * @return a future representing the response + */ + public CompletableFuture post(URL url, EventLoop loop, ByteBuf body, + Consumer decorator) { + CompletableFuture reply = new CompletableFuture<>(); + establishConnection(url, loop) + .addListener((ChannelFutureListener) future -> { + if (future.isSuccess()) { + Channel channel = future.channel(); + + channel.pipeline().addLast("collector", new SimpleHttpResponseCollector(reply)); + + String pathAndQuery = url.getPath(); + if (url.getQuery() != null && url.getQuery().length() > 0) { + pathAndQuery += "?" + url.getQuery(); + } + + DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, + HttpMethod.POST, pathAndQuery, body); + request.headers().add(HttpHeaderNames.HOST, url.getHost()); + request.headers().add(HttpHeaderNames.USER_AGENT, userAgent); + request.headers().add(HttpHeaderNames.CONTENT_LENGTH, body.readableBytes()); + decorator.accept(request); + + System.out.println(request); + + channel.writeAndFlush(request, channel.voidPromise()); + } else { + body.release(); + reply.completeExceptionally(future.cause()); + } + }); + return reply; + } }