へなちょこサーブレットコンテナもどき「Henacat」を作る

Webアプリケーションを作る

ここからは、いよいよWebアプリケーションを作ります。

昔は、WebアプリケーションといえばCGI(Common Gateway Interface)でした。CGIというのは、クエリストリングを 環境変数に設定しリクエストボディを標準入力に流し込む形で 外部プロセスを起動する、という仕組で、 昔はこれを使用して掲示板など多くのプログラムが作られていました。

今回作っているWebサーバに、CGIの機能を組み込むことはもちろん可能です。 ただ、今時CGIもないでしょうし、今回の開発言語はJavaなので、 ここでは「サーブレットコンテナ」を作ってみようと思います。

サーブレットというのは、Webサーバで動作するJavaプログラムのことで、 それを起動する仕組がサーブレットコンテナです。 CGIとは異なり、 毎回新規のプロセスを起動しないので高速という特長があります。 サーブレットコンテナの実装としては、 Apache Software Foundationが提供しているTomcatが有名です。

サーブレットの仕様は、Java Servlet APIとして定義されていますが、 ここではフルセットのサーブレットコンテナを作るつもりは毛頭ありません。 あくまで仕組の説明のためサンプルを例示するのが目的ですので、 最低限のAPIを実装したへなちょこサーブレットコンテナを作ります。

へなちょこなTomcatもどきですので、ここで作るサーブレットコンテナは 「Henacat」と呼ぶことにします。

掲示板サーブレット

サーブレットコンテナを作る前に、 まずは普通にTomcat上で動作するサーブレットを作ってみます。

例題としては、ありがちですが、掲示板を作ることにしましょう。

Tomcatはapache.orgから入手できます。私は、 以下のページのCoreのところから、 apache-tomcat-7.0.47.zipを入手しました。

http://tomcat.apache.org/download-70.cgi

これを展開したものをそのままどこかに置いて(私は、 C:\直下にTomcat7というフォルダを作って配置しました)、 binの下のstartup.batを実行すると、Tomcatが起動します。 ポート番号は8080ですので、http://localhost:8080/でアクセスして、 以下の画面が表示されたら成功です。

次に、Tomcat上で動かす掲示板サーブレットのプログラムを用意します。

今回作る掲示板の画面は、以下のようになります(見てのとおり、 ひどい手抜き掲示板です)。

これを実現するプログラムですが、 まずはサーブレットの本体となる、TestBBS.javaです ※1

  1: import java.io.*;
  2: import java.util.*;
  3: import javax.servlet.*;
  4: import javax.servlet.http.*;
  5: 
  6: public class TestBBS extends HttpServlet {
  7:     private ArrayList<Message> messageList = new ArrayList<Message>();
  8: 
  9:     private String htmlEscape(String src) {
 10:         return src.replace("&", "&amp;").replace("<", "&lt;")
 11:             .replace(">", "&gt;");
 12:     }
 13: 
 14:     @Override
 15:     public void doGet(HttpServletRequest request, HttpServletResponse response)
 16:         throws IOException, ServletException {
 17:         response.setContentType("text/html;charset=UTF-8");
 18:         PrintWriter out = response.getWriter();
 19:         out.println("<html>");
 20:         out.println("<head>");
 21:         out.println("<title>テスト掲示板</title>");
 22:         out.println("<head>");
 23:         out.println("<body>");
 24:         out.println("<h1>テスト掲示板</h1>");
 25:         out.println("<form action='/testbbs/TestBBS' method='post'>");
 26:         out.println("ハンドル名:<input type='text' name='handle'><br/>");
 27:         out.println("<textarea name='message' rows='4' cols='60'>"
 28:                     + "</textarea><br/>");
 29:         out.println("<input type='submit'/>");
 30:         out.println("</form>");
 31:         out.println("<hr/>");
 32:         for (Message message : messageList) {
 33:             out.println("<p>" + htmlEscape(message.handle) + " さん</p>");
 34:             out.println("<p>");
 35:             out.println(htmlEscape(message.message).replace("\r\n", "<br/>"));
 36:             out.println("</p><hr/>");
 37:         }
 38:         out.println("</body>");
 39:         out.println("</html>");
 40:     }
 41: 
 42:     @Override
 43:     public void doPost(HttpServletRequest request, HttpServletResponse response)
 44:         throws IOException, ServletException {
 45:         request.setCharacterEncoding("UTF-8");
 46:         Message newMessage = new Message(request.getParameter("handle"),
 47:                                          request.getParameter("message"));
 48:         messageList.add(0, newMessage);
 49:         doGet(request, response);
 50:     }
 51: }

今回の掲示板は、簡単にするため、 投稿をデータベースなどではなくメモリ上に保持します(なので、 Tomcatを再起動すると投稿は消えてしまいます)。 投稿を保持するクラスが以下のMessage.javaです。

  1: class Message {
  2:     String handle;
  3:     String message;
  4: 
  5:     Message(String handle, String message) {
  6:         this.handle = handle;
  7:         this.message = message;
  8:     }
  9: }

サーブレットは、 javax.servlet.http.HttpServletというクラスを継承し、 そのdoGet(), doPost()メソッドをオーバーライドして作ります。 その名のとおり、doGet()はGETのときに呼び出されるメソッド、 doPost()はPOSTのときに呼び出されるメソッドです。

TestBBS.javaのdoGet()メソッドでは、 まず17行目で、Content-Typeレスポンスヘッダの設定を行っています。 この呼び出しにより、 次の行で取得しているPrintWriterのエンコーディングも設定されます。 そして、取得したPrintWriterを使って、 レスポンスボディの内容を19行目以降で出力しています。

31行目までは固定のHTMLを出力、32行目からは、 投稿された内容を出力しています。

投稿された内容は、7行目で宣言しているmessageListという ArrayListで保持しています。 これは、TestBBSクラスのインスタンスフィールドですが、 ここで投稿内容を保持することで、 複数の人がこの掲示板を見ても同じ内容が見えます。 つまり、サーブレットのインスタンスは、 Tomcat内にひとつだけ生成され、 そのひとつのインスタンスがマルチスレッドでたくさんのリクエストを処理します (マルチスレッドなので本来はmessageListの排他制御が必要ですが、 今回は簡単にするため省略しています)。

