yukiwikiminiをJavaにしてみた

最近、Perlの勉強をしていたので、yukiwikiminiをJavaに書き換えてみた。最初はServletでやろうとしたのだけど、Perlのようにリクエストごとにプロセスが分かれていないためグローバル変数に対応する変数を定義できない。そこで、別途Yukiwikimin.javaというクラスを作って、グローバルっぽいものはフィールドにした。こうすればリクエストごとにインスタンスを生成することでPerlっぽく書ける。

package yukiwikimini;

import java.io.IOException;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * YukiWikiMini Version 1.0.2 をJava(GAEj)に移植したもの。
 * 
 * @author n-3104
 */
public class Yukiwikimin {
	//my $dbname = 'ykwkmini';
	// PerlのようにDBMファイルを利用できないので利用しなかった。

	//my $thisurl = 'ykwkmini.cgi';
	static private final String THIS_URL = "ykwkmini";

	//my $frontpage = 'FrontPage';
	static private final String FRONT_PAGE = "FrontPage";

	//my $indexpage = 'Index';
	static private final String INDEX_PAGE = "Index";

	//my $errorpage = 'Error';
	static private final String ERROR_PAGE = "Error";

	//my $WikiName = '([A-Z][a-z]+([A-Z][a-z]+)+)';
	static private final Pattern WIKI_NAME = Pattern.compile("([A-Z][a-z]+([A-Z][a-z]+)+)");

	//my $kanjicode = 'sjis';
	// 利用しなかった。普通のWebアプリだと指定がいるがGAEだといらないようなので。
	
	//my $editchar = '?';
	static private final String EDIT_CHAR = "?";

	//my $bgcolor = 'white';
	static private final String BG_COLOR = "white";

	//my $contenttype = 'Content-type: text/html; charset=Shift_JIS';
	static private final String CONTENT_TYPE = "text/html;charset=UTF-8";

	//my $naviwrite = 'Write';
	static private final String NAVI_WRITE = "Write";

	//my $naviedit = 'Edit';
	static private final String NAVI_EDIT = "Edit";

	//my $naviindex = 'Index';
	static private final String NAVI_INDEX = "Index";

	//my $msgdeleted = ' is deleted.';
	static private final String MSG_DELETED = " is deleted.";
	
	//my $cols = 80;
	static private final int COLS = 80;

	//my $rows = 20;
	static private final int ROWS = 20;

	//my $style = <<'EOD';
	//<style type="text/css">
	//<!--
	//body { font-family: "Courier New", monospace; }
	//pre { line-height:130%; }
	//a { text-decoration: none }
	//a:hover { text-decoration: underline }
	//-->
	//</style>
	//EOD
	static private final String STYLE = 
		"<style type=\"text/css\">" + 
		"<!--" + 
		"body { font-family: \"Courier New\", monospace; }" + 
		"pre { line-height:130%; }" + 
		"a { text-decoration: none }" + 
		"a:hover { text-decoration: underline }" + 
		"-->" + 
		"</style>"
	;
	
	//my %form;
	private Map<String, String> form;

	//my %database;
	private Database database;
	
	// Perlのようにグローバルにアクセスできるようにフィールドにしておく。
	private HttpServletRequest req;
	private HttpServletResponse resp;
	
	public Yukiwikimin(HttpServletRequest req, HttpServletResponse resp) {
		this.req = req;
		this.resp = resp;
	}

