Was sind eigentlich Singletons in Ruby und was hat es mit der Meldung "singleton can't be dumped" auf sich, auf die man als Rails-Entwickler hin und wieder stößt?

Zunächst einmal: Der Begriff Singleton, so wie ihn Ruby benutzt, hat nicht viel mit dem Design-Pattern zu tun, das man kennt. Es geht viel mehr um Klassen, die objektspezifisch sind. Also alle Klassen, die man mit dynamischen Methoden wie instance_eval verändert hat. Eine sehr gute Zusammenfassung auf englisch bietet dieser Artikel, auch wenn er schon etwas älter ist.

Interessant wird diese Thematik dann, wenn man Caching mit memcached betreibt. Dort werden die Daten mittels Marshal.dump() in einen String verwandelt. Der Trick dabei ist, dass sämtliche Informationen zu einem Objekt - also auch alle Assoziationen usw. - in diesen String einfließen. Beim späteren Wiederherstellen des Cache-Inhalts mittels Marshal.load() wird aus dem String das komplette Objekt erzeugt. Dazu muss aber klar sein, wie die Klasse aussieht. In diesem Beispiel funktioniert das nicht:

obj = Object.new
def obj.own_method
  puts "test"
end
Marshal.dump(obj) # TypeError
Marshal.load() hätte später keine Chance, obj wieder herzustellen, da die Information über own_method verloren wäre.

Übrigens: Hier liegt auch ein Unterschied in clone und dup. Beide erzeugen eine "Shallow copy", in denen Instanz-Variablen übernommen, deren Objekte aber nicht selbst kopiert werden. Während clone aber auch Singleton-Methoden kopiert, erzeugt dup im Prinzip nur ein neues Objekt mittels seines Konstruktors. Dabei werden die Singleton-Methoden nicht übernommen.

Leider wird man oft, wenn man auf diesen Fehler trifft, gar nicht selbst "schuld" sein, sondern muss den Fehler tiefer im Framework suchen. Und das kostet Zeit! Hilfreich ist es, zunächst einmal herauszufinden, was für ein Objekt die Ursache ist. Hier können temporäre Logger-Ausgaben an der richtigen Stelle helfen. Anhand des Stacktraces findet man leicht die Stelle, an der Marshal.dump() aufgerufen wird. Wichtig: Lokal haben wir oft mit Memory-Cache getestet, wo der Fehler nicht auftritt. Will man die Stelle finden, an der die Exception auftritt, muss man auch tatsächlich memcached nutzen.

# in memcache-client-1.8.5/lib/memcache.rb, Zeile 357,
# in anderen Versionen ähnlich
def set(key, value, expiry = 0, raw = false)
	raise MemCacheError, "Update of readonly cache" if @readonly
	
	Rails.logger.info value.inspect # Gibt uns nähere Informationen über das Objekt
	Rails.logger.info value.singleton_methods # Falls es ein Singleton ist, 
	                                          # bekommen wir hier alle Methoden geliefert,
	                                          # die dynamisch deklariert wurden
	Rails.logger.info value.instance_variables.inspect # Zeigt Details aller Instanz-
	                                                   # Variablen an (von denen auch 
	                                                   # welche Singletons sein können).
	Rails.logger.info caller(10) # Zeigt einen Stacktrace an, 
	                             # wobei die ersten 10 Zeilen abgeschnitten werden
	value = Marshal.dump value unless raw
	...

Beispiel:

Wir hatten ein solches Phänomen mit dieser Infrastruktur:

  • Rails 2.3.5
  • has_and_belongs_to_many_with_deferred_save - ein Plugin, durch das HABTM-Beziehungen genau wie Attribute erst gespeichert werden, wenn man das Hauptobjekt speichert
  • i18n mit dem ActiveRecord-Backend und aktiviertem I18n-Cache.
Nun haben wir folgenden einfachen Code:
# in config/locales/de.yml
de:
  activerecord:
    errors:
      messages:
        blank: muss ausgefüllt werden

# in app/models/some_model.rb
class SomeModel < ActiveRecord::Base

  has_and_belongs_to_many_with_deferred_save :multi

  validate :presence_of_multi

  def presence_of_multi
    errors.add_on_blank(:multi)
  end
end

obj = SomeModel.new
obj.save # => false
obj.errors.full_messages # => TypeError: singleton can't be dumped
Was passiert?
Rails stellt fest, dass obj.multi leer ist und sucht eine Fehlermeldung. Zunächst vermutet es diese aber im Scope "de.activerecord.errors.models.some_model.attributes.multi.blank". Dort wird es nicht fündig und arbeitet sich weiter hoch, bis es schlussendlich die "blank"-Übersetzung am gewünschten Ort findet.

Aber sobald es die erste Übersetzung nicht findet, erzeugt Rails ein MissingTranslationData-Objekt. Und dieses wird durch den modularen Aufbau des I18n-ChainBackends zunächst in den Cache geschrieben. Das MissingTranslationData-Objekt enthält u.a. eine Referenz auf das zu validierende Element - unsere multi-Liste. Diese ist aber nur dem Anschein nach ein Array. In Wirklichkeit ist es ein Proxy-Objekt, welches durch unser Plugin mit einigen zusätzlichen Singleton-Methoden versehen wurde.

Um das Problem zu umgehen, gibt es einige Ansätze, aber alle sind für größere Projekte nicht umsetzbar und stellen eben keine richtige Lösung dar. Wirklich gelöst werden kann dies nur, indem entweder das I18n-Backend (Warum werden Zwischenergebnisse in den Cache geschrieben?) oder das Plugin (braucht es die Singleton-Methoden?) geändert wird.

Ich habe mich für letzteres entschieden und das Gem geforkt. Das neue Gem deferred_associations vermeidet Singletons, indem es ein Proxy-Array definiert, welches sich im Prinzip genauso verhält, wie die Singleton-Instanzen des alten Gems. Wie der Name vermuten lässt, funktioniert das Prinzip nun auch mit "has_many"-Beziehungen. Und zusätzlich wurde eine längst überfällige Unterstützung für Rails3 eingebaut.

Fazit: Bevor man Singleton-Methoden benutzt, sollte man sich genau überlegen, ob das notwendig ist. Insbesondere bei Objekten, die vom Anwender einer API verwendet werden, sollte man darauf verzichten, Objekte derart dynamisch zu ändern - man weiß nie, was der Anwender mit ihnen alles machen will.