Ruby는 항상 개발자에게 생산성을 제공하는 것으로 알려져 있습니다. 코드 작성 시 생산성을 높이는 우아한 구문, 풍부한 메타 프로그래밍 지원 등과 같은 기능과 함께 TracePoint
라는 또 다른 비밀 무기도 있습니다. 더 빨리 "디버그"하는 데 도움이 됩니다.
이 게시물에서는 디버깅에 대해 알게 된 두 가지 흥미로운 사실을 보여주기 위해 간단한 예를 사용하겠습니다.
- 대부분의 경우 버그 자체를 찾는 것은 어렵지 않지만 프로그램이 어떻게 작동하는지 자세히 이해하는 것은 어렵습니다. 이에 대해 깊이 이해하면 일반적으로 버그를 즉시 발견할 수 있습니다.
- 메서드 호출 수준까지 프로그램을 관찰하는 것은 시간이 많이 소요되며 디버깅 프로세스의 주요 병목 현상입니다.
그런 다음 TracePoint
프로그램이 수행하는 작업을 "알려줌"으로써 디버깅에 접근하는 방식을 변경할 수 있습니다.
디버깅은 프로그램과 디자인을 이해하는 것입니다.
plus_1
이라는 Ruby 프로그램이 있다고 가정해 보겠습니다. 제대로 작동하지 않습니다. 이것을 어떻게 디버깅합니까?
# 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
에서 .
이것은 현실과 거리가 멀다!
물론 실제 디버깅이 예제에서 보여지는 것처럼 간단하지 않다는 것을 우리 모두 알고 있습니다. 실제 프로그램과 우리의 예 사이의 중요한 차이점은 크기입니다. 5줄 프로그램을 설명하기 위해 10단계를 사용했습니다. 작은 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
보시다시피 여기에서는 일부 변수의 값과 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)
4개의 puts
을 추가해야 할 뿐만 아니라 그러나 값을 별도로 인쇄하려면 일부 값의 중간 상태에 액세스하기 위해 논리를 분할해야 합니다. 이 경우 8줄의 변경으로 내부 상태에 대해 4개의 추가 출력을 얻었습니다. 평균적으로 1줄의 출력에 대해 2줄의 변경 사항입니다! 그리고 변경 횟수는 프로그램의 크기에 따라 선형적으로 증가하므로 O(n)
와 비교할 수 있습니다. 작업.
디버깅 비용이 많이 드는 이유는 무엇입니까?
우리 프로그램은 유지보수성, 성능, 단순성 등 많은 목표를 염두에 두고 작성할 수 있지만 일반적으로 "추적성"을 위한 것은 아닙니다. 연결된 메서드 호출 분할.
- 정보를 더 많이 얻을수록 코드에 더 많은 추가/변경이 필요합니다.
그러나 일단 얻은 정보의 양이 일정 수준에 도달하면 효율적으로 처리할 수 없습니다. 따라서 정보를 필터링하거나 이해를 돕기 위해 레이블을 지정해야 합니다.
- 정보가 정확할수록 코드에 더 많은 추가/변경이 필요합니다.
마지막으로, 작업은 버그(예:컨트롤러 대 모델 로직)에 따라 매우 다를 수 있는 코드베이스를 만지는 작업을 포함하기 때문에 자동화하기 어렵습니다. 코드베이스가 추적 친화적이더라도(예:"데미터의 법칙"을 엄격히 따름) 대부분의 경우 다른 변수/메서드 이름을 수동으로 입력해야 합니다.
(사실, 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
로 추적할 수 없습니다. 다음과 같은 이유로:
- <올 시작="2">
- 입력 검색(
"1"
)ARGV
ARGV
다음[]
현재 call/c_call로 간주되지 않습니다.- <올 시작="4">
1
할당 지역 변수input
에- 현재 변수 할당에 대한 이벤트가 없습니다.
line
으로 (일종의) 추적할 수 있습니다. 이벤트 + 정규식이지만 정확하지 않습니다. - <올 시작="6">
+
호출1
의 메소드 인수2
사용 , 결과를 반환합니다.3
- 내장
+
와 같은 특정 메소드 호출 또는 속성 접근자 메서드는 현재 추적할 수 없습니다.
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 애플리케이션에 유용한 상용구와 함께 발견한 몇 가지 트릭과 문제를 알려주는 또 다른 기사를 준비했습니다.
이 개념이 흥미롭다면 구현의 번거로움을 모두 숨길 수 있는 taping_device라는 보석도 만들었습니다.
결론
디버거와 추적은 둘 다 디버깅을 위한 훌륭한 도구이며 우리는 이를 수년 동안 사용해 왔습니다. 그러나 이 기사에서 설명했듯이 디버깅 프로세스 중에 이를 사용하려면 많은 수동 작업이 필요합니다. 그러나 TracePoint
의 도움으로 , 그 중 많은 부분을 자동화하여 디버깅 성능을 높일 수 있습니다. 이제 TracePoint
를 추가할 수 있기를 바랍니다. 디버깅 도구 상자로 이동하여 시도해 보십시오.