mzpです。 先日、北海道旅行に行きました。
先日文書番号ルールの設定機能をリリースしました。 今回は、その実装に利用したパーサライブラリParsletを紹介します。
✨文書番号ルールの設定機能
文書番号ルールの設定機能は、請求書などの右上に記載される番号の初期値を設定できる機能です。
このルールは{Y}{M}{D}-{連番}
といった形式で指定でき、 {}
内に文字列によって何に置換されるかが決まります。 ここで指定できる文字列には以下のようなものがあります。
文字列 | 意味 |
---|---|
{Y} | 文書作成時の年 (4桁) |
{M} | 文書作成時の月 (2桁) |
{D} | 文書作成時の日 (2桁) |
{連番} | 作成開始時点での文書ごとの連番 |
💬PEGの採用
当初は1文字ずつ見ていく、いわゆる手書きの再帰下降パーサを検討していました。しかし、今後、構文規則が拡張されていくことが予想されたため、パーサライブラリを利用するように方針を切り替えました。
どのパーサライブラリを採用するかも迷いましたが、PEGという構文規則に基づくライブラリが、言語を問わず存在して便利そうだったので採用しました。
このときの会話は以下のようになっています。
📝処理の流れ
文字列のパース
文書番号のルールを記述した文字列をパースし、扱いやすいデータ構造にします。
例えば、{Y}{M}{D}-{連番}
は以下のようなデータ構造になります。
# {Y}{M}{D}-{連番} [ { rule: { identifier: 'Y') } }, { rule: { identifier: 'M') } }, { rule: { identifier: 'D') } }, { word: '-' }, { rule: { identifier: '連番' } } ]
このパーサは以下のように定義しています。
class Parser < Parslet::Parser RESERVED_WORDS = %w({ } Y M D 連番).freeze rule(:word) { match('[^{}[:space:][:cntrl:]]').repeat(1) } rule(:identifier) { RESERVED_WORDS.map { |w| str(w) }.reduce(&:|) } rule(:rule) { str('{') >> identifier.as(:identifier) >> str('}') } rule(:numbering_rule) { (rule.as(:rule) | word.as(:word)).repeat } root :numbering_rule end
パースした結果の変換
{ rule: { identifier: 'Y') } }
といったハッシュのままだと処理しずらいので、文字列を内部的に扱いやすい形に変換します。
先ほどの例は、以下のようになります。
# [ # { rule: { identifier: 'Y') } }, # { rule: { identifier: 'M') } }, # { rule: { identifier: 'D') } }, # { word: '-' }, # { rule: { identifier: '連番' } } # ] [ :current_year, :current_month, :current_date, '-', :document_sequence_number ]
この変換は以下のように定義しています。
class Transform < Parslet::Transform IDENTIFIER = { 'D' => :current_date, 'M' => :current_month, 'Y' => :current_year, '{' => :left_brace, '}' => :right_brace }.freeze rule(word: simple(:s)) { s.to_s } rule(identifier: simple(:i)) { IDENTIFIER[i.to_s] } rule(rule: simple(:r)) { r } rule(rule: sequence(:a)) { a } rule('') { [] } end
文書番号の生成
変換した結果を元に文書番号を生成します。
先ほどの例からは、以下のような文書番号が生成されます。
# [ # :current_year, # :current_month, # :current_date, # '-', # :document_sequence_number # ] 20170630-001
これは文字列ならばそのまま出力する、シンボルならば内部で定義したメソッドを呼ぶ、という形で実装しています。
class Evaluator def execute(rule) transformed = Transform.new.apply(Parser.new.parse(rule)) transformed.each_with_object(::String.new) do |r, s| s << case r when ::String then r when ::Symbol then respond_to?(r, true) ? send(r) : '' end end end private def current_time @current_time ||= Time.current end def current_year current_time.year.to_s end def current_month format('%02d', current_time.month) end def current_date format('%02d', current_time.day) end def document_sequence_number # ... end end
💖感想
Parsletがだいぶ分かりやすかったと思う。 実際、学習を含めても1時間くらいで実装できた。
また、手書きするより見通しがよくなってよかったと思う。
🔊 採用
Misocaでは構文解析に興味あるエンジニアを募集してます。