web-dev-qa-db-fra.com

Comment ajouter des informations à un message d'exception dans Ruby?

Comment ajouter des informations à un message d'exception sans changer sa classe en ruby?

L'approche que j'utilise actuellement est

strings.each_with_index do |string, i|
  begin
    do_risky_operation(string)
  rescue
    raise $!.class, "Problem with string number #{i}: #{$!}"
  end
end

Idéalement, j'aimerais également conserver la trace.

Y a-t-il un meilleur moyen?

53
Andrew Grimm

Pour relancer l'exception et modifier le message, tout en préservant la classe d'exception et sa trace, procédez comme suit: 

strings.each_with_index do |string, i|
  begin
    do_risky_operation(string)
  rescue Exception => e
    raise $!, "Problem with string number #{i}: #{$!}", $!.backtrace
  end
end

Ce qui donnera:

# RuntimeError: Problem with string number 0: Original error message here
#     backtrace...
91
BoosterStage

Ce n'est pas beaucoup mieux, mais vous pouvez simplement relancer l'exception avec un nouveau message:

raise $!, "Problem with string number #{i}: #{$!}"

Vous pouvez également obtenir vous-même un objet exception modifié avec la méthode exception:

new_exception = $!.exception "Problem with string number #{i}: #{$!}"
raise new_exception
17
Chuck

Voici un autre moyen:

class Exception
  def with_extra_message extra
    exception "#{message} - #{extra}"
  end
end

begin
  1/0
rescue => e
  raise e.with_extra_message "you fool"
end

# raises an exception "ZeroDivisionError: divided by 0 - you fool" with original backtrace

(révisé pour utiliser la méthode exception en interne, merci @Chuck)

6
AlexChaffee

Mon approche serait de extend l'erreur rescued avec un module anonyme qui étend la méthode message de l'erreur:

def make_extended_message(msg)
    Module.new do
      @@msg = msg
      def message
        super + @@msg
      end
    end
end

begin
  begin
      raise "this is a test"
  rescue
      raise($!.extend(make_extended_message(" that has been extended")))
  end
rescue
    puts $! # just says "this is a test"
    puts $!.message # says extended message
end

De cette façon, vous ne saisissez aucune autre information dans l’exception (c’est-à-dire sa backtrace).

4
Mark Rushakoff

Je mets mon vote que Ryan Heneise réponse devrait être la acceptée. 

Il s'agit d'un problème courant dans les applications complexes et la préservation de la trace d'origine est souvent si critique que nous avons pour cela une méthode d'utilitaire dans notre module d'assistance ErrorHandling

L'un des problèmes que nous avons découverts est qu'essayer de générer des messages plus significatifs lorsqu'un système est dans un état de désordre entrainerait la génération d'exceptions à l'intérieur du gestionnaire d'exceptions lui-même, ce qui nous a conduit à renforcer notre fonction utilitaire comme suit:

def raise_with_new_message(*args)
  ex = args.first.kind_of?(Exception) ? args.shift : $!
  msg = begin
    sprintf args.shift, *args
  rescue Exception => e
    "internal error modifying exception message for #{ex}: #{e}"
  end
  raise ex, msg, ex.backtrace
end

Quand ça va bien

begin
  1/0
rescue => e
  raise_with_new_message "error dividing %d by %d: %s", 1, 0, e
end

vous recevez un message bien modifié

ZeroDivisionError: error dividing 1 by 0: divided by 0
    from (irb):19:in `/'
    from (irb):19
    from /Users/sim/.rvm/rubies/Ruby-2.0.0-p247/bin/irb:16:in `<main>'

Quand ça va mal

begin
  1/0
rescue => e
  # Oops, not passing enough arguments here...
  raise_with_new_message "error dividing %d by %d: %s", e
end

vous ne perdez toujours pas la trace de la grande image

ZeroDivisionError: internal error modifying exception message for divided by 0: can't convert ZeroDivisionError into Integer
    from (irb):25:in `/'
    from (irb):25
    from /Users/sim/.rvm/rubies/Ruby-2.0.0-p247/bin/irb:16:in `<main>'
2
Sim

Je me rends compte que je suis avec 6 ans de retard à cette fête, mais ... je pensais avoir compris Ruby le traitement des erreurs jusqu'à cette semaine et ai rencontré cette question. Bien que les réponses soient utiles, il existe un comportement non évident (et non documenté) qui peut être utile aux futurs lecteurs de ce fil. Tout le code a été exécuté sous Ruby v2.3.1.

@Andrew Grimm demande

Comment ajouter des informations à un message d'exception sans changer sa classe en ruby?

et fournit ensuite un exemple de code:

