Tue, 30 Jun 2009

Transparent backwards compatibility using Moose

The current Net::Twitter is a complete rewrite of a prior version using Moose. One of my design goals was transparent backwards compatibility so that existing code based on Net::Twitter would continue to run, unchanged (provided the additional required modules were installed).

By using Moose roles I was able to factor out optional features and legacy behavior into separate classes. The core functionality became Net::Twitter::Core. Net::Twitter itself became an object factory applying appropriate roles to create a concrete classes including the appropriate mix of optional features then instances of those classes.

The Legacy role provides attributes and functionality that was not carried forward into Net::Twitter::Core from Net::Twitter version 2.12. It modifies the new core functionality with Moose method modifiers (in particular, the around modifier). It also applies the set of other, optional roles required to make Net::Twitter backwards compatible.

The interesting and challenging work was getting the Net::Twitter factory class correct. Initially, I used MooseX::Traits in Net::Twitter::Core which provides the role application feature I needed with its new_with_traits method. But simply having Net::Twitter->new call Net::Twitter::Core->new_with_traits wasn’t sufficient for Net::Twitter derived classes. I ended up stealing a bit of the code and the concepts from MooseX::Traits to get the behavior I needed.

Net::Twitter’s only public method is new. It can be called with a traits argument, or with a legacy argument which is just a shortcut to save a bit of typing. Traits and roles are the same thing. I use the terms interchangeably here.

# The following are all equivalent, creating objects
# backwards compatible with Net::Twitter 2.12:
my $nt = Net::Twitter->new;
my $nt = Net::Twitter->new(legacy => 1);
my $nt = Net::Twitter->new(traits => ['Legacy']);

# New can also create non-legacy variations
my $nt = Net::Twitter->new(traits => ['API::REST', 'OAuth']);

Here is the complete new method:

sub new {
    my $class = shift;

    croak '"new" is not an instance method' if ref $class;

    my %args = @_ == 1 && ref $_[0] eq 'HASH' ? %{$_[0]} : @_;

    my $traits = delete $args{traits};

    if ( defined (my $legacy = delete $args{legacy}) ) {
        croak "Options 'legacy' and 'traits' are mutually exclusive. Use only one."
            if $traits;

        $traits = [ $legacy ? 'Legacy' : 'API::REST' ];
    }

    $traits ||= [ qw/Legacy/ ];
    $traits   = [ $class->$resolve_traits(@$traits) ];

    my $superclasses = [ 'Net::Twitter::Core' ];
    my $meta = $create_anon_class->($superclasses, $traits, 1);

    # create a Net::Twitter::Core object with roles applied
    my $new = $meta->name->new(%args);

    # rebless it to include a subclass, if necessary
    if ( $class ne __PACKAGE__ ) {
        unshift @$superclasses, $class;
        my $final_meta = $create_anon_class->($superclasses, $traits, 0);
        bless $new, $final_meta->name;
    }

    return $new;
}

The new method expects either a single HASH reference or a list of key/value pairs. It checks for and dereferences a HASH ref argument. Then it normalizes the optional legacy argument to the appropriate traits. If no traits or legacy argument is provided, it uses the default Legacy trait.

The first interesting bit of code is the call to $create_anon_class, which may be called twice.

The first call to $create_anon_class creates a meta-class from Net::Twitter::Core with the appropriate roles applied and assigns it to the variable $meta. Anonymous classes aren’t /really/ anonymous; they have auto-generated names. So, after creating the meta class, we create an instance of the class it represents and assign the instance to the variable $new.

If Net::Twitter->new was called directly, we’re done. We can simply return the new instance. However, if new was called from some Net::Twitter derived class we need to create another anonymous class with the derived class prepended to the list of superclasses. It gets assigned to the variable $final_meta.

We couldn’t create the first meta class with the derived class specified in superclasses, because calling new on the class it represents is likely to be infinitely recursive!

Consider this Net::Twitter derived class:

package My::Net::Twitter::DerivedClass;
use base 'Net::Twitter';

sub some_new_method { ... }

Net::Twitter’s new method will be called with the $class argument My::Net::Twitter::DerivedClass. Calling new on the resulting anonymous class, with My::Net::Twitter::DerivedClass in superclasses will result in an infinite recursion since Net::Twitter’s new will be called, again.

So, after creating an instance of the anonymous class without My::Net::Twitter::DerivedClass in superclasses, we create another anonymous class and assign it to the variable $final_meta. Then we rebless our existing instance into the new anonymous class and return it. Reblessed into the new class, it can find and use the derived class methods.

$create_anon_class has some interesting features.

my $create_anon_class = sub {
    my ($superclasses, $traits, $immutable) = @_;

    my $meta;
    $meta = Net::Twitter::Core->meta->create_anon_class(
        superclasses => $superclasses,
        roles        => $traits,
        methods      => { meta => sub { $meta }, isa => $isa },
        cache        => 1,
    );
    $meta->make_immutable(inline_constructor => $immutable);

    return $meta;
};

With the cache argument set to 1, create_anon_class caches the classes it creates and returns an existing instance of the mata-class when the same arguments are passed again. So, although create_anon_class may be called an arbitrary number of times in the life of an application, a new, anonymous class is only created when different roles or superclasses are specified.

A final point of interest is the method isa added to to the anonymous classes.

my $isa = sub {
    my $self = shift;
    my $isa  = shift;

    return $isa eq __PACKAGE__ || $self->SUPER::isa($isa)
};

Net::Twitter is an object factory. It does not create instances of itself. It creates instances of anonymous classes based on Net::Twitter::Core with roles applied. Classes derived from Net::Twitter may be surprised (and die!) if ->isa('Net::Twitter') fails on their instances. So, objects created by Net::Twitter claim to be Net::Twitter instances when queried with isa.

Net::Twitter contains a bit more code than I’ve shown here. The additional code, stolen from MooseX::Traits, simply expands role names, passed in the traits argument into the Net::Twitter::Role namespace.

This may not be the best possible implementation, but it seems to work well for all the conditions I have encountered tested. Your feed back is welcome. You can send me email or find me online. I’m semifor on IRC and can usually be found in the #moose and #net-twitter channels on irc.perl.org. I’m also semifor on Twitter.

[/perl] [link]

About this weblog

This site is the personal weblog of Marc Mims. You can contact Marc by sending e-mail to:
marc@questright.com.

Marc writes here about cycling, programming, Linux, and other items of personal interest.

This site is syndicated with RSS.

Archives

Credits

CSS stolen from Tom Coates who didn't even complain.