Computer >> Máy Tính >  >> Lập trình >> Ruby

Thay đổi cách tiếp cận để gỡ lỗi trong Ruby với TracePoint

Ruby luôn được biết đến với năng suất mà nó mang lại cho các nhà phát triển. Cùng với các tính năng như cú pháp thanh lịch, hỗ trợ lập trình meta phong phú, v.v. giúp bạn làm việc hiệu quả khi viết mã, nó còn có một vũ khí bí mật khác được gọi là TracePoint điều đó có thể giúp bạn "gỡ lỗi" nhanh hơn.

Trong bài đăng này, tôi sẽ sử dụng một ví dụ đơn giản để cho bạn thấy 2 sự thật thú vị mà tôi đã tìm ra về gỡ lỗi:

  1. Hầu hết thời gian, việc tự tìm ra lỗi không khó, nhưng hiểu chi tiết cách chương trình của bạn hoạt động. Sau khi hiểu sâu về vấn đề này, bạn thường có thể phát hiện ra lỗi ngay lập tức.
  2. Việc quan sát chương trình của bạn ở cấp độ cuộc gọi phương thức rất tốn thời gian và là điểm nghẽn chính trong quá trình gỡ lỗi của chúng tôi.

Sau đó, tôi sẽ chỉ cho bạn cách TracePoint có thể thay đổi cách chúng tôi tiếp cận gỡ lỗi bằng cách làm cho chương trình "cho chúng tôi biết" nó đang làm gì.

Gỡ lỗi là để hiểu chương trình của bạn và thiết kế của nó

Giả sử chúng ta có một chương trình Ruby có tên là plus_1 và nó không hoạt động chính xác. Làm cách nào để gỡ lỗi này?

# 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

Tốt nhất, chúng ta có thể giải quyết lỗi trong 3 bước:

  1. Tìm hiểu những mong đợi từ thiết kế
  2. Hiểu cách triển khai hiện tại
  3. Theo dõi lỗi

Học những kỳ vọng từ thiết kế

Hành vi mong đợi ở đây là gì? plus_1 nên thêm 1 đối số của nó, là đầu vào của chúng ta từ dòng lệnh. Nhưng làm thế nào để chúng ta "biết" điều này?

Trong trường hợp thực tế, chúng ta có thể hiểu các mong đợi bằng cách đọc các trường hợp thử nghiệm, tài liệu, mô hình, yêu cầu người khác phản hồi, v.v. Sự hiểu biết của chúng ta phụ thuộc vào cách chương trình được "thiết kế".

Bước này là phần quan trọng nhất trong quá trình gỡ lỗi của chúng tôi. Nếu bạn không hiểu chương trình sẽ hoạt động như thế nào, bạn sẽ không thể gỡ lỗi nó.

Tuy nhiên, có nhiều yếu tố có thể là một phần của bước này, chẳng hạn như phối hợp nhóm, quy trình phát triển, v.v. TracePoint sẽ không thể giúp bạn những vấn đề đó, vì vậy hôm nay chúng tôi sẽ không giải quyết những vấn đề này.

Hiểu về việc triển khai hiện tại

Khi chúng ta đã hiểu về hành vi mong đợi của chương trình, chúng ta cần tìm hiểu cách nó hoạt động vào lúc này.

Trong hầu hết các trường hợp, chúng tôi cần thông tin sau để hiểu đầy đủ cách hoạt động của một chương trình:

  • Các phương thức được gọi trong quá trình thực thi chương trình
  • Thứ tự gọi và trả về của phương thức gọi
  • Các đối số được chuyển cho mỗi lệnh gọi phương thức
  • Các giá trị được trả về từ mỗi lần gọi phương thức
  • Bất kỳ tác dụng phụ nào đã xảy ra trong mỗi lần gọi phương thức, ví dụ:đột biến dữ liệu hoặc yêu cầu cơ sở dữ liệu

Hãy mô tả ví dụ của chúng tôi với thông tin 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
  1. Xác định một phương thức được gọi là plus_1
  2. Truy xuất đầu vào ("1" ) từ ARGV
  3. Cuộc gọi to_i trên "1" , trả về 1
  4. Chỉ định 1 đến biến cục bộ input
  5. Cuộc gọi plus_1 phương thức với input (1 ) như đối số của nó. Tham số n bây giờ mang giá trị 1
  6. Cuộc gọi + phương thức trên 1 với một đối số 2 và trả về kết quả 3
  7. Trả về 3 cho bước 5
  8. Cuộc gọi puts
  9. Cuộc gọi to_s trên 3 , trả về "3"
  10. Đạt "3" đến puts gọi từ bước 8, kích hoạt hiệu ứng phụ in chuỗi thành Stdout. Sau đó, nó trả về nil .

Mô tả không chính xác 100%, nhưng đủ để giải thích đơn giản.

Giải quyết lỗi

