Как стать автором
Обновить

4. Metaprogramming patterns. 19 кю. Спасение утопающих дело рук самих утопающих

Время на прочтение 12 мин
Количество просмотров 2.9K
Предположим, что у вас есть библиотечный метод, который иногда кидает ексепшены.
Этот метод библиотечный в том смысле, что вы не хотите трогать руками тот файл, где он определён, так как этот файл, например, относится к библиотеке, которая регулярно обновляется, и ваши изменения после каждого обновления будут теряться, если вы специально не позаботитесь о их сохранении.
Такие методы принято менять в своем собственном коде — в динамических языках можно прямо в своем коде переписать избранный метод избранного класса. Например:

require 'net/http'<br>
module Net<br>
  class HTTP<br>
    def get(*args)<br>
      # ваш собственный код для этого метода<br>
    end <br>
  end<br>
end<br>
<br>

Такая техника называется monkey patching. Спрашивается, причём здесь обезьяны? Они здесь совершенно не причём. Гораздо ближе к причине гориллы. Но и они тоже не виноваты. А виноваты во всем партизаны! Изначально этот термин назывался «партизанским патчем» (guerrilla patch), английский термин уж очень был похож по звучанию на «патч гориллы», ну а там пошла пьянка и он превратился в «обезьяний патч» (программисты стали друг на друга обижаться, «обезьяна» менее обидна, чем «горилла», на том все и сошлись).

О взаимодействии партизанских отрядов


Как поступают партизаны? Они в тайне, без долгих дипломатических переговоров, предварительных угроз, соглашений об открытии и закрытии гуманитарного коридора, без договоров о правилах ведения войны и др. формальностей начинают действовать.

Партизанских группировок может быть несколько, они часто знают или догадываются о существовании других партизанских группировок, но у них нет единого плана, и, в принципе, одна группировка может решить ограбить поезд с вооружением, а вторая этот же поезд решить взорвать. Последовательность здесь будет важна, но в принципе, необходимая партизанам глобальная цель будет достигнута в любом случае. Неприятно, если поезд взорвется во время грабежа, и погибнут свои же.
В программировании однонитевых приложений одновременность отсутствует как явление, особенно если дело касается инициализации классов — нити принято создавать после того, как сделаны необходимые require и классы динамическим образом созданы/пропатчены. Как поступать с require — отдельный сложный вопрос (1, 2). Лочить require (то есть не давать управления другим нитям, пока не закончится выполнение require) оказывается нельзя, так как может возникнуть dead lock).

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

Примеры в студию!


Итак, мы можем взять любой метод любого класса и переопределить его. Говорят, что классы в динамических языках открыты.
Ой, каких только подлостей можно понатворить, используя эту открытость:
class Fixnum<br>
  def *(x)<br>
    42<br>
  end<br>
end<br>
puts 5*5<br>
puts 5*14<br>
<br>
class Fixnum<br>
  alias orig_div /<br>
  def /(x)<br>
    puts "стук-стук: тут кто-то делит #{self} на #{x}"<br>
    self.orig_div(x)     <br>
  end<br>
end<br>
puts 54/12<br>
puts 13/ 0

В последнем примере использована языковая конструкция alias, которая сохраняет тело функции под другим именем. Правильнее думать про операцию alias как про операцию копирования тела метода и назначения ему нового имени. Используют alias перед переопределением метода с целью получения доступа к предыдущей непропатченой версии метода по выбранному новому имени.

Такой подход активно применяется на практике. Например, можно писать код подобный следующему:
require 'net/http'<br>
class HTTP<br>
  alias get_orig get<br>
  def restore_connection<br>
    begin<br>
      do_start<br>
      true<br>
    rescue <br>
      false<br>
    end<br>
  end<br>
  def get(*args)<br>
    attempts = 0 <br>
    begin<br>
      get_orig(*args, &block)<br>
    rescue Errno::ECONNABORTED => e<br>
      if (attempts += 1) < 3<br>
        restore_connection<br>
        retry<br>
      end<br>
      raise e<br>
    end<br>
  end <br>
end

