Chienomi

Technology of Pluto

開発::commercial

Plutoは私がInflatonで開発していた業務用宛名組版システムである。

これの技術文書はInflatonで提供していたのだが、私が抜けてから全く管理されていないようなので、事実上失われてしまっている。

私としては、Plutoはあまり本意ではないソフトウェアである。 なんといっても、振る舞いに関する部分やソフトウェアビジョンに関して、私が良いと思えないものであった。 私は発展的性質のある、つまりは今の業務をよりクオリティの高いものにできるソフトウェアにすべきだと考えていたし、より積極的に活用できるソフトウェアにするべきだと思っていた。

実際は、実地検証も拒否され、言われるままに作らされる形になったため、不本意なのだ。

だが、テクノロジー面では色々と魅力的な部分がある。 一般的なアプローチと大きく違うので、興味深く読んでもらえるのではないだろうか。

基本的な構造

Plutoはほぼ全面的にRubyで書かれている。

構成としては

  • 7つのRubyライブラリ
  • 11のRubyスクリプト
  • 2のZshスクリプト
  • 2つのデータベース
  • 10のSystemd unit

である。 1万行超あるだけに、私としては珍しい規模のものだ。

さらに、ベースになっているデータと、ここからデータベースを生成するスクリプト、そしてサーバーの構築/操作を行うための7つのスクリプトがある。

サーバーはArch Linuxであり、Systemdに依存している。 サービスはConoHa VPS上で展開されたが、Systemd, Ruby, Zsh, Popplerを揃えられれば他のVPSでも動作させられる。

PDFの生成はCairo/Pangoライブラリを使用している。 ページ単位で生成し、Poppler(pdfunite)で結合している。

大枠としては、Systemdでデーモン起動/コントロールを行っており、並列性もSystemdが複数のワーカーを起動することによって実現している。

各ワーカーのコントロールにはRubyのTupplespaceライブラリ、“Rinda”を使用している。 標準添付ライブラリでありながら存在感のないRindaだが、私は結構多用する。

構造的に待ち合わせはあまりしない。 各ワーカーは自分がすべきことをtupplespaceから取り、処理を終えたら次のステップのワーカーに向けてtupplespaceに書き込む。

起点になるプログラムはSystemd Timerによって起動される。このプログラムは処理対象のファイルを処理ファイル用ディレクトリに移動し、tupplespaceに書き込む。

ソースファイル/splitting

ソースファイルは基本的にCSVファイルである。 UTF-8/LF/tabのideal CSVと、Excelの「Unicodeテキスト」形式に従ったUTF-16LE/CRLF/tabのExcel CSVの2種類をサポート。

さらに、動作は保証されていないが、Excelシートもサポートされている。

当初スキームファイルを書く形式も想定されていたが、結局リリース時にはカラムごとに値が固定されるようになった。

Excelファイルの処理にはexcel2csvを使用しており、これが将来的に安定してサポートされると考えることができないため、保証されていないのである。 例えばLibreofficeを使って変換することはできるが、計算量が大幅に増えてしまうため採用していない。

初期段階で行うことは、ソースファイルを解析してRubyオブジェクトに変換すること、 そして、「並列数と同数に分割してRuby Marshal形式で保存すること」である。

並列数と同数に分割するのは、各ワーカーの処理単位にするためだ。 つまり、次のステップではワーカーは1つのRuby Marshalファイルを読み込んで処理する。 ワーカーはtupplespaceに書き込まれるのを待って、仕事をし、仕事をしたらtupplespaceを見るという繰り返しであり、同ステップにおけるワーカーは複数起動されているので、最初の分割数がイコール並列数になるのだ。

この分割数は契約プランによるため、設定ファイルを読んで行う。 ただし、1ワーカーあたりの処理力が秒間1.5万件ほどあるので、分割する最少数として10000が設定されている。 これは、これを下回るように分割しないという意味であり、最大で19999件まで1ファイルになるということである。 25000件のソースファイルであれば12500件の2ファイルに分割される。

処理そのものはほぼリニアにスケールするが、あまり細かく分割することは推奨されない。 というのは、RindaはFIFOにはなっていないので、たくさんの入力があるときに大量に分割してしまうと運が悪いといつまでたっても完了しないという状況が発生するリスクが高まる。 あまりにも入力が多くなると、大きいファイルはそれだけ時間がかかるので確率自体は変わらない。 だが、「入力ソースが大きくて少なく、分割数が非常に多い場合」には例えば2つのソースファイル、20のワーカー、1000の分割数となったときに、分割しなければ2つのソースファイルが両方処理されて小さいソースファイルは早く終え、大きいソースファイルが時間がかかるのに対し、細かく分割すると分割されない小さなソースファイルは1/1000の確率を引かない限り処理されないという事態になる。 全体的には、「自分のファイルサイズが処理時間に反映されにくくなる傾向が強まる」という問題である。

