Ruby でソースコード検索エンジンの作り方 〜Milkode の内部実装解説〜

はじめに

はじめまして、おんがえしと申します。

この記事では私の作っている Milkode という Ruby で書かれたソースコード検索エンジンの作り方について紹介します。

Milkode の内部で使われているたくさんの gem の紹介と、そのカスタマイズ方法を中心に進めます。Ruby で一定以上の規模のアプリケーションやライブラリを作るのであれば、RubyGems を使いこなすことは、とても大切なトピックの一つではないでしょうか。

具体的には、アプリケーション内部で

  • 何の gem が使われているのか
  • どうやって使うのか
  • どこをカスタマイズする必要があるのか

を紹介していきます。みなさんが何かを作る時の一助になれば幸いです。

Milkode とは

知らない人も多いと思いますので、簡単に紹介します。

Milkode は Ruby で書かれた行指向のソースコード検索エンジンと検索アプリです。数万オーダーのファイルから、目的のキーワードを含む 1 行を瞬時に検索することが可能です。私の手元のマシンでは 15 万ファイルほど登録しても 1〜2 秒以内で検索できます。

さらに詳しい使い方については以下のホームページや紹介記事をご覧ください。

旅の仕度

記事内でもサンプルコードを交えながら紹介していきますが、 Milkode のソースコードを事前に Milkode に登録して (ややこしいです)、検索しながら読むとさらに理解が深まるのでおすすめです。

Milkode のインストールについては Milkode - ダウンロード を参考にしてください。

インストールしてデータベースの作成が終わりましたらソースコードを登録します。 Git レポジトリから直接ソースコードを登録することが可能です。

$ milk add git@github.com:ongaeshi/milkode.git

もしくは milkode - Kodeworld をどうぞ。

Rroonga - 検索エンジン&ストレージ

Rroonga - ラングバ

$ gem install rroonga

国産の全文検索エンジン Groonga を Ruby から使えるようにしたものです。Groonga データベースをストレージ&検索エンジンとして利用しています。心臓部分ですね。

色々な人に Milkode を紹介するとその検索速度の速さに驚いてもらえることが多いのですが、それは Groonga の検索性能による所がとても大きいです。

Rroonga 自体がカラムストアエンジンとして使えるので MySQL などのストレージエンジンを別途インストールする必要がないのも Rroonga を採用した理由のひとつです。 Milkode は gem でインストールするアプリケーションなので、他にインストーラーを立ち上げるようなものが増えると、どんどん敷居が上がってしまうので……。

Rroonga で検索するには事前にテーブルを定義しておく必要があります。以下は Milkode が内部で定義しているテーブルです*1。ひとつのファイルを 1 レコードとして登録しています。全文検索時に転置インデックス格納用のテーブル (terms) を作る必要があるのは少し複雑ですが、それ以外は素直ではないかと思います。

/milkode/lib/milkode/database/document_table.rb:15

Groonga::Schema.define do |schema|
  schema.create_table("documents", :type => :hash) do |table|          
    table.string("path")
    table.string("package")
    table.string("restpath")
    table.text("content")
    table.time("timestamp")
    table.string("suffix")
  end

  schema.create_table("terms",
                      :type => :patricia_trie,
                      :key_normalize => true,
                      :default_tokenizer => "TokenBigramSplitSymbolAlphaDigit") do |table|
    table.index("documents.path", :with_position => true)
    table.index("documents.package", :with_position => true)
    table.index("documents.restpath", :with_position => true)
    table.index("documents.content", :with_position => true)
    table.index("documents.suffix", :with_position => true)
  end
end

検索の時は select メソッドを使います。 10 万〜100 万位のレコード数でも一瞬で検索することができます。

/milkode/lib/milkode/database/document_table.rb:174

# shortpathの一致するレコードを取得
def find_shortpath(shortpath)
  package, restpath = Util::divide_shortpath(shortpath)
  result = @table.select { |record| (record.package == package) & (record.restpath == restpath) }
  return result.records[0]
end

