株式会社クリアコード > ククログ

ククログ


Speee Cafe Meetup #02 :OSS開発者を増やしたい! #speee_lounge

2016年9月1日に「Speee Cafe Meetup #02」という「OSS開発」をテーマにした勉強会がSpeee Loungeで開催されました。

この勉強会で「OSS開発者を増やしたい!」という話をしてきました。

関連リンク:

この話では「独自にOSSを開発する」だけでなく「既存のOSSの開発」(たとえばバグレポートとか問題修正とか)も含んで「OSS開発」と言っています。

話の流れは次の通りです。

  • 「個人」がOSSを開発する動機は「自分が割に合うか」でよい
    • OSSを開発したことがない人は「OSSを開発する」こと自体の敷居が高いので「割に合う」と判断しにくい
    • OSS Gateという「OSS開発をする敷居を下げる」取り組みがあるので活用するとよい
  • 「個人」ではなく「組織」で「OSSを開発する」ことに取り組むにはどうすればよいか
    • まず、(多くの)組織にとって「OSSを開発する」ことは「目的」ではなく「手段」であることを認識する
    • 「目的」は技術力アップ・開発効率アップ・知名度アップなどである
    • 「OSSを開発する」ことで↑の目的を実現できることがある
    • そのため、組織でOSSを開発することに取り組む場合は「みんなでOSSを開発しよう!」と「OSS開発すること自体を目的」にするのではなく、「(↑で挙げたような)メリットを実現するためにOSSを開発するというやり方を使おう」という取り組み方の方が現実的である
      • そうしないと、OSSを開発したけど技術力アップ・開発効率アップ・知名度アップなどにつながらなかったら「なんでOSSを開発しているんだろう…やめよう…」となってしまう

なお、クリアコードは組織がOSS開発に取り組むことを支援するOSS開発支援サービスを提供しています。組織としてOSS開発に取り組みたいと検討している方はご相談ください。なにを期待してOSS開発に取り組みたいかを明確にするところからのサポートにも対応します。

2016-09-01

複数の作業環境から変更が同一ブランチにpushされるGitリポジトリについて、リモートにある内容を確実に手元に反映する

この記事では、Gitの「普通のpull」と「rebase」について、「普通のpullではなくrebaseした方が良いと言われるけれども、実際にrebaseを使ってみたら、衝突が発生した時に色々よくわからない事が起こって辛い思いをした」という人を対象として、rebaseの正しい使い方を解説します。 GitそのものやGitHubそのものの説明については省略していますのでご注意下さい。 また、GitはLinuxやmacOS(OS X)の端末エミュレータ上のシェルでコマンド列を実行して利用する形態を想定しています。

Gitでの「手元のコードをremoteの最新の状態と同期する」場面

  • GitHub上にあるリポジトリを複数人で手元にcloneして、各人が作業の成果をcommitしてpushしている
  • 自宅と会社のPCそれぞれにcloneして、各環境で変更をcommitしてpushしている

このような状況では、pushしようとしてできないという事が度々起こります。

  • 自分が作業をしている間に他の誰かが変更をpushしたせいでremoteのmasterの内容が変わってしまっているにも関わらず、そのまま自分の変更をpushしようとした
  • 自宅PCで作業した内容をpushし忘れたまま出勤し、会社のPCで改めて同じような変更をpushした後、帰宅後に自宅PCで作業を再開し、変更をpushしようとした

言うまでもなくこれはpullのし忘れによって起こることで、こういう時は一旦remoteの変更を手元にpullして、それから改めてpushするという操作が必要になります。

このときpullの仕方には、ざっくり言って*「普通のpull」「rebase」*の2通りがあります。

ブランチの運用スタイルによっては、「普通のpullではなくrebaseするほうが良い」とアドバイスされたり、あるいはルールやマナーとして「rebaseするように」と求められる場合があります。 筆者の印象では、Git FlowやGitHub Flowのようなトピックブランチを使う開発スタイルではなく、masterブランチに関係者それぞれが直接変更をpushするスタイルの時に、rebaseが推奨される印象があります。

普通のpullもrebaseも、どちらも衝突(conflict)がなければ問題ないのですが、衝突が発生してしまった場合、rebaseはその後でとる対応が普通のpullに比べると分かりにくくて面倒です。 このときの対応を誤ると、せっかくの作業の成果を失うことにもなりかねません。

作業の成果が衝突する状況の例

もう少し具体的に、衝突が発生する状況を示しましょう。 共有リポジトリにmasterというブランチがあり、変更は直接このブランチにpushしていく運用だと仮定します。 また、2人の作業者がいて、それぞれの手元で変更を行った結果以下の図のような状態になっているとします。 (図の左が過去、右が未来を指しています。========より上は共有リポジトリのブランチ、下は作業者の手元のリポジトリのブランチです。)

A-+ [remote master]
==|================================
  |
  +-B---C---D [作業者1 local master]
  |
  +-E---F---G [作業者2 local master]

ブランチの内容は、Aの段階では以下のような内容のファイル「sample.js」が1つだけあります。

"OK"

作業者1は、「OKをNGに書き換える」という意図の元で、Aから以下のように作業を行いました。

  1. 「NG」に書き換える。(コミットB)

    "NG"
    
  2. 「NG」を増やす。(コミットC)

    "NG-NG"
    
  3. 「NG」をさらに増やす。(コミットD)

    "NG-NG-NG"
    

一方、作業者2は「ダブルクオートからシングルクオートにする」という意図の元で、Aから以下のように作業を行いました。

  1. ダブルクオートからシングルクオートに書き換える。(コミットE)

    'OK'
    
  2. 「OK」を増やす。(コミットF)

    'OK-OK'
    
  3. 「OK」をさらに増やす。(コミットG)

    'OK-OK-OK'
    

はい、いかにも衝突しそうですね。

pullで変更が衝突した状況からの回復

rebaseの何が難しいのかを分かりやすく示すために、まず普通のpullの場合を説明します。

ここで作業者1が作業の成果をpushすると、ブランチの状態は以下のようになります。

A-+-B---C---D("NG-NG-NG") [remote master]
==|=============================================
  |
  +-E---F---G('OK-OK-OK') [作業者2 local master]

作業者2はこの時、そのままgit pushはできません(しようとしてもエラーになります)。

そこで一旦git pullします。 すると、

A-+-B---C---D("NG-NG-NG")-+ [remote master]
==|=======================|=======================
  |                       |
  +-E---F---G('OK-OK-OK')-+-[作業者2 local master]
                            <<<<<<< HEAD
                            'OK-OK-OK'
                            =======
                            "NG-NG-NG"
                            >>>>>>> xxxxxxxxxxxx

このように、DとGの内容が矛盾しているので矛盾箇所を修正するように求められます。 これが「衝突(conflict)」という状態です。

衝突をどう解消するかは場合によりますが、ここでは2人の作業者の意図を尊重して「OKはNGにする」「ダブルクオートはシングルクオートにする」というそれぞれの折衷案を採る事にしました。 ファイルの内容を'NG-NG-NG'に書き換えてgit commit -aし、コミットします。

A-+-B---C---D("NG-NG-NG")-+ [remote master]
==|=======================|=====================================
  |                       |
  +-E---F---G('OK-OK-OK')-+-H('NG-NG-NG') [作業者2 local master]

こうして、DとGの矛盾を解消するコミットHができました。 このように、remote masterとlocal masterのような複数のブランチを統合(マージ)するために必要な、矛盾を解消するコミットを「マージコミット」と言います。

衝突が解消されてマージコミットHができたら、作業者2はようやくgit pushで変更点を共有リポジトリのブランチに反映させられる状態になります。 以下はgit pushして共有リポジトリに作業者2の成果が反映された状態です。

A-+-B---C---D-+--H[remote master ('NG-NG-NG')]
  |           |
  +-E---F---G-+
==============================================
(作業者1、2の手元に固有の変更は無い)

不要なマージコミットの問題とrebase

先程、「複数のブランチを統合するために必要な、矛盾を解消するコミットをマージコミットと言う」と述べました。 しかし場合によっては各ブランチは矛盾無く統合できることもあります。 それぞれの作業者で違うファイルを編集していたり、単にファイルを追加しただけだったりする場合、矛盾を解消するための手作業での修正は必要なく、Gitは単に「2つのブランチを統合した」という事を示すだけのマージコミットを自動的に作成してくれます。

これは言い換えると、自動的に統合できた場面でのマージコミットには、「2つのブランチを無事に統合できた」という事以上のさしたる情報は含まれないという事です。 この事から、プロジェクト関係者の判断によって、「こういった無益なマージコミットが履歴の上に何度も現れるのは目障りなので、マージコミットが作成されないrebaseを使うように」という通達がなされる場合があります。

rebaseは、作業の起点になったコミットを別のコミット、特にリモートブランチの最新のコミットに切り替えるという操作です。 先の説明の途中の「作業者1と作業者2の作業の成果がそれぞれあって、作業者2の成果をpushできない状態」に戻ってrebaseの様子を示しましょう。

A-+-B-C-D [remote master]
==|=============================
  |
  +-E-F-G [作業者2 local master]

ここで*git pull --rebase*とすると、作業者2の作業の起点がリモートブランチのAからDに切り替わります。 つまり、「Aから派生して作業していたのではなく、Dから派生して作業していた事になる」という小規模な歴史改変が行われます。

A-B-C-D-+ [remote master]
========|=============================
        |
        +-E-F-G [作業者2 local master]

この状態からであれば、作業者2は問題なく成果をpushできます。

A-B-C-D-E-F-G [remote master]
====================================
(作業者1、2の手元に固有の変更は無い)

今度は先程の通常のpullの場合と異なり、マージコミットが作成されていません。 大した意味のないコミットがコミット履歴の中に現れる事がないため、履歴を見るのが楽になっています。

