天然パーマです。

Perlによる速習プランもしくは「Webアプリエンジニア養成読本」番外編

先日共著で出版し、本日出版記念のイベントが行われる「Webアプリエンジニア養成読本」。基礎知識から運用まで一気通貫でWebアプリ周辺の教本を目指してアレンジしました。

Webアプリエンジニア養成読本[しくみ、開発、環境構築・運用…全体像を最新知識で最初から! ] (Software Design plus)
和田 裕介 石田 絢一 (uzulla) すがわら まさのり 斎藤 祐一郎
技術評論社
売り上げランキング: 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型で取得可能にするためにinflatedeflateの設定をしています。

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 %>">&laquo;</a></li>
% }
  <li><a href="#"><%= $pager->current_page %></a></li>
% if (my $next_page = $pager->next_page) {
  <li><a href="/?page=<%= $next_page %>">&raquo;</a></li>
% }
</ul>

投稿用フォームを表示する/newというパスに対応するコントローラーの/postメソッドはこれだけでよいです。

sub post {
    my $self = shift;
    $self->stash->{messages} = undef;
    $self->render();
}

諸事情でstashmessagesという変数に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/」などにアクセスしてみましょう!

Shiori

出来ましたね!

まとめ

コードばかりでかつ駆け足になっちゃいましたが、分からないところは適宜ドキュメントやWeb上のリソースを見てください!また、

  • Mojoliciousでいいのか?
  • DateTimeでいいのか?
  • 画像やJSなど静的ファイルはどこに置くのか?
  • subtestの粒度が荒いんだけどどうすれば?
  • Test::mysqldを毎スコープごとに立ち上げるの辛い
  • テーブルが増えた時Join的なのはどうするのか?
  • 本番サーバーで運用するには?

などの課題があると思うのでその点も意識しつつ、徐々にノウハウを貯めていければよいでしょう。今回のサンプルを含め、つくり方は色々なんで皆さんなりのWebアプリのつくり方を身につけてくださいね!そんでもって「Webアプリエンジニア養成読本」もよろしく!

Webアプリエンジニア養成読本[しくみ、開発、環境構築・運用…全体像を最新知識で最初から! ] (Software Design plus)
和田 裕介 石田 絢一 (uzulla) すがわら まさのり 斎藤 祐一郎
技術評論社
売り上げランキング: 285