	public void main()	throws Exception {
		//&init_form;
		initForm();
		
		//&sanitize_form;
		sanitizeForm();
		
		//foreach (keys %form) {
		//    if (/^($WikiName)$/) {
		//        $form{mycmd} = 'read';
		//        $form{mypage} = $1;
		//        last;
		//    }
		//}
		// WIKI_NAMEのパターンに一致するパラメータが存在する場合は
		// そのページを参照するリクエストと判断する。
		for (Enumeration<String> keys = req.getParameterNames(); keys.hasMoreElements();) {
			String key = keys.nextElement();
			if (WIKI_NAME.matcher(key).matches()) {
				form.put("mycmd", "read");
				form.put("mypage", key);
			}
		}
		
		//unless (dbmopen(%database, $dbname, 0666)) {
		//    &print_error("(dbmopen)");
		//}
		// DBMファイルのように操作できるようにDatabaseクラスを用意した。
		database = new Database();
		try {
			//$_ = $form{mycmd};
			//if (/^read$/) {
			//    &do_read;
			//} elsif (/^write$/) {
			//    &do_write;
			//} elsif (/^edit$/) {
			//    &do_edit;
			//} elsif (/^index$/) {
			//    &do_index;
			//} else {
			//    $form{mypage} = $frontpage;
			//    &do_read;
			//}
			String mycmd = form.get("mycmd");
			if ("read".equals(mycmd)) {
				doRead();
			} else if ("write".equals(mycmd)) {
				doWrite();
			} else if ("edit".equals(mycmd)) {
				doEdit();
			} else if ("index".equals(mycmd)) {
				doIndex();
			} else {
				form.put("mypage", FRONT_PAGE);
				doRead();
			}
		} finally {
			//dbmclose(%database);
			database.close();
		}
	}

	private void initForm() {
		//my ($query);
		//if ($ENV{REQUEST_METHOD} =~ /^post$/i) {
		//    read(STDIN, $query, $ENV{CONTENT_LENGTH});
		//} else {
		//    $query = $ENV{'QUERY_STRING'};
		//}
		// POSTとGETの差分を吸収。これはServletで行っているのでここでは何もしない。
		
		//my @assocarray = split(/&/, $query);
		//foreach (@assocarray) {
		//    my ($property, $value) = split /=/;
		//    $value =~ tr/+/ /;
		//    $value =~ s/%([A-Fa-f0-9][A-Fa-f0-9])/pack("C", hex($1))/eg;
		//    &jcode'convert(\$value, $kanjicode);
		//    $form{$property} = $value;
		//}
		// リクエストパラメータの抽出とデコード
		// デコードはGAEの場合は不要のようだ。
		// ちなみにgetParameterMapで取得できるMapはUnmodifiableMapであるため利用できない。
		// form = req.getParameterMap();
		form = new HashMap<String, String>();
		for (Enumeration<String> keys = req.getParameterNames(); keys.hasMoreElements();) {
			String key = keys.nextElement();
			form.put(key, req.getParameter(key));
		}
	}

	private void sanitizeForm() throws Exception {
		//if (defined($form{mypage}) and $form{mypage} !~ /^$WikiName$/) {
		//    &print_error("(invalid mypage)");
		//}
		// mypageパラメータがWIKI_NAMEに一致しない場合はエラーとする。
		// mypageパラメータがない場合は参照モードであるためエラーとしない。
		String mypage = form.get("mypage");
		if (mypage != null && WIKI_NAME.matcher(mypage).find() == false) {
			printError("(invalid mypage)");
		}
	}

	private void doIndex() throws IOException {
		//&print_header($indexpage, 0);
		//print qq|<ul>\n|;
		//foreach (sort keys %database) {
		//    print qq|<li><a href="$thisurl?$_"><tt>$_</tt></a></li>\n|
		//}
		//print qq|</ul>\n|;
		//&print_footer;
		printHeader(INDEX_PAGE, false);
		for (String mypage : database.keySet()) {
			resp.getWriter().print("<li><a href=\"" + THIS_URL + "?" + mypage + "\"><tt>" + mypage + "</tt></a></li>\n");
		}
		printFooter();
	}

	private void doEdit() throws IOException {
		//&print_header($form{mypage}, 0);
		//my $mymsg = &escape($database{$form{mypage}});
		//print <<"EOD";
		//<form action="$thisurl" method="post">
		//    <input type="hidden" name="mycmd" value="write">
		//    <input type="hidden" name="mypage" value="$form{mypage}">
		//    <input type="submit" value="$naviwrite"><br />
		//    <textarea cols="$cols" rows="$rows" name="mymsg" wrap="off">$mymsg</textarea><br />
		//    <input type="submit" value="$naviwrite">
		//    </form>
		//EOD
		//&print_footer;
		printHeader(form.get("mypage"), false);
		String mymsg = escape(database.get(form.get("mypage")));
		resp.getWriter().print(
			"<form action=\"" + THIS_URL + "\" method=\"post\">" + 
			"    <input type=\"hidden\" name=\"mycmd\" value=\"write\">" + 
			"    <input type=\"hidden\" name=\"mypage\" value=\"" + form.get("mypage") + "\">" + 
			"    <input type=\"submit\" value=\"" + NAVI_WRITE + "\"><br />" + 
			"    <textarea cols=\"" + COLS + "\" rows=\"" + ROWS + "\" name=\"mymsg\" wrap=\"off\">" + mymsg + "</textarea><br />" + 
			"    <input type=\"submit\" value=\"" + NAVI_WRITE + "\">" + 
			"</form>"
		);
		printFooter();
	}