この例では減ったマージコミットは1つだけなので、あまり差が分からないかもしれません。 しかし、1日の間にマージコミットが10個も20個も発生するような状況だと、履歴はマージコミットだらけになってしまい、本当に重要なコミットが埋もれて分からなくなってしまいます。 そのような状況下では、「普通のpullではなくrebaseするようにする」という方針が大きな意味を持ってきます。

rebaseで変更が衝突した状況からの回復の、間違ったケース

本題はここからです。

上記の図ではつつがなくrebaseできた事にしていますが、実際には、リモートブランチの先頭であるDの状態と、作業者の手元のE、F、Gの状態は矛盾しています。 そのため、「Aから派生して作業していたのではなく、Dから派生して作業していた事になる」という歴史改変は行えません。 これも「衝突(conflict)」で、通常のpullでマージコミットを自分で作成するのと同様に、rebaseの場合も衝突を解消する必要があります。

ただ、rebaseでの衝突の解消は、通常のpullでの衝突の解消とは勝手が違います。 通常のpullではたった1つのマージコミットHで衝突を解消する事になるので、解消の手間は1回で済みますが、rebaseでは場合によっては何度も解消を繰り返さなくてはなりません。 通常のpullしか知らない人にとっては、ここがrebaseの分かりにくい点です。

作業者1が変更をpushした直後の状態から順番に見ていきましょう。 筆者は当初、以下のような誤解をしていました。

A-+-B-C-D("NG-NG-NG") [remote master]
==|===================================
  |
  +-E-F-G('OK-OK-OK') [作業者2 local master]

ここでgit pull --rebaseを実行すると、以下のように衝突した事を示すメッセージが表示されます。

$ git pull --rebase
...
From XXXXXXX
 * branch            HEAD       -> FETCH_HEAD
First, rewinding head to replay your work on top of it...
Applying: E
Using index info to reconstruct a base tree...
M	sample.js
Falling back to patching base and 3-way merge...
Auto-merging sample.js
CONFLICT (content): Merge conflict in sample.js
error: Failed to merge in the changes.
Patch failed at 0001 E
The copy of the patch that failed is found in: .git/rebase-apply/patch

When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".

通常のpullの時と同じ要領で衝突を手作業で修正しようとするのですが、どうも先程のマージコミットの作成時とは様子が違います。 手元では'OK-OK-OK'が最新の状態だったはずなのに、ずいぶん前の状態との差分が出ています。

<<<<<<< xxxxxxxxxxxx
"NG-NG-NG"
=======
'OK'
>>>>>>> E

まあいいでしょう、先程のマージの時のように2人の作業者の意図を汲んで「OKはNGにする」「ダブルクオートはシングルクオートにする」という変更を統合することにします。

'NG-NG-NG'

そして、先程のようにマージコミットにするべくgit commit -aします。 rebaseでは作業をリモートのブランチの最新の状態から行ったように歴史改変してくれるということなので、こういう状態になっているはずです。

A-B-C-D("NG-NG-NG")-+ [remote master]
====================|===================================
                    |
                    +-E?-F?-G?('OK-OK-OK') [作業者2 local master?]

ところが、衝突を解消したと思ってgit pushしてみても何も送信される気配がありません。 もしかしてリモート側にまた作業者1がpushしたのか?と思ってgit pull --rebaseしたら、こんな事を言われます。

$ git pull --rebase
You are not currently on a branch.
Please specify which branch you want to rebase against.
See git-pull(1) for details.

    git pull <remote> <branch>

どういうことか?と思ってgit branchしてみると、

$ git branch
* (no branch, rebasing master)
  master

えっ、masterブランチにいたはずなのにどうして別のブランチになってるの!? という事で慌ててgit checkout masterでmasterに切り替え直してリポジトリの内容を見ると、git pull --rebaseする前の状態に戻っています。

A-+-B-C-D("NG-NG-NG") [remote master]
==|===================================
  |
  +-E-F-G('OK-OK-OK') [作業者2 local master]

とりあえず元の状態に戻せたので一安心ですが、しかし衝突を直すために行った作業は電子の藻屑と消えてしまいました。 一体どうして……?

rebaseで変更が衝突した状況からの回復の、正しいケース

この時何が起こっていたのか、どうして衝突を解決したはずなのにその情報が失われてしまったのか。 これはrebaseの時に実際には何が起こっているのかを順を追って見ていかないと理解できません。

もう一度、作業者1が変更をpushした直後の状態から見ていきましょう。

A("OK")-+-B-C-D("NG-NG-NG") [remote master]
========|===================================
        |
        +-E('OK')-F-G [作業者2 local master]

ここでgit pull --rebaseを実行すると、何が起こるのか。 もう一度、git pull --rebaseした時のメッセージを見てみましょう。

$ git pull --rebase
...
From XXXXXXX
 * branch            HEAD       -> FETCH_HEAD
First, rewinding head to replay your work on top of it...
Applying: E
Using index info to reconstruct a base tree...
M	sample.js
Falling back to patching base and 3-way merge...
Auto-merging sample.js
CONFLICT (content): Merge conflict in sample.js
error: Failed to merge in the changes.
Patch failed at 0001 E
...

First, rewinding head to replay your work on top of it...というメッセージは、「リモートブランチの最新の状態に移動して、そこにあなたの作業の成果を反映します」という事を言っています。 そして続けてApplying: E(「Eを反映中」)と出た後、自動でのマージに失敗した事と、衝突したファイルの情報が出力されています。

つまり、この時Gitは「手元のmasterブランチの内容とリモートのmasterブランチの内容を完全に統合」しようとはしておらず、あくまで*「リモートのmasterから改めて作業を行った場合のコミットとしてEを反映」しようとしている*という状態なのです。

この状態を図に示しましょう。

A("OK")-+-B-C-D("NG-NG-NG")-+ [remote master]
========|===================|==================================
        |                   |
        |                   +-+-[作業者2 local temporary]
        |                     | <<<<<<< xxxxxxxxxxxx
        |                     | "NG-NG-NG"
        |                     | =======
        |                     | 'OK'
        |                     | >>>>>>> E
        |                     |
        +-E('OK')-------------+-F---G [作業者2 local master]

AではなくDから作業を開始したかのように歴史を改変しようとして、DとEが衝突します。 そこで、Gitは「Aから作業を行った時のEではなく、Dから作業を行った時に妥当な内容となるE」の作成を作業者2に求めているというわけです。

また、この時手元のmasterではなく、衝突解消作業用の新しい一時的なブランチ(のようなもの)にフォーカスが移っている事にも注意が必要です。 実際に、この段階でgit branchを実行すると、今いるブランチは(no branch, rebasing master)と表示され、masterではないことが分かります。

  • 全体のマージではなく、自分がcloneの後に行ったコミットの中で、最初にリモートのmasterと衝突してしまったEのやり直しが求められている。
  • この時参照しているのは、リモートのmasterブランチでもローカルのmasterブランチでもなく、rebaseの衝突修正用に一時的に生まれたブランチ(のようなもの)である。

rebaseではこの2点を忘れないようにくれぐれも注意して下さい。

さて、作業者2は手作業で衝突箇所を修正しますが、ここでは作業者1の成果に作業者2のEでの作業「ダブルクオートはシングルクオートにする」を反映することにしましょう。

しかし、衝突を解消してもgit commit -aなどとして普通にコミットしてはいけませんgit commitは、実はrebaseの衝突の解消の作業の中で実行される事が想定されていない操作です。 この時点で実行してしまうと、リポジトリの状態がGitの想定する状態からずれてしまい、以後の衝突の解消の操作がすべて破綻してしまいます。

では、一体どうすればよいのか。 答えはgit pull --rebaseで衝突が発生した時のメッセージの最後に書かれています。

...
When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".

訳すとこうなります。

  • 問題を解消したら、git rebase --continueを実行する。
  • このコミットを反映せず飛ばしたい時は、git rebase --skipを実行する。
  • rebaseを取りやめて元の状態に戻したい時は、git rebase --abortを実行する。

つまり、先程衝突を解消したので、ここではgit rebase --continueを実行すればよいということになります。

ただし、ここでgit rebase --continueを実行してもまたエラーメッセージが表示されてしまいます。

$ git rebase --continue
sample.js: needs merge
You must edit all merge conflicts and then
mark them as resolved using git add

メッセージ内に説明があるのですが、衝突を解消した後は「この衝突は解消済み」という事をGitに伝えなくてはなりません。 それには、git add (ファイルのパス)で変更をステージングに移すという操作を行います。 ステージングに移された変更は解消済みの衝突と見なされるため、これでやっとgit rebase --continueできるようになります。

git add sample.jsで変更をステージングに移してgit rebase --continueを実行すると、Gitは自動的に、「Dから作業した場合のE」すなわち「E'」として現在ステージング済みの状態をコミットします。

A-+-B-C-D-+ [remote master ("NG-NG-NG")]
==|=======|=========================================
  |       |
  |       +-E'('NG-NG-NG') [作業者2 local temporary]
  |
  +-E-F('OK-OK')-G [作業者2 local master]

これで手元のブランチのコミットEはリベースが完了しました。

するとGitは続けて、E'の後にFを行ったかのように歴史を改変しようとします。 しかしE'とFも矛盾するため、また衝突が起こります。

A-+-B-C-D("NG-NG-NG")-+ [remote master]
==|===================|===========================================
  |                   |
  |                   +-E'('NG-NG-NG')-+-[作業者2 local temporary]
  |                                    | <<<<<<< xxxxxxxxxxxx
  |                                    | 'NG-NG-NG'
  |                                    | =======
  |                                    | 'OK-OK'
  |                                    | >>>>>>> F
  |                                    |
  +-E-F('OK-OK')-----------------------+-G [作業者2 local master]

今度の矛盾はどう解消しましょう。ここでは2通りのやり方があります。

  • 先程のE'と同じように、Fの意図を汲んでE'に反映する新しいコミット「F'」を作る。
  • 最終的に持っていきたい状態を考えるとFの作業内容は必要ないため、Fは破棄する。

