Igo: GoogleAppEngineで形態素解析サーバ

IgoをGoogleAppEngine上で動かしてみた。

URL

URLと仕様。

形態素解析分かち書きの結果の書式はmecabコマンドのそれと同様。

$ curl -d text='すもももももももものうち' http://igo-morp.appspot.com/parse
すもも	名詞,一般,*,*,*,*,すもも,スモモ,スモモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
も	助詞,係助詞,*,*,*,*,も,モ,モ
もも	名詞,一般,*,*,*,*,もも,モモ,モモ
の	助詞,連体化,*,*,*,*,の,ノ,ノ
うち	名詞,非自立,副詞可能,*,*,*,うち,ウチ,ウチ
EOS


動かすまでの手順などは後で再現可能なように後日まとめる。
GAEを一度試してみたかった*2だけで、特に何かに利用する予定はなし。

追記(2010/10/08):

少し修正。
もともとはGAEのアップロードサイズ制限に引っかかっていたファイル(辞書データ)を圧縮しておき、クラスロード時に解凍して読み込むようにしていた。
ただそれだとロードの度に凄く時間が掛かる(20〜30秒程度)ので、圧縮ではなく、単純にファイルを分割して保存/読み込みするように変更。
これでロードに掛かる時間は4〜5秒程度に短縮された模様。

追記2(2010/10/09): GAE上で動かすためのpatchとサーブレートのソース等

Igo(0.4.2)をGoogle App Engine上で動かすためには以下の修正が必要だった。

  • ファイルサイズ制限対応
    • 10MBを越えるファイルは分割して保存/読み込みを行うように修正*3
  • MappedByteBufferを非使用に
    • GAEでは使えないようなのでMappedByteBufferで行っていたファイル読み込み部分を、DataInputStreamで置換
  • バイトオーダーをビッグエンディアンに統一
    • もともとはネイティブのエンディアンを使用していたが、DataInputStreamはビッグエンディアン(Javaの標準?)に固定のようなので、データ書き出し部分もそれに合わせて修正

これらの修正を踏まえて、以下のpatchファイルを作成。
igo-0.4.2-gae.patch

