2012/06/15

各種トライの速度比較

6/17 GCの影響をできるだけ排除するように実装し、測り直しました。

LOUDSTrieの実装が落ち着いてきたので、パトリシアトライ、ダブルアレイ、LOUDSトライの構築、contains速度を測ってみました。

何回か測ってみたけど、だいたいこんな感じ。

構築(ms)contains(ms)
java.util.HashSet1,042816
PatriciaTrie(UTF-16 char[])891662
MultilayerPatriciaTrie(多層トライ)3,5381,354
TailCompactionDoubleArray5,082858
OptimizedTailCompactionDoubleArray5,7601,022
LOUDSTrie9221,025

CPUはCore i7 2.5GHz。データはWikipedia日本語タイトル127万件。パトリシアトライの実装はここ、ダブルアレイの実装はここを参照。DoubleArray2つとLOUDSTrieはPatriciaTrieを使って構築してるので、実際の構築時間はPatriciaTrieの分も必要です。

意外とLOUDSTrieが構築、検索共に頑張ってる印象。というかDoubleArrayってもっと速いんじゃなかったっけ。実装が悪いのかも知れませんが。

実装はTrie4J(GitHub)にて公開してます。

2012/05/16

Wikipedia英語タイトルも格納してみた

Wikipedia英語版のタイトル一覧(2012/4/3版。9,310,564エントリ)も格納して、サイズ測ってみました。
実装配列要素数(base/check等)TAIL配列要素数全体サイズ
OptimizedTailCompactionDoubleArray12,710,31115,699,525162,867,250

各種トライの性能を比較した記事「2011-01-10 marisa-trie 強いな」と比較してみると、エントリ数が1.5倍弱になってることを考慮して、darts-cloneに近いメモリ効率をたたき出してると思う。ただdarts-cloneはTAILをDAWG(Directed Acyclic Word Graph)で保持してるようなので、今のトライ使ったTAIL圧縮だと勝てないかも知れない。あと文字をcharで保持してたり、違いはいろいろ。まぁこっちはJavaなので既に速度的にはだいぶ水開けられてるし(Corei72.5GHzで24秒程度)、元よりあまり有効な比較にはならないかも知れない。構築途中で1.3GBくらいメモリ消費するしねー。

ダブルアレイのサイズ比較

今勉強&実装してるダブルアレイのサイズを測ってみました。詳しい話はまた今度(というつつパトリシアトライの解説もまだ途中だけど・・・)。

データはパトリシアトライと同じく、Wikipedia日本語タイトルの127万エントリ。文字列は全てcharで保持してます。また、予めパトリシアトライを構築し、それをもとにダブルアレイを構築してます。

以下の表にまとめます。
実装配列要素数(base/check等)TAIL配列要素数全体サイズ
DoubleArray*15,668,704048,547,151
TailDoubleArray*21,679,6164,651,339(capacity: 4,718,590)31,947,268
TailCompactionDoubleArray*31,679,6161,797,866(capacity: 2,359,294)27,226,058
OptimizedTailCompactionDoubleArray*41,519,6791,797,86621,177,535
*1 - シンプルなダブルアレイ実装。空き要素の位置を記録して構築高速化、孫ノードの多い子ノードを先に登録してメモリ利用効率向上、TAIL無し。
*2 - TAILあり。
*3 - 接尾辞トライによるTAIL圧縮。
*4 - 構築終了時に配列サイズをtrim。checkの要素をintではなくcharにする。

パトリシアトライの時に苦労していたのが嘘のように、シンプルな実装でいきなり48MBを叩きだしてます。前回Javaではオブジェクト数大事、と連呼してましたが、トライを保持する方法がノードオブジェクトをポインタでつなぐ方法から、int配列2つに変わったことで、TAIL圧縮すらしてないのに約34MB減りました。

結論: やっぱりオブジェクト数大事

さらにTAIL配列、TAIL配列圧縮等工夫していくことで、最終的に21MBまで小さくなっています。これ以上は、ダブルアレイの仕組みそのものを変えないと大幅な圧縮は期待できず、行き着く先は、トライの保持をビットベクタで行うLOUDS(Level Order Unary Degree Sequence。参考)です。なので今後はLOUDSを勉強&実装してく予定です。

