Ruby เป็นที่รู้จักในด้านประสิทธิภาพการทำงานมาโดยตลอดสำหรับนักพัฒนา นอกเหนือจากคุณสมบัติต่างๆ เช่น ไวยากรณ์ที่สวยงาม การรองรับการเขียนโปรแกรมเมตาที่หลากหลาย ฯลฯ ที่ทำให้คุณทำงานได้อย่างมีประสิทธิภาพเมื่อเขียนโค้ด ก็ยังมีอาวุธลับอีกอย่างที่เรียกว่า TracePoint
ที่สามารถช่วยให้คุณ "ดีบัก" ได้เร็วขึ้น
ในโพสต์นี้ ฉันจะใช้ตัวอย่างง่ายๆ เพื่อแสดงข้อเท็จจริงที่น่าสนใจ 2 ข้อที่ฉันพบเกี่ยวกับการดีบัก:
- โดยส่วนใหญ่แล้ว การค้นหาจุดบกพร่องนั้นไม่ยาก แต่การทำความเข้าใจว่าโปรแกรมของคุณทำงานอย่างไรโดยละเอียดนั้น เมื่อคุณเข้าใจเรื่องนี้อย่างลึกซึ้งแล้ว คุณก็จะสามารถตรวจพบจุดบกพร่องได้ทันที
- การสังเกตโปรแกรมของคุณจนถึงระดับการเรียกใช้เมธอดนั้นใช้เวลานาน และเป็นคอขวดหลักของกระบวนการดีบักของเรา
จากนั้น ผมจะแสดงให้คุณเห็นว่า TracePoint
สามารถเปลี่ยนวิธีการแก้จุดบกพร่องของเราด้วยการทำให้โปรแกรม "บอกเรา" ว่ากำลังทำอะไร
การดีบักคือการทำความเข้าใจโปรแกรมของคุณและการออกแบบ
สมมติว่าเรามีโปรแกรม Ruby ชื่อ plus_1
และทำงานไม่ถูกต้อง เราจะดีบักสิ่งนี้ได้อย่างไร
# plus_1.rb
def plus_1(n)
n + 2
end
input = ARGV[0].to_i
puts(plus_1(input))
$ ruby plus_1.rb 1
3
ตามหลักการแล้ว เราควรแก้ไขจุดบกพร่องได้ใน 3 ขั้นตอน:
- เรียนรู้ความคาดหวังจากการออกแบบ
- ทำความเข้าใจการใช้งานในปัจจุบัน
- ติดตามจุดบกพร่อง
เรียนรู้ความคาดหวังจากการออกแบบ
พฤติกรรมที่คาดหวังที่นี่คืออะไร? plus_1
ควรเพิ่ม 1
อาร์กิวเมนต์ซึ่งเป็นอินพุตของเราจากบรรทัดคำสั่ง แต่เรา "รู้" สิ่งนี้ได้อย่างไร
ในสถานการณ์จริง เราสามารถเข้าใจความคาดหวังโดยการอ่านกรณีทดสอบ เอกสาร แบบจำลอง การขอความคิดเห็นจากผู้อื่น ฯลฯ ความเข้าใจของเราขึ้นอยู่กับว่าโปรแกรม "ออกแบบ" อย่างไร
ขั้นตอนนี้เป็นส่วนสำคัญที่สุดของกระบวนการดีบักของเรา หากคุณไม่เข้าใจวิธีการทำงานของโปรแกรม คุณจะไม่สามารถแก้ไขข้อบกพร่องได้
อย่างไรก็ตาม มีหลายปัจจัยที่สามารถเป็นส่วนหนึ่งของขั้นตอนนี้ เช่น การประสานงานของทีม เวิร์กโฟลว์การพัฒนา ฯลฯ TracePoint
ไม่สามารถช่วยเหลือคุณได้ ดังนั้นเราจะไม่พูดถึงปัญหาเหล่านี้ในวันนี้
ทำความเข้าใจการใช้งานในปัจจุบัน
เมื่อเราเข้าใจพฤติกรรมที่คาดหวังของโปรแกรมแล้ว เราต้องเรียนรู้วิธีการทำงานของโปรแกรมในขณะนี้
ในกรณีส่วนใหญ่ เราต้องการข้อมูลต่อไปนี้เพื่อให้เข้าใจอย่างถ่องแท้ว่าโปรแกรมทำงานอย่างไร:
- เมธอดที่เรียกใช้ระหว่างการทำงานของโปรแกรม
- ลำดับการโทรและส่งคืนของการเรียกใช้เมธอด
- อาร์กิวเมนต์ที่ส่งไปยังแต่ละเมธอด
- ค่าที่ส่งคืนจากการเรียกแต่ละเมธอด
- ผลข้างเคียงใดๆ ที่เกิดขึ้นระหว่างการเรียกแต่ละเมธอด เช่น ผลข้างเคียง การกลายพันธุ์ของข้อมูลหรือคำขอฐานข้อมูล
มาอธิบายตัวอย่างของเราด้วยข้อมูลข้างต้น:
# plus_1.rb
def plus_1(n)
n + 2
end
input = ARGV[0].to_i
puts(plus_1(input))
$ ruby plus_1.rb 1
3
- กำหนดวิธีการที่เรียกว่า
plus_1
- ดึงข้อมูลอินพุต (
"1"
) จากARGV
- โทร
to_i
บน"1"
ซึ่งส่งคืน1
- กำหนด
1
ไปยังตัวแปรท้องถิ่นinput
- โทร
plus_1
เมธอดด้วยinput
(1
) เป็นอาร์กิวเมนต์ พารามิเตอร์n
ตอนนี้มีค่า1
- โทร
+
วิธีการใน1
พร้อมอาร์กิวเมนต์2
และส่งคืนผลลัพธ์3
- ส่งคืน
3
สำหรับขั้นตอนที่ 5 - โทร
puts
- โทร
to_s
บน3
ซึ่งส่งคืน"3"
- ส่ง
"3"
ไปที่puts
โทรจากขั้นตอนที่ 8 ซึ่งจะเรียกผลข้างเคียงที่พิมพ์สตริงไปยัง Stdout จากนั้นจะส่งกลับnil
.
คำอธิบายไม่ถูกต้อง 100% แต่ก็เพียงพอแล้วสำหรับคำอธิบายง่ายๆ
การจัดการข้อบกพร่อง
ตอนนี้เราได้เรียนรู้ว่าโปรแกรมของเราควรทำงานอย่างไรและทำงานอย่างไร เราสามารถเริ่มมองหาจุดบกพร่องได้ ด้วยข้อมูลที่เรามี เราสามารถค้นหาจุดบกพร่องได้โดยทำตามวิธีการเรียกขึ้น (เริ่มจากขั้นตอนที่ 10) หรือลง (เริ่มจากขั้นตอนที่ 1) ในกรณีนี้ เราสามารถทำได้โดยย้อนกลับไปที่เมธอดที่คืนค่า 3 มาที่แรก—ซึ่งก็คือ 1 + 2
ใน step 6
.
นี่ยังห่างไกลจากความเป็นจริง!
แน่นอน เราทุกคนทราบดีว่าการดีบักจริงนั้นไม่ง่ายอย่างที่เป็นตัวอย่าง ความแตกต่างที่สำคัญระหว่างโปรแกรมในชีวิตจริงและตัวอย่างของเราคือขนาด เราใช้ 10 ขั้นตอนในการอธิบายโปรแกรม 5 บรรทัด เราต้องการกี่ขั้นตอนสำหรับแอป Rails ขนาดเล็ก โดยพื้นฐานแล้วมันเป็นไปไม่ได้เลยที่จะแยกย่อยโปรแกรมจริงที่มีรายละเอียดเหมือนที่เราทำในตัวอย่าง หากปราศจากความเข้าใจโดยละเอียดเกี่ยวกับโปรแกรมของคุณ คุณจะไม่สามารถติดตามจุดบกพร่องผ่านเส้นทางที่ชัดเจนได้ ดังนั้น คุณจะต้องตั้งสมมติฐาน หรือเดา
ข้อมูลมีราคาแพง
อย่างที่คุณอาจสังเกตเห็นแล้ว ปัจจัยสำคัญในการดีบักคือจำนวนข้อมูลที่คุณมี แต่ต้องใช้อะไรในการดึงข้อมูลมากขนาดนั้น? มาดูกัน:
# plus_1_with_tracing.rb
def plus_1(n)
puts("n = #{n}")
n + 2
end
raw_input = ARGV[0]
puts("raw_input: #{raw_input}")
input = raw_input.to_i
puts("input: #{input}")
result = plus_1(input)
puts("result of plus_1 #{result}")
puts(result)
$ ruby plus_1_with_tracing.rb 1
raw_input: 1
input: 1
n = 1
result of plus_1: 3
3
อย่างที่คุณเห็น เราได้รับข้อมูล 2 ประเภทเท่านั้น:ค่าของตัวแปรบางตัวและลำดับการประเมินของ puts
(ซึ่งหมายถึงลำดับการดำเนินการของโปรแกรม)
ข้อมูลนี้มีค่าใช้จ่ายเท่าไร
def plus_1(n)
+ puts("n = #{n}")
n + 2
end
-input = ARGV[0].to_i
-puts(plus_1(input))
+raw_input = ARGV[0]
+puts("raw_input: #{raw_input}")
+input = raw_input.to_i
+puts("input: #{input}")
+
+result = plus_1(input)
+puts("result of plus_1: #{result}")
+
+puts(result)
ไม่เพียงแต่เราต้องเพิ่ม puts
4 รายการ ในโค้ด แต่หากต้องการพิมพ์ค่าแยกกัน เราจำเป็นต้องแยกตรรกะของเราออกด้วยเพื่อเข้าถึงสถานะกลางของค่าบางค่า ในกรณีนี้ เราได้ 4 เอาต์พุตเพิ่มเติมสำหรับสถานะภายในโดยมีการเปลี่ยนแปลง 8 บรรทัด นั่นคือการเปลี่ยนแปลง 2 บรรทัดสำหรับเอาต์พุต 1 บรรทัดโดยเฉลี่ย! และเนื่องจากจำนวนการเปลี่ยนแปลงเพิ่มขึ้นเชิงเส้นตามขนาดของโปรแกรม เราจึงสามารถเปรียบเทียบกับ O(n)
ปฏิบัติการ
เหตุใดการดีบักจึงมีราคาแพง
โปรแกรมของเราสามารถเขียนได้โดยมีเป้าหมายหลายอย่าง เช่น ความสามารถในการบำรุงรักษา ประสิทธิภาพ ความเรียบง่าย ฯลฯ แต่โดยปกติแล้วไม่ใช่สำหรับ "การตรวจสอบย้อนกลับ" ซึ่งหมายถึงการรับค่าสำหรับการตรวจสอบ ซึ่งมักจะต้องมีการแก้ไขโค้ด เช่น แยกการเรียกเมธอดที่ถูกล่ามโซ่
- ยิ่งคุณได้รับข้อมูลมากเท่าใด คุณก็ยิ่งต้องเพิ่มเติม/เปลี่ยนแปลงโค้ดมากขึ้นเท่านั้น
อย่างไรก็ตาม เมื่อปริมาณข้อมูลที่คุณได้รับถึงจุดหนึ่ง คุณจะไม่สามารถประมวลผลได้อย่างมีประสิทธิภาพ เราจึงต้องกรองข้อมูลออกหรือติดป้ายกำกับเพื่อช่วยให้เราเข้าใจข้อมูลดังกล่าว
- ยิ่งข้อมูลแม่นยำมากเท่าใด คุณก็ยิ่งต้องเพิ่มเติม/เปลี่ยนแปลงโค้ดมากเท่านั้น
สุดท้าย เนื่องจากงานนี้เกี่ยวข้องกับการแตะ codebase—ซึ่งอาจแตกต่างกันมากระหว่างบั๊ก (เช่น controller กับ model logic)— มันยากที่จะทำให้เป็นอัตโนมัติ แม้ว่า codebase ของคุณจะเป็นมิตรกับการติดตาม (เช่น ปฏิบัติตาม "Law of Demeter" อย่างเคร่งครัด) โดยส่วนใหญ่ คุณจะต้องพิมพ์ชื่อตัวแปร/เมธอดต่างๆ ด้วยตนเอง
(ที่จริงแล้ว ใน Ruby มีเคล็ดลับบางอย่างที่ควรหลีกเลี่ยง:—เช่น __method__
. แต่อย่าทำให้สิ่งต่างๆ ซับซ้อนขึ้นที่นี่)
TracePoint:พระผู้ช่วยให้รอด
อย่างไรก็ตาม Ruby มีเครื่องมือพิเศษที่ช่วยลดต้นทุนได้มาก:TracePoint
. ฉันพนันได้เลยว่าพวกคุณส่วนใหญ่เคยได้ยินหรือเคยใช้มาก่อน แต่จากประสบการณ์ของผม มีคนไม่มากที่ใช้เครื่องมืออันทรงพลังนี้ในการแก้จุดบกพร่องรายวัน
ให้ฉันแสดงวิธีการใช้งานเพื่อรวบรวมข้อมูลอย่างรวดเร็ว คราวนี้ เราไม่ต้องแตะต้องตรรกะที่มีอยู่เลย เราแค่ต้องการโค้ดก่อนหน้านั้น:
TracePoint.trace(:call, :return, :c_call, :c_return) do |tp|
event = tp.event.to_s.sub(/(.+(call|return))/, '\2').rjust(6, " ")
message = "#{event} of #{tp.defined_class}##{tp.callee_id} on #{tp.self.inspect}"
# if you call `return` on any non-return events, it'll raise error
message += " => #{tp.return_value.inspect}" if tp.event == :return || tp.event == :c_return
puts(message)
end
def plus_1(n)
n + 2
end
input = ARGV[0].to_i
puts(plus_1(input))
หากคุณเรียกใช้รหัส คุณจะเห็น:
return of #<Class:TracePoint>#trace on TracePoint => #<TracePoint:c_return `trace'@plus_1_with_trace_point.rb:1>
call of Module#method_added on Object
return of Module#method_added on Object => nil
call of String#to_i on "1"
return of String#to_i on "1" => 1
call of Object#plus_1 on main
return of Object#plus_1 on main => 3
call of Kernel#puts on main
call of IO#puts on #<IO:<STDOUT>>
call of Integer#to_s on 3
return of Integer#to_s on 3 => "3"
call of IO#write on #<IO:<STDOUT>>
3
return of IO#write on #<IO:<STDOUT>> => 2
return of IO#puts on #<IO:<STDOUT>> => nil
return of Kernel#puts on main => nil
รหัสของเราสามารถอ่านได้มากขึ้นในขณะนี้ มันไม่น่าทึ่งเหรอ? มันพิมพ์การทำงานของโปรแกรมส่วนใหญ่พร้อมรายละเอียดมากมาย! เราสามารถแมปกับรายละเอียดการดำเนินการก่อนหน้าของฉันได้:
- กำหนดวิธีการที่เรียกว่า
plus_1
- ดึงข้อมูลอินพุต (
"1"
) จากARGV
- โทร
to_i
บน"1"
ซึ่งส่งคืน1
- กำหนด
1
ไปยังตัวแปรท้องถิ่นinput
- โทร
plus_1
เมธอดด้วยinput
(1
) เป็นอาร์กิวเมนต์ พารามิเตอร์n
ตอนนี้มีค่า1
- โทร
+
วิธีการใน1
พร้อมอาร์กิวเมนต์2
และส่งคืนผลลัพธ์3
- ส่งคืน
3
สำหรับขั้นตอนที่ 5 - โทร
puts
- โทร
to_s
บน3
ซึ่งส่งคืน"3"
- ส่ง
"3"
ไปที่puts
โทรจากขั้นตอนที่ 8 ซึ่งจะเรียกผลข้างเคียงที่พิมพ์สตริงไปยัง Stdout แล้วส่งกลับnil
.
# ignore this, it's TracePoint tracing itself ;D
return of #<Class:TracePoint>#trace on TracePoint => #<TracePoint:c_return `trace'@plus_1_with_trace_point.rb:1>
call of Module#method_added on Object # 1. Defines a method called `plus_1`.
return of Module#method_added on Object => nil
call of String#to_i on "1" # 3-1. Calls `to_i` on `"1"`
return of String#to_i on "1" => 1 # 3-2. which returns `1`
call of Object#plus_1 on main # 5. Calls `plus_1` method with `input`(`1`) as its argument.
return of Object#plus_1 on main => 3 # 7. Returns `3` for step 5
call of Kernel#puts on main # 8. Calls `puts`
call of IO#puts on #<IO:<STDOUT>>
call of Integer#to_s on 3 # 9. Calls `to_s` on `3`, which returns `"3"`
return of Integer#to_s on 3 => "3"
call of IO#write on #<IO:<STDOUT>> # 10-1. Passes `"3"` to the `puts` call from step 8
# 10-2. which triggers a side effect that prints the string to Stdout
3 # original output
return of IO#write on #<IO:<STDOUT>> => 2
return of IO#puts on #<IO:<STDOUT>> => nil
return of Kernel#puts on main => nil # 10-3. And then it returns `nil`.
เรียกได้ว่าละเอียดกว่าที่บอกไปก่อนหน้านี้ด้วยซ้ำ! อย่างไรก็ตาม คุณอาจสังเกตเห็นว่าขั้นตอนที่ 2, 4 และ 6 หายไปจากเอาต์พุต ขออภัย ไม่สามารถติดตามได้โดย TracePoint
ด้วยเหตุผลดังต่อไปนี้:
-
- ดึงข้อมูลอินพุต (
"1"
) จากARGV
ARGV
และ[]
. ต่อไปนี้ ไม่ถือเป็นการโทร/c_call ในขณะนี้
- ดึงข้อมูลอินพุต (
-
- กำหนด
1
ไปยังตัวแปรท้องถิ่นinput
- ขณะนี้ ยังไม่มีเหตุการณ์สำหรับการกำหนดตัวแปร เราสามารถ (เรียงลำดับ) ติดตามมันด้วย
line
เหตุการณ์ + regex แต่จะไม่ถูกต้อง
- กำหนด
-
- โทร
+
วิธีการใน1
พร้อมอาร์กิวเมนต์2
และส่งคืนผลลัพธ์3
- บางวิธีการเรียกเหมือนในตัว
+
หรือแอตทริบิวต์ accessor method ไม่สามารถติดตามได้ในขณะนี้
- โทร
จาก O(n) ถึง O(log n)
ดังที่คุณเห็นจากตัวอย่างก่อนหน้านี้ ด้วยการใช้ TracePoint
. อย่างเหมาะสม เราแทบจะทำให้โปรแกรม "บอกเรา" ได้ว่ากำลังทำอะไรอยู่ ตอนนี้เนื่องจากจำนวนบรรทัดที่เราต้องการ TracePoint
ไม่เติบโตเชิงเส้นตามขนาดของโปรแกรมของเรา ผมว่ากระบวนการทั้งหมดกลายเป็น O(log(n))
ปฏิบัติการ
ขั้นตอนต่อไป
ในบทความนี้ ฉันได้อธิบายปัญหาหลักในการดีบักแล้ว หวังว่าฉันยังทำให้คุณเชื่อในวิธีที่ TracePoint
อาจเป็นตัวเปลี่ยนเกม แต่ถ้าคุณลอง TracePoint
ตอนนี้มันอาจจะทำให้คุณหงุดหงิดมากกว่าช่วยคุณ
ด้วยจำนวนข้อมูลที่มาจาก TracePoint
, อีกไม่นานคุณจะถูกเสียงอึกทึกครึกโครม ความท้าทายใหม่คือการกรองเสียงรบกวนโดยทิ้งข้อมูลที่มีค่าไว้ ตัวอย่างเช่น ในกรณีส่วนใหญ่ เราสนใจเฉพาะรุ่นหรือออบเจ็กต์บริการที่เฉพาะเจาะจงเท่านั้น ในกรณีเหล่านี้ เราสามารถกรองการเรียกตามคลาสของผู้รับได้ดังนี้:
TracePoint.trace(:call) do |tp|
next unless tp.self.is_a?(Order)
# tracing logic
end
สิ่งที่ควรทราบอีกอย่างคือบล็อกที่คุณกำหนดสำหรับ TracePoint
สามารถประเมินได้หลายหมื่นครั้ง ในระดับนี้ วิธีที่คุณใช้ตรรกะการกรองสามารถมีผลกระทบอย่างมากต่อประสิทธิภาพของแอปของคุณ ตัวอย่างเช่น ฉันไม่แนะนำสิ่งนี้:
TracePoint.trace(:call) do |tp|
trace = caller[0]
next unless trace.match?("app")
# tracing logic
end
สำหรับปัญหา 2 ข้อนี้ ฉันได้เตรียมบทความอื่นเพื่อแจ้งให้คุณทราบถึงกลเม็ดและคำสั่งต่างๆ ที่ฉันพบพร้อมกับเอกสารสำเร็จรูปที่มีประโยชน์สำหรับแอปพลิเคชัน Ruby/Rails ทั่วไป
และหากคุณพบว่าแนวคิดนี้น่าสนใจ ฉันยังได้สร้างอัญมณีที่เรียกว่า tapping_device ซึ่งซ่อนความยุ่งยากในการใช้งานทั้งหมดไว้
บทสรุป
ดีบักเกอร์และการติดตามเป็นเครื่องมือที่ยอดเยี่ยมสำหรับการดีบัก และเราใช้งานมาหลายปีแล้ว แต่อย่างที่ฉันได้แสดงให้เห็นในบทความนี้ การใช้สิ่งเหล่านี้จำเป็นต้องมีการดำเนินการด้วยตนเองหลายอย่างในระหว่างกระบวนการดีบัก อย่างไรก็ตาม ด้วยความช่วยเหลือของ TracePoint
คุณสามารถทำให้หลายรายการเป็นอัตโนมัติและเพิ่มประสิทธิภาพการดีบักของคุณ ฉันหวังว่าคุณสามารถเพิ่ม TracePoint
. ได้แล้ว ไปยังกล่องเครื่องมือดีบักของคุณแล้วลองดู