このことから大きなファイルを入力するユーザーは分割数の多いプランを契約することで、空いているときに確実に高速に処理され、混雑時も少し優遇される。 システムのスループット的には分割数はほとんど影響を与えない。

住所解析

Plutoにとって最大の機能がこの住所解析である。

まず前提として、Plutoは日本郵便が公開しているCSVファイルを元にしたデータベースを持っている。 これは、単純にデータ変換したわけではなく、そのデータから推測可能なデータを補完し、データを整形したものである。

使ったことある人は知っているかと思うが、このCSVは非常にdirtyである。 例えばこんな感じだ。

01102,"001  ","0010000","ホッカイドウ","サッポロシキタク","イカニケイサイガナイバアイ","北海道","札幌市北区","以下に掲載がない場合0",0,0,0,0,0,0
01102,"060  ","0600810","ホッカイドウ","サッポロシキタク","キタ10ジョウニシ(5-11チョウメ)","北海道","札幌市北区","北十条西(5~11丁目)",1,0,1,0,0,0

そのため、これをそのまま使うことはできない。 さらに、郵便番号ベースであるため、住所エレメントが全ては揃わない。さらに欠けもあるようである。

まず、このデータは両面から使われる。 つまり、郵便番号が正しく引ける場合、当該郵便番号の住所とのマッチングを行う。この場合、表記ゆれもなるべく拾うようにする。この場合、表記ゆれは注意と共に正しい住所の提示、非マッチは警告。 郵便番号が正しく引けない場合は住所を推測する。正しく引けない場合はその時点で警告対象だが、これはその後の流れのためだ。

住所はその処理として

  • 適切な折返しポイントの設定
  • ノイズの除去

の2点がある。

これは住所要素を

  • 住所
  • 地番
  • 建物名等

の3要素に分け、地番までを住所1、それ以降を住所2とする。 ソースデータに住所1, 住所2のカラムが存在し、この処理をスキップする設定もあるが、通常はそれらは結合された状態で再度判定して分割する。 これは、「一般に住所データはきちんとしたルールに基づいて入力されていないため」である。

こうしたことから最も重要になるのは、「地番の検出」である。 そして、地番を検出するために住所を正確に推測することが必要になる。

郵便番号が検証でき、なおかつ郵便番号に紐づく住所と一致しない場合、いずれにせよ警告される。 ただし、

  • 警告はエラーの内容を詳細にreportする
  • 可能な限り修正してDWIM1する

マッチングできない場合、この検出に住所要素を1文字ずつ適用するという動作をする。 ここでは表記ゆれは無視する。

こうした形で検出された「住所」の末尾をendpointとして定義する。 このendpointのあとに地番要素が来る必要があり、後続要素が地番であると認識できない場合は警告される。 先程から「警告」とひとまとめに言っているが、info, notice, warning, error, criticalの5段階が設定されており、warning以下に関してはユーザーに提示するログに記録されるだけで、処理を放棄することはしない。

ここからが本題である

endpointに地番が継続しているとみなせない場合、extenderという機能が使われる。 extenderは3系統あり、

  • none
  • control
  • extreme

である。

このうちnoneはendpointを動かすことはしない。つまり、endpointの1が住所1で、endpointからうしろは地番がない住所2であるとみなす。

control系列は明確でシンプルなルールによって拡張する。以下はドキュメントから

「住所でない」とみなされる文字
spaces スペース文字
alnumeric 数字(漢数字 含む)
alnumeric_space 数字(漢数字 含む)またはスペース文字
latin_alnumeric 数字(漢数字 除く)
latin_alnumeric_space 数字(漢数字 除く)またはスペース文字
non_japanese ひらがな、カタカナ、漢字 以外
non_japanese_or_spaces ひらがな、カタカナ、漢字、スペース文字 以外
non_hiragana_kanji ひらがな、漢字 以外
non_hiragana_kanji_or_spaces ひらがな、漢字、スペース文字 以外
non_ideographic 漢字 以外
non_ideographic_or_spaces 漢字とスペース 以外