ダブルアレイの実装について少し。最初のDoubleArrayクラスでは、教科書通りの実装(こことか参照)に加えて、少し効率化を図ってます。一つは構築速度の効率化として行なっている、空き要素位置の保持です。これは最初の空き要素位置を保持する変数を1個儲け、2番目以降の空き要素位置はcheck配列内に保持してます。ただし真面目にやると大変(時間がかかる)なので、実際見に行って空き要素ではなければ、そこから1つずつ空き要素を探すようにしています。もう一つは孫ノードの多い子ノードを先に登録することで充填率が上がり、結果配列のサイズが小さくなります。これはまぁデータ依存なところはありますが。

TailDoubleArrayクラスは、DoubleArrayにTAIL配列(ここのMinimal Prefix Double-Arrayあたり参照)を導入したものです。一つしか子持たないノードが続く区間は、ラベルを文字配列内にコピーすることで、メモリ利用効率を上げます。

TailCompcationDoubleArrayクラスは、TAIL配列を可能な限り共有することで、さらに効率を上げます。"world"がTAILに格納されている状態で"javaworld"を格納する場合に"world\0java\1(0)"というように、既に接尾辞の一部が格納されている場合はそれを再利用するという工夫も行なってます。これは、多層トライで使ったテクニックを応用して、TAILを逆順に格納したトライをつくることで実現しています。このあたりはまた別途詳しく書く予定。

OptimizedTailCompactionDoubleArrayクラスは、構築終了時にbase配列やcheck配列の末尾にある使用していない領域を縮小したり、TAIL配列(中身はStringBuilder)をtrimしたりしてます。また、check配列をintではなくcharにしています。文字の種類が32,767を超えると格納できなくなりますし、次の空き要素までの距離が32,768を超えても格納できなくなります。現実的にはどちらもまず起こらない(Wikipedia日本語タイトルでも、文字種は7,564)ので、実用上問題ないんじゃないかと思います(でも念のため別クラスとして実装)。

実装は今までどおり、GitHubで公開してます。

2012/04/19

パトリシアトライのサイズ比較

 記事が前後しますが。気になったのでちょっと調べてみました。トライの内部形式をchar[]からbyte[]にすることでどれくらいインパクトがあるのか。byte[]にする際、UTF-8、Shift-JIS、EUC、JIS(ISO2022JP)でどのような違いがあるのか。

以下の表にまとめます。

トライのノード文字配列数
エンコーディング(内部形式)数サイズ数サイズ全体サイズ
ISO-2022-JP(byte[])1,763,96758,210,9111,764,14241,189,910123,904,937
UTF-8(byte[])1,736,91057,318,0301,737,08340,404,315123,095,316
Shift_JIS(byte[])1,639,06454,089,1121,639,22935,103,790112,335,651
EUC_JP(byte[])1,633,86553,917,5451,634,03135,089,059112,013,060
UTF-16(char[])1,513,46948,431,0081,515,34635,043,030103,472,496
UTF-16(char[])+フィールド最適化(*1)1,513,469(*3)39,664,136(*4)1,515,33834,688,31291,774,563
UTF-16(char[])+フィールド最適化(*1)+多層トライ(*2)1,995,303(*3)55,082,824(*4)483,714(*5)10,345,546(*5)82,870,660

*1 - 子ノードを持たない/1つだけ/2つ以上、終端である/ないの組み合わせでそれぞれ最適なフィールドを持つノードクラスを使用する。
*2 - ラベルを逆順に持つ圧縮用トライを用意(参考: 多層トライの実験結果 - やた@はてな日記)。本来のトライのノードもその圧縮用トライのノードをフィールドに持つものを使用。
*3 - 全ノードの合計数。
*4 - 全ノードの合計サイズ。
*5 - 文字配列は圧縮用トライ内のもの。本来のトライには文字配列を持たない。

いずれもWikipedia日本語タイトル127.3万件を格納した結果です。フィールド最適化+多層トライのデータでは、圧縮用トライのノードや文字配列のサイズも加算されています。

こうして見てみると、多層トライがいかに圧縮率が高いかと、オブジェクト数の増減がいかにインパクトがあるかがわかります。せっかく多層トライで圧縮したメモリサイズ(24MB)も、ノードオブジェクト数の増加でその効果は半減しています。

結論: Javaではオブジェクト数大事

多層トライの実装はGitHubで公開してます。org.trie4j.trie.patricia.multilayer.MultilayerPatriciaTrieがフィールド最適化と多層トライの実装で、全タイトル挿入後のデータ、挿入してからpack(), morePack()を呼び出した後のデータが表の下2つです。packで多層トライでの圧縮、morePackで多層トライのノードのフィールド最適化を行います(morePack遅いです)。