投稿を受け付けるほうは、doPost()で行います。 引数として受け取ったHttpServletRequestクラスから、 POSTパラメタを取得し、messageListの先頭にadd()しています。 最後にdoGet()メソッドを呼び出すことで、 投稿した直後にも掲示板が表示されるようにしています (この方法には問題があります。後述)。

このプログラムをコンパイルするときは、 サーブレットのライブラリにclasspathを通す必要があります。 私の場合、C:\Tomcat7以下にTomcatを配置したので、 以下のようにすればコンパイルできます。

javac -classpath C:\Tomcat7\lib\servlet-api.jar *.java

実行するにはこれをTomcatに配置しなければなりませんが、 これが結構面倒です。順に説明します。

  1. まず、Tomcatのインストールフォルダ(私の場合はC:\Tomcat7)以下の webappsフォルダの下に、testbbsという名前のフォルダを作成します。 このtestbbs(もちろん何でもかまいません)という名前が、 この掲示板の「Webアプリケーション名」になります。
  2. 次に、testbbsフォルダの下に、 「WEB-INF」という名前のフォルダを作成します。 この「WEB-INF」という名前は固定です。
  3. 「WEB-INF」フォルダの下に、web.xmlというファイルを作成します。 その内容は以下の通り。
      1: <?xml version="1.0" encoding="ISO-8859-1"?>
      2: <web-app xmlns="http://java.sun.com/xml/ns/j2ee"
      3:          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      4:          xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee 
      5:          http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd"
      6:          version="2.4">
      7:   <servlet>
      8:     <servlet-name>TestBBS</servlet-name>
      9:     <servlet-class>TestBBS</servlet-class>
     10:   </servlet>
     11: 
     12:   <servlet-mapping>
     13:     <servlet-name>TestBBS</servlet-name>
     14:     <url-pattern>/TestBBS</url-pattern>
     15:   </servlet-mapping>
     16: </web-app>
    
    <servlet>要素でTestBBSというサーブレット名(任意)と TestBBSクラスを紐付け、<servlet-mapping>要素で TestBBSというサーブレット名にURLを紐付けています。
  4. 「WEB-INF」フォルダの下に「classes」という名前のフォルダを作り、 この中に、コンパイルしたクラスファイルを格納します。

配置後のフォルダ階層は以下のようになります。

C:\Tomcat7\webapps\testbbs
└─WEB-INF
    │  web.xml
    │
    └─classes
            Message.class
            TestBBS.class

この状態で、以下のURLでアクセスすることで、掲示板を表示できます。

http://localhost:8080/testbbs/TestBBS

このURLは、Webアプリケーション名(testbbs)の後ろに <servlet-mapping>要素で指定したurl-patternをくっつけたものです。

投稿した後リロードすると

上に挙げたTestBBS.javaの実装では、doPost()の末尾でdoGet()を呼び出し、 掲示板を表示するためのHTMLを「POSTリクエストのレスポンスとして」 ブラウザに送り返しています。 上にも書きましたが、この方法には問題があります。

掲示板に投稿した後、誰かが返信をくれたかな、と F5キーを叩いてリロードすると、たとえばFirefoxの場合、 以下の警告が出ます。

ここでダンコ「再送信」をクリックすると、 前に投稿した内容がまた投稿されてしまいます。

これはつまり、F5によるブラウザのリロードは、 「直前に送ったリクエストを再度送信する」ことを意味しているからです。 直前に送ったリクエストとは、この場合掲示板に投稿したPOSTですから、 リロードすれば当然再度POSTされてしまうわけです。

これを避けるには、投稿後、POSTに対するレスポンスとして 掲示板のHTMLを返すのではなく、 リダイレクトで再度掲示板を表示するURLに飛ばすようにします。 これは別に難しくはないですが、次回のネタに取っておきます。

Henacatで実装するServlet API

ここまでで、Tomcat上で動かす掲示板のサーブレットを作ることができました。 この掲示板を動作させられるサーブレットコンテナを作るのが今回の目的です。

最低限の機能として、 以下のクラス、インタフェースおよびメソッドを提供することにします。

HttpServletクラス

HttpServletクラスは、 アプリケーションプログラマがこれを継承して サーブレットを作るための抽象クラスです。

void doGet(HttpServletRequest req, HttpServletResponse resp)
上記の通り、GETを受け付けるメソッドです。 アプリケーションプログラマがオーバーライドする前提ですから Henacatとしては空実装(中身が空っぽのメソッド)を提供します。
void doPost(HttpServletRequest req, HttpServletResponse resp)
上と同じく、POSTを受け付けるメソッドです。同様に空実装です。
void service(HttpServletRequest req, HttpServletResponse resp)
これは、GETでもPOSTでもどちらでも呼び出されるメソッドです。 デフォルト実装では、この中でメソッドを見分け、 doGet()、doPost()のいずれかを呼び出します。
ユーザがこれをオーバーライドすれば、 GETでもPOSTでもどちらでも動作するサーブレットを作ることができます。
HttpServletRequestインタフェース

リクエストを表現するインタフェースで、doGet()やdoPost()に引数で渡されます。

String getMethod()
GET/POSTメソッドを識別するためのメソッドです。 Henacatでは、"GET"または"POST"のいずれかを返します。
String getParameter(String name)
パラメタ名を引数として渡し、その値を取得するメソッドです。 GETのときはクエリストリングに付けられたGETパラメタを、 POSTのときはPOSTで送付されたリクエストボディのPOSTパラメタを 取得して返します。 Henacatでは、multipart/form-dataによるPOSTには対応していません。
void setCharacterEncoding(String env)
パラメタのエンコーディングを指定します。
getParameter()によってパラメタを取得する際、 getParameter()はパラメタをURLデコードしてくれますが、 その時の文字コードを指定するメソッドがこれです。
HttpServletResponseインタフェース

レスポンスに関連する操作をまとめたインタフェースです。 doGet()やdoPost()に引数で渡されます。