そもそもノイジーな要素を取り除いてできるだけ「よきにはからう」ようになっているため、例えば

神奈川県横浜市鶴見区

という郵便番号から引けるエレメントが

神奈川県 横浜市 鶴見区

のように半角/全角のスペースが混ざり込んでいるような場合でもスペースを除去して処理するようになっている。 スペースがスプリッタになるものに関してはこの点を考えたものである。住所エレメント中にスペースが入らないものに関してはスペースがスプリッタになるルールのほうが適切に機能するわけだ。

extreme系列は全く異なる考え方だ。

extreme1は「人間が未知の住所を与えられたときに、どのようにして解釈するか」という心理的なモデルに基づいている。 例えば

大銀河県銀河市太陽区町川町三村下ニ銀河ビルディング305

が与えられたとき、「そんな住所は存在しない」という「知識ベースの処理」を不可としたときに、人はこの条件で地番を “2” であると認識できる(もちろん、町川町 “3” であり、建物が「村下ニ銀河ビルディング」である可能性は否定できないが、可能性はより低い)。

extreme1の実際の挙動はコード内に次の記述がある。

=begin
- 既に飲み込んだ文字列の長さ knownlength をメモ
- 飲み込んだ分を含む完全なアドレス joined_full を作成
- joined_full から https://ja.wikipedia.org/wiki/%E3%81%B2%E3%82%89%E3%81%8C%E3%81%AA%E3%83%BB%E3%82%AB%E3%82%BF%E3%82%AB%E3%83%8A%E5%9C%B0%E5%90%8D に基づいて非漢字地名を同一文字数の漢字に置換えた ideographic_full を作成
- ideographic_full から非漢字(スペースでもない)が登場する最初の位置を endpoint_border としてメモ
- n丁目/番地 (漢数字) を検索し、 endpoint_border よりも手前であれば更新
- ideographic_fullから 都道府県, 市, 区, 町と大きい行政区分から順に検索する。 このとき前回マッチが成功していればその位置からオフセットして開始し、そのマッチが規定文字数の範囲になくマッチする場合は失敗とする。例えば x町 で 2..4 であれば、 町田 (1) や 銀河高速町 (5) は失敗する。 また、endpoint_borderを越えた場合も失敗する。
- current_index < knowlength であれば current_index = knowlength に更新
- endpoint_borderとの間に残っているのが漢数字だけであるならばそこで終了。 そうでない場合はendpoint_borderまで拡張
- 飲み込んだ分の末尾の漢数字の文字数だけインデックスを吐き戻す
- current_indexをposとして返す
=end

同一文字数の仮想文字列を生成して位置ベースで処理する、というRubyとしてはちょっと変わった手法を織り交ぜている。

extreme2はより単純明快。 Mecab/IPAdic NEologdを用いる。

神奈川県横浜市西区みなとみらい2-2-1
神奈川県横浜市西区みなとみらい 名詞,固有名詞,地域,一般,*,*,神奈川県横浜市西区みなとみらい,カナガワケンヨコハマシニシクミナトミライ,カナガワケンヨコハマシニシクミナトミライ
2   名詞,数,*,*,*,*,*
-2  名詞,固有名詞,一般,*,*,*,−2,マイナスニ,マイナスニ
-1  名詞,固有名詞,一般,*,*,*,−1,マイナスイチ,マイナスイチ
EOS

extreme3は学習的データベースを使用する。 これは一般的な機械学習手法だが、学習量が足りないため、実用になることなく退任した。

住所という非常に限定された条件下で、IPAdic NEologd以上の精度が出る可能性は低く、正直なところextreme3は試してみたというニュアンスが強い。実際、extreme3に関してはドキュメントにも一切掲載されていない。 (extreme2ですらベータドキュメントにしか掲載されていない)

なぜここまでして住所分割にこだわるのか、というと、Plutoでは一切の文字列折返しが禁じられているためである。 住所1と住所2はそのままaddress line 1, line 2の関係であり(短ければ結合されるが)、そこで確実に改行され、また1行に収まるように縮小される。 要求として正確な警告と修正、そして自動的に最適な分割を行うことが求められたため、この点に関して特別な手が入っているわけである。

さらに、住所は備考が付与されている場合があるということで、ゴミの除去をmecab-ipadic-neologdを利用して行う。

ボックスレイアウティング

値が整ったらレイアウトである。

横書きの場合はごく単純に並べていくだけなのだが、縦書きだとかなり都合が違う。

