The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.

NAME

Resource::Silo - lazy declarative resource container for Perl.

DESCRIPTION

This module provides a container that manages initialization, caching, and cleanup of resources that the application needs to talk to the outside world, such as configuration files, database connections, queues, external service endpoints, and so on.

Upon use, a one-off container class based on Resource::Silo::Container with a one-and-true (but not only) instance is created.

The resources are then defined using a Moose-like DSL, and their identifiers become method names in said class. Apart from a name, each resource defined an initialization routine, and optionally dependencies, cleanup routine, and various flags.

Resources are instantiated on demand and cached. The container is fork-aware and will reset its cache whenever the process ID changes.

SYNOPSIS

Declaring the resources:

    package My::App;

    # This creates 'resource' and 'silo' functions
    # and *also* makes 'silo' re-exportable via Exporter
    use Resource::Silo;

    # A literal resource, that is, initialized with a constant value
    resource config_file =>
        literal => '/etc/myapp/myapp.yaml';

    # A typical resource with a lazy-loaded module
    resource config =>
        require => 'YAML::XS',
        init    => sub {
            my $self = shift;
            YAML::XS::LoadFile( $self->config_file );
        };

    # Derived resource is a front end to other resources
    # without side effects of its own.
    resource app_name =>
        derived => 1,
        init    => sub { $_[0]->config->{name} };

    # An RDBMS connection is one of the most expected things here
    resource dbh =>
        require      => [ 'DBI' ],      # loading multiple modules is fine
        dependencies => [ 'config' ],
        init         => sub {
            my $self = shift;
            my $config = $self->config->{database};
            DBI->connect(
                $config->{dsn},
                $config->{username},
                $config->{password},
                { RaiseError => 1 }
            );
        };

    # A full-blown Spring style dependency injection
    resource myclass =>
        derived => 1,
        class   => 'My::App::Class',  # call My::App::Class->new
        dependencies => {
            dbh => 1,                 # pass 'dbh' resource to new()
            name => 'app_name',       # set 'name' parameter to 'app_name' resource
            version => \3.14,         # pass a literal value
        };

Accessing the resources in the app itself:

    use My::App qw(silo);

    my $app = silo->myclass; # this will initialize all the dependencies
    $app->frobnicate;

Partial resource usage and fine-grained control, e.g. in a maintenance script:

    use 5.010;
    use My::App qw(silo);

    # Override a resource with something else
    silo->ctl->override( config => shift );

    # This will derive a database connection from the given configuration file
    my $dbh = silo->dbh;

    say $dbh->selectall_arrayref('SELECT * FROM users')->[0][0];

Writing tests:

    use Test::More;
    use My::All qw(silo);

    # replace side effect with mocks
    silo->ctl->override( config => $config_hash, dbh => $local_sqlite );

    # make sure no other side effects will ever be triggered
    # (unless 'derived' flag is set or resource is a literal)
    silo->ctl->lock;

    my $app = silo->myclass;
    # run actual tests below

IMPORT/EXPORT

The following functions will be exported into the calling module, unconditionally:

  • resource - resource declaration DSL;

  • silo - a re-exportable prototyped function returning the one and true container instance.

Additionally, Exporter is added to the calling package's @ISA and silo is appended to our @EXPORT.

NOTE If the module has other exported functions, they should be added via

    push our @EXPORT, qw( foo bar quux );

or else the silo function in that array will be overwritten.

USE OPTIONS

-class

If a -class argument is given on the use line, the calling package will itself become the container class.

Such a class may have normal fields and methods in addition to resources and will also be Moose- and Moo-compatible.

-shortcut <function name>

If specified, use that name for singleton instance instead of silo. Name must be a valid identifier, i.e. /[a-z_][a-z_0-9]*/i.

resource

    resource 'name' => sub { ... };
    resource 'name' => %options;

%options may include:

init => sub { $container, $name, [$argument] }

A coderef to obtain the resource. Required, unless literal or class are specified.

If the number of arguments is odd, the last one is popped and considered to be the init function.

The arguments to the initializer are the container itself, resource name, and an optional argument or an empty string if none given.

Returning an undef value is considered an error.

Using Carp::croak in the initializer will blame the code that has requested the resource, skipping Resource::Silo's internals.

literal => $value

Replace initializer with sub { $value }.

In addition, derived flag is set, and an empty dependencies list is implied.

argument => sub { ... } || qr( ... )

Declare a (possibly infinite) set of sibling resources under the same name, distinguished by a string parameter. Said parameter will be passed as the 3rd parameter to the init function.

Exactly one resource instance will be cached per argument value.

A regular expression will always be anchored to match the whole string. A function must return true for the parameter to be valid.

If the argument is omitted, it is assumed to be an empty string.