# 指定パス以下のファイルを全て取得
def find_shortpath_below(shortpath)
  if (shortpath.nil? || shortpath.empty?)
    @table.select.records
  else
    package, restpath = Util::divide_shortpath(shortpath)

    if (restpath.nil? || restpath.empty?)
      @table.select { |record| record.package == package }.to_a
    else
      @table.select { |record| (record.package == package) & (record.restpath =~ restpath)}.to_a
    end
  end
end

ちなみに、私自身が Rroonga をもっと簡単に使えるようにするために、GrnMini というライブラリも開発しています。カラム指定不要でデータを追加できたり、転置インデックス用のテーブルを自動で作成してくれたりします。この記事を読んで Rroonga に興味を持ってくれた方は、GrnMini のことも覚えておいてくれたら幸いです。以上宣伝でした。

Thor - コンソールアプリ本体

Thor - Home

$ gem install thor

サブコマンド付きのコンソールアプリを簡単に書ける gem です。以下のコードで add, update, remove の 3 つのサブコマンドを持ったアプリケーションを作成することができます。

/milkode/lib/milkode/cli.rb:8

require 'thor'

module Milkode
  class CLI < Thor
    # コマンドの説明
    desc "add PATH", "Add packages"
    # オプション指定
    option :branch_name,    :type => :string,  :aliases => '-b',   :desc => 'Branch name.'
    option :empty,          :type => :boolean,                     :desc => 'Add empty package.'
    def add(*args)
      # addコマンド本体
    end

    desc "update [keyword1 keyword2 ...]", "Update database"
    # updateコマンドのオプション
    def update(*args)
      # updateコマンド本体
    end

    desc "remove keyword_or_path1 [keyword_or_path2 ...]", "Remove package"
    # removeコマンドのオプション
    def remove(*args)
      # removeコマンド本体
    end
end

@tomykaira さんの Pull Request #27 で実現されました。それ以前は Ruby 標準の OptionParser をむりやりハックして使っていたのですが、アプリケーションの規模が大きくなってきて OptionParser でサブコマンドを書くのが大変になっていたので大変助かりました。

カスタマイズ: milk add -h を実現する

thor には一点だけ不満があって、デフォルトのヘルプコマンドが

$ milk help add

のように "コマンド help サブコマンド" の形式しか受け付けてくれないのです。私はサブコマンドのヘルプを見たい時は "milk add (ここでヘルプを見たくなる)" となることが多いので、"コマンド サブコマンド -h" の形式も受け付けるようにカスタマイズしています。

  • (A): '-h' を Thor 全体で有効なオプションとして定義します
  • (B): invoke_command 内で '-h' が来た時に CLI.task_help を呼び出すことでサブコマンドのヘルプを表示することができます
  • (C): task.name != 'grep' しているのは、Pull Request #27 でも議論されていますが、milk grep コマンドだけ独自に OptionParaser でパースしているためです

/milkode/lib/milkode/cli.rb:10

class CLI < Thor
(A)  class_option :help, :type => :boolean, :aliases => '-h', :desc => 'Help message'
  .
  .
  no_tasks do
    def shell
      @shell ||= Thor::Base.shell.new
    end

    # デフォルトメソッドを上書きして -h を処理
    # invoke_command は  /lib/thor/invocation.rb で定義されている
    def invoke_command(task, *args)
(B)      if options[:help] &&
(C)         task.name != 'grep'
        CLI.task_help(shell, task.name)
      elsif options[:version] && task.name == 'help'
        puts "milk #{Version}"
      else
        super
      end
    end
  end
end

Sinatra, Haml, Sass - Web アプリ

Sinatra Haml Sass

$ gem install sinatra haml sass

Milkode の Web アプリ部分を構成する gem です。Rails を使わない選択肢としては割とオーソドックスな方ではないかと思います。 Sinatra は README の分かりやすさで使うことを決めました。 Haml は若干書き方に癖があるのですが、慣れると erb より少ない記述量でさくさく HTML が書けるので好きです。

Sass を使うようになったのは割と最近で、相対 URL 対応のために使っています。 (それまでは生の CSS を使っていました)

/milkode/lib/milkode/cdweb/views/milkode.scss:266