下記にVisualVMでの主要なオブジェクトのスナップショットを。まずはUTF-16のフィールド最適化のみ。

次にフィールド最適化+多層トライ(もフィールド最適化)。

サイズをオブジェクト数で割るとわかるけど、1オブジェクトで24とか32バイト消費してます。フィールド最適化を行わない場合、40バイト消費することもあります(64bit環境で。32bitなら半分になります。)

結論: Javaではオブジェクト数大事(2回目)

しかしこの画像の小ささ何とかならんかな。

2012/04/13

Trie4J - Java Patricia Trie Implementation

ちょこちょことまとめているパトリシアトライのJava実装ですが、現状のソースをGitHubにあげました。

https://github.com/takawitter/trie4j

記事では簡単のためラベルをString, 子ノードをList<String>で管理しているものを紹介していますが、この実装では
  • ラベルをchar[]、子ノードをNode[]で管理する、org.trie4j.patricia.simple.PatriciaTrie
  • ノードの状態(終端かどうか、子ノードを持つかどうかなど)に応じてクラスを変更し、かつ多層トライによるサイズ圧縮に対応する、org.trie4j.patricia.multilayer.MultilayerPatriciaTrie
の2つが含まれています(もう一つありますが、あまり効果無いのでそのうち消します)。 詳しい解説はまた後ほど(predictive search書けてから)。

JavaでPatriciaTrieで各種検索

前回からの続きで、今回は検索メソッドを実装してみます。実装するのはこの2つ。
  • common prefix search
  • predictive search
common prefix searchは、クエリ"東京国際フォーラム"が与えられた時に、"東"、"東京"、"東京国"、"東京国際フォーラム"をトライから列挙するという検索です(実際これらの単語はWikipedia日本語版にあります)。
上図で赤枠で示したノードをたどりながら、終端ノードを列挙していく処理になります。先頭からノードをたどり、ノードのラベルとクエリを比較し、ノードのラベルを比較し終えたら、終端ノードであれば列挙、そして子ノードから残りのクエリの比較を行う候補を探し、そのノードに対しても同じ処理をしていきます。一方向に辿っていくだけなので再帰は必要なく、単純なループで実現できます。コードを下記に示します。
public Iterable<String> commonPrefixSearch(String query) {
 List<String> ret = new ArrayList<String>(); // 結果配列
 int cur = 0; // query内の照合位置
 Node node = root;
 while(node != null){
  String letters = node.getLetters();
  if(letters.length() > (query.length() - cur)) return ret; // 残りの文字列よりノードのラベルのほうが長ければマッチしないので終了
  for(int i = 0; i < letters.length(); i++){
   if(letters.charAt(i) != query.charAt(cur + i)) return ret;
  }
  if(node.isTerminated()){
   ret.add(query.substring(0 , cur + letters.length()));
  }
  cur += letters.length();
  if(query.length() == cur) return ret;
  node = node.getChild(query.charAt(cur));
 }
 return ret;
}
単純なwhileループで、着目するノード(変数node)を書き換えながら処理を行なっています。
predictive searchは、指定された文字列(prefix)を前方一致検索します。例えば"東京国"が指定されれば、"東京国"、"東京国税局"、"東京国際フォーラム"、"東京国際マラソン"が列挙されます。
上図赤枠のノードが列挙対象になります。まず指定されたprefixを含むノードを探し、見つかったノード以降のノードを全て列挙します。この列挙は部分木全部が対象になるので、再帰で実装するのが楽です(再帰が嫌な場合はQueue使ってもOK)。コードはこんな感じ。
/**
 * 部分木の列挙をする再帰関数。
 */
private static void enumLetters(Node node, String prefix, List<String> letters){
 List<node> children = node.getChildren();
 if(children == null) return;
 for(Node child : children){
  String text = prefix + child.getLetters();
  if(child.isTerminated()) letters.add(text);
  enumLetters(child, text, letters);
 }
}

public Iterable<String> predictiveSearch(String prefix) {
 char[] prefixChars = prefix.toCharArray();
 int cur = 0; // prefixChars内の照合位置
 Node node = root;
 while(node != null){
  String letters = node.getLetters();
  int n = Math.min(letters.length(), prefixChars.length - cur); // 共通部分の比較のためnは短い方に合わせる。
  for(int i = 0; i < n; i++){
   if(letters.charAt(i) != prefixChars[cur + i]){
    return Collections.emptyList();
   }
  }
  cur += n;
  if(prefixChars.length == cur){ // prefixCharsの比較が終われば、あとは部分木の列挙
   List<String> ret = new ArrayList<String>();
   prefix += letters.substring(n);
   if(node.isTerminated()) ret.add(prefix);
   enumLetters(node, prefix, ret);
   return ret;
  }
  // 比較がまだ終わって無ければ、子ノードを探して検索を継続
  node = node.getChild(prefixChars[cur]);
 }
 return Collections.emptyList();
}