raise $!.class, "Problem with string number #{i}: #{$!}"

Je pense que c'est critical pour souligner que cela n'ajoute PAS d'informations à l'objet d'instance d'erreur d'origine , mais crée à la place un objet NEW avec la même classe.

@BoosterStage dit

Pour relancer l'exception et modifier le message ...

mais encore une fois, le code fourni

raise $!, "Problem with string number #{i}: #{$!}", $!.backtrace

lèvera une nouvelle instance de la classe d'erreur quelle que soit la classe référencée par $ !, mais elle sera not la même instance que $ !.

La différence entre le code de @Andrew Grimm et l'exemple de @ BoosterStage réside dans le fait que le premier argument de #raise dans le premier cas est un Classname__, alors que dans le deuxième cas, il s'agit d'une instance de (probablement) StandardErrorname__. La différence est importante car la documentation de Kernel # raise indique:

Avec un seul argument String, déclenche une RuntimeError avec la chaîne en tant que message. Sinon, le premier paramètre devrait être le nom d'une classe d'exception (ou un objet qui renvoie un objet d'exception lors de l'envoi d'un message d'exception).

Si un seul argument est donné et qu'il s'agit d'une instance d'objet d'erreur, cet objet sera raisename__d IF La méthode #exception de cet objet hérite ou implémente le comportement par défaut défini dans Exception # exception (string) :

Sans argument, ou si l'argument est identique à celui du destinataire, retournez le destinataire. Sinon, créez un nouvel objet exception de la même classe que le destinataire, mais avec un message égal à string.to_str.

Comme beaucoup devineraient:

...
catch StandardError => e
  raise $!
...

soulève la même erreur référencée par $ !, la même chose que simplement appeler:

...
catch StandardError => e
  raise
...

mais probablement pas pour les raisons que l'on pourrait penser. Dans ce cas, l'appel à raiseest NOT soulève simplement l'objet dans $!... il soulève le résultat de $!.exception(nil), qui dans ce cas se produit être $!.

Pour clarifier ce comportement, considérons ce code jouet:

      class TestError < StandardError
        def initialize(message=nil)
          puts 'initialize'
          super
        end
        def exception(message=nil)
          puts 'exception'
          return self if message.nil? || message == self
          super
        end
      end

L'exécuter (c'est la même chose que l'échantillon de @Andrew Grimm que j'ai cité ci-dessus):

2.3.1 :071 > begin ; raise TestError, 'message' ; rescue => e ; puts e ; end

résulte en:

initialize
message

Donc, une erreur TestError était initializename__d, rescuename__d et son message a été imprimé. Jusqu'ici tout va bien. Un deuxième test (analogue à l'exemple de @ BoosterStage cité ci-dessus):

2.3.1 :073 > begin ; raise TestError.new('foo'), 'bar' ; rescue => e ; puts e ; end

Les résultats quelque peu surprenants:

initialize
exception
bar

Ainsi, TestErrorétait initializename__d avec 'foo', mais alors #raise a appelé #exception sur le premier argument (une instance de TestErrorname__) et a transmis le message de 'bar' créez une seconde instance de TestErrorname__, qui est finalement levée .

TIL.

De plus, comme @Sim, je suis very soucieux de préserver tout contexte de trace arrière, mais au lieu d'implémenter un gestionnaire d'erreurs personnalisé comme son raise_with_new_message, Ruby Exception#cause a mon dos: chaque fois que je veux intercepter une erreur, l'enroulez dans une erreur spécifique au domaine et ensuite relance that erreur, j'ai toujours la trace d'origine disponible via #cause sur l’erreur générée spécifique au domaine.

Le but de tout cela est que - comme @Andrew Grimm - je veux soulever des erreurs avec plus de contexte; Plus précisément, je souhaite uniquement générer des erreurs spécifiques à un domaine à partir de certains points de mon application pouvant comporter de nombreux modes de défaillance liés au réseau. Ensuite, mes erreurs peuvent être signalées pour traiter les erreurs de domaine au plus haut niveau de mon application et j'ai tout le contexte dont j'ai besoin pour la journalisation/la création de rapports en appelant #cause de manière récursive jusqu'à la "cause première".

J'utilise quelque chose comme ça:

class BaseDomainError < StandardError
  attr_reader :extra
  def initialize(message = nil, extra = nil)
    super(message)
    @extra = extra
  end
end
class ServerDomainError < BaseDomainError; end

Ensuite, si j'utilise quelque chose comme Faraday pour passer des appels vers un service distant REST, je peux envelopper toutes les erreurs possibles dans une erreur propre à un domaine et transmettre des informations supplémentaires (ce qui, selon moi, est la question initiale de ce service). fil):