void setContentType(String contentType)
Content-Typeレスポンスヘッダを指定します。
Content-Typeヘッダは「text/html;charset=UTF-8」といった内容になりますが、 このセミコロンの後ろの「charset=UTF-8」の部分で、 出力するHTMLの文字コードも決まります。
void setCharacterEncoding(String charset)
出力するHTMLの文字コードを設定します。 先にsetContentType()で設定されていた場合、上書きします。
このメソッドはTestBBS.javaでは使用していません。
PrintWriter getWriter()
レスポンスボディを出力するためのPrintWriterを取得します。 アプリケーションは、このPrintWriterに、 クライアントで表示するHTMLを出力すればよいわけです。
ServletExceptionクラス
サーブレットAPIがthrowする例外クラスです。

Henacatの実装について

パッケージ階層

Henacatのパッケージ階層は以下のようになっています。

com.kmaebashi.henacat
    ├ webserver (Webサーバ部分)
    ├ servletinterfaces (サーブレットAPIのインタフェース群)
    ├ servletimpl (サーブレットの実装)
    └ util (ユーティリティクラス群)

Javaでは、世界中でパッケージ名が重複しないよう、 ドメイン名を逆順にしてパッケージ名にするというルールになっています。 私はkmaebashi.comというドメインを持っているので、 henacatのトップレベルのパッケージはcom.kmaebashi.henacatにします。

その下の「webserver」パッケージは、Webサーバとしての機能を提供します。

「servletinterfaces」パッケージは、サーブレットのAPIとして、 サーブレットのプログラムが利用するインタフェースを格納しています。 本来、henacatがサーブレットコンテナなのであれば、 javax.servletパッケージに置かなければいけないのかもしれませんが、 所詮へなちょこなので正規の場所に配置するのは遠慮しました。

「servletimpl」パッケージは、servletinterfacesパッケージの interfaceをimplementsするクラス等、サーブレットAPIの実装を格納しています。

「util」パッケージは、こまごまとした便利メソッドを格納しています。

それでは、順に説明します。

com.kmaebashi.henacat.webserverパッケージ

com.kmaebashi.henacat.webserverパッケージは、 HenacatのWebサーバ部分の実装を配置しています。

Mainクラスはこのパッケージに配置しました。Main.javaです。

  1: package com.kmaebashi.henacat.webserver;
  2: import com.kmaebashi.henacat.servletimpl.ServletInfo;
  3: import java.io.*;
  4: import java.net.*;
  5: import java.util.*;
  6: 
  7: public class Main {
  8:     public static void main(String[] argv) throws Exception {
  9:         ServletInfo.addServlet("/testbbs/TestBBS",
 10:                                "C:\\maebashi\\henacat\\webapps\\testbbs",
 11:                                "TestBBS");
 12: 
 13:         try (ServerSocket server = new ServerSocket(8001)) {
 14:             for (;;) {
 15:                 Socket socket = server.accept();
 16: 
 17:                 ServerThread serverThread = new ServerThread(socket);
 18:                 Thread thread = new Thread(serverThread);
 19:                 thread.start();
 20:             }
 21:         }
 22:     }
 23: }
 24: 

前回までのものとほとんど変わりませんが、9行目で、ServletInfoクラスの addServlet()メソッドで、サーブレットの登録を行っています。 この呼び出しが、Tomcatにおけるweb.xmlの設定内容を代替します (本来、web.xmlで外部から定義できるようにすべきなのでしょうが、 今回は手を抜きました)。ServletInfoクラスについては後述します。

続いて、Webサーバの本体となる、ServerThread.javaです。

  1: package com.kmaebashi.henacat.webserver;
  2: import com.kmaebashi.henacat.servletimpl.ServletService;
  3: import com.kmaebashi.henacat.servletimpl.ServletInfo;
  4: import com.kmaebashi.henacat.util.*;
  5: import java.io.*;
  6: import java.net.*;
  7: import java.nio.file.*;
  8: import java.util.*;
  9: 
 10: class ServerThread implements Runnable {
 11:     private static final String DOCUMENT_ROOT = "C:\\maebashi\\homepage";
 12:     private static final String ERROR_DOCUMENT = "C:\\webserver\\error_document";
 13:     private static final String SERVER_NAME = "localhost:8001";
 14:     private Socket socket;
 15: 
 16:     private static void addRequestHeader(Map<String, String> requestHeader,
 17:                                          String line) {
 18:         int colonPos = line.indexOf(':');
 19:         if (colonPos == -1)
 20:             return;
 21: 
 22:         String headerName = line.substring(0, colonPos).toUpperCase();
 23:         String headerValue = line.substring(colonPos + 1).trim();
 24:         requestHeader.put(headerName, headerValue);
 25:     }
 26: 
 27:     @Override
 28:     public void run() {
 29:         OutputStream output = null;
 30:         try {
 31:             InputStream input = socket.getInputStream();
 32: 
 33:             String line;
 34:             String requestLine = null;
 35:             String method = null;
 36:             Map<String, String> requestHeader = new HashMap<String, String>();
 37:             while ((line = Util.readLine(input)) != null) {
 38:                 if (line == "") {
 39:                     break;
 40:                 }
 41:                 if (line.startsWith("GET")) {
 42:                     method = "GET";
 43:                     requestLine = line;
 44:                 } else if (line.startsWith("POST")) {
 45:                     method = "POST";
 46:                     requestLine = line;
 47:                 } else {
 48:                     addRequestHeader(requestHeader, line);
 49:                 }
 50:             }
 51:             if (requestLine == null)
 52:                 return;
 53: 
 54:             String reqUri = URLDecoder.decode(requestLine.split(" ")[1],
 55:                                               "UTF-8");
 56:             String[] pathAndQuery = reqUri.split("\\?");
 57:             String path = pathAndQuery[0];
 58:             String query = null;
 59:             if (pathAndQuery.length > 1) {
 60:                 query = pathAndQuery[1];
 61:             }
 62:             output = new BufferedOutputStream(socket.getOutputStream());
 63: 
 64:             ServletInfo servletInfo = ServletInfo.searchServlet(path);
 65:             if (servletInfo != null) {
 66:                 ServletService.doService(method, query, servletInfo,
 67:                                          requestHeader, input, output);
 68:                 return;
 69:             }
 70:             String ext = null;
 71:             String[] tmp = reqUri.split("\\.");
 72:             ext = tmp[tmp.length - 1];
 73: 
 74:             if (path.endsWith("/")) {
 75:                 path += "index.html";
 76:                 ext = "html";
 77:             }
 78:             FileSystem fs = FileSystems.getDefault();
 79:             Path pathObj = fs.getPath(DOCUMENT_ROOT + path);
 80:             Path realPath;
 81:             try {
 82:                 realPath = pathObj.toRealPath();
 83:             } catch (NoSuchFileException ex) {
 84:                 SendResponse.SendNotFoundResponse(output, ERROR_DOCUMENT);
 85:                 return;
 86:             }
 87:             if (!realPath.startsWith(DOCUMENT_ROOT)) {
 88:                 SendResponse.SendNotFoundResponse(output, ERROR_DOCUMENT);
 89:                 return;
 90:             } else if (Files.isDirectory(realPath)) {
 91:                 String host = requestHeader.get("HOST");
 92:                 String location = "http://"
 93:                     + ((host != null) ? host : SERVER_NAME)
 94:                     + path + "/";
 95:                 SendResponse.SendMovePermanentlyResponse(output, location);
 96:                 return;
 97:             }
 98:             try (InputStream fis
 99:                  = new BufferedInputStream(Files.newInputStream(realPath))) {
100:                 SendResponse.SendOkResponse(output, fis, ext);
101:             } catch (FileNotFoundException ex) {
102:                 SendResponse.SendNotFoundResponse(output, ERROR_DOCUMENT);
103:             }
104:         } catch (Exception ex) {
105:             ex.printStackTrace();
106:         } finally {
107:             try {
108:                 if (output != null) {
109:                     output.close();
110:                 }
111:                 socket.close();
112:             } catch (Exception ex) {
113:                 ex.printStackTrace();
114:             }
115:         }
116:     }
117: 
118:     ServerThread(Socket socket) {
119:         this.socket = socket;
120:     }
121: }

