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

การเปลี่ยนวิธีการแก้จุดบกพร่องใน Ruby ด้วย TracePoint

Ruby เป็นที่รู้จักในด้านประสิทธิภาพการทำงานมาโดยตลอดสำหรับนักพัฒนา นอกเหนือจากคุณสมบัติต่างๆ เช่น ไวยากรณ์ที่สวยงาม การรองรับการเขียนโปรแกรมเมตาที่หลากหลาย ฯลฯ ที่ทำให้คุณทำงานได้อย่างมีประสิทธิภาพเมื่อเขียนโค้ด ก็ยังมีอาวุธลับอีกอย่างที่เรียกว่า TracePoint ที่สามารถช่วยให้คุณ "ดีบัก" ได้เร็วขึ้น

ในโพสต์นี้ ฉันจะใช้ตัวอย่างง่ายๆ เพื่อแสดงข้อเท็จจริงที่น่าสนใจ 2 ข้อที่ฉันพบเกี่ยวกับการดีบัก:

  1. โดยส่วนใหญ่แล้ว การค้นหาจุดบกพร่องนั้นไม่ยาก แต่การทำความเข้าใจว่าโปรแกรมของคุณทำงานอย่างไรโดยละเอียดนั้น เมื่อคุณเข้าใจเรื่องนี้อย่างลึกซึ้งแล้ว คุณก็จะสามารถตรวจพบจุดบกพร่องได้ทันที
  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 ขั้นตอน:

  1. เรียนรู้ความคาดหวังจากการออกแบบ
  2. ทำความเข้าใจการใช้งานในปัจจุบัน
  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
  1. กำหนดวิธีการที่เรียกว่า plus_1
  2. ดึงข้อมูลอินพุต ("1" ) จาก ARGV
  3. โทร to_i บน "1" ซึ่งส่งคืน 1
  4. กำหนด 1 ไปยังตัวแปรท้องถิ่น input
  5. โทร plus_1 เมธอดด้วย input (1 ) เป็นอาร์กิวเมนต์ พารามิเตอร์ n ตอนนี้มีค่า 1
  6. โทร + วิธีการใน 1 พร้อมอาร์กิวเมนต์ 2 และส่งคืนผลลัพธ์ 3
  7. ส่งคืน 3 สำหรับขั้นตอนที่ 5
  8. โทร puts
  9. โทร to_s บน 3 ซึ่งส่งคืน "3"
  10. ส่ง "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

รหัสของเราสามารถอ่านได้มากขึ้นในขณะนี้ มันไม่น่าทึ่งเหรอ? มันพิมพ์การทำงานของโปรแกรมส่วนใหญ่พร้อมรายละเอียดมากมาย! เราสามารถแมปกับรายละเอียดการดำเนินการก่อนหน้าของฉันได้:

  1. กำหนดวิธีการที่เรียกว่า plus_1
  2. ดึงข้อมูลอินพุต ("1" ) จาก ARGV
  3. โทร to_i บน "1" ซึ่งส่งคืน 1
  4. กำหนด 1 ไปยังตัวแปรท้องถิ่น input
  5. โทร plus_1 เมธอดด้วย input (1 ) เป็นอาร์กิวเมนต์ พารามิเตอร์ n ตอนนี้มีค่า 1
  6. โทร + วิธีการใน 1 พร้อมอาร์กิวเมนต์ 2 และส่งคืนผลลัพธ์ 3
  7. ส่งคืน 3 สำหรับขั้นตอนที่ 5
  8. โทร puts
  9. โทร to_s บน 3 ซึ่งส่งคืน "3"
  10. ส่ง "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. ดึงข้อมูลอินพุต ("1" ) จาก ARGV
    • ARGV และ [] . ต่อไปนี้ ไม่ถือเป็นการโทร/c_call ในขณะนี้
    1. กำหนด 1 ไปยังตัวแปรท้องถิ่น input
    • ขณะนี้ ยังไม่มีเหตุการณ์สำหรับการกำหนดตัวแปร เราสามารถ (เรียงลำดับ) ติดตามมันด้วย line เหตุการณ์ + regex แต่จะไม่ถูกต้อง
    1. โทร + วิธีการใน 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 . ได้แล้ว ไปยังกล่องเครื่องมือดีบักของคุณแล้วลองดู