Возможно, кому-то этот код покажется прорывом, но меня он не удовлетворяет. Я хочу писать так:
<br>
require 'net/http'<br>
class HTTP<br>
  make_rescued :get,<br>
    :rescue => [Errno::ECONNABORTED, Errno::ECONNRESET, EOFError, Timeout::Error],<br>
    :retry_attempts => 3,<br>
    :on_success => lambda{|obj, args, res| puts "We did it!: #{args.inspect}"},<br>
    :sleep_before_retry => 1,<br>
    :ensure => lambda{|obj,args| puts "Finishing :#{args.inspect}" },<br>
    :timeout => 3,<br>
    :retry_if => lambda do |obj, args, e, attempt|<br>
      obj.instance_eval do<br>
        case e<br>
        when Errno::ECONNABORTED, Errno::ECONNRESET<br>
          # сокет! порвали сокет! <br>
          restore_connection<br>
        when EOFError, Timeout::Error<br>
          # что за ерунда? А ну ка еще раз<br>
          true<br>
        end<br>
      end<br>
    end<br>
end<br>
<br>

Делать более терпимые к исключительным ситуациям методы — важнейшая, часто возникающая задача. Код, посвящённый обработке исключительных ситуаций, постепенно увеличивает свою долю, и я осмелюсь сказать, что в эвристическом программировании, в веб программировании и, вообще, в современном программировании, он уже составляет 30% или более и является важнейшей компонентой бизнес логики. А раз это важно, почему бы не написать метод общего назначения make_rescued, учитывающий разнообразные опции и решающий задачу спасения в полной мере? Пора делать новый паттерн!

Да-да, паттерны метапрограммирования в Ruby часто представлены в виде методов модифицирующих методы. Другие типичные паттерны — примеси, сами техники использования примесей (extend & include & included), а также метод method_missing. Обо всём этом мы поговорим в следующих топиках.

Приближение 1. Учитываем опции :rescue, :retry_attempts
module MakeRescued<br>
  def extract_options(args)<br>
    args.pop if args.last.is_a?(Hash)<br>
  end<br>
  def alias_method(a, b)<br>
    class_eval "alias #{a} #{b}"<br>
  end<br>
  def make_rescued(*methods)<br>
    options = extract_options(methods)<br>
    exceptions = options[:rescue] || [Exception]<br>
    methods.each do |method|<br>
      method_without_rescue = "#{method}_without_rescue"<br>
      alias_method  method_without_rescue, method<br>
      define_method(method) do |*args|<br>
        retry_attempts = 0 <br>
        begin<br>
          send(method_without_rescue, *args)<br>
        rescue Exception => e<br>
          retry_attempts += 1<br>
          unless options[:retry_attempts] && retry_attempts > options[:retry_attempts]<br>
            if exceptions.any?{|klass| klass===e}<br>
              retry<br>
            end<br>
          end<br>
          raise e<br>
        end<br>
      end<br>
    end<br>
  end<br>
end<br>
<br>


Приближение 2. Учитываем все предложенные опции

require 'timeout'<br>
<br>
module MakeRescued<br>
  def extract_options(args)<br>
     args.last.is_a?(Hash) ? args.pop : {}<br>
  end<br>
  def alias_method(a, b)<br>
    class_eval "alias #{a} #{b}"<br>
  end<br>
  def make_rescued(*methods)<br>
    options = extract_options(methods)<br>
    exceptions = options[:rescue] || [Exception]<br>
    methods.each do |method|<br>
      method_without_rescue = "#{method}_without_rescue"<br>
      alias_method  method_without_rescue, method<br>
      define_method(method) do |*args|<br>
        retry_attempts = 0 <br>
        begin<br>
          res = nil<br>
          res = if options[:timeout]<br>
            Timeout::timeout( options[:timeout] ) do<br>
              send(method_without_rescue, *args)<br>
            end<br>
          else<br>
            send(method_without_rescue, *args)<br>
          end<br>
          options[:on_success][self,args,res] if options[:on_success]<br>
          res<br>
        rescue Exception => e<br>
          retry_attempts += 1<br>
          unless options[:retry_attempts] && retry_attempts > options[:retry_attempts]<br>
            if exceptions.any?{|klass| klass===e}<br>
              if options[:retry_if] && options[:retry_if][self,args,e,retry_attempts]<br>
                sleep options[:sleep_before_retry] if options[:sleep_before_retry]<br>
                retry<br>
              end<br>
            end<br>
          end<br>
          options[:on_fail][self,args,e] if options[:on_fail]<br>
          raise e<br>
        ensure<br>
          options[:ensure][self,args,res] if options[:ensure]<br>
          res<br>
        end<br>
      end<br>
    end<br>
  end<br>