このクラスも大筋では修正していません。変更点は以下です。

  1. GETメソッドだけでなくPOSTメソッドも対応するようにしました(44行目〜)。
  2. リクエストヘッダについて、 従来はHost:ヘッダだけ参照するという場当たり対応だったので、 HashMapに格納するようにしました(36行目と48行目)。 そのための下請けメソッドが16行目のaddRequestHeader()です。 リクエストヘッダの名前は本来大文字と小文字を区別しないので、 ここで大文字に正規化しています。
  3. リクエストのパスが、mainメソッドでServletInfoに登録した パスに合致していたら、サーブレットを呼び出すようにしています (64〜69行目)。 呼び出された側で何をしているかは後述します。

com.kmaebashi.henacat.servletimplパッケージ

いよいよサーブレットコンテナの実装です。 まずは、サーブレットを登録するクラス、ServletInfo.javaです。

  1: package com.kmaebashi.henacat.servletimpl;
  2: import com.kmaebashi.henacat.servletinterfaces.*;
  3: import java.util.*;
  4: 
  5: public class ServletInfo {
  6:     private static HashMap<String, ServletInfo> servletCollection =
  7:         new HashMap<String, ServletInfo>();
  8: 
  9:     private String urlPattern;
 10:     String servletDirectory;
 11:     String servletClassName;
 12:     HttpServlet servlet;
 13: 
 14:     public ServletInfo(String urlPattern, String servletDirectory,
 15:                        String servletClassName) {
 16:         this.urlPattern = urlPattern;
 17:         this.servletDirectory = servletDirectory;
 18:         this.servletClassName = servletClassName;
 19:     }
 20: 
 21:     public static void addServlet(String urlPattern, String servletDirectory,
 22:                                   String servletClassName) {
 23:         servletCollection.put(urlPattern,
 24:                               new ServletInfo(urlPattern, servletDirectory,
 25:                                               servletClassName));
 26:     }
 27: 
 28:     public static ServletInfo searchServlet(String urlPattern) {
 29:         
 30:         ServletInfo info = servletCollection.get(urlPattern);
 31:         return servletCollection.get(urlPattern);
 32:     }
 33: }

9〜12行目までがインスタンスフィールドの宣言です。見ての通り、 以下のものを保持しています。

  1. urlPattern :サーブレットを呼び出すURLのパターン(web.xmlにおけるurl-patternに相当)。
  2. servletDirectory :コンパイル済みのサーブレットのクラスファイルの配置ディレクトリ。
  3. servletClassName :サーブレットのクラス名。
  4. servlet :サーブレットのクラス本体。

このうち、urlPattern, servletDirectory, servletClassNameまでは newの時点でコンストラクタで設定します。 4つ目のservletは、サーブレットの初回呼び出し時に設定されます(後述)。

また、ServletInfoはstaticにHashMapを保持しており、 urlPatternをキーに登録/取得するstaticメソッドを備えています。

web.xmlのurl-patternによる指定では、*を使ったワイルドカード等も使えますが、 ServletInfoクラスでは対応していません。 また、大文字小文字を区別してしまってよいのかといった問題もありますが、 ひとまずこの実装にしてあります。

