View Javadoc
1   /*
2    * Copyright 2014 The Netty Project
3    *
4    * The Netty Project licenses this file to you under the Apache License, version 2.0 (the
5    * "License"); you may not use this file except in compliance with the License. You may obtain a
6    * copy of the License at:
7    *
8    * https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software distributed under the License
11   * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
12   * or implied. See the License for the specific language governing permissions and limitations under
13   * the License.
14   */
15  package io.netty5.handler.codec.http2;
16  
17  import io.netty5.buffer.api.Buffer;
18  import io.netty5.channel.ChannelHandlerContext;
19  import io.netty5.channel.embedded.EmbeddedChannel;
20  import io.netty5.handler.codec.ByteToMessageDecoder;
21  import io.netty5.handler.codec.compression.Brotli;
22  import io.netty5.handler.codec.compression.BrotliCompressor;
23  import io.netty5.handler.codec.compression.BrotliOptions;
24  import io.netty5.handler.codec.compression.CompressionOptions;
25  import io.netty5.handler.codec.compression.Compressor;
26  import io.netty5.handler.codec.compression.DeflateOptions;
27  import io.netty5.handler.codec.compression.GzipOptions;
28  import io.netty5.handler.codec.compression.StandardCompressionOptions;
29  import io.netty5.handler.codec.compression.ZlibCompressor;
30  import io.netty5.handler.codec.compression.ZlibWrapper;
31  import io.netty5.handler.codec.compression.ZstdCompressor;
32  import io.netty5.handler.codec.compression.ZstdOptions;
33  import io.netty5.util.concurrent.Future;
34  import io.netty5.util.concurrent.Promise;
35  import io.netty5.util.concurrent.PromiseCombiner;
36  import io.netty5.util.internal.ObjectUtil;
37  import io.netty5.util.internal.UnstableApi;
38  
39  import static io.netty5.handler.codec.http.HttpHeaderNames.CONTENT_ENCODING;
40  import static io.netty5.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH;
41  import static io.netty5.handler.codec.http.HttpHeaderValues.BR;
42  import static io.netty5.handler.codec.http.HttpHeaderValues.DEFLATE;
43  import static io.netty5.handler.codec.http.HttpHeaderValues.GZIP;
44  import static io.netty5.handler.codec.http.HttpHeaderValues.IDENTITY;
45  import static io.netty5.handler.codec.http.HttpHeaderValues.X_DEFLATE;
46  import static io.netty5.handler.codec.http.HttpHeaderValues.X_GZIP;
47  import static io.netty5.handler.codec.http.HttpHeaderValues.ZSTD;
48  import static java.util.Objects.requireNonNull;
49  
50  /**
51   * A decorating HTTP2 encoder that will compress data frames according to the {@code content-encoding} header for each
52   * stream. The compression provided by this class will be applied to the data for the entire stream.
53   */
54  @UnstableApi
55  public class CompressorHttp2ConnectionEncoder extends DecoratingHttp2ConnectionEncoder {
56      private int compressionLevel;
57      private int windowBits;
58      private int memLevel;
59      private final Http2Connection.PropertyKey propertyKey;
60  
61      private final boolean supportsCompressionOptions;
62  
63      private BrotliOptions brotliOptions;
64      private GzipOptions gzipCompressionOptions;
65      private DeflateOptions deflateOptions;
66      private ZstdOptions zstdOptions;
67  
68      /**
69       * Create a new {@link CompressorHttp2ConnectionEncoder} instance
70       * with default implementation of {@link StandardCompressionOptions}
71       */
72      public CompressorHttp2ConnectionEncoder(Http2ConnectionEncoder delegate) {
73          this(delegate, defaultCompressionOptions());
74      }
75  
76      /**
77       * Create a new {@link CompressorHttp2ConnectionEncoder} instance
78       */
79      @Deprecated
80      public CompressorHttp2ConnectionEncoder(Http2ConnectionEncoder delegate, int compressionLevel, int windowBits,
81                                              int memLevel) {
82          super(delegate);
83          this.compressionLevel = ObjectUtil.checkInRange(compressionLevel, 0, 9, "compressionLevel");
84          this.windowBits = ObjectUtil.checkInRange(windowBits, 9, 15, "windowBits");
85          this.memLevel = ObjectUtil.checkInRange(memLevel, 1, 9, "memLevel");
86  
87          propertyKey = connection().newKey();
88          connection().addListener(new Http2ConnectionAdapter() {
89              @Override
90              public void onStreamRemoved(Http2Stream stream) {
91                  final Compressor compressor = stream.getProperty(propertyKey);
92                  if (compressor != null) {
93                      cleanup(stream, compressor);
94                  }
95              }
96          });
97  
98          supportsCompressionOptions = false;
99      }
100 
101     /**
102      * Create a new {@link CompressorHttp2ConnectionEncoder} with
103      * specified {@link StandardCompressionOptions}
104      */
105     public CompressorHttp2ConnectionEncoder(Http2ConnectionEncoder delegate,
106                                             CompressionOptions... compressionOptionsArgs) {
107         super(delegate);
108         requireNonNull(compressionOptionsArgs, "CompressionOptions");
109         ObjectUtil.deepCheckNotNull("CompressionOptions", compressionOptionsArgs);
110 
111         for (CompressionOptions compressionOptions : compressionOptionsArgs) {
112             // BrotliOptions' class initialization depends on Brotli classes being on the classpath.
113             // The Brotli.isAvailable check ensures that BrotliOptions will only get instantiated if Brotli is on
114             // the classpath.
115             // This results in the static analysis of native-image identifying the instanceof BrotliOptions check
116             // and thus BrotliOptions itself as unreachable, enabling native-image to link all classes at build time
117             // and not complain about the missing Brotli classes.
118             if (Brotli.isAvailable() && compressionOptions instanceof BrotliOptions) {
119                 brotliOptions = (BrotliOptions) compressionOptions;
120             } else if (compressionOptions instanceof GzipOptions) {
121                 gzipCompressionOptions = (GzipOptions) compressionOptions;
122             } else if (compressionOptions instanceof DeflateOptions) {
123                 deflateOptions = (DeflateOptions) compressionOptions;
124             } else if (compressionOptions instanceof ZstdOptions) {
125                 zstdOptions = (ZstdOptions) compressionOptions;
126             } else {
127                 throw new IllegalArgumentException("Unsupported " + CompressionOptions.class.getSimpleName() +
128                         ": " + compressionOptions);
129             }
130         }
131 
132         supportsCompressionOptions = true;
133 
134         propertyKey = connection().newKey();
135         connection().addListener(new Http2ConnectionAdapter() {
136             @Override
137             public void onStreamRemoved(Http2Stream stream) {
138                 final Compressor compressor = stream.getProperty(propertyKey);
139                 if (compressor != null) {
140                     cleanup(stream, compressor);
141                 }
142             }
143         });
144     }
145 
146     private static CompressionOptions[] defaultCompressionOptions() {
147         if (Brotli.isAvailable()) {
148             return new CompressionOptions[] {
149                     StandardCompressionOptions.brotli(),
150                     StandardCompressionOptions.gzip(),
151                     StandardCompressionOptions.deflate() };
152         }
153         return new CompressionOptions[] { StandardCompressionOptions.gzip(), StandardCompressionOptions.deflate() };
154     }
155 
156     @Override
157     public Future<Void> writeData(final ChannelHandlerContext ctx, final int streamId, Buffer data, int padding,
158                                   final boolean endOfStream) {
159         final Http2Stream stream = connection().stream(streamId);
160         final Compressor compressor = stream == null ? null : (Compressor) stream.getProperty(propertyKey);
161         if (compressor == null) {
162             // The compressor may be null if no compatible encoding type was found in this stream's headers
163             return super.writeData(ctx, streamId, data, padding, endOfStream);
164         }
165 
166         try {
167             Buffer buf = compressor.compress(data, ctx.bufferAllocator());
168             if (buf.readableBytes() == 0) {
169                 buf.close();
170                 if (endOfStream) {
171                     buf = compressor.finish(ctx.bufferAllocator());
172                     return super.writeData(ctx, streamId, buf, padding,
173                             true);
174                 }
175                 // END_STREAM is not set and the assumption is data is still forthcoming.
176                 return ctx.newSucceededFuture();
177             }
178 
179             Future<Void> future = super.writeData(ctx, streamId, buf, padding, false);
180             if (endOfStream) {
181                 Promise<Void> promise = ctx.newPromise();
182                 PromiseCombiner combiner = new PromiseCombiner(ctx.executor());
183                 combiner.add(future);
184 
185                 buf = compressor.finish(ctx.bufferAllocator());
186 
187                 // Padding is only communicated once on the first iteration
188                 future = super.writeData(ctx, streamId, buf, 0, true);
189                 combiner.add(future);
190                 combiner.finish(promise);
191                 return promise.asFuture();
192             }
193             return future;
194 
195         } catch (Throwable cause) {
196             return ctx.newFailedFuture(cause);
197         } finally {
198             if (endOfStream) {
199                 cleanup(stream, compressor);
200             }
201         }
202     }
203 
204     @Override
205     public Future<Void> writeHeaders(ChannelHandlerContext ctx, int streamId, Http2Headers headers, int padding,
206             boolean endStream) {
207         try {
208             // Determine if compression is required and sanitize the headers.
209             Compressor compressor = newCompressor(ctx, headers, endStream);
210 
211             // Write the headers and create the stream object.
212             Future<Void> future = super.writeHeaders(ctx, streamId, headers, padding, endStream);
213 
214             // After the stream object has been created, then attach the compressor as a property for data compression.
215             bindCompressorToStream(compressor, streamId);
216 
217             return future;
218         } catch (Throwable e) {
219             return ctx.newFailedFuture(e);
220         }
221     }
222 
223     @Override
224     public Future<Void> writeHeaders(final ChannelHandlerContext ctx, final int streamId, final Http2Headers headers,
225             final int streamDependency, final short weight, final boolean exclusive, final int padding,
226             final boolean endOfStream) {
227         try {
228             // Determine if compression is required and sanitize the headers.
229             Compressor compressor = newCompressor(ctx, headers, endOfStream);
230 
231             // Write the headers and create the stream object.
232             Future<Void> future = super.writeHeaders(ctx, streamId, headers, streamDependency, weight, exclusive,
233                                                       padding, endOfStream);
234 
235             // After the stream object has been created, then attach the compressor as a property for data compression.
236             bindCompressorToStream(compressor, streamId);
237 
238             return future;
239         } catch (Throwable e) {
240             return ctx.newFailedFuture(e);
241         }
242     }
243 
244     /**
245      * Returns a new {@link Compressor} that encodes the HTTP2 message content encoded in the specified
246      * {@code contentEncoding}.
247      *
248      * @param ctx the context.
249      * @param contentEncoding the value of the {@code content-encoding} header
250      * @return a new {@link ByteToMessageDecoder} if the specified encoding is supported.
251      * Otherwise {@code null}.
252      * Alternatively, you can throw a {@link Http2Exception} to block unknown encoding.
253      * @throws Http2Exception If the specified encoding is not supported and warrants an exception
254      */
255     protected Compressor newContentCompressor(ChannelHandlerContext ctx, CharSequence contentEncoding)
256             throws Http2Exception {
257         if (GZIP.contentEqualsIgnoreCase(contentEncoding) || X_GZIP.contentEqualsIgnoreCase(contentEncoding)) {
258             return newCompressionChannel(ctx, ZlibWrapper.GZIP);
259         }
260         if (DEFLATE.contentEqualsIgnoreCase(contentEncoding) || X_DEFLATE.contentEqualsIgnoreCase(contentEncoding)) {
261             return newCompressionChannel(ctx, ZlibWrapper.ZLIB);
262         }
263         if (Brotli.isAvailable() && brotliOptions != null && BR.contentEqualsIgnoreCase(contentEncoding)) {
264             return BrotliCompressor.newFactory(brotliOptions.parameters()).get();
265         }
266         if (zstdOptions != null && ZSTD.contentEqualsIgnoreCase(contentEncoding)) {
267             return ZstdCompressor.newFactory(zstdOptions.compressionLevel(),
268                     zstdOptions.blockSize(), zstdOptions.maxEncodeSize()).get();
269         }
270         // 'identity' or unsupported
271         return null;
272     }
273 
274     /**
275      * Returns the expected content encoding of the decoded content. Returning {@code contentEncoding} is the default
276      * behavior, which is the case for most compressors.
277      *
278      * @param contentEncoding the value of the {@code content-encoding} header
279      * @return the expected content encoding of the new content.
280      * @throws Http2Exception if the {@code contentEncoding} is not supported and warrants an exception
281      */
282     protected CharSequence getTargetContentEncoding(CharSequence contentEncoding) throws Http2Exception {
283         return contentEncoding;
284     }
285 
286     /**
287      * Generate a new instance of an {@link Compressor} capable of compressing data
288      * @param ctx the context.
289      * @param wrapper Defines what type of encoder should be used
290      */
291     private Compressor newCompressionChannel(final ChannelHandlerContext ctx, ZlibWrapper wrapper) {
292         if (supportsCompressionOptions) {
293             if (wrapper == ZlibWrapper.GZIP && gzipCompressionOptions != null) {
294                 return ZlibCompressor.newFactory(wrapper, gzipCompressionOptions.compressionLevel()).get();
295             } else if (wrapper == ZlibWrapper.ZLIB && deflateOptions != null) {
296                 return ZlibCompressor.newFactory(wrapper, deflateOptions.compressionLevel()).get();
297             } else {
298                 throw new IllegalArgumentException("Unsupported ZlibWrapper: " + wrapper);
299             }
300         }
301         return ZlibCompressor.newFactory(wrapper, compressionLevel).get();
302     }
303 
304     /**
305      * Checks if a new compressor object is needed for the stream identified by {@code streamId}. This method will
306      * modify the {@code content-encoding} header contained in {@code headers}.
307      *
308      * @param ctx the context.
309      * @param headers Object representing headers which are to be written
310      * @param endOfStream Indicates if the stream has ended
311      * @return The channel used to compress data.
312      * @throws Http2Exception if any problems occur during initialization.
313      */
314     private Compressor newCompressor(ChannelHandlerContext ctx, Http2Headers headers, boolean endOfStream)
315             throws Http2Exception {
316         if (endOfStream) {
317             return null;
318         }
319 
320         CharSequence encoding = headers.get(CONTENT_ENCODING);
321         if (encoding == null) {
322             encoding = IDENTITY;
323         }
324         final Compressor compressor = newContentCompressor(ctx, encoding);
325         if (compressor != null) {
326             CharSequence targetContentEncoding = getTargetContentEncoding(encoding);
327             if (IDENTITY.contentEqualsIgnoreCase(targetContentEncoding)) {
328                 headers.remove(CONTENT_ENCODING);
329             } else {
330                 headers.set(CONTENT_ENCODING, targetContentEncoding);
331             }
332 
333             // The content length will be for the decompressed data. Since we will compress the data
334             // this content-length will not be correct. Instead of queuing messages or delaying sending
335             // header frames...just remove the content-length header
336             headers.remove(CONTENT_LENGTH);
337         }
338 
339         return compressor;
340     }
341 
342     /**
343      * Called after the super class has written the headers and created any associated stream objects.
344      * @param compressor The compressor associated with the stream identified by {@code streamId}.
345      * @param streamId The stream id for which the headers were written.
346      */
347     private void bindCompressorToStream(Compressor compressor, int streamId) {
348         if (compressor != null) {
349             Http2Stream stream = connection().stream(streamId);
350             if (stream != null) {
351                 stream.setProperty(propertyKey, compressor);
352             }
353         }
354     }
355 
356     /**
357      * Release remaining content from {@link EmbeddedChannel} and remove the compressor from the {@link Http2Stream}.
358      *
359      * @param stream The stream for which {@code compressor} is the compressor for
360      * @param compressor The compressor for {@code stream}
361      */
362     void cleanup(Http2Stream stream, Compressor compressor) {
363         compressor.close();
364         stream.removeProperty(propertyKey);
365     }
366 }