Using FIRM
Loading FIRM
To use FIRM in your application the library must be required like this.
require 'firm'
Optionally the nokogiri
gem (if installed) can be required before FIRM to enable support for the XML output format like this.
require 'nokogiri'
require 'firm'
Serialization and deserialization
Any class which has FIRM (de-)serialization support will provide a #serialize
instance method and a #deserialize
class method (this includes the core Ruby classes supported out of the box).
The #serialize
method has the following signature.
def serialize(pretty: false, format: Serializable.default_format) def serialize(io, pretty: false, format: Serializable.default_format)
Serialize this object.
Overloads
serialize(pretty: false, format: Serializable.default_format)
Returns serialized data.
Parameters:
pretty
(Boolean) (defaults to:false
) - if true specifies to generate pretty formatted output if possible
format
(Symbol, String) (defaults to:Serializable.default_format
) - specifies output formatReturns:
(String) - serialized data
serialize(io, pretty: false, format: Serializable.default_format)
Writes serialized data to given stream.
Parameters:
io
(IO) - IO(-like) object to write serialized data to
pretty
(Boolean) (defaults to:false
) - if true specifies to generate pretty formatted output if possible
format
(Symbol, String) (defaults to:Serializable.default_format
) - specifies output formatReturns:
(String) - serialized data
Out of the box the default format will be :json
. This format can be overruled either by providing the format
argument to the #serialize
method or by altering the FIRM::Serializable.default_form
setting process wide as follows.
require 'firm'
# application code ...
# at some setup time
FIRM::Serializable.default_format = :yaml
Out of the box the formats :json
and :yaml
are supported. In case the nokogiri
gem has been installed and loaded before the firm
gem the :xml
format will be available as well.
The #deserialization
class method has the following signature.
def self.deserialize(source, format: Serializable.default_format)
Deserializes object from source data
Parameters:
source
(IO,String) - source data (String or IO(-like object))
format
(Symbol, String) - data format of sourceReturns:
(Object) - deserialized object
As said this method is available as a class method on any serializable class but in addition it is also available on the FIRM
module itself (see example below).
The following example shows how to serialize an object and deserialize it at a later point.
require 'firm'
# initialize settings object
settings = OpenStruct.new({
country: 'NL',
language: 'EN',
defaults: {
background: 'GREEN',
foreground: 'YELLOW'
}
#...
})
# serialize settings object to JSON file
File.open('settings.json', 'w+') { |f| settings.serialize(f) }
# ...
# deserialize settings from JSON file
new_settings = File.open('settings.json') { |f| FIRM.deserialize(f) }
Core Ruby class serialization support
FIRM supports (de-)serializing the following core Ruby objects out of the box:
-
NilClass
-
TrueClass
&FalseClass
-
Integer
-
Float
-
Rational
-
Complex
-
BigDecimal
(if loaded; not default anymore starting from Ruby 3.4) -
String
-
Symbol
-
Array
-
Hash
-
Range
-
Regexp
-
Time
-
Struct
-
Set
-
OpenStruct
-
Date
-
DateTime
For security reasons FIRM does not support direct (de-)serializing of Class
objects but will rather serialize (and deserialize) these as their scoped string names.
This means that code like FIRM.deserialize(MyClass.serialize)
will result in a string with the scoped name of the class MyClass
.
Customized property setters (see below) can be used to resolve Class objects from these names if really needed.
User defined class serialization
User defined classes can be declared serializable for FIRM by including the FIRM::Serializable
mixin module.
require 'firm'
class Point
# declare serializable
include FIRM::Serializable
# ...
end
Of course declaring a class to be serializable has not much use without defining what properties of any instances of the class need to be (de-)serialized. Including the FIRM::Serializable
module extends the including class with a number of class methods to do just that.
Define serializable properties
To define a serializable property for a class the #property
method can be used which has the following signature.
def self.property(*props, force: false) def self.property(hash, force: false) def self.property(*props, force: false, handler: nil, &block)
Adds (a) serializable property(-ies) for instances of his class (and derived classes)
Overloads:
property(*props, force: false)
Specifies one or more serialized properties.
The serialization framework will determine the availability of setter and getter methods automatically by looking for methods
"#{prop_id}=(v)"
,"#set{prop_id}(v)"
or"#{prop_id}(v)"
for setters and"#{prop_id}()"
or"#get{prop_id}"
for getters.Parameters:
props
(Symbol,String) - one or more ids of serializable properties
force
(Boolean) - overrides any#disable_serialize
for the properties specified
optional
(Object) - indicates optionalityif
false
the property will not be optional
true
means optional if the serialized value ==nil
</br>any value other than ‘false’ or ‘true’ means optional if the serialize value equals that value;
alternatively a Proc, Lambda (gets the object and the property id passed) or UnboundMethod (gets the property id passed) can be specified which is called at serialization time to determine the default (optional) value
Returns:
(undefined)
property(hash, force: false)
Specifies one or more serialized properties with associated setter/getter method ids/procs/lambda-s. Procs with setter support MUST accept 1 or 2 arguments (1 for getter, 2 for setter) where the first argument will always be the property owner’s object instance and the second (in case of a setter proc) the value to restore.
NOTE Use
*val
to specify the optional value argument for setter requests instead ofval=nil
to be able to support setting explicit nil values.Parameters:
hash
(Hash) - a hash of pairs of property ids and getter/setter procs
force
(Boolean) - overrides any#disable_serialize
for the properties specified
optional
(Object) - indicates optionalityif
false
the property will not be optional
true
means optional if the serialized value ==nil
</br>any value other than ‘false’ or ‘true’ means optional if the serialize value equals that value;
alternatively a Proc, Lambda (gets the object and the property id passed) or UnboundMethod (gets the property id passed) can be specified which is called at serialization time to determine the default (optional) value
Returns:
(undefined)
property(*props, force: false, handler: nil, &block)
Specifies one or more serialized properties with a getter/setter handler proc/method/block. The getter/setter proc or block should accept either 2 (property id and object for getter) or 3 arguments (property id, object and value for setter) and is assumed to handle getter/setter requests for all specified properties. The getter/setter method should accept either 1 (property id for getter) or 2 arguments (property id and value for setter) and is assumed to handle getter/setter requests for all specified properties.
NOTE Use
*val
to specify the optional value argument for setter requests instead ofval=nil
to be able to support setting explicit nil values.Parameters:
props
(Symbol,String) - one or more ids of serializable properties
force
(Boolean) - overrides any#disable_serialize
for the properties specified
optional
(Object) - indicates optionalityif
false
the property will not be optional
true
means optional if the serialized value ==nil
</br>any value other than ‘false’ or ‘true’ means optional if the serialize value equals that value;
alternatively a Proc, Lambda (gets the object and the property id passed) or UnboundMethod (gets the property id passed) can be specified which is called at serialization time to determine the default (optional) value
Yield Parameters:
id
(Symbol,String) - property id
obj
(Object) - object instance
val
(Object) - optional property value to set in case of setter requestReturns:
(undefined)
There are 2 requirements for declaring a serializable property:
-
the property’s data type needs to be serializable (duh!);
-
there need to be appropriate getter & setter methods/procedures defined (see below).
The simplest property declaration takes this form.
require 'firm'
class Point
# declare serializable
include FIRM::Serializable
# define serializable properties
property :x, :y
def initialize(*args)
if args.empty?
@x = @y = 0
else
@x, @y = *args
end
end
attr_accessor :x, :y
end
This defines the serializable properties :x
and :y
for the Point
class. By default the FIRM library will identify getter and setter methods by looking for standard attribute accessor methods named #property_id()
(getter) and #property_id=(val)
. If these can not be found the library looks for #get_property_id()
(getter) and #set_property_id(val)
(or #property_id(v)
) respectively.
The following example shows usage of the alternative standard getter / setter scheme.
require 'firm'
class Point
# declare serializable
include FIRM::Serializable
# define serializable properties
property :x, :y
def initialize(*args)
if args.empty?
@x = @y = 0
else
@x, @y = *args
end
end
def get_x
@x
end
def get_y
@y
end
protected
def set_x(val)
@x = val
end
def set_y(val)
@y = val
end
end
This example also shows that getters and setters do not necessarily need to be public. Thus the user defined class can be immutable from the viewpoint of the application but still be serializable.
In case the standard getter / setter scheme does not provide an acceptable solution there is another option to define serializable properties with associated custom setter and setter methods or procs/lambdas using the second form of the #property
method. The following example demonstrates this option.
require 'firm'
class Point
# declare serializable
include FIRM::Serializable
# define serializable properties
property x: :serialize_x, # use instance method #serialize_x
y: ->(pt, *val) { # use given lambda
if val.empty?
pt.y
else
pt.__send__(:set_y, *val)
end
}
def initialize(*args)
if args.empty?
@x = @y = 0
else
@x, @y = *args
end
end
attr_reader :x, :y
protected
def serialize_x(*val)
@x = *val unless val.empty?
@x
end
def set_y(val)
@y = val
end
end
Finally there is a last customization option by using the last form of the #property
method with which a single serialization handler (method, proc or block) can be defined for multiple properties. The following example demonstrates this option.
require 'firm'
class Point
# declare serializable
include FIRM::Serializable
# define serializable properties
property :x, :y, handler: :serialize_point # use instance method #serialize_point
# alternatively a block-form could be used
# property(:x, :y) do |pt, prop_id, *val|
# case prop_id
# when :x
# # ...
# when :y
# # ...
# end
# end
def initialize(*args)
if args.empty?
@x = @y = 0
else
@x, @y = *args
end
end
attr_reader :x, :y
protected
def serialize_point(prop_id, *val)
case prop_id
when :x
@x = *val unless val.empty?
@x
when :y
@y = *val unless val.empty?
@y
end
end
end
The #property
method is also aliased as #properties
and #contains
for syntactical convenience.
Serializing without property definitions
It is not mandatory to define properties for a serializable class. In case object existence is all the state that is required (or such object’s state needs to be freshly initialized when restored) including the mixin module is all that is needed. This will than result in an ‘empty’ serialized instance of the particular class that will simply be recreated when deserialized.
Another situation where it might not be needed to define properties is in the case of a derived class that does not define any additional state but only additional functionality.
Excluding a base property
In some cases a derived class may need to suppress serialization of a property of it’s base class because the derived class may for some reason (re-)initialize this property itself depending on some external factor. For these cases the #excluded_property
method is available with the following signature:
def self.excluded_property(*props)
Excludes a serializable property for instances of this class.
Parameters:
props
(Symbol,String) - one or more ids of serializable propertiesReturns:
(undefined)
The following example showcases use of this property.
require 'firm'
Point = Struct.new(:x, :y) do |struct_klass|
def +(other)
Point.new(self.x + other.x, self.y + other.y)
end
end
Size = Struct.new(:width, :height)
class Rect
# declare serializable
include FIRM::Serializable
# define serializable properties
property :position, :size
def initialize(*args)
if args.empty?
@position = @size = nil
else
@position, @size = *args
end
end
attr_accessor :position, :size
end
class RelativeRect < Rect
# no need to include mixin since this is inherited
class << self
def origin
@origin ||= Point.new(0, 0)
end
def origin=(org)
@origin = org
end
end
# persist new property
property :offset
# exclude base property :position
excluded_property :position
def initialize(*args)
super()
unless args.empty?
offs, @size = *args
set_offset(offs)
end
end
attr_reader :offset
def set_offset(offs)
@offset = offs
@position = self.class.origin + @offset
end
private :position=
end
# set the current origin for relative rectangles
RelativeRect.origin = Point.new(10,10)
# serializing a regular Rect instance will persist position and size
rect = Rect.new(Point.new(33,33), Size.new(10, 40))
rect_json = rect.serialize
# while serializing a RelativeRect will persist offset and size
relrect = RelativeRect.new(Point.new(5,5), Size.new(20, 65))
relrect_json = relrect.serialize
# Set new origin for relative rectangles
RelativeRect.origin = Point.new(20,40)
# deserializing the regular Rect will restore it as it was
rect2 = Rect.deserialize(rect_json)
# deserializing the RelativeRect will restore it at a new position
relrect2 = RelativeRect.deserialize(relrect_json)
Selective serialization
In other cases a derived class may add a fixed item to a base class collection. When the base collection is persisted this fixed item should not be serialized as the derived constructor would always add the fixed item.
For these cases the #disable_serialize
instance method is available for any user defined serializable class. This method has the following signature.
def disable_serialize
Disables serialization for this object as a single property or as part of a property container (array or set).
Returns:
(undefined)
The following example showcases using this method.
require 'firm'
class Point
# define the class as serializable
include FIRM::Serializable
# declare the serializable properties of instances of this class
properties :x, :y
# allow instantiation using the default ctor (no args)
# (custom creation schemes can be defined)
def initialize(*args)
if args.empty?
@x = @y = 0
else
@x, @y = *args
end
end
# define the default getter/setter support FIRM will use when (de-)serializing properties
attr_accessor :x, :y
end
class Path
# declare serializable
include FIRM::Serializable
property :points
def initialize(points = [])
@points = points
end
attr_reader :points
def set_points(pts)
@points.concat(pts)
end
private :set_points
end
class ExtendedPath < Path
def initialize(points = [])
super
# create a fixed point
pt = Point.new(1, 2)
# disable serializing this instance
pt.disable_serialize
# insert this as a fixed origin point
@points.insert(0, pt)
end
end
# serializing a regular Path will persist all it's points
path = Path.new([Point.new(10,10), Point.new(20,20), Point.new(30,30)])
path_json = path.serialize
# serializing an ExtendedPath will persist all points except the fixed origin
extpath = ExtendedPath.new([Point.new(15,15), Point.new(25,25), Point.new(35,35)])
extpath_json = extpath.serialize
# deserializing both object will still restore them as they were
path2 = Path.deserialize(path_json)
extpath2 = ExtendedPath.deserialize(extpath_json)
An additional requirement may be to persist the additional item from the derived class as it is not fixed but rather externally defined. This is where the :force
parameter of the #property
method is of use as shown in the following example.
require 'firm'
class Point
# define the class as serializable
include FIRM::Serializable
# declare the serializable properties of instances of this class
properties :x, :y
# allow instantiation using the default ctor (no args)
# (custom creation schemes can be defined)
def initialize(*args)
if args.empty?
@x = @y = 0
else
@x, @y = *args
end
end
# define the default getter/setter support FIRM will use when (de-)serializing properties
attr_accessor :x, :y
end
class Path
# declare serializable
include FIRM::Serializable
property :points
def initialize(points = [])
@points = points
end
attr_reader :points
def set_points(pts)
@points.concat(pts)
end
private :set_points
end
class ExtendedPath < Path
# declare a serialization property that must **always** be persisted
property :origin, force: true
def initialize(points = [], origin: nil)
super(points)
self.origin = origin
end
attr_reader :origin
def origin=(org)
# delete any existing origin
@points.delete_at(0) if @origin
# set the new origin
@origin = org
if @origin
# disable serializing this instance if defined
@origin.disable_serialize
# insert this as a origin point
@points.insert(0, @origin)
end
end
end
# serializing a regular Path will persist all it's points
path = Path.new([Point.new(10,10), Point.new(20,20), Point.new(30,30)])
path_json = path.serialize
# serializing an ExtendedPath will persist all points with the assigned origin as a separate property
extpath = ExtendedPath.new([Point.new(15,15), Point.new(25,25), Point.new(35,35)], origin: Point.new(1,2))
extpath_json = extpath.serialize
# deserializing both object will still restore them as they were
path2 = Path.deserialize(path_json)
extpath2 = ExtendedPath.deserialize(extpath_json)
Object aliases
The requirements in the previous section could also have been met by using persisted object aliases which without FIRM are only supported with YAML. FIRM however implements functionality to also support object aliasing with JSON and XML.
When applying aliasing the FIRM code will not serialize multiple copies of an object instance referenced multiple times in a dataset being serialized but instead it will serialize a single copy the first time the instance is encountered with a special ‘anchor’ id attached and serialize only shallow ‘alias’ references to this ‘anchor’ id for any other reference of the same instance encountered while serializing. On deserialization the same instance will be restored for the ‘anchored’ copy as well as any ‘alias’ references.
Aliasing is supported for user defined serializable classes, the core container classes (Array
, Hash
, Set
), the OpenStruct
class and named Struct
(-derived) classes.
NOTE: When using the
:yaml
format aliasing will also be supported for other classes likeTime
,Date
,DateTime
etc. FIRM however does not extend it’s aliasing support to these classes for:json
or:xml
format.
The following example showcases this functionality.
require 'firm'
class Point
# define the class as serializable
include FIRM::Serializable
# declare the serializable properties of instances of this class
properties :x, :y
# allow instantiation using the default ctor (no args)
# (custom creation schemes can be defined)
def initialize(*args)
if args.empty?
@x = @y = 0
else
@x, @y = *args
end
end
# define the default getter/setter support FIRM will use when (de-)serializing properties
attr_accessor :x, :y
end
class Path
# declare serializable
include FIRM::Serializable
property :points
def initialize(points = [])
@points = points
end
attr_reader :points
def set_points(pts)
@points.concat(pts)
end
private :set_points
end
class ExtendedPath < Path
# declare a serialization property
property origin: :serialize_origin
def initialize(points = [], origin: nil)
super(points)
self.origin = origin
end
attr_reader :origin
def origin=(org)
# delete any existing origin
@points.delete(@origin) if @origin
# set the new origin
@origin = org
if @origin
# insert this as a origin point
@points.insert(0, @origin)
end
end
def serialize_origin(*val)
@origin = *val unless val.empty?
@origin
end
end
# serializing a regular Path will persist all it's points
path = Path.new([Point.new(10,10), Point.new(20,20), Point.new(30,30)])
path_json = path.serialize
# serializing an ExtendedPath will persist all points and the assigned origin as a separate (aliased) property
extpath = ExtendedPath.new([Point.new(15,15), Point.new(25,25), Point.new(35,35)], origin: Point.new(1,2))
extpath_json = extpath.serialize
# deserializing both object will still restore them as they were
path2 = Path.deserialize(path_json)
extpath2 = ExtendedPath.deserialize(extpath_json)
Cyclic references
FIRM automatically recognizes and handles cyclic references of aliasable objects.
As this support is based on the FIRM aliasing support (except for the YAML format) handling cyclic references is restricted to those classes for which FIRM supports aliasing.
CAVEAT: The JRuby Psych implementation has a bug that breaks cyclic reference support for
Set
objects.
Custom initialization for deserialization
By default FIRM deserialization will initialize class instances for deserialization by calling the #initialize
instance method of a class without arguments (default construction), i.e. instance.__send__(:initialize)
. This may not always be appropriate for various reasons. For these cases it is possible to overload the default initialization method for user defined serializable classes.
In case customized initialization is required overload the (protected) #init_from_serialized(data)
instance method as shown in the following example.
require 'firm'
# Singleton class without public constructor
class Singleton
include FIRM::Serializable
class << self
private :new
def instance
@instance ||= self.new
end
# FIRM creates new instances for deserialization by calling #allocate followed by #init_from_serialized
def allocate
instance
end
end
# Overload the deserialization initializer.
# Initializes a newly allocated instance for subsequent deserialization (optionally initializing
# using the given data hash).
# The default implementation calls the standard #initialize method without arguments (default constructor)
# and leaves the property restoration to a subsequent call to the instance method #from_serialized(data).
# Classes that do not support a default constructor can override this class method and
# implement a custom initialization scheme.
# @param [Object] _data hash-like object containing deserialized property data (symbol keys)
# @return [self] the object
def init_from_serialized(_data)
# nothing to initialize (already done in allocate)
self
end
protected :init_from_serialized
end
Deserialization finalizers
User defined classes may also depend on non-trivial initialization at construction time derived from initial construction arguments. In these cases simple default construction followed by property restoration may also not suffice.
In essence there are three options to handle these cases.
The first option is to define customized, non-trivial, serialization handlers for certain properties that will not only handle property restoration but may also (re-)initialize dependent, non-persisted, attributes (as in the case of the first ExtendedPath
example above).
This approach however has limits in that it does not scale to cases where the dependent, non-persisted, attributes rely on more than one restored property. For these cases the approach of overloading the #create_for_deserialize
method may be more appropriate.
In cases which involve restoring large amounts of persisted properties this may however be cumbersome. Instead of overloading #create_for_deserialize
there is therefor another customization option available that allows to define deserialization finalizers.
A deserialization finalizer is a method, proc or block that will be called for a deserialized object after all it’s serialized properties have been deserialized and restored.
FIRM supports a default finalizer scheme but also allows alternative definitions (or disabling) of deserialization finalizers using the #define_deserialize_finalizer
class method which has the following signature:
def self.define_deserialize_finalizer(meth) def self.define_deserialize_finalizer(&block)
Defines a finalizer method/proc/block to be called after all properties have been deserialized and restored.
Procs or blocks will be called with the deserialized object as the single argument.
Unbound methods will be bound to the deserialized object before calling.
Explicitly specifying nil will undefine the finalizer.Overloads
define_deserialize_finalizer(meth)
Parameters:
meth
(Symbol, String, Proc, UnboundMethod, nil) - name of instance method, proc or method to call for finalizingReturns:
(undefined)
define_deserialize_finalizer(&block)
Yield Parameters:
obj
(Object) - deserialized object to finalizeReturns:
(undefined)
Default finalizer
By default FIRM assumes that with any user defined serializable class that defines a #create
instance method with no arguments this method is intended als an initialization finalizer and will use that method as the deserialization finalizer unless defined differently by a call to #define_deserialize_finalizer
.
The following example shows a serializable class with a #create
finalizer.
class CreateFinalizer
include FIRM::Serializable
property :value
def initialize(val = nil)
@value = val
create if val
end
# default finalizer
def create
@symbol = case @value
when 1
:one
when 2
:two
when 3
:three
else
:none
end
end
attr_reader :value, :symbol
def set_value(v)
@value = v
end
private :set_value
end
In case a user defined class defines a matching #create
method that for some reason is not to be used as a deserialization finalizer the default assignment can be disabled by using a call to #define_deserialize_finalizer
with a nil
argument as follows.
class SomeClass
include FIRM::Serializable
properties :a, :b, :c
# disable any deserialization finalizer
define_deserialize_finalizer nil
# should not be used as finalizer
def create
# ...
end
end
Instead of disabling #define_deserialize_finalizer
could also be used to define an alternative finalizer.
Alternative finalizer
Alternatively #define_deserialize_finalizer
can be used to explicitly define a finalizer from:
-
a name (
String
orSymbol
) identifying an instance method of the serializable class; -
a lambda or Proc;
-
a block.
Instance methods should not expect any arguments. Procs or blocks should expect the deserialized object instance to be passed as the single argument.