F'を作る場合は、先程同様にファイルを編集してgit addしてgit rebase --continueします。

Fを破棄する場合、衝突を解消するための手作業での修正は行わずに、git rebase --skipを実行します。 ここではFを破棄したとしましょう。

A-+-B-C-D("NG-NG-NG")-+ [remote master]
==|===================|=========================================
  |                   |
  |                   +-E'('NG-NG-NG') [作業者2 local temporary]
  |
  +-E-F-G[作業者2 local master ('OK-OK-OK')]

すると、「F'を作成するための変更」がキャンセルされます。 そして、今度はGitはE'の後にFを行ったのではなく、E'の後にGを行ったかのように歴史を改変しようとします

A-+-B-C-D("NG-NG-NG")-+ [remote master]
==|===================|===========================================
  |                   |
  |                   +-E'('NG-NG-NG')-+-[作業者2 local temporary]
  |                                    | <<<<<<< xxxxxxxxxxxx
  |                                    | 'NG-NG-NG'
  |                                    | =======
  |                                    | 'OK-OK-OK'
  |                                    | >>>>>>> G
  |                                    | 
  +-E-F-G('OK-OK-OK')------------------+ [作業者2 local master]

はい、また衝突しました。ここでも先程と同様に2通りの選択肢があります。

  • 先程のE'と同じように、Gの意図を汲んでE'に反映する新しいコミット「G'」を作る。
  • 最終的に持っていきたい状態を考えるとGの作業内容は必要ないため、Gは破棄する。

どちらにしてもいいのですが、今度もまたGを破棄してgit rebase --skipすることにしましょう。 すると、「G'を作成するための変更」がキャンセルされます。

A-+-B-C-D("NG-NG-NG")-+ [remote master]
==|===================|=========================================
  |                   |
  |                   +-E'('NG-NG-NG') [作業者2 local temporary]
  |
  +-E-F-G('OK-OK-OK') [作業者2 local master]

そして、すべての手元の変更をrebaseし終えたということで、Gitは先程までrebase作業用の一時的なブランチ(のようなもの)だった物を新しいlocalのmasterブランチに昇格させ、今までmasterだったブランチは破棄します(リポジトリ内にコミットごとのデータはまだ残っていますが、どこからも参照されなくなるので見えなくなります)。

A-B-C-D("NG-NG-NG")-+ [remote master]
====================|=========================================
                    |
                    +-E'('NG-NG-NG') [作業者2 local master]

この状態からであれば、作業者2は変更をpushできます。 ということでgit pushしてしまいましょう。

A-B-C-D-E'('NG-NG-NG') [remote master]
======================================
(作業者1、2の手元に固有の変更は無い)

以上が、rebaseで実際に起こっている事の詳細と、rebaseでの衝突の正しい解消手順です。

「今自分がどのブランチで作業しているのか」を一目で分かるようにする

rebaseが分かりにくい理由の1つには、自分が作業していたはずのブランチから何の警告もなく勝手に別の一時的なブランチ(のようなもの)に移ってしまい、自分で明示的にrebase終了の操作をするまではそのブランチにいるままになってしまうから、という理由があるように筆者には思えます。

この対策としてお薦めなのが、シェルのプロンプト上に現在のブランチ名を自動的に表示するように設定しておくという方法です。 過去に紹介したおすすめzsh設定にもそのような設定が含まれていますが、ここではmacOS(OS X)や多くのLinuxディストリビューションで端末エミュレータを起動した時の既定のシェルになっているBash用の設定をご紹介します。

BashでGitリポジトリの状態をプロンプトに表示するツールとしては、bash-git-promptが有名なようです。 以下のようにするとインストールできます。

cd ~/
git clone https://github.com/magicmonty/bash-git-prompt.git .bash-git-prompt --depth=1
echo 'GIT_PROMPT_ONLY_IN_REPO=1' >> ~/.bashrc
echo 'source ~/.bash-git-prompt/gitprompt.sh' >> ~/.bashrc

後半2つのコマンド列は、Bashを起動した時点で最初からこのツールが読み込まれた状態にしておくための設定です。

以上の設定を施した状態で新しい端末を起動し、任意のGitリポジトリの中にcdすると、プロンプトがGitリポジトリの状態を示す内容に自動的に切り替わります。 プロンプトの表示が大きく変わりますので、慣れない場合はテーマを切り替えたりカスタマイズしたりしてみるとよいでしょう。 筆者の場合はUbuntuの通常のプロンプトからの変化を最小限に抑えたかったため、

override_git_prompt_colors() {
  GIT_PROMPT_THEME_NAME="Single_line_Ubuntu_default"

  GIT_PROMPT_START_USER="\u@\h:${PathShort}"
  GIT_PROMPT_END_USER="${ResetColor}$ "
  GIT_PROMPT_END_ROOT="${BoldRed}# "
}

reload_git_prompt_colors "Single_line_Ubuntu_default"

以上の内容のテーマ定義ファイルを~/.bash-git-prompt/themes/Single_line_Ubuntu_default.bgpthemeの位置に置き、~/.bashrcの末尾にGIT_PROMPT_THEME=Single_line_Ubuntu_defaultという行を追加して、この自作テーマを使うように設定しています。

まとめ

通常のpullしか知らない状態でgit pull --rebaseしようとして、衝突が発生した時にお手上げになってしまう状況について、筆者の事例を元に誤解のポイントとrebase作業の正しい流れを解説しました。 また、rebaseで失敗の元になりやすいと思われる「今いるブランチがぱっと見で分からない」問題について、Bashのプロンプトにブランチ名を表示させるための方法もご紹介しました。

rebaseは、衝突が発生しない状況では変更履歴を簡単に綺麗に保つ事ができてとても便利です。 しかし一度衝突が発生すると、途端に衝突と解消の連続の地獄に陥ります。 現在自分がどの状態にあってどのコミットまで衝突を解消したのか、この衝突の解消ではどこまでやって良くてどこから先はやらない方がよいのか、常に正確に把握しながら作業しないと、どこを目指して衝突を解消すればよいのかをすぐに見失ってしまいます。

この問題について、通常のpullでの単一のマージコミットのような、シンプルで分かりやすい解決策は残念ながらありません。 いわゆる「運用でカバー」な方法としては以下のような対策が考えられます。

  • 複数人で並行して作業をしていて衝突が頻発するような状況では、rebaseを使わない。 (マージコミットの発生については、避けられない事として諦める。)
  • 例えば、Git FlowやGitHub Flowのように、masterには直接コミットせず、必ずトピックブランチを作成してそれをmasterにマージする形でのみ変更を反映する、という運用を徹底する。このような状況であればrebaseはそもそも行わなくて済む。
  • 自分が作業を開始する前に必ずgit pull --rebaseを実行し、また、1つ何かコミットする度にもgit pull --rebaseして、rebaseでの衝突の解消を「リモートブランチの最新の状態と、手元の先程の作業1つとの衝突の解消」に留めるようにする。 (手元のブランチの「その次の作業」との整合性まで考慮した衝突の解消は困難極まりないので、しないで済むようにする。)

以上のような対策を取りつつ、皆さんも是非rebaseを正しく活用していって下さい。

タグ: Git
2016-09-02

書き捨てのRubyスクリプトをgemにするときの育て方の一例

Rubyでちょっとしたこと、例えばテキスト処理などをしたくてスクリプトを書くことはよくあります。そんなスクリプトは意外と再利用したくなるものです。しかし、作業用ディレクトリに適当なファイル名で保存していたりすると探し出すのは困難ですし、コマンドラインにワンライナーで書いたものだとそもそも残っていないこともあります*1。別のPCでの再利用も、それらの保存方法では当然ながら不可能でしょう。

そこで、どこか整理された場所に保存しておいて再利用に備えたいのですが、保存先が問題になります*2。そんなとき、私は次のような理由からgem(RubyGems)にしてしまうことにしています。手元ですぐに実行できますし、別の環境へのインストールも簡単です。また、他のRubyプログラムへの組み込みもコピペせずに実現できます。もちろん他の人が使えるようになるというメリットもあるのですが、自分の用途に限っても長い目で見れば作業時間の元は取れると思います。*3

ここから、実際にスクリプトをgemにする手順を見ていきます。おおまかな手順は以下です。

  1. gemの雛形を作る
  2. スクリプトをexe/かbin/にコピーする
  3. lib/に切り出す

gemの雛形を作る

Bundlerのbundleコマンドでgemの雛形を作ることができます。実行ファイルを含めるgemの場合、--exeオプションを指定すると、実行ファイルを配置するディレクトリを生成してくれます。

% bundle gem GEM_NAME --exe

オプションの詳細は以下のコマンドで見られます。

% bundle help gem

雛形にはいくつかTODOコメントが埋め込まれています。これらが残っているとgemを作成する際にエラーになってしまうので、埋められるところはこの段階で埋めておきましょう。

スクリプトをexe/かbin/にコピーする

スクリプトをexeディレクトリ以下に生成されたファイルにコピーします。例えば、GEM_NAMEをpiyoとした場合、exe/piyoというファイルが生成されているはずです*4。このファイルの名前がコマンド名になります。コマンド名は基本的にgemと同じ名前にします。もし複数のコマンドを含めたい場合、gem名-XXXのようにgem名をプレフィックスとして付けるようにすることが多いです*5

例えば、画面にランダムで1〜10回piyoと表示させるスクリプトの場合は以下のようになります。

% cat exe/piyo
#!/usr/bin/env ruby

require "piyo"

puts "piyo" * (rand(10) + 1)

それでは実行してみましょう。手元で実行するときは、bundle installしてからbundle execを付けて呼び出します*6

% bundle install
...
% bundle exec piyo
piyopiyopiyo

これで、スクリプトをgemにすることができました。

もう少しgemっぽくするのであれば、以下のようにRubyの標準添付ライブラリであるOptionParserを導入することで、バージョン番号を表示するオプションを追加することもできます。

