Статьи

Копаем с TracePoint

копать лопатой

В этой статье описывается редкий случай использования, поэтому я вынужден дать некоторые пояснения, объясняющие мои мотивы «копания» с помощью TracePoint. Вы можете перейти к следующему разделу, если он вам не интересен.

Примечание Все примеры не являются производственным кодом! Образцы просто ради этой статьи. Пожалуйста, не копируйте и не вставляйте его в свой проект без тщательного рассмотрения.

Почему я начал играть с TracePoint?

В повседневном программировании на Ruby в начале метода есть некоторый код для проверки его аргументов. Особенно, если вы пишете метод API, который будет вызываться из внешнего кода, поскольку вы не можете доверять ничему из внешних источников.

Это привело меня к написанию кода вроде:

def my_method(arg)
  arg = arg.to_sym if arg.is_a?(String)
  raise unless arg.is_a?(Symbol)
end

Я сказал себе «Стоп». Я пишу что-то похожее на приведенный выше пример в каждом методе. Этот код должен быть унифицирован и извлечен в мою библиотеку ежедневного использования. Google не дал мне ни одного полезного камня, поэтому я запустил проект EnsureIt .

Очевидный способ обеспечить эту функциональность состоит в том, чтобы обезопасить (или улучшить, начиная с 2.1.0ensure_symbolensure_string Этот метод дает нам самый быстрый (в некоторых случаях, без проверки) код.

Когда я начал кодировать методы bang ( ensure_symbol! Сообщения об ошибках должны быть информативными и содержать как минимум имя недопустимого аргумента. Но мне нужно было иметь понятный и удобный API, поэтому передача имени аргумента в качестве другого аргумента (что-то вроде arg.ensure_symbol!(:arg) Мое решение состояло в том, чтобы получить контекст вызывающего абонента в пределах ensure_symbol! метод, чтобы проверить его на наличие аргументов и локальных переменных.

И это начало, указывающее на эту статью.

Цель

Моя первая цель — получить контекст вызывающего в вызываемом методе. Это не очень сложно, но я опишу свою реализацию ниже. Что действительно интересно, а также моя вторая цель — это вызвать ошибку в вызываемом методе с помощью сообщения, созданного из контекста вызывающей стороны.

Получение контекста вызывающего из вызываемого метода

Давайте начнем с некоторого кода. Принимая во внимание мою фоновую форму выше, monkeypath Objectensure_symbol! :

 class Object
  def ensure_symbol!
    raise 'argument should be a Symbol or a String'
  end
end

class Symbol
  def ensure_symbol!
    self
  end
end

class String
  def ensure_symbol!
    to_sym
  end
end

def test_method(test_arg)
  test_arg = test_arg.ensure_symbol!
  puts "passed argument: #{test_arg.inspect}"
end

test_method(:symbol)
test_method('string')
test_method(0)

Это просто. Для символов верните себя. Для строк преобразуйте значение в символ. Поднимите ошибку для любого другого объекта.

Вот вывод:

 $ ruby example_01.rb
passed argument: :symbol
passed argument: :string
example_01.rb:3:in `ensure_symbol!': argument should be a Symbol or a String ( RuntimeError)
    from example_01.rb:20:in `test_method'
    from example_01.rb:26:in `<main>'

Проблема в том, что сообщение об ошибке не является информативным. Я хочу иметь имя аргумента ( test_argtest_method Что-то вроде argument 'test_arg' in method 'test_method' should be a Symbol or a String

Для этого мне нужен контекст вызывающего абонента внутри ensure_symbol! , В сети есть похожие реализации. Многие из них, например, binding_of_caller , зависят от платформы ruby. Я предпочитаю использовать нативные инструменты ruby ​​везде, где это возможно. Вот где TracePoint

Начиная с версии 2.0.0set_trace_func ) отслеживания выполнения программы — класс TracePoint . Он предоставляет ряд внутренних событий ruby, таких как возврат из метода или создание исключения, которые мы можем записать с помощью наших собственных блоков кода. Пожалуйста, прочитайте официальную документацию для лучшего понимания.

В качестве первой попытки я связываюсь с событием :return

 def raise_error(method_name, message: nil)
  message = 'local variable have wrong type' unless message.is_a?(String)
  counter = 0
  TracePoint.trace(:return) do |tp|
    if (counter += 1) > 2
      tp.disable
      puts 'Caller context reached.'
      puts "Caller method name is: #{tp.method_id}"
      method = eval("method(:#{tp.method_id})", tp.binding)
      puts "Caller arguments: #{method.parameters}"
    end
  end
end # <= first call of trace point block

class Object
  def ensure_symbol!
    raise_error(
      :ensure_symbol!,
      message: 'argument should be a Symbol or a String'
    )
  end # <= second call of trace point block
end

def test_method(test_arg)
  test_arg = test_arg.ensure_symbol!
  puts "passed argument: #{test_arg.inspect}"
end # <= last call of trace point block

test_method(0)

Здесь мы создаем вспомогательный метод под названием raise_error Мы создаем внутри него экземпляр TracePoint, который будет вызываться по return Первоначальный вызов raise_error Мы еще не достигли желаемого контекста. Следующий блок вызывается по возвращении из ensure_symbol! , Также пропустите это. Наконец, последний блок вызывается из желаемого контекста вызывающей стороны, и мы можем получить имя метода с его аргументами. Давайте посмотрим на вывод:

 $ ruby example_02.rb
passed argument: #<TracePoint:enabled>
Caller context reached.
Caller method name is: test_method
Caller arguments: [[:req, :test_arg]]

Ага! У нас есть желанный контекст звонящего. Но сначала мы еще не подняли ошибку. Также наш ensure_symbol! блок выполняется после оставшегося кода в методе вызывающего ( puts "passed argument...

Давайте raise_errorensure_symbol! :

 def raise_error(method_name, message: nil)
  message = 'local variable have wrong type' unless message.is_a?(String)
  counter = 0
  TracePoint.trace(:return) do |first_tp|
    if (counter += 1) > 1
      first_tp.disable
      TracePoint.trace(:line) do |second_tp|
        second_tp.disable
        puts 'Caller context reached.'
        puts "Caller method name is: #{second_tp.method_id}"
        method = eval("method(:#{second_tp.method_id})", second_tp.binding)
        puts "Caller arguments: #{method.parameters}"
      end
    end
  end
end # <= first call of first_tp block

class Object
  def ensure_symbol!
    raise_error(
      :ensure_symbol!,
      message: 'argument should be a Symbol or a String'
    )
  end # <= second and last call of first_tp block
end

def test_method(test_arg)
  test_arg = test_arg.ensure_symbol!
  puts "passed argument: #{test_arg.inspect}" # <= call of second_tp block
end

test_method(0)

Теперь мы отслеживаем только первые два возврата (а не три, как в предыдущем примере). Во втором вызове мы в конце ensure_symbol! метод. Здесь мы используем другой тип события трассировки — :line Поскольку raise_errorraise_error При этом условии мы можем пропустить одну строку, чтобы достичь контекста вызывающей стороны. Давайте попробуем выполнить код:

 $ ruby example_03.rb
Caller context reached.
Caller method name is: test_method
Caller arguments: [[:req, :test_arg]]
passed argument: #<TracePoint:disabled>

И вуаля! Мы достигли нашей первой цели! Контекст вызывающей стороны получается непосредственно перед выполнением любого кода после вызова нашего ensure_symbol! метод. Однако вторая проблема все еще остается — мы не подняли ошибку.

Возникновение «умной» ошибки

Как мы можем поднять наше информативное сообщение об ошибке? Наша первая идея — поднять ошибку из блока точек трассировки:

 def raise_error(method_name, message: nil)
  message = 'local variable have wrong type' unless message.is_a?(String)
  counter = 0
  TracePoint.trace(:return) do |first_tp|
    if (counter += 1) > 1
      first_tp.disable
      TracePoint.trace(:line) do |second_tp|
        second_tp.disable
        raise "raised from trace point in method #{second_tp.method_id}"
      end
    end
  end
end

выполнение этого дает нам:

 $ ruby example_04.rb
example_04.rb:9:in `block (2 levels) in raise_error': raised from trace point in method test_method (RuntimeError)
    from example_04.rb:38:in `test_method'
    from example_04.rb:41:in `<main>'

Это выглядит достаточно красиво, но это не сработает, если вызов ensure_symbol! находится внутри блока begin-rescue Давайте test_methoddef test_method(test_arg)
begin
test_arg = test_arg.ensure_symbol!
rescue RuntimeError => err
puts 'error occured while checking the argument'
raise err
end
puts "passed argument: #{test_arg.inspect}"
end

 $ ruby example_05.rb
example_05.rb:9:in `block (2 levels) in raise_error': raised from trace point in method test_method (RuntimeError)
    from example_05.rb:43:in `test_method'
    from example_05.rb:46:in `<main>'

что приводит к:

 ensure_symbol!

Тот же результат. Но где сообщение об ошибке при проверке аргумента ? Что произошло? Давайте вернемся к последнему примеру в предыдущем разделе.

Наша вторая точка трассировки пропускает одну строку в ensure_symbol! метод, а затем незамедлительно дает нам контекст вызывающей стороны после rescue , На данный момент, нет повышенного исключения еще. Это объясняет, почему наш код выше не работает — в момент возврата из метода исключений нет, а ruby ​​просто пропускает блок восстановления. Теперь исключение в блоке точек трассировки генерируется и выводится.

Это только один вывод — мы должны вызвать исключение, прежде чем мы вернемся с ensure_symbol! , ОК, но теперь у нас другая проблема. Сообщение, которое мы хотим сгенерировать, основано на контексте вызывающего, но нам нужно вызвать исключение, прежде чем мы получим указанный контекст. Мне вспоминаются отличные фильмы «Назад в будущее». Невозможно?

На самом деле это возможно! Если исключение возбуждается внутри raise_error

 def raise_error(method_name, message: nil)
  message = 'local variable have wrong type' unless message.is_a?(String)
  counter = 0
  error = nil
  TracePoint.trace(:return, :raise) do |first_tp|
    if first_tp.event == :raise
      error = first_tp.raised_exception
      puts "error captured with message: #{error.message}"
    else
      if (counter += 1) > 1
        first_tp.disable
        TracePoint.trace(:line) do |second_tp|
          second_tp.disable
          unless error.nil?
            puts "now we have #{error.inspect} in #{second_tp.method_id} context "
          end
        end
      end
    end
  end
  raise %q{raised from '#raise_error' method}
end

И это дает нам:

 $ ruby example_06.rb
error captured with message: raised from '#raise_error' method
now we have #<RuntimeError: raised from '#raise_error' method> in test_method context
error occured while checking the argument
example_06.rb:21:in `raise_error': raised from '#raise_error' method (RuntimeError)
    from example_06.rb:26:in `ensure_symbol!'
    from example_06.rb:47:in `test_method'
    from example_06.rb:55:in `<main>'

Большой! Мы очень близки к успеху. Ошибка правильно выводится и фиксируется блоком begin-rescue

Теперь нам просто нужно изменить сообщение об ошибке. К сожалению, это не так просто. Класс Exception Мы можем создать наш собственный класс ExceptionException

Подожди, что является исключением? Это просто объект Ruby. Благодаря одноэлементным методам Ruby мы можем переопределить любой метод в одном объекте, не изменяя ничего больше!

Вот окончательная версия raise_error

 def raise_error(method_name, message: nil)
  message = 'local variable have wrong type' unless message.is_a?(String)
  counter = 0
  error = nil
  TracePoint.trace(:return, :raise) do |first_tp|
    if first_tp.event == :raise
      error = first_tp.raised_exception
    else
      if (counter += 1) > 1
        first_tp.disable
        TracePoint.trace(:line) do |second_tp|
          second_tp.disable
          unless error.nil?
            new_message = "#{message} in '#{second_tp.method_id}' method"
            error.define_singleton_method :message do
              new_message
            end
          end
        end
      end
    end
  end
  raise %q{raised from '#raise_error' method}
end

И результат:

 $ ruby example_07.rb
error occured while checking the argument
example_07.rb:29:in `raise_error': argument should be a Symbol or a String in 'test_method' method (RuntimeError)
    from example_07.rb:34:in `ensure_symbol!'
    from example_07.rb:55:in `test_method'
    from example_07.rb:63:in `<main>'

Рубиновая магия в действии!

Конечно, остаются некоторые технические работы для создания правильного сообщения об ошибке. Мы могли бы использовать регулярные выражения, чтобы получить и проанализировать звонок. Метод ensure_symbol Объяснение всего этого выходит за рамки данной статьи. Вы можете найти рабочий исходный код проекта EnsureIt на github для деталей.

Вывод

Некоторые могут найти эту тему странной и хакерской. Я могу видеть эту точку зрения. Тем не менее, подобные упражнения — хороший опыт программирования на Ruby. Также, учитывая условия проекта, вызовы таких методов, как ensure_symbol! очень часто. Поэтому моей целью было выполнить минимальный объем кода внутри этих методов, только для проверки и преобразования, выполняя любую другую работу, только если произошла ошибка.

Когда мои тесты стали зелеными, я посмотрел в окно и увидел красивый восход солнца. 🙂 Копать с рубином действительно увлекательно!

Удачи!

(Спасибо Алексу Сказке и моей сестре за рецензирование этой статьи)