命名ミスった問題(Accept-Encoding: deflate)

なるほど。

Deflate compression browser compatibility and advantages over GZIPより:

” ‘Gzip’ is the gzip format, and ‘deflate’ is the zlib format. They should probably have called the second one ‘zlib’ instead to avoid confusion with the raw deflate compressed data format. While the HTTP 1.1 RFC 2616 correctly points to the zlib specification in RFC 1950 for the ‘deflate’ transfer encoding, there have been reports of servers and browsers that incorrectly produce or expect raw deflate data per the deflate specification in RFC 1951, most notably Microsoft products. So even though the ‘deflate’ transfer encoding using the zlib format would be the more efficient approach (and in fact exactly what the zlib format was designed for), using the ‘gzip’ transfer encoding is probably more reliable due to an unfortunate choice of name on the part of the HTTP 1.1 authors.” (source: http://www.gzip.org/zlib/zlib_faq.html)

単純な自作 CGI (Python製)で、せっかくなので Accept-Encoding に反応してやろうかと思ってさ。

他にもほぼ同じことを言及してるこれ

It is apparently due to a misunderstanding resulting from the choice of the name “Deflate”. The http standard clearly states that “deflate” really means the zlib format:

The “zlib” format defined in RFC 1950 [31] in combination with
the “deflate” compression mechanism described in RFC 1951 [29].

However early Microsoft servers would incorrectly deliver raw deflate for “Deflate” (i.e. just RFC 1951 data without the zlib RFC 1950 wrapper). This caused problems, browsers had to try it both ways, and in the end it was simply more reliable to only use gzip.

The impact in bandwidth and execution time to use gzip instead of “Deflate” (zlib), is relatively small. So there we are and there it is likely to remain.

The difference is 12 more bytes for gzip and slightly more CPU time to calculate a CRC instead of an Adler-32.

RFC 2626(*)RFC 2616 に正しく従った Content-Encoding: deflate な反応をするサーバ相手に、C# プログラムで復号出来ない、なんて話題もみつけた。

ここでは、「What is the format of deflated content?」「What is what I call `zlib deflate format’?」「What is what I call `raw deflate format’?」と説明してる。

どうにも文章だけ読んでても今ひとつ確信が持てなかったので、実際に検証してみた。検証の核心部分のみ抜粋:

 1 # -*- coding: utf-8 -*-
 2 # ...(snip)...
 3 try:
 4     import StringIO
 5     StringIO = StringIO.StringIO
 6     BytesIO = StringIO
 7     bstdout = sys.stdout
 8     import codecs
 9     sstdout = codecs.getwriter('utf-8')(sys.stdout)
10 except ImportError:
11     # python 3
12     import io
13     from io import StringIO
14     from io import BytesIO
15     # ...(snip)...
16     sstdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
17     bstdout = sstdout.buffer
18 import re
19 # ...(snip)...
20 # ----------------------------------------------------------------------
21 # 「Accept-Encoding: gzip,deflate;q=0.9,identity;q=0.1」みたいなヘッダを
22 # 要求優先順に並べて返す
23 def _client_accepts_sorted(env):
24     _ = [
25         re.sub(r"\s+", "", (ae if ";" in ae else "%s;q=1" % ae)).split(";q=")
26         for ae in env.split(",")]
27 
28     return [ac
29         for q, ac in sorted(
30             [(float(ac[1]), ac[0]) for ac in _], key=lambda k: 1.0 - k[0])]
31 
32 # Accept-Encodingでの要求と、お応え出来るエンコーディングとのマッチングを
33 # して、クライアントが欲しがっている「圧縮」に出来るだけお応えしてみたり
34 # してみなかったり
35 def content_encode(content):
36     # これを True にしてみたり False にしてみたり
37     _NEED__RAW_DEFLATE = False
38 
39     def _gzip(content):
40         import gzip
41         out = BytesIO()
42         f = gzip.GzipFile(fileobj=out, mode='w')
43         f.write(content.encode("utf-8"))
44         f.close()
45         return out.getvalue()
46 
47     def _deflate(content):
48         def _ord(s):
49             if isinstance(s, (int,)):  # Python 3.x
50                 return s
51             return ord(s)
52 
53         import zlib
54         compressor = zlib.compressobj()
55         deflated = compressor.compress(content.encode("utf-8"))
56         deflated += compressor.flush()
57 
58         if _NEED__RAW_DEFLATE:
59             start = 2  # [CMF][FLG]        
60             fdb = int('00100000', 2)
61             if (_ord(deflated[1]) & fdb) == fdb: # if FLG.FDICT (bit 5)
62                 start += 4  # DICTID
63             return deflated[start:-4]
64         
65         return deflated
66 
67     # 以下を deflate だけにしてみたり
68     supported = {
69         "gzip": _gzip,
70         "deflate": _deflate,
71         "identity": lambda content: content,
72         }
73     
74     for ae in _client_accepts_sorted(os.environ.get("HTTP_ACCEPT_ENCODING", "identity")):
75         if ae in supported:
76             c = supported[ae](content)
77             return ae, len(c), c
78     # TODO: ("406", "Not Acceptable")
79 # ----------------------------------------------------------------------
80 # ...(snip)...
81 responce = ("200", "OK")
82 content_type = "text/html"
83 
84 content_encoding, content_length, content = content_encode(result)
85 if content_encoding == "identity":
86     bstdout = sstdout
87 
88 # 2015-07-04 06:50よりも前にここを見た方へ。以下のように間違ってましたが、「Status:」が正解です
89 #sstdout.write("HTTP/1.0 Status: " + " ".join(responce) + "\n")
90 sstdout.write("Status: " + " ".join(responce) + "\n")
91 
92 sstdout.write("Content-Type: %s\n" % content_type)
93 sstdout.write("Content-Length: %d\n" % content_length)
94 sstdout.write("Content-Encoding: %s\n" % content_encoding)
95 sstdout.write("\n")
96 sstdout.flush()
97 
98 bstdout.write(content)
99 bstdout.flush()

HTTP 1.1 における本来の deflate は

1 def _deflate(content):
2     import zlib
3     compressor = zlib.compressobj()
4     deflated = compressor.compress(content)
5     deflated += compressor.flush()
6     return deflated

だけでいい、ということになってる(*)。

実は色々読んでてわからなくなってたのが、Python の zlib.compressobj で「ヘッダとチェックサムも付けてるの?」という点。ドキュメントだけ漁ってても今ひとつ伝わりにくい。で、RFC 1950を真面目に読んで、zlib.compressobj が「CMF|FLG」「(if FLG.FDICT set)DICTID」「ADLER32」をちゃんと作ってる、と仮定して「compressed data」だけを取り出す「つもり」で書いたのがこの部分:

1             start = 2  # [CMF][FLG]        
2             fdb = int('00100000', 2)
3             if (_ord(deflated[1]) & fdb) == fdb: # if FLG.FDICT (bit 5)
4                 start += 4  # DICTID
5             return deflated[start:-4]  # -4は、ADLER32チェックサムの4バイト分

(_ordは単に Python 3.x と 2.7 の違いを吸収してるだけ。)

で、これで Chrome (バージョン 43.0.2357.130 m) と IE (11.0.9600.17843) のお相手をしてみた。

ら。「This caused problems, browsers had to try it both ways」でした、確かに。かつ、Python の zlib.compressobj で「ヘッダとチェックサムも付け」てました。上記検証コード(CGI)の _NEED__RAW_DEFLATE を True にしても False にしても、両ブラウザともに「deflate」として復号してしまった。

まずクライアント目線としては「deflate はアヤシゲなので gzip 優先がハッピー」でいいと思うのね。サーバは「deflate 以外受けとらねー」ってヤンチャなことを要求された場合、「raw deflate format しか知りません」なクライアントが存在することを前提にすべきなんだろうか、無視していいんだろうか? ほとんどの近代ブラウザはどちらでも出来る、ということはわかったけれど、どの程度誤った方に依存しているだろうか?

ところで「Accept-Encoding: compress」でなきゃイヤン、てのも弱る。そんな「前近代的」なクライアントはいないとは思うけれど…。(なお、sdch はもっと困る。)

13:51追記
「Chrome (バージョン 43.0.2357.130 m) と IE (11.0.9600.17843) のお相手」とともに、PHP からも検証してたのを書くの忘れてた。PHP の cURL でも同じです。「間違ったほうの deflate、正しいほうの deflate」、ともに復号出来る。PHP側は例えばこんな感じ:

 1     $curl = curl_init();
 2 
 3     $ua = 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)';
 4     $verbose_stream = tmpfile(); // receiver stream for cURL verbose output
 5     curl_setopt_array($curl, array(
 6         CURLOPT_HEADER         => FALSE,
 7         CURLOPT_RETURNTRANSFER => TRUE,
 8         CURLOPT_VERBOSE        => TRUE,
 9         CURLOPT_STDERR         => $verbose_stream,
10         CURLOPT_FAILONERROR    => TRUE, // fail on error code >= 400
11         CURLOPT_USERAGENT => $ua,
12         CURLOPT_REFERER => home_url('/'),
13 
14         // Accept-Encoding は CURLOPT_ENCODING で。
15         // (例えばChromeはjavascriptで明示的に制御しない限りは"gzip,deflate"。)
16         CURLOPT_ENCODING => "deflate"
17     ));
18 
19     curl_setopt($curl, CURLOPT_URL, "http://path/to/my.cgi");
20     curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
21     curl_setopt($curl, CURLOPT_POST, 1);
22 
23     $post_data = json_encode($params);
24 
25     curl_setopt($curl, CURLOPT_POSTFIELDS, $post_data);
26     $output = curl_exec($curl);

これを踏まえればなお、サーバは誤ったクライアントに優しい必要はないだろうと思う。ここでやった通り、「compressed data」だけ抽出するのはなんにも難しくないんだから。

あとこれとは関係なく、上で挙げた Python スクリプト、「Accept-Encoding: *」(We’ll accept any encoding you want to serve.)への考慮がないですな。

2015-07-04 13:51追記
「a qvalue of 0 means “not acceptable.”」への考慮も抜けている、のだけれども。ちと真面目に調べだしたらこの件に限らずヘンチクリンな話がいっぱい出てきたので、あとで別物としてまとめようかな。