2012/03/15

JavaでPatriciaTrieを実装してみた

書籍「日本語入力を支える技術」を読んでテンション上がったので、Javaでパトリシアトライを実装してみました(本当はビットベクタを実装したかったけどとりあえずダブル配列からやろうと思ってそれでも挫折してPatriciaTrieから入門した次第)。また、このエントリで紹介するコードはパトリシアトライの説明用で、全然最適化が行われていないので、メモリ効率も速度も良くないです。そのあたりを最適化したコードは、別のエントリで紹介する予定です。
(↓※アソシエイトIDあり)
ちなみにPatriciaはPractical Algorithm to Retrieve Information Coded in Alphanumericの略らしい(参考: wikipedia:基数木)。構造は至ってシンプルで、いろいろと実装例があります。
wikipediaのパトリシアトライの説明から図を借りると、その構造はこんな感じ:

ちょっとわかりにくいので、形を変えてみると:
空間効率的には、上図右側の各文字列の灰色で示した部分が他の文字列と共有されるので、かなりデータ量は小さくて済みます(wikipedia日本語タイトルデータで50%程度の効率化)。また、検索効率は、検索したい文字列を先頭から順に探して行くだけなので、最悪O(k)で済みます(kは文字列長)。実際は子ノードの探索方法によっては検索速度に影響が出るけど、それはまた後で。

構造がわかったところで、それを表現するクラスを定義します。

public class Node{
 // コンストラクタ、getter、setterは割愛
 private String letters;
 private List<node> children;
 private boolean terminated;
}

各ノードで保持する文字列は変数lettersに、子ノードは変数childrenに格納します。加えて、そのノードで文字列が終わっているかどうかを保持する変数terminatedを設けます。これは、例えば上図の文字列に加え、"rom"や"rubic"が加わった場合、ツリーの途中で文字列が終わることになるので、それに対応するため。上図に、文字列の終端という概念を加えて、あとツリーの途中で文字列が終わるケースを示すために"rom"と"rubic"も挿入されたとして、終端のノード(terminated==true)を太枠で表すと、下図のようになります。


終端については、終端文字列を設けて('$'とか)、それを終端となるノードにぶら下げるのが一般的のようなんですが、コードをシンプルにしたかったので、上記実装にしました(実際はいくつか工夫の余地はあり、今できるだけメモリを使わない方法をトライ中なんですが、それは完成した後に詳しく書く予定)。

さて、まずはある文字列がトライに含まれるかどうかを判定する、containsメソッドを実装してみます。考え方としては、
  1. 自ノードに文字列が設定されている場合、検索文字列との比較を行う。
  2. 自ノードの文字列だけでは比較が終わらない場合、子ノードを探す。
  3. 子ノードが見つかった場合、子ノードに対して同じ操作を行う
という感じで、検索対象文字列の先頭から、ノード内の文字列との比較を行い、どんどん子ノードに降りて行きます。コードは下記。

boolean contains(String word){
 int rest = word.length();
 int n = letters.length();
 if(n > rest) return false; // 自ノードの文字列のほうが長ければ比較失敗。
 for(int i = 0; i < n; i++){
  if(letters.charAt(i) != word.charAt(i)) return false;
 }
 if(n == rest) return terminated; // 文字列が同じであれば、終端かどうかを返す。
 word = word.substring(n); // 比較済み文字列を捨てる。
 if(children != null){
  char c = word.charAt(0); // 子ノードを探す。(*1)
  for(Node n : children){
   if(n.letters.charAt(0) == c){
    return n.contains(word); // 見つかれば、containsを呼び出す。
   }
  }
 }
 return false;
}
自ノードの文字列(letters)、子ノード(children)の順で処理を行なっています。子ノードの検索(*1)は線形探索していて、当然遅いので、実用で使うには、頻度順に並べるとか、childrenはソートしておいて2分探索を使うとか、ハッシュを使うとかする必要がありますが、今は割愛。データ量が膨大になる場合は、各ノードでハッシュを持つとメモリ消費が問題になる可能性があるので、2分探索か頻度順が現実的かと思います(そのぶん構築コストは高いけど)。実際wikipediaの日本語タイトルを挿入してみると、線形探索と2分探索では、処理にかかる時間の桁が違いました(50万件の検索で線形探索1762ms, 2分探索201ms)。

