Resource::Silo - lazy declarative resource container for Perl.
We assume the following setup:
The application needs to access multiple resources, such as configuration files, databases, queues, service endpoints, credentials, etc.
The application has helper scripts that don't need to initialize all the resources at once, as well as a test suite where accessing resources is undesirable unless a fixture or mock is provided.
The resource management has to be decoupled from the application logic where possible.
And we propose the following solution:
All available resources are declared in one place and encapsulated within a single container.
Such container is equipped with methods to access resources, as well as an exportable prototyped function for obtaining the one and true instance of it (a.k.a. optional singleton).
Every class or script in the project accesses resources through this container and only through it.
The default mode is to create a one-off container for all resources and export if into the calling class via silo function.
silo
package My::App; use Resource::Silo; use DBI; use YAML::LoadFile; ... resource config => sub { LoadFile( ... ) }; resource dbh => sub { my $self = shift; my $conf = $self->config->{dbh}; DBI->connect( $conf->{dsn}, $conf->{user}, $conf->{pass}, { RaiseError => 1 } ); }; resource queue => sub { My::Queue->new( ... ) }; ... my $statement = silo->dbh->prepare( $sql ); my $queue = silo->queue;
For more complicated projects, it may make more sense to create a dedicated class for resource management:
# in the container class package My::Project::Res; use Resource::Silo -class; # resource definitions will now create # eponymous methods in My::Project::Res resource foo => sub { ... }; # declare resources as in the above example resource bar => sub { ... }; 1; # in all other modules/packages/scripts: package My::Project; use My::Project::Res qw(silo); silo->foo; # obtain resources silo->bar; My::Project::Res->new; # separate empty resource container
The following functions will be exported into the calling module, unconditionally:
silo - a singleton function returning the resource container. Note that this function will be created separately for every calling module, and needs to be re-exported to be shared.
resource - a DSL for defining resources, their initialization and properties. See below.
Additionally, if the -class argument was added to the use line, the following things happen:
-class
Resource::Silo::Container and Exporter are added to @ISA;
@ISA
silo function is added to @EXPORT and thus becomes re-exported by default;
@EXPORT
calling resource creates a corresponding method in this package.
resource
resource 'name' => sub { ... }; resource 'name' => %options;
%options may include:
A coderef to obtain the resource. Required, unless literal or class are specified.
literal
class
If the number of arguments is odd, the last one is popped and considered to be the init function.
Replace initializer with sub { $value }.
sub { $value }
In addition, derived flag is set, and an empty dependencies list is implied.
derived
dependencies
sub { ... }
qr( ... )
If specified, assume that the resource in question may have several instances, distinguished by a string argument. Such argument will be passed as the 3rd parameter to the init function.
init
Only one resource instance will be cached per argument value.
This may be useful e.g. for DBIx::Class result sets, or for Redis::Namespace.
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.
See "MORE EXAMPLES" below.
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.
If set, don't cache resource, always create a fresh one instead. See also "fresh" in Resource::Silo::Container.
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.
silo->ctl->preload
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
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).
$my_service_main_object
Like cleanup, but only in case a change in process ID was detected. See "FORKING"
cleanup
This may be useful if cleanup is destructive and shouldn't be performed twice.
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.
loose_deps
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).
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.
Turn on Spring-style dependency injection. This forbids init and argument parameters and requires dependencies to be a hash.
argument
The dependencies' keys become the arguments to Class::Name->new, and the values format is as follows:
Class::Name->new
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;
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, ); };
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.
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.
use Resource::Silo
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.
silo->new
It's usually a bad idea to access real-world resources in one's test suite, especially if it's e.g. a partner's endpoint.
Now the #1 rule when it comes to mocks is to avoid mocks and instead design the modules in such a way that they can be tested in isolation. This however may not always be easily achievable.
Thus, Resource::Silo provides a mechanism to substitute a subset of resources with mocks and forbid the instantiation of the rest, thereby guarding against unwanted side-effects.
The lock/unlock methods in Resource::Silo::Container, available via silo->ctl frontend, temporarily forbid instantiating new resources. The resources already in cache will still be OK though.
lock
unlock
silo->ctl
The override method allows to supply substitutes for resources or their initializers.
override
The derived flag in the resource definition may be used to indicate that a resource is safe to instantiate as long as its dependencies are either instantiated or mocked, e.g. a DBIx::Class schema is probably fine as long as the underlying database connection is taken care of.
Here is an example:
use Test::More; use My::Project qw(silo); silo->ctl->lock->override( dbh => DBI->connect( 'dbi:SQLite:database=:memory:', '', '', { RaiseError => 1 ), ); silo->dbh; # a mocked database silo->schema; # a DBIx::Class schema reliant on the dbh silo->endpoint( 'partner' ); # an exception as endpoint wasn't mocked
See Resource::Silo::Container for resource container implementation.
All resources are cached, the ones with arguments are cached together with the argument.
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.
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.
As of current, using Resource::Silo -class and Moose in the same package doesn't work.
Resource::Silo -class
Moose
Usage together with Moo works, but only if Resource::Silo comes first:
Moo
package My::App; use Resource::Silo -class; use Moo; has config_name => is => ro, default => sub { '/etc/myapp/myfile.yaml' }; resource config => sub { LoadFile( $_[0]->config_name ) };
Compatibility issues are being slowly worked on.
package My::App; use Resource::Silo; resource config => sub { require YAML::XS; YAML::XS::LoadFile( "/etc/myapp.yaml" ); }; resource dbh => sub { require DBI; my $self = shift; my $conf = $self->config->{database}; DBI->connect( $conf->{dbi}, $conf->{username}, $conf->{password}, { RaiseError => 1 } ); }; resource user_agent => sub { require LWP::UserAgent; LWP::UserAgent->new(); # set your custom UserAgent header or SSL certificate(s) here };
Note that though lazy-loading the modules is not necessary, it may speed up loading support scripts.
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; }; resource schema => derived => 1, # merely a frontend to dbi require => 'My::App::Schema', init => sub { my $self = shift; return My::App::Schema->connect( sub { $self->dbh } ); };
An useless but short example:
#!/usr/bin/env perl use strict; use warnings; use Resource::Silo; resource fibonacci => argument => qr(\d+), init => sub { my ($self, $name, $arg) = @_; $arg <= 1 ? $arg : $self->fibonacci($arg-1) + $self->fibonacci($arg-2); }; print silo->fibonacci(shift);
A more pragmatic one:
package My::App; use Resource::Silo; resource redis_conn => sub { my $self = shift; require Redis; Redis->new( server => $self->config->{redis} ); }; my %known_namespaces = ( lock => 1, session => 1, user => 1, ); resource redis => argument => sub { $known_namespaces{ $_ } }, require => 'Redis::Namespace', init => sub { my ($self, $name, $ns) = @_; Redis::Namespace->new( redis => $self->redis, namespace => $ns, ); }; # later in the code silo->redis; # nope! silo->redis('session'); # get a prefixed namespace
use Test::More; use My::App qw(silo); silo->ctl->override( dbh => $temp_sqlite_connection ); silo->ctl->lock; my $stuff = My::App::Stuff->new(); $stuff->frobnicate( ... ); # will only affect the sqlite instance $stuff->ping_partner_api(); # oops! the user_agent resource wasn't # overridden, so there'll be an exception
use My::App qw(silo); my $dbh = silo->ctl->fresh('dbh'); $dbh->begin_work; # Perform a Big Scary Update here # Any operations on $dbh won't interfere with normal usage # of silo->dbh by other application classes.
Bread::Board - a more mature IoC / DI framework.
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 and I had great coworkers!
This software is still in beta stage. Its interface is still evolving.
Version 0.09 brings a breaking change that forbids forward dependencies.
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.
You can find documentation for this module with the perldoc command.
perldoc Resource::Silo
You can also look for information at:
RT: CPAN's request tracker (report bugs here)
https://rt.cpan.org/NoAuth/Bugs.html?Dist=Resource-Silo
CPAN Ratings
https://cpanratings.perl.org/d/Resource-Silo
Search CPAN
https://metacpan.org/release/Resource-Silo
Copyright (c) 2023, Konstantin Uvarin, <khedin@gmail.com>
<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.
To install Resource::Silo, copy and paste the appropriate command in to your terminal.
cpanm
cpanm Resource::Silo
CPAN shell
perl -MCPAN -e shell install Resource::Silo
For more information on module installation, please visit the detailed CPAN module installation guide.