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

Master Ruby File Handling: Slurping vs Streaming อธิบายครบครัน

ใน Ruby Magic ครั้งนี้ เราจะเรียนรู้เกี่ยวกับ streaming files ใน Ruby ว่า class IO จัดการ reading files โดยไม่ต้อง load ทั้งหมดเข้า memory และมันอ่าน files ต่อบรรทัดโดย buffering read bytes มาดำดิ่งกันเลย!

"Slurping" และ Streaming Files

Ruby's File.read method อ่าน file และคืน full content

irb> content = File.read("log/production.log") => "I, [2018-06-27T16:45:02.843719 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"articles\" for 127.0.0.1 at 2018-06-27 16:45:02 +0200\nI, [2018-06-27T16:45:02.846719 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Processing by ArticlesController#index as HTML\nI, [2018-06-27T16:45:02.848212 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Rendering articles/index.html.erb within layouts/application\nD, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Article Load (0.3ms) SELECT \"articles\".* FROM \"articles\"\nI, [2018-06-27T16:45:02.850901 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Rendered articles/index.html.erb within layouts/application (1.7ms)\nI, [2018-06-27T16:45:02.851633 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Completed 200 OK in 5ms (Views: 3.4ms | ActiveRecord: 0.3ms)\n"

ภายในนี้ เปิด file อ่าน content ปิด file และคืน content เป็น single string ด้วยการ "slurp" file's content ทีเดียว มันถูกเก็บใน memory จนกว่าจะถูก clean up โดย Ruby's garbage collector

เป็นตัวอย่าง สมมติเราอยาก uppercase ทุก characters ใน file และเขียนไปอีก file ด้วย File.read เราสามารถได้ content เรียก String#upcase บน resulting string และส่ง uppercased string ให้ File.write

irb> upcased = File.read("log/production.log").upcase => "I, [2018-06-27T16:45:02.843719 #9098] INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] STARTED GET \"/ARTICLES\" FOR 127.0.0.1 AT 2018-06-27 16:45:02 +0200\nI, [2018-06-27T16:45:02.846719 #9098] INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] PROCESSING BY ARTICLESCONTROLLER#INDEX AS HTML\nI, [2018-06-27T16:45:02.848212 #9098] INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] RENDERING ARTICLES/INDEX.HTML.ERB WITHIN LAYOUTS/APPLICATION\nD, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] ARTICLE LOAD (0.3MS) SELECT \"ARTICLES\".* FROM \"ARTICLES\"\nI, [2018-06-27T16:45:02.850901 #9098] INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] RENDERED ARTICLES/INDEX.HTML.ERB WITHIN LAYOUTS/APPLICATION (1.7MS)\nI, [2018-06-27T16:45:02.851633 #9098] INFO -- : [86A5D18C-19DD-4CBF-9D7A-461C79E98C22] COMPLETED 200 OK IN 5MS (VIEWS: 3.4MS | ACTIVERECORD: 0.3MS)\n" irb> File.write("log/upcased.log", upcased) => 896

แม้จะทำงานสำหรับ small files การอ่าน whole file เข้า memory อาจ problematic เมื่อ deal กับ larger files ตัวอย่างเมื่อ parsing 14-gigabyte log file การอ่าน whole file ทีเดียวจะเป็น expensive operation Content ของ file ถูกเก็บใน memory ดังนั้น app's memory footprint เติบโตมาก อาจนำไปสู่ memory swapping และ OS kill app's process

โชคดีที่ Ruby อนุญาตอ่าน files ต่อบรรทัดด้วย File.foreach แทนที่จะอ่าน file's full content ทีเดียว มันจะ execute passed block สำหรับแต่ละบรรทัด

Result เป็น enumerable ดังนั้นมัน either yields block สำหรับแต่ละบรรทัด หรือคืน Enumerator object ถ้าไม่มี block ส่งไป นี่ช่วยให้อ่าน bigger files ได้โดยไม่ต้อง load all content เข้า memory ทีเดียว