まず、Pangoの縦書きは、gravityという、文字ごとにどちらを下にするかという属性と、ボックスの回転(rotate)を組み合わせて行う。 だが、これはBlinkが持っているような自然な縦書き機能ではなく、割と難しい要素がある。

あまり思い出したくもない不快な経験だが、PangoGravityHintによっていかにテキストを描画すべきかの調整も行っている。

住所2に関しては「下揃えしてほしい」という要求があったため、rotateする量が異なっているのだが、これがまた思わぬ問題を生じていたりもする。

基本的にはPangoLayoutを利用して一度素で書いた描画サイズを元に紙面に収まるようにフォントサイズを落とす。 つまり、基本的には描画を2回行う。元々フォントサイズの調整は計算的に出すのが(私の数学量では)難しく、ループで調整していたのだが、PangoLayoutを使うようになって計算量もわずかながら減った。

特殊な処理を行っているのが氏名である。

氏名に関しては基本的には均等割付なのだが、Pangoのjustifyレイアウトが日本語では全く実用にならないのである。 しかも、そもそもword wrapに対して働くもので、行溢れしない場合は機能しないものなので、氏名については1文字ずつ分解して独立したボックスとして描画するようにしている。

なお、このために氏名のフォントサイズは、当該フォントにおいて高さが特に高いグリフを元に、グリフサイズと縦に欲しいスペースを仮想グリフボックスとみなし、この仮想グリフボックスを並べたときに溢れない大きさに調整する。 これは、PangoLayoutのサイズに基づいて処理するようになる前のフォントサイズ調整に共通の仕様である。

さらにスペースをあけるべき場所はスペース文字が入っているのだが、スペース文字前後の空間は圧縮されるようになっており、その圧縮された割合を含めて均等になるように調整している。 簡単な人にとっては簡単だろうが、私にとってはわりかし難解な計算である。

unification

レイアウターは1ページずつの紙面PDFを吐く。 ワーカーが出力するPDFは、分割された1つのRuby Marshalファイル分である。 そして全てのPDFを出力し終えたら、指定ページ数ごとにまとめたPDFを生成する処理に入る。

だが、ここで問題がある。レイアウターまでの流れは待ち合わせは必要なかったのだが、結合に関しては全てのページを吐いている状態である必要があるのだ。

そこで、直接uniterに渡すのではなく、layouerがtupple spaceに書いた内容は、unite checkerというワーカーが拾う。layouterがtupple spaceに自分が完了したプロジェクトを書き込むと、unite checkerが同プロジェクトの全ファイルが揃っているかを確認する。 揃っていればtupple spaceに対してuniterに結合を指示する内容を書き込むが、揃っていなければそのまま無視する。 uniteはプロジェクトあたり直列の処理になっている。そうせざるをえないというのもあるが、ここに関してはプロジェクト単位での並列性でもあまり支障がないというのもある。

PDFの結合はPoppler(pdfunite)を使用している。

Uniterが仕事を終えると、そのまま次の処理に移る。 つまり、生成された結合済みPDFをユーザーがダウンロードできる場所に移動し、後述する処理中のドキュメントソースを削除し、不要になった処理データを削除し、notificator向けの指示をtupple spaceに書き込む。

処理時間は layout > parse > unite であり、このうちlayoutだけがプロジェクト内で並列化されているということである。 実のところスループット的にはプロジェクト単位の並列化はしないほうが良いのだが、「すいてるときには早く完了したい」という要望もあり、効果の大きいlayouterの並列化を行っているのだ。

UI

一般的にはウェブインターフェイスなのだろうが、扱うのが高度機密データである可能性があるということで、ごく高いセキュリティが要求された。

そして、やることとしては、ソースファイルと指示ファイルのふたつをアップロードし、生成されたファイルをダウンロードする、という流れだけである。

このために高度なセキュリティを持つウェブアプリケーションを制作するのはバカバカしいし、そもそもパスワードなんて保持したら脆弱性以外の何者でもない。

このことから、PlutoのインターフェイスはSFTPになっている。

OpenSSHのInternal SFTPを利用しており、chrootedだ。 利用方法はECDSAまたはED25519に限られており、もちろんSFTP以外の一切の利用ができない。

ユーザー作成、及び鍵登録は私以外でもできるようになっており、新規契約時の処理は私が手動でやる必要はない。 また、鍵の追加登録が可能で、利用規約として鍵の複数マシン間の共有は禁止している。

