📝ParsletによるPEGパーサ

mzpです。 先日、北海道旅行に行きました。

先日文書番号ルールの設定機能をリリースしました。 今回は、その実装に利用したパーサライブラリParsletを紹介します。

✨文書番号ルールの設定機能

f:id:mzp:20170628130956p:plain

文書番号ルールの設定機能は、請求書などの右上に記載される番号の初期値を設定できる機能です。

このルールは{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では構文解析に興味あるエンジニアを募集してます。