.star {
     text-indent: -5000px;
     display: block;
     background: transparent url(<%= url_for "/images/milkode-star.png" %>) 20px;
     height: 20px;
     width: 20px;
}

背景画像のパスを相対 URL に合わせて変更しています。

Launchy - ブラウザを直接起動する

Launchy

$ gem install launchy

'milk web' を実行した時に自動でブラウザを開くために使っています。 OS や使っているブラウザに依存せずに Web ページを開くことができるので便利です。以下で 'http://localhost:9292/' を開きます。

Launchy.open('http://localhost:9292/')

カスタマイズ: Rack との兼ね合い

これで簡単に、といきたい所ですが現実はなかなかうまくいきません。 Web アプリケーションを Rack で立ち上げた後に、その URL を Launchy で起動することを考えてみます。

例えば以下のようなコードになります。

# Rackサーバー生成
rack_server = Rack::Server.new(options)

# Rackサーバー起動
rack_server.start

# ブラウザを開く
Launchy.open('http://localhost:9292/')

起動してみると、何かおかしいです。

$ milk web
>> Thin web server (v1.5.1 codename Straight Razor)
>> Maximum connections set to 1024
>> Listening on 127.0.0.1:9292, CTRL+C to stop
# ……あれブラウザが立ち上がらないぞ?

仕方なく停止すると……

$ milk web
>> Thin web server (v1.5.1 codename Straight Razor)
>> Maximum connections set to 1024
>> Listening on 127.0.0.1:9292, CTRL+C to stop
  C-c C-c>> Stopping ...
# C-c C-c で停止
rubima-milkode-01.png

Web アプリ停止後にブラウザが起動してきます。困りましたね。 'rack_server.start' すると Web サーバーを停止するまで次の行にいかないことが原因のようです。

そこで Milkode では、'Rack::Server#start' にブロックを渡して、その中で Launchy を起動するようにしています。

/milkode/lib/milkode/cdweb/cli_cdweb.rb:44

def self.execute_with_options(stdout, options)
  .
  .
  # Rackサーバー生成
  rack_server = Rack::Server.new(options)

  # 起動URL生成
  launch_url = create_launch_url(options)

  # URL設定
  ENV['MILKODE_RELATIVE_URL'] = File.join('/', options[:url]) if options[:url]

  # 起動
  rack_server.start do
    # この時点でoptions[:Host]やoptions[:Port]などの値が壊れてしまっているため事前にURLを生成している
    Launchy.open(launch_url) if launch_url
  end
  .
  .
end

これで上手くいきました。何とかなるものですね。

rubima-milkode-04.png

CodeRay - ソースコード色付け

CodeRay

$ gem install coderay

ソースコードの色付けをしてくれる gem です。よいライブラリを見つけるのが結構大変でした。

最初は簡単に使えそうなものをいくつか試したのですが、ruby/doc/ChangeLog-1.8.0 のようにサイズの大きなファイルを開くと重くなったりして大変でした (このサイズだと CodeRay でも結構重いですが、もっと重かったのです)。結果として CodeRay に辿り着いたのですが検索でなかなか見つけることができずに苦労しました。 'ruby ソースコード 色付け' とかで検索しても簡単に出てこないんですよね……。当時The Ruby Toolbox を知っていればもっと簡単に見つけることができたと思います。

CodeRay はよくできていて使いやすいのですが、例によってアプリの価値を高めていこうとするとデフォルトの機能でばっちり上手くいくことはなかなかなないものです。特に Milkode においてはソースコードの見やすさはユーザーさんの印象を決める大切な部分のため、しつこくカスタマイズを加えています。

その 1: マッチ行全体をハイライトする

マッチ行をハイライトする機能自体は CodeRay 自体に用意されているのですが、デフォルトだと左の行番号が赤くなるだけで若干分かりにくいです。以下は 'define' で検索した場合です。

rubima-milkode-02.png

そこでコードとスタイルシートに手を加えて、マッチ行全体が色付けされるようにしています。検索した時はマッチ行周辺を中心にコードを読むことが多くなるので、地味ですが大切な修正です。