続いては、サーブレットの処理本体であるServletService.javaです。

  1: package com.kmaebashi.henacat.servletimpl;
  2: import com.kmaebashi.henacat.servletinterfaces.*;
  3: import com.kmaebashi.henacat.util.*;
  4: import java.util.*;
  5: import java.nio.file.*;
  6: import java.net.*;
  7: import java.io.*;
  8: 
  9: public class ServletService {
 10:     private static HttpServlet createServlet(ServletInfo info)
 11:         throws Exception {
 12:         FileSystem fs = FileSystems.getDefault();
 13:         Path pathObj = fs.getPath(info.servletDirectory);
 14:         URLClassLoader loader
 15:             = URLClassLoader.newInstance(new URL[]{pathObj.toUri().toURL()});
 16:         Class<?> clazz = loader.loadClass(info.servletClassName);
 17:         return (HttpServlet)clazz.newInstance();
 18:     }
 19: 
 20:     private static Map<String, String> stringToMap(String str) {
 21:         Map<String, String> parameterMap = new HashMap<String, String>();
 22:         if (str != null) {
 23:             String[] paramArray = str.split("&");
 24:             for (String param : paramArray) {
 25:                 String[] keyValue = param.split("=");
 26:                 parameterMap.put(keyValue[0], keyValue[1]);
 27:             }
 28:         }
 29:         return parameterMap;
 30:     }
 31: 
 32:     private static String readToSize(InputStream input, int size)
 33:         throws Exception{
 34:         int ch;
 35:         StringBuilder sb = new StringBuilder();
 36:         int readSize = 0;
 37: 
 38:         while (readSize < size && (ch = input.read()) != -1) {
 39:             sb.append((char)ch);
 40:             readSize++;
 41:         }
 42:         return sb.toString();
 43:     }
 44: 
 45:     public static void doService(String method, String query, ServletInfo info,
 46:                                  Map<String, String> requestHeader,
 47:                                  InputStream input, OutputStream output)
 48:         throws Exception {
 49:         if (info.servlet == null) {
 50:             info.servlet = createServlet(info);
 51:         }
 52:         HttpServletRequest req;
 53:         if (method.equals("GET")) {
 54:             Map<String, String> map;
 55:             map = stringToMap(query);
 56:             req = new HttpServletRequestImpl("GET", map);
 57:         } else if (method.equals("POST")) {
 58:             int contentLength
 59:                 = Integer.parseInt(requestHeader.get("CONTENT-LENGTH"));
 60:             Map<String, String> map;
 61:             String line = readToSize(input, contentLength);
 62:             map = stringToMap(line);
 63:             req = new HttpServletRequestImpl("POST", map);
 64:         } else {
 65:             throw new AssertionError("BAD METHOD:" + method);
 66:         }
 67:         HttpServletResponseImpl resp = new HttpServletResponseImpl(output);
 68: 
 69:         info.servlet.service(req, resp);
 70:         
 71:         resp.printWriter.flush();
 72:     }
 73: }

45行目からが、ServerThread.javaから呼び出されるdoService()メソッドです。

まず、該当のサーブレットの初回の呼び出しで、 まだサーブレットのインスタンスが生成されていない場合は、 createServlet()メソッドによりクラスファイルを動的にロードし、 サーブレットを生成します(49〜51行目)。

これを実際に行っているのは10行目からのcreateServlet()メソッドです。 URLClassLoaderクラスを使いサーブレットのクラスをロードして、 クラスのnewInstance()メソッドで、サーブレットのインスタンスを生成しています ※2 (余談ですが、ここでclazzという変数名を使っているのは、 classだと予約語なのでコンパイルエラーになってしまうためです。 こういう場合、慣習的にclazzが使われることはよくあります)。

引き続き、HTTPリクエストに応じてHttpServletRequestを生成します(52行目〜)。

53行目からがGETメソッドの場合の処理で、 下請けメソッドstringToMap()を使用してクエリ文字列の解釈をしています。 stringToMap()の実装は20行目からです。入力をまず「&」で区切り、 それをさらに「=」で区切ってHashMapに詰め込んでいます。 この時点では、URLエンコードのデコードは行っていません。

57行目からがPOSTメソッドの処理です。POSTメソッドの場合、 パラメタはメッセージボディに格納されており、かつ、 メッセージボディの終了は、Content-Lengthヘッダにより検知する必要があります (ソケットは双方向の通信経路であり、レスポンスの返信も同じソケットで行うので、 この時点ではクライアントはソケットをclose()しません)。 そこで、Content-Lengthで指定されたバイト数分、 下請けメソッドreadToSize()メソッドで文字列に読み込み、 GETと同様にstringToMap()メソッドでパラメタをMapに詰め込みます。

GET/POSTともに、こうして生成したMapを元にHttpServletRequestの インスタンスを生成し(56行目、63行目)、 さらにHttpServletResponseのインスタンスも生成して(67行目)、 サーブレットのservice()メソッドを呼び出します(69行目)。

ここで生成したHttpServletRequest/Responseの実装については 以下に説明します。まずはHttpServletRequestImpl.javaから。

  1: package com.kmaebashi.henacat.servletimpl;
  2: import com.kmaebashi.henacat.servletinterfaces.*;
  3: import java.util.*;
  4: import java.io.*;
  5: import java.nio.charset.*;
  6: import java.net.*;
  7: 
  8: class HttpServletRequestImpl implements HttpServletRequest {
  9:     private String method;
 10:     private String characterEncoding;
 11:     private Map<String, String> parameterMap;
 12: 
 13:     @Override
 14:     public String getMethod() {
 15:         return this.method;
 16:     }
 17: 
 18:     @Override
 19:     public String getParameter(String name) {
 20:         String value = this.parameterMap.get(name);
 21:         String decoded = null;
 22:         try {
 23:             decoded = URLDecoder.decode(value, this.characterEncoding);
 24:         } catch (UnsupportedEncodingException ex) {
 25:             throw new AssertionError(ex);
 26:         }
 27:         return decoded;
 28:     }
 29: 
 30:     @Override
 31:     public void setCharacterEncoding(String env)
 32:         throws UnsupportedEncodingException {
 33:         if (!Charset.isSupported(env)) {
 34:             throw new UnsupportedEncodingException("encoding.." + env);
 35:         }
 36:         this.characterEncoding = env;
 37:     }
 38: 
 39:     HttpServletRequestImpl(String method, Map<String, String> parameterMap) {
 40:         this.method = method;
 41:         this.parameterMap = parameterMap;
 42:     }
 43: }

getMethod()メソッドは自明なので説明しません。

getParameter()メソッドは、パラメタを取得するメソッドですが、 日本語等のデコードはこの中で行っています。 エンコーディングはsetCharacterEncoding()で指定します。

