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

ククログ


学生向けリーダブルコード勉強会の参加者募集を開始

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弾開催の可能性が高まりそうな気がします。

2014-05-14

Thunderbirdの要約ファイルやアドレス帳ファイルの内容を読む方法

FirefoxやThunderbirdではデータの永続的な保存のためにSQLiteやJSON、プレーンテキストなど、様々な形式のファイルが使われていますが、その中にMork形式という物があります。この記事では、それらのファイルの内容を見る方法について解説します。

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形式のファイルの内容をもっと扱いやすい形で全出力する

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を一度試してみて下さい。

タグ: Mozilla
2014-05-21

HerokuでRroongaを使う方法

RubyやRailsも使えるPaaSであるHerokuRroongaを使えるようにしました。これにより、高速な全文検索機能を提供するRubyによるWebアプリケーションをHeroku上で動かすことができるようになりました。

ここでは、HerokuでRroongaを使う方法と、どのように動いているかを簡単に説明します。

サンプルアプリケーション

Heroku上でRroongaを使えることを示すサンプルアプリケーションとして、Rroongaで全文検索できるブログを作成しました。

Railsでscaffoldしたものに、全文検索関連の機能を追加して見た目を整えた*1だけの簡単なアプリケーションです。

全文検索機能はページ上部の検索ボックスにキーワードを入力してサブミットすると確認できます。キーワードにマッチするとキーワードがハイライトするようになっていますが、これもRroongaの機能です。

Rroongaを使ったアプリケーションの作り方

それでは、Herokuで動くRroongaを使ったアプリケーションの作り方を説明します。

Heroku用Railsアプリケーションの作成

まず、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の組み込み

それでは、ここに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のデータベースにアクセスできるようになりました。

このブログは次のデータをデータベースに格納します。

  • title: タイトル
  • content: 内容
  • created_at: 作成時間
  • updated_at: 更新時間

今回はすべてにインデックスを張って検索できるようにします。そのための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_pathslib/を追加します。

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検索もできます。もちろん、日本語も使えます。

  • AND検索:キーワードをスペースで区切る。
    • 「hello」と「world」を両方含んでいたらヒットする例:「hello world」
  • OR検索:キーワードを「 OR 」で区切る。
    • 「hello」または「world」をどちらか含んでいたらヒットする例:「hello OR world」
  • NOT検索:キーワードの前に「-」を付ける。
    • 「hello」は含むが「world」を含まなかったらヒットする例:「hello -world」

ローカルで動作することを確認できたのでコミットします。Herokuにデプロイするときはgit pushする必要があるのでコミットしておかないといけません。

% git add .
% git commit --message 'Import'
Herokuで動かす

ローカルで動作することを確認できたので、いよいよ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を動かすということ

ここからはHerokuでRroongaを動かすということは、どのようなメリット、どのような制約があるのかについて説明します。このあたりにあまり興味がなく、単に使えれば十分という人は次の2点だけ覚えておけば十分です。

  • マスターデータはどこかに持っておき、groonga/init.rbでGroongaデータベースにマスターデータを投入すること*4
  • 扱えるデータの量は多くても100MB程度
揮発性ローカルストレージのHerokuとローカルストレージに保存するRroonga

Herokuはgit pushしたり、dynoが再起動する毎にローカルストレージの内容が消えます。

Rroongaはローカルストレージにデータベースを作成し、そこに対して読み書きします。もちろん、dynoが再起動するとRroongaが作ったデータベースも消えます。相性が悪いですね。

この相性の悪さは毎回Groongaのデータベースを1から作成することで解決します。Immutable InfrastructureとかDisposable Componentsのような考え方です。デプロイする毎にGroongaのデータベースが破棄されることを前提にします。そのため、毎回データベースを1から作成します。そのための仕組みがgroonga/init.rbです。

Herokuはアプリケーションが動くまで次のような流れになります。

  • git push heroku masterすると、slugを作成する。
  • slugを元にdyno(アプリケーション)を起動する。
  • なにかあったらslugを元にdynoを再起動する。

ポイントは、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の起動が非常に遅くならないか心配になると思いますが、次の理由から問題にはならないでしょう。

  • Groongaの更新速度は速い
  • データ量は多くない
slugの最大サイズは300MB

データ量はどうして多くないのか説明します。

それは、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を動かすということはなんだったのか

HerokuでRroongaを使うということは、ちょっとした全文検索機能つきWebアプリケーションをRubyで簡単に開発・運用できるということです。大規模システムには向きませんが、手軽にやりたいことを試せます。

まとめ

この記事で説明したことをまとめます。

  • HerokuでRroongaを使えるようになった
    • Rroonga用のビルドパックを使う
    • groonga/init.rbを用意する
    • あとはRuby用のビルドパックのときと同じ
  • Groongaのデータベースは毎回1から作る
    • Herokuの特性に合わせた
    • Disposable Database
  • 全文検索機能つきのちょとしたWebアプリケーションをRubyで簡単に開発・運用できる
    • 大規模データは対象外

ビルドパックの作り方も説明しようとしましたが、力尽きました。またの機会に。。。

*1 @mallowlabsさんが整えてくれました。

*2 詳細はまた別の機会に。

*3 このファイル名は固定です。詳細は後述します。

*4 この記事で作っているブログではまだ実装していない。

*5 ただし、るりまサーチはデータサイズの制限という別の制限に引っかかるためHerokuでは動かせません。

*6 Post.table_exists?でテーブルがあるかをチェックしているのはheroku run rake db:migrateをする前の最初のgit push heroku masterのときでもエラーにならないようにするためです。

タグ: Ruby | Groonga
2014-05-28

«前月 最新記事 翌月»
タグ:
年・日ごとに見る
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|