Bây giờ chúng ta đã biết chương trình của chúng ta sẽ hoạt động như thế nào và nó thực sự hoạt động như thế nào, chúng ta có thể bắt đầu tìm kiếm lỗi. Với thông tin chúng tôi có, chúng tôi có thể tìm kiếm lỗi bằng cách thực hiện theo các lệnh gọi phương thức trở lên (bắt đầu từ bước 10) hoặc hướng xuống (bắt đầu từ bước 1). Trong trường hợp này, chúng ta có thể làm điều đó bằng cách truy tìm lại phương thức đã trả về 3 ở vị trí đầu tiên⁠ — là 1 + 2step 6 .

Điều này khác xa với thực tế!

Tất nhiên, tất cả chúng ta đều biết rằng gỡ lỗi thực sự không đơn giản như ví dụ làm cho nó được. Sự khác biệt quan trọng giữa các chương trình ngoài đời thực và ví dụ của chúng tôi là kích thước. Chúng tôi đã sử dụng 10 bước để giải thích một chương trình 5 dòng. Chúng ta cần bao nhiêu bước cho một ứng dụng Rails nhỏ? Về cơ bản, không thể chia nhỏ một chương trình thực chi tiết như chúng tôi đã làm trong ví dụ này. hoặc đoán.

Thông tin đắt tiền

Như bạn có thể đã nhận thấy, yếu tố quan trọng trong việc gỡ lỗi là bạn có bao nhiêu thông tin. Nhưng cần gì để lấy được nhiều thông tin đó? Hãy 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ư bạn có thể thấy, chúng tôi chỉ nhận được 2 loại thông tin ở đây:giá trị của một số biến và thứ tự đánh giá của các puts của chúng tôi (ngụ ý thứ tự thực hiện của chương trình).

Thông tin này khiến chúng tôi phải trả giá 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)

Chúng ta không chỉ cần thêm 4 puts vào mã, nhưng, để in các giá trị một cách riêng biệt, chúng ta cũng cần tách logic của mình để truy cập các trạng thái trung gian của một số giá trị. Trong trường hợp này, chúng tôi có 4 đầu ra bổ sung cho các trạng thái bên trong với 8 dòng thay đổi. Đó là 2 dòng thay đổi cho 1 dòng đầu ra, trung bình! Và vì số lượng thay đổi tăng tuyến tính với kích thước của chương trình, chúng ta có thể so sánh nó với một O(n) hoạt động.

Tại sao gỡ lỗi lại tốn kém?

Các chương trình của chúng tôi có thể được viết với nhiều mục tiêu:khả năng bảo trì, hiệu suất, tính đơn giản, v.v. nhưng thường không dành cho "Truy xuất nguồn gốc", nghĩa là, nhận các giá trị để kiểm tra, thường yêu cầu sửa đổi mã, ví dụ:chia nhỏ các cuộc gọi phương thức chuỗi.

  • Bạn càng nhận được nhiều thông tin, bạn càng cần thực hiện nhiều bổ sung / thay đổi đối với mã.

Tuy nhiên, khi lượng thông tin bạn nhận được đạt đến một điểm nhất định, bạn sẽ không thể xử lý nó một cách hiệu quả. Vì vậy, chúng tôi cần lọc thông tin ra hoặc gắn nhãn thông tin để giúp chúng tôi hiểu thông tin.

  • Thông tin càng chính xác, bạn càng cần thực hiện nhiều bổ sung / thay đổi đối với mã.

Cuối cùng, vì công việc liên quan đến việc chạm vào cơ sở mã⁠ — có thể rất khác nhau giữa các lỗi (ví dụ:bộ điều khiển so với lôgic của mô hình) ⁠ — rất khó để tự động hóa nó. Ngay cả khi cơ sở mã của bạn thân thiện với khả năng theo dõi (ví dụ:nó tuân thủ nghiêm ngặt "Luật Demeter"), hầu hết thời gian, bạn sẽ cần phải nhập các tên biến / phương thức khác nhau theo cách thủ công.

(Trên thực tế, trong Ruby, có một số thủ thuật để tránh điều này⁠ — như __method__ . Nhưng đừng làm phức tạp mọi thứ ở đây.)

TracePoint:Vị cứu tinh

Tuy nhiên, Ruby cung cấp cho chúng ta một công cụ đặc biệt có thể giảm phần lớn chi phí:TracePoint . Tôi cá rằng hầu hết các bạn đã nghe nói về nó hoặc sử dụng nó trước đây. Nhưng theo kinh nghiệm của tôi, không có nhiều người sử dụng công cụ mạnh mẽ này trong các phương pháp gỡ lỗi hàng ngày.

Hãy để tôi chỉ cho bạn cách sử dụng nó để thu thập thông tin một cách nhanh chóng. Lần này, chúng tôi không cần chạm vào bất kỳ logic nào hiện có của mình, chúng tôi chỉ cần một số mã trước nó:

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))

Nếu bạn chạy mã, bạn sẽ thấy:

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