続いて、HttpResponseImpl.javaです。

  1: package com.kmaebashi.henacat.servletimpl;
  2: import com.kmaebashi.henacat.servletinterfaces.*;
  3: import com.kmaebashi.henacat.util.*;
  4: import java.io.*;
  5: 
  6: class HttpServletResponseImpl implements HttpServletResponse {
  7:     private String contentType = "application/octet-stream";
  8:     private String characterEncoding = "ISO-8859-1";
  9:     private OutputStream outputStream;
 10:     PrintWriter printWriter;
 11: 
 12:     @Override
 13:     public void setContentType(String contentType) {
 14:         this.contentType = contentType;
 15:         String[] temp = contentType.split(" *;");
 16:         if (temp.length > 1) {
 17:             String[] keyValue = temp[1].split("=");
 18:             if (keyValue.length == 2 && keyValue[0].equals("charset")) {
 19:                 setCharacterEncoding(keyValue[1]);
 20:             }
 21:         }
 22:     }
 23: 
 24:     @Override
 25:     public void setCharacterEncoding(String charset) {
 26:         this.characterEncoding = charset;
 27:     }
 28: 
 29:     @Override
 30:     public PrintWriter getWriter() throws IOException {
 31:         this.printWriter
 32:             = new PrintWriter(new OutputStreamWriter(this.outputStream,
 33:                                                      this.characterEncoding));
 34:         SendResponse.SendOkResponseHeader(this.printWriter, this.contentType);
 35:         return this.printWriter;
 36:     }
 37:     HttpServletResponseImpl(OutputStream output) {
 38:         this.outputStream = output;
 39:     }
 40: }

setContentType()メソッドは、 レスポンスヘッダのContent-TYpeヘッダを指定します。 このメソッドに「text/html;charset=UTF-8」といった指定を行うと、 出力のエンコーディングも併せて設定されるので、その解析も行っています。

setCharacterEncoding()メソッドは、 出力のエンコーディングを直接指定するメソッドです。

getWriter()は、レスポンスを出力するPrintWriterを取得するメソッドです。 ここで、setCharacterEncodingで指定したエンコーディングで PrintWriterを作ってしまうので、 これを呼んでしまうともうエンコーディングの変更はできません。

また、getWriter()の時点で、 ステータスコード200でレスポンスヘッダの出力まで行ってしまっています (34行目で、SendResponse.SendOkResponseHeader()を呼んでいる)。

「おいおい、こんなところでレスポンスヘッダを出力しちゃって、 この後エラーとか起きたらどうするんだよ」と思う人もいるかもしれません。 Webアプリケーションで、たとえばNullPointerExceptionなどで処理がコケた場合、 普通はステータスコードとして500を返します。 getWriter()で「200 OK」を出力するところまで行っていては、 ステータスコードを変更できないように思えます。

Henacatでは手を抜いていますが、 サーブレットの仕様ではレスポンスをバッファリングすることが認められており、 バッファをフラッシュするとレスポンスは「コミットした」ことになります。 コミット前はsendError()メソッドにより500等のエラーを返すことができますが、 コミットしてしまうと、ステータスコードは変更できません。

JavaのURLDecoder.decode()がたいへんにタコである話

Wikipediaの説明にもありますが、 URLエンコーディングはバイト単位に行います。 バイト単位で見て英数字等の「安全な文字」になっている場合、 そのバイトはエンコードしなくてかまいません(してもかまいません)。

上記Wikipediaでは、シフトJISの「ウィキペディア」をURLエンコードすると 「%83E%83B%83L%83y%83f%83B%83A」になるという例が挙げられています。 URLエンコードはバイトを「%と2桁の16進数」に置き換える操作ですが、 上の例で「L」とか「y」とかが登場しているのは、 シフトJISの2バイト目ではこういった文字のコードが出現することがあり、 そのバイトはURLエンコードする必要がないからです。 実際、たいていのブラウザはそのようなエンコードを行います。

しかし、JavaのURLDecode.decode()メソッドは、 この形式のエンコードに対応していないようです (私はこれはバグだと思いますが……)。

Tomcat5以降でsetCharacterEncoding()の仕様が変わった話

Henacatの実装では、GETのパラメタもPOSTのパラメタも、 URLデコードはしないままHttpServletRequestに保持し、 アプリケーションがパラメタを取得しようとしたときに、 setCharacterEncoding()で指定されたエンコーディングでデコードして返します。

この方法だと、setCharacterEncoding()で指定したエンコーディングが GETパラメタに対しても有効になりますが、 どうもこれは正しくはサーブレットの仕様に合致していないようで、 Tomcatでは、Tomcat5から、 setCharacterEncoding()はGETパラメタに対しては効かないようになりました。

……不便だと思うんですが。

com.kmaebashi.henacat.utilパッケージ

このパッケージには、従来から存在したUtil.javaと SendResponse.javaを格納しています。 SendResponse.javaにはサーブレットが「200 OK」を返すときのための メソッドを追加しましたが、 その程度の修正しか行っておりませんので説明は省略します。

Util.java

  1: package com.kmaebashi.henacat.util;
  2: import java.io.*;
  3: import java.util.*;
  4: import java.text.*;
  5: 
  6: public class Util {
  7:     // InputStreamからのバイト列を、行単位で読み込むユーティティメソッド
  8:     public static String readLine(InputStream input) throws Exception {
  9:         int ch;
 10:         String ret = "";
 11:         while ((ch = input.read()) != -1) {
 12:             if (ch == '\r') {
 13:                 // 何もしない
 14:             } else if (ch == '\n') {
 15:                 break;
 16:             } else {
 17:                 ret += (char)ch;
 18:             }
 19:         }
 20:         if (ch == -1) {
 21:             return null;
 22:         } else {
 23:             return ret;
 24:         }
 25:     }
 26: 
 27:     // 1行の文字列を、バイト列としてOutputStreamに書き込む
 28:     // ユーティリティメソッド
 29:     public static void writeLine(OutputStream output, String str)
 30:         throws  Exception {
 31:         for (char ch : str.toCharArray()) {
 32:             output.write((int)ch);
 33:         }
 34:         output.write((int)'\r');
 35:         output.write((int)'\n');
 36:     }
 37: 
 38:     // 現在時刻から、HTTP標準に合わせてフォーマットされた日付文字列を返す
 39:     public static String getDateStringUtc() {
 40:         Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
 41:         DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss",
 42:                                              Locale.US);
 43:         df.setTimeZone(cal.getTimeZone());
 44:         return df.format(cal.getTime()) + " GMT";
 45:     }
 46: 
 47:     // 拡張子とContent-Typeの対応表
 48:     private static final HashMap<String, String> contentTypeMap =
 49:         new HashMap<String, String>() {{
 50:             put("html", "text/html");
 51:             put("htm", "text/html");
 52:             put("txt", "text/plain");
 53:             put("css", "text/css");
 54:             put("png", "image/png");
 55:             put("jpg", "image/jpeg");
 56:             put("jpeg", "image/jpeg");
 57:             put("gif", "image/gif");
 58:         }
 59:     };
 60: 
 61:     // 拡張子を受け取りContent-Typeを返す
 62:     public static String getContentType(String ext) {
 63:         String ret = contentTypeMap.get(ext.toLowerCase());
 64:         if (ret == null) {
 65:             return "application/octet-stream";
 66:         } else {
 67:             return ret;
 68:         }
 69:     }
 70: }