E.g. when using Redis::Namespace:

    package My::App;
    use Resource::Silo;

    resource redis_server => sub { Redis->new() };

    resource redis =>
        require         => 'Redis::Namespace',
        derived         => 1,
        argument        => qr([\w:]*),
        init            => sub {
            my ($c, undef, $ns) = @_;
            Redis::Namespace->new(
                redis     => $c->redis_server,
                namespace => $ns,
            );
        };

derived => 1 | 0

Assume that resource can be derived from its dependencies, or that it introduces no extra side effects compared to them.

This also naturally applies to resources with pure initializers, i.e. those having no dependencies and adding no side effects on top.

Examples may be Redis::Namespace built on top of a Redis handle or DBIx::Class built on top of DBI connection.

Derivative resources may be instantiated even in locked mode, as they would only initialize if their dependencies have already been initialized or overridden.

See "lock" in Resource::Silo::Container.

ignore_cache => 1 | 0

If set, don't cache resource, always create a fresh one instead. See also "fresh" in Resource::Silo::Container.

preload => 1 | 0

If set, try loading the resource when silo->ctl->preload is called. Useful if you want to throw errors when a service is starting, not during request processing.

See "preload" in Resource::Silo::Container.

cleanup => sub { $resource_instance }

Undo the init procedure. Usually it is assumed that the resource will do it by itself in the destructor, e.g. that's what a DBI connection would do. However, if it's not the case, or resources refer circularly to one another, a manual "destructor" may be specified.

It only accepts the resource itself as an argument and will be called before erasing the object from the cache.

See also fork_cleanup.

cleanup_order => $number

The higher the number, the later the resource will get destroyed.

The default is 0, negative numbers are also valid, if that makes sense for you application (e.g. destroy $my_service_main_object before the resources it consumes).

    resource logger =>
        cleanup_order   => 9e9,     # destroy as late as possible
        require         => [ 'Log::Any', 'Log::Any::Adapter' ],
        init            => sub {
            Log::Any::Adapter->set( 'Stderr' );
            # your rsyslog config could be here
            Log::Any->get_logger;
        };

fork_cleanup => sub { $resource_instance }

If present, use this function in place of cleanup if the process ID has changed. This may be useful if cleanup is destructive and shouldn't be performed twice.

See "FORKING".

dependencies => \@list

List other resources that may be requested in the initializer. Unless loose_deps is specified (see below), the dependencies must be declared before the dependant.

A resource with parameter may also depend on itself.

The default is all eligible resources known so far.

NOTE This behavior was different prior to v.0.09 and may be change again in the near future.

This parameter has a different structure if class parameter is in action (see below).

loose_deps => 1|0

Allow dependencies that have not been declared yet.

Not specifying the dependencies parameter would now mean there are no restrictions whatsoever.

NOTE Having to resort to this flag may be a sign of a deeper architectural problem.

class => 'Class::Name'

Turn on Spring-style dependency injection. This forbids init and argument parameters and requires dependencies to be a hash.

The dependencies' keys become the arguments to Class::Name->new, and the values format is as follows:

  • argument_name => resource_name

    Use a resource without parameter;

  • argument_name => [ resource_name => argument ]

    Use a parametric resource;

  • resource_name => 1

    Shorthand for resource_name => resource_name;

  • name => \$literal_value

    Pass $literal_value to the constructor as is.

So this:

    resource foo =>
        class           => 'My::Foo',
        dependencies    => {
            dbh     => 1,
            redis   => [ redis => 'session' ],
            version => \3.14,
        };

Is roughly equivalent to:

    resource foo =>
        dependencies    => [ 'dbh', 'redis' ],
        init            => sub {
            my $c = shift;
            require My::Foo;
            My::Foo->new(
                dbh     => $c->dbh,
                redis   => $c->redis('session'),
                version => 3.14,
            );
        };

require => 'Module::Name' || \@module_list

Load module(s) specified before calling the initializer.

This is exactly the same as calling require 'Module::Name' in the initializer itself except that it's more explicit.

silo

A re-exportable singleton function returning one and true Resource::Silo::Container instance associated with the class where the resources were declared.

NOTE Calling use Resource::Silo from a different module will create a separate container instance. You'll have to re-export (or otherwise provide access to) this function.

This is done on purpose so that multiple projects or modules can coexist within the same interpreter without interference.

silo->new will create a new instance of the same container class.

CAVEATS AND CONSIDERATIONS

See Resource::Silo::Container for the container implementation.

See Resource::Silo::Metadata for the metadata storage.

FINE-GRAINED CONTROL INTERFACE

Calling $container->ctl will return a frontend object which allows to control the container itself. This is done so in order to avoid polluting the container namespace:

    use My::App qw(silo);

    # instantiate a separate instance of a resource, ignoring the cache
    # e.g. for a long and invasive database update
    my $dbh = silo->ctl->fresh("dbh");

