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 necessarily 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 has 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 count(*) 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 main 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;

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

%options may include:

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

The initializer coderef. Required, unless literal or class are specified.

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

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

Consider the resource to be a value known at startup time. This may be e.g. a configuration file name or an environmental variable:

resource config_file =>
    literal => $ENV{MY_CONFIG} // '/etc/myapp/config.yaml';

Replaces initializer with sub { $value }.

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

class => 'Class::Name'

Turn on Spring-style dependency injection. This forbids the argument parameter 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,
        );
    };

init, literal, and class are mutually exclusive.

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.

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 => 0 | 1

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.

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 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,
        );
    };

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.

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.

The default is same as cleanup.

See "FORKING".

fork_safe => 0 | 1

If present, don't reinitialize resource if the process ID change is detected. E.g. in a daemon we don't want to re-read and re-parse the configuration every time a new worker starts.

This is mutually exclusive with 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;
    };

derived => 0 | 1

Assume the resource introduces no side effects apart from those already handled by its dependencies.

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 either initialized, or overridden.

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

preload => 0 | 1

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.

silo

A re-exportable function returning one and true 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. The resource container class may therefore be viewed as an optional singleton.

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.

Bread::Board::Declare - a declarative wrapper around Bread::Board and Moose, resulting in a DSL similar to this module's.

BUGS

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

  • Version 0.09 brings a breaking change that forbids forward dependencies.

  • Version 0.14 removes the ignore_cache flag as it doesn't seem to have compelling use cases while complicating the implementation.

  • 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.