SendResnponse.java

  1: package com.kmaebashi.henacat.util;
  2: import java.io.*;
  3: import java.net.*;
  4: 
  5: public class SendResponse {
  6:     public static void SendOkResponseHeader(PrintWriter writer,
  7:                                             String contentType)
  8:         throws IOException {
  9:         writer.println("HTTP/1.1 200 OK");
 10:         writer.println("Date: " + Util.getDateStringUtc());
 11:         writer.println("Server: Henacat");
 12:         writer.println("Connection: close");
 13:         writer.println("Content-type: " + contentType);
 14:         writer.println("");
 15:     }
 16: 
 17:     public static void SendOkResponse(OutputStream output, InputStream fis,
 18:                                       String ext) throws Exception {
 19:         Util.writeLine(output, "HTTP/1.1 200 OK");
 20:         Util.writeLine(output, "Date: " + Util.getDateStringUtc());
 21:         Util.writeLine(output, "Server: Henacat");
 22:         Util.writeLine(output, "Connection: close");
 23:         Util.writeLine(output, "Content-type: "
 24:                        + Util.getContentType(ext));
 25:         Util.writeLine(output, "");
 26: 
 27:         int ch;
 28:         while ((ch = fis.read()) != -1) {
 29:             output.write(ch);
 30:         }
 31:     }
 32: 
 33:     public static void SendMovePermanentlyResponse(OutputStream output,
 34:                                                    String location)
 35:         throws Exception {
 36:         Util.writeLine(output, "HTTP/1.1 301 Moved Permanently");
 37:         Util.writeLine(output, "Date: " + Util.getDateStringUtc());
 38:         Util.writeLine(output, "Server: Henacat");
 39:         Util.writeLine(output, "Location: " + location);
 40:         Util.writeLine(output, "Connection: close");
 41:         Util.writeLine(output, "");
 42:     }
 43: 
 44:     public static void SendNotFoundResponse(OutputStream output,
 45:                                             String errorDocumentRoot)
 46:         throws Exception {
 47:         Util.writeLine(output, "HTTP/1.1 404 Not Found");
 48:         Util.writeLine(output, "Date: " + Util.getDateStringUtc());
 49:         Util.writeLine(output, "Server: Henacat");
 50:         Util.writeLine(output, "Connection: close");
 51:         Util.writeLine(output, "Content-type: text/html");
 52:         Util.writeLine(output, "");
 53: 
 54:         try (InputStream fis
 55:              = new BufferedInputStream(new FileInputStream(errorDocumentRoot
 56:                                                            + "/404.html"))) {
 57:             int ch;
 58:             while ((ch = fis.read()) != -1) {
 59:                 output.write(ch);
 60:             }
 61:         }
 62:     }
 63: }

com.kmaebashi.henacat.servletinterfacesパッケージ

このパッケージは、サーブレットのインタフェースを提供しており、 メソッドが減っていることを除き公式のサーブレットAPI仕様と同様ですし、 上のほうでも説明していますし、実装もほぼ空なので説明は省略します。

HttpServlet.java

  1: package com.kmaebashi.henacat.servletinterfaces;
  2: 
  3: public abstract class HttpServlet {
  4:     protected void doGet(HttpServletRequest req, HttpServletResponse resp)
  5:         throws ServletException, java.io.IOException {
  6:     }
  7: 
  8:     protected void doPost(HttpServletRequest req, HttpServletResponse resp)
  9:         throws ServletException, java.io.IOException {
 10:     }
 11: 
 12:     public void service(HttpServletRequest req,
 13:                            HttpServletResponse resp)
 14:         throws ServletException, java.io.IOException {
 15:         if (req.getMethod().equals("GET")) {
 16:             doGet(req, resp);
 17:         } else if (req.getMethod().equals("POST")) {
 18:             doPost(req, resp);
 19:         }
 20:     }
 21: }

HttpServletRequest.java

  1: package com.kmaebashi.henacat.servletinterfaces;
  2: import java.util.*;
  3: import java.io.*;
  4: 
  5: public interface HttpServletRequest {
  6:     String getMethod();
  7:     String getParameter(String name);
  8:     void setCharacterEncoding(String env) throws UnsupportedEncodingException;
  9: }

HttpServletResponse.java

  1: package com.kmaebashi.henacat.servletinterfaces;
  2: import java.io.*;
  3: 
  4: public interface HttpServletResponse {
  5:     void setContentType(String contentType);
  6:     void setCharacterEncoding(String charset);
  7:     PrintWriter getWriter() throws IOException;
  8: }

ServletException.java

  1: package com.kmaebashi.henacat.servletinterfaces;
  2: 
  3: public class ServletException extends Exception {
  4:     public ServletException(String message) {
  5:         super(message);
  6:     }
  7: 
  8:     public ServletException(String message, Throwable rootCause)  {
  9:         super(message, rootCause);
 10:     }
 11: 
 12:     public ServletException(java.lang.Throwable rootCause) {
 13:         super(rootCause);
 14:     }
 15: }

上記のソース一式をzipで固めたものは、 ここからダウンロードできます。

Henacat上で掲示板を動かす

今回作成したTestBBS.javaをHenacatの上で動かすには、 ソースコードに多少の修正が必要です。

HenacatのサーブレットAPIは、公式のjavax.servletパッケージ以下ではなく、 com.kmaebashi.henacat.servletinterfacesにありますから、 import文を修正します。

Tomcat版:

import javax.servlet.*;
import javax.servlet.http.*;

Henacat版:

import com.kmaebashi.henacat.servletinterfaces.*;

その上で、Henacatのcomディレクトリのあるところ(com/kmaebashi/henacat/...という ディレクトリ階層の根元)にCLASSPATHを向けてコンパイルし、 クラスファイルのあるディレクトリをMain.javaの ServletInfo.addServlet()呼び出しの第2引数に指定してください。

