Some Meta Fun with Moose and Avro

Aug 11, 2012 / By Yanick Champoux

Tags: ,

I won’t try to bamboozle you: Diving into Moose‘s metaclass system is not easy. Not because there is a dearth of documentation (au contraire, it’s gorgeously extensive), and not because the underlying code is as hairy as a macaque in the middle of winter (considering the potent magic it carries, it’s surprisingly — some would even say suspiciously — sane), but simply because playing with classes that beget classes is heady, confusing stuff. It often feels like trying to type by looking at the keyboard in a mirror.

But once that dragon is tamed, it can do truly wonderful, terrible things…

For example, in my dream-quest for Unknown Hadoop, I stumbled on Avro, a data serialization system using JSON. I found its simple way to describe data schemas…endearing, and began to wonder how hard it would be to auto-generate classes out of an Avro schema. Like, say:

use Class::Avro;

my $class = Class::Avro->new( schema => <<'END_SCHEMA' );
{
    "type": "record",
    "name": "PlayingCard",
    "fields": [
        { "name": "color", "type": "string" },
        { "name": "number", "type": "int" }
    ]
}
END_SCHEMA

And then be able to create new objects of the programmatically-minted PlayingCard class:

my $card = $class->new_object( color => 'spade', number => 1 );

say $card->color;

Or go one step further and deserialize directly from that $class:

my $other_card = $class->deserialize( q#{ "color": "heart", "number": "12" }# );

say $other_card->number;

And, of course, it would also be nice to go the other way around and have a regular Moose class be able to spit out an Avro representation of its attributes:

package TarotCard;

use Moose;

with 'Class::Avro::Role';

has suit => (
    isa => 'Str',
    is => 'ro',
);

has number => (
    isa => 'Int',
    is => 'ro',
);

# and then later
say TarotCard->avro_schema;

my $card = TarotCard->new( suit => 'batons', number => 5 );

say $card->suit, ' ', $card->number;

Ultimately, we also want the Avro to and fro to be compatible, so that we could do full-Ouroboros and do:

# prints back the same schema, imported and exported
say Class::Avro->new( schema => <<'END_SCHEMA' )->avro_schema;
{
    "type": "record",
    "name": "Dice",
    "fields": [
        { "name": "nbr_sides", "type": "int" },
        { "name": "color", "type": "string" }
    ]
}
END_SCHEMA

Sounds like a lot of stuff to do, doesn’t it? But, as usual, the truth is not as bacchanalian as one would fear, but rather quite spartan. Let me show you…

Class::Avro – The Class Generator

The goal of the class generator is rather simple: Take a schema in, produce a class with the corresponding attributes out. If we keep that in mind, the implementation is no more complicated than:

package Class::Avro;

use strict;
use warnings;

use Moose;

use Class::Avro::Role;

use Method::Signatures;

use Moose::Util::TypeConstraints;

subtype AvroSchema => as 'HashRef';
coerce  AvroSchema => from 'Str' => via { from_json $_ };

has schema => (
    is => 'ro',
    isa => 'AvroSchema',
    required => 1,
    coerce => 1,
);

has class => (
    is => 'ro',
    lazy => 1,
    builder => '_build_class',
    handles => [qw/ new_object /],
);

method _build_class {
    my $schema = $self->schema;

    # Moose::Meta::Class->create( $schema->{name} )
    # doesn't do what I want, it seems. :-/
    eval "package $schema->{name}; use Moose;";

    my $class = $schema->{name}->meta;

    Class::Avro::Role->meta->apply($class);

    for my $field ( @{ $schema->{fields} } ) {
        my %arg = ( accessor => $field->{name} );

        if ( my $type = $Class::Avro::Role::AVRO2MOOSE_TYPEMAP{$field->{type}} ) {
            $arg{isa} = $type;
        }

        $class->add_attribute( $field->{name} => %arg );
    }

    return $class;
}

method deserialize($json) { $self->class->name->thaw($json); }

method avro_schema { $self->class->name->avro_schema; }

1;

The slurping of the schema is nothing arcane, just a simple coercion to turn the JSON string into a good ol’ hashref. The building of the class is only a bit more involved. We create the new Moose class, slap on it the Class::Avro::Role, and decorate it with all the fields provided by the schema (with the right type constraint in bonus if we can map the Avro type to the Moose equivalent). Two itsy-bitsy utility functions (deserialize and avro_schema) that are nothing but proxies for the created class are tacked at the end and, well, that’s it.

Class::Avro::Role – The Core of the Beast

Ah ah, you think, this is where things get complicated! Well, I hate to say this, but you’re about to get disappointed. Big time.

package Class::Avro::Role;

use 5.14.0;

use strict;
use warnings;

use Moose::Role;

use MooseX::Storage;

use Method::Signatures;
use JSON qw/ from_json to_json /;

with Storage( format => 'JSON' );

our %AVRO2MOOSE_TYPEMAP = (
    string  => 'Str',
    boolean => 'Bool',
    int     => 'Int',
);

our %MOOSE2AVRO_TYPEMAP = reverse %AVRO2MOOSE_TYPEMAP;

method avro_schema {
    my $class = $self->meta;

    my %schema;

    $schema{type} = 'record';
    $schema{name} = $class->name;

    $schema{fields} = [];

    for my $attr ( grep {
            not $_->does(
                'MooseX::Storage::Meta::Attribute::Trait::DoNotSerialize'
            )
        } $class->get_all_attributes ) {

        push $schema{fields} => {
            name => $attr->name,
            ( type => $MOOSE2AVRO_TYPEMAP{$attr->type_constraint} )
                x $attr->has_type_constraint
        };
    }

    return to_json %schema;
}

method serialize { $self->freeze }

1;

How can it be so short and sweet? Well, mostly because I embraced laziness and leveraged my dear pal MooseX::Storage to do all the serializing/deserializing business. With that out of the way, creating the Avro schema is mostly walking through the different attributes, seeing which ones we don’t want (thanks to the trait brought in by MooseX::Storage), and converting the resulting structure into its JSON counterpart.

Is That Really All?

To run the example code given above? Absolutely. Is Class::Avro CPAN-ready? No, not quite. The current code only implements three of the basic types, and there are more complex types (maps, unions, and all that jazz) that would require a little more work, but hardly more magic. But still, we already have a neat little class and a role that allows us to at least baby-talk, if not extensively, with other Avro-abled applications. And that, I dare say, is darn nifty.

Leave a Reply

  • (will not be published)

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>