Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

ลดความซับซ้อนของ Regex: แทนที่ด้วย Parser ที่เรียบง่าย อ่านง่าย

ขอ कबूल: ผมไม่ค่อยชอบทำงานกับ Regular Expressions แม้จะใช้ตลอดเวลา แต่วันที่ซับซ้อนกว่า /^foo.*$/ ก็ต้องหยุดคิดนาน แม้จะมีคนอ่าน Expression แบบ \A(?=\w{6,10}\z)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3}) ได้ในทันที แต่ผมใช้เวลาหลายนาที Googling และทำให้ผมไม่มีความสุข มันต่างจากการอ่าน Ruby มาก

ถ้าสนใจ ตัวอย่างข้างต้นมาจากบทความเกี่ยวกับ Regex Lookaheads

สถานการณ์

ที่ Honeybadger ผมกำลังปรับปรุง Search UI ของเรา ในระบบ Search หลายๆ ระบบ รวมถึงของเรา ใช้ Query Language เรียบง่าย ก่อนที่ผมจะเปลี่ยน ถ้าอยาก Search Custom Date Range ต้องพิมพ์ Query เองแบบนี้:


occurred:[2017-06-12T16:10:00Z TO 2017-06-12T17:10:00Z]

เจ็บ!

ใน Search UI ใหม่ เราต้องการ Detect ตอนเริ่มพิมพ์ Date-related Query แล้วโชว์ Datepicker ช่วยเหลือ และ Datepicker 只是 เริ่มต้น ต่อไปเราจะขยาย Context-sensitive Hinting ครอบคลุม Search Terms มากขึ้น นี่คือตัวอย่าง:


assigned:jane@email.com context.user.id=100 resolved:false ignored:false occurred:[ params.article.title:"Starr's parser post" foo:'ba

ผมต้อง Tokenize Strings เหล่านี้ให้:

  • Whitespace แยก Tokens เว้นแต่วรอบด้วย '', "" หรือ []
  • Unquoted Whitespace เป็น Token ของตัวเอง
  • รัน tokens.join("") ได้ Input String เดิมทุกตัว

ตัวอย่าง:


tokenize(%[params.article.title:"Starr's parser post" foo:'ba]) => ["params.article.title:\"Starr's parser post\"", " ", "foo:'ba"]

ใช้ Regular Expression

ความคิดแรกคือ用 Capturing Regex กำหนด Valid Token 長什么样 แล้ว用 String#split แยก String เป็น Tokens มันเป็น Trick ค่อนข้าง Cool:


# Parentheses ใน Regexp ทำให้ Separator เพิ่มเข้า Array ด้วย
"foo bar baz".split(/(foo|bar|baz)/) # => ["", "foo", " ", "bar", " ", "baz"]

ดูมีหวัง แต่ Real-world Regex ของผมซับซ้อนมาก Draft แรกเป็นแบบนี้:


/ (
  # Capture group ทำให้ split รวม matching และ non-matching strings
  (?:
    # ตัวอักษรแรกของ Key ซึ่งคือ
    (?!\s)[^:\s"'\[]{1}
    # ..ตัวอักษร "key" ใดๆ ที่ไม่มี whitespace นำหน้า
    |^[^:\s"'\[]{0,1}
    # ..หรือตัวอักษร "key" ใดๆ ที่ต้นบรรทัด
  )
  [^:\s"'\[]*  # ตัวอักษร "key" ที่เหลือ
  :            # colon
  (?:
    # ตัวอักษร "value" ซึ่งคือ
    '[^']+'    # ..อะไรก็ได้รอบๆ single quotes
    | "[^"]+"  # ..หรืออะไรก็ได้รอบๆ double quotes
    | \[\S+\sTO\s\S+\] # ..หรืออะไรแบบ [x TO y]
    | [^\s"'\[]+ # ..หรือ string ใดๆ ที่ไม่มี whitespace หรือ special chars
  )
) /xi

ทำงานกับมันรู้สึกท้องเสีย ทุกครั้งเจอ Edge Case ต้องแก้ Regex ทำให้ซับซ้อนขึ้นเรื่อยๆ อีกทั้งต้องทำงานใน JavaScript ด้วย ทำให้ Feature บางอย่าง เช่น Negative Lookbehind ใช้ไม่ได้

...ประมาณเวลานี้ ความไร้เหตุผลของทั้งนี้โจมตีผม Regex Approach ที่ผมใช้ซับซ้อนกว่าเขียน Simple Parser เองจากศูนย์มาก

สรีระวิทยาของ Parser

ผมไม่ใช่ Expert แต Simple Parsers เรียบง่าย ทุกอย่างที่มันทำคือ:

  • Step ผ่าน String ทีละ Character
  • Append แต่ละ Character ไป Buffer
  • เมื่อเจอ Token-separating Condition ให้ Save Buffer ลง Array และล้าง Buffer

รู้แล้ว เราสามารถตั้ง Parser เรียบง่ายที่แยก String ด้วย Whitespace ได้ ประมาณเทียบเท่า "foo bar".split(/(\s+)/)


class Parser
  WHITESPACE = /\s/
  NON_WHITESPACE = /\S/

  def initialize
    @buffer = []
    @output = []
  end

  def parse(text)
    text.each_char do |c|
      case c
      when WHITESPACE
        flush if previous.match(NON_WHITESPACE)
        @buffer << c
      else
        flush if previous.match(WHITESPACE)
        @buffer << c
      end
    end
    flush
    @output
  end

  protected

  def flush
    if @buffer.any?
      @output << @buffer.join("")
      @buffer = []
    end
  end

  def previous
    @buffer.last || ""
  end
end

puts Parser.new().parse("foo bar baz").inspect
# Outputs ["foo", " ", "bar", " ", "baz"]

นี่เป็นขั้นตอนหนึ่งไปสู่สิ่งที่ผมอยาก แต่ยังขาด Quotes และ Brackets โชคดี เพิ่มได้แค่ไม่กี่บรรทัด:


def parse(text)
  surround = nil
  text.each_char do |c|
    case c
    when WHITESPACE
      flush if previous.match(NON_WHITESPACE) && !surround
      @buffer << c
    when '"', "'"
      @buffer << c if !surround
      surround = c
      elsif surround == c
        flush
        surround = nil
      end
    when "["
      @buffer << c
      surround = c if !surround
    when "]"
      @buffer << c if surround == "["
      flush
      surround = nil
    else
      flush() if previous().match(WHITESPACE) && !surround
      @buffer << c
    end
  end
  flush
  @output
end

โค้ดนี้ยาวกว่า Regex Approach เพียงเล็กน้อย แต่น่าอ่านและเข้าใจง่ายมาก

ข้อคิดเหลือ

อาจมี Regex ใดๆ ที่ทำงานได้กับ Use Case ของผม ถ้าประวัติบ่งบอก มันอาจ Simple พอที่จะทำให้ผมดูโง่ :)

แตผมเพลินมากที่ได้เขียน Parser น้อยๆ นี้ มันดึงผมออกจาก Rut ที่ติดกับ Regex Approach โบนัสคือ ผมมั่นใจในโค้ดผลลัพธ์มากกว่าโค้ดที่อิง Complex Regular Expressions มาก