JSP

ここまで、サーブレットで掲示板を動かすための簡易サーブレットコンテナ Henacatを作ってみました。いろいろ機能的に抜けはあるとはいえ、 Henacatのソースコードは全体で500行あまりです。 意外に簡単に、サーブレットコンテナらしきものが作れることがわかると思います。

ただし、それはそれとして、サーブレットのプログラム(今回であれば TestBBS.java)は、なんとも悲惨です。 doGet()メソッドの中にout.println()がずらずらと並んでいます。 画面のレイアウトを変えたいからHTMLをちょっと直したい、という場合でも、 プログラムロジックの中に埋もれた Javaのソースをいちいち直してコンパイルしなければなりません。

大昔、CGIとPerlあたりでがんばって掲示板とかを作っていた人達は、 実際にこういうソースを書いていたわけですが、 今の目で見るとさすがにどうかと思います。

Javaの場合、JSP(JavaServer Pages)を使えば、 ASPやPHPのようにHTMLにコードを埋め込む形でWebアプリケーションを 作ることができます。 JSPで(「Hello World1」とともに)現在時刻を表示するプログラムは 以下のようになります ( こちらのページのものをベースに改変。)。

  1: <html>
  2: <head><title>Hello World!</title></head>
  3: <body>
  4: <h1>Hello World!</h1>
  5: <p>
  6: Now..<%= (new java.util.Date()).toString() %>
  7: </p>
  8: </body>
  9: </html>

ところで上記のJSPですが、実際に実行されるときには、 以下のようなサーブレットに変換の上、コンパイルして実行されます。

  1: /*
  2:  * Generated by the Jasper component of Apache Tomcat
  3:  * Version: Apache Tomcat/7.0.47
  4:  * Generated at: 2013-12-08 04:37:47 UTC
  5:  * Note: The last modified time of this file was set to
  6:  *       the last modified time of the source file after
  7:  *       generation to assist with modification tracking.
  8:  */
  9: package org.apache.jsp.first_005fjsp;
 10: 
 11: import javax.servlet.*;
 12: import javax.servlet.http.*;
 13: import javax.servlet.jsp.*;
 14: 
 15: public final class hello_jsp extends org.apache.jasper.runtime.HttpJspBase
 16:     implements org.apache.jasper.runtime.JspSourceDependent {
 17: 
 18:   private static final javax.servlet.jsp.JspFactory _jspxFactory =
 19:           javax.servlet.jsp.JspFactory.getDefaultFactory();
 20: 
 21:   private static java.util.Map<java.lang.String,java.lang.Long> _jspx_dependants;
 22: 
 23:   private javax.el.ExpressionFactory _el_expressionfactory;
 24:   private org.apache.tomcat.InstanceManager _jsp_instancemanager;
 25: 
 26:   public java.util.Map<java.lang.String,java.lang.Long> getDependants() {
 27:     return _jspx_dependants;
 28:   }
 29: 
 30:   public void _jspInit() {
 31:     _el_expressionfactory = _jspxFactory.getJspApplicationContext(getServletConfig().getServletContext()).getExpressionFactory();
 32:     _jsp_instancemanager = org.apache.jasper.runtime.InstanceManagerFactory.getInstanceManager(getServletConfig());
 33:   }
 34: 
 35:   public void _jspDestroy() {
 36:   }
 37: 
 38:   public void _jspService(final javax.servlet.http.HttpServletRequest request, final javax.servlet.http.HttpServletResponse response)
 39:         throws java.io.IOException, javax.servlet.ServletException {
 40: 
 41:     final javax.servlet.jsp.PageContext pageContext;
 42:     javax.servlet.http.HttpSession session = null;
 43:     final javax.servlet.ServletContext application;
 44:     final javax.servlet.ServletConfig config;
 45:     javax.servlet.jsp.JspWriter out = null;
 46:     final java.lang.Object page = this;
 47:     javax.servlet.jsp.JspWriter _jspx_out = null;
 48:     javax.servlet.jsp.PageContext _jspx_page_context = null;
 49: 
 50: 
 51:     try {
 52:       response.setContentType("text/html");
 53:       pageContext = _jspxFactory.getPageContext(this, request, response,
 54:                         null, true, 8192, true);
 55:       _jspx_page_context = pageContext;
 56:       application = pageContext.getServletContext();
 57:       config = pageContext.getServletConfig();
 58:       session = pageContext.getSession();
 59:       out = pageContext.getOut();
 60:       _jspx_out = out;
 61: 
 62:       out.write("<html>\r\n");
 63:       out.write("<head><title>Hello World!</title></head>\r\n");
 64:       out.write("<body>\r\n");
 65:       out.write("<h1>Hello World!</h1>\r\n");
 66:       out.write("<p>\r\n");
 67:       out.write("Now..");
 68:       out.print( (new java.util.Date()).toString() );
 69:       out.write("\r\n");
 70:       out.write("</p>\r\n");
 71:       out.write("</body>\r\n");
 72:       out.write("</html>  ");
 73:     } catch (java.lang.Throwable t) {
 74:       if (!(t instanceof javax.servlet.jsp.SkipPageException)){
 75:         out = _jspx_out;
 76:         if (out != null && out.getBufferSize() != 0)
 77:           try { out.clearBuffer(); } catch (java.io.IOException e) {}
 78:         if (_jspx_page_context != null) _jspx_page_context.handlePageException(t);
 79:         else throw new ServletException(t);
 80:       }
 81:     } finally {
 82:       _jspxFactory.releasePageContext(_jspx_page_context);
 83:     }
 84:   }
 85: }

62行目からのout.write()の連続部分が、サーブレットと同じ仕組みで、 レスポンスに対して結果を流し込んでいることがわかると思います。

また、JSPでは、requestとかresponseとかoutとかいった変数が なぜか最初から使えますが、それが何者であるか、 上記の変換後のソースを見ればよくわかるのではないでしょうか。

これから

Henacatは、リダイレクトとか細かいところを直した上で、 Cookieに対応してセッションを持てるようにする、 というところあたりまでは作っていこうと思っています。

それではまた気長にお待ちくださいませ。

2013/12/08公開


前のページ | 次のページ | ひとつ上のページに戻る | トップページに戻る