ちなみに2分探索だと、コードは下記のようになります。
boolean contains(String word){
 int rest = word.length();
 int n = letters.length();
 if(n > rest) return false; // 自ノードの文字列のほうが長ければ比較失敗。
 for(int i = 0; i < n; i++){
  if(letters.charAt(i) != word.charAt(i)) return false;
 }
 if(n == rest) return terminated; // 文字列が同じであれば、終端かどうかを返す。
 word = word.substring(n); // 比較済み文字列を捨てる。
 if(children != null){
  char c = word.charAt(0); // 2分探索で子ノードを探す
  int end = children.size();
  int start = 0;
  while(start < end){
   int i = (start + end) / 2;
   Node child = children.get(i);
   int d = c - child.getLetters().charAt(0);
   if(d == 0){
    return child.contains(word);
   }
   if(d < 0){
    end = i;
   } else if(start == i){
    break;
   } else{
    start = i;
   }
  }
 }
 return false;
}

次に要素の挿入です。おおまかに以下の流れで実装しました。
  1. ノード内の文字列(thisLetters)か比較対象の文字列(letters)の短い方の文字数分比較
  2. 全て比較成功
    1. thisLettersとlettersの長さが同じであれば、ノードを終端にして(terminated = true)終了(a)
    2. thisLettersの方が長ければ、ノードを分割して終了(b)
    3. lettersの方が長ければ、比較済み文字列を捨てる
    4. 新しいlettersの先頭文字とマッチする先頭文字を持つ子ノードを探す
    5. 存在すれば、その子ノードに対してinsertChildを呼び出して終了(c)
    6. 存在しなければ、新しい子ノードを作成して追加し終了(d)
  3. 途中で比較が止まった場合、その位置でノードを分割し、lettersの残りも子ノードとして追加する。(e)
コードは下記のようになります。
public void insertChild(String letters){
   int n = Math.min(letters.length(), this.letters.length());
   int i = 0;
   while(i < n && (letters.charAt(i) == this.letters.charAt(i))) i++;
   if(i == n){
    if(letters.length() == this.letters.length()){
     terminated = true; // (a)
     return;
    }
    if(letters.length() < this.letters.length()){
     Node node = new Node( // (b)
       this.letters.substring(letters.length())
       , this.children, this.terminated);
     this.letters = letters;
     this.children = new ArrayList<Node>(1);
     this.children.add(node);
     ((ArrayList<Node>)this.children).trimToSize();
     this.terminated = true;
     return;
    }
    letters = letters.substring(i);
    int index = 0;
    for(Node child : children){
     int c = letters.charAt(0) - child.letters.charAt(0);
     if(c < 0) break;
     if(c == 0){
      child.insertChild(letters); // (c)
      return;
     }
     index++;
    }
    this.children.add(index, new Node(letters)); // (d)
    ((ArrayList<Node>)this.children).trimToSize();
    return;
   }
   String newLetter1 = this.letters.substring(0, i); // (e)
   String newLetter2 = this.letters.substring(i);
   String newLetter3 = letters.substring(i);
   List<Node> newChildren = new ArrayList<Node>(2);
   if(newLetter2.charAt(0) < newLetter3.charAt(0)){
    newChildren.add(new Node(newLetter2, this.children, this.terminated));
    newChildren.add(new Node(newLetter3));
   } else{
    newChildren.add(new Node(newLetter3));
    newChildren.add(new Node(newLetter2, this.children, this.terminated));
   }
   this.letters = newLetter1;
   this.terminated = false;
   this.children = newChildren;
  }

なお、子ノードは全て昇順でソートされるようにして、探索は線型探索を使っています(ここも2分探索にすれば高速化できる)。また、メモリ利用量を抑えるため、子ノードの配列は正味のサイズだけ確保するようにしています(その分メモリリアロケーションが増えて速度は落ちているはず)。

ちなみに、上記のコードでは、VMの最大メモリ容量が128MBだと、Wikipedia日本語タイトル(127万件)はメモリに載りません(180MB程度必要です。ちなみにlettersをchar[]に、childrenをArrayListではなくNode[]に変えれば載ります)。ですが、まぁ一応機能はするので、次はTrieの特徴であるcommon prefix searchを実装してみます。