指定されたディレクトリにディレクトリを作って指示ファイルとソースファイルを置くと、systemd timerによって起動されるpickerがこれを拾い上げて作業ディレクトリに移動させる。 最終的な出力は出力用のディレクトリに置かれる。 これは、完成したPDFのほか、Plutoシステムによって整形されたCSVファイル、警告など確認すべき事項が記載されたログファイル、そして中止された場合は詳細なエラーに関するログファイルなどが置かれる。

ソースファイルのディレクトリ以外はroot:root 755ディレクトリであり、またファイルもroot:root 644であるため、あくまでダウンロードできるに過ぎない。 これらのファイルは定期的に(systemd timerによって起動されるプログラムにより)削除される。

pickerはアップロード時にまだアップロード中のファイルを拾ってしまわないため、前回の巡回で見つけたファイルのサイズをメモしている。 そして、前回と同じファイルサイズのファイルのみで構成されるディレクトリを見つけた場合にpickするわけである。

また、pickerはディレクトリ内にPlutoのソースと関係ないファイルを置いた場合に削除するという仕事もしている。

起動と停止

ゆるやかな連携による非同期処理は、様々な面でメリットがあるが、起動と停止はとてもむずかしくなる。

Plutoはこの問題を、「pickerがpickしたファイルを消すのはuniter」という手順にすることで解決している。

つまり、最初の段階のソースファイルは、最終出力を終えたあとにしか消されない。

これによって、システムを停止するときには何の配慮もいらず、いきなり停止して構わない。

起動/再起動には、まず最初にQueueserver (実態としては、Rindaサーバー)を起動させ、このあとcleanerを起動する。 cleanerは処理ディレクトリ上にある全てのファイルを削除し、最初のソースファイルが残っている場合はそのソースファイルのparseを指示する内容をtupple spaceに書き込む。

当初、処理したデータを一段階前まで巻き戻す、という方法で停止できるようにしていたのだが、それと比べてロスは発生するものの、非常にわかりやすく安定した運用ができる方法である。

cleanerの処理が終わったらsystemctl restart pluto-system.targetである。

むすび

いかがだっただろうか。

開発自体は私ひとりでやっているため、基本的に「設計偏重」という私の考え方が強く出ている。

特徴的なのは、各コンポーネント、各機能が直交している、ということである。

全体の設計を考えるとなかなか複雑だが、各機能や単一のコンポーネントについて考えるとき、今見ているもの以外について考える必要がない。

例えば、住所を住所1/2に切り直す機能についても、AddressProcessorというmoduleになっており、ファイル的にも独立している。そして、AddressProcessor#proc_addressを呼び出すだけで、現在のオブジェクトが保有している住所要素の値が修正される。

各ワーカーはtupple spaceから取り出して、自分がすることを終えたらtupple spaceに書き込むだけであるから、前後のワーカーの事情は一切気にする必要がないし、並列性に関しても気にする必要はない。 並列性を保証する方法は設計によって単純に保証されており、ワーカーの実装の変更によって破綻することはないし、並列数を増やしたい場合は単純にSystemdを使って起動数を増やせばいい(各ワーカーは@serviceユニットになっている)。

より効率的な方法がある場合でも、より単純な方法を採用するようにしている。 また、状況に対して個別に対応したほうが効率的だとしても、より統一的な方法で扱えるようにしている。 これは、ひとりで全部やる以上テストに割ける時間が限られていることもあり、ミスが発生する余地を減らすという意味もあるが、タイミングの問題で「たまたま処理できなかった」というようなトラブルを避けるためにも「丈夫な」システムを作る上で「単純で間違いようのない設計」を重視したのである。

こうした考え方は、UNIXの歴史の中で培われた教訓を活かしている。 また、Jargonfileから読み取ることのできるhacker文化の中で培われた価値観も反映しており、サービスとしてのスループット値として1分間で300万件もの宛名PDFを生成することができ、それにしては小さくて扱いやすいものになっている。

実際、Plutoはまともな要件定義もなされなかったことから毎リリースで仕様変更を要求され、その仕様は行ったり来たりした。 だが、そのような無茶苦茶な要求があっても、一両日中にリリースできるほどメンテナンス性の良いシステムであり、リリースもバージョンアップも特に難しい問題なく気軽に行えるし、テストもしやすい。 こうした「小回りがきく」設計は、全部ひとりでやっている状況ではひとりで回すために極めて重要な要素となる。

Plutoは技術的に面白い要素が多く、語り尽くせないくらいであるが、とりあえず一端ここで締めよう。