Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Đơn Giản Hóa Regex Phức Tạp: Thay Thế Bởi Parser Đơn Giản, Dễ Đọc

Thú nhận: tôi không đặc biệt thích làm việc với regular expressions. Mặc dù tôi dùng chúng luôn, bất cứ thứ gì phức tạp hơn /^foo.*$/ đều khiến tôi phải dừng lại và suy nghĩ. Mặc dù tôi chắc chắn có những người có thể giải mã các biểu thức như \A(?=\w{6,10}\z)(?=[^a-z]*[a-z])(?=(?:[^A-Z]*[A-Z]){3}) chỉด้วย một cái nhìn, nhưng nó mất tôi vài phút google và khiến tôi không vui. Nó khác hoàn toàn với việc đọc Ruby.

Nếu bạn tò mò, ví dụ trên được lấy từ bài viết này về regex lookaheads.

Tình Huống

Tại Honeybadger, tôi hiện đang làm việc cải thiện search UI của chúng tôi. Giống như nhiều hệ thống search, của chúng tôi sử dụng một query language đơn giản. Trước khi tôi thay đổi, nếu bạn muốn tìm kiếm một custom date range, bạn phải thủ công gõ query như sau:

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

Đau đớn!

Trong search UI mới, chúng tôi muốn detect khi bạn bắt đầu gõ một date-related query và bật lên một datepicker hữu ích. Và datepicker chỉ là khởi đầu. Cuối cùng chúng tôi sẽ mở rộng context-sensitive hinting để cover nhiều loại search terms hơn. Đây là một vài ví dụ:

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

Tôi cần tokenize các chuỗi này sao cho:

  • Whitespace tách token, trừ khi được bao quanh bởi '', "" hoặc []
  • Unquoted whitespace là token riêng
  • Tôi có thể chạy tokens.join("") để tái tạo chính xác chuỗi đầu vào

Ví dụ:

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

Sử Dụng Regular Expression

Ý nghĩ đầu tiên của tôi là dùng capturing regular expression để định nghĩa token hợp lệ trông như thế nào, sau đó dùng String#split để tách chuỗi thành tokens. Đây thực sự là một trick khá cool:

# Parens trong regexp nghĩa là separator sẽ được thêm vào mảng
"foo bar baz".split(/(foo|bar|baz)/) => ["", "foo", " ", "bar", " ", "baz"]

Trông đầy hứa hẹn ban đầu, несмотря vào những empty-string kỳ lạ. Nhưng regular expression thực tế của tôi phức tạp hơn nhiều. Bản nháp đầu tiên trông như thế này:

/
  ( # Capture group để split bao gồm cả matching và non-matching strings
    (?:
      # Ký tự đầu tiên của key, là
      (?!\s)[^:\s"'\[]{1}
      # ..bất kỳ "key" char hợp lệ nào không được tiền tố bởi whitespace
      |^[^:\s"'\[]{0,1}
      # ..hoặc bất kỳ "key" char hợp lệ nào ở đầu dòng
    )
    [^:\s"'\[]* # Phần còn lại của "key" chars
    : # một colon
    (?:
      # "value" chars, là
      '[^']+' # ..bất cứ thứ gì bị bao quanh bởi single quotes
      | "[^"]+" # ..hoặc bất cứ thứ gì bị bao quanh bởi double quotes
      | \[\S+\sTO\s\S+\] # ..hoặc bất cứ thứ gì như [x TO y]
      | [^\s"'\[]+ # ..hoặc bất kỳ string nào không chứa whitespace hoặc special chars
    )
  )
/xi

Làm việc với cái này khiến tôi cảm giác chìm đắm. Mỗi lần tôi tìm thấy edge case, tôi phải sửa regular expression, làm nó còn phức tạp hơn. Ngoài ra, nó cần hoạt động cả trong JavaScript lẫn Ruby, nên các tính năng như negative lookbehind không khả dụng.

...Đúng lúc này sự ngớ ngẩn của tất cả này đánh trúng tôi. Cách tiếp cận regular expression tôi đang dùng phức tạp hơn nhiều so với việc viết một parser đơn giản từ đầu.

Cấu Trúc Của Một Parser

Tôi không phải chuyên gia, nhưng parser đơn giản thì đơn giản. Tất cả những gì chúng làm là:

  • Duyệt qua chuỗi, ký tự bởi ký tự
  • Gắn từng ký tự vào buffer
  • Khi gặp điều kiện tách token, lưu buffer vào mảng và xóa nó.

Biết điều này, chúng ta có thể thiết lập một parser đơn giản tách string bằng whitespace. Nó đại khái tương đương với "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"]

Đây là bước tiến về phía những gì tôi muốn, nhưng nó thiếu hỗ trợ cho quotes và brackets. May mắn thay, thêm chúng chỉ mất vài dòng code:


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
    end
    else
      flush() if previous().match(WHITESPACE) && !surround
      @buffer << c
    end
  end
  flush
  @output
end

Code này chỉ dài hơn một chút so với cách tiếp cận regular-expression của tôi nhưng đơn giản, dễ hiểu hơn rất nhiều.

Su Nghĩ Của Tôi

Có lẽ có một regular expression nào đó sẽ hoạt động tốt với use case của tôi. Nếu lịch sử là hướng dẫn, nó có thể đủ đơn giản để làm cho tôi trông như một kẻ ngốc. :)

Nhưng tôi thực sự tận hưởng cơ hội viết parser nhỏ này. Nó giúp tôi thoát khỏi cái bế tắc tôi gặp với cách tiếp cận regex. Lợi ích phụ hay, tôi tự tin hơn nhiều vào code kết quả so với code dựa trên regular expressions phức tạp.