Ruby luôn được biết đến productivity mang lại cho developers. Bên cạnh features như elegant syntax, rich meta-programming support, etc. làm bạn productive khi viết code, nó còn có secret weapon khác tên TracePoint giúp bạn "debug" nhanh hơn.
Trong bài này, dùng simple example show 2 interesting facts tìm thấy về debugging:
- Hầu hết thời gian, tìm bug itself không hard, nhưng hiểu program works in detail mới hard. Khi có deep understanding này, thường spot bug ngay lập tức.
- Observe program xuống đến method call level tốn thời gian, và là major bottleneck của debugging process.
Sau đó, show cách TracePoint thay đổi cách approach debugging bằng cách làm program "báo cho chúng ta" nó đang làm gì.
Debugging Là Về Hiểu Program Và Design Của Nó
Giả sử có Ruby program tên plus_1 không functioning correctly. Làm sao 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
Lý tưởng, address bug trong 3 steps:
- Learn expectations từ design
- Hiểu current implementation
- Trace bug
Học Expectations Từ Design
Expected behavior ở đây? plus_1 nên add 1 vào argument, là input từ command line. Nhưng làm sao "know" này?
Trong real-world case, hiểu expectations bằng cách đọc test cases, documents, mockups, hỏi người khác feedback, etc. Understanding depends on how program được "designed".
Step này crucial nhất debugging process. Nếu không hiểu program should work như thế nào, bạn never debug được.
Tuy nhiên, nhiều factors có thể part của step này, như team coordination, development workflow, etc. TracePoint không help với those, nên không dwell on these problems today.
Hiểu Current Implementation
Khi đã hiểu expected behavior, cần learn nó function như thế nào tại moment.
Trong most cases, cần following information để fully understand program works:
- Methods called during program execution
- Call và return order của method calls
- Arguments passed cho mỗi method call
- Values returned từ mỗi method call
- Side effects happened during mỗi method call, e.g. data mutation hoặc database requests
Describe example với information trên:
# 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
- Defines method tên
plus_1 - Retrieves input (
"1") từARGV - Calls
to_itrên"1", returns1 - Assigns
1to local variableinput - Calls
plus_1method vớiinput(1) as argument. Parameternnow carries value1 - Calls
+method trên1với argument2, returns result3 - Returns
3cho step 5 - Calls
puts - Calls
to_strên3, returns"3" - Passes
"3"đếnputscall từ step 8, triggers side effect print string to Stdout. Returnsnil.
Mô tả không 100% accurate, nhưng đủ cho simple explanation.
Address Bug
Giờ đã learn program should work và actually works, start looking for bug. Với information, search bug bằng cách follow method calls upward (start từ step 10) hoặc downward (start từ step 1). Trong case này, trace back đến method returned 3 first place — là 1 + 2 trong step 6.
This Is Far From Reality!
Chắc chắn real debugging không simple như example. Critical difference giữa real-life programs và example là size. Dùng 10 steps explain 5-line program. Cần bao nhiêu steps cho small Rails app? Basically impossible break down real program detailed như example. Không detailed understanding program, không track down bug through obvious path, cần make assumptions hoặc guesses.
Information Is Expensive
Như đã notice, key factor debugging là how much information you have. Nhưng lấy many information tốn gì? Xem:
# 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
Như thấy, chỉ get 2 types information: values của some variables và evaluation order của puts (implies program execution order).
Information này tốn bao nhiêu?
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)
Không chỉ cần add 4 puts vào code, mà để print values separately, cũng cần split logic để access intermediate states của some values. Trong case này, got 4 additional outputs cho internal states với 8 lines changes. Đó là 2 lines changes cho 1 line output, average! Vì số changes grows linearly với size của program, compare với O(n) operation.
Tại Sao Debugging Đắt?
Programs viết với nhiều goals: maintainability, performance, simplicity, etc. nhưng usually không phải "Traceability", nghĩa là get values for inspection, thường requires modification của code, e.g. splitting chained method calls.
- Càng nhiều information, càng nhiều additions/changes cần make to code.
Tuy nhiên, khi amount of information reach certain point, không process efficiently. Vì vậy either filter information out hoặc label để help understand.
- Càng precise information, càng nhiều additions/changes cần make to code.
Cuối cùng, vì work involves touching codebase — có thể very different giữa bugs (e.g. controller vs model logic) — hard to automate. Ngay cả codebase tracing-friendly (e.g. follow "Law of Demeter" strictly), most of the time, cần type different variable/method names manually.
(Thực tế, trong Ruby, có tricks avoid this — như __method__. Nhưng không complicate things here.)
TracePoint: The Savior
Tuy nhiên, Ruby cung cấp exceptional tool largely reduce cost: TracePoint. Tôi bet most of you đã heard của nó hoặc used before. Nhưng in my experience, không nhiều người dùng powerful tool này trong daily debugging practices.
Cho tôi show cách dùng nó collect information quickly. Lần này, không cần touch any existing logic, chỉ cần some code before it:
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))
Run code, bạn thấy:
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
Code readable hơn nhiều. Amazing không? In ra most program execution với lots of details! Có thể map với earlier execution breakdown:
- Defines method called
plus_1 - Retrieves input (
"1") từARGV - Calls
to_itrên"1", returns1 - Assigns
1to local variableinput - Calls
plus_1method vớiinput(1) as argument. Parameternnow carries value1 - Calls
+method trên1với argument2, returns result3 - Returns
3cho step 5 - Calls
puts - Calls
to_strên3, returns"3" - Passes
"3"đếnputscall từ step 8, triggers side effect print string to Stdout. Returnsnil.
# ignore this, it's TracePoint tracing itself ;D
return of ##trace on TracePoint => #
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 #>
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 #>
# 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 #> => 2
return of IO#puts on #> => nil
return of Kernel#puts on main => nil
# 10-3. And then it returns `nil`.
Có thể nói detailed hơn những gì tôi nói trước! Tuy nhiên, bạn notice steps 2, 4 và 6 missing từ output. Thật không may, chúng không trackable bởi TracePoint vì:
- Retrieves input (
"1") từARGV ARGVvà following[]không considered as call/c_call at the moment
- Retrieves input (
- Assigns
1to local variableinput - Currently, no event cho variable assignments. Có thể (sort of) track với
lineevent + regex, nhưng không accurate
- Assigns
- Calls
+method trên1với argument2, returns result3 - Certain method calls như built-in
+hoặc attributes accessor methods không trackable at the moment
- Calls
Từ O(n) Đến O(log n)
Như thấy từ previous example, với proper usage của TracePoint, gần như làm program "báo cho chúng ta" nó đang làm gì. Giờ, vì số lines needed, TracePoint không grow linearly với size của program. Tôi nói whole process becomes O(log(n)) operation.
Next Steps
Trong bài này, explained main difficulty với debugging. Hy vọng cũng convinced bạn cách TracePoint có thể game-changer. Nhưng nếu try TracePoint right now, probably frustrate bạn more than help.
Với amount of information từ TracePoint, bạn soon be swamped by noise. Challenge mới là filter out noise, leaving valuable information. Ví dụ, most cases, chỉ care về specific models hoặc service objects. Trong cases này, filter calls by receiver's class, như:
TracePoint.trace(:call) do |tp|
next unless tp.self.is_a?(Order)
# tracing logic
end
Another thing to keep in mind: block bạn define cho TracePoint có thể evaluated tens of thousands of times. At this scale, how you implement filtering logic có great impact on app performance. Ví dụ, không recommend this:
TracePoint.trace(:call) do |tp|
trace = caller[0]
next unless trace.match?("app")
# tracing logic
end
Cho 2 problems này, tôi prepared another article let you know tricks và gotchas tìm thấy với useful boilerplates cho typical Ruby/Rails applications.
Và nếu find concept này interesting, tôi cũng created gem叫 tapping_device hide all implementation hassles.
Kết Luận
Debugger và tracing đều great tools cho debugging, dùng nhiều năm. Nhưng như demonstrated, dùng chúng requires many manual operations trong debugging process. Tuy nhiên, với help của TracePoint, bạn can automate many of them và thus boost debugging performance. Hy vọng bạn add TracePoint vào debugging toolbox và give it a try.