	private void doWrite() throws IOException {
		//if ($form{mymsg}) {
		//    $database{$form{mypage}} = $form{mymsg};
		//    &print_header($form{mypage}, 1);
		//    &print_content;
		//} else {
		//    delete $database{$form{mypage}};
		//    &print_header($form{mypage} . $msgdeleted, 0);
		//}
		//&print_footer;
		if (form.get("mymsg") != null && form.get("mymsg").length() > 0) {
			database.put(form.get("mypage"), form.get("mymsg"));
			printHeader(form.get("mypage"), true);
			printContent();
		} else {
			database.remove(form.get("mypage"));
			printHeader(form.get("mypage") + MSG_DELETED, false);
		}
		printFooter();
	}

	private void doRead() throws IOException {
		//&print_header($form{mypage}, 1);
		//&print_content;
		//&print_footer;
		printHeader(form.get("mypage"), true);
		printContent();
		printFooter();
	}

	//    (
	//        ((mailto|http|https|ftp):[\x21-\x7E]*)  # Direct http://...
	//            |
	//        ($WikiName)                             # LocalLinkLikeThis
	//    )
	static private final Pattern LINK_PATTERN = 
		Pattern.compile(
			"(" +
				"((mailto|http|https|ftp):[\\x21-\\x7E]*)" +  // Direct http://...
					"|" +
				"(" + WIKI_NAME + ")" +                       // LocalLinkLikeThis
			")"
		);

	private void printContent() throws IOException {
		//$_ = &escape($database{$form{mypage}});
		String mymsg = escape(database.get(form.get("mypage")));
		
		//s!
		//    (
		//        ((mailto|http|https|ftp):[\x21-\x7E]*)  # Direct http://...
		//            |
		//        ($WikiName)                             # LocalLinkLikeThis
		//    )
		//!
		//    &make_link($1)
		//!gex;
		// Javaではeオプション(置換後の評価)は出来ないためループで実装する。
		Matcher m = LINK_PATTERN.matcher(mymsg);
		StringBuffer sb = new StringBuffer();
		while (m.find()) {
			m.appendReplacement(sb, makeLink(m.group()));
		}
		m.appendTail(sb);
		mymsg = sb.toString();
		
		//print "<pre>", $_, "</pre>";
		resp.getWriter().print("<pre>" + mymsg + "</pre>");
	}

	//if (/^(http|https|ftp):/) {
	static private final Pattern URL_PATTERN = Pattern.compile("^(http|https|ftp):");

	private String makeLink(String s) {
		//$_ = shift;
		//if (/^(http|https|ftp):/) {
		//    return qq|<a href="$_">$_</a>|;
		//} elsif (/^(mailto):(.*)/) {
		//    return qq|<a href="$_">$2</a>|;
		//} elsif ($database{$_}) {
		//    return qq|<a href="$thisurl?$_">$_</a>|;
		//} else {
		//    return qq|$_<a href="$thisurl?mycmd=edit&mypage=$_">$editchar</a>|;
		//}
		if (URL_PATTERN.matcher(s).find()) {
			return "<a href=\""+ s + "\">" + s +"</a>";
		} else if (s.startsWith("mailto:")) {
			// JavaではMatcherを利用しないとマッチ変数を利用できないためsubStringメソッドで代用した。
			return "<a href=\""+ s + "\">" + s.substring("mailto:".length()) +"</a>";
		} else if (database.containsKey(s)) {
			return "<a href=\""+ THIS_URL + "?" + s + "\">" + s +"</a>";
		} else {
			return s + "<a href=\""+ THIS_URL + "?mycmd=edit&mypage=" + s + "\">" + EDIT_CHAR +"</a>";
		}
	}

