2014/6/22(日)にSEプラスさんが学生向けリーダブルコード勉強会を開催します。リーダブルコードの解説を書いた須藤がトレーナーをします。会場提供はクックパッドさんで、ランチも提供してくれます。
勉強会の応募は5/8から受付を開始しています。応募者多数の場合は第2弾もありうるということです*1。5/19(月)に判断するということなので、参加したい方、参加したいけど6/22(日)は都合が悪くて参加できない、他の日なら…という方は月曜日までに応募してください。なお、5/25(日)までに応募すると、リーダブルコードを無料で送ってくれるそうです。勉強会参加前に読んでおくと得られることが増えそうですね!
勉強会の内容の準備状況も少し紹介します。一ヶ月ほど前に重視することと概要を紹介しました。現在は、内容の具体化と課題の作成に着手しています。どちらもGitHubにリポジトリーを作って作業を進めています。
勉強会で使うコンテンツはCC BY-SA 4.0で利用可能なので、参考にして自分たちで実施しても構いません。実装したコードを交換してさらに開発を進める、というアイディアは、他の人のコードを読む機会を作るよいアイディアだと自負しています。よさそうだと思ったらぜひ取り入れてください。
さいごに、もう一度勉強会ページへのリンクを置いておきます。このページから応募できます。
*1 現在、20名ほどの応募があるとのことなので、さらに10名、20名の応募があれば第2弾開催の可能性が高まりそうな気がします。
FirefoxやThunderbirdではデータの永続的な保存のためにSQLiteやJSON、プレーンテキストなど、様々な形式のファイルが使われていますが、その中にMork形式という物があります。この記事では、それらのファイルの内容を見る方法について解説します。
Morkは、ある程度の構造を持ったデータをテキストで表現したファイル形式です。Thunderbirdでは、メールフォルダの要約ファイル(*.msf)やアドレス帳のファイル(*.mab)に使われています。
このファイル形式の困った所は、Thunderbird内でよく使われている割に、扱いが非常に面倒であるという点です。Morkが生まれた歴史的経緯について詳しく解説しているMork の謎という記事を見ると、Mozillaプロジェクト内でも厄介者扱いされている事が見て取れます。Thunderbirdにおいても廃止(別形式への移行)が提案されはしていましたが、開発リソースの不足から、実行に移されないまま立ち消えになってしまっているというのが現状です。
実際の例を見てみましょう。以下は、「ローカルフォルダ」アカウントの「送信済みメール」フォルダに1つだけメールがある状態での要約ファイル(Sent.msf)の内容です。
// <!-- <mdb:mork:z v="1.4"/> -->
< <(a=c)> // (f=iso-8859-1)
(B8=sortOrder)(B9=viewFlags)(BA=viewType)(BB=sortColumns)
(BC=columnStates)(BD=LastPurgeTime)(BE=useServerRetention)
(BF=customSortCol)(C0=imageSize)(C1=junkscore)(C2=keywords)(C3=account)
(C4=notAPhishMessage)(C5=gloda-dirty)
(C6=mailbox://nobody@smart%20mailboxes/Sent)(C7=MRMTime)
(80=ns:msg:db:row:scope:msgs:all)(81=subject)(82=sender)(83=message-id)
(84=references)(85=recipients)(86=date)(87=size)(88=flags)(89=priority)
(8A=label)(8B=statusOfset)(8C=numLines)(8D=ccList)(8E=bccList)
(8F=msgThreadId)(90=threadId)(91=threadFlags)(92=threadNewestMsgDate)
(93=children)(94=unreadChildren)(95=threadSubject)(96=msgCharSet)
(97=ns:msg:db:table:kind:msgs)(98=ns:msg:db:table:kind:thread)
(99=ns:msg:db:table:kind:allthreads)
(9A=ns:msg:db:row:scope:threads:all)(9B=threadParent)(9C=threadRoot)
(9D=msgOffset)(9E=offlineMsgSize)
(9F=ns:msg:db:row:scope:dbfolderinfo:all)
(A0=ns:msg:db:table:kind:dbfolderinfo)(A1=numMsgs)(A2=numNewMsgs)
(A3=folderSize)(A4=expungedBytes)(A5=folderDate)(A6=highWaterKey)
(A7=mailboxName)(A8=UIDValidity)(A9=totPendingMsgs)
(AA=unreadPendingMsgs)(AB=expiredMark)(AC=version)
(AD=fixedBadRefThreading)(AE=dateReceived)(AF=ProtoThreadFlags)
(B0=gloda-id)(B1=sender_name)(B2=storeToken)(B3=charSetOverride)
(B4=charSet)(B5=folderName)(B6=MRUTime)(B7=sortType)>
<(82=0)>[1:m(^9C=0)(^90=0)(^92=0)(^91=0)(^93=0)]
<(8A=352)(A7=53607898)(80=1)>[2:m(^9C^8A)(^90^8A)(^92^A7)(^91=0)(^93=1)]
<(A1=850)(8E=20)(A2=YUKI Hiroshi <yuki@clear-code.com>)(A3
=yuki@clear-code.com)(A4
==?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?=)(A5
=53607898.3010904@clear-code.com)(A6=account4)(89=ISO-2022-JP)(A8=43c)
(A9=f)(AA=38d)(AB=2843|YUKI Hiroshi)(97=)(8C=ffffffff)>
{1:^80 {(k^97:c)(s=9)}
[352(^9D^8A)(^B2^A1)(^88=1)(^8A=0)(^8B=20)(^82^A2)(^85^A3)(^81^A4)
(^83^A5)(^C3^A6)(^86^A7)(^AE^A7)(^89=1)(^96^89)(^87^A8)(^8C=f)(^9E^AA)
(^B1^AB)(^C2=)(^9B^8C)(^8F^8A)(^AF=0)]}
{2:^80 {(k^C6:c)(s=9)} 352 }
{352:^80 {(k^98:c)(s=9)2:m } 352 }
{FFFFFFFD:^9A {(k^99:c)(s=9)} [352(^95^A4)]}
<(AC=78e)(AD=536078b0)(92=204)(93=Sent)(94=$E9$80$81$E4$BF$A1$E6$B8$88$E3$81$BF\
$E3$83$88$E3$83$AC$E3$82$A4)(A0=1398831280)(96=12)(98
={"threadCol":{"visible":true,"ordinal":"1"},"flaggedCol":{"visible":true,\
"ordinal":"3"},"attachmentCol":{"visible":true,"ordinal":"5"},"subjectCol":{"v\
isible":true,"ordinal":"7"},"unreadButtonColHeader":{"visible":true,"ordinal":\
"9"},"senderCol":{"visible":true,"ordinal":"13"},"recipientCol":{"visible":fal\
se,"ordinal":"11"},"junkStatusCol":{"visible":true,"ordinal":"15"},"receivedCo\
l":{"visible":false,"ordinal":"37"},"dateCol":{"visible":true,"ordinal":"17"},\
"statusCol":{"visible":false,"ordinal":"19"},"sizeCol":{"visible":false,"ordin\
al":"21"},"tagsCol":{"visible":false,"ordinal":"23"},"accountCol":{"visible":f\
alse,"ordinal":"25"},"priorityCol":{"visible":false,"ordinal":"27"},"unreadCol\
":{"visible":false,"ordinal":"29"},"totalCol":{"visible":false,"ordinal":"31"}\
,"locationCol":{"visible":false,"ordinal":"39"},"idCol":{"visible":false,"ordi\
nal":"33"},"enigmailStatusCol":{"visible":false,"ordinal":"39"}})(99
=Tue Apr 17 17:59:57 2012)>
{1:^9F {(k^A0:c)(s=9u)}
[1(^AC=1)(^AD=1)(^A1=1)(^A3^AC)(^A5^AD)(^88^92)(^A7^93)(^B5^94)(^B6^A0)
(^B7=12)(^B8=1)(^B9=0)(^BA=0)(^BB=)(^BC^98)(^BD^99)(^A6^8A)(^BE=1)
(^A4^8A)(^C7^A0)]}
@$${13{@
<(AE=1398831281)>[1:^9F(^B6^AE)]
@$$}13}@
部分的には読める部分もなくはないのですが、記号や括弧だらけで、とても人間に読めた物ではありません。
そういうわけで可能なら避けて通りたいMorkなのですが、Mozilla製品に対する有償サポートを提供しているクリアコードでは、このファイルと真正面から向き合わなくてはならない事も度々あります。例えば、Thunderbirdの要約ファイルの意図しない書き換えや破損が原因で発生している可能性が疑われる障害について、原因を詳しく調査するといった場合です。そのような場合には、全体的な構造を見たり、複数のファイルを比較して変化した箇所を列挙したり、といった事をしたくなりますが、上記の例を見ての通り、Morkでそれを行うのはまず不可能です。
Morkをもっと簡単に読む方法は無いのでしょうか? 内容をもっと人間にも読みやすい形式に変換して出力できれば、全体的な構造を眺めたり、複数のファイルの内容を比較したりといった調査をやりやすくなります。
というわけで、そのようなコマンド「morkdump」を作成してみました。RubyとPerlの併用で、実装は以下の通りです。
#!/usr/bin/env ruby
#
# Mork Dumper, based on the CPAN module "Mozilla::Mork"
# http://search.cpan.org/~kript/Mozilla-Mork-0.01/lib/Mozilla/Mork.pm
#
# description:
# "Mork" is a format of Mozilla's internal data files, like
# summary files (*.msf) of Thunderbird. This command reports
# all contents of the specified Mork file as a JSON string.
#
# usage:
# First, you have to install the Mozilla::Mork.
#
# % curl -L http://cpanmin.us | perl - --sudo App::cpanminus
# % sudo cpan Mozilla::Mork
# % sudo cpan JSON
#
# or
#
# % curl -L http://cpanmin.us | perl - App::cpanminus
# % cpan Mozilla::Mork
# % cpan JSON
#
# Then you can run this command.
#
# % morkdump /path/to/morkfile
#
# If you specify "--decode" option, subjects in msf files are decoded.
#
# % morkdump --decode /path/to/morkfile
#
# If you specify "--parse-flags" option, flags in msf files are shown
# with human readable flag names.
#
# % morkdump --parse-flags /path/to/morkfile
require "open3"
require "json"
require "optparse"
require "kconv"
def parse_mork(file)
morkdump = <<-"PERL"
use Mozilla::Mork;
use JSON;
my $file = "#{file}";
my $MorkDetails = Mozilla::Mork->new($file);
my $results = $MorkDetails->ReturnReferenceStructure();
my $json = JSON->new->allow_nonref;
print $json->encode($results);
exit 0;
PERL
stdout, error, status = Open3.capture3("perl", :stdin_data => morkdump)
parsed = stdout.encode("UTF-16BE", :invalid => :replace,
:undef => :replace,
:replace => '?')
parsed = parsed.encode("UTF-8")
JSON.parse(parsed)
end
$nsMsgMessageFlags = {
:Read => 0x00000001,
:Replied => 0x00000002,
:Marked => 0x00000004,
:Expunged => 0x00000008,
:HasRe => 0x00000010,
:Elided => 0x00000020,
:FeedMsg => 0x00000040,
:Offline => 0x00000080,
:Watched => 0x00000100,
:SenderAuthed => 0x00000200,
:Partial => 0x00000400,
:Queued => 0x00000800,
:Forwarded => 0x00001000,
:Priorities => 0x0000E000,
:New => 0x00010000,
:Ignored => 0x00040000,
:IMAPDeleted => 0x00200000,
:MDNReportNeeded => 0x00400000,
:MDNReportSent => 0x00800000,
:Template => 0x01000000,
:Attachment => 0x10000000,
:Labels => 0x0E000000,
}
$nsMsgFolderFlags = {
:Newsgroup => 0x00000001,
:Unused3 => 0x00000002,
:Mail => 0x00000004,
:Directory => 0x00000008,
:Elided => 0x00000010,
:Virtual => 0x00000020,
:Unused5 => 0x00000040,
:Unused2 => 0x00000080,
:Trash => 0x00000100,
:SentMail => 0x00000200,
:Drafts => 0x00000400,
:Queue => 0x00000800,
:Inbox => 0x00001000,
:ImapBox => 0x00002000,
:Archive => 0x00004000,
:Unused1 => 0x00008000,
:Unused4 => 0x00010000,
:GotNew => 0x00020000,
:Unused6 => 0x00040000,
:ImapPersonal => 0x00080000,
:ImapPublic => 0x00100000,
:ImapOtherUser => 0x00200000,
:Templates => 0x00400000,
:PersonalShared => 0x00800000,
:ImapNoselect => 0x01000000,
:CreatedOffline => 0x02000000,
:ImapNoinferiors => 0x04000000,
:Offline => 0x08000000,
:OfflineEvents => 0x10000000,
:CheckNew => 0x20000000,
:Junk => 0x40000000,
:Favorite => 0x80000000,
}
$nsMsgFolderFlags[:SpecialUse] = [
:Inbox,
:Drafts,
:Trash,
:SentMail,
:Templates,
:Junk,
:Archive,
:Queue,
].collect do |flag_name|
$nsMsgFolderFlags[flag_name]
end.reduce do |a, b|
a | b
end
def parse_record_flags(record)
if folder_record?(record)
parse_folder_flags(record["flags"])
else
parse_message_flags(record["flags"])
end
end
def folder_record?(record)
record["folderName"] or record["columnStates"] or record["mailboxName"]
end
def thread_record?(record)
record["threadId"] and not record["message-id"]
end
def parse_message_flags(flags)
flags = flags.to_i(16)
$nsMsgMessageFlags.keys.select do |flag_name|
flag_value = $nsMsgMessageFlags[flag_name]
flags & flag_value != 0
end.join(",")
end
def parse_folder_flags(flags)
flags = flags.to_i(16)
$nsMsgFolderFlags.keys.select do |flag_name|
flag_value = $nsMsgFolderFlags[flag_name]
flags & flag_value != 0
end.join(",")
end
def normalize_records(records, options={})
records = records.collect do |record|
sorted_record = {}
record.keys.sort.each do |key|
value = record[key]
value = value.toutf8 if options[:decode]
if key == "flags" and options[:parse_flags]
value = "#{value} (#{parse_record_flags(record)})"
end
sorted_record[key] = value
end
sorted_record
end
records.sort do |a, b|
sort_records(a, b)
end
end
def sort_records(a, b)
case
when (folder_record?(a) and not folder_record?(b))
-1
when (not folder_record?(a) and folder_record?(b))
1
when (thread_record?(a) and not thread_record?(b))
-1
when (not thread_record?(a) and thread_record?(b))
1
when (a["message-id"] and b["message-id"])
a["message-id"] <=> b["message-id"]
when (a["threadId"] and b["threadId"])
a["threadId"] <=> b["threadId"]
else
0
end
end
def main
options = {
:decode => false,
:parse_flags => nil,
}
parser = OptionParser.new
parser.on("--[no-]decode",
"Decode non-ASCII strings in the subject field.") do |decode|
options[:decode] = decode
end
parser.on("--[no-]parse-flags",
"Parse flags of messages.") do |parse_flags|
options[:parse_flags] = parse_flags
end
parser.parse!(ARGV)
file = ARGV[0]
unless file
print "usage: morkdump <file>\n"
return 1
end
if file.end_with?(".msf") and options[:parse_flags].nil?
options[:parse_flags] = true
end
records = parse_mork(file)
records = normalize_records(records, options)
print JSON.pretty_generate(records)
print "\n"
0
end
exit main
全体としてはRubyスクリプトなのですが、Morkをパースする処理については、既に存在しているMozilla::Mork - search.cpan.orgというCPANモジュールを使っています。最初から全部Perlで書けばいいじゃないかという話なのですが、筆者がPerlに不慣れなため、PerlではMorkをJSONにする所までだけをやり、後の整形はRubyで行うようにした次第です。
冒頭のコメントの説明に従って必要なCPANモジュールをインストールした状態でmorkdumpを実行すると、以下のような出力が得られます(※スクリプトを置いた位置にパスが通っていると仮定します)。
% morkdump /path/to/Sent.msf
[
{
"LastPurgeTime": "Tue Apr 17 17:59:57 2012",
"MRMTime": "1398831280",
"MRUTime": "1398831281",
"children": "0",
"columnStates": "{\"threadCol\":{\"visible\":true,\"ordinal\":\"1\"},\"flaggedCol\":{\"visible\":true,\"ordinal\":\"3\"},\"attachmentCol\":{\"visible\":true,\"ordinal\":\"5\"},\"subjectCol\":{\"visible\":true,\"ordinal\":\"7\"},\"unreadButtonColHeader\":{\"visible\":true,\"ordinal\":\"9\"},\"senderCol\":{\"visible\":true,\"ordinal\":\"13\"},\"recipientCol\":{\"visible\":false,\"ordinal\":\"11\"},\"junkStatusCol\":{\"visible\":true,\"ordinal\":\"15\"},\"receivedCol\":{\"visible\":false,\"ordinal\":\"37\"},\"dateCol\":{\"visible\":true,\"ordinal\":\"17\"},\"statusCol\":{\"visible\":false,\"ordinal\":\"19\"},\"sizeCol\":{\"visible\":false,\"ordinal\":\"21\"},\"tagsCol\":{\"visible\":false,\"ordinal\":\"23\"},\"accountCol\":{\"visible\":false,\"ordinal\":\"25\"},\"priorityCol\":{\"visible\":false,\"ordinal\":\"27\"},\"unreadCol\":{\"visible\":false,\"ordinal\":\"29\"},\"totalCol\":{\"visible\":false,\"ordinal\":\"31\"},\"locationCol\":{\"visible\":false,\"ordinal\":\"39\"},\"idCol\":{\"visible\":false,\"ordinal\":\"33\"},\"enigmailStatusCol\":{\"visible\":false,\"ordinal\":\"39\"}}",
"expungedBytes": "352",
"fixedBadRefThreading": "1",
"flags": "204",
"folderDate": "536078b0",
"folderName": "送信済みトレイ",
"folderSize": "78e",
"highWaterKey": "352",
"mailboxName": "Sent",
"numMsgs": "1",
"sortColumns": "",
"sortOrder": "1",
"sortType": "12",
"threadFlags": "0",
"threadId": "0",
"threadNewestMsgDate": "0",
"threadRoot": "0",
"useServerRetention": "1",
"version": "1",
"viewFlags": "0",
"viewType": "0"
},
{
"ProtoThreadFlags": "0",
"account": "account4",
"date": "53607898",
"dateReceived": "53607898",
"flags": "1",
"keywords": "",
"label": "0",
"message-id": "53607898.3010904@clear-code.com",
"msgCharSet": "ISO-2022-JP",
"msgOffset": "352",
"msgThreadId": "352",
"numLines": "f",
"offlineMsgSize": "38d",
"priority": "1",
"recipients": "yuki@clear-code.com",
"sender": "YUKI Hiroshi <yuki@clear-code.com>",
"sender_name": "2843|YUKI Hiroshi",
"size": "43c",
"statusOfset": "20",
"storeToken": "850",
"subject": "=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?=",
"threadParent": "ffffffff",
"threadSubject": "=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?="
},
{
"children": "1",
"threadFlags": "0",
"threadId": "352",
"threadNewestMsgDate": "53607898",
"threadRoot": "352"
}
]
このように、個々のレコードはオブジェクトになります。また、各レコードのキーはアルファベット順でソートされているため、内容が似ていると考えられる複数の要約ファイル(例えば、何かの操作の前後で取得した要約ファイルのスナップショット同士など)をdiffにかければ、どこが変化したのかを詳細に調べる事もできます。例えば、以下はメールにタグを付けた前後の要約ファイルの比較結果です。
% morkdump /path/to/Sent.msf > /tmp/before.json
% morkdump /path/to/Sent.msf > /tmp/after.json
% diff -uNr /tmp/before.json /tmp/after.json
--- /tmp/before.json 2014-04-30 14:36:10.000000000 +0900
+++ /tmp/after.json 2014-04-30 14:36:29.000000000 +0900
@@ -2,13 +2,13 @@
{
"LastPurgeTime": "Tue Apr 17 17:59:57 2012",
"MRMTime": "1398831280",
- "MRUTime": "1398831281",
+ "MRUTime": "1398836179",
"children": "0",
"columnStates": "{\"threadCol\":{\"visible\":true,\"ordinal\":\"1\"},\"flaggedCol\":{\"visible\":true,\"ordinal\":\"3\"},\"attachmentCol\":{\"visible\":true,\"ordinal\":\"5\"},\"subjectCol\":{\"visible\":true,\"ordinal\":\"7\"},\"unreadButtonColHeader\":{\"visible\":true,\"ordinal\":\"9\"},\"senderCol\":{\"visible\":true,\"ordinal\":\"13\"},\"recipientCol\":{\"visible\":false,\"ordinal\":\"11\"},\"junkStatusCol\":{\"visible\":true,\"ordinal\":\"15\"},\"receivedCol\":{\"visible\":false,\"ordinal\":\"37\"},\"dateCol\":{\"visible\":true,\"ordinal\":\"17\"},\"statusCol\":{\"visible\":false,\"ordinal\":\"19\"},\"sizeCol\":{\"visible\":false,\"ordinal\":\"21\"},\"tagsCol\":{\"visible\":false,\"ordinal\":\"23\"},\"accountCol\":{\"visible\":false,\"ordinal\":\"25\"},\"priorityCol\":{\"visible\":false,\"ordinal\":\"27\"},\"unreadCol\":{\"visible\":false,\"ordinal\":\"29\"},\"totalCol\":{\"visible\":false,\"ordinal\":\"31\"},\"locationCol\":{\"visible\":false,\"ordinal\":\"39\"},\"idCol\":{\"visible\":false,\"ordinal\":\"33\"},\"enigmailStatusCol\":{\"visible\":false,\"ordinal\":\"39\"}}",
"expungedBytes": "352",
"fixedBadRefThreading": "1",
"flags": "204",
- "folderDate": "536078b0",
+ "folderDate": "53608bd8",
"folderName": "送信済みトレイ",
"folderSize": "78e",
"highWaterKey": "352",
@@ -32,7 +32,7 @@
"date": "53607898",
"dateReceived": "53607898",
"flags": "1",
- "keywords": "",
+ "keywords": "\\$label1",
"label": "0",
"message-id": "53607898.3010904@clear-code.com",
"msgCharSet": "ISO-2022-JP",
これを見ると、メールにタグを付ける操作により最終更新日時とタグの情報だけが変化した、という事を容易に見て取れます。
また、morkdumpは非ASCII文字列をエンコードして埋め込んだ状態のSubjectなどのフィールドを、自動的にデコードして出力する機能も含んでいます。「--decode」オプションを指定すると、この機能が有効になります。
% morkdump --decode /path/to/Sent.msf > /tmp/after-decoded.json
% diff -uNr /tmp/after.json /tmp/after-decoded.json
--- /tmp/after.json 2014-04-30 14:36:29.000000000 +0900
+++ /tmp/after-decoded.json 2014-04-30 14:48:40.000000000 +0900
@@ -47,9 +47,9 @@
"size": "43c",
"statusOfset": "20",
"storeToken": "850",
- "subject": "=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?=",
+ "subject": "日本語のSubject",
"threadParent": "ffffffff",
- "threadSubject": "=?ISO-2022-JP?B?GyRCRnxLXDhsJE4bKEJTdWJqZWN0?="
+ "threadSubject": "日本語のSubject"
},
{
"children": "1",
Mork形式の概要について説明しました。また、Morkの内容を人間でも読める形に出力するmorkdumpコマンドをご紹介しました。
Morkは将来性があるとはお世辞にも言い難い技術ですが、まだまだMozilla製品、特にThundebrirdの内部では使われ続けています。もし何かの拍子に要約ファイルの内容を調査しなくてはならなくなったというような場合には、この記事を思い出して、morkdumpを一度試してみて下さい。
RubyやRailsも使えるPaaSであるHerokuでRroongaを使えるようにしました。これにより、高速な全文検索機能を提供するRubyによるWebアプリケーションをHeroku上で動かすことができるようになりました。
ここでは、HerokuでRroongaを使う方法と、どのように動いているかを簡単に説明します。
Heroku上でRroongaを使えることを示すサンプルアプリケーションとして、Rroongaで全文検索できるブログを作成しました。
Railsでscaffoldしたものに、全文検索関連の機能を追加して見た目を整えた*1だけの簡単なアプリケーションです。
全文検索機能はページ上部の検索ボックスにキーワードを入力してサブミットすると確認できます。キーワードにマッチするとキーワードがハイライトするようになっていますが、これもRroongaの機能です。
それでは、Herokuで動くRroongaを使ったアプリケーションの作り方を説明します。
まず、Railsアプリケーションを作ります。Heroku用のRailsアプリケーションの作り方の詳細はHerokuのドキュメント(英語)を参考にしてください。
% rails new rroonga-blog --database=postgresql --skip-bundle % cd rroonga-blog % git init
Gemfileに次の内容を追加します。rails_12factorはHeroku用で、rroongaはRroongaを使うためです。
1 2 3 |
gem 'rails_12factor', group: :production gem 'rroonga' |
config/database.ymlはproduction用の設定を次のように変えるだけでよいです。
production: url: <%= ENV['DATABASE_URL'] %>
Debian GNU/Linuxではデータベース周りの初期設定は次の通りです。
% sudo -H apt-get install -V -y postgresql postgresql-server-dev-all % sudo -u postgres -H createuser --createdb $USER % bundle install % rake db:create
scaffoldでベースの機能を作ります。
% rails generate scaffold post title:string content:text % rake db:migrate
これでブログができました。
それでは、ここにRroongaを組み込んでいきます。
まず、次の内容でconfig/initilizers/groonga.rbを作ります。やっていることはGroongaデータベースの作成またはオープンです。
1 2 3 4 5 6 7 8 9 10 |
require 'fileutils' require 'groonga' database_path = ENV['GROONGA_DATABASE_PATH'] || 'groonga/database' if File.exist?(database_path) Groonga::Database.open(database_path) else FileUtils.mkdir_p(File.dirname(database_path)) Groonga::Database.create(path: database_path) end |
Herokuで動くときはGroongaのデータベースのパスは環境変数GROONGA_DATABASE_PATHで渡ってきます*2。しかし、テストのためにローカルで動かすときはこの環境変数が設定されていないため、デフォルト値として'grooonga/detabase'を使っています。
Groongaのデータベースはリポジトリーに入れる必要はないので無視するようにします。
.gitignore:
# ... /groonga/database
config/initializers/に書いたので、RailsアプリケーションはどこでもGroongaのデータベースにアクセスできるようになりました。
このブログは次のデータをデータベースに格納します。
今回はすべてにインデックスを張って検索できるようにします。そのためのGroongaのスキーマをgroonga/init.rb*3で定義します。後述する通り、このGroongaのデータベースは永続的なものではなく、何度でも作り直すものなので、マイグレーションのような仕組みは不要です。
groonga/init.rb:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
require_relative '../config/environment' # データを保存するテーブルを定義。カラムはPostgreSQLと同じ。 Groonga::Schema.define do |schema| schema.create_table('Posts', type: :hash, key_type: :uint32) do |table| table.short_text('title') table.text('content') table.time('created_at') table.time('updated_at') end end # 後でここにPostgreSQLのデータをインポートするコードを入れる # インデックスを定義。通常はこのパラメーターで十分。 Groonga::Schema.define do |schema| schema.create_table('Terms', type: :patricia_trie, key_type: :short_text, normalizer: 'NormalizerAuto', default_tokenizer: 'TokenBigram') do |table| table.index('Posts.title') table.index('Posts.content') end schema.create_table('Times', type: :patricia_trie, key_type: :time) do |table| table.index('Posts.created_at') table.index('Posts.updated_at') end end |
Groonga側にテーブルができたので、PostgreSQLにデータを追加・更新・削除するときにGroongaのデータベースの中身も更新するようにします。
まず、Groongaのインデックスを更新するクラスを作ります。
lib/post_indexer.rb:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class PostIndexer def initialize @posts = Groonga['Posts'] end def add(post) attributes = post.attributes id = attributes.delete('id') @posts.add(id, attributes) end def remove(post) @posts[post.id].delete end end |
lib/に置いたのでconfig.autoload_pathsにlib/を追加します。
config/application.rb:
1 2 3 4 5 6 7 |
# ... module RroongaBlog class Application < Rails::Application # ... config.autoload_paths += ["#{config.root}/lib"] end end |
Postクラスにコールバックを設定します。
app/models/post.rb:
1 2 3 4 5 6 7 8 9 10 11 |
class Post < ActiveRecord::Base after_save do |post| indexer = PostIndexer.new indexer.add(post) end after_destroy do |post| indexer = PostIndexer.new indexer.remove(post) end end |
これで、データが変わるとGroongaのデータベースと同期するようになりました。
それでは全文検索機能を組み込みましょう。queryというパラメーターが指定されたらタイトルと内容を全文検索します。
app/controllers/posts_controller.rb:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
class PostsController < ApplicationController # ... def index query = params[:query] if query @posts = search(query) else @posts = Post.all end end # ... private # ... def search(query) # Groongaを使って全文検索 groonga_posts = Groonga['Posts'] matched_groonga_posts = groonga_posts.select do |record| # titleかcontentにqueryがマッチ、という検索パターンを指定 record.match(query) do |match_target| match_target.title | match_target.content end end # Groongaのデータベースでは各レコードのキーに # PostgreSQLのレコードのIDが入っているので、 # それを使って対象レコードを取得。 post_ids = matched_groonga_posts.collect(&:_key) Post.where(id: post_ids) end end |
検索フォームを追加します。
app/views/posts/index.html.erb:
1 2 3 4 5 6 7 8 |
<h1>Listing posts</h1> <%= form_tag posts_path, method: "get" do %> <%= search_field_tag :query, params[:query] %> <%= submit_tag 'Search' %> <% end %> <!-- ... --> |
http://localhost:3000/postsをWebブラウザーで開いてテストデータを投入し、検索してみてください。AND検索だけではなく、OR検索やNOT検索もできます。もちろん、日本語も使えます。
ローカルで動作することを確認できたのでコミットします。Herokuにデプロイするときはgit pushする必要があるのでコミットしておかないといけません。
% git add . % git commit --message 'Import'
ローカルで動作することを確認できたので、いよいよHerokuで動かします。
Heroku Toolbeltをインストール済みであるという前提で説明します。
まず、Herokuアプリケーションを作ります。Rroonga用のビルドパックを指定することがポイントです。Rroonga用のビルドパックはGroongaを追加でインストールすること以外はRuby用のビルドパックと同じです。そのため、Rroognaを使っていないHeroku用のRailsアプリケーションと同じように開発できます。
% heroku apps:create --buildpack https://codon-buildpacks.s3.amazonaws.com/buildpacks/groonga/rroonga.tgz
PostgreSQLデータベースの初期設定をします。
% heroku run rake db:migrate
これでHeroku上でブログを使えるようになりました。Webブラウザーで開いて動作を確認してみてください。
% heroku apps:open
HerokuでRroongaを使って全文検索をするアプリケーションができましたね。Herokuでも全文検索したい人は試してみてください。
ここからはHerokuでRroongaを動かすということは、どのようなメリット、どのような制約があるのかについて説明します。このあたりにあまり興味がなく、単に使えれば十分という人は次の2点だけ覚えておけば十分です。
groonga/init.rbでGroongaデータベースにマスターデータを投入すること*4Herokuはgit pushしたり、dynoが再起動する毎にローカルストレージの内容が消えます。
Rroongaはローカルストレージにデータベースを作成し、そこに対して読み書きします。もちろん、dynoが再起動するとRroongaが作ったデータベースも消えます。相性が悪いですね。
この相性の悪さは毎回Groongaのデータベースを1から作成することで解決します。Immutable InfrastructureとかDisposable Componentsのような考え方です。デプロイする毎にGroongaのデータベースが破棄されることを前提にします。そのため、毎回データベースを1から作成します。そのための仕組みがgroonga/init.rbです。
Herokuはアプリケーションが動くまで次のような流れになります。
git push heroku masterすると、slugを作成する。ポイントは、1つのslugを使ってN回dynoを起動するということです。
groonga/init.rbはslugを作るときに動きます。groonga/init.rbが作ったGroongaのデータベースはslugの中に含まれるため、dynoを起動したときはGroongaのデータベースがセットアップされた状態になります。ただし、dynoを起動した後にマスターデータが更新されないアプリケーションの場合は、という条件がつきます。
簡単に言うと、更新機能がないアプリケーションならgroonga/init.rbで作ったGroongaデータベースを使い続けられます。例えば、るりまサーチはそのようなタイプのアプリケーションです*5。
この記事で作成したブログはこの条件には当てはまりません。ブログに記事を投稿したり、既存の記事を削除したりできるからです。これはマスターデータを変更しているため、slugを作成するときに作ったGroongaのデータベースは古くなっています。
このようなアプリケーションの場合はdynoを起動する毎にGroongaのデータベースを1から作成します。この記事で作成したブログでは次のようになります。
groonga/init.rb:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# ... # データを保存するテーブルを定義。カラムはPostgreSQLと同じ。 # ... # 後でここにPostgreSQLのデータをインポートするコードを入れる if Post.table_exists? indexer = PostIndexer.new Post.all.each do |post| indexer.add(post) end end # インデックスを定義。通常はこのパラメーターで十分。 # ... |
PostgreSQLからデータを持ってきてGroongaのデータベースを更新しているだけです*6。
これをdynoが起動するタイミングでも実行します。
Procfile:
web: ruby groonga/init.rb && bin/rails server -p $PORT -e $RAILS_ENV
dynoが起動するたびに実行するとdynoの起動が非常に遅くならないか心配になると思いますが、次の理由から問題にはならないでしょう。
データ量はどうして多くないのか説明します。
それは、dynoが使えるローカルストレージのサイズにそんなに大きくない上限があるだろうからです。実際に上限がいくつかはわかりませんが、1GB強くらいでしょう。
予想してみましょう。
slugの最大サイズは300MBです。slugはgzで圧縮されています。Rubyのビルドパックでできるファイルがだいたい100MBで、それをtar.gzにすると25MBくらいです。そのため、ここでは1/4くらいに圧縮できると考えます。dynoではslugを展開して利用します。1/4に圧縮されているとすると、展開後は1.2GBになります。そのため、1GB強くらいが上限になっていると考えられます。
しかし、展開後で1.2GBになるdynoは想定外でしょうから、実際に使えるのはもっと少ないと考えるべきです。半分の500MBくらいとしましょう。そのうち、Ruby関連のファイルで100MBくらい使います。残りは400MBです。Groongaのデータベースはインデックスの張り方にもよりますが、少なくとも検索対象のデータ(入力データ)の3倍以上の大きさになります。実際にはいくつかインデックスを張るでしょうから、4倍以上などもっとサイズが増えます。よって、入力データは多くても100MBより少なくしなければいけません。
つまり、それほど大きなデータを扱うことはできないということです。そのため、Groongaのデータベースの作成にかかる時間も短くなり、dynoを起動する毎にGroongaのデータベースを作ることも現実的になります。
HerokuでRroongaを使うということは、ちょっとした全文検索機能つきWebアプリケーションをRubyで簡単に開発・運用できるということです。大規模システムには向きませんが、手軽にやりたいことを試せます。
この記事で説明したことをまとめます。
groonga/init.rbを用意するビルドパックの作り方も説明しようとしましたが、力尽きました。またの機会に。。。