irb> File.foreach("log/production.log") { |line| p line } "I, [2018-06-27T16:45:02.843719 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" for 127.0.0.1 at 2018-06-27 16:45:02 +0200\n" "I, [2018-06-27T16:45:02.846719 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Processing by ArticlesController#index as HTML\n" "I, [2018-06-27T16:45:02.848212 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Rendering articles/index.html.erb within layouts/application\n" "D, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Article Load (0.3ms) SELECT \"articles\".* FROM \"articles\"\n" "I, [2018-06-27T16:45:02.850901 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Rendered articles/index.html.erb within layouts/application (1.7ms)\n" "I, [2018-06-27T16:45:02.851633 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Completed 200 OK in 5ms (Views: 3.4ms | ActiveRecord: 0.3ms)\n"

เพื่อ uppercase whole file เราอ่านจาก input file ต่อบรรทัด uppercase มัน และ append ไปที่ output file

irb> File.open("upcased.log", "a") do |output| irb* File.foreach("production.log") { |line| output.write(line.upcase) } irb> end => nil

ดังนั้น reading file ต่อบรรทัดทำงานอย่างไรโดยไม่ต้องอ่าน whole file ก่อน? เพื่อเข้าใจนั้น เราต้อง剝 layers บางส่วนรอบ reading files มาดู Ruby's IO class ใกล้ชิดกัน

I/O และ Ruby's IO Class

แม้ File.read และ File.foreach จะมีอยู่ documentation ของ File class ไม่ได้ list พวกมัน จริงๆ แล้วคุณจะไม่หา file reading/writing methods ใดๆ ใน File class documentation ได้ เพราะพวกมัน inherited จาก parent IO class

I/O

An I/O device คือ device ที่ transfer data ไปหรือจาก computer เช่น keyboards, displays และ hard drives มัน perform Input/Output หรือ I/O โดย reading หรือ producing streams ของ data

Reading และ writing files จาก hard drive เป็น most common I/O ที่คุณจะเจอ Other types ของ I/O รวมถึง socket communication, logging output ไป terminal และ input จาก keyboard

IO class ใน Ruby handle all input และ output เหมือน reading และ writing ไป files เพราะ reading files ไม่ต่างจาก reading จาก I/O stream อื่น File class directly inherits methods เหมือน IO.read และ IO.foreach

irb> IO.foreach("log/production.log") { |line| p line } "I, [2018-06-27T16:45:02.843719 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" for 127.0.0.1 at 2018-06-27 16:45:02 +0200\n" "I, [2018-06-27T16:45:02.846719 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Processing by ArticlesController#index as HTML\n" "I, [2018-06-27T16:45:02.848212 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Rendering articles/index.html.erb within layouts/application\n" "D, [2018-06-27T16:45:02.850020 #9098] DEBUG -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Article Load (0.3ms) SELECT \"articles\".* FROM \"articles\"\n" "I, [2018-06-27T16:45:02.850901 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Rendered articles/index.html.erb within layouts/application (1.7ms)\n" "I, [2018-06-27T16:45:02.851633 #9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Completed 200 OK in 5ms (Views: 3.4ms | ActiveRecord: 0.3ms)\n"

File.foreach เทียบเท่า IO.foreach ดังนั้น IO class version สามารถใช้ได้ผลลัพธ์เดียวกับที่เราทำก่อนหน้านี้

Reading I/O Streams ผ่าน Kernel

ภายใน Ruby's IO class's reading และ writing abilities based บน abstractions รอบ kernel system calls Operating system's kernel จัดการ reading จากและ writing ไป I/O devices

Opening Files

IO.sysopen เปิด file โดยขอ kernel ใส่ reference ไป file ใน file table และสร้าง file descriptor ใน process's file descriptor table

File Descriptors และ File Table

การเปิด file คืน file descriptor — integer ใช้ access I/O resource

แต่ละ process มี file descriptor table ของตัวเองเก็บ file descriptors ใน memory และแต่ละ descriptor points ไป entry ใน system-wide file table

เพื่อ read จากหรือ write ไป I/O resource process ส่ง file descriptor ให้ kernel ผ่าน system call Kernel จึง access file ระหว่าง process เนื่องจาก processes ไม่มี access ไป file table

การเปิด files จะ ไม่ เก็บ content ของพวกมันใน memory แต file descriptor table อาจเต็ม ดังนั้น good practice ที่จะ close files เสมอหลังเปิด Methods ที่ wrap File.open เหมือน File.read ทำอัตโนมัติ รวมถึงที่รับ block

ในตัวอย่างนี้ เราจะไปอีกขั้นโดยเรียก IO.sysopen method โดยตรง โดยส่ง filename method สร้าง file descriptor ที่เราสามารถใช้ reference ไป open file ภายหลัง

irb> IO.sysopen("log/production.log") => 9

เพื่อสร้าง IO instance สำหรับ Ruby อ่านและเขียน เราส่ง file descriptor ให้ IO.new

irb> file_descriptor = IO.sysopen("log/production.log") => 9 irb> io = IO.new(file_descriptor) => #

เพื่อ close I/O stream และลบ reference ไป file จาก files table เราเรียก IO#close บน IO instance

irb> io.close => nil 

Reading Bytes และ Moving Cursors

IO#sysread อ่านจำนวน bytes จาก IO object

irb> io.sysread(64) => " [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" "

ตัวอย่างนี้ใช้ IO instance ที่เราสร้างก่อนหน้านี้โดยส่ง file descriptor integer ให้ IO.new มันอ่านและคืน 64 bytes แรกจาก file โดยเรียก IO#sysread ด้วย 64 เป็น argument

irb> io.sysread(64) => "for 127.0.0.1 at 2018-06-27 16:45:02 +0200\nI, [2018-06-27T16:45:"

ครั้งแรกที่เราขอ bytes จาก file cursor ถูก moved อัตโนมัติ ดังนั้นการเรียก IO#sysread บน instance เดียวกันอีกครั้งจะผลิต 64 bytes ถัดไปของ file

Moving the Cursor

IO.sysseek เคลื่อน cursor ไป location ใน file ด้วยตนเอง

irb> io.sysseek(32) => 32 irb> io.sysread(64) => "9098] INFO -- : [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started " irb> io.sysseek(0) => 0 irb> io.sysread(64) => " [86a5d18c-19dd-4cbf-9d7a-461c79e98c22] Started GET \"/articles\" "

ในตัวอย่างนี้ เรา move ไป position 32 แล้วอ่าน 64 bytes 用 IO#sysread โดยการเรียก IO.sysseek อีกครั้งด้วย 0 เรากลับไปจุดเริ่มต้นของ file ทำให้เราอ่าน 64 bytes แรกได้อีกครั้ง

Reading Files Line by Line

ตอนนี้เรารู้ว่า IO class's convenience methods เปิด IO streams, อ่าน bytes จากพวกมัน และ move cursor's position อย่างไร

Methods เหมือน IO.foreach และ IO#gets สามารถ request lines ต่อบรรทัดแทน per number of bytes ไม่มี performant way ของการ look ahead หา newline ถัดไปและเอา bytes จนถึง position นั้น ดังนั้น Ruby ต้องดูแลการ split file's content เอง

class MyIO def initialize(filename) fd = IO.sysopen(filename) @io = IO.new(fd) end def each(&block) line = "" while (c = @io.sysread(1)) != $/ line << c end block.call(line) each(&block) rescue EOFError @io.close end end

ใน implementation ตัวอย่างนี้ method #each เอา bytes จาก file 用 IO#sysread ทีละ byte จนกว่า byte จะเป็น $/ แสดง newline เมื่อหาเจอ มันหยุดเอา bytes และเรียก passed block ด้วย line นั้น

Solution นี้ทำงานแต่ inefficient เพราะเรียก IO.sysread สำหรับทุก byte ใน file

Buffering File Content

Ruby ฉลาดกว่านี้ในการทำ โดยเก็บ internal buffer ของ file's content แทนที่จะอ่าน file ทีละ byte มันเอา 512 bytes ทีเดียวและ check ว่ามี newlines ใน returned bytes หรือไม่ ถ้ามี มันคืน portion ก่อน newline และเก็บส่วนที่เหลือใน memory เป็น buffer ถ้า buffer ไม่ include newline มัน fetch 512 bytes เพิ่มจนกว่าจะหาเจอ

class MyIO def initialize(filename) fd = IO.sysopen(filename) @io = IO.new(fd) @buffer = "" end def each(&block) @buffer << @io.sysread(512) until @buffer.include?($/) line, @buffer = @buffer.split($/, 2) block.call(line) each(&block) rescue EOFError @io.close end end

ในตัวอย่างนี้ method #each เพิ่ม bytes เข้า internal @buffer variable เป็น chunks ของ 512 bytes จนกว่า @buffer variable จะ include newline เมื่อเกิดขึ้น มัน split buffer โดย newline แรก ส่วนแรกคือ line ส่วนที่สองคือ buffer ใหม่

Passed block จึงถูกเรียกกับ line และ remaining @buffer ถูกเก็บไว้ใช้ใน next loop

ด้วยการ buffer file's content จำนวน I/O calls ลดลง ในขณะที่แบ่ง file เป็น logical chunks

Streaming Files

สรุป streaming files ทำงานโดยขอ operating system's kernel เปิด file แล้วอ่าน bytes จากมันทีละนิด เมื่ออ่าน file ต่อบรรทัดใน Ruby data ถูกเอาจาก file 512 bytes ทีละครั้งและ split เป็น "lines" หลังจากนั้น

นี่คือสรุป overview ของ I/O และ streaming files ใน Ruby เรายินดีรู้ความคิดเห็นของคุณเกี่ยวกับบทความนี้ หรือถ้ามีคำถาม เรายังคงมองหา topics ที่จะ investigate และ explain ถ้ามีอะไร magical ใน Ruby ที่คุณอยากอ่าน อย่าลังเลบอกเราที่ @AppSignal!