diff -crN igo-0.4.2-src/src/net/reduls/igo/dictionary/WordDic.java igo-0.4.2-gae/src/net/reduls/igo/dictionary/WordDic.java
*** igo-0.4.2-src/src/net/reduls/igo/dictionary/WordDic.java	2010-03-29 09:25:40.000000000 +0900
--- igo-0.4.2-gae/src/net/reduls/igo/dictionary/WordDic.java	2010-10-07 12:58:27.000000000 +0900
***************
*** 17,23 ****
  
      public WordDic(String dataDir) throws IOException {
  	trie    = new Searcher(dataDir+"/word2id");
! 	data    = FileMappedInputStream.getString(dataDir+"/word.dat");
  	indices = FileMappedInputStream.getIntArray(dataDir+"/word.ary.idx");
  	
  	{
--- 17,25 ----
  
      public WordDic(String dataDir) throws IOException {
  	trie    = new Searcher(dataDir+"/word2id");
! 	data    = FileMappedInputStream.getString(dataDir+"/word.dat.1")+
!                   FileMappedInputStream.getString(dataDir+"/word.dat.2")+
!                   FileMappedInputStream.getString(dataDir+"/word.dat.3");
  	indices = FileMappedInputStream.getIntArray(dataDir+"/word.ary.idx");
  	
  	{
diff -crN igo-0.4.2-src/src/net/reduls/igo/dictionary/build/WordDic.java igo-0.4.2-gae/src/net/reduls/igo/dictionary/build/WordDic.java
*** igo-0.4.2-src/src/net/reduls/igo/dictionary/build/WordDic.java	2010-03-29 18:35:02.000000000 +0900
--- igo-0.4.2-gae/src/net/reduls/igo/dictionary/build/WordDic.java	2010-10-07 13:18:09.000000000 +0900
***************
*** 131,143 ****
  	}
  
  	// 単語データを出力
! 	final FileMappedOutputStream fmosDat = 
! 	    new FileMappedOutputStream(outputDir+"/word.dat", wdat.length()*2);
! 	try {
! 	    fmosDat.putString(wdat.toString());
! 	} finally {
! 	    fmosDat.close();
! 	}
  	
  	// 単語情報の配列へのインデックスを保存する
  	{ 
--- 131,147 ----
  	}
  
  	// 単語データを出力
!         final int[] wdatLens = {wdat.length()/3, wdat.length()/3, wdat.length()/3+wdat.length()%3};
!         final int[] wdatBegs = {0, wdat.length()/3, wdat.length()/3*2};
!         for(int i=0; i < 3; i++) {
!             final FileMappedOutputStream fmosDat = 
!                 new FileMappedOutputStream(outputDir+"/word.dat."+(i+1), wdatLens[i]*2);
!             try {
!                 fmosDat.putString(wdat.substring(wdatBegs[i],wdatBegs[i]+wdatLens[i]).toString());
!             } finally {
!                 fmosDat.close();
!             }
!         }
  	
  	// 単語情報の配列へのインデックスを保存する
  	{ 
diff -crN igo-0.4.2-src/src/net/reduls/igo/util/FileMappedInputStream.java igo-0.4.2-gae/src/net/reduls/igo/util/FileMappedInputStream.java
*** igo-0.4.2-src/src/net/reduls/igo/util/FileMappedInputStream.java	2010-03-29 18:35:02.000000000 +0900
--- igo-0.4.2-gae/src/net/reduls/igo/util/FileMappedInputStream.java	2010-10-09 18:57:47.662956449 +0900
***************
*** 6,35 ****
  import java.nio.ByteOrder;
  import java.nio.channels.FileChannel;
  
  /**
   * ファイルにマッピングされた入力ストリーム<br />
   * net.reduls.igo以下のパッケージではファイルからバイナリデータを取得する場合、必ずこのクラスが使用される
   */
  public final class FileMappedInputStream {
!     private final FileChannel cnl;
!     private int cur=0;
! 
      /**
       * 入力ストリームを作成する
       * 
       * @param filepath マッピングするファイルのパス
       */
      public FileMappedInputStream(String filepath) throws IOException {
! 	cnl = new FileInputStream(filepath).getChannel();
      }
  
      public int getInt() throws IOException {
! 	return map(4).getInt();
      }
      
      public int[] getIntArray(int elementCount) throws IOException {
  	final int[] ary = new int[elementCount];
! 	map(elementCount*4).asIntBuffer().get(ary);
  	return ary;
      }
      
--- 6,50 ----
  import java.nio.ByteOrder;
  import java.nio.channels.FileChannel;
  
+ import java.io.File;
+ import java.io.DataInputStream;
+ import java.io.BufferedInputStream;
+ import java.io.EOFException;
+ import java.util.zip.GZIPInputStream;
+ 
  /**
   * ファイルにマッピングされた入力ストリーム<br />
   * net.reduls.igo以下のパッケージではファイルからバイナリデータを取得する場合、必ずこのクラスが使用される
   */
  public final class FileMappedInputStream {
!     private final DataInputStream dis;
!     private final int fileSize;
      /**
       * 入力ストリームを作成する
       * 
       * @param filepath マッピングするファイルのパス
       */
      public FileMappedInputStream(String filepath) throws IOException {
!         dis = new DataInputStream(new BufferedInputStream(new FileInputStream(filepath)));
!         fileSize = (int)new File(filepath).length();
      }
  
      public int getInt() throws IOException {
!         return dis.readInt();
!     }
! 
!     public short getShort() throws IOException {
!         return dis.readShort();
!     }
! 
!     public char getChar() throws IOException {
!         return dis.readChar();
      }
      
      public int[] getIntArray(int elementCount) throws IOException {
  	final int[] ary = new int[elementCount];
!         for(int i=0; i < elementCount; i++)
!             ary[i] = getInt();
  	return ary;
      }
      
***************
*** 44,84 ****
  
      public short[] getShortArray(int elementCount) throws IOException {
  	final short[] ary = new short[elementCount];
! 	map(elementCount*2).asShortBuffer().get(ary);
  	return ary;
      }
  
      public char[] getCharArray(int elementCount) throws IOException {
  	final char[] ary = new char[elementCount];
! 	map(elementCount*2).asCharBuffer().get(ary);
  	return ary;
      }
  
      public String getString(int elementCount) throws IOException {
! 	return map(elementCount*2).asCharBuffer().toString();
      }
  
      public static String getString(String filepath) throws IOException {
  	final FileMappedInputStream fmis = new FileMappedInputStream(filepath);
  	try {
! 	    return fmis.getString(fmis.size()/2);
  	} finally {
  	    fmis.close();
  	}
      }
  
      public int size() throws IOException {
! 	return (int)cnl.size();
      }
  
      public void close() {
  	try {
! 	    cnl.close();
  	} catch (IOException e) {}
      }
! 
!     private ByteBuffer map(int size) throws IOException {
! 	cur += size;
! 	return cnl.map(FileChannel.MapMode.READ_ONLY, cur-size, size).order(ByteOrder.nativeOrder());
!     }
! }
\ ファイル末尾に改行がありません
--- 59,104 ----
  
      public short[] getShortArray(int elementCount) throws IOException {
  	final short[] ary = new short[elementCount];
!         for(int i=0; i < elementCount; i++)
!             ary[i] = getShort();
  	return ary;
      }
  
      public char[] getCharArray(int elementCount) throws IOException {
  	final char[] ary = new char[elementCount];
!         for(int i=0; i < elementCount; i++)
!             ary[i] = getChar();
  	return ary;
      }
  
      public String getString(int elementCount) throws IOException {
!         StringBuilder sb = new StringBuilder(elementCount);
!         for(int i=0; i < elementCount; i++)
!             sb.append(dis.readChar());
!         return sb.toString();
      }
  
      public static String getString(String filepath) throws IOException {
  	final FileMappedInputStream fmis = new FileMappedInputStream(filepath);
  	try {
!             StringBuilder sb = new StringBuilder(fmis.size());
!             try {
!                 for(;;)
!                     sb.append(fmis.getChar());
!             } catch (EOFException e) {}
!             return sb.toString();
  	} finally {
  	    fmis.close();
  	}
      }
  
      public int size() throws IOException {
! 	return fileSize;
      }
  
      public void close() {
  	try {
! 	    dis.close();
  	} catch (IOException e) {}
      }
! }
diff -crN igo-0.4.2-src/src/net/reduls/igo/util/FileMappedOutputStream.java igo-0.4.2-gae/src/net/reduls/igo/util/FileMappedOutputStream.java
*** igo-0.4.2-src/src/net/reduls/igo/util/FileMappedOutputStream.java	2010-03-29 18:35:02.000000000 +0900
--- igo-0.4.2-gae/src/net/reduls/igo/util/FileMappedOutputStream.java	2010-10-06 17:22:58.000000000 +0900
***************
*** 26,32 ****
  	final FileChannel cnl = new RandomAccessFile(filepath,"rw").getChannel();
  	try {
  	    mbb = cnl.map(FileChannel.MapMode.READ_WRITE, 0, size);
! 	    mbb.order(ByteOrder.nativeOrder());
  	} finally {
  	    cnl.close();
  	}
--- 26,32 ----
  	final FileChannel cnl = new RandomAccessFile(filepath,"rw").getChannel();
  	try {
  	    mbb = cnl.map(FileChannel.MapMode.READ_WRITE, 0, size);
! 	    mbb.order(ByteOrder.BIG_ENDIAN);
  	} finally {
  	    cnl.close();
  	}

パッチを当てた後も使用方法は通常版と変わらない。
※ ただし、バイナリ辞書の互換性はなくなる

# パッチを当てる手順
$ tar zxvf igo-0.4.2-src.tar.gz
$ patch -p0 < igo-0.4.2-gae.patch


サーブレット:
後はサーブレットのソース等。

// ファイル名: Igo.java
// - クラスロード時にTaggerをロードしてstaticに持つ

import java.util.List;
import net.reduls.igo.Tagger;
import net.reduls.igo.Morpheme;
import javax.jdo.annotations.IdGeneratorStrategy;
import javax.jdo.annotations.IdentityType;
import javax.jdo.annotations.PersistenceCapable;
import javax.jdo.annotations.Persistent;
import javax.jdo.annotations.PrimaryKey;

@PersistenceCapable(identityType = IdentityType.APPLICATION)
public class Igo {
    @PrimaryKey
    @Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
    private Long id;

    private static Tagger tagger;
    static {
        try {
            // 辞書ロード: 辞書のパスは固定
            tagger = new Tagger("ipadic/");
        } catch (Exception e) {}
    }

    public static List<String> wakati(String text) {
        return tagger.wakati(text);
    }

    public static List<Morpheme> parse(String text) {
        return tagger.parse(text);
    }
}
// ファイル名: ParseServlet.java
// - http://igo-morp.appspot.com/parseリクエスト時に使用されるクラス

import java.io.IOException;
import javax.servlet.http.*;
import net.reduls.igo.Morpheme;

public class ParseServlet extends HttpServlet {
    public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String text = req.getParameter("text");
        if(text==null)
            text = "";
        
        resp.setContentType("text/plain; charset=UTF-8");
        for(Morpheme m : Igo.parse(text))
            resp.getWriter().println(m.surface+"\t"+m.feature);
        resp.getWriter().println("EOS");
    }
    
    public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        doGet(req,resp);
    }
}
// ファイル名: ParseServlet.java
// - http://igo-morp.appspot.com/wakatiリクエスト時に使用されるクラス

import java.io.IOException;
import javax.servlet.http.*;

public class WakatiServlet extends HttpServlet {
    public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String text = req.getParameter("text");
        if(text==null)
            text = "";
        
        boolean first = true;
        resp.setContentType("text/plain; charset=UTF-8");
        for(String s : Igo.wakati(text)) {
            if(!first)
                resp.getWriter().print(" ");
            first = false;
            resp.getWriter().print(s);
        }
        resp.getWriter().println("");
    }
    
    public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        doGet(req,resp);
    }
}

