Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Brotli compression support for HTTP (via libbrotli) #40750

Merged
merged 2 commits into from
Jun 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -67,14 +67,16 @@

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`.

Check warning on line 70 in docs/src/main/asciidoc/http-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'. Raw Output: {"message": "[Quarkus.TermsSuggestions] Depending on the context, consider using 'because' or 'while' rather than 'as'.", "location": {"path": "docs/src/main/asciidoc/http-reference.adoc", "range": {"start": {"line": 70, "column": 169}}}, "severity": "INFO"}

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.

Check warning on line 76 in docs/src/main/asciidoc/http-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Spelling] Use correct American English spelling. Did you really mean 'Brotli'? Raw Output: {"message": "[Quarkus.Spelling] Use correct American English spelling. Did you really mean 'Brotli'?", "location": {"path": "docs/src/main/asciidoc/http-reference.adoc", "range": {"start": {"line": 76, "column": 6}}}, "severity": "WARNING"}

[[static-resources-config]]
=== Other Configurations

Check warning on line 79 in docs/src/main/asciidoc/http-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.HeadingPunctuation] Do not use end punctuation in headings. Raw Output: {"message": "[Quarkus.HeadingPunctuation] Do not use end punctuation in headings.", "location": {"path": "docs/src/main/asciidoc/http-reference.adoc", "range": {"start": {"line": 79, "column": 1}}}, "severity": "INFO"}

Check warning on line 79 in docs/src/main/asciidoc/http-reference.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Headings] Use sentence-style capitalization in '1.5. Other Configurations'. Raw Output: {"message": "[Quarkus.Headings] Use sentence-style capitalization in '1.5. Other Configurations'.", "location": {"path": "docs/src/main/asciidoc/http-reference.adoc", "range": {"start": {"line": 79, "column": 1}}}, "severity": "INFO"}

Additionally, the index page for static resources can be changed from default `index.html`, the hidden files (e.g. dot files) can be indicated as not served, the range requests can be disabled, and the caching support (e.g. caching headers and file properties cache) can be configured.

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
Loading