rubima-milkode-03.png

やり方としては、まず CodeRay は 'Encoder' というクラスを継承することで様々な形式 (HTML, Text, JSON など) で出力できるようになっているのですが、その HTML クラスを継承した HTML2 クラスを作ってアウトプットを Milkode 側で乗っ取ります。

/milkode/lib/milkode/cdweb/lib/coderay_html2.rb:14

module CodeRay
module Encoders
  class HTML2 < HTML
    register_for :html2   # html2という名前で登録
    .
    .
  end
end
end

さらに CodeRay::Encoders::HTML2::finish の中で受け取った出力をそのまま ornament_line_attr 関数に渡します。

/milkode/lib/milkode/cdweb/lib/coderay_html2.rb:23

# [ref] CodeRay::Encoders::HTML#finish (coderay-1.0.5/lib/coderay/encoders/html.rb:219)
def finish options
  @out = ornament_line_attr(options)
  .
  .

ornament_line_attr の中で行番号のカウントと各行ごとの処理を行います。line_attr 関数に続きます。

/milkode/lib/milkode/cdweb/lib/coderay_html2.rb:49

def ornament_line_attr(options)
  line_number = options[:line_number_start]
  lines = @out.split("\n")

  lines.map{|l|
    line_number += 1
    line_attr(l, line_number - 1, options)
  }.join("\n") + "\n"
end

ここまで来たらあと一息です。差し替えた CodeRay::Encoders::HTML2#line_attr の中で highlight-line クラスを指定します。

/milkode/lib/milkode/cdweb/lib/coderay_html2.rb:65

def line_attr(line, no, options)
  is_highlight = true if options[:highlight_lines].include?(no)

  r = []
  r << "id=\"n#{no}\""
  r << "class=\"highlight-line\"" if is_highlight    # ここでhighlight-lineを指定
  .
  .

highlight-line クラスは背景色を変更しているだけです。

/milkode/lib/milkode/cdweb/public/css/coderay-patch.css:2

