View Javadoc
1   /*
2    * Copyright 2012 The Netty Project
3    *
4    * The Netty Project licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a 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
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  package io.netty.handler.codec.http;
17  
18  import io.netty.buffer.ByteBuf;
19  import io.netty.channel.Channel;
20  import io.netty.channel.ChannelHandlerContext;
21  import io.netty.channel.embedded.EmbeddedChannel;
22  import io.netty.handler.codec.MessageToByteEncoder;
23  import io.netty.handler.codec.compression.Brotli;
24  import io.netty.handler.codec.compression.BrotliEncoder;
25  import io.netty.handler.codec.compression.BrotliOptions;
26  import io.netty.handler.codec.compression.CompressionOptions;
27  import io.netty.handler.codec.compression.DeflateOptions;
28  import io.netty.handler.codec.compression.GzipOptions;
29  import io.netty.handler.codec.compression.SnappyFrameEncoder;
30  import io.netty.handler.codec.compression.SnappyOptions;
31  import io.netty.handler.codec.compression.StandardCompressionOptions;
32  import io.netty.handler.codec.compression.ZlibCodecFactory;
33  import io.netty.handler.codec.compression.ZlibWrapper;
34  import io.netty.handler.codec.compression.Zstd;
35  import io.netty.handler.codec.compression.ZstdEncoder;
36  import io.netty.handler.codec.compression.ZstdOptions;
37  import io.netty.util.internal.ObjectUtil;
38  
39  import java.util.ArrayList;
40  import java.util.List;
41  
42  import static io.netty.util.internal.ObjectUtil.checkInRange;
43  
44  /**
45   * Compresses an {@link HttpMessage} and an {@link HttpContent} in {@code gzip} or
46   * {@code deflate} encoding while respecting the {@code "Accept-Encoding"} header.
47   * If there is no matching encoding, no compression is done.  For more
48   * information on how this handler modifies the message, please refer to
49   * {@link HttpContentEncoder}.
50   */
51  public class HttpContentCompressor extends HttpContentEncoder {
52  
53      private final BrotliOptions brotliOptions;
54      private final GzipOptions gzipOptions;
55      private final DeflateOptions deflateOptions;
56      private final ZstdOptions zstdOptions;
57      private final SnappyOptions snappyOptions;
58  
59      private final int contentSizeThreshold;
60      private ChannelHandlerContext ctx;
61  
62      private static final CompressionOptions[] DEFAULT_COMPRESSION_OPTIONS;
63      static {
64          List<CompressionOptions> options = new ArrayList<>(5);
65          options.add(StandardCompressionOptions.gzip());
66          options.add(StandardCompressionOptions.deflate());
67          options.add(StandardCompressionOptions.snappy());
68          if (Brotli.isAvailable()) {
69              options.add(StandardCompressionOptions.brotli());
70          }
71          if (Zstd.isAvailable()) {
72              options.add(StandardCompressionOptions.zstd());
73          }
74          DEFAULT_COMPRESSION_OPTIONS = options.toArray(new CompressionOptions[0]);
75      }
76  
77      /**
78       * Creates a new handler with {@link StandardCompressionOptions#brotli()} (if supported) ,
79       * {@link StandardCompressionOptions#zstd()} (if supported), {@link StandardCompressionOptions#snappy()},
80       * {@link StandardCompressionOptions#gzip()} and {@link StandardCompressionOptions#deflate()}.
81       */
82      public HttpContentCompressor() {
83          this(0, (CompressionOptions[]) null);
84      }
85  
86      /**
87       * Creates a new handler with the specified compression level, default
88       * window size (<tt>15</tt>) and default memory level (<tt>8</tt>).
89       *
90       * @param compressionLevel
91       *        {@code 1} yields the fastest compression and {@code 9} yields the
92       *        best compression.  {@code 0} means no compression.  The default
93       *        compression level is {@code 6}.
94       */
95      @Deprecated
96      public HttpContentCompressor(int compressionLevel) {
97          this(compressionLevel, 15, 8, 0);
98      }
99  
100     /**
101      * Creates a new handler with the specified compression level, window size,
102      * and memory level.
103      *
104      * @param compressionLevel
105      *        {@code 1} yields the fastest compression and {@code 9} yields the
106      *        best compression.  {@code 0} means no compression.  The default
107      *        compression level is {@code 6}.
108      * @param windowBits
109      *        The base two logarithm of the size of the history buffer.  The
110      *        value should be in the range {@code 9} to {@code 15} inclusive.
111      *        Larger values result in better compression at the expense of
112      *        memory usage.  The default value is {@code 15}.
113      * @param memLevel
114      *        How much memory should be allocated for the internal compression
115      *        state.  {@code 1} uses minimum memory and {@code 9} uses maximum
116      *        memory.  Larger values result in better and faster compression
117      *        at the expense of memory usage.  The default value is {@code 8}
118      */
119     @Deprecated
120     public HttpContentCompressor(int compressionLevel, int windowBits, int memLevel) {
121         this(compressionLevel, windowBits, memLevel, 0);
122     }
123 
124     /**
125      * Creates a new handler with the specified compression level, window size,
126      * and memory level.
127      *
128      * @param compressionLevel
129      *        {@code 1} yields the fastest compression and {@code 9} yields the
130      *        best compression.  {@code 0} means no compression.  The default
131      *        compression level is {@code 6}.
132      * @param windowBits
133      *        The base two logarithm of the size of the history buffer.  The
134      *        value should be in the range {@code 9} to {@code 15} inclusive.
135      *        Larger values result in better compression at the expense of
136      *        memory usage.  The default value is {@code 15}.
137      * @param memLevel
138      *        How much memory should be allocated for the internal compression
139      *        state.  {@code 1} uses minimum memory and {@code 9} uses maximum
140      *        memory.  Larger values result in better and faster compression
141      *        at the expense of memory usage.  The default value is {@code 8}
142      * @param contentSizeThreshold
143      *        The response body is compressed when the size of the response
144      *        body exceeds the threshold. The value should be a non negative
145      *        number. {@code 0} will enable compression for all responses.
146      */
147     @Deprecated
148     public HttpContentCompressor(int compressionLevel, int windowBits, int memLevel, int contentSizeThreshold) {
149         this(contentSizeThreshold,
150                 defaultCompressionOptions(
151                     StandardCompressionOptions.gzip(
152                             checkInRange(compressionLevel, 0, 9, "compressionLevel"),
153                             checkInRange(windowBits, 9, 15, "windowBits"),
154                             checkInRange(memLevel, 1, 9, "memLevel")
155                     ),
156                     StandardCompressionOptions.deflate(
157                             checkInRange(compressionLevel, 0, 9, "compressionLevel"),
158                             checkInRange(windowBits, 9, 15, "windowBits"),
159                             checkInRange(memLevel, 1, 9, "memLevel")
160                     )
161                 )
162         );
163     }
164 
165     /**
166      * Create a new {@link HttpContentCompressor} Instance with specified
167      * {@link CompressionOptions}s and contentSizeThreshold set to {@code 0}
168      *
169      * @param compressionOptions {@link CompressionOptions} or {@code null} if the default
170      *        should be used.
171      */
172     public HttpContentCompressor(CompressionOptions... compressionOptions) {
173         this(0, compressionOptions);
174     }
175 
176     /**
177      * Create a new {@link HttpContentCompressor} instance with specified
178      * {@link CompressionOptions}s
179      *
180      * @param contentSizeThreshold
181      *        The response body is compressed when the size of the response
182      *        body exceeds the threshold. The value should be a non negative
183      *        number. {@code 0} will enable compression for all responses.
184      * @param compressionOptions {@link CompressionOptions} or {@code null}
185      *        if the default should be used.
186      */
187     public HttpContentCompressor(int contentSizeThreshold, CompressionOptions... compressionOptions) {
188         this(contentSizeThreshold, DEFAULT_MAX_PIPELINE_DEPTH, compressionOptions);
189     }
190 
191     /**
192      * Create a new {@link HttpContentCompressor} instance with specified
193      * {@link CompressionOptions}s
194      *
195      * @param contentSizeThreshold
196      *        The response body is compressed when the size of the response
197      *        body exceeds the threshold. The value should be a non negative
198      *        number. {@code 0} will enable compression for all responses.
199      * @param maxPipelineDepth
200      *        The maximum allowed depth of the encoding pipeline queue, the default
201      *        value is set to {@link DEFAULT_MAX_PIPELINE_DEPTH}
202      * @param compressionOptions {@link CompressionOptions} or {@code null}
203      *        if the default should be used.
204      */
205     public HttpContentCompressor(int contentSizeThreshold, int maxPipelineDepth,
206             CompressionOptions... compressionOptions) {
207         super(maxPipelineDepth);
208         this.contentSizeThreshold = ObjectUtil.checkPositiveOrZero(contentSizeThreshold, "contentSizeThreshold");
209         BrotliOptions brotliOptions = null;
210         GzipOptions gzipOptions = null;
211         DeflateOptions deflateOptions = null;
212         ZstdOptions zstdOptions = null;
213         SnappyOptions snappyOptions = null;
214         if (compressionOptions == null || compressionOptions.length == 0) {
215             compressionOptions = DEFAULT_COMPRESSION_OPTIONS;
216         }
217 
218         ObjectUtil.deepCheckNotNull("compressionOptions", compressionOptions);
219         for (CompressionOptions compressionOption : compressionOptions) {
220             // BrotliOptions' class initialization depends on Brotli classes being on the classpath.
221             // The Brotli.isAvailable check ensures that BrotliOptions will only get instantiated if Brotli is
222             // on the classpath.
223             // This results in the static analysis of native-image identifying the instanceof BrotliOptions check
224             // and thus BrotliOptions itself as unreachable, enabling native-image to link all classes
225             // at build time and not complain about the missing Brotli classes.
226             if (Brotli.isAvailable() && compressionOption instanceof BrotliOptions) {
227                 brotliOptions = (BrotliOptions) compressionOption;
228             } else if (compressionOption instanceof GzipOptions) {
229                 gzipOptions = (GzipOptions) compressionOption;
230             } else if (compressionOption instanceof DeflateOptions) {
231                 deflateOptions = (DeflateOptions) compressionOption;
232             } else if (Zstd.isAvailable() && compressionOption instanceof ZstdOptions) {
233                 zstdOptions = (ZstdOptions) compressionOption;
234             } else if (compressionOption instanceof SnappyOptions) {
235                 snappyOptions = (SnappyOptions) compressionOption;
236             } else {
237                 throw new IllegalArgumentException("Unsupported " + CompressionOptions.class.getSimpleName() +
238                         ": " + compressionOption);
239             }
240         }
241 
242         this.gzipOptions = gzipOptions;
243         this.deflateOptions = deflateOptions;
244         this.brotliOptions = brotliOptions;
245         this.zstdOptions = zstdOptions;
246         this.snappyOptions = snappyOptions;
247     }
248 
249     @Deprecated
250     private static CompressionOptions[] defaultCompressionOptions(
251             GzipOptions gzipOptions, DeflateOptions deflateOptions) {
252         List<CompressionOptions> options = new ArrayList<>(5);
253         options.add(gzipOptions);
254         options.add(deflateOptions);
255         options.add(StandardCompressionOptions.snappy());
256 
257         if (Brotli.isAvailable()) {
258             options.add(StandardCompressionOptions.brotli());
259         }
260         if (Zstd.isAvailable()) {
261             options.add(StandardCompressionOptions.zstd());
262         }
263         return options.toArray(new CompressionOptions[0]);
264     }
265 
266     @Override
267     public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
268         this.ctx = ctx;
269     }
270 
271     @Override
272     protected Result beginEncode(HttpResponse httpResponse, String acceptEncoding) throws Exception {
273         if (this.contentSizeThreshold > 0) {
274             if (httpResponse instanceof HttpContent &&
275                     ((HttpContent) httpResponse).content().readableBytes() < contentSizeThreshold) {
276                 return null;
277             }
278         }
279 
280         String contentEncoding = httpResponse.headers().get(HttpHeaderNames.CONTENT_ENCODING);
281         if (contentEncoding != null) {
282             // Content-Encoding was set, either as something specific or as the IDENTITY encoding
283             // Therefore, we should NOT encode here
284             return null;
285         }
286 
287         String targetContentEncoding = determineEncoding(acceptEncoding);
288         if (targetContentEncoding == null) {
289             return null;
290         }
291 
292         Channel channel = ctx.channel();
293         return new Result(targetContentEncoding,
294                 EmbeddedChannel.builder()
295                         .channelId(channel.id())
296                         .hasDisconnect(channel.metadata().hasDisconnect())
297                         .config(channel.config())
298                         .handlers(createEncoderFor(targetContentEncoding))
299                         .build());
300     }
301 
302     private MessageToByteEncoder<ByteBuf> createEncoderFor(String targetContentEncoding) {
303         switch (targetContentEncoding) {
304             case "br":
305                 if (brotliOptions == null || !Brotli.isAvailable()) {
306                     throw new IllegalStateException("Brotli not configured");
307                 }
308                 return new BrotliEncoder(brotliOptions.parameters());
309             case "zstd":
310                 if (zstdOptions == null) {
311                     throw new IllegalStateException("Zstd not configured");
312                 }
313                 return new ZstdEncoder(
314                         zstdOptions.compressionLevel(),
315                         zstdOptions.blockSize(),
316                         zstdOptions.maxEncodeSize()
317                 );
318             case "snappy":
319                 if (snappyOptions == null) {
320                     throw new IllegalStateException("Snappy not configured");
321                 }
322                 return new SnappyFrameEncoder();
323             case "gzip":
324                 if (gzipOptions == null) {
325                     throw new IllegalStateException("Gzip not configured");
326                 }
327                 return ZlibCodecFactory.newZlibEncoder(
328                         ZlibWrapper.GZIP,
329                         gzipOptions.compressionLevel(),
330                         gzipOptions.windowBits(),
331                         gzipOptions.memLevel()
332                 );
333             case "deflate":
334                 if (deflateOptions == null) {
335                     throw new IllegalStateException("Deflate not configured");
336                 }
337                 return ZlibCodecFactory.newZlibEncoder(
338                         ZlibWrapper.ZLIB,
339                         deflateOptions.compressionLevel(),
340                         deflateOptions.windowBits(),
341                         deflateOptions.memLevel()
342                 );
343             default:
344                 throw new IllegalStateException("Unknown encoding: " + targetContentEncoding);
345         }
346     }
347 
348     @SuppressWarnings("FloatingPointEquality")
349     protected String determineEncoding(String acceptEncoding) {
350         float starQ = -1.0f;
351         float brQ = -1.0f;
352         float zstdQ = -1.0f;
353         float snappyQ = -1.0f;
354         float gzipQ = -1.0f;
355         float deflateQ = -1.0f;
356 
357         int start = 0;
358         int length = acceptEncoding.length();
359         while (start < length) {
360             int comma = acceptEncoding.indexOf(',', start);
361             if (comma == -1) {
362                 comma = length;
363             }
364             String encoding = acceptEncoding.substring(start, comma);
365             float q = 1.0f;
366             int equalsPos = encoding.indexOf('=');
367             if (equalsPos != -1) {
368                 try {
369                     q = Float.parseFloat(encoding.substring(equalsPos + 1));
370                 } catch (NumberFormatException e) {
371                     // Ignore encoding
372                     q = 0.0f;
373                 }
374             }
375             if (encoding.contains("*")) {
376                 starQ = q;
377             } else if (encoding.contains("br") && q > brQ) {
378                 brQ = q;
379             } else if (encoding.contains("zstd") && q > zstdQ) {
380                 zstdQ = q;
381             } else if (encoding.contains("snappy") && q > snappyQ) {
382                 snappyQ = q;
383             } else if (encoding.contains("gzip") && q > gzipQ) {
384                 gzipQ = q;
385             } else if (encoding.contains("deflate") && q > deflateQ) {
386                 deflateQ = q;
387             }
388             start = comma + 1;
389         }
390         if (brQ > 0.0f || zstdQ > 0.0f || snappyQ > 0.0f || gzipQ > 0.0f || deflateQ > 0.0f) {
391             if (brQ != -1.0f && brQ >= zstdQ && this.brotliOptions != null) {
392                 return "br";
393             } else if (zstdQ != -1.0f && zstdQ >= snappyQ && this.zstdOptions != null) {
394                 return "zstd";
395             } else if (snappyQ != -1.0f && snappyQ >= gzipQ && this.snappyOptions != null) {
396                 return "snappy";
397             } else if (gzipQ != -1.0f && gzipQ >= deflateQ && this.gzipOptions != null) {
398                 return "gzip";
399             } else if (deflateQ != -1.0f && this.deflateOptions != null) {
400                 return "deflate";
401             }
402         }
403         if (starQ > 0.0f) {
404             if (brQ == -1.0f && this.brotliOptions != null) {
405                 return "br";
406             }
407             if (zstdQ == -1.0f && this.zstdOptions != null) {
408                 return "zstd";
409             }
410             if (snappyQ == -1.0f && this.snappyOptions != null) {
411                 return "snappy";
412             }
413             if (gzipQ == -1.0f && this.gzipOptions != null) {
414                 return "gzip";
415             }
416             if (deflateQ == -1.0f && this.deflateOptions != null) {
417                 return "deflate";
418             }
419         }
420         return null;
421     }
422 
423     @Deprecated
424     @SuppressWarnings("FloatingPointEquality")
425     protected ZlibWrapper determineWrapper(String acceptEncoding) {
426         float starQ = -1.0f;
427         float gzipQ = -1.0f;
428         float deflateQ = -1.0f;
429         for (String encoding : acceptEncoding.split(",")) {
430             float q = 1.0f;
431             int equalsPos = encoding.indexOf('=');
432             if (equalsPos != -1) {
433                 try {
434                     q = Float.parseFloat(encoding.substring(equalsPos + 1));
435                 } catch (NumberFormatException e) {
436                     // Ignore encoding
437                     q = 0.0f;
438                 }
439             }
440             if (encoding.contains("*")) {
441                 starQ = q;
442             } else if (encoding.contains("gzip") && q > gzipQ) {
443                 gzipQ = q;
444             } else if (encoding.contains("deflate") && q > deflateQ) {
445                 deflateQ = q;
446             }
447         }
448         if (gzipQ > 0.0f || deflateQ > 0.0f) {
449             if (gzipQ >= deflateQ) {
450                 return ZlibWrapper.GZIP;
451             } else {
452                 return ZlibWrapper.ZLIB;
453             }
454         }
455         if (starQ > 0.0f) {
456             if (gzipQ == -1.0f) {
457                 return ZlibWrapper.GZIP;
458             }
459             if (deflateQ == -1.0f) {
460                 return ZlibWrapper.ZLIB;
461             }
462         }
463         return null;
464     }
465 
466 }