Pattern ReplaceCharFilterFactoryの使い方

今回はPatternReplaceCharFilterFactoryの使い方を紹介します。このクラスは前回のMappingCharFilterFactoryと同様に、SolrのCharFilterの一つで、tokenizerが解析する前の文字列に対して処理を行います。名前が示す通り、正規表現を用いたパターンマッチを用いて置換を行うコンポーネントです。正規表現を利用できるので、柔軟に置換ルールを設定できます。しかし、詳細は後述しますが、メモリ効率やパフォーマンスがよくない事もあるため、パターンマッチでしか実現できない事以外では積極的に利用するのは控えた方がいいでしょう。
利用シーンとしては次のようなものが考えられます。

特定のパターンに一致する文字列を除外したい

・HTML or XMLのコメント要素を除去: <!--.+-->→空文字
 例:abcd<!-- コメント -->efg→abcdefg
・英字を除去: [a-zA-Z]+→空文字
 例:hoge12hogeあいう→12あいう
・英字以外を除去: [^a-zA-Z]+→空文字
 例:hoge12hogeあいう→hogehoge

特定のパターンに一致する文字列を置換したい

・複数の空白文字の連続を一つの空白に置換:\s+→" "(半角スペース)
 例:test1      test2→test1 test2
・endの連続の圧縮構文を置換:en+d→rubykaigi2011
 例:the ennnnnnnnnd!!→the rubykaigi2011!!
 ※http://redmine.ruby-lang.org/issues/5054 より

特定のパターンに一致する文字列を前方参照を使って置換したい

・同じ文字の連続を1文字に置換:(.)\1+→$1
 例:1223334444あああ→1234あ
・メールアドレス(っぽいもの)の一部を隠す:(.+)@.+\..+→$1@xxxx
 例:hoge@hoge.com→hoge@xxxx

使い方

属性名 説明 必須
pattern 正規表現Java正規表現を指定可能※1)
replacement 置換文字列(※2) 未定義の場合はマッチしたパターンの除去となる -
maxBlockChars パターンマッチを行う文字列の最大文字数(これを超える文字列の場合は、この文字数ごとにブロック化されてパターンマッチを行う。未指定の場合は10000文字。) -
blockDelimiters maxBlockChars以内でこの文字列が出てきた場合、ブロックの区切りがこの文字列の位置になる -

※1 java.util.regex.Patternで使える正規表現が指定可能
 前方参照を使う場合、正しくグループ指定しないと解析時の実行時エラーになるので注意
※2 $nで前方参照が可能(Matcherのjavadocを参照)

一つの定義で一つのパターンしか設定できないので、複数のパターンを設定したい場合は、下記のようにPatternReplaceCharFilterを指定したい数だけ設定します。

<fieldType name="prcf_test" class="solr.TextField" positionIncrementGap="100">
  <analyzer>
    <charFilter class="solr.PatternReplaceCharFilterFactory" pattern="(aa)\d+(bb)" replacement="$1 $2"/>
    <charFilter class="solr.PatternReplaceCharFilterFactory" pattern="(.)\1+" replacement="$1"/>
    <tokenizer class="solr.WhitespaceTokenizerFactory"/>
  </analyzer>
</fieldType>

maxBlockCharsとblockDelimitersは、パターンマッチを行う単位(ブロック)を文字数もしくは区切り文字で設定します。文字列はここで指定したブロック単位で分割され、その後ブロック単位でパターンマッチが行われます。ブロックをまたがるパターンにはマッチしないので注意して下さい。予め文字列の最大が分かっているのであれば、maxBlockCharsに十分大きな値を設定する事で回避できますが、crawlerなどサイズが予測できない場合は漏れるケースがあるので、そういう場合は補助的な使い方に留めておくのがいいでしょう。

注意点

置換前と後で文字数が変わる場合はhighlightがずれてしまうようです。(javadocより)どういう場合に発生するのかまだ実際に確認出来ていませんので、機会があれば試してみます。

それでは中身を見ていきましょう。前回のMappingCharFilterFactoryと同じような構成で、作りもよりシンプルです。

クラスの詳細


※Solr 3.3のものです(現行trunkではPatternReplaceCharFilterはLuceneに移動しました)

処理のほとんどがPatternReplaceCharFilterで行われており、Factoryはインスタンスを生成する役割のみです。

このFilterのポイント

・readメソッド

  @Override
  public int read() throws IOException {
    while( prepareReplaceBlock() ){
      return replaceBlockBuffer.charAt( replaceBlockBufferOffset++ );
    }
    return -1;
  }

なぜifではなくwhileなのかはさておき、、、置換後の文字列を必要に応じて準備→出来れば一文字返し、出来なければ-1を返します。次にprepareReplaceBlockで何が行われているか見てみましょう。

・prepareReplaceBlockメソッド

  private boolean prepareReplaceBlock() throws IOException {
    while( true ){
      if( replaceBlockBuffer != null && replaceBlockBuffer.length() > replaceBlockBufferOffset )
        return true;
      // prepare block buffer
      blockBufferLength = 0;
      while( true ){
        int c = nextChar();
        if( c == -1 ) break;
        blockBuffer[blockBufferLength++] = (char)c;
        // end of block?
        boolean foundDelimiter =
          ( blockDelimiters != null ) &&
          ( blockDelimiters.length() > 0 ) &&
          blockDelimiters.indexOf( c ) >= 0;
        if( foundDelimiter ||
            blockBufferLength >= maxBlockChars ) break;
      }
      // block buffer available?
      if( blockBufferLength == 0 ) return false;
      replaceBlockBuffer = getReplaceBlock( blockBuffer, 0, blockBufferLength );
      replaceBlockBufferOffset = 0;
    }
  }

ここでmaxBlockCharsの文字数かblockDelimitersまでもしくはreadが-1を返すまでの、置換前の文字列のブロックが読み込まれ、パターンマッチと置換を行います。まだ読み込まれていない置換後の文字列がある時は、読込と置換処理は行わずにtrueを返します。次にパターンマッチと置換を見ましょう。

・getReplaceBlockメソッド

  String getReplaceBlock( char block[], int offset, int length ){
    StringBuffer replaceBlock = new StringBuffer();
    String sourceBlock = new String( block, offset, length );
    Matcher m = pattern.matcher( sourceBlock );
    int lastMatchOffset = 0, lastDiff = 0;
    while( m.find() ){
      m.appendReplacement( replaceBlock, replacement );
      // record cumulative diff for the offset correction
      int diff = replaceBlock.length() - lastMatchOffset - lastDiff - ( m.end( 0 ) - lastMatchOffset );
      if (diff != 0) {
        int prevCumulativeDiff = getLastCumulativeDiff();
        if (diff > 0) {
          for(int i = 0; i < diff; i++){
            addOffCorrectMap(nextCharCounter - length + m.end( 0 ) + i - prevCumulativeDiff,
                prevCumulativeDiff - 1 - i);
          }
        } else {
          addOffCorrectMap(nextCharCounter - length + m.end( 0 ) + diff - prevCumulativeDiff,
              prevCumulativeDiff - diff);
        }
      }
      // save last offsets
      lastMatchOffset = m.end( 0 );
      lastDiff = diff;
    }
    // copy remaining of the part of source block
    m.appendTail( replaceBlock );
    return replaceBlock.toString();
  }

パターンマッチと置換を行っています。置換前と後で文字数が変わる場合、元の文字列との位置を補正する為の情報も一緒に保存しています。(MappingCharFilterでもやっていました)

あとは入力が無くなるまで上記の繰り返しです。シンプルですね。

感想

Java標準の正規表現ライブラリで使ったことのないメソッド(Matcher#appendReeplacement)が使われてて、こう使うのかと勉強になった
何でStringBuilderじゃなくてStringBuffer使ってるのかなと思ったら、上記メソッドの引数がStringBufferだったというオチ(StringBuilderは1.5からで、Matcherは1.4から)
自分の正規表現力の無さを改めて痛感・・・

参考情報

海外のPatternReplaceCharFilterFactoryの記事:java.dzone.com

CharFilterの最後はHTMLStripCharFilterFactoryです。