diff --git a/http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala b/http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala
new file mode 100644
index 000000000..d555f78e7
--- /dev/null
+++ b/http-bench-jmh/src/main/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderBenchmark.scala
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.http.shaded.com.twitter.hpack
+
+import java.io.ByteArrayOutputStream
+import java.util.concurrent.TimeUnit
+
+import org.openjdk.jmh.annotations._
+
+@State(Scope.Benchmark)
+@OutputTimeUnit(TimeUnit.NANOSECONDS)
+@BenchmarkMode(Array(Mode.AverageTime))
+@Fork(2)
+@Warmup(iterations = 5, time = 1)
+@Measurement(iterations = 8, time = 2)
+class HpackEncoderBenchmark {
+
+ /** `default` lets the encoder pick Huffman when it is shorter, the other two force one string literal form. */
+ @Param(Array("default", "huffman", "raw"))
+ var mode = "default"
+
+ // indexing is disabled so that every call encodes the values as string literals instead of table references
+ var encoder: Encoder = null
+ val out = new ByteArrayOutputStream(1024)
+
+ // a typical browser request; every value goes through HuffmanEncoder.getEncodedLength and most through encode
+ val headers: Array[(String, String)] = Array(
+ ":method" -> "GET",
+ ":scheme" -> "https",
+ ":authority" -> "www.example.com",
+ ":path" -> "/api/v1/users/12345/orders?page=2&sort=created_at&direction=desc",
+ "user-agent" ->
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "accept" -> "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
+ "accept-language" -> "en-US,en;q=0.9",
+ "accept-encoding" -> "gzip, deflate, br",
+ "cookie" -> "session=ab12cd34ef56ab12cd34ef56ab12cd34ef56ab12; theme=dark; consent=1",
+ "cache-control" -> "no-cache")
+
+ @Setup
+ def setup(): Unit =
+ encoder = mode match {
+ case "default" => new Encoder(4096, false, false, false)
+ case "huffman" => new Encoder(4096, false, true, false)
+ case "raw" => new Encoder(4096, false, false, true)
+ }
+
+ @Benchmark
+ def encodeHeaders(): Int = {
+ out.reset()
+ var i = 0
+ while (i < headers.length) {
+ val (name, value) = headers(i)
+ encoder.encodeHeader(out, name, value, false)
+ i += 1
+ }
+ out.size
+ }
+}
diff --git a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java
index c94d6df90..2fbe81906 100644
--- a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java
+++ b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/Encoder.java
@@ -172,15 +172,17 @@ private static void encodeInteger(OutputStream out, int mask, int n, int i) thro
/** Encode string literal according to Section 5.2. */
private void encodeStringLiteral(OutputStream out, String string) throws IOException {
- int length = string.length();
- int huffmanLength = Huffman.ENCODER.getEncodedLength(string);
+ // convert once up front: the length computation, the Huffman coder and the raw literal all work
+ // on the octets
+ byte[] stringBytes = StringTools.asciiStringBytes(string);
+ int length = stringBytes.length;
+ int huffmanLength = Huffman.ENCODER.getEncodedLength(stringBytes);
if ((huffmanLength < length && !forceHuffmanOff) || forceHuffmanOn) {
encodeInteger(out, 0x80, 7, huffmanLength);
- Huffman.ENCODER.encode(out, string);
+ out.write(Huffman.ENCODER.encode(stringBytes, huffmanLength), 0, huffmanLength);
} else {
- byte[] stringBytes = StringTools.asciiStringBytes(string);
encodeInteger(out, 0x00, 7, length);
- out.write(stringBytes, 0, stringBytes.length);
+ out.write(stringBytes, 0, length);
}
}
diff --git a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java
index 7b8771e8e..ffd6858b1 100644
--- a/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java
+++ b/http-core/src/main/java/org/apache/pekko/http/shaded/com/twitter/hpack/HuffmanEncoder.java
@@ -31,9 +31,6 @@
package org.apache.pekko.http.shaded.com.twitter.hpack;
-import java.io.IOException;
-import java.io.OutputStream;
-
final class HuffmanEncoder {
private final int[] codes;
@@ -53,23 +50,27 @@ final class HuffmanEncoder {
/**
* Compresses the input string literal using the Huffman coding.
*
- * @param out the output stream for the compressed data
- * @throws IOException if an I/O error occurs. In particular, an IOException may be
- * thrown if the output stream has been closed.
+ *
The result is assembled in an array and returned instead of being written byte by byte to an
+ * OutputStream, whose per-byte write(int) (synchronized on the JDK
+ * stream implementations) dominated the cost of encoding.
+ *
+ * @param data the string literal to be Huffman encoded
+ * @param encodedLength the value of {@link #getEncodedLength(byte[])} for data,
+ * which the caller has typically already computed to decide whether to use Huffman coding
+ * @return the Huffman coded string literal, exactly encodedLength bytes long
*/
- public void encode(OutputStream out, String string) throws IOException {
- if (out == null) {
- throw new NullPointerException("out");
- } else if (string == null) {
- throw new NullPointerException("string");
+ public byte[] encode(byte[] data, int encodedLength) {
+ if (data == null) {
+ throw new NullPointerException("data");
}
+ byte[] out = new byte[encodedLength];
+ int pos = 0;
long current = 0;
int n = 0;
- int len = string.length();
- for (int i = 0; i < len; i++) {
- int b = string.charAt(i) & 0xFF;
+ for (byte value : data) {
+ int b = value & 0xFF;
int code = codes[b];
int nbits = lengths[b];
@@ -79,15 +80,21 @@ public void encode(OutputStream out, String string) throws IOException {
while (n >= 8) {
n -= 8;
- out.write(((int) (current >> n)));
+ out[pos++] = (byte) (current >> n);
}
}
if (n > 0) {
current <<= (8 - n);
current |= (0xFF >>> n); // this should be EOS symbol
- out.write((int) current);
+ out[pos++] = (byte) current;
+ }
+
+ if (pos != encodedLength) {
+ throw new IllegalArgumentException(
+ "encodedLength " + encodedLength + " does not match the Huffman encoded length " + pos);
}
+ return out;
}
/**
@@ -96,13 +103,13 @@ public void encode(OutputStream out, String string) throws IOException {
* @param data the string literal to be Huffman encoded
* @return the number of bytes required to Huffman encode data
*/
- public int getEncodedLength(String data) {
+ public int getEncodedLength(byte[] data) {
if (data == null) {
throw new NullPointerException("data");
}
long len = 0;
- for (int i = 0; i < data.length(); i++) {
- len += lengths[data.charAt(i) & 0xFF];
+ for (byte b : data) {
+ len += lengths[b & 0xFF];
}
return (int) ((len + 7) >> 3);
}
diff --git a/http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-huffman-encode-bytes.excludes b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-huffman-encode-bytes.excludes
new file mode 100644
index 000000000..c8ec580c0
--- /dev/null
+++ b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/hpack-huffman-encode-bytes.excludes
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+# The shaded HPACK HuffmanEncoder (a package-private class) codes byte arrays instead of Strings
+ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.apache.pekko.http.shaded.com.twitter.hpack.HuffmanEncoder.encode")
+ProblemFilters.exclude[IncompatibleMethTypeProblem]("org.apache.pekko.http.shaded.com.twitter.hpack.HuffmanEncoder.getEncodedLength")
diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala
index 92823da04..3eb25e24b 100644
--- a/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala
+++ b/http-core/src/main/scala/org/apache/pekko/http/impl/util/StringTools.scala
@@ -27,9 +27,9 @@ private[http] object StringTools {
// keeps the array as is with a LATIN1 coder, so it is the same single copy.
new String(bytes, ISO88591)
- def asciiStringBytes(string: String): Array[Byte] = {
- // this is as fast as Unsafe.copyUSAsciiStrToBytes for recent JDK versions
- // and avoids the use of deprecated Unsafe methods
- string.getBytes(java.nio.charset.StandardCharsets.US_ASCII)
- }
+ def asciiStringBytes(string: String): Array[Byte] =
+ // ISO-8859-1 makes this the exact inverse of asciiStringFromBytes: HPACK string literals are opaque octets and
+ // a character in 0x80-0xFF has to come out as that octet, not as the '?' that encoding with US-ASCII would
+ // substitute. Since JDK 9 (compact strings) it is a single array copy for a string with a LATIN1 coder.
+ string.getBytes(ISO88591)
}
diff --git a/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala
index ec30db525..2347668bf 100644
--- a/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala
+++ b/http-core/src/test/scala/org/apache/pekko/http/impl/util/StringToolsSpec.scala
@@ -50,5 +50,21 @@ class StringToolsSpec extends AnyWordSpec with Matchers {
"encode an ASCII string to its US-ASCII bytes" in {
StringTools.asciiStringBytes("abc") shouldEqual "abc".getBytes(StandardCharsets.US_ASCII)
}
+
+ "be the inverse of asciiStringFromBytes for every octet" in {
+ // HPACK string literals are opaque octets, so a char in 0x80-0xFF must map back to that octet rather than
+ // to the '?' that encoding with US-ASCII would substitute
+ val allOctets = Array.tabulate(256)(_.toByte)
+ StringTools.asciiStringBytes(StringTools.asciiStringFromBytes(allOctets)) shouldEqual allOctets
+ }
+
+ "encode a character above 0xFF, which no octet can represent, as a single '?'" in {
+ // U+0100 forces the UTF-16 coder on JDK 9+, so this covers the non-arraycopy path too
+ StringTools.asciiStringBytes("aĀb") shouldEqual Array[Byte]('a', '?', 'b')
+ }
+
+ "encode the empty string to an empty array" in {
+ StringTools.asciiStringBytes("") shouldEqual Array.emptyByteArray
+ }
}
}
diff --git a/http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala b/http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala
new file mode 100644
index 000000000..6952a3ba9
--- /dev/null
+++ b/http-core/src/test/scala/org/apache/pekko/http/shaded/com/twitter/hpack/HpackEncoderSpec.scala
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.pekko.http.shaded.com.twitter.hpack
+
+import java.io.{ ByteArrayInputStream, ByteArrayOutputStream }
+
+import scala.collection.immutable.VectorBuilder
+
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+
+import org.apache.pekko.http.impl.util.StringTools
+
+class HpackEncoderSpec extends AnyWordSpec with Matchers {
+
+ private def hex(bytes: Array[Byte]): String = bytes.map(b => f"$b%02x").mkString
+
+ private def huffmanEncode(bytes: Array[Byte]): Array[Byte] =
+ Huffman.ENCODER.encode(bytes, Huffman.ENCODER.getEncodedLength(bytes))
+
+ private def huffmanEncode(literal: String): Array[Byte] = huffmanEncode(StringTools.asciiStringBytes(literal))
+
+ // indexing off so that the header value is always sent as a string literal, never as a table reference
+ private def encodeHeader(name: String, value: String, forceHuffmanOn: Boolean, forceHuffmanOff: Boolean)
+ : Array[Byte] = {
+ val encoder = new Encoder(4096, false, forceHuffmanOn, forceHuffmanOff)
+ val out = new ByteArrayOutputStream()
+ encoder.encodeHeader(out, name, value, false)
+ out.toByteArray
+ }
+
+ private def decodeHeaders(bytes: Array[Byte]): Seq[(String, String)] = {
+ val decoder = new Decoder(8192, 4096)
+ val headers = new VectorBuilder[(String, String)]()
+ decoder.decode(new ByteArrayInputStream(bytes),
+ (name: String, value: String, parsedValue: AnyRef, _: Boolean) => {
+ headers += name -> value
+ parsedValue
+ })
+ headers.result()
+ }
+
+ "HuffmanEncoder" should {
+ // the string literals from RFC 7541 Appendix C.4 with their expected Huffman codes
+ val rfc7541Examples = Seq(
+ "www.example.com" -> "f1e3c2e5f23a6ba0ab90f4ff",
+ "no-cache" -> "a8eb10649cbf",
+ "custom-key" -> "25a849e95ba97d7f",
+ "custom-value" -> "25a849e95bb8e8b4bf")
+
+ "produce the Huffman codes from RFC 7541 Appendix C.4" in {
+ rfc7541Examples.foreach {
+ case (literal, expected) =>
+ hex(huffmanEncode(literal)) shouldEqual expected
+ }
+ }
+
+ "report the encoded length that encode actually produces" in {
+ rfc7541Examples.foreach {
+ case (literal, expected) =>
+ Huffman.ENCODER.getEncodedLength(StringTools.asciiStringBytes(literal)) shouldEqual expected.length / 2
+ }
+ }
+
+ "encode the empty literal to no bytes" in {
+ huffmanEncode("") shouldEqual Array.emptyByteArray
+ Huffman.ENCODER.getEncodedLength(Array.emptyByteArray) shouldEqual 0
+ }
+
+ "encode every octet with the code for that octet" in {
+ // a byte above 0x7F must be looked up as an unsigned symbol, not as a negative array index
+ val allOctets = Array.tabulate(256)(_.toByte)
+ Huffman.DECODER.decode(huffmanEncode(allOctets)) shouldEqual allOctets
+ }
+
+ "reject an encoded length that does not match the input" in {
+ val bytes = StringTools.asciiStringBytes("no-cache")
+ an[IllegalArgumentException] should be thrownBy Huffman.ENCODER.encode(bytes, 7)
+ an[ArrayIndexOutOfBoundsException] should be thrownBy Huffman.ENCODER.encode(bytes, 5)
+ }
+ }
+
+ "Encoder" should {
+ "encode the value from RFC 7541 Appendix C.4.1 with Huffman coding when it is shorter" in {
+ // 0x01: literal without indexing, name index 1 (:authority); 0x8c: Huffman coded, 12 octets
+ hex(encodeHeader(":authority", "www.example.com", forceHuffmanOn = false, forceHuffmanOff = false)) shouldEqual
+ "018cf1e3c2e5f23a6ba0ab90f4ff"
+ }
+
+ "encode the header from RFC 7541 Appendix C.2.1 as a raw literal when Huffman coding is forced off" in {
+ // 0x00: literal without indexing, new name; 0x0a/0x0d: raw string literals of 10 and 13 octets
+ hex(encodeHeader("custom-key", "custom-header", forceHuffmanOn = false, forceHuffmanOff = true)) shouldEqual
+ "000a637573746f6d2d6b65790d637573746f6d2d686561646572"
+ }
+
+ "round-trip a value carrying opaque octets above 0x7F in both string literal forms" in {
+ // HPACK string literals are opaque octets: a decoder maps each byte to the char of the same value, so an
+ // encoder has to map each such char back to that byte, in the raw form as well as the Huffman form
+ val value = new String(Array.tabulate(256)(_.toChar))
+ Seq(true, false).foreach { huffman =>
+ val bytes = encodeHeader("x-opaque", value, forceHuffmanOn = huffman, forceHuffmanOff = !huffman)
+ decodeHeaders(bytes) shouldEqual Seq("x-opaque" -> value)
+ }
+ }
+ }
+}