See "ctl" in Resource::Silo::Container for more.

OVERRIDES AND LOCKING

In addition to declaring resources, Resource::Silo provides a mechanism to override an existing initializer with a user-supplied routine. (If a non-coderef value is given, it's wrapped into a function.)

It also allows to prevent instantiation of new resources via lock method. After $container->ctl->lock, trying to obtain a resource will cause an exception, unless the resource is overridden, already in the cache, or marked as derived and thus considered safe, as long as its dependencies are safe.

The primary use for these is of course providing test fixtures / mocks:

    use Test::More;
    use My::App qw(silo);

    silo->ctl->override(
        config  => $config_hash,     # short hand for sub { $config_hash }
        dbh     => $local_sqlite,
    );
    silo->ctl->lock;

    silo->dbh->do( $sql );                  # works on the mock
    silo->user_agent->get( $partner_url );  # dies unless the UA was also mocked

Passing parameters to the container class constructor will use override internally, too:

    package My::App;
    use Resource::Silo -class;

    resource foo => sub { ... };

    # later...
    my $app = My::App->new( foo => $foo_value );
    $app->frobnicate();      # will use $foo_value instead of instantiating foo

See "override" in Resource::Silo::Container, "lock" in Resource::Silo::Container, and "unlock" in Resource::Silo::Container for details.

CACHING

All resources are cached, the ones with arguments are cached together with the argument.

FORKING

If the process forks, resources such as database handles may become invalid or interfere with other processes' copies. As of current, if a change in the process ID is detected, the resource cache is reset altogether.

This may change in the future as some resources (e.g. configurations or endpoint URLs) are stateless and may be preserved.

CIRCULAR DEPENDENCIES

If a resource depends on other resources, those will be simply created upon request.

It is possible to make several resources depend on each other. Trying to initialize such resource will cause an expection, however.

COMPATIBILITY

Resource::Silo uses Moo internally and is therefore compatible with both Moo and Moose when in -class mode:

    package My::App;

    use Moose;
    use Resource::Silo -class;

    has path => is => 'ro', default => sub { '/dev/null' };
    resource fd => sub {
        my $self = shift;
        open my $fd, "<", $self->path;
        return $fd;
    };

Extending such mixed classes will also work. However, as of current, the resource definitions will be taken from the nearest ancestor that has any, using breadth first search.

TROUBLESHOOTING

Resource instantiation order may become tricky in real-life usage.

$container->ctl->list_cached will output a list of all resources that have been initialized so far. The ones with arguments will be in form of "name/argument". See "list_cached" in Resource::Silo::Container.

$container->ctl->meta will return a metaclass object containing the resource definitions. See "meta" in Resource::Silo::Container.

MORE EXAMPLES

Setting up outgoing HTTP. Aside from having all the tricky options in one place, this prevents accidentally talking to production endpoints while running tests.

    resource user_agent =>
        require => 'LWP::UserAgent',
        init => sub {
            my $ua = LWP::UserAgent->new;
            $ua->agent( 'Tired human with red eyes' );
            $ua->protocols_allowed( ['http', 'https'] );
            # insert your custom SSL certificates here
            $ua;
        };

Using DBIx::Class together with a regular DBI connection:

    resource dbh => sub { ... };

    resource schema =>
        derived         => 1,                   # merely a frontend to DBI
        require         => 'My::App::Schema',
        dependencies    => [ 'dbh' ],
        init            => sub {
            my $self = shift;
            return My::App::Schema->connect( sub { $self->dbh } );
        };

    resource resultset =>
        derived         => 1,
        dependencies    => 'schema',
        argument        => qr(\w+),
        init            => sub {
            my ($c, undef, $name) = @_;
            return $c->schema->resultset($name);
        };

SEE ALSO

Bread::Board - a more mature IoC / DI framework.

BUGS

This software is still in beta stage. Its interface is still evolving.

Version 0.09 brings a breaking change that forbids forward dependencies.

Forced re-exporting of silo was probably a bad idea and should have been left as an exercise to the user.

Please report bug reports and feature requests to https://github.com/dallaylaen/resource-silo-p5/issues or via RT: https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Resource-Silo.

ACKNOWLEDGEMENTS

  • This module was names after a building in the game Heroes of Might and Magic III.

  • This module was inspired in part by my work for Cloudbeds. That was a great time!

SUPPORT

You can find documentation for this module with the perldoc command.

    perldoc Resource::Silo

You can also look for information at:

COPYRIGHT AND LICENSE

Copyright (c) 2023-2024, Konstantin Uvarin, <khedin@gmail.com>

This program is free software. You can redistribute it and/or modify it under the terms of either: the GNU General Public License as published by the Free Software Foundation, or the Artistic License.

See http://dev.perl.org/licenses/ for more information.