end<br>
<br>
Module.module_eval { include MakeRescued }


Этот код можно развивать далее. Например, добавить опцию :default, в которой указывается значение метода по умолчанию, если выпадает Exception. Если эта опция равна блоку (есть объект класса Proc), то значит нужно вызывать этот блок с параметрами (self, args) и результат вычисления возвращать как результат метода.

Другие предложения по улучшению метода make_rescued приветствуются.

Классика: alias_method_chain


Это классика monkey patching. Её нужно знать:

и критически к ней подходить:


О методе alias_method_chain мы ещё поговорим. Сейчас лишь отметим, что можно было бы писать так:
...
  def get_with_rescue(*args)
    ...
      get_without_rescue(*args)
    ...
  end

  alais_method_chain :get, :rescue

где для модулей предварительно определяется метод
def alias_method_chain(target, feature)<br>
  alias_method "#{target}_without_#{feature}", target<br>
  alias_method target, "#{target}_with_#{feature}"<br>
end<br>

Использование нотации именования method_with_feature и method_without_feature позволяет программистам понимать по стеку вызовов, что происходит углубление в пропатченные партизанами методы. При выпадании Exception мы видим значащие имена методов. Кроме того, у нас для каждой фичи есть два метода — с этой фичей и без неё, и иногда возникает необходимость вызывать их непосредственно.
class Module<br>
  def alias_method(a, b)<br>
    class_eval "alias #{a} #{b}"<br>
  end<br>
  def alias_method_chain(target, feature)<br>
    alias_method "#{target}_without_#{feature}", target<br>
    alias_method target, "#{target}_with_#{feature}"<br>
  end<br>
end<br>
<br>
# для каждой фичи будет два метода: method_without_feature и method_with_feature<br>
class Abc<br>
  def hello<br>
    puts "hello"<br>
    raise 'Bang!' <br>
  end<br>
  <br>
  def hello_with_attention<br>
    puts "attention,"<br>
    hello_without_attention<br>
  end<br>
  alias_method_chain :hello, :attention<br>
  <br>
  def hello_with_name(name)<br>
    puts "my darling #{name},"<br>
    hello_without_name<br>
  end<br>
  alias_method_chain :hello, :name<br>
end<br>
<br>
Abc.new.hello('Liza')<br>

greck $ ruby method_chain_sample_backtrace.rb
method_chain.rb:14:in `hello_without_attention': Bang! (RuntimeError)
	from method_chain.rb:19:in `hello_without_name'
	from method_chain.rb:25:in `hello'
	from method_chain.rb:30
my darling Liza,
attention,
hello
greck $ 

Есть и другой важный бонус, который предоставляется такой техникой: различные IDE могут быстро при клике на строчку из backtrace выпавшего ексепшена перекинуть вас на нужную строчку нужного метода, поскольку методы действительно имеют разные имена, в отличие от упрощённого альтернативного подхода, где делаются только методы method_without_feature, а все определения определяют один и тот же метод:
<br>
# упрощённый подхода, где делаются только методы вида method_without_feature<br>
class Abc<br>
  def hello<br>
    puts "hello"<br>
    raise 'Bang!' <br>
  end<br>
  <br>
  alias  hello_without_attention hello <br>
  def hello<br>
    puts "attention,"<br>
    hello_without_attention<br>
  end<br>
  <br>
  alias hello_without_name hello<br>
  def hello(name)<br>
    puts "my darling #{name},"<br>
    hello_without_name<br>
  end<br>
end<br>
<br>



Ссылки


Теги:
Хабы:
+20
Комментарии 7
Комментарии Комментарии 7

Публикации

Истории

Работа

Ruby on Rails
17 вакансий
Программист Ruby
15 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн