Cookieとは……と書きかけたのですが、 どうもこれを正確に説明するのはちょっと難しいようです。 まずは世間一般の説明を引用してみます。
以下はWikipediaより。
http://ja.wikipedia.org/wiki/HTTP_cookie
HTTP cookie(エイチティーティーピークッキー、単にクッキーとも表記される)は、 RFC 6265などで定義されたHTTPにおけるウェブサーバとウェブブラウザ間で 状態を管理するプロトコル、 またそこで用いられるウェブブラウザに保存された情報のことを指す。 ユーザ識別やセッション管理を実現する目的などに利用される。
次はe-WordsのIT用語辞典。
http://e-words.jp/w/Cookie.html
Webサイトの提供者が、Webブラウザを通じて訪問者のコンピュータに 一時的にデータを書き込んで保存させる仕組み。
どちらも間違ってはいないと思うのですが、 Cookieは必ずしも「状態を管理する」(Wikipediaより)ためのものではないですし、 必ずしも「Webサイトの提供者(=Webサーバ?)が」(IT用語辞典)発行する ものでもありません。
Cookieは、サーバ側で発行する場合、 HTTPレスポンスヘッダを使って、ブラウザに送られます。 ブラウザは、それを指定された期間だけ保持し、 以後、そのサーバにリクエストを送るときには、 単純な画像取得等を含むすべて※1 のHTTPリクエストにおいて、 HTTPリクエストヘッダにCookie情報を埋め込みます。
以前にも少し触れましたが、 HTTPはステートレスなプロトコルです。 Aさんが使っているブラウザがサーバに立て続けに2回リクエストを送ったとして、 サーバには、1回目のリクエストと2回目のリクエストはまったく独立したものとして 届きます。これでは、 「ショッピングサイトにログインしてカートに商品を登録していく」 といったことができません。
しかし、Cookieを使って、ブラウザごとに固有のIDを送付すれば、 以後のリクエストにはすべてそのIDが付いてきますから、 サーバ側でどのブラウザからの接続であるかを識別できるわけです。 現在、Cookieの使用法としては、これが最も重要な使用法といえるでしょう。 ただし、Cookieの仕組み自体は、その用途に限定されるものではありません。 たとえばサイトのデザインや文字サイズを選択できるような機能を作ったときには、 ユーザごとの設定をCookieで保持しておくことができるでしょう。
また、CookieはJavaScriptを用いてクライアント側で発行することも可能です。 一例として、うちのWebサイトの掲示板では ハンドル名やパスワードを毎回入力しなくてよいようにするためにCookieを 使用していますが、このCookieはクライアント側で生成しています。 そして、このCookieは、サーバに送られはしますが、 うちの掲示板では、サーバ側でそれを無視しています。この用途では、単に 「ブラウザにちょっとした情報を覚えておいてもらう」ためだけに Cookieを使用しているわけです。
ごちゃごちゃ説明するより、 ここを読んでいるような人にとっては 実際のHTTPリクエスト/レスポンスを見たほうが理解が早いと思いますので、 HttpFoxでキャプチャしながらGoogle(google.co.jp)につないでみます (実験前に、ブラウザのCookieとキャッシュはクリアしています)。
これを見ると、初回のgoogle.co.jpへのアクセス (キャプチャで反転している行)に対するレスポンスヘッダで、 以下のふたつが返されていることがわかります。
上の「PREF=ID=ddbf……」のほうについて、全体を見ると、 以下のようになっています。
Set-Cookie:PREF=ID=ddbfd8d2cf17bd19:FF=0:TM=1396769006:(行継続) LM=1396769006:S=MCu-T1FuYci762u-; (行継続) expires=Tue, 05-Apr-2016 07:23:26 GMT; path=/; domain=.google.co.jp
私のブラウザはFirefoxなのですが、Firefoxのメニューから 「オプション」→「プライバシー」→「Cookieの表示」にて google.co.jpのCookieを表示すると、以下のようになっています。
ここからわかることは以下です。
次は、google.co.jpへのアクセスで飛んだリダイレクトの中から、 2行目を見てみます。
リクエストヘッダで、以下が送られていることがわかります。
Cookie:PREF=ID=ddbfd8d2cf17bd19:FF=0:TM=1396769006:(行継続) LM=1396769006:S=MCu-T1FuYci762u-;(行継続) NID=67=DdYtbVxACQ6-1ZrAUQo8gl3-_WHZ-sTFRSxW1BlgxwtZPt8Cwp(行継続) YdvFQyngTvjtgppWNMtHtmxdPYSexfEvcqo2V-nDoAIWk5765l1(行継続) sipCtNq69tZ6ttiX4rX9c7373fB
サーバから送られてくるレスポンスヘッダでは、 「Set-Cookie」という名前で、(Googleの場合は)NID, PREFという ふたつの値が送られてきたところ、 クライアントから送るリクエストヘッダでは、 「Cookie」という名前で、NID, PREFの両方を一度に送っていることがわかります。
単なる画像取得のためのリクエストヘッダでも、 同様にCookieが送られています(下図参照)。
まとめると、サーバとクライアントの間では、 以下のようなやり取りが行われています。
Set-Cookie:(Cookie名)=(値); expires=Tue, 05-Apr-2016 07:23:26 GMT; path=/; domain=.google.co.jp Set-Cookie:(Cookie名)=(値); expires=Tue, 06-Oct-2014 07:23:26 GMT; path=/; domain=.google.co.jp
Cookie:(Cookie名)=(値);(Cookie名)=(値)
ブラウザにより異なりますが、Cookieの数やサイズには制限があります。
新しめのブラウザの制限については、たとえば以下のページに記載がありますが、
http://d.hatena.ne.jp/hosikiti/20130925/1380098776
Webサイトを作る側としては、最新ブラウザの(一番ゆるいであろう) 制限に合わせたのでは古いブラウザを使っている閲覧者が困るわけで、 規格化された中では一番古い RFC2109 の6.3を見ると以下の記述があります。
- at least 300 cookies
最低 300 個のクッキー- at least 4096 bytes per cookie (as measured by the size of the characters that comprise the cookie non-terminal in the syntax description of the Set-Cookie header)
ひとつのクッキーあたり最低4096バイト (Set-Cookieの構文規則における、cookie非終端子を構成する文字のサイズで数える)。- at least 20 cookies per unique host or domain name
個別のホストまたはドメイン名ごとに最低20個。
これを見ると、「ホストもしくはドメイン名ごとに最低20個」 という制限がかなりきついように見えます ※2。
先のGoogleのCookieの例では、「PREF」という名前のCookieに対し、 「ID=ddbfd8d2cf17bd19:FF=0:TM=1396769006:(後略)」という値が 設定されていました。これを見ると、本当に保持したいのは IDやFFやTMであるところ、 ドメインあたりのCookieの総数制限にひっかからないよう、 ひとつのCookieにたくさんの値を突っ込んでいると推測できます。 ひとつのCookieあたり4096バイトという制限は、 ドメインごとに20Cookieという制限より実用上ゆるいからです。
上記のGoogleの例では、PREF, NIDというふたつのCookieについて、 最初に登場したのはレスポンスヘッダでした。 つまり、このふたつのCookieについては、サーバ側で発行されています。
ただし、Cookieはクライアント側で発行することもできます。
このWebサイト(kmaebashi.com)の掲示板においても クライアント側でCookieを発行しています。 その用途は、ハンドル名、URL、削除用パスワードについて、 次回投稿時に再入力しなくてよいようにすることです。 テスト掲示板で その動きを見てみましょう。
ハンドル名、件名、リンクURL、本文、削除用パスワードを入れて 掲示板に投稿し(下のほうにある「ほげぴよ」はspam対策です。 こちらを参照してください。)、投稿します。
うちの掲示板では、投稿すると、プレビュー画面が表示されます。 それを表示するためのリクエストとレスポンスをHttpFoxにて表示したものが以下です。
クライアントから、Cookieリクエストヘッダが送られていることがわかります。 そして、このケースでは、サーバはこのリクエストを無視しています (だからレスポンスにもSet-Cookieヘッダは入っていません)。
上記を実現しているJavaScriptについては、 こちらを参照してください。
最近は、Cookieの用途もいろいろと多様化していますが、 以前は、このように掲示板のハンドル名等をクライアント側で 保持するという用途が割と主流だった頃がありました。
そのような用途で結構使われていたにも関わらず、 「Cookieとは」とかでぐぐって調べるとサーバ側で発行する Cookieのことばかり書いてあり、 そのくせ具体的なJavaScriptのコードサンプルを探すと クライアントで発行する例がぞろぞろひっかかってくる、という、 なんだかちょっといびつな状況が、 ことWebアプリケーションの勉強においては、 今でもいろいろ続いているような気がします。
さきほどのGoogleのCookieの例において、Set-Cookieヘッダには、 Cookieの名前と値のほか、以下のような情報が付与されていました。
expires=Tue, 05-Apr-2016 07:23:26 GMT; path=/; domain=.google.co.jp
このexpiresやpathやdomainを、Cookieの属性と呼びます。
Cookieの属性について、Cookieに関する最新のRFCである RFC6265を 参照すると、以下が記載されています。
上記を見ると、Expires属性とMax-Age属性が ほぼ同じ機能を持っているように見えます。 Expires属性は、Netscapeが最初にCookieを導入した際から存在した属性ですが、 Max-Age属性は、IETFが標準化を試みた RFC2109から登場しました。 しかし、対応ブラウザがなかなか増えなかったためか、 昔ながらのExpires属性が今でも広く使われています。 JavaのServlet APIにおいても、 デフォルトではNetscape仕様のCookieが送られます ( setVersion()メソッドにより変更可能)。 それにしては、有効期限を設定するメソッド名はsetMaxAge()であり、 指定するのもMax-Age風に秒数なので紛らわしいですが……
では、開発中のへなちょこサーブレットコンテナHenacatに、 Cookieを発行する仕組みを組み込むことにします。
Henacatでは、Servlet APIにおいてCookieに関連するクラスとメソッドのうち、 以下を実装するものとします。
Cookieクラスは、ひとつのCookieを表現するクラスです。 コンストラクタのほか、各種属性のgetter/setterがあります。
クライアントから送られてきたCookieをアプリケーションが 取得できるように、HttpServletRequestにメソッドを追加します。
サーバからクライアントにCookieを送り返すため、 HttpServletResponseにメソッドを追加します。
まずはCookieの状態を保持するオブジェクト、Cookieクラスから。
1: package com.kmaebashi.henacat.servletinterfaces; 2: 3: public class Cookie { 4: private String name; 5: private String value; 6: private String domain; 7: private int maxAge = -1; 8: private String path; 9: private boolean secure; 10: private boolean httpOnly; 11: 12: public Cookie(String name, String value) { 13: this.name = name; 14: this.value = value; 15: } 16: 17: public void setDomain(String pattern) { 18: this.domain = pattern; 19: } 20: 21: public String getDomain() { 22: return this.domain; 23: } 24: 25: public void setMaxAge(int expiry) { 26: this.maxAge = expiry; 27: } 28: 29: public int getMaxAge() { 30: return this.maxAge; 31: } 32: 33: public void setPath(String uri) { 34: this.path = uri; 35: } 36: 37: public String getPath() { 38: return this.path; 39: } 40: 41: public void setSecure(boolean flag) { 42: this.secure = flag; 43: } 44: 45: public boolean getSecure() { 46: return this.secure; 47: } 48: 49: public void setHttpOnly(boolean httpOnly) { 50: this.httpOnly = httpOnly; 51: } 52: 53: public boolean isHttpOnly() { 54: return this.httpOnly; 55: } 56: 57: public String getName() { 58: return this.name; 59: } 60: 61: public void setValue(String newValue) { 62: this.value = newValue; 63: } 64: 65: public String getValue() { 66: return this.value; 67: } 68: }
なんのことはありません。 単に、Cookieヘッダでクライアントから受け取ったり、 Set-Cookieヘッダでクライアントに送る情報を詰め込むための構造体的なクラスです。
クライアントから受け取ったCookieヘッダの内容を元に Cookieクラスのインスタンスを生成する処理は、 HttpServletRequestImpl内に記述しました。 以下は今回の修正箇所の抜粋です。
41: @Override 42: public Cookie[] getCookies() { 43: return this.cookies; 44: } 45: 46: private static Cookie[] parseCookies(String cookieString) { 47: if (cookieString == null) { 48: return null; 49: } 50: String[] cookiePairArray = cookieString.split(";"); 51: Cookie[] ret = new Cookie[cookiePairArray.length]; 52: int cookieCount = 0; 53: 54: for (String cookiePair : cookiePairArray) { 55: String[] pair = cookiePair.split("=", 2); 56: 57: ret[cookieCount] = new Cookie(pair[0], pair[1]); 58: cookieCount++; 59: } 60: 61: return ret; 62: } 63: 64: HttpServletRequestImpl(String method, Map<String, String> requestHeader, 65: Map<String, String> parameterMap) { 66: this.method = method; 67: this.requestHeader = requestHeader; 68: this.cookies = parseCookies(requestHeader.get("COOKIE")); 69: this.parameterMap = parameterMap; 70: } 71: }
41行目〜44行目はインタフェースとしてアプリケーションに公開している getCookies()メソッドです。インスタンスフィールドとして保持している Cookieの配列を返しているだけです。
Cookieヘッダの解釈は、46行目からのparseCookies()で行っています。 クライアントから送られるCookieヘッダは、単に「(Cookie名)=(値)」 をセミコロン区切りで並べてあるだけなので、 「;」でsplit()した後「=」でsplitして、Cookieの名前と値を取得しています。
Cookieの値にはセミコロンが含まれないことは RFC6265では保証されていますが、 「=」については保証されていません(それどころか、 上記のGoogleの例でも「=」は含まれていました)。 Cookieヘッダに「A=B=C」と記述されていたらそれは 「A」という名前のCookieの値が「B=C」である、 と解釈しなければいけません。ここでは、それを実現するために、 split()メソッドの第2引数に「2」を渡しています。 split()メソッドの仕様の詳細については こちらをどうぞ。
サーバからCookieをクライアントに送付するとき、 Cookieは、HTTPレスポンスヘッダとして送付されます。 前回のHenacatの実装では、 getWriter()を呼んだ時点でレスポンスヘッダの出力まで行ってしまっていました。 これでは、getWriter()の呼び出し以降にHttpServletResponseの addCookie()を呼び出しても無駄です。 レスポンスヘッダはもう出力されてしまっているからです。
addCookie()はgetWriter()の呼び出し前に行っておけ、 というルールにするのはアプリケーションプログラマにとって結構不便ですし、 Tomcat等ではgetWriter()以後のaddCookie()も(ある程度?)効くわけですから、 いくらへなちょこサーブレットコンテナといっても ここは合わせておきたいところです。
そこで、今回、Henacatでは、 アプリケーションがgetWriter()に対して出力した内容を すべてバッファリングするようにしました。
この処理を記述しているのはServletService.javaです。 該当箇所を抜粋します。
68: ByteArrayOutputStream outputBuffer = new ByteArrayOutputStream(); 69: HttpServletResponseImpl resp 70: = new HttpServletResponseImpl(outputBuffer); 71: info.servlet.service(req, resp); 72: 73: ResponseHeaderGenerator hg 74: = new ResponseHeaderGeneratorImpl(resp.cookies); 75: SendResponse.sendOkResponseHeader(output, resp.contentType, hg); 76: resp.printWriter.flush(); 77: byte[] outputBytes = outputBuffer.toByteArray(); 78: for (byte b: outputBytes) { 79: output.write((int)b); 80: } 81: } 82: }
見てのとおり、バッファとしてByteArrayOutputStreamを作成し、 HttpServletResponseにはそれを渡しています。 そして、71行目でサーブレットのservice()メソッドを呼び出した後、 75行目でSendResponse.sendOkResponseHeader()にてレスポンスヘッダを出力し、 その後、ByteArrayOutputStreamに蓄積した内容を出力しています。
その途中、73行目でResponseHeaderGeneratorImplというクラスを newしており、それをSendResponse.sendOkResponseHeader()に渡しています。 このResponseHeaderGeneratorImplが、今回Set-Cookieヘッダを生成している箇所です。
以下がResponseHeaderGeneraterImpl.javaです。新規クラスなので全掲載します。
1: package com.kmaebashi.henacat.servletimpl; 2: import com.kmaebashi.henacat.util.*; 3: import com.kmaebashi.henacat.servletinterfaces.*; 4: import java.util.*; 5: import java.io.*; 6: import java.text.*; 7: 8: class ResponseHeaderGeneratorImpl implements ResponseHeaderGenerator { 9: private ArrayList<Cookie> cookies; 10: 11: private static String getCookieDateString(Calendar cal) { 12: DateFormat df = new SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss", 13: Locale.US); 14: df.setTimeZone(cal.getTimeZone()); 15: Date now = cal.getTime(); 16: return df.format(cal.getTime()) + " GMT"; 17: } 18: 19: public void generate(OutputStream output) throws IOException { 20: for (Cookie cookie : cookies) { 21: String header; 22: header = "Set-Cookie: " 23: + cookie.getName() + "=" + cookie.getValue(); 24: 25: if (cookie.getDomain() != null) { 26: header += "; Domain=" + cookie.getDomain(); 27: } 28: if (cookie.getMaxAge() > 0) { 29: Calendar cal 30: = Calendar.getInstance(TimeZone.getTimeZone("UTC")); 31: cal.add(Calendar.SECOND, cookie.getMaxAge()); 32: header += "; Expires=" + getCookieDateString(cal); 33: } else if (cookie.getMaxAge() == 0) { 34: Calendar cal 35: = Calendar.getInstance(TimeZone.getTimeZone("UTC")); 36: cal.set(1970, 0, 1, 0, 0, 10); 37: header += "; Expires=" + getCookieDateString(cal); 38: } 39: if (cookie.getPath() != null) { 40: header += "; Path=" + cookie.getPath(); 41: } 42: if (cookie.getSecure()) { 43: header += "; Secure"; 44: } 45: if (cookie.isHttpOnly()) { 46: header += "; HttpOnly"; 47: } 48: Util.writeLine(output, header); 49: } 50: } 51: 52: ResponseHeaderGeneratorImpl(ArrayList<Cookie> cookies) { 53: this.cookies = cookies; 54: } 55: }
19行目からのgenerate()メソッドがSet-Cookieヘッダを生成しているところです。
22〜23行目で名前と値を出力し、25行目以降で各属性を出力しています。 属性の順序は、Tomcatの出力に合わせました。
基本的にCookieクラスに設定されている内容を出力しているだけですが、 多少面倒なことをしているのはExpires属性の出力です(28〜38行目)。 前述の通り、Servlet APIでは、setMaxAge()の引数が0の時は、 過去日付(Tomcatの場合1970年1月1日0時0分0秒)を、 負の値を与えるとExpires属性自体を出力しないことになっています。 Henacatの実装では、28行目からが正の値の時、つまり現在時刻に 指定した秒数を加算して出力する処理で、 33行目からが過去日付を出力する処理です。
11行目からのgetCookieDateString()メソッドは、 Cookieに付与する日付をフォーマットするための下請けメソッドです。
日付をフォーマットする下請けメソッドは、Henacatでは utilパッケージのUtil.javaにgetDateStringUtc()メソッドとして 既に用意してあります。 にもかかわらずなぜこれが必要になったのかは こちらの補足を参照してください。
ResponseHeaderGeneratorImplクラスのgenerate()メソッドを呼び出しているのは、 SendResponse.sendOkResponseHeader()です。 次はこれを見ていきます。
SendResponseクラスに今回追加したメソッドは、 以下のsendOkResponseHeader()メソッドです(6〜19行目)。 21行目以降のsendOkResponse()メソッドは元からあったものです ※3。 (このクラスにはsendNotFoundResponse()等のメソッドもあるのですが、 今回記載は省略しています)。
5: public class SendResponse { 6: public static void sendOkResponseHeader(OutputStream output, 7: String contentType, 8: ResponseHeaderGenerator hg) 9: throws IOException { 10: Util.writeLine(output, "HTTP/1.1 200 OK"); 11: Util.writeLine(output, "Date: " + Util.getDateStringUtc()); 12: Util.writeLine(output, "Server: Henacat"); 13: Util.writeLine(output, "Connection: close"); 14: Util.writeLine(output, "Content-type: " + contentType); 15: hg.generate(output); 16: Util.writeLine(output, ""); 17: } 18: 19: public static void sendOkResponse(OutputStream output, InputStream fis, 20: String ext) throws Exception { 21: Util.writeLine(output, "HTTP/1.1 200 OK"); 22: Util.writeLine(output, "Date: " + Util.getDateStringUtc()); 23: Util.writeLine(output, "Server: Henacat"); 24: Util.writeLine(output, "Connection: close"); 25: Util.writeLine(output, "Content-type: " 26: + Util.getContentType(ext)); 27: Util.writeLine(output, ""); 28: 29: int ch; 30: while ((ch = fis.read()) != -1) { 31: output.write(ch); 32: } 33: }
sendOkResponse()メソッドは、 通常のWebページを出力するときに使用するもので、 webserverパッケージのServerThreadクラスから呼び出されています。 このメソッドは、レスポンスボディも出力しています。
それに対し、sendOkResponseHeader()は、サーブレットの出力で使用するもので、 servletimplパッケージのServletServiceクラスから呼び出され、 ヘッダのみを出力します。 中身を見ればわかるように似たようなメソッドなので、utilパッケージの SendResponseクラスにまとめているわけです。 「これだけ似た実装なら、こんなコピペプログラミングじみたことやってないで、 ヘッダ出力の機能を切り出して共通化すべきなのでは」 という指摘は大いにわかるのですが、企画の趣旨上、 出力しているものをなるべく生で書いたほうがわかりやすいのでは、 という趣旨でこうしています。
そして、sendOkResponseHeader()メソッドは、 ResponseHeaderGeneratorインタフェース経由で、 ResponseHeaderGeneratorImplのgenarete()メソッドを呼び出しています (15行目) このように、わざわざResponseHeaderGeneratorインタフェースを作成し、 実装であるResponseHeaderGeneratorImplを分けているのは以下の理由によります。
まず、SendResponseクラスはutilパッケージに存在します。 このパッケージは、 逆にwebserverパッケージ、servletimplパッケージから使用されますが、 utilパッケージ自体はどのパッケージにも依存しないようにすべきだと思われます (なにせutilパッケージですし)。
今回は、構造上、ResponseHeaderGeneratorはservletimplに置きたいところですが、 それをutilパッケージから呼び出すとutilがservletimplに依存することに なってしまうので、インタフェースを介すことで依存関係を絶っているわけです (ResponseHeaderGeneratorインタフェース自体はutilパッケージに配置してあります)。
今回のケースでこれが「よい設計」なのかどうかは異論もあるかと思いますが、 私はこうしてみました。
上記のソース一式をzipで固めたものは、 ここからダウンロードできます。
Henacatの実装では、CookieのExpires属性の日付をフォーマットするために、 ResponseHeaderGeneratorImpl.javaにて、 getCookieDateString()というメソッドを作っています。
これを見て、
あれ? HTTPで日付なんてあちこちに出てくるわけで、 日付をフォーマットするメソッドなんかどこかにひとつ作っておけばいいだろう。
なぜCookie専用のメソッドなんか作るんだ。コピペ脳かよ!
と思う人がいるかもしれません。
ところが不思議なことに、Cookieの日付は、 HTTPの別のところの日付とはフォーマットが異なります。 たとえばこちらで挙げた Dateレスポンスヘッダのフォーマットは以下のようになっています。
Tue, 30 Jul 2013 17:47:09 GMT
それに対し、Set-Cookieのフォーマットは以下です。
Tue, 05-Apr-2016 07:23:26 GMT
Set-Cookieの方では、日、月、年の間にハイフンが入っています。
実のところ、Cookieの最新の仕様であるRFC6265ではこの日付は RFC2616, Section 3.3.1で定義されると書いてあって、 RFC2616というのはつまりHTTP1.1の仕様ですから、 Cookieの日付フォーマットは HTTPレスポンスヘッダ等のフォーマットと同じでよい(というか、 最新のRFCでは同じにすることが求められている)はずなのですが、 Netscapeの当初の仕様書ではハイフン入りのフォーマットになっていて、 現状、これが引き継がれているようです。
参考: Cookieの日付形式
Cookieの送受信ができるようになったので、 次はこれを使ってセッションを実装しようと考えています。
そこまでできれば、Webアプリケーションの「本当の基礎」はひととおり 眺めたことになるかなあ、と思います。