	private String escape(String s) {
		//my $s = shift;
		//$s =~ s|\r\n|\n|g;
		//$s =~ s|\r|\n|g;
		//$s =~ s|\&|&amp;|g;
		//$s =~ s|<|&lt;|g;
		//$s =~ s|>|&gt;|g;
		//$s =~ s|"|&quot;|g;
		//return $s;
		// Javaではundefを空文字と評価できないためnullは空文字とする。
		if (s == null) return "";
		s = s.replaceAll("\\r\\n", "\n");
		s = s.replaceAll("\\r", "\n");
		s = s.replaceAll("\\&", "&amp");
		s = s.replaceAll("<", "&lt;");
		s = s.replaceAll(">", "&gt;");
		s = s.replaceAll("\"", "&quot;");
		return s;
	}

	private void printHeader(String title, boolean canEdit) throws IOException {
		//my ($title, $canedit) = @_;
		//print <<"EOD";
		//$contenttype
		//
		//<html>
		//    <head><title>$title</title>$style</head>
		//    <body bgcolor="$bgcolor">
		//        <table width="100%" border="0">
		//            <tr valign="top">
		//                <td>
		//                    <h1>$title</h1>
		//                </td>
		//                <td align="right">
		//                    <a href="$thisurl?$frontpage">$frontpage</a> | 
		//                    @{[$canedit ? qq(<a href="$thisurl?mycmd=edit&mypage=$form{mypage}">$naviedit</a> | ) : '' ]}
		//                    <a href="$thisurl?mycmd=index">$naviindex</a> | 
		//                    <a href="http://www.hyuki.com/yukiwiki/mini/">YukiWikiMini</a>
		//                </td>
		//            </tr>
		//        </table>
		//EOD

		resp.setContentType(CONTENT_TYPE);

		//                    @{[$canedit ? qq(<a href="$thisurl?mycmd=edit&mypage=$form{mypage}">$naviedit</a> | ) : '' ]}
		// Javaでは式に三項演算子を含められないので事前に作成している。
		String naviEdit = canEdit ? "<a href=\"" + THIS_URL + "?mycmd=edit&mypage=" + form.get("mypage") + "\">" + NAVI_EDIT + "</a> | " : "";
		resp.getWriter().print(
				"<html>" + 
				"    <head><title>" + title + "</title>" + STYLE + "</head>" + 
				"    <body bgcolor=\"" + BG_COLOR + "\">" + 
				"        <table width=\"100%\" border=\"0\">" + 
				"            <tr valign=\"top\">" + 
				"                <td>" + 
				"                    <h1>" + title + "</h1>" + 
				"                </td>" + 
				"                <td align=\"right\">" + 
				"                    <a href=\"" + THIS_URL + "?" + FRONT_PAGE + "\">" + FRONT_PAGE + "</a> | " + 
				naviEdit + 
				"                    <a href=\"" + THIS_URL + "?mycmd=index\">" + NAVI_INDEX + "</a> | " + 
				"                    <a href=\"http://www.hyuki.com/yukiwiki/mini/\">YukiWikiMini</a>" + 
				"                </td>" + 
				"            </tr>" + 
				"        </table>"
		);
	}

	private void printFooter() throws IOException {
		//print "</body></html>";
		resp.getWriter().print("</body></html>");
	}

	private void printError(String msg) throws Exception {
		//my $msg = shift;
		//&print_header($errorpage, 0);
		//print "<h1>$msg</h1>";
		//&print_footer;
		printHeader(ERROR_PAGE, false);
		resp.getWriter().print("<h1>" + msg+ "</h1>");
		printFooter();

		//exit(0);
		// Javaはリクエスト単位でプロセスを生成するわけではないためexitできない。
		// ので、とりあえず例外にしておく。本当はServlet側で例外発生時にprintErrotとした方がよいだろうが。。
		throw new Exception(msg);
	}
}

