2012年6月13日水曜日

郵便番号検索を作る─5.住所検索できるようにする(後編)

前回で住所検索を実装する準備が整ったので、今回一気に実装までしてみる。

■作成手順
・郵便番号データを作成
・郵便番号の全文一致でリストを出力
・郵便番号の前方一致でリストを出力
・都道府県、市区町村によるリストを作成
・住所(漢字、カナ、かな)で検索できるように←今回ココ
・郵便番号データに知人/友人を登録できるようにする
・レイアウトをカスタマイズする
どんな動作になるようにするか

できるなら、以下のような動きになるように作っていきたい。

  1:indexページで郵便番号入力フォームに住所を入力したら住所検索となる
  2:転置インデックスを使って高速に部分検索できるようにする
  3:更に、丁目以降の細かい住所を入れた場合も近似の場所がでるようにする

転置インデックスは、hasOneでジョインかな。3を実装する為に、再帰関数を使ってヒットしなかった場合は検索文字列の最後を削って再検索する、という方法を使ってみる。

・「東京都中央区入船3丁目」で検索
・ヒットしなければ次は「東京都中央区入船3丁」で検索
・以下10回まで繰り返す。
10回試行しても見つからなければエラー

何度も無駄な検索をかけるのは効率悪いけど、1検索あたり0.1秒もかからないんと思うから(多分)。同時アクセスが多い場合は他の方法を考えないとダメだけどね。

転置テーブルを結合

転置インデックスでは同一テーブルをいくつもjoinさせ、検索文字列をずらしていきながらそれぞれのjoinテーブルで検索をかけていって精度を高めるって方法をとる。

【発行したいSQL例】東京都中央区入船を検索する場合
select * from postal_codes as P
inner join postal_tips as t0 on P.id = t0.postal_code_id 
inner join postal_tips as t1 on P.id = t1.postal_code_id
inner join postal_tips as t2 on P.id = t2.postal_code_id
inner join postal_tips as t3 on P.id = t3.posatl_code_id
where P.address like '%東京都中央区入船%'
and t0.area = '東京'
and t1.area = '都中'
and t2.area = '央区'
and t3.area = '入船'

沢山inner joinすると効率悪いのではないかって思うかもしれないけど、Mysqlオプティマイザはいい子だからそれらのjoinテーブルから一番件数の少ないものを選びながら他のワードは排除していくっていう効率いい検索をしてくれる。から、問題ない(多分ね)。

これをcakePHPで実装するには、PostalCodeモデルにhasOneを指定するといいみたい。
hasOneを指定すると、外部キーを持つテーブルと一対一のjoinを発行してくれる。

hasOneを指定する

通常だったら、以下のようにPostalCodeモデルに付け加えたら自動的にcakePHPが結合してくれる。

