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

Khám phá Mỏ Ngọc Ẩn của Ruby: StringScanner cho Text Parsing Mạnh mẽ

Ruby không chỉ là ngôn ngữ vui, nó cũng đi kèm với một thư viện chuẩn tuyệt vời. Một số trong đó không quá nổi tiếng, và gần như là hidden Gems (mỏ ngọc ẩn). Hôm nay tác giả khách Michael Kohl nổi bật một favorite: StringScanner.

Hidden Gems của Ruby: StringScanner

Có thể đi khá xa mà không cần cài third-party gem, từ data structures như OpenStruct và Set đến CSV parsing và benchmarking. Tuy nhiên, có một số thư viện ít biết hơn trong cài đặt chuẩn của Ruby có thể rất hữu ích, một trong số đó là StringScanner mà theo documentation nó "cung cấp lexical scanning operations trên string".

Scanning và Parsing

Vậy "lexical scanning" có nghĩa là gì chính xác? về cơ bản nó mô tả quá trình lấy một input string và trích xuất các bit thông tin có ý nghĩa từ nó, tuân theo các quy tắc nhất định. Ví dụ, điều này có thể thấy ở stage đầu tiên của compiler lấy expression như 2 + 1 làm input và biến nó thành sequence token sau:

[{ number: "1" }, {operator: "+"}, { number: "1"}] 

Lexical scanners thường được triển khai như finite-state automata và có một số tool nổi tiếng có thể generate chúng cho chúng ta (ví dụ ANTLR hoặc Ragel).

Tuy nhiên, đôi khi nhu cầu parsing của chúng ta không phức tạp đến vậy, và một thư viện đơn giản hơn như StringScanner dựa trên regular expression có thể rất handy trong những tình huống như vậy. Nó hoạt động bằng cách nhớ vị trí của một scan pointer (con trỏ quét) mà đơn giản chỉ là index vào string. Quá trình scanning sau đó cố gắng match code ngay sau scan pointer với expression được cung cấp. Bên cạnh matching operations, StringScanner cũng cung cấp phương thức để di chuyển scan pointer (tiến hoặc lùi qua string), looking ahead (xem cái tiếp theo mà không modify scan pointer) cũng như tìm ra chúng ta đang ở đâu trong string (có phải beginning hay end của line/cả string v.v.).

Parsing Rails Logs

Đủ lý thuyết, hãy xem StringScanner hoạt động. Ví dụ sau sẽ lấy một log entry của Rails như bên dưới,

log_entry = <<EOS Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900 Processing by HomeController#index as HTML Rendered text template within layouts/application (0.0ms) Rendered layouts/_assets.html.erb (2.0ms) Rendered layouts/_top.html.erb (2.6ms) Rendered layouts/_about.html.erb (0.3ms) Rendered layouts/_google_analytics.html.erb (0.4ms) Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms) EOS

và parse nó thành hash sau:

{ method: "GET", path: "/" ip: "127.0.0.1", timestamp: "2017-08-20 20:53:10 +0900", success: true, response_code: "200", duration: "79ms", }

NB: Mặc dù đây là ví dụ tốt cho StringScanner, ứng dụng thực tế sẽ tốt hơn dùng Lograge và JSON log formatter của nó.

Để dùng StringScanner chúng ta trước hết cần require nó:

require 'strscan'

Sau đó chúng ta có thể initialize instance mới bằng cách truyền log entry làm argument cho constructor. Cùng lúc đó chúng ta cũng định nghĩa empty hash để hold kết quả parsing:

scanner = StringScanner.new(log_entry) log = {}

Chúng ta có thể dùng phương thức pos của scanner để lấy vị trí hiện tại của scan pointer. Như mong đợi, kết quả là 0, ký tự đầu tiên của string:

scanner.pos #=> 0

Hãy visualize để quá trình dễ theo dõi hơn:

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900 ^ ... Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms) 

Để introspection thêm về state của scanner, chúng ta có thể dùng beginning_of_line? và eos? để confirm scan pointer hiện tại ở beginning of line và chúng ta chưa fully consume input:

scanner.beginning_of_line? #=> true scanner.eos? #=> false

Thông tin đầu tiên chúng ta muốn trích xuất là HTTP request method, nằm ngay sau từ "Started" theo sau bởi space. Chúng ta có thể dùng phương thức skip của scanner để advance scan pointer, trả về số ký tự bị ignore, trong trường hợp là 8. Thêm vào đó chúng ta có thể dùng matched? để confirm mọi thứ hoạt động như mong đợi:

scanner.skip(/Started /) #=> 8 scanner.matched? #=> true

Scan pointer bây giờ nằm ngay trước request method:

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900 ^ ... Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms) 

Bây giờ chúng ta có thể dùng scan_until để trích xuất giá trị thực, trả về entire regex match. Vì request method toàn uppercase, chúng ta có thể dùng character class đơn giản và operator + match một hoặc nhiều ký tự:

log[:method] = scanner.scan_until(/[A-Z]+/) #=> "GET"

Sau operation này scan pointer sẽ ở cuối "T" của từ "GET".

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900 ^ ... Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms) 

Để trích xuất requested path, chúng ta sẽ cần skip một space và trích xuất mọi thứ trong double quotes. Có vài cách, một trong đó là qua capture group (phần regex trong ngoặc, tức (.+)) match một hoặc nhiều ký tự bất kỳ:

scanner.scan(/\s"(.+)"/) #=> " \"/\""

Tuy nhiên, chúng ta sẽ không dùng return value của operation scan này trực tiếp, mà dùng captures để lấy giá trị của capture group đầu tiên:

log[:path] = scanner.captures.first #=> "/"

Chúng ta đã trích xuất thành công path và scan pointer bây giờ ở closing double quote:

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900 ^ ... Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms) 

Để parse IP address từ log, chúng ta dùng skip để ignore string "for" bao quanh bởi space và dùng scan_until match một hoặc nhiều non-whitespace characters (\s là character class cho whitespace và [^\s] là negation):

scanner.skip(/ for /) #=> 5 log[:ip] = scanner.scan_until(/[^\s]+/) #=> "127.0.0.1"

Bạn có thể nói scan pointer sẽ ở đâu bây giờ? Nghĩ một chút rồi so sánh với solution:

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900 ^ ... Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms) 

Parsing timestamp nên quen thuộc. Đầu tiên dùng skip ignore literal string " at " rồi scan_until đọc đến end of line, được biểu diễn bởi $ trong regex:

scanner.skip(/ at /) #=> 4 log[:timestamp] = scanner.scan_until(/$/) #=> "2017-08-20 20:53:10 +0900"

Thông tin tiếp theo chúng ta quan tâm là HTTP status code trên dòng cuối, nên dùng skip_until đưa chúng ta đến space sau từ "Completed".

scanner.skip_until(/Completed /) #=> 296

Như tên gợi ý, điều này hoạt động giống scan_until nhưng thay vì trả về matched string, nó trả về số ký tự bị skip. Điều này đặt scan pointer ngay trước HTTP status code chúng ta quan tâm.

Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900 ... Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms) ^ 

Trước khi scan HTTP response code, liệu có hay không nếu chúng ta có thể biết response code chỉ success (2xx range) hay failure (các range khác)? Để làm vậy, chúng ta dùng peek xem ký tự tiếp theo, không move scan pointer.

log[:success] = scanner.peek(1) == "2" #=> true

Bây giờ chúng ta dùng scan đọc 3 ký tự tiếp theo, biểu diễn bằng regex /\d{3}/:

log[:response_code] = scanner.scan(/\d{3}/) #=> "200"
Started GET "/" for 127.0.0.1 at 2017-08-20 20:53:10 +0900 ... Completed 200 OK in 79ms (Views: 78.8ms | ActiveRecord: 0.0ms) ^ 

Bit cuối cùng chúng ta muốn trích xuất là execution time in milliseconds, đạt được bằng skip qua string " OK in " rồi đọc mọi thứ đến và bao gồm literal string "ms".

scanner.skip(/ OK in /) #=> 7 log[:duration] = scanner.scan_until(/ms/) #=> "79ms"

Với bit cuối cùng đó, chúng ta có hash chúng ta muốn.

{ method: "GET", path: "/" ip: "127.0.0.1", timestamp: "2017-08-20 20:53:10 +0900", success: true, response_code: "200", duration: "79ms", }

Tóm tắt

StringScanner của Ruby chiếm middle ground đẹp giữa simple regular expressions và full-blown lexer. Nó không phải lựa chọn tốt cho complex scanning/parsing. Nhưng bản chất straightforward của nó làm cho nó dễ dùng cho mọi người có basic regex knowledge để trích xuất thông tin từ input string và tôi đã dùng thành công trong production code. Hy vọng bạn sẽ khám phá hidden Gem này.

PS: Cho chúng ta biết bạn nghĩ hidden Gems nào chúng ta nên highlight tiếp!