極力、元のソースに対応するように書いてみた。ただ、以下の点はJavaでは同じように書けなかった。

  • DBMファイル ⇒ Dababaseクラスを作成してそれっぽくしてみた。
  • 正規表現置換での/eオプション ⇒ MatcherのappendReplacementメソッドを利用する。JavaDocにサンプルコードが載っている。
  • String#matchesではマッチ変数を利用できない ⇒ Matcherが必要。面倒なのでsubStringメソッドで代用した。
  • undefを空文字として評価してくれない ⇒ nullチェックを入れた。
  • 式に三項演算子を含められない ⇒ 事前に処理をするようにした。
  • exitできない ⇒ Servletはスレッド単位でかつプールしているので。よって例外をthrowするようにした。
  • ヒアドキュメントがない ⇒ ひたすら文字列連結で書いた。
  • 文字列中の変数評価 ⇒ 文字列連結演算子で代用。

まぁ、言語そのものの性質が異なるので当たり前のことなんだけど、Perl便利だな〜と思った部分もある。ちなみに、呼び出し側のServletのソースは以下の通り。単にリクエストごとにYukiwikiminを生成して呼び出しているだけ。

package yukiwikimini;

import java.io.IOException;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@SuppressWarnings("serial")
public class YukiwikiminiServlet extends HttpServlet {

	public void doGet(HttpServletRequest req, HttpServletResponse resp)
			throws IOException {
		doPost(req, resp);
	}

	public void doPost(HttpServletRequest req, HttpServletResponse resp)
			throws IOException {
		try {
			new Yukiwikimin(req, resp).main();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

}

ちなみにDatabaseクラスは以下の通り。PMFについてはGAEjのドキュメントを参照してください。http://code.google.com/intl/ja/appengine/docs/java/datastore/usingjdo.html#Getting_a_PersistenceManager_Instance

package yukiwikimini;

import java.util.HashSet;
import java.util.Set;

import javax.jdo.Extent;
import javax.jdo.JDOObjectNotFoundException;
import javax.jdo.PersistenceManager;

/**
 * Wikiのページを管理するデータベース。
 * DBMファイル(Hash)と同じような感覚で操作できるようにした。
 * 各WikiページはPageクラスとして表現している。
 * 
 * @author n-3104
 */
public class Database {

	private PersistenceManager pm;
	
	public Database() {
		pm = PMF.get().getPersistenceManager();
	}

	public void close() {
		pm.close();
	}

	public Set<String> keySet() {
	    Extent<Page> extent = pm.getExtent(Page.class, false);
	    Set<String> set = new HashSet<String>();
		for (Page p : extent) {
			set.add(p.getName());
		}
		extent.closeAll();
		return set;
	}

	public String get(String key) {
		try {
			Page page = pm.getObjectById(Page.class, key);
			return page.getMsg();
		} catch (JDOObjectNotFoundException e) {
			return null;
		}
	}

	public void put(String key, String value) {
		// トランザクションなしで後勝ちとした。
		pm.makePersistent(new Page(key, value));
	}

	public void remove(String key) {
		try {
			Page page = pm.getObjectById(Page.class, key);
			pm.deletePersistent(page);
		} catch (JDOObjectNotFoundException e) {
			// 他のユーザーによって削除されている場合があるのでエラーとしない
		}
	}

	public boolean containsKey(String key) {
		try {
			Page page = pm.getObjectById(Page.class, key);
			return true;
		} catch (JDOObjectNotFoundException e) {
			return false;
		}
	}

}

で、Pageクラスは以下の通り。TextをStringとして扱う方法はGAEでBlobやTextを定義する方法を参考にさせていただきました。

package yukiwikimini;

import javax.jdo.annotations.IdentityType;
import javax.jdo.annotations.PersistenceCapable;
import javax.jdo.annotations.Persistent;
import javax.jdo.annotations.PrimaryKey;

import com.google.appengine.api.datastore.Text;

@PersistenceCapable(identityType = IdentityType.APPLICATION)
public class Page {
	@PrimaryKey
	private String name;

	@Persistent
	private Text msgText;
	
	public Page(String name, String msg) {
		this.name = name;
	    this.msgText = new Text(msg);
	}

	public String getName() {
		return name;
	}

	public String getMsg() {
	    if (msgText == null) {
	        return null;
	    }
	    return msgText.getValue();
	}

}