#!/usr/bin/env ruby

require "piyo"

require "optparse"

option_parser = OptionParser.new
option_parser.on("-v", "--version",
                 "Show version number") do
  puts Piyo::VERSION
  exit
end
option_parser.parse!(ARGV)

puts "piyo" * (rand(10) + 1)

lib/に切り出す

せっかくgemにしたので、もう少しgemらしくしてみます。主な処理をlibディレクトリに移すことで、コマンドとしてだけでなく、ライブラリとしても使うことができるようになります。

piyoと表示する処理をChickクラスに切り出したコミットは以下です。

diff --git a/exe/piyo b/exe/piyo
index 8259679..3f483a1 100755
--- a/exe/piyo
+++ b/exe/piyo
@@ -12,4 +12,5 @@ option_parser.on("-v", "--version",
 end
 option_parser.parse!(ARGV)
 
-puts "piyo" * (rand(10) + 1)
+chick = Piyo::Chick.new
+chick.chirp
diff --git a/lib/piyo.rb b/lib/piyo.rb
index 835a236..efe8efa 100644
--- a/lib/piyo.rb
+++ b/lib/piyo.rb
@@ -1,3 +1,4 @@
+require "piyo/chick"
 require "piyo/version"
 
 module Piyo
diff --git a/lib/piyo/chick.rb b/lib/piyo/chick.rb
new file mode 100644
index 0000000..33e9084
--- /dev/null
+++ b/lib/piyo/chick.rb
@@ -0,0 +1,7 @@
+module Piyo
+  class Chick
+    def chirp
+      puts "piyo" * (rand(10) + 1)
+    end
+  end
+end

GitHubでも同じ内容を公開しています。こちらからご覧いただけます。

libディレクトリに切り出すことで、ユニットテストがしやすくなるというメリットもあります。

おわりに

ちょっとしたスクリプトをgemにする手順の一例を紹介しました。gemにすることで、埋もれていた便利スクリプトを再利用しやすくなります。今後も使いそうなスクリプトが書けたときにはぜひ試してみてください。*7

*1 私の環境だとコマンドラインにワンライナーで書いたものでもヒストリーに残っている(参考:おすすめzsh設定 - ククログ(2011-09-05))ため残ってはいるのですが、名前が付いていないスクリプトの山から探し出すのは大変です。

*2 部分的なプログラムということで、まずGistが思い浮かびます。ちょっと参考にしたいという程度ならこれで十分なのですが、手元で実行したり、別のプログラムに組み込んだりしたいときには、一旦手元にまるっとコピーする必要があり、不便です。

*3 gemを作る過程で、似たようなgemがないか自然と探すことになり、便利gemを見つけることができるというのもメリットの一つと言えるかもしれません。ちょっとしたスクリプトだと、ついつい調べずに自分で書いてしまうことが多いのですが、ちょっと調べればもっと便利なgemが見つかることも多いです。

*4 以前はbinディレクトリがこの用途で使われていたのですが、現在はbinディレクトリには開発用コマンドのファイルを置き、公開するコマンドはexeディレクトリに置くのが標準となっているようです。公開するコマンドの場所としてbinディレクトリを使いたい場合は、gemspecファイルの spec.bindir を bin に変更します。その場合は自動生成された開発用コマンドは削除するか別のディレクトリに移動するかしましょう。

*5 他のコマンドとの名前の衝突を避けるためです。

*6 他のディレクトリで実行してみたい場合は rake install でインストールできます。

*7 このやり方を別の観点で見ると、常に動く状態を保つことができるというメリットもあります。最終的にライブラリにするのであっても、動かない状態で書き進めるのは大変なので、最初は実際に動くスクリプトから気軽に書き始めるというのが私のやり方です。

タグ: Ruby
2016-09-08

RubyKaigi 2016にスポンサーとして参加 #rubykaigi #oss_gate

予告通り、2016年9月8日から10日まで3日間開催されたRubyKaigi 2016にスポンサーとして参加しました。今年はブースを出せるということだったのでブースを出しました。(スピーカーとしての参加についてはHow to create bindings 2016を参照してください。)

クリアコードブース

クリアコードは例年RubyKaigiをスポンサーとして応援しています。応援することが目的なので特に会社としての明確なメリットを設定しておらず、次のような「こうなるといいな」が実現されたらラッキーくらいに考えています。

そのため、それほど前のめりなブース利用はせずに、次の方々にクリアコードブースの一部を提供しながら利用しました。

  • SMSさん
    • 背景1:スポンサーになるのがRubyKaigi 2016開催直前でブースを使えなかった
    • 背景2:OSS開発支援サービスbm-sms organization以下にあるdaimon-XXXの開発に参加しているつながり
    • 補足:寿司とトンカツのチロルチョコを配りながら採用活動をしていたのがSMSさんです。
  • やまねさん
    • 背景1:High Tech Seat in mrubyの発表内で実施した「ウォシュレットをmrubyで制御する」デモを展示する場所を探していた
    • 背景2:たいやき部つながり
    • 補足:たくさんの方がデモを体験して喜んでいたので場所を提供できてよかったです。

ブースの残りのクリアコード分ではOSS Gateの紹介をしていました。今のOSS Gateのワークショップは個人が対象(土曜日開催なので企業が社員に土曜日に行ってこいとはなかなか言えない)なので、企業でOSS開発に取り組みたいという方にはOSS開発支援サービスを紹介しました。何人かMozilla Firefoxのサポートに興味のある方がいたのでその方たちにはサポートサービスの説明をしました。もう少しGroongaの紹介もした方がよかったです。

発表後の質問の受け付け場所としてもブースを利用しました。これはとてもよくて、発表で触れていないことや実際に使ってみるにはどうしたらよいかなど、絵を描きながら話したほうがよさそうな話や時間がかかる話をすることができました。発表時の質疑応答時でもいくつかやりとりできたのですが、やはり、時間が限られていることもあり、別の場で補完できることは有用でした。今後、またブースがあるときは、発表後の質問の受付場所としても活用する予定です。

日本Ruby会議2009でブースを出したことがありますが、RubyKaigi 2016はそのときよりもよく設計されたブーススペースになっていました。飲食物がある休憩スペースと一体になっているために人の流れがあり、休憩時間を過ぎて話し込んでいてもセッションの邪魔にならないような部屋割りになっていました。また、ときおりスタッフが「なにか困っていることはないか」とフィードバックを受け付けに来てくれていました。途中まで、ゴミ箱がなくて少し不便だったのですが、解消してくれました。

RubyKaigi 2016はよいRubyKaigiでした。応援できてよかったです。実現されたらラッキーくらいの扱いのスポンサーのメリットですが、現時点ではOSS Gateに参加する人が増えるという件は効果がでています。20-30人くらい増えたので予想以上です。すごい。他の件についてはまだ問い合わせはありません。

タグ: Ruby | 会社
2016-09-13

RubyKaigi 2016:How to create bindings 2016 #rubykaigi

2016年9月8日から10日にかけて開催されたRubyKaigi 2016で「バインディング開発者を増やしたい!」という話をしました。

関連リンク:

話の流れは次の通りです。

  • バインディングについて説明
  • バインディングを作る方法として以下のを4つを紹介
    • 拡張ライブラリー
    • SWIG
    • Ruby FFI
    • GObject Introspection
  • GObject Introspectionをオススメ
  • バインディング開発者になろう!

「バインディング」とは「Cで実装された機能をRubyから使うためのライブラリー」です。話している中で参加者に「バインディングを知っているか」聞いたところ、参加者の半分以上は「バインディング」について知りませんでした。「バインディング開発者」を増やすには「バインディング」に関する情報提供から頑張る必要がありそうです。

当日話し忘れたことがあります。たしかにGObject Introspectionがオススメなのですが、場合によっては他の方法が適切なケースもあるので、ケースバイケースで適切な方法を使えばよいです。「GObject Introspectionを使うバインディング開発者」を増やしたいのではなく、「バインディング開発者(やり方は問わない)」を増やしたいのです。

バインディングの作り方

発表中でのバインディングを作る方法はざっくりとした説明でした。雰囲気はわかるけどこの情報だけでは実際に作れるわけではないというものでした。これは、詳細まで説明するとそれぞれの方法の比較から意識が離れてしまうから、という判断でした。資料を用意していましたがあえて省略しました。しかし、このままだとバインディング開発者が増えにくいので、実際に作れるようになる情報をまとめます。

拡張ライブラリー

拡張ライブラリーはCで実装されたRubyライブラリーのことです。バインディングを作るために使われることが多いです。おそらく、バインディングを知らない人は拡張ライブラリーのことも知らないでしょう。バインディングと合わせて拡張ライブラリーに関する情報提供も頑張るとよさそうです。

拡張ライブラリーでバインディングを作る方法を説明する前に拡張ライブラリーを作る方法を説明します。次のRubyで書かれたライブラリーを拡張ライブラリーとして実装するとします。

class Hello
  def to_s
    "Hello"
  end
end

この拡張ライブラリーを作るときに用意するものは次の2つで、どちらもこの発表のスライドを管理しているリポジトリーに入っています。

hello.cは実装で次のようになります。コメントで解説を書いています。

/* Rubyが提供する拡張ライブラリーを作るためのAPIを使うため。 */
#include <ruby.h>

/* Hello#to_sの実体。 */
/* VALUEはRubyのオブジェクトのCでの表現。
   すべてのメソッドはRubyのオブジェクトを返すので、Cでの実装ではVALUEを返す。 */
static VALUE
hello_to_s(VALUE self)
{
  /* rb_str_new_cstr()はCの文字列からRubyのStringオブジェクトを作るAPI。 */
  return rb_str_new_cstr("Hello");
}

/* 初期化関数。 */
/* 「hello.so」を「require」すると、Rubyは「Init_hello」を呼ぶ。 */
void
Init_hello(void)
{
  VALUE hello;

  /* 「class Hello」に対応。 */
  hello = rb_define_class("Hello", rb_cObject);
  /* 「def to_s」に対応。最後の「0」は引数が「0」という意味。 */
  rb_define_method(hello, "to_s", hello_to_s, 0);
}

拡張ライブラリーを作ったことがない人でもCがわかればなんとなく読めますよね。

これをビルドするためのMakefileを作るのがextconf.rbです。

# Makefileを作るための便利ライブラリーを使う。
require "mkmf"
# 「hello.so」を作るMakefileを生成。
create_makefile("hello")

簡単ですね。

このhello.cextconf.rbを用意すると次のようにビルドできます。

% ruby extconf.rb
% make

これを使うには次のようにします。

% irb -I . --simple-prompt
>> require "hello" # 「hello.so」を読み込む。このときにInit_hello()が呼ばれる。
=> true
>> hello = Hello.new
=> #<Hello:0x00000001cf2438>
>> hello.to_s # Cで実装したhello_to_s()が呼ばれる。
=> "Hello"

Rubyで書いた実装と同じように使えますね。

それでは拡張ライブラリーとしてバインディングを実装する方法を説明します。

次のhello.hのようなAPIのCライブラリーのバインディングを作成することにします。

#pragma once

typedef struct hello_t Hello;

/* コンストラクター */
Hello      *hello_new    (void);
/* デストラクター */
void        hello_free   (Hello *hello);
/* メソッド */
const char *hello_message(Hello *hello);

このライブラリーのバインディングを拡張ライブラリーとして作るときに用意するものは次の2つで、どちらもリポジトリーに入っています。

このバインディングはRubyで書くと次のようなクラスを実現します。

class Hello
  def initialize
    @message = "Hello"
  end

  def message
    @message
  end
end

hello.cは実装で次のようになります。コメントで解説を書いています。前述の「バインディングではない拡張ライブラリー」との大きな違いはライブラリー対象のHello構造体をラップしているかどうかです。増えているコードの多くはこのためのコードです。

/* Rubyが提供する拡張ライブラリーを作るためのAPIを使うため。 */
#include <ruby.h>

/* バインディング対象のライブラリーのAPIを使うため。 */
#include <hello.h>

/* ラップしている`Hello`構造体を開放する関数。 */
static void
rb_hello_free(void *data)
{
  Hello *hello = data;
  /* ライブラリーが提供しているデストラクターを呼ぶ。 */
  hello_free(hello);
}

/* Rubyにどのような構造体をラップしているかを伝えるための情報。 */
static const rb_data_type_t rb_hello_type = {
  "Hello",
  {
    NULL,
    rb_hello_free, /* ラップしている構造体を開放する関数。↑で定義。 */
    NULL,
  },
  NULL,
  NULL,
  RUBY_TYPED_FREE_IMMEDIATELY,
};

/* 構造体をラップするオブジェクトを新しく作る関数。 */
/* Hello.newの中で暗黙的に呼ばれる。 */
static VALUE
rb_hello_alloc(VALUE klass)
{
  /* ↑の「どのような構造体をラップしているか」情報を使ってオブジェクトを生成。 */
  /* この時点ではラップ対象の構造体はまだ存在しないため最後の引数はNULL。 */
  return TypedData_Wrap_Struct(klass, &rb_hello_type, NULL);
}

/* Hello#initializeの実装。 */
static VALUE
rb_hello_initialize(VALUE self)
{
  Hello *hello;
  /* ラップ対象の構造体をコンストラクターを使って生成。 */
  hello = hello_new();
  /* ラップ対象の構造体を設定。 */
  DATA_PTR(self) = hello;
  return Qnil;
}

/* Hello#messageの実装。 */
static VALUE
rb_hello_message(VALUE self)
{
  Hello *hello;
  const char *message;

  /* ↑の「DATA_PTR(self) = hello」で指定した「hello」を取得。 */
  /* ↑の「どのような構造体をラップしているか」情報は引数チェックのために利用。 */
  TypedData_Get_Struct(self, Hello, &rb_hello_type, hello);
  /* ライブラリーが提供しているメソッド用の関数を呼び出す。 */
  message = hello_message(hello);

  /* 結果(Cの文字列)をRubyのオブジェクトに変換して返す。 */
  return rb_str_new_cstr(message);
}

/* 初期化関数。 */
/* 「hello.so」を「require」すると、Rubyは「Init_hello」を呼ぶ。 */
void
Init_hello(void)
{
  VALUE hello;

  /* 「class Hello」に対応。 */
  /* 親クラスが「rb_cData」にすることがポイント。 */
  hello = rb_define_class("Hello", rb_cData);
  /* 構造体をラップするオブジェクトを新しく作る関数を
     Hello.newの中で暗黙的に呼ばれるようにする。 */
  rb_define_alloc_func(hello, rb_hello_alloc);
  /* 「def initialize」に対応。最後の「0」は引数が「0」という意味。 */
  rb_define_method(hello, "initialize", rb_hello_initialize, 0);
  /* 「def message」に対応。最後の「0」は引数が「0」という意味。 */
  rb_define_method(hello, "message", rb_hello_message, 0);
}

量が増えているのは構造体をラップするためです。この状態から新しくメソッドを追加する場合に増えるコード量は「バインディングではない拡張ライブラリー」とほとんど変わりません。そのため、最初だけコードが多くなりますが、その後はそれほど多くなりません。

これをビルドするためのMakefileを作るextconf.rbは次のようになります。バインディング対象のlibhello.soを検出するためのコードが増えています。

# Makefileを作るための便利ライブラリーを使う。
require "mkmf"

# libhello.soとhello.hを見つけるための引数を受けつける。
# --with-libhello-XXXという引数を指定できるようになる。
dir_config("libhello")
# hello.hを探す。
exit(false) unless have_header("hello.h")
# libhello.soを探す。「hello_new」があるかもチェックする。
exit(false) unless have_library("hello", "hello_new")

# 「hello.so」を作るMakefileを生成。
create_makefile("hello")

このhello.cextconf.rbを用意すると次のようにビルドできます。バインディング対象のlibhello.soの実装はlibhelloとしてリポジトリーに入っています。hello.h/tmp/local/include/hello.hにインストールされ、ビルドされたlibhello.so/tmp/local/lib/libhello.soにインストールされているとします。

% ruby extconf.rb --with-libhello-dir=/tmp/local
% make

これを使うには次のようにします。

% LD_LIBRARY_PATH=/tmp/local/lib irb -I . --simple-prompt
>> require "hello" # 「hello.so」を読み込む。このときにInit_hello()が呼ばれる。
=> true
>> hello = Hello.new # rb_hello_initialize()経由でhello_new()を呼ぶ。
=> #<Hello:0x000000018feca0>
>> hello.message # rb_hello_message()経由でhello_message()を呼ぶ。
=> "Hello"

Rubyのリポジトリーには拡張ライブラリーを作り方を説明したdoc/extension.ja.rdocが入っているので、このドキュメントも参照すると捗るでしょう。

SWIG

SWIGはラッパーを生成するツールです。Ruby専用のツールではありませんが、拡張ライブラリーとして実装されたRubyのバインディングを生成することができます。

SWIGは.iファイル(「i」は「interface」の「i」)を入力としてCのソースを出力します。.iファイルの中にはどのようにバインディングを生成するかという情報を記述します。

前述のhello.hのようなAPIのCライブラリーのバインディングを作成する場合は次のようなhello.iになります。

/* Helloモジュール以下にクラスやメソッドを定義する。 */
%module hello

%{
/* 出力したCのソースでhello.hをinlcudeする。 */
#include <hello.h>
%}

/* SWIGがhello.hをパースして関数を検出してバインディングを生成する。 */
/* 「#」includeではなく「%」includeなことに注意。 */
%include <hello.h>

これでhello_wrap.cという名前の2000行強のCプログラムが生成されます。このhello_wrap.cをビルドするためのMakefileを作るextconf.rbは次のようになります。hello.iからhello_wrap.cを生成するためのコードが増えています。

# Makefileを作るための便利ライブラリーを使う。
require "mkmf"

# libhello.soとhello.hを見つけるための引数を受けつける。
# --with-libhello-XXXという引数を指定できるようになる。
# swigコマンドにヘッダーファイルがある場所を指定するので
# dir_configの戻り値にinclude_dirと名前を付けている。
include_dir, library_dir = dir_config("libhello")
# hello.hを探す。
exit(false) unless have_header("hello.h")
# libhello.soを探す。「hello_new」があるかもチェックする。
exit(false) unless have_library("hello", "hello_new")

# この時点ではまだhello_wrap.cが存在しないため、
# mkmfはソースとして認識できない。そのため、手動で設定する。
$srcs = ["hello_wrap.c"]
# 「make clean」時に削除される設定も手動で行う。
$cleanfiles << "hello_wrap.c"

# 「hello.so」を作るMakefileを生成。
create_makefile("hello")
# Makefileにはhello.iからhello_wrap.cを生成するルールがないので追記する。
File.open("Makefile", "a") do |makefile|
  makefile.puts(<<-MAKEFILE)
hello_wrap.c: hello.i
	# -rubyを指定してRuby用のコードを生成する。
	swig -Wall -I"#{include_dir}" -ruby -o $@ $<
  MAKEFILE
end

このhello.iextconf.rbとSWIGを用意すると次のようにビルドできます。バインディング対象のlibhello.soの実装はlibhelloとしてリポジトリーに入っています。hello.h/tmp/local/include/hello.hにインストールされ、ビルドされたlibhello.so/tmp/local/lib/libhello.soにインストールされているとします。

% ruby extconf.rb --with-libhello-dir=/tmp/local
% make

これを使うには次のようにします。

% LD_LIBRARY_PATH=/tmp/local/lib irb -I . --simple-prompt
>> require "hello" # 「hello.so」を読み込む。
=> true
>> hello = Hello.hello_new # hello_new()を呼ぶ。
=> #<SWIG::TYPE_p_hello_t:0x00000002c703d8 @__swigtype__="_p_hello_t">
>> Hello.hello_message(hello) # hello_message()を呼ぶ。
=> "Hello"
>> Hello.hello_free(hello) # hello_free()を呼ぶ。
=> nil

Cの関数をそのまま呼べるようなバインディングが自動生成されています。自動生成なので関数が多くても手間がかかりませんが、使い勝手はよくありません。hello.messageのように書けなかったり、GCにメモリーの解放を任せるのではなく手動でHello.hello_free(hello)を実行しないといけないからです。

使いやすくする方法は2つあります。

1つはRubyで使いやすいラッパーを作る方法です。たとえば次のようなコードを用意します。ObjectSpace.define_finalizerを使って自動的に解放するようにしているところとWrappedHello#messageでラップしているところがポイントです。

require "hello.so"

class WrappedHello
  class << self
    def finalizer(hello)
      lambda do |id|
        Hello.hello_free(hello)
      end
    end
  end

  def initialize
    @hello = Hello.hello_new
    ObjectSpace.define_finalizer(self, self.class.finalizer(@hello))
  end

  def message
    Hello.hello_message(@hello)
  end
end

次のように使います。

% LD_LIBRARY_PATH=/tmp/local/lib irb -I . --simple-prompt
>> require "hello" # 「hello.so」を読み込む。
=> true
>> hello = WrappedHello.new # ラップしたバージョンを使う。
=> #<WrappedHello:0x00000002c30418 @hello=#<SWIG::TYPE_p_hello_t:0x00000002c304b8 @__swigtype__="_p_hello_t">>
>> hello.message # ラップしたバージョンを使う。
=> "Hello"

もう1つの方法は.iファイルでオブジェクト指向なAPIにする方法です。たとえば、次のようなhello.iにします。

/* Helloモジュール以下にクラスやメソッドを定義する。 */
%module hello

%{
/* 出力したCのソースでhello.hをinlcudeする。 */
#include <hello.h>
%}

/* Hello::Helloクラスを定義する。 */
typedef struct hello_t {
  %extend {
    /* Hello::Hello#initializeで呼ぶCの関数を定義する。 */
    /* C++のコンストラクターと同じ構文。 */
    hello_t() {return hello_new();}
    /* Hello::HelloオブジェクトがGCされたときに呼び出す関数を定義する。 */
    /* C++のデストラクターと同じ構文。 */
    ~hello_t() {hello_free($self);}
    /* Hello::Hello#messageで呼ぶCの関数を定義する。 */
    const char *message() {
      return hello_message($self);
    }
  }
} Hello;

extconf.rbは前述のものと同じです。そのためビルド方法も同じです。

% ruby extconf.rb --with-libhello-dir=/tmp/local
% make

使い勝手は次のようになります。オブジェクト指向なAPIになっています。

% LD_LIBRARY_PATH=/tmp/local/lib irb -I . --simple-prompt
>> require "hello"
=> true
>> hello = Hello::Hello.new
=> #<Hello::Hello:0x000000018d8c58 @__swigtype__="_p_hello_t">
>> hello.message
=> "Hello"
Ruby FFI

Ruby FFIはRubyでバインディングを作れるようにするライブラリーです。libffiを使って実現しています。

拡張ライブラリーでバインディングを実装するよりも非常に短い記述になります。前述のhello.hのようなAPIのCライブラリーのバインディングは次のhello.rbのようになります。

# Ruby FFIを読み込む。
require "ffi"

module LibHello
  # このモジュールでRuby FFIを使う。
  extend FFI::Library
  # バインディング対象の共有ライブラリーを指定する。
  ffi_lib "/tmp/local/lib/libhello.so"
  # 「Hello *hello_new(void)」に対応。
  attach_function :hello_new, [], :pointer
  # 「const char *hello_message(Hello *hello)」に対応。
  attach_function :hello_message, [:pointer], :string
  # 「void hello_free(Hello *hello)」に対応。
  attach_function :hello_free, [:pointer], :void
end

拡張ライブラリーと違ってビルドする必要はないのですぐに使えます。使い勝手は次のようになります。

% LD_LIBRARY_PATH=/tmp/local/lib irb -I . --simple-prompt
>> require "hello" # 「hello.so」を読み込む。
=> true
>> hello = LibHello.hello_new  # hello_new()を呼ぶ。
=> #<FFI::Pointer address=0x000000012835f0>
>> LibHello.hello_message(hello) # hello_message()を呼ぶ。
=> "Hello"
>> LibHello.hello_free(hello) # hello_free()を呼ぶ。
=> nil

単純にSWIGを使った場合と同じくCの関数をそのまま呼べるだけなので使い勝手はよくありません。

使い勝手をよくするには次のようなコードを使ってラップします。SWIG用のラッパーと同じことをしています。

class Hello
  def initialize
    # GC時に自動で解放してくれる便利オブジェクトでラップ。
    # 内部ではObjectSpece.define_finalizerを使っている。
    @hello = FFI::AutoPointer.new(LibHello.hello_new,
                                  LibHello.method(:hello_free))
  end

  def message
    LibHello.hello_message(@hello)
  end
end

このラッパーを使うと次のような使い勝手になります。

% LD_LIBRARY_PATH=/tmp/local/lib irb -I . --simple-prompt
>> require "hello" # 「hello.so」を読み込む。
=> true
>> hello = Hello.new # ラップしたバージョンを使う。
=> #<Hello:0x000000031f03f8 @hello=#<FFI::AutoPointer address=0x000000031f7b40>>
>> hello.message # ラップしたバージョンを使う。
=> "Hello"

オブジェクト指向なAPIになりました。

GObject Introspection

GObject IntrospectionはCで実装されたライブラリーの言語バインディングを簡単に実装できるようにするライブラリー・ツール群です。SWIGと同じように様々な言語に対応したソフトウェアです。「Ruby専用」ではなく「Rubyでも使える」ソフトウェアです。

バインディング対象のライブラリーがGObject Introspectionに対応している場合(たとえばWebブラウザーライブラリーであるWebKitGTK+)はgobject-introspection gemを使うと次のようにすればバインディングができあがります。(このAPIは次回リリースの3.1.0から使えるようになる予定です。そのため、最新リリースの3.0.9では動きません。)

# gobject-introspection gemを読み込む書き方の1つ。短いバージョン。
require "gi"
# GObject Introspectionを使ってWebKitGTK+のバインディングを生成する。
# Moduleが返ってくるので「WebKit」という定数に設定。
WebKit = GI.load("WebKit2")
# ↑は以下の書き方のシンタックスシュガー。
#   module WebKit
#   end
#   loader = GObjectIntrospection::Loader.new(WebKit)
#   loader.load("WebKit2")

次のように使えます。自動生成なのに自然に使えるようになっていることがわかります。

% irb --simple-prompt
>> require "gi" # gobject-introspection gemを読み込み。
=> true
>> WebKit = GI.load("WebKit2") # WebKitGTK+のバインディングを作成。
=> WebKit
# WebKitGTK+のバインディングの動作を確認するためにGTK+ 3を利用。
# GTK+ 3はGUIを提供するライブラリー。
>> require "gtk3"
=> true
# GTK+ 3のウィジェットを表示するにはイベントループを回す必要がある。
# irb上でブロックせずにイベントループを回すために別スレッドでイベントループを実行。
>> Thread.new {c = GLib::MainContext.default; loop {sleep 0.01 unless c.iteration(false)}}
=> #<Thread:0x00000002172b98@(irb):4 run>
# ウィンドウを作成。
>> w = Gtk::Window.new
=> #<Gtk::Window:0x2d85d68 ptr=0x3f7c280>
# ウィンドウを表示。
>> w.show
=> #<Gtk::Window:0x2d85d68 ptr=0x3f7c280>
# Webブラウザーウィジェットを作成。
# GI.load("WebKit2")で作成したバインディング。
>> web_view = WebKit::WebView.new
=> #<WebKit::WebView:0x2d6ab30 ptr=0x3f87420>
# ウィンドウにWebブラウザーウィジェットを追加。
>> w << web_view
=> #<Gtk::Window:0x2d85d68 ptr=0x3f7c280>
# Webブラウザーウィジェットを表示。
>> web_view.show
=> #<WebKit::WebView:0x2d6ab30 ptr=0x3f87420>
# Webブラウザーウィジェットでページを表示。
# GI.loadが引数の処理を自動で実装していることを確認できる。
>> web_view.load_uri("http://rubykaigi.org/")
=> #<WebKit::WebView:0x2d6ab30 ptr=0x3f87420>
# 現在表示しているページのスクリーンショットを取得。
# 非同期で取得するのでブロックで処理完了時の処理を指定。
# GI.loadがenumの値を名前(:full_documentと:none)で指定できるような
# バインディングを生成している。1や2のように数値で指定しなくてもよい。
# GI.loadがブロックに対応したバインディングを生成している。
>> snapshot = nil; web_view.get_snapshot(:full_document, :none) {|_, result| snapshot = web_view.get_snapshot_finish(result)}
=> #<WebKit::WebView:0x2d6ab30 ptr=0x3f87420>
# スクリーンショットとして返ってきたオブジェクト(Cairo::ImageSurface)は
# GI.loadで作成されていないクラスのオブジェクトだが連携できている。
>> snapshot
=> #<Cairo::ImageSurface:0x00000002c6c850>
# ↑で:full_documentと指定していたものは
# WebKit::SnapshotRegion::FULL_DOCUMENTに相当する。
>> WebKit::SnapshotRegion::FULL_DOCUMENT
=> #<WebKit::SnapshotRegion full-document>

このようなオブジェクト指向なAPI、名前でenumの値を指定する、ブロック対応(、この例では出てきていない例外)などに対応するためにはGObjectというCで実装されたオブジェクトシステムを利用する必要があります。(WebKitGTK+は利用しています。)バインディングを作りたいライブラリーがGObjectを利用していない場合はGObjectを使うようにしたラッパーを作成すればGObject Introspectionを使えるようになります。たとえば、GroongaはGObjectを使っていませんが、GObjectを使ったラッパーであるGroonga GObjectを使うと、GObject IntrospectionでGroongaのバインディングを使えます。

GObject Introspectionの重要なポイントは、共有ライブラリーだけでなく、共有ライブラリーのメタデータも利用するところです。メタデータとはたとえば関数のシグネチャー(名前・引数・戻り値の情報)です。Ruby FFIでは共有ライブラリーしか利用していないため関数のシグネチャーをバインディング開発者が指定(attach_functionで指定)していましたが、GObject Introspectionの場合はそれを利用できるのでバインディング開発者は指定する必要はありません。このため、自動でいい感じのバインディングを生成できるのです。

GObjectがGTK+用(もう少し言うとGIMP用)で利用するために開発されているライブラリーのため、GObjectを活用しているGObject IntrospectionもGUI用(GTK+はGUI用のライブラリー)の仕組みと勘違いする人がいます。しかし、GObjectはGUIに関係ない下回りの機能を提供するライブラリーなため、GUIを使わないライブラリーでも利用されています。たとえば、PDFを処理するPoppler、PNGやJPEGなどを含む様々な画像を統一的に処理する機能を提供するGdkPibuf、マルチメディアを処理するGStreamerなどはGUIを使わない(必須ではない)ライブラリーですが、GObjectを利用しています。つまり、GObject IntrospectionはGUIを使わないサーバー上でも活用できる仕組みです。

GObject Introspectionに対応したライブラリーの作り方はGObject Introspectionに対応したライブラリーの作り方を参考にしてください。今回の発表のためにlibgobject-hellolibgobject-calcという実装も用意しました。これらも参考になるはずです。

実際に作ろうとしてつまづいた人はruby-gnome2/ruby-gnome2のissuesで相談してください。日本語で構いません。

まとめ

RubyKaigi 2016でバインディングの作り方を紹介し、バインディング開発者を増やそうとしました。バインディング開発者が増えるとRubyを活用できる場面が広がると考えているからです。

発表中ではバインディングの作り方の詳細を省略したのでここで補足しました。

Windowsでのバインディングのインストールを簡単にするための仕組みとしてfat gemというものがあり、fat gemの説明資料も用意していたのですが、今回のRubyKaigi中では説明する機会がありませんでした。いつか、機会があれば。。。Rubyの拡張ライブラリーやバインディングに特化したイベントを開催するときはぜひ声をかけてください。

発表中に参加者に「バンディングという概念を知っていたか?」と聞いたところ、参加者の半分以上は知らないということでした。「拡張ライブラリー」と合わせてバインディングの情報を提供していった方がRubyを活用できる場面が広がりそうです。

タグ: Ruby
2016-09-14

RubyKaigi2016: Ruby Reference Manual 2016 Autumn #rubykaigi

2016年9月8日から10日にかけて開催されたRubyKaigi 2016で「Rubyリファレンスマニュアル(るりま)に関わる人を増やしたい!」という話をしました。

話の流れは以下の通りです。

  • 「るりま」と「るびま」の違いについて
  • 簡単に歴史をおさらい
    • 10周年だった
  • これまでのコントリビューター数を紹介
  • これからやりたいことを紹介
    • Ruby2.4.0 対応
    • EPUB
    • RDoc

「るりま」と「るびま」の違いについて

「るりま」と「るびま」の違いについて確認しました。

一文字違いですが、どちらも一般社団法人 日本Rubyの会のサポートを受けて活動しています。

簡単に歴史をおさらい

  • 2006-08-27 にプロジェクトが始まりました
    • 初期は青木さんの個人のサーバーでホスティングされていました
    • その後、いつだったか忘れましたが、日本Rubyの会のサーバーでホスティングされるようになりました
  • 2011-09-01 メンテナンスフェイズに移行しました
    • 最低限のドキュメントを書くのに約5年かかりました
  • 2013-05-30 RubyKaigi2013で発表しました
  • 2013-06-02 GitHub にリポジトリを移行しました
  • Rubyの新しいバージョンがリリースされるたびにドキュメントの更新をしています
  • 2016-09-09 RubyKaigi2016 で発表しました

これまでのコントリビューター数を紹介

延べ100人以上の人が貢献してくれています!RubyKaigi2016での発表後に新たにプルリクエストを送ってくれた人もいるので、これからも多くの人が協力してくれることを期待しています。

これからやりたいことを紹介

Ruby2.4.0対応やEPUB生成等、やりたいことを話しました。

初日のパーティでYassLabの安川さんと話したところ、色々と参考になるお話を聞くことができました。

  • EPUB生成をやるなら、kmuto/review: Re:VIEW is flexible document format/conversion systemを使ったらいいですよ
    • EPUBの仕様は複雑なので、EPUB対応を主にやっているツールを使うべき
  • Ruby本体が更新されたときの更新通知はYassLabさんで開発しているツールが使えるかもしれない
  • ドキュメント翻訳のシステム化とお金の話

また、発表後に質問しに来てくれた人が数人いて、興味を持って話を聞きにきてくれた人がいたことを嬉しく思いました。

以下のようなフィードバックをもらいました。

  • 具体的にやりたいことややって欲しいことをリスト化すると貢献したい人がやってくれるのでは?
  • (修正が必要な場所を探すのはできないけど)翻訳だけならできると言っていた人がいたので、翻訳して欲しいものリストがあるといいのでは?

フィードバックを受けて https://github.com/rurema/doctree/issueshttps://github.com/rurema/bitclust/issues に issue を登録していくことにしました。

発表の中や、会場で話をするのを忘れていたのは、ゆるく相談する場としてGitterを用意したり、commit-email.info - Commit Email as a Serviceで差分付きのコミットメールを送ってもらうようにしたりするのはどうかということでした。

RubyKaigiで発表することで新しい人にるりまを知ってもらうことができたり、普段会えない人に話を聞くことができたりして、とても有意義な時間を過ごすことができました。

タグ: Ruby
2016-09-16

MySQLとPostgreSQLと日本語全文検索3:MroongaとPGroongaの導入方法例 #mypgft

2016年9月29日(肉の日!)に「MySQLとPostgreSQLと日本語全文検索3」というイベントを開催しました。その名の通りMySQLとPostgreSQLでの日本語全文検索についての話題を扱うイベントです。今回もDMM.comラボさんに会場を提供してもらいました。

2月9日に開催した1回目のイベントではMroongaPGroongaについては次の2つのことについて紹介しました。

  • Mroonga・PGroongaが速いということ
  • Mroonga・PGroongaの使い方

6月9日に開催した2回目のイベントではMroonga・PGroongaについては次の2つのことについて紹介しました。

  • Mroonga・PGroongaのオススメの使い方
  • レプリケーションまわり

今回はMroonga・PGroongaについては次のことについて紹介しました。

関連リンク:

Redmineへの導入方法

Redmineというチケット管理システムへのMroonga・PGroongaの導入方法を説明します。RedmineはRuby on Railsを利用しているのでRuby on Railsを使っているアプリケーションに導入する例ということになります。

Redmineは右上の検索ボックスから全文検索できます。ここから全文検索したときにMroonga・PGroongaを使うようにします。

Redmineの検索ボックス

redmine_full_text_searchプラグインを使うとRedmineでMroongaまたはPGroongaを使って全文検索できるようになります。

このプラグインを使うとRedmineの全文検索が高速になります。たとえば、クリアコードで使っているRedmineには3000件くらいのチケットがありますが、その環境では次のように高速になりました。

プラグイン 時間
なし 467ms
あり 93ms

200万件のチケットがある環境でも約380msで検索できているという報告もあります。

Mroongaを導入する方法

Mroongaはトランザクションに対応していないのでトランザクションが必須のRedmineに組み込む場合はひと工夫必要になります。単純に、ALTER TABLE table ENGINE=Mroonga ADD FULLTEXT INDEX (column)とするわけにはいきません。

ではどうするかというと別途全文検索用のテーブルを作成して元のテーブルとはJOINできるようにします。(他にもレプリケーションしてレプリケーション先をMroongaにするという2回目のイベントで紹介した方法もありますが、プラグインでやるには大掛かりなのでこの方法を使っています。)

マイグレーションファイルでいうと次のようにします。ここではissuesテーブル用の全文検索用のテーブルを作成しています。

def up
  create_table(:fts_issues, # 全文検索用テーブル作成
               id: false, # idは有効・無効どっちでも可
               options: "ENGINE=Mroonga") do |t|
    t.belongs_to :issue, index: true, null: false
    t.string :subject, default: "", null: false
    t.text :description, limit: 65535, null: false
    t.index [:subject, :description], type: "fulltext"
  end
end

全文検索用のテーブルには元のデータをコピーする必要があります。マイグレーション時には既存のデータを一気にコピーします。そのため、本当のマイグレーションの内容は次のようになります。データコピー後にインデックスを追加するようにしているのはそっちの方が速いからです。

def up
  create_table(:fts_issues, # 全文検索用テーブル作成
               id: false, # idは有効・無効どっちでも可
               options: "ENGINE=Mroonga") do |t|
    t.belongs_to :issue, index: true, null: false
    t.string :subject, default: "", null: false
    t.text :description, limit: 65535, null: false
  end
  execute("INSERT INTO " + # データをコピー
            "fts_issues(issue_id, subject, description) " +
            "SELECT id, subject, description FROM issues;")
  add_index(:fts_issues, [:subject, :description],
            type: "fulltext") # 静的インデックス構築(速い)
end

このテーブルのモデルは次のようになります。

class FtsIssue < ActiveRecord::Base
  # 実際はissue_idカラムは主キーではない。
  # 主キーなしのテーブルなので
  # Active Recordをごまかしているだけ。
  self.primary_key = :issue_id
  belongs_to :issue
end

Mroonga導入後に更新されたデータはアプリケーション(Redmine)側でデータをコピーします。Active Recordのafter_saveフックを利用します。Mroongaがトランザクションをサポートしていないため、ロールバックのタイミングによってはデータに不整合が発生することがありますが、再度保存すれば復旧できることとそれほどロールバックは発生しないため、実運用時には問題になることはないでしょう。

class Issue
  # この後にロールバックされることがあるのでカンペキではない
  # 再度同じチケットを更新するかデータを入れ直せば直る
  after_save do |record|
    fts_record = FtsIssue.find_or_initialize_by(issue_id: record.id)
    fts_record.subject     = record.subject
    fts_record.description = record.description
    fts_record.save!
  end
end

全文検索時は全文検索用のテーブルをJOINしてMATCH AGAINSTを使います。

issue.
  joins(:fts_issue).
  where(["MATCH(fts_issues.subject, " +
               "fts_issues.description) " +
          "AGAINST (? IN BOOLEAN MODE)",
         # ↓デフォルトANDで全文検索
         "*D+ #{keywords.join(', ')}"])

この説明はわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

PGroongaを導入する方法

PGroongaはトランザクションに対応しているので別途全文検索用のテーブルを作成する必要はありません。既存のテーブルに全文検索用のインデックスを作成します。

マイグレーションファイルでいうと次のようにします。ここではissuesテーブルに全文検索用のインデックスを作成しています。enable_extension("pgroonga")はPGroongaを使えるようにするためのSQLです。

def up
  enable_extension("pgroonga")
  add_index(:issues,
            [:id, :subject, :description],
            using: "pgroonga")
end

あとは検索時に全文検索条件をつけるだけです。

issue.
  # 検索対象のカラムごとに
  # クエリーを指定
  where(["subject @@ ? OR " +
         "description @@ ?",
         keywords.join(", "),
         keywords.join(", ")])

この説明もわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

Zulipへの導入方法

ZulipというチャットツールへのPGroongaの導入方法を説明します。ZulipはPostgreSQLを使っているので、導入するのはPGroongaだけです。ZulipはDjangoを使っているのでDjangoを使っているアプリケーションに導入する例ということになります。

Zulipは上部の検索ボックスから全文検索できます。ここから全文検索したときにPGroongaを使うようにします。

Zulipの検索ボックス

Zulipはチャットツールです。チャットツールなので小さなテキストの書き込みが頻繁に発生する傾向があります。各書き込みは十分速く完了する必要があります。書き込みが遅いとユーザーの不満が溜まりやすいからです。

Zulipは書き込みをできるだけ速くするためにインデックスの更新を遅延させています。インデックスの更新はデータの追加よりも重い処理なので、その処理を後回しにしているということです。(PGroongaは検索だけでなく更新も速いので遅延させずにリアルタイムで更新しても十分速いかもしれません。アプリケーションの要件次第でどのような実装にするか検討する必要があります。)

Zulipは、インデックスの更新を遅延させるため、カラムの値を直接全文検索対象にせずに、別途全文検索用のカラム(zulip_message.search_pgroongaカラム)を用意しています。その全文検索用のカラムの更新を後回しにすることでインデックスの更新を遅延させています。

マイグレーションファイルでいうと次のようにします。最初のALTER ROLEはPGroongaが提供する@@という全文検索用の演算子の優先順位を調整するためのものです。本質ではないのでここでは気にしなくて構いません。

migrations.RunSQL("""
ALTER ROLE zulip SET search_path
  TO zulip,public,pgroonga,pg_catalog;
ALTER TABLE zerver_message
  ADD COLUMN search_pgroonga text;
UPDATE zerver_message SET search_pgroonga =
  subject || ' ' || rendered_content;
CREATE INDEX pgrn_index ON zerver_message
  USING pgroonga(search_pgroonga);
""", "...")

全文検索対象のカラム(zerver_message.subjectカラムとzerver_message.rendered_contentカラム)が更新されたらそのレコードのIDをログテーブル(fts_update_logテーブル)に追加します。Zulipは次のトリガーでこれを実現しています。

CREATE FUNCTION append_to_fts_update_log()
  RETURNS trigger
  LANGUAGE plpgsql AS $$
    BEGIN
      INSERT INTO fts_update_log (message_id) VALUES (NEW.id);
      RETURN NEW;
    END
  $$;
CREATE TRIGGER update_fts_index_async
  BEFORE INSERT OR UPDATE OF
    subject, rendered_content ON zerver_message
  FOR EACH ROW
    EXECUTE PROCEDURE append_to_fts_update_log();

全文検索対象のカラムのインデックスは別プロセスで更新します。別プロセスで更新するためには、全文検索対象のカラムが更新されたことをその別プロセスが知らなければいけません。これを実現するためにはポーリングする方法と更新した側から通知を受け取る方法があります。PostgreSQLにはLISTEN/NOTIFYという通知の仕組みがあるので、Zulipはこれらを利用して通知を受け取る方法を実現しています。

更新した側は次のトリガーで更新したことを(fts_update_logチャネルに)通知します。このトリガーはログテーブル(fts_update_logテーブル)にレコードが追加されたら呼ばれるようになっているので、レコードが追加されるごとに通知しているということです。

CREATE FUNCTION do_notify_fts_update_log()
  RETURNS trigger
  LANGUAGE plpgsql AS $$
    BEGIN
      NOTIFY fts_update_log;
      RETURN NEW;
    END
  $$;
CREATE TRIGGER fts_update_log_notify
  AFTER INSERT ON fts_update_log
  FOR EACH STATEMENT
    EXECUTE PROCEDURE do_notify_fts_update_log();

通知を受け取るプロセスはPythonで実装されています。単純化すると次のようになっています。(詳細はpuppet/zulip/files/postgresql/process_fts_updatesを参照。)(fts_update_logチャネルに)通知がきたら全文検索用カラムを更新する(update_fts_columns(cursor)を実行する)ということを繰り返しています。

  import psycopg2
  conn = psycopg2.connect("user=zulip")
  cursor = conn.cursor
  cursor.execute("LISTEN fts_update_log;")
  while True:
      if select.select([conn], [], [], 30) != ([], [], []):
          conn.poll()
          while conn.notifies:
              conn.notifies.pop()
              update_fts_columns(cursor)

全文検索用カラムの更新(update_fts_columnsの実装)は次のようになっています。ログテーブル(fts_update_logテーブル)から更新されたレコードのIDを取得してきて各レコードごとに全文検索用カラムを更新しています。最後に処理したレコードのIDをログテーブルから削除します。

def update_fts_columns(cursor):
    cursor.execute("SELECT id, message_id FROM fts_update_log;")
    ids = []
    for (id, message_id) in cursor.fetchall():
        cursor.execute("UPDATE zerver_message SET "
                       "search_pgroonga = "
                       "subject || ' ' || rendered_content "
                       "WHERE id = %s", (message_id,))
        ids.append(id)
    cursor.execute("DELETE FROM fts_update_log "
                   "WHERE id = ANY(%s)", (ids,))

このようにしてインデックスの更新を遅延し、書き込み時の処理時間を短くしています。書き込み時のレスポンスが大事なチャットツールならではの工夫です。

インデックスが更新できたらあとは全文検索するだけです。全文検索は次のようにWHERE search_pgroonga @@ 'クエリー'を追加するだけです。

from sqlalchemy.sql import column
def _by_search_pgroonga(self, query, operand):
    # WHERE search_pgroonga @@ 'クエリー'
    target = column("search_pgroonga")
    condition = target.op("@@")(operand)
    return query.where(condition)

全文検索してヒットしたキーワードがどこにあるかを見つけやすくするために、Zulipはキーワードハイライト機能を実現しています。以下は「problem」というキーワードをハイライトしている様子です。

Zulipのキーワードハイライト機能

PostgreSQLには標準でts_headline()関数というキーワードハイライト機能がありますが、ZulipのようにHTMLで結果を取得したい場合には使えません。これはts_headline()関数はHTMLエスケープ機能を提供していないからです。HTMLエスケープ機能がないと次のように不正なHTMLができあがってしまいます。

SELECT  ts_headline('english',
                    'PostgreSQL <is> great!',
                    to_tsquery('PostgreSQL'),
                    'HighlightAll=TRUE');
--           ts_headline          
-- -------------------------------
--  <b>PostgreSQL</b> <is> great!
-- (1 row)  不正なHTML↑

そのため、ZulipではPostgreSQLにキーワード出現位置を返す関数を追加して、Zulip側でキーワードハイライト機能を実現しています。PGroongaを使っている場合はpgroonga.match_positions_byte()関数pgroonga.query_extract_keywords()関数を利用してこの機能を実現しています。

なお、PGroongaはHTMLエスケープ機能付きのハイライト関数pgroonga.highlight_html()を提供しているため、Zulipのようにアプリケーション側でハイライト機能の一部を実装する必要はありません。Zulipではすでに実装されていたためPGroongaを使った場合でもpgroonga.highlight_html()関数を使わずにハイライト機能を実現しています。

この説明はわかりやすさのために実際の実装を単純化しています。詳細が知りたい方は実装を確認してください。

まとめ

MySQLとPostgreSQLと日本語全文検索3で、実例をもとにMroonga・PGroongaの導入方法を紹介しました。たとえMySQL・PostgreSQLレベルで日本語全文検索できても、実際にアプリケーションで使えるようにならないとユーザーに日本語全文検索を提供できません。そのため、このような導入方法の紹介にしました。

アプリケーションごとになにを大事にするかは変わるので、この事例をそのまま適用できるわけではありませんが、Mroonga・PGroongaを導入する際には参考にしてください。

タグ: Groonga
2016-09-29

«前月 最新記事 翌月»
タグ:
年・日ごとに見る
2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|12|
2020|01|02|03|04|05|06|07|08|09|10|11|12|