先日共著で出版し、本日出版記念のイベントが行われる「Webアプリエンジニア養成読本」。基礎知識から運用まで一気通貫でWebアプリ周辺の教本を目指してアレンジしました。
技術評論社
売り上げランキング: 285
目玉となるアプリケーションのプログラミングを行う第2章では、諸々の関係上、PHPとRubyのみの実装を扱っています。執筆時期の最後の方で「2ページ空きが出来たからなんか書いて!」と言われ悪あがき的にPerlの紹介を書きましたがそれではさすがにページ数が足りません。そこで今回は「PHP/Rubyによる速習プラン」に追加する形で「Perlによる速習プラン」をお届けいたします。
とは言ってもPerlとはなんぞや?から入ると書くのが大変ですので、その辺りは「初めてのPerl」や「続・初めてのPerl」などで補ってください。今回は最も肝心なところの例として、Ruby編のサンプルアプリをつくる件、をPerlで実装してみたコードを紹介します。該当部分の作者であるすがさんによると…
はてなブックマークには及びませんが、素敵なWebアプリケーションを作成してきましょう。
ということでShioriというブックマークWebアプリをつくります。
既にあがっているコードはこちら。
仕様
分かりやすいようにすがさんの書いた箇所を今回つくるアプリなりにアレンジしつつ列挙します。まず、ユースケースは以下の2つ。
- URLを登録出来る
- 登録したURLの一覧を参照出来る
次にデータベースの構造はbookmark
テーブルひとつとなります。MySQL/InnoDBでの開発運用を見据えて、SQL文で書くとこんな具合になりました。
CREATE TABLE bookmark ( id INT UNSIGNED AUTO_INCREMENT, url TEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET 'utf8' engine=InnoDB;
URLのエンドポイントは以下の3つです。
-
GET /
=> 登録されたURLの一覧が見れる -
GET /new
=> URLを登録するフォーム画面 -
POST /create
=> 上記フォームがPOSTする先
Mojoliciousによる実装
Perl編ってことでWAFは「Mojolicious」を使います。Amon2でもやり方はそんな変わらないはずです。
まずMojolicious付属のmojo
コマンドで雛形をつくります。
$ mojo generatge app Shiori::Web
ディレクトリとファイルが生成されますが、これはそのまま使わず一部カスタムして利用します。最終的なディレクトリ構造は以下の通りです。完全僕の趣味が出てます。
. ├── etc # SQLのスキーマファイルなど入れる ├── lib │ └── Shiori │ ├── DB # これから説明するTeng::*を継承したモジュール群 │ ├── Model # ロジック │ └── Web # Webに関係するもの │ └── Controller # コントローラー ├── log # Mojoliciousのログ、レポジトリには入れない ├── script # 自動生成されたスクリプトがあるが使わない ├── t # テストコード └── templates ├── layouts # テンプレートの大枠 └── root # 個別テンプレート
.psgiの作成
.psgi
ファイルをつくっておくとPlack::Middleware::*
が使えたりして何かと便利なのでつくります。
use strict; use warnings; use FindBin; use lib "$FindBin::Bin/lib"; use Mojo::Server::PSGI; use Plack::Builder; use Shiori::Web; my $psgi = Mojo::Server::PSGI->new(app => Shiori::Web->new); my $app = $psgi->to_psgi_app; builder { # ここでPlack::Middleware::*を使う記述をする $app; };
これをshiori_web.psgi
という名前に保存してplackup
するとよいでしょう。
$ plackup -R templates,lib -p 5000 shiori_web.psgi
設定ファイルのロード
Mojoliciousにも設定ファイルをロードする機能はありますが、これをそのまま使うとMojolicious依存が強すぎちゃうんで独自でつくりましょう。環境変数PLACK_ENV
を覗いてその状態によりロードするファイルを分岐させています。Config::PLを使ったコードをlib/Shiori.pm
に書いてます。
package Shiori; use strict; use warnings; use Config::PL; our $VERSION = '0.01'; my $config; sub _load_config { my $mode = $ENV{PLACK_ENV} || 'development'; my $filename = "config_${mode}.pl"; return config_do $filename; } sub config { return $config if $config; $config = Shiori->_load_config(); return $config; } 1;
この設定ファイルがしっかりロードさせれているか?をテストコード書いて確かめましょう。実際に扱うconfig_development.pl
が存在しつつハッシュリファレンスを返せないととテストが通らないという、ちょっとよろしくない実装になってますが、分かりやすいので紹介します。t/config.t
がこちら。
use strict; use Test::More; BEGIN { use_ok('Shiori'); } subtest 'load_config' => sub { $ENV{PLACK_ENV} = 'development'; my $config = Shiori->config(); ok $config; isa_ok $config, 'HASH'; }; done_testing();
テストコードはこのように通常t/
ディレクトリに置いて、prove -l
コマンドで実行します。
$ prove -l t/config.t
Tengでデータベースを扱う
O/R MapperにはTengを利用してみましょう。lib/Shiori/DB.pm
及びlib/Shiori/DB/Schema.pm
を以下のように実装します。
package Shiori::DB; use parent 'Teng'; __PACKAGE__->load_plugin('Pager'); # 後ほどページャーをつくるのでPluginをロード 1;
Shiori::DB::Schema
は日付を扱うフィールドに対し、更新時DateTime型を与えられる、もしくは参照時にDateTime型で取得可能にするためにinflate
とdeflate
の設定をしています。
package Shiori::DB::Schema; use Teng::Schema::Declare; use DateTime::Format::MySQL; table { name 'bookmark'; pk 'id'; columns qw/id url created_at updated_at/; inflate qr/.+_at/ => sub { my $value = shift; return DateTime::Format::MySQL->parse_datetime($value); }; deflate qr/.+_at/ => sub { my $value = shift; return DateTime::Format::MySQL->format_datetime($value); }; }; 1;
ここまでくれば…
my $db = Shiori::DB->new( connect_info => [ 'dbi:mysql:shiori:localhost', 'root', undef ] ); my $bookmark = $db->single('bookmark', { id => 1 }); print $bookmark->url;
のような操作を行うことでデータベースを操作可能です。
モデルをつくる
上記のShiori::DB
を直接コントローラーで処理してもいいのですが、後の拡張性を考えた場合にもう一層モデルをかませた方がいいでしょう。ユースケースを元に
-
create
=> パラメータを元にブックマークを作成する -
entries
=> パラメータを元にブックマーク一覧を返却する
という二つのメソッドを持つモデルにします。この実装ではcreate
メソッドで引数の検証を行います。コントローラーでも使うことが出来るFormValidator::Lite
を利用してバリデーション。エラーだった場合とDBへのInsertが成功した場合で返却されるハッシュリファレンスの構造を変えることで呼び出し側が判断出来るようにしています。ちなみにこちらもMojolicious依存を避けるためにMojo::Baseは使わずにMouseなクラスにしています。
package Shiori::Model::Bookmark; use Mouse; use Shiori; use Shiori::DB; use DateTime; use FormValidator::Lite; FormValidator::Lite->load_constraints(qw/URL/); has 'connect_info' => ( is => 'ro', isa => 'ArrayRef', default => sub { return Shiori->config->{connect_info}; } ); has 'db' => ( is => 'ro', isa => 'Shiori::DB', lazy_build => 1 ); sub _build_db { my $self = shift; Shiori::DB->new( connect_info => $self->connect_info() ); } sub create { my ($self, $args) = @_; my $validator = FormValidator::Lite->new($args); $validator->load_function_message('en'); $validator->set_param_message( url => 'URL' ); my $res = $validator->check( url => [qw/NOT_NULL HTTP_URL/], ); if($validator->has_error) { my $messages = $validator->get_error_messages(); return { error => { messages => $messages } }; } my $now = $self->now; my $bookmark = $self->db->insert('bookmark', { url => $args->{url}, created_at => $now, updated_at => $now }); return { success => { bookmark => $bookmark } }; } sub entries { my ($self, $args) = @_; my $limit = $args->{limit} || 10; my $page = $args->{page} || 1; my ( $entries, $pager ) = $self->db->search_with_pager( 'bookmark', {}, { page => $page, rows => $limit, order_by => 'id DESC' } ); if(wantarray) { return ($entries, $pager); }else{ return $entries; } } sub now { DateTime->now( time_zone => 'Asia/Tokyo' ); } __PACKAGE__->meta->make_immutable();
さて、こちらもテストしていきましょう。DBを扱うのでローカルなどに立ててるテスト用のサーバーにアクセスさせてもいいですが、その都度追加したレコードを削除しなくてはいけなかったり扱いが面倒です。そこでTest::mysqldを使い専用のテンポラリなMySQLサーバをつくり出しそれを参照させます。
use strict; use Test::More; use FindBin; use lib "$FindBin::Bin/lib"; use DBI; use SQL::SplitStatement; use Path::Tiny; use Test::mysqld; use Shiori::Model::Bookmark; subtest 'bookmark' => sub { my $mysqld = Test::mysqld->new( my_cnf => { 'skip-networking' => '', } ) or die $Test::mysqld::errstr; my $dbh = DBI->connect($mysqld->dsn, 'root', undef); my $schema_file = path('etc', 'shiori_schema.sql'); my $schema_sql = $schema_file->slurp(); my $initial_sql = <<"SQL"; USE test; $schema_sql SQL my $splitter = SQL::SplitStatement->new( keep_terminator => 1, keep_comments => 0, keep_empty_statement => 0, ); for ( $splitter->split($initial_sql) ) { $dbh->do($_) or die($dbh->errstr); } my $dsn = $mysqld->dsn(); ok $dsn; my $model = Shiori::Model::Bookmark->new(connect_info => [ $dsn, 'root', undef ]); ok $model; my $res = $model->create({ url => 'htt://example.jp/' }); ok $res->{error}; $res = $model->create({ url => 'http://example.jp/' }); ok $res->{success}; isa_ok $res->{success}{bookmark}, 'Shiori::DB::Row::Bookmark'; my ($entries, $pager) = $model->entries({ page => 1 , limit => 1 }); ok $entries; isa_ok $pager, 'Data::Page::NoTotalEntries'; }; done_testing();
Web.pmとコントローラをつくる
モデルが出来ればあとはすんなりいくでしょう。lib/Shiori/Web.pm
を以下のように変更します。Mojoliciousの機能であるhelper
でモデルの呼び出しを可能にしています。コントローラー内で$self->model
メソッドが使えるようになるのです。
package Shiori::Web; use Mojo::Base 'Mojolicious'; use Shiori::Model::Bookmark; sub startup { my $self = shift; my $model = Shiori::Model::Bookmark->new(); $self->helper( model => sub { return $model; } ); my $r = $self->routes; $r->namespaces([qw/Shiori::Web::Controller/]); $r->get('/')->to('root#index'); $r->get('/new')->to('root#post'); $r->post('/create')->to('root#create'); } 1;
まだブックマークが登録されていないと思いますが、トップページのコントローラに該当する部分はこうでしょう。
sub index { my $self = shift; my $page = $self->param('page') || 1; my ($entries, $pager) = $self->model->entries({ page => $page, limit => 10 }); $self->stash->{entries} = $entries; $self->stash->{pager} = $pager; $self->render(); }
対応するテンプレートは以下のとおりです。Mojo::Templateを使っているのでPerlが直接書けちゃいます。
% title 'Top'; % layout 'default'; <h1>URL LIST</h1> <p> <a href="/new"><button type="button" class="btn btn-default">NEW POST</button></a> </p> <table class="table table-striped table-bordered table-hover"> <thead> <tr> <th>ID</th> <th>URL</th> <th>DATE</th> </tr> </thead> <tbody> % for my $entry (@$entries) { <tr> <td><%= $entry->id %></td> <td><a href="<%= $entry->url %>"><%= $entry->url %></a></td> <td><%= $entry->created_at->ymd('/') %> <%= $entry->created_at->hms(':') %></td> </tr> % } </tbody> </table> <ul class="pagination"> % if (my $prev_page = $pager->prev_page) { <li><a href="/?page=<%= $prev_page %>">«</a></li> % } <li><a href="#"><%= $pager->current_page %></a></li> % if (my $next_page = $pager->next_page) { <li><a href="/?page=<%= $next_page %>">»</a></li> % } </ul>
投稿用フォームを表示する/new
というパスに対応するコントローラーの/post
メソッドはこれだけでよいです。
sub post { my $self = shift; $self->stash->{messages} = undef; $self->render(); }
諸事情でstash
のmessages
という変数にundef
を渡しております。テンプレートは以下のとおり。エラーが出た際の表示も担っています。
% title 'POST URL'; % layout 'default'; <h1>POST URL</h1> % if ($messages) { <div class="alert alert-danger"> <ul> % for my $message (@$messages) { <li></li> % } </ul> </div> % } <form method="post" action="/create"> <div class="form-group"> " type="hidden" /> URL </div> <button type="submit" class="btn btn-default">Submit</button> </form>
いよいよ、投稿のエンドポイントPOST /create
に対するコントローラーメソッドは以下です。モデルからエラーが来た場合に適切に処理しています。
sub create { my $self = shift; my $validation = $self->validation(); if( $validation->csrf_protect->has_error() ){ return $self->render_not_found(); } my $res = $self->model->create({ url => $self->param('url') }); if( $res->{error} ) { $self->stash->{messages} = $res->{error}{messages}; return $self->render('root/new'); } $self->redirect_to('/'); }
完成
これまで紹介した個別のテンプレートファイルの外枠となるべきtemplates/layouts/default.html.ep
にてCSSフレームワークのBootstrapを読みこませれば完成です。
<!DOCTYPE html> <html> <head> <title>Shiori - <%= title %></title> <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css"> <link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap-theme.min.css"> </head> <body> <div class="navbar navbar-inverse"> <div class="container"> <a class="navbar-brand" href="/">Shiori</a> </div> </div> <div class="container"> </div> </body> </html>
ではplackup
でアプリを起動し「http://localhost:5000/」などにアクセスしてみましょう!
出来ましたね!
まとめ
コードばかりでかつ駆け足になっちゃいましたが、分からないところは適宜ドキュメントやWeb上のリソースを見てください!また、
- Mojoliciousでいいのか?
- DateTimeでいいのか?
- 画像やJSなど静的ファイルはどこに置くのか?
-
subtest
の粒度が荒いんだけどどうすれば? - Test::mysqldを毎スコープごとに立ち上げるの辛い
- テーブルが増えた時Join的なのはどうするのか?
- 本番サーバーで運用するには?
などの課題があると思うのでその点も意識しつつ、徐々にノウハウを貯めていければよいでしょう。今回のサンプルを含め、つくり方は色々なんで皆さんなりのWebアプリのつくり方を身につけてくださいね!そんでもって「Webアプリエンジニア養成読本」もよろしく!
技術評論社
売り上げランキング: 285