2011/02/02

Slim3 JSON機能の説明(非公式)

(2/7更新)
(2/26更新)
(12/22更新 @Jsonはフィールド直接指定になりました)

Slim3 JSON機能のドキュメントを書き始めました。まだ非公式なものですが、ひと通り書き終わったら公式へのマージを提案する予定です。でも、公式は英語なんですよね。どうしたものか。まぁ、いきなり下手な英語で書くより、まずは日本語でちゃんと書いたほうが良いはず。

というわけで、下記ドキュメントの草案です。若干改行が変ですが、evernoteに書きなぐってexportしたものを貼っつけてるためだと思います。これにあとJSON入出力のカスタマイズ方法を書いて一段落とする予定です。(書きました!)

概要
 Slim3のJSON機能は、モデル(@org.slim3.datastore.Modelアノテーションが付加されたクラス)のJSON変換機能を提供します。JSON変換は、org.slim3.datastore.ModelMeta<M>に定義される以下のメソッドを呼び出すことにより行われます。

モデルのJSON文字列への変換:
  • String modelToJson(Object model);
  • String modelToJson(Object model, int maxDepth);
  • String modelsToJson(Object[] models);
  • String modelsToJson(Object[] models, int maxDepth);
JSON文字列のモデルへの変換:

  • M jsonToModel(String json);
  • M jsonToModel(String json, int maxDepth);
  • M[] jsonToModels(String json);
  • M[] jsonToModels(String json, int maxDepth);
 例えばTestModelというモデルに対してJSON変換機能を使用するコードは、次のようになります。

TestModel m = new TestModel();
m.setValue(1);
String json = TestModelMeta.get().modelToJson(m);
System.out.println(json); // {"value": 1}
TestModel m2 = TestModelMeta.get().jsonToModel(json);
Assert.assertEquals(m.getValue(), m2.getValue());


JSON変換の制御
 フィールドにJSONアノテーション(@org.slim3.datastore.json.JSON)を指定することで、JSON変換の振る舞いを変更できます。以下にJSONアノテーションで指定できるパラメータを示します。

パラメータ名 型 デフォルト 説明
ignore boolean false このアトリビュートを無視します。JSON出力に含まれず、JSON入力時にも読み込まれません。
ignoreNull boolean true アトリビュートがnullの場合、何も出力しません。falseの場合は、"null"が出力されます。
alias String 空文字列 アトリビュートのJSON文字列内での名前を指定します。
coder Class<? extends JsonCoder> Default.class JSON入出力を行うJsonCoderのクラスを指定します。ここに独自のクラスを指定することで、JSON入出力のカスタマイズを行うことができます(後述)。

 例えばプロパティを無視したり、エイリアスを設定したりする場合、以下のように記述します。

@Model
class TestModel{
  ...
  @Json(ignore=true)
  private String ignoredValue;
  @Json(alise="foo")
  private String bar;
}


対応する値型
 Slim3のJSON変換機能は、以下の値型に対応します。また、後述するカスタマイズを行えば、これ以外の型に対応したり、入出力方法を変更する事ができます。