//Model/PostalCode.php内
  class PostalCode extends AppModel{
    public $hasOne = array(
        'PostalTip'
    );
    //あるいは

  class PostalCode extends AppModel{
    public $hasOne = array(
        'T1'=>array(
          'className'=>'PostalTip',
          //'foreignKey'=>'postal_code_id',
          //'type'=>'inner'
        )
    );
   //コメントアウトしてる部分は、あってもなくてもいい

foreignKeyは外部キー名を指定するんだけど、cakePHPの命名規約に沿って作ってる場合は特に指定する必要がない。
あとcakePHPのjoinにはhasOneの他にhasManybelongsTohasAndBelongsToManyがあるみたいで、最後の以外は基本的に構文は同じ。
(多少はそれぞれで違うけど、基本は下記のでできる)

宣言 $結合方法 = array('結合モデル1','結合モデル2',...);
#または
宣言 $結合方法 = array(
     'エイリアス名' => array(
          'className'=>'結合モデル',
          'foreignKey'=>'外部キー名',
          'type'=>'inner / left(結合タイプ)',
          'conditions'=>array(基本の検索条件),
          'fields'=>array(基本の抽出フィールド),
          'order' =>array(ソート方法),
          'dependent'=> false 
      (DELETEを発行した場合同時削除するかどうか true:削除する)
        ),
      .
      .
      .
    );
後から指定する場合

基本は上のようににクラス名の最初の方で宣言して使用するんだけど、そうしちゃうと、PostalCodeモデルを使って発行した全てのクエリにPostalTipモデルがもれなくくっついてきてしまう。

だけど、今回はあくまでも住所検索時だけしか使いたくない。それにこの検索は発行されるクエリによって結合する数が変わる為、そもそも最初に宣言するという方法は使えない。というわけで、今回は住所検索時にだけ結合を宣言するという方法を使う。

そのやり方としてcakePHPでは色々な関数が用意されてるみたいで、今回はbindModelを使ってそれを実装する。

Model::bindModel($params , $reset = true);
【例外的に結合を設定する】
$params = array('結合方法(hasOneなど)'=>array(上の指定方法と同じ));
$reset = trueの場合適応は1回限り。falseの場合処理が終了するまで永続的に適応
//コントローラ内で使う場合は$this->モデル名->bindModel()となる

なお、これと反対で例外的に結合を解除するunbindModel()というのもある。使い方はbindModelとほぼ同じ。

というわけで、作っていく

結合の仕方なども決まったので、あとは一気に作成していく。

//Model/PostalCode.php内
//PostalTipを使って転置インデックスを行う
public function useTips($area = ''){
   if(!strlen($area)) return false;
   //既に設定されてる可能性もあるからそれを一時的にリセット
   if(count($this->hasOne)){
     $this->unbindModel(array('hasOne'=>array_keys($this->hasOne)) , false);
   }
   $area = mb_convert_kana($area , 'C' , 'utf8');
   $count = mb_strlen($area);
   //文字数に応じてどんどん結合していく
   for($i = 0; $i * 2 < $count; $i++){
     $_area = mb_substr($area , $i * 2 , 2 , 'utf8');
     if(mb_strlen($_area) === 2){
        $conditions['T'.$i.'.area'] = $_area;
        $hasOne['hasOne']['T'.$i] = array('className'=>'PostalTip' , 'type'=>'inner');
      }else{
        break;
     }
   }
  //結合(ページネーションを使う為第二引数はfalseにする)
   $this->bindModel($hasOne , false);
   //ついでに作成した検索条件を返す
   $conditions[$this->alias.'.address like'] = '%' . $area . '%';
   return $conditions;
}
//Controller/PostalCodesController.php内
//findsアクションを作成
public function finds($area = 'チュウオウクイリフネ'){
   //検索をする
   $result = $this->_findArea($area);
   $this->set(compact('result'));
   //結果をaddress.ctpへと投げる
   $this->render('address');
}

//見つかるまで10回だけ検索しなおす
public function _findArea($area , $count = 0){
   $mb_strlen = mb_strlen($area , 'utf8');
   //2文字以下または10回試行したらエラー
   if($count >= 10 || $mb_strlen <= 2){
      $this->Session->setFlash('その住所では見つかりませんでした。もう少し広範囲になるように設定しなおすか別の検索しなおして下さい');
      $this->redirect('index');
   }
   $conditions = $this->PostalCode->useTips($area);
   //ページネーションに設定
   $this->paginate = array('conditions'=>$conditions,'limit'=>10);
   $result = $this->paginate('PostalCode' , array() , array());
   if(!$result){
      //結果がなかった場合、カウントを繰り上げ検索文字を後ろから1文字減らして再検索
      $result = $this->_findArea(mb_substr($area , 0 , --$mb_strlen),++$count);
   }else{
      //存在したらその検索した文字列をセット
      $this->set('area',$area);
   }
   return $result;
}

//indexアクションを変更
public function index(){
   if($this->request->is('post')){
     $data = $this->request->data('PostalCode.zipcode');
     if(preg_match("/([0-9]{3})\-?([0-9]{0,4})/i" , $data)){
        //郵便番号の形の場合郵便番号検索
        $this->redirect(array('action'=>'address',$data));
     }else{
        //そうでない場合住所検索
        $this->redirect(array('action'=>'finds',$data));
     }
   }
}

今回は既に過去に使ったことのある関数しか使ってないから特に解説しない。
ただ注意として、ページネーションを使う場合、bindModelの第二引数はfalse(永続設定)にするというのを忘れちゃいけないみたい。
というのも第二引数のデフォルトはtrueで、1回の検索で設定がリセットされるようになっている。 一方ページネーションを使うと件数をカウントするクエリが自動で発行されて、2回クエリを投げることになる為に1回の検索でリセットされちゃうとうまくいかないのだ。

あと、今回再帰関数でbindModelを何回も発行する可能性があるんで、最初にunbindModelでリセットしないと大変なことになる。
というのも、今回の作り方だとちゃんとリセットしないと

【8文字で検索した】→【4つの結合とそれぞれの検索条件を発行】→【結果がなかった】→【1文字減らして再検索】→【3つの結合とそれぞれの検索条件を発行】→【でも、実際は4つ結合がされてる(リセットされない設定にしてる)】→【1つの結合には検索条件が指定されない】→【その結合は全データを舐める】→【357万件を読み込みにいく】→→→
【(((゚Д゚)))】

こんなbindModelの発行の仕方は特殊と思うから滅多にならないことだと思うけど、とりあえずこれで10分くらいハマった。。再帰関数を使う場合はそういう設定は毎回リセットするように心がけた方がいいね。

実際にチェック

あとは、address.ctpにセットした$areaを上部に表示するようちょこちょこっといじったり(郵便番号検索ではセットされてないからある場合のみ表示するよう条件分岐してね)、index.ctpの表示をちょっといじった(1行程度の変更だから割愛)。

実際に「東京都中央区入船3丁目」と入力して動作チェックしてみる。ちなみに、郵便番号では「東京都中央区入船」しかない。

下のクエリ発行時間を見ると、ページネーションのカウンタも含め合計8回クエリを発行してて、合計の発行クエリ時間は8ミリ秒だった。

ついでに「東京都中央区日本橋」でも検索してみる。

こっちもやたらと早い。3ミリ秒。ある程度成功と言えそう。

ひらがなでの検索もちゃんとできた。


ここまで、ちょっとずつ段階を踏んで郵便番号検索を作ってきて何とか住所もラクラク検索できるように設定できた。「東京都日本橋」で検索したら「東京都日」で検索されちゃったりする点ではgoo郵便番号検索には劣っちゃうけど、ひらがなやカタカナでも検索できる点は勝ってる(気がする)。前者のもコードを書き換えたら実装できそうだけど、面倒だからいいや。

次回から、ちょっと一休みしてhasOneとかbelongsToとかの結合方法について色々調べたことをまとめてみようかと思います。

【参考リンク】
cakePHP2.xCookBook(英語版)
lvystar:[CakePHP] belongsTo と hasMeny の関係図
PROGRAMMER BLOG:CakePHPのbindModelとunbindModel

0 件のコメント:

コメントを投稿