JSP

<!-- ファイル名: igo.jsp -->
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.util.List" %>
<%@ page import="javax.jdo.PersistenceManager" %>

<html>
  <head>
    <Meta Http-equiv="content-type" Content="text/html; charset=UTF-8">
    <title>Igo: 形態素解析</title>
  </head>

  <body>
    <a href="http://sourceforge.jp/projects/igo/">Igo</a>を用いた形態素解析を行います。<br />
    テキストを入力して「形態素解析」ボタンを押してください。<br />
    <form method="POST" action="/parse">
      <textarea name="text" cols="50" rows="10"></textarea>
      <br />
      <input type="submit" value="形態素解析" />
    </form>
  </body>
</html>

web.xml

<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE web-app PUBLIC
 "-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
 "http://java.sun.com/dtd/web-app_2_3.dtd">

<web-app xmlns="http://java.sun.com/xml/ns/javaee" version="2.5">
  <servlet>
    <servlet-name>wakati</servlet-name>
    <servlet-class>WakatiServlet</servlet-class>
  </servlet>

  <servlet-mapping>
    <servlet-name>wakati</servlet-name>
    <url-pattern>/wakati</url-pattern>
  </servlet-mapping>

  <servlet>
    <servlet-name>parse</servlet-name>
    <servlet-class>ParseServlet</servlet-class>
  </servlet>

  <servlet-mapping>
    <servlet-name>parse</servlet-name>
    <url-pattern>/parse</url-pattern>
  </servlet-mapping>

  <welcome-file-list>
    <welcome-file>igo.jsp</welcome-file>
  </welcome-file-list>
</web-app>

*1:UTF-8文字列をURIエンコードしたもの

*2:及びIgoで試したいことがあった(結局やらなかったけど...)

*3:初めの追記にも書いてあるが、もともとは該当ファイルを圧縮(gzip圧縮)し、読み込み時に解凍するしていたが、これだとクラスのロードの度に凄く時間が掛かってしまうため、ファイル分割に変更