Javaクラス
JSON表現
例
java.lang.String, com.google.appengine.api.datastore.Text
文字列。cipher=trueの場合、JSON内では暗号化されます。
"text":"hello"
byte[], com.google.appengine.api.datastore.ShortBlob, com.google.appengine.api.datastore.Blob
バイト列のBase64文字列。
"blob":"mMB4qZAgtBKJq0d1LBGTCA=="
boolean, java.lang.Boolean
ブール値(true or false)。
"value":true
short, java.lang.Short, int, java.lang.Integer, long, java.lang.Long
整数。
"value":100
float, java.lang.Float, double, java.lang.Double
小数。
"value":1.0
java.util.Date
整数(Date.getTime()が返す値)。
"value":10233400
java.lang.Enum
文字列(Enum.name()が返す値)。
"value":"MONDAY"
com.google.appengine.api.users.User
ネストしたJSON文字列(User.getEmail()等の値をJSONエンコード)。
"user":{"authDomain":"authDomain","email":"user@test.com"}
com.google.appengine.api.datastore.Key
文字列(KeyFactory.keyToString(Key)の実行結果)。
"key":"aglzbGltMy1nZW5yCwsSBHRlc3QY6AcM"
com.google.appengine.api.datastore.Category
文字列(Category.getCategory()の値)。
"category":"partOfSpeech"
com.google.appengine.api.datastore.Email
文字列(Email.getEmail()の値)。
"mail":"user@domain.tld"
com.google.appengine.api.datastore.GeoPt
ネストしたJSON文字列(GeoPt.getLatitude()及びGeoPt.getLongitude()の値をJSONエンコード)。
"geopt":{"latitude":10.0,"longitude":10.0}
com.google.appengine.api.datastore.IMHandle
ネストしたJSON文字列(IMHandle.getAddress()及びIMHandle.getProtocol()の値をJSONエンコード)。
"handle":{"address":"handle","protocol":"xmpp"}
com.google.appengine.api.datastore.Link
文字列(Link.getValue()の値)。
"link":"linkValue"
com.google.appengine.api.datastore.PhoneNumber
文字列(PhoneNumber.getNumber()の値)。
"phone":"000-000-000"
com.google.appengine.api.datastore.PostalAddress
文字列(PostalAddress.getAddress()の値)。
"address":"Tokyo, Japan"
com.google.appengine.api.datastore.Rating
整数(Rating.getRating()の値)。
"rating":100
com.google.appengine.api.blobstore.BlobKey
文字列(BlobKey.getKeyString()の値)。
"blobkey":"Q3PqkweYlb4iWpp0BVw"
org.slim3.datastore.ModelRef<M>
文字列(モデルのkeyに対してKeyFactory.keyToString(key)した結果)。
"ref":{"key":"lskfo2ijalefkwejfwlke",value:100}

 また上記サポートする型のコレクションにも対応しています。対応するコレクションクラスはjava.util.List, java.util.Set, java.util.SortedSetで、JSON文字列からモデルを作成する際は、それぞれjava.util.ArrayList、java.util.HashSet、java.util.TreeSetをnewして要素を追加したものがセットされます。


ModelRef<M>の展開
 デフォルトではJSONアノテーションのcoderパラメータにorg.slim3.datastore.json.Defaultが指定されたものとして扱われ、このクラスは、ModelRefを参照先のモデルのキーに変換します。参照先のモデルの内容を展開したい場合、org.slim3.datastore.json.Expandedを指定して下さい。このクラスは、参照先のモデルの内容を展開し、そのモデルがさらにModelRefを持っている場合も展開します。展開のネストの深さは、modelToJsonメソッドやjsonToModelメソッドの、maxDepth引数で制限できます。
 以下にコード例を示します。

@Model
class TestModel{
  ...
  @Json(coder=Expanded.class)
  private ModelRef<TestModel> ref = new ModelRef<TestModel>(TestModel.class);
}
...
TestModel m = new TestModel();
TestModelMeta.get().modelToJson(m, 3);  // 3階層まで展開


JSON入出力のカスタマイズ
 JSONアノテーションのcoderパラメータにJSON入出力を行うクラスを指定することにより、JSON変換の振る舞いを変更することができます。デフォルトではorg.slim3.datastore.json.Defaultが指定されており、slim3が対応するデータ型のJSON入出力が行われます。slim3ではもう一つ、org.slim3.datastore.json.Expandedが用意されていて、これをModelRef<M>型のフィールドに対して使用すると、ModelRef<M>が参照しているオブジェクトも展開します。
 slim3が対応していないデータ型を使う場合や入出力方法を変更したい場合は、org.slim3.datastore.json.Defaultから継承したクラスを作成し、encode/decodeメソッドをオーバーライドして下さい。
 下記にjava.awt.Pointの入出力をサポートするクラスの例を示します。