class ServiceX
  def initialize(foo)
    @foo = foo
  end
  def get_data(args)
    begin
      # This method is not defined and calling it will raise an error
      make_network_call_to_service_x(args)
    rescue StandardError => e
      raise ServerDomainError.new('error calling service x', binding)
    end
  end
end

Oui, c'est vrai: je viens littéralement de réaliser que je peux définir les informations extrasur le bindingpour saisir tous les vars locaux définis au moment où ServerDomainErrorest instancié/levé. Ce code de test:

begin
  ServiceX.new(:bar).get_data(a: 1, b: 2)
rescue
  puts $!.extra.receiver
  puts $!.extra.local_variables.join(', ')
  puts $!.extra.local_variable_get(:args)
  puts $!.extra.local_variable_get(:e)
  puts eval('self.instance_variables', $!.extra)
  puts eval('self.instance_variable_get(:@foo)', $!.extra)
end

affichera:

exception
#<ServiceX:0x00007f9b10c9ef48>
args, e
{:a=>1, :b=>2}
undefined method `make_network_call_to_service_x' for #<ServiceX:0x00007f9b10c9ef48 @foo=:bar>
@foo
bar

Désormais, un contrôleur Rails appelant ServiceX n'a ​​pas particulièrement besoin de savoir que ServiceX utilise Faraday (ou gRPC, ou autre), il passe simplement l'appel et gère BaseDomainErrorname__. Encore une fois: à des fins de journalisation, un seul gestionnaire au niveau supérieur peut consigner de manière récursive tous les #causes de toutes les erreurs interceptées, et pour toute instance de BaseDomainErrordans la chaîne d'erreurs, il peut également enregistrer les valeurs de extraname__, y compris les variables locales. extraites de la bindingname __ (s) encapsulée.

J'espère que cette tournée a été aussi utile pour les autres que pour moi. J'ai beaucoup appris.

UPDATE: Skiptrace semble ajouter les liaisons aux erreurs Ruby.

Voir aussi cet autre article pour obtenir des informations sur la manière dont la mise en œuvre de Exception#exception va cloner l'objet (instance de copie variables).

2
Lemon Cat

Voici ce que j'ai fini par faire:

Exception.class_eval do
  def prepend_message(message)
    mod = Module.new do
      define_method :to_s do
        message + super()
      end
    end
    self.extend mod
  end

  def append_message(message)
    mod = Module.new do
      define_method :to_s do
        super() + message
      end
    end
    self.extend mod
  end
end

Exemples:

strings = %w[a b c]
strings.each_with_index do |string, i|
  begin
    do_risky_operation(string)
  rescue
    raise $!.prepend_message "Problem with string number #{i}:"
  end
end
=> NoMethodError: Problem with string number 0:undefined method `do_risky_operation' for main:Object

et:

pry(main)> exception = 0/0 rescue $!
=> #<ZeroDivisionError: divided by 0>
pry(main)> exception = exception.append_message('. With additional info!')
=> #<ZeroDivisionError: divided by 0. With additional info!>
pry(main)> exception.message
=> "divided by 0. With additional info!"
pry(main)> exception.to_s
=> "divided by 0. With additional info!"
pry(main)> exception.inspect
=> "#<ZeroDivisionError: divided by 0. With additional info!>"

Cela ressemble à la réponse de Mark Rushakoff mais:

  1. Remplace to_s au lieu de message car par défaut message est défini simplement comme to_s (au moins en Ruby 2.0 et 2.2 où je l’ai testé)
  2. Appelle extend pour vous au lieu de demander à l'appelant d'effectuer cette étape supplémentaire.
  3. Utilise define_method et une fermeture pour que la variable locale message puisse être référencée. Lorsque j’ai essayé d’utiliser une classe variable @@message, elle a averti: "attention: accès aux variables de classe depuis le niveau le plus élevé" (Voir cette question : "Puisque vous ne créez pas de classe avec le mot clé class, votre variable de classe est définie sur Object, pas [votre module anonyme] ")

Caractéristiques:

  • Facile à utiliser
  • Réutilise le même objet (au lieu de créer une nouvelle instance de la classe), préservant ainsi des éléments tels que l'identité de l'objet, la classe et la trace
  • to_s, message et inspect répondent tous correctement
  • Peut être utilisé avec une exception déjà stockée dans une variable; vous n'avez pas besoin de re-relancer quoi que ce soit (comme la solution qui implique le passage de la trace de retour à relancer: raise $!, …, $!.backtrace). Cela était important pour moi car l'exception était transmise à ma méthode de journalisation, ce n'était pas quelque chose que j'avais sauvé moi-même.
0
Tyler Rick