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

Revolutionize Ruby Debugging with TracePoint: Faster Bug Fixes & Deeper Insight

Ruby รู้จักกันดีจาก productivity ที่นำมาให้ developers นอกจาก features เช่น elegant syntax, rich meta-programming support ฯลฯ ที่ทำให้ productive ตอนเขียนโค้ด ยังมี secret weapon อีกอย่างชื่อ TracePoint ช่วย "debug" เร็วขึ้น

ในโพสต์นี้จะใช้ simple example show 2 interesting facts ที่พบเกี่ยวกับ debugging:

  1. ส่วนใหญ่หา bug เองไม่ยาก แต่เข้าใจว่า program ทำงานอย่างละเอียดยังไง คือยาก เมื่อเข้าใจลึกแล้ว มัก spot bug ทันที
  2. Observe program ลงระดับ method calls time-consuming และเป็น major bottleneck ของ debugging process

จากนั้นจะ show ว่า TracePoint เปลี่ยนวิธี debug โดยให้ program "บอกเรา" ว่ากำลังทำอะไร

Debugging Is about Understanding Your Program and Its Design

สมมติมี Ruby program ชื่อ plus_1 ไม่ทำงานถูกต้อง Debug ยังไง?

# 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

Ideally ควร address bug ใน 3 steps:

  1. Learn expectations จาก design
  2. Understand current implementation
  3. Trace the bug

Learning the Expectations from the Design

Expected behavior คืออะไร? plus_1 ควรเพิ่ม 1 ให้ argument ซึ่งเป็น input จาก command line แต่เรา "รู้" ยังไง?

Real-world case เข้าใจ expectations โดยอ่าน test cases, documents, mockups, ถามคนอื่น feedback ฯลฯ understanding ขึ้นอยู่กับ program "designed" ยังไง

Step นี้ crucial สุดของ debugging process ถ้าไม่เข้าใจ program ควรทำงานยังไง จะ debug ไม่ได้เลย

แตมี factors หลายอย่างใน step นี้ เช่น team coordination, development workflow ฯลฯ TracePoint ช่วยไม่ได้ จะไม่มุ่งมั่นปัญหาเหล่านี้วันนี้

Understanding the Current Implementation

เข้าใจ expected behavior แล้ว ต้องเรียนรู้ว่ามัน function ยังไงตอนนี้

ในกรณีส่วนใหญ่ต้องการ information ต่อไปนี้เข้าใจ program ได้เต็มที่:

  • Methods called ระหว่าง program execution
  • Call และ return order ของ method calls
  • Arguments passed ให้แต่ละ method call
  • Values returned จากแต่ละ method call
  • Side effects ที่เกิดระหว่างแต่ละ method call เช่น data mutation หรือ database requests

Describe example ด้วยข้อมูลข้างต้น:

# 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
  1. Define method ชื่อ plus_1
  2. Retrieve input ("1") จาก ARGV
  3. Call to_i บน "1" return 1
  4. Assign 1 ให้ local variable input
  5. Call plus_1 method ด้วย input(1) as argument parameter n มี value 1
  6. Call + method บน 1 ด้วย argument 2 return result 3
  7. Return 3 สำหรับ step 5
  8. Call puts
  9. Call to_s บน 3 return "3"
  10. Pass "3" ให้ puts call จาก step 8 trigger side effect print string to Stdout return nil

Description ไม่ 100% accurate แต่พอสำหรับ simple explanation

Addressing the Bug

ตอนเข้าใจ program ควรทำงานและ actual แล้ว เริ่มหา bug ด้วยข้อมูลที่มี search bug โดย follow method calls upward (เริ่ม step 10) หรือ downward (เริ่ม step 1) กรณีนี้ trace back ไป method ที่ return 3 ก่อน — คือ 1 + 2 ใน step 6

This Is Far from Reality! (ไกลจากความเป็นจริงมาก!)

เรารู้ทั้งหมด real debugging ไม่ simple อย่าง example Critical difference ระหว่าง real-life programs และ example คือ size ใช้ 10 steps explain 5-line program ต้องกี่ steps สำหรับ small Rails app? 基本上 impossible break down real program detailed เท่าที่ทำกับ example โดยไม่มี detailed understanding ของ program ไม่สามารถ track down bug ผ่าน obvious path จะต้องทำ assumptions หรือ guesses

Information Is Expensive (ข้อมูลแพง)

สังเกตแล้ว key factor ใน debugging คือ information มากน้อยแค่ไหน แต retrieval ข้อมูลมากนี่ cost อะไร? มาดู:

# 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 

เห็นได้สองประเภท information: values ของบาง variables และ evaluation order ของ puts (imply program execution order)

ข้อมูลนี้ cost เราเท่าไหร่?

 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)

ไม่แค่เพิ่ม 4 puts แต่เพื่อ print values แยกกัน ต้อง split logic เข้าถึง intermediate states ของบาง values ในกรณีนี้ได้ 4 additional outputs สำหรับ internal states ด้วย 8 lines of changes นั่นคือ 2 lines changes ต่อ 1 line output โดยเฉลี่ย! และจำนวน changes เติบโต linearly กับ program size เปรียบเทียบได้กับ O(n) operation

Why Is Debugging Expensive? (ทำไม Debugging ถึงแพง?)

Programs เขียนได้หลาย goals: maintainability, performance, simplicity ฯลฯ แต่ปกติไม่ใช่ "Traceability" คือได้ values สำหรับ inspect ซึ่งมักต้อง modify code เช่น split chained method calls

  • ยิ่งมาก information ที่ได้ ยิ่งมาก additions/changes ที่ต้องทำที่ code

แต่มากกว่านี้ จำนวน information ที่ได้ถึงจุดหนึ่ง จะ process ไม่ได้ efficiently ต้อง filter information out หรือ label ให้ช่วยเข้าใจ

  • ยิ่ง precise information ยิ่งมาก additions/changes ที่ต้องทำที่ code

สุดท้าย เพราะงาน involve touching codebase — ซึ่งต่างกันระหว่าง bugs (เช่น controller vs model logic) — ยาก automate แม้ codebase tracing-friendly (เช่น follow "Law of Demeter" อย่างเคร่งครัด) ส่วนใหญ่ต้อง type variable/method names แบบ manual

(Actually ใน Ruby มี tricks หลีกเลี่ยงนี้ — เช่น __method__ แต่ไม่ยุ่งในนี้)

TracePoint: The Savior (TracePoint: ผู้ช่วยชีวิต)

แต Ruby ให้ exceptional tool ลด cost มาก: TracePoint เดิมพันส่วนใหญ่เคยได้ยินหรือใช้มาก่อน แต่ในประสบการณ์ ไม่มากคนใช้ powerful tool นี้ใน daily debugging practices

แสดงวิธีใช้ collect information เร็ว ครั้งนี้ไม่ต้อง touch existing logic ใดๆ แค่ต้องการ code ก่อนหน้า:

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 ##trace on TracePoint => #
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 #>
call of Integer#to_s on 3
return of Integer#to_s on 3 => "3"
call of IO#write on #>
3
return of IO#write on #> => 2
return of IO#puts on #> => nil
return of Kernel#puts on main => nil 

โค้ด readable มากขึ้นแล้ว Amazing ไหม? พิมพ์ program execution ส่วนใหญ่พร้อมรายละเอียด! Map กับ execution breakdown ก่อนหน้าได้:

  1. Define method plus_1
  2. Retrieve input ("1") จาก ARGV
  3. Call to_i บน "1" return 1
  4. Assign 1 ให้ local variable input
  5. Call plus_1 method ด้วย input(1) as argument parameter n มี value 1
  6. Call + method บน 1 ด้วย argument 2 return result 3
  7. Return 3 สำหรับ step 5
  8. Call puts
  9. Call to_s บน 3 return "3"
  10. Pass "3" ให้ puts call จาก step 8 trigger side effect print string to Stdout และ return nil
# ignore this, it's TracePoint tracing itself ;D
return of ##trace on TracePoint => #
call of Module#method_added on Object
# 1. Define method `plus_1`.
return of Module#method_added on Object => nil
call of String#to_i on "1"
# 3-1. Call `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. Call `plus_1` method with `input`(`1`) as its argument.
return of Object#plus_1 on main => 3
# 7. Return `3` for step 5
call of Kernel#puts on main
# 8. Call `puts`
call of IO#puts on #>
call of Integer#to_s on 3
# 9. Call `to_s` on `3`, which returns `"3"`
return of Integer#to_s on 3 => "3"
call of IO#write on #>
# 10-1. Pass `"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 #> => 2
return of IO#puts on #> => nil
return of Kernel#puts on main => nil
# 10-3. And then it returns `nil`. 

แม่นยำกว่าที่พูดก่อนหน้านี้! แต่สังเกต steps 2, 4 และ 6 หายไปจาก output น่าเสียดายไม่ trackable โดย TracePoint เหตุผล:

    1. Retrieve input ("1") จาก ARGV
    • ARGV และ [] ต่อมา ไม่ considered as call/c_call ตอนนี้
    1. Assign 1 ให้ local variable input
    • ปัจจุบันไม่มี event สำหรับ variable assignments สามารถ (sort of) track ด้วย line event + regex แต่ไม่ accurate
    1. Call + method บน 1 ด้วย argument 2 return result 3
    • Certain method calls เช่น built-in + หรือ attribute accessor methods ไม่ trackable ตอนนี้

From O(n) to O(log n)

เห็นจากตัวอย่างก่อนหน้า ด้วย proper usage ของ TracePoint ทำให้เกือบ program "บอกเรา" ว่ากำลังทำอะไร ตอนนี้เพราะจำนวน lines ที่ต้องการ TracePoint ไมเติบโต linearly กับ program size ผมบอก whole process กลายเป็น O(log(n)) operation

Next Steps

ใน article นี้อธิบาย main difficulty ของ debugging หวังว่า convince คุณว่า TracePoint อาจเป็น game-changer แต่ถ้าลอง TracePoint ตอนนี้ น่าจะ frustrate คุณมากกว่าช่วย

ด้วย amount of information จาก TracePoint จะ swamped by noise เร็วๆ นี้ Challenge ใหม่คือ filter noise ทิ้งเหลือ valuable information เช่นในกรณีส่วนใหญ่ care แค่ specific models หรือ service objects ในกรณีนี้ filter calls by receiver's class:

TracePoint.trace(:call) do |tp|
  next unless tp.self.is_a?(Order)
  # tracing logic
end

อีกอย่าง block ที่ define สำหรับ TracePoint อาจ evaluate tens of thousands of times ที่ scale นี้ การ implement filtering logic ส่งผลมากต่อ app performance ตัวอย่างไม่แนะนำ:

TracePoint.trace(:call) do |tp|
  trace = caller[0]
  next unless trace.match?("app")
  # tracing logic
end

สำหรับ 2 ปัญหานี้ เตรียม article อีกอัน show tricks และ gotchas ที่พบ พร้อม boilerplates มีประโยชน์สำหรับ typical Ruby/Rails applications

และถ้าคิด concept นี้น่าสนใจ สร้าง gem ชื่อ tapping_device ซ่อน implementation hassles ทั้งหมด

สรุป

Debugger และ tracing ทั้งคู่เป็น great tools สำหรับ debugging ใช้มาหลายปี แต่แสดงใน article นี้ การใช้พวกมันต้อง manual operations มากใน debugging process แต่ด้วย TracePoint สามารถ automate หลายอย่างและ boost debugging performance หวังว่าเพิ่ม TracePoint เข้า debugging toolbox และลองใช้ดู