PerlのDIコンテナ, Bread::Boardの紹介
この記事はPerl Advent Calendar 2018 21日目の記事です。
みなさん、普段開発するときにDIしていますか?
Perlだと簡単にメソッドのMockが可能なため、他の言語とくらべてDIすることは少ないかも知れません。
それでも巨大なアプリケーションになってMockだと辛くなったりしたときや、モジュールの外部からモジュールの挙動を制御したくなる時など、DIを利用したい時はあると思います。
DIするとなると、オブジェクトの組み立てにDIコンテナを使うことが多いかと思いますが、
本記事ではPerlのDIコンテナである Bread::Board
というモジュールの紹介をしたいと思います。
DIとは?
本題に入る前に、DI(Dependency Injection = 依存性注入)をよく知らない方に向けてDIとはどういうものかというのを軽く説明したいと思います。
(本当に簡単に説明するだけなので、詳しいことを知りたい方は別の記事や本などを読むことをおすすめします)
今回は、DBからユーザーを登録したりユーザー情報を取り出す処理を行うクラスを例にしてDIを説明していこうと思います。
DIしないコード
まずはDIを利用しないようなクラスを見てみましょう。
package UserService {
use v5.28;
use utf8;
use Moo;
use DBIx::Sunny;
my $dbh = DBIx::Sunny->connect(
'dbi:Pg:dbname=some_system',
'some_db_user',
'',
);
sub regist {
my ($self, %args) = @_;
my ($id, $password) = @args{qw( id password )};
my $sth = $dbh->prepare('INSERT INTO "users" ("id", "password") VALUES (?, ?)');
$sth->execute($id, $password);
}
sub fetch_by_id {
my ($self, $id) = @_;
my $row = $dbh->select_row('SELECT * FROM "users" WHERE id = ?', $id);
}
}
use v5.28;
use utf8;
use DDP;
my $id = 'some_user';
my $service = UserService->new;
$service->regist(
id => 'some_user',
password => '***********',
);
p $service->fetch_by_id('some_user');
DBを操作するモジュールのインスタンスがUserService内で作られており、モジュールの外部からコントロールすることができません。
DIするコード
次にDIを利用するクラスを見てみす。
package UserService {
use v5.28;
use utf8;
use Moo;
use Types::Standard qw( :types );
use Type::Utils qw( class_type );
has dbh => (
is => 'ro',
isa => class_type(+{ class => 'DBIx::Sunny::db' }),
required => 1,
);
sub regist {
my ($self, %args) = @_;
my ($id, $password) = @args{qw( id password )};
my $sth = $self->dbh->prepare('INSERT INTO "users" ("id", "password") VALUES (?, ?)');
$sth->execute($id, $password);
}
sub fetch_by_id {
my ($self, $id) = @_;
my $row = $self->dbh->select_row('SELECT * FROM "users" WHERE id = ?', $id);
}
}
use v5.28;
use utf8;
use DDP;
use DBIx::Sunny;
my $id = 'some_user';
my $dbh = DBIx::Sunny->connect(
'dbi:Pg:dbname=some_system',
'some_db_user',
'',
);
my $service = UserService->new( dbh => $dbh );
$service->regist(
id => $id,
password => '***********',
);
p $service->fetch_by_id($id);
UserServiceのインスタンスを作るときにDBIx::Sunnyのインスタンスを渡し、内部のDBを利用するにそのインスタンスを利用しています。
DBを扱うモジュールのインスタンス生成処理がUserServiceの外部に移動し、凝集度が高くなっていると言えそうですね。
具体的にどういう風に嬉しくなるかというと、
- DBの情報を知らなくてもUserServiceを実装できるようになる
- UserServiceを修正することなく接続するDBやDBの情報を変更できるようになる
- Master / Slave どちらに接続できるかを外部からコントロールできる
- テスト時はテスト用のDBに接続させることができるので、テストもしやすくなる
といった点があげられます。
DIコンテナとは?
上で述べたようなメリットがあるDIですが、プログラムを実行するときにたくさんの引数を受け取ってオブジェクトを組み立てないといけなくなる、というデメリットが発生します。
そうすると、簡単にオブジェクトを組み立てて生成してくれるもの(オブジェクトを組み立てる方法を管理するもの)が欲しくなってきます。
それをしてくれるのがDIコンテナです。
Bread::Boardについて
Bread::Boardは、DIとオブジェクトのライフサイクルの管理に焦点をあてたIoC(Inversion of Control = 制御の反転)フレームワークです。
DIというのはIoCを実現するための1つの手法なので、IoCを実現するためにDIコンテナとしての機能がある、といった位置づけのようです。
Perlの他のコンテナ系のモジュールと比べて重厚な感じであり、僕は大規模なソフトウェアでの利用に特に向いていると思っています。
特徴
- 依存関係やオブジェクトのライフサイクルをDSLで明示的に記述できる
- APIが充実している
- サブコンテナ(入れ子のコンテナ)を簡単に作れる / 簡単にアクセスできる
実際に使ってみる
先ほどDIの例を説明するために使ったコードを、Bread::Boardを使って書き換えて、テストDBと本番用のDBに接続するUserServiceのインスタンスをそれぞれ作るコンテナを作ってみます。
package UserService {
use v5.28;
use utf8;
use Moo;
use Types::Standard qw( :types );
use Type::Utils qw( class_type );
has dbh => (
is => 'ro',
isa => class_type(+{ class => 'DBIx::Sunny::db' }),
required => 1,
);
sub regist {
my ($self, %args) = @_;
my ($id, $password) = @args{qw( id password )};
my $sth = $self->dbh->prepare('INSERT INTO "users" ("id", "password") VALUES (?, ?)');
$sth->execute($id, $password);
}
sub fetch_by_id {
my ($self, $id) = @_;
my $row = $self->dbh->select_row('SELECT * FROM "users" WHERE id = ?', $id);
}
}
use v5.28;
use utf8;
use DDP;
use DBIx::Sunny;
use Bread::Board;
# コンテナの宣言
my $c = container UserService => as {
# サブコンテナの宣言
container TestDB => as {
# サービス(コンテナに登録する内容)の宣言
service dsn => 'dbi:Pg:dbname=some_test_system';
service username => 'some_db_user';
service password => '';
service dbh => (
# 依存しているサービスを指定
# ここで指定された同じコンテナ内のサービスが block で渡されたコードリファレンスの引数のパラメータとして渡ってくる
dependencies => [qw( dsn username password )],
# サービスに登録するオブジェクトを手動で組み立てたい場合はコードリファレンスを引数で渡す
block => sub {
my $s = shift;
DBIx::Sunny->connect(
# 依存しているサービスの取得
$s->param('dsn'),
$s->param('username'),
$s->param('password'),
);
},
);
};
container ProductionDB => as {
service dsn => 'dbi:Pg:dbname=some_system';
service username => 'some_db_user';
service password => '';
service dbh => (
block => sub {
my $s = shift;
DBIx::Sunny->connect(
$s->param('dsn'),
$s->param('username'),
$s->param('password'),
);
},
dependencies => [qw( dsn username password )],
);
};
service test_service => (
# 依存しているサービスは key: blockで受け取る引数名, value: サービス名 いった形式でも指定できる
# 他のコンテナのサービスに依存している場合はパス表記で指定する
dependencies => +{ dbh => 'TestDB/dbh' },
# block がなくて class だけ指定されているサービスは dependencies で指定された
# 引数を自動で渡してインスタンスを生成する
class => 'UserService',
);
service production_service => (
class => 'UserService',
dependencies => +{ dbh => 'ProductionDB/dbh' },
# オブジェクトのライフサイクルの管理(一度生成したらそれをキャッシュする)
lifecycle => 'Singleton',
);
};
use Test::More;
test();
sub test {
my $id = 'test_user';
# コンテナの test_service を利用する
my $service = $c->resolve( service => 'test_service' );
$service->regist(
id => $id,
password => '***********',
);
is_deeply(
$service->fetch_by_id($id),
+{
id => 'test_user',
password => '***********',
}
);
done_testing;
}
sub main {
my $id = 'some_user';
my $service = $c->resolve( service => 'production_service' );
$service->regist(
id => $id,
password => '***********',
);
p $service->fetch_by_id($id);
}
コードを見ていただければわかるように、コンテナの構造、サービス(コンテナに登録する内容)の依存関係をわかりやすいDSLで記述できます。
また、Bread::Board::Containerを継承したクラスを作ることで、簡単にコンテナクラスを作ることもできます。
まとめ
以上、PerlのDIコンテナであるBread::Boardについて簡単に紹介させていただきました。
重厚な感じがあるので速度の要求されるようなプロジェクトには向いていなさそうですが、巨大なプロジェクトには向いている特徴を備えていると思うので、良さそうだと思った方はぜひ使ってみてください。