.CodeRay .highlight-line { background-color: #f5f7f9; }

……長い道のりでしたが*2なんとかなりました。ここまでやってできたことといえば、検索行がハイライトされる "だけ" です*3。が、やっぱり使う上では大切だったりするのです。

ぱっと見の簡単さ、実装難易度、そして実際の役立ち度には相関性がないことが時折あります。なので作っている最中も「こんなに大変なことわざわざやらなくていいんじゃない?」という誘惑に何度も負けそうになりました。

困った時は Milkode を使っている側に気持ちを切り替えて (私は Milkode のヘビーユーザーでもあるのです)、率直な感想をぶちまけてみます。「いやいやマッチ行が見にくかったら不便で困るよ、っていうか使わなくなっちゃうよ」と思い直して作業を続けていきました。根気よくソースコードを読んで、上手くいかないと時は仕切り直して作戦を練り直していけばその内なんとかなることが多いです。

その 2: アンカーを貼る

"hogehoge.rb#n11" の "#n11" の部分のことです。

ソースコード検索では結果から目的行に直接ジャンプする必要があるのでこちらも地味ですが必須の機能です。幸い、背景色を設定したのと同じ個所で実装できました。

背景色の時は HTML タグにクラスを付加しましたが、アンカーの場合は id を付加します。

/milkode/lib/milkode/cdweb/lib/coderay_html2.rb:64

def line_attr(line, no, options)
  is_highlight = true if options[:highlight_lines].include?(no)

  r = []
  r << "id=\"n#{no}\""                               # ここでアンカーを指定
  r << "class=\"highlight-line\"" if is_highlight
  .
  .

I18n - 国際化

svenfuchs/i18n

$ gem install i18n

実は英語にも対応しています。

Ruby での国際化の知識がとぼしかったので実現には時間がかかるかな、と思っていたのですが、るびまの高橋編集長による Twitter からのプルリクエストのおかげで、一瞬で終わりました。ソーシャルコーディングってすごいです。

高機能な sinatra-r18n も試しましたが、そんなに複雑な翻訳箇所もなく、高橋さんから頂いたパッチが i18n で書かれていたので、そのまま i18n で進めました。

i18n の基本的な使い方としては、en.yml, ja.yml といったファイルを用意して……

/milkode/lib/milkode/cdweb/locales/ja.yml:1

ja:
  search: 検索
  name: 名前
  recently_viewed: 最近使った
  added: 追加
  updated: 更新
  favorite: お気に入り

/milkode/lib/milkode/cdweb/locales/en.yml:1

en:
  search: Search
  name: Name
  recently_viewed: Viewed
  added: New
  updated: Updated
  favorite: Favorites

"t()" 関数で展開します。シンプルですね。

/milkode/lib/milkode/cdweb/views/index.haml:28

          .form
            %form(method="post" action="#{url_for '/search'}")
              %input(name="query" type="text" style="width: 419px;")
              %input(type="submit" value="#{t(:search)}")                       # 日本語の時は"検索"、英語の時は"Search"に置き換わる
              %input(name='pathname' type='hidden' value='#{url_for '/home'}')

まとめ

Ruby を使ってアプリケーションを作る際にどのように gem を利用しながら、またどのような個所をカスタマイズしていったのかを紹介しました。

RubyGems はとても大きなエコシステムなのでたくさんの gem が登録されています。逆に言うとその中から有用なものを探しだすのは少しコツがいるいうことです。大切なことは「今」制作物にとって何が必要かをできるだけはっきりさせる、ということです。

Web アプリを作りたい、zip ファイルを生成したい、画像を生成したい、pdf を表示したい、を自分の言葉で書き出しましょう。さらに具体化させられるなら (例えば画像生成だったら生成したいのは .png なのか、 .jpg なのか、それとも gif アニメなのか? など) できる限り影響範囲を圧縮させられると、より検索精度が向上します。

目的を上手く言語化できたら探し始めます。その際は、汎用的な検索エンジンに頼りすぎないようにしましょう。The Ruby Toolbox を探す、Twitter で独り言をつぶやく、周りの人に聞いてみる、など他の情報源も合わせてチェックするのがおすすめです。

お好みの gem を見つけることができたら必要に応じてラッパーを書いたりカスタマイズを施していきます。他の人が作った gem は自分のやりたいことを解決するために作られたわけではないので、デフォルトの機能のままで目的を達成できることは、むしろまれです。しかし他の gem と組み合わせたり、自分で糊の役割りをするコードを書くことで大体のことは達成できます。

ここでも大切なことは「何のためにその gem を使っているのか?」という目的意識です。目的意識が明解になっていればどのアプローチが最短距離かを冷静に判断できるはずです。

カスタマイズの際は必要に応じて gem のマニュアルやコードを読む必要がありますが、0 から作り、デバッグすることを考えれば、トータルの時間としては大幅に短縮することができるでしょう。また、他人の書いたライブラリを使ったりソースを読むことは異文化に触れることでもあります。異文化を理解して使いこなせるようになった時、大きなスキルアップを実感できるでしょう。一番大切なのは新しいことに挑戦する自分を好きになって楽しんでやることです!

長くなりましたがここまで読んで頂きありがとうございました。感想等ありましたら Twitter などで教えて頂けたら嬉しいです。それでは、またどこかで。

筆者について

ongaeshi-icon.jpg

おんがえし (@ongaeshi)

プログラマ。職業プログラマの傍らでオープンソースのソフトウェアを作る。最近作ったのは Milkode, GrnMini, RubyKokuban など。こつこつと何かを作りながら生きていければよいと思っている。Twitter: @ongaeshi ホームページ: http://ongaeshi.me


*1 お手元で Milkode を使っている人は、 '/milkode/lib/milkode/database/document_table.rb:15' といったテキストを検索ボックスに貼り付けて検索すると目的個所に一発でジャンプできます (ダイレクトジャンプ機能)。

*2 実際 HTML2 クラスで上書きするのは 3 回目位の作戦だった気がします。

*3 マッチしたキーワードを黄色にしたり、選択行をさらに濃くする方法についてはまた次の機会に……。