class CustomCoder extends org.slim3.datastore.json.Default{
  public void encode(JsonWriter writer, Object value){
    if(value instanceof java.awt.Point){
      java.awt.Point pt = (java.awt.Point)value;
      writer.beginObject();
      writer.writeValueProperty("x", pt.x);
      writer.writeValueProperty("y", pt.y);
      writer.endObject();
    } else{
      super.encode(writer, value);
  }
  public <T> T decode(JsonReader reader, T defaultValue, Class<T> clazz){
    if(java.awt.Point.isAssignableFrom(clazz)){
      try{
        int x = Integer.parseInt(reader.readProperty("x");
        int y = Integer.parseInt(reader.readProperty("y");
        return clazz.cast(new Point(x, y));
      } catch(NumberFormatException e){
      }
    }
    return defaultValue;
  }
}

 java.awt.PointクラスのフィールドをJSON入出力の対象にするには、上記のCustomCoderをcoderパラメータに指定します。

import java.awt.Point;

@Model
class TestModel{
  public Key getKey(){
    return key;
  }

  public void setKey(Key key){
    this.key = key;
  }

  public Point getPoint(){
    return point;
  }

  public void setPoint(Point point){
    this.point = point;
  }

  @Attribute(primariKey=true)
  private Key key;

  @Attribute(persistent=false)
  @Json(coder=CustomCoder.class)
  private Point point;

}

 上記のように指定すると、java.awt.Point型のpointフィールドのJSON入出力がおこなえるようになります。

TestModel m = new TestModel();
m.setPoint(new Point(10, 20));
String json = TestModelMeta.get().modelToJson(m); // {"point":{"x":10,"y":20}}
TestModel m2 = TestModelMeta.get().jsonToModel(json);
Assert.assertEquals(m.x, m2.x);
Assert.assertEquals(m.y, m2.y); 

2011/01/02

AppEngine Java で絵文字入りメールを送信する

Google AppEngineで絵文字メールを送る方法を紹介します。AppEngineでは送信メールのエンコーディングを指定できない( issueへのリンク)ため、絵文字を送ることはできないと思っていたんですが、 @tmurakamiさんに、送る方法を教えてもらいました。ただし、今のところこの方法で動作確認が出来ているのは、auのW62T, W63H, W64Kのみです。また、AppEngineの振る舞いに依存しているため、今後も動くかどうかの保証はありません。また、メールをWebメール等に転送している場合、Webメール側(au oneとgmailで確認されています)ではメール全体が(絵文字部分だけでなく)正常に表示されないようです(ヘッダにあるエンコーディング情報とメールボディのエンコーディングが違うので無理も無いですが)。

方法は至ってシンプルで、JISエンコード済みのテキストを無理やりStringに入れるというものです。例えばスマイルマーク(ucs: ☺, au: )であれば、auでの文字コードは0x7656なので、以下のようにします。
String text = new String(
   new byte[]{0x1b, '$', 'B', 0x76, 0x56, 0x1b, '(', 'B'}
   , "ISO8859-1");
0x1b, '$', 'B'はJIS漢字を開始するエスケープシーケンス、0x1b, '(', 'B'はアスキー文字を開始するエスケープシーケンスです。こうやって作った文字列を、メールの本文に設定します。
MailService.Message m = new MailService.Message();
 m.setTextBody(text);
 // set sender, recipients and subject
 MailServiceFactory.getMailService().send(m);
ここでは触れませんが、JavaMailApiでは、ByteArrayDataSourceを使うと送れるそうです。

AppEngineはメールを送信する際、含まれているキャラクタを見て、エンコーディングを決定します。日本語が含まれていればISO2022JPを選択しますし、中国語であればBig5、UTF8になる場合もあります。上記のように制御コードが混じっていると、ISO8859-1として、そのまま送信するようです。そして実機ではエンコーディングを無視してISO2022JPとして表示するため、絵文字が表示できる、という仕組みです。ただしこのAppEngineの振る舞いは規定されているものではないので、今後も動き続ける保証はありません。

pos2witではこの方法を使って、絵文字の送信機能を実装しました(今のところauのみ)。ダッシュボードから有効にできるので、au携帯でお使いの方は是非試してみてください。また、今後他社の携帯も調査して、可能であれば対応する予定です。

2010/11/20

JSON変換機能、slim3にマージ中

以前のエントリで書いたslim3モデルのJSON変換機能ですが、slim3のコミッターになってマージ中です!

ロードマップはこんな感じ。

1st step (完了).
・modelToJsonメソッドの追加。基礎的な仕組みの実装。
・short, int, long, float, double及びそれらのラッパークラスのJSON出力実装。
・StringのJSON出力実装(cipher=trueを考慮)。
・ラッパークラスとStringのList, Set, SortedSetのJSON出力実装。

2nd step (完了).
・AppEngine固有の型(Blob, Category, Email, GeoPt, IMHandle, Link,
PhoneNumber, PostalAddress, Rating, ShortBlob, Text, UserのJSON出力実装。
・Date, EnumのJSON出力実装。
・上記の型のList, Set, SortedSetのJSON出力実装。

3rd step以降.
・リファクタリング
・ModelRef対応。
・シリアライズ対応(おそらくシリアライズしてBase64)
・アノテーションによる制御の検討(フィールド名のエイリアスとか、JSON変換時は無視するとか)
・jsonToModelメソッド追加(と上記の型の対応)。

実装の途中経過は、適宜slim3のDiscussion Groupでお知らせしてます。