Skip to content

Commit

Permalink
Merge pull request #40750 from Karm/brotli-4-all
Browse files Browse the repository at this point in the history
Adds Brotli compression support for HTTP (via libbrotli)
  • Loading branch information
gsmet committed Jun 10, 2024
2 parents b7a98a1 + cc361d1 commit 169608b
Show file tree
Hide file tree
Showing 27 changed files with 802 additions and 26 deletions.
8 changes: 4 additions & 4 deletions .github/native-tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@
},
{
"category": "HTTP",
"timeout": 110,
"test-modules": "elytron-resteasy, resteasy-jackson, elytron-resteasy-reactive, resteasy-mutiny, resteasy-reactive-kotlin/standard, vertx, vertx-http, vertx-web, vertx-web-jackson, vertx-graphql, virtual-http, rest-client, rest-client-reactive, rest-client-reactive-stork, rest-client-reactive-multipart, websockets, management-interface, management-interface-auth, mutiny-native-jctools",
"timeout": 120,
"test-modules": "elytron-resteasy, resteasy-jackson, elytron-resteasy-reactive, resteasy-mutiny, resteasy-reactive-kotlin/standard, vertx, vertx-http, vertx-web, vertx-http-compressors/all, vertx-http-compressors/some, vertx-web-jackson, vertx-graphql, virtual-http, rest-client, rest-client-reactive, rest-client-reactive-stork, rest-client-reactive-multipart, websockets, management-interface, management-interface-auth, mutiny-native-jctools",
"os-name": "ubuntu-latest"
},
{
Expand All @@ -104,8 +104,8 @@
},
{
"category": "Misc2",
"timeout": 70,
"test-modules": "hibernate-validator, test-extension/tests, logging-gelf, mailer, native-config-profile, locales",
"timeout": 75,
"test-modules": "hibernate-validator, test-extension/tests, logging-gelf, mailer, native-config-profile, locales/all, locales/some",
"os-name": "ubuntu-latest"
},
{
Expand Down
6 changes: 4 additions & 2 deletions docs/src/main/asciidoc/http-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ The response body of a static resource is not compressed by default.
You can enable the HTTP compression support by means of `quarkus.http.enable-compression=true`.
If compression support is enabled then the response body is compressed if the `Content-Type` header derived from the file name of a resource is a compressed media type as configured via `quarkus.http.compress-media-types`.

TIP: By default, the following list of media types is compressed: `text/html`, `text/plain`, `text/xml`, `text/css`, `text/javascript` and `application/javascript`.
TIP: By default, the following list of media types is compressed: `text/html`, `text/plain`, `text/xml`, `text/css`, `text/javascript`, `application/javascript`, `application/graphql+json`. It means some other noteworthy media types such as `application/json`, `application/xhtml+xml` are NOT compressed by default.

NOTE: If the client does not support HTTP compression then the response body is not compressed.
NOTE: If the client does not indicate its support for HTTP compression in a request header, e.g. `Accept-Encoding: deflate, gzip, br`, then the response body is not compressed.

TIP: Brotli compression is not available by default. You can enable it by setting `quarkus.http.compressors=deflate,gzip,br`. In case of building native image, it adds around 1MB to your executable size.

[[static-resources-config]]
=== Other Configurations
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.vertx.http.deployment;

import static io.quarkus.deployment.pkg.steps.GraalVM.Version.CURRENT;
import static io.quarkus.runtime.TemplateHtmlBuilder.adjustRoot;
import static io.quarkus.vertx.http.deployment.RequireBodyHandlerBuildItem.getBodyHandlerRequiredConditions;
import static io.quarkus.vertx.http.deployment.RouteBuildItem.RouteType.FRAMEWORK_ROUTE;
Expand Down Expand Up @@ -39,17 +40,23 @@
import io.quarkus.deployment.builditem.ServiceStartBuildItem;
import io.quarkus.deployment.builditem.ShutdownContextBuildItem;
import io.quarkus.deployment.builditem.ShutdownListenerBuildItem;
import io.quarkus.deployment.builditem.nativeimage.NativeImageResourcePatternsBuildItem;
import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem;
import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem;
import io.quarkus.deployment.logging.LogCleanupFilterBuildItem;
import io.quarkus.deployment.pkg.builditem.NativeImageRunnerBuildItem;
import io.quarkus.deployment.pkg.steps.GraalVM;
import io.quarkus.deployment.pkg.steps.NativeOrNativeSourcesBuild;
import io.quarkus.deployment.pkg.steps.NoopNativeImageBuildRunner;
import io.quarkus.kubernetes.spi.KubernetesPortBuildItem;
import io.quarkus.netty.runtime.virtual.VirtualServerChannel;
import io.quarkus.runtime.LaunchMode;
import io.quarkus.runtime.LiveReloadConfig;
import io.quarkus.runtime.RuntimeValue;
import io.quarkus.runtime.shutdown.ShutdownConfig;
import io.quarkus.tls.TlsRegistryBuildItem;
import io.quarkus.utilities.OS;
import io.quarkus.vertx.core.deployment.CoreVertxBuildItem;
import io.quarkus.vertx.core.deployment.EventLoopCountBuildItem;
import io.quarkus.vertx.http.HttpServerOptionsCustomizer;
Expand Down Expand Up @@ -507,4 +514,74 @@ private static boolean isSslConfigured() {

return false;
}

/**
* Compressors, deals with adding brotli compression via Brotli4J JNI wrapper.
*/
@BuildStep(onlyIf = NativeOrNativeSourcesBuild.class)
void brotliResources(HttpBuildTimeConfig httpBuildTimeConfig,
BuildProducer<NativeImageResourcePatternsBuildItem> resources,
BuildProducer<RuntimeInitializedClassBuildItem> runtimeInitializedClasses,
NativeImageRunnerBuildItem nativeImageRunnerBuildItem) throws BuildException {

if (httpBuildTimeConfig.compressors.isPresent() &&
httpBuildTimeConfig.compressors.get().stream().anyMatch(s -> s.equalsIgnoreCase("br"))) {
final String arch = System.getProperty("os.arch");
final boolean amd64 = arch.matches("^(amd64|x64|x86_64)$");
final boolean aarch64 = "aarch64".equals(arch);
final String lib;
if (OS.determineOS() == OS.LINUX) {
if (amd64) {
lib = "linux-x86_64/libbrotli.so";
} else if (aarch64) {
lib = "linux-aarch64/libbrotli.so";
} else {
throw new BuildException("Brotli compressor: No library for linux-" + arch);
}
} else if (OS.determineOS() == OS.WINDOWS) {
if (amd64) {
lib = "windows-x86_64/brotli.dll";
} else if (aarch64) {
lib = "windows-aarch64/brotli.dll";
} else {
throw new BuildException("Brotli compressor: No library for windows-" + arch);
}
} else if (OS.determineOS() == OS.MAC) {
if (amd64) {
lib = "osx-x86_64/libbrotli.dylib";
} else if (aarch64) {
lib = "osx-aarch64/libbrotli.dylib";
} else {
throw new BuildException("Brotli compressor: No library for osx-" + arch);
}
} else {
throw new BuildException("Brotli compressor: Your platform is not supported.");
}

resources.produce(NativeImageResourcePatternsBuildItem.builder()
// We do have Brotli4J on classpath thanks to Vert.X -> Netty dependencies.
.includePattern("\\QMETA-INF/services/com.aayushatharva.brotli4j.service.BrotliNativeProvider\\E")
// Native library. We pick only the one relevant to our system.
.includePattern("\\Qlib/" + lib + "\\E")
.build());

// Static initializer tries to load the native library in Brotli4jLoader; must be done at runtime.
runtimeInitializedClasses
.produce(new RuntimeInitializedClassBuildItem("com.aayushatharva.brotli4j.Brotli4jLoader"));
final GraalVM.Version v;
if (nativeImageRunnerBuildItem.getBuildRunner() instanceof NoopNativeImageBuildRunner) {
v = CURRENT;
logger.warnf("native-image is not installed. " +
"Using the default %s version as a reference to build native-sources step.", v.getVersionAsString());
} else {
v = nativeImageRunnerBuildItem.getBuildRunner().getGraalVMVersion();
}
// Newer 23.1+ GraalVM/Mandrel does not need this explicitly marked for runtime init thanks
// to a different strategy: https://github.com/oracle/graal/blob/vm-23.1.0/substratevm/CHANGELOG.md?plain=1#L10
if (v.compareTo(GraalVM.Version.VERSION_23_1_0) <= 0) {
runtimeInitializedClasses
.produce(new RuntimeInitializedClassBuildItem("io.netty.handler.codec.compression.Brotli"));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,34 +1,39 @@
package io.quarkus.vertx.http;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.lessThan;
import static org.hamcrest.Matchers.nullValue;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;

import org.hamcrest.Matchers;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.restassured.RestAssured;
import io.restassured.config.DecoderConfig;
import io.vertx.core.http.HttpHeaders;
import io.vertx.ext.web.Router;

public class CompressionTest {
private static final String APP_PROPS = "" +
"quarkus.http.enable-compression=true\n";

static String longString;
static {

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; ++i) {
sb.append("Hello World;");
}
longString = sb.toString();
}
public static final String TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor " +
"incididunt ut labore et " +
"dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip " +
"ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu " +
"fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt " +
"mollit anim id est laborum." +
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et " +
"dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip " +
"ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu " +
"fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt " +
"mollit anim id est laborum.";

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
Expand All @@ -41,15 +46,25 @@ public void test() throws Exception {
// RestAssured is aware of quarkus.http.root-path
// If this changes then please modify quarkus-azure-functions-http maven archetype to reflect this
// in its test classes
RestAssured.given().get("/compress").then().statusCode(200)
.header("content-encoding", "gzip")
.header("content-length", Matchers.not(Matchers.equalTo(Integer.toString(longString.length()))))
.body(Matchers.equalTo(longString));
given().get("/compress").then().statusCode(200)
.header("content-encoding", is("gzip"))
.header("content-length", Integer::parseInt, lessThan(TEXT.length()))
.body(equalTo(TEXT));

// Why don't you just given().header("Accept-Encoding", "deflate")?
// Because RestAssured silently ignores that and sends gzip anyway,
// search RestAssured GitHub for Accept-Encoding and decoder config.
given().config(RestAssured.config
.decoderConfig(DecoderConfig.decoderConfig().with().contentDecoders(DecoderConfig.ContentDecoder.DEFLATE)))
.get("/compress").then().statusCode(200)
.header("content-encoding", is("deflate"))
.header("content-length", Integer::parseInt, lessThan(TEXT.length()))
.body(equalTo(TEXT));

RestAssured.given().get("/nocompress").then().statusCode(200)
given().get("/nocompress").then().statusCode(200)
.header("content-encoding", is(nullValue()))
.header("content-length", Matchers.equalTo(Integer.toString(longString.length())))
.body(Matchers.equalTo(longString));
.header("content-length", Integer::parseInt, equalTo(TEXT.length()))
.body(equalTo(TEXT));
}

@ApplicationScoped
Expand All @@ -60,12 +75,12 @@ public void register(@Observes Router router) {
router.route("/compress").handler(rc -> {
// The content-encoding header must be removed
rc.response().headers().remove(HttpHeaders.CONTENT_ENCODING);
rc.response().end(longString);
rc.response().end(TEXT);
});
router.route("/nocompress").handler(rc -> {
// This header is set by default
// rc.response().headers().set("content-encoding", "identity");
rc.response().end(longString);
rc.response().end(TEXT);
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@ public class HttpBuildTimeConfig {
@ConfigItem
public boolean enableDecompression;

/**
* If user adds br, then brotli will be added to the list of supported compression algorithms.
* It implies loading libbrotli native library via JNI and in case of native image,
* packing the native library into the native image as a resource thus inflating its size.
* Note that a native shared object library must be available for your platform in Brotli4J project.
* <p>
* Client expresses its capability by sending Accept-Encoding header, e.g.
* Accept-Encoding: deflate, gzip, br
* Server chooses the compression algorithm based on the client's capabilities and
* marks it in a response header, e.g.:
* content-encoding: gzip
*
*/
@ConfigItem(defaultValue = "gzip,deflate")
public Optional<List<String>> compressors;

/**
* List of media types for which the compression should be enabled automatically, unless declared explicitly via
* {@link Compressed} or {@link Uncompressed}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ private boolean isCompressed(String path) {
return false;
}
final String resourcePath = path.endsWith("/") ? path + StaticHandler.DEFAULT_INDEX_PAGE : path;
String contentType = MimeMapping.getMimeTypeForFilename(resourcePath);
final String contentType = MimeMapping.getMimeTypeForFilename(resourcePath);
return contentType != null && compressMediaTypes.contains(contentType);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

import org.jboss.logging.Logger;

import io.netty.handler.codec.compression.BrotliOptions;
import io.netty.handler.codec.compression.DeflateOptions;
import io.netty.handler.codec.compression.GzipOptions;
import io.netty.handler.codec.compression.StandardCompressionOptions;
import io.quarkus.credentials.CredentialsProvider;
import io.quarkus.credentials.runtime.CredentialsProviderFinder;
import io.quarkus.runtime.LaunchMode;
Expand Down Expand Up @@ -274,6 +278,35 @@ public static void applyCommonOptions(HttpServerOptions httpServerOptions,
httpServerOptions.setMaxInitialLineLength(httpConfiguration.limits.maxInitialLineLength);
httpServerOptions.setHandle100ContinueAutomatically(httpConfiguration.handle100ContinueAutomatically);

if (buildTimeConfig.compressors.isPresent()) {
// Adding defaults too, because mere addition of .addCompressor(brotli) actually
// overrides the default deflate and gzip capability.
for (String compressor : buildTimeConfig.compressors.get()) {
if ("gzip".equalsIgnoreCase(compressor)) {
// GZip's default compression level is 6 in Netty Codec 4.1, the same
// as the default compression level in Vert.x Core 4.5.7's HttpServerOptions.
final GzipOptions defaultOps = StandardCompressionOptions.gzip();
httpServerOptions.addCompressor(StandardCompressionOptions
.gzip(httpServerOptions.getCompressionLevel(), defaultOps.windowBits(), defaultOps.memLevel()));
} else if ("deflate".equalsIgnoreCase(compressor)) {
// Deflate's default compression level defaults the same as with GZip.
final DeflateOptions defaultOps = StandardCompressionOptions.deflate();
httpServerOptions.addCompressor(StandardCompressionOptions
.deflate(httpServerOptions.getCompressionLevel(), defaultOps.windowBits(), defaultOps.memLevel()));
} else if ("br".equalsIgnoreCase(compressor)) {
final BrotliOptions o = StandardCompressionOptions.brotli();
// The default compression level for brotli as of Netty Codec 4.1 is 4,
// so we don't pick up Vert.x Core 4.5.7's default of 6. User can override:
if (buildTimeConfig.compressionLevel.isPresent()) {
o.parameters().setQuality(buildTimeConfig.compressionLevel.getAsInt());
}
httpServerOptions.addCompressor(o);
} else {
Logger.getLogger(HttpServerOptionsUtils.class).errorf("Unknown compressor: %s", compressor);
}
}
}

if (httpConfiguration.http2) {
var settings = new Http2Settings();
if (httpConfiguration.limits.headerTableSize.isPresent()) {
Expand Down
1 change: 1 addition & 0 deletions integration-tests/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@
<module>hibernate-orm-jpamodelgen</module>
<module>hibernate-orm-envers</module>
<module>vertx-http</module>
<module>vertx-http-compressors</module>
<module>vertx-web</module>
<module>vertx-web-jackson</module>
<module>vertx</module>
Expand Down
12 changes: 12 additions & 0 deletions integration-tests/vertx-http-compressors/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Compressors
===========

All
---

Adds Brotli compressors, tweaks defaults.

Some
----

Tests compressors in our default setting with Native Image.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file disables the unbind-executions profile in the quarkus-integration-tests-parent.
Loading

0 comments on commit 169608b

Please sign in to comment.