Mã của chúng tôi bây giờ dễ đọc hơn nhiều. Thật tuyệt vời phải không? Nó in ra hầu hết quá trình thực hiện chương trình với rất nhiều chi tiết! Chúng tôi thậm chí có thể ánh xạ nó với phân tích thực thi trước đó của tôi:

  1. Xác định một phương thức được gọi là plus_1
  2. Truy xuất đầu vào ("1" ) từ ARGV
  3. Cuộc gọi to_i trên "1" , trả về 1
  4. Chỉ định 1 đến biến cục bộ input
  5. Cuộc gọi plus_1 phương thức với input (1 ) như đối số của nó. Tham số n bây giờ mang một giá trị 1
  6. Cuộc gọi + phương thức trên 1 với một đối số 2 và trả về kết quả 3
  7. Trả về 3 cho bước 5
  8. Cuộc gọi puts
  9. Cuộc gọi to_s trên 3 , trả về "3"
  10. Đạt "3" đến puts gọi từ bước 8, kích hoạt hiệu ứng phụ in chuỗi thành Stdout. Và sau đó nó trả về 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`.

Chúng tôi thậm chí có thể nói rằng nó chi tiết hơn những gì tôi đã nói trước đó! Tuy nhiên, bạn có thể nhận thấy rằng các bước 2, 4 và 6 bị thiếu trong đầu ra. Rất tiếc, chúng không thể theo dõi bằng TracePoint vì những lý do sau:

    1. Truy xuất đầu vào ("1" ) từ ARGV
    • ARGV[] sau hiện tại không được coi là call / c_call
    1. Chỉ định 1 đến biến cục bộ input
    • Hiện tại, không có sự kiện nào cho các phép gán biến. Chúng tôi có thể (loại) theo dõi nó bằng line event + regex, nhưng nó sẽ không chính xác
    1. Cuộc gọi + phương thức trên 1 với một đối số 2 và trả về kết quả 3
    • Một số lệnh gọi phương thức như + tích hợp sẵn hoặc các phương thức của trình truy cập thuộc tính hiện không thể theo dõi được

Từ O (n) đến O (log n)

Như bạn có thể thấy từ ví dụ trước, với việc sử dụng đúng cách TracePoint , chúng tôi gần như có thể làm cho chương trình "cho chúng tôi biết" nó đang làm gì. Bây giờ, do số lượng dòng chúng ta cần, TracePoint không phát triển tuyến tính với kích thước của chương trình của chúng tôi. Tôi muốn nói rằng toàn bộ quá trình trở thành một O(log(n)) hoạt động.

Các bước tiếp theo

Trong bài viết này, tôi đã giải thích khó khăn chính khi gỡ lỗi. Hy vọng rằng tôi cũng đã thuyết phục bạn về cách TracePoint có thể là người thay đổi cuộc chơi. Nhưng nếu bạn thử TracePoint ngay bây giờ, nó có thể sẽ làm bạn thất vọng nhiều hơn là giúp bạn.

Với lượng thông tin đến từ TracePoint , bạn sẽ sớm bị bao trùm bởi tiếng ồn. Thách thức mới là lọc bỏ những tạp âm, để lại những thông tin có giá trị. Ví dụ, trong hầu hết các trường hợp, chúng tôi chỉ quan tâm đến các mô hình hoặc đối tượng dịch vụ cụ thể. Trong những trường hợp này, chúng tôi có thể lọc các cuộc gọi theo lớp của người nhận, như sau:

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

Một điều khác cần lưu ý là khối bạn xác định cho TracePoint có thể được đánh giá hàng chục nghìn lần. Ở quy mô này, cách bạn triển khai logic lọc có thể có tác động lớn đến hiệu suất ứng dụng của bạn. Ví dụ:tôi không khuyến nghị điều này:

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

Đối với 2 vấn đề này, tôi đã chuẩn bị một bài viết khác để cho bạn biết một số thủ thuật và mẹo mà tôi tìm thấy với một số bảng soạn sẵn hữu ích cho các ứng dụng Ruby / Rails điển hình.

Và nếu bạn thấy khái niệm này thú vị, tôi cũng đã tạo một viên đá quý có tên tapping_device để ẩn tất cả các phức tạp khi triển khai.

Kết luận

Debugger và tracing đều là những công cụ tuyệt vời để gỡ lỗi và chúng tôi đã sử dụng chúng trong nhiều năm. Nhưng như tôi đã trình bày trong bài viết này, việc sử dụng chúng đòi hỏi nhiều thao tác thủ công trong quá trình gỡ lỗi. Tuy nhiên, với sự trợ giúp của TracePoint , bạn có thể tự động hóa nhiều trong số chúng và do đó tăng hiệu suất gỡ lỗi của bạn. Tôi hy vọng bây giờ bạn có thể thêm TracePoint vào hộp công cụ gỡ lỗi của bạn và dùng thử.