You are here
Home > !Laravel

パブリックキーを使用してsftp (2) sftponlyの環境で

前回では、Laravel Collective Remoteを利用して、sftpでファイルをアップロード・ダウンロードする話をしました。それだけで事はほとんど足りるのですが、1つ困ったことがありました。

sftpを使用するのは、たいてい自社のサーバーとではなく、他社とのサーバーとです。注文データを取得してくるのも、こちらのデータをアップロードするのも。御存知のように、sftpはsshと同じプロトコルであり、sshが使用できるならsftpも使用可能。しかし、先のような状況だと、セキュリティのために、sftpは使用できるけれど、sshは使用できないようにサーバーが設定されています。また、sftpでは、勝手に他のディレクトリへ行ったりできないように、閲覧できるディレクトリを特定して、jailします。

このような制限された設定となると、例えば以下のように、lsコマンドの実行が不可能となり、ファイルのリストさえ取ってくることできなくなります。

Psy Shell v0.7.2 (PHP 5.6.26 — cli) by Justin Hileman
>>> use SSH;
=> null
>>> SSH::into('acme')->run(['ls data']);
[foo@acme] (acme) This service allows sftp connections only.
..

かと言って、

>>> SSH::into('acme')->list('data');
PHP Fatal error:  Call to undefined method Collective\Remote\Connection::list()

のように、listのコマンドがあるわけでもありません。

いろいろ、探ってみると、

Laravel Collective Remoteは、

https://github.com/LaravelCollective/remote/blob/5.3/src/SecLibGateway.php

では、

以下のphpseclibというパッケージを使用しています。

https://github.com/phpseclib/phpseclib/blob/master/phpseclib/Net/SFTP.php

これらを参考にすると、

>>>SSH::into('acme')->getGateway()->getConnection()->nlist('data');
=> [
     "base-invoice.csv",
     "base-product.csv",
     ".",
   ]
..

というように、nlistを実行できます。

他にも、chdirとも実行できるようです。オープンソースのおかげでこういう問題自分で解決できます。

パブリックキーを使用してsftp

中規模のECを営む私のお客さんのところでは、自社製品を持ち出荷するゆえに、自社のウェブで販売するのみではなく、他社でのウェブサイトでも製品が売られています。となると、そこからも注文データが来ます。

その発注データは、ウェブサービスを使用したAPIを使用して取得、というようなものではなく、彼らが生成した注文データをCSVファイルとして指定のサーバーに置かれ、それを毎日sftpでダウンロードして、システム内に取り込みます。

また、逆に自社サイトで販売した注文情報を、出荷や解析の目的で他のサーバーにsftpでアップロードというケースもあります。

ここで重要なのは、パブリックキーを使用したsftpのコミュニケーションが必要なことです。

そこで登場するのが、以前会員編集フォーム紹介した、Laravel Collectiveです。

Laravel Collectiveのインストール

最初に、以下をコマンドラインで実行します。

$ composer require "laravelcollective/remote":"^5.3.0"

この実行で、必要なライブラリがインストールされ、composer.jsonが更新されます。

次に、config/app.phpのファイルをエディタで開き、以下を追加します。

  'providers' => [
    // ...
    Collective\Remote\RemoteServiceProvider::class,
    // ...
  ],

  'aliases' => [
    // ...
    'SSH' => Collective\Remote\RemoteFacade::class,
    // ...
  ],

設定

SSHのパッケージがインストールされたところで、次は設定です。Laravel用に開発されたパッケージでは、たいていはパッケージ独自の設定ファイルを、config/ディレクトリに作成します。

以下を、コマンドラインで実行してください。

$ php artisan vendor:publish --provider="Collective\Remote\RemoteServiceProvider"

この実行により、config/remote.phpの設定ファイルが作成されます。

エディターで、そのファイルを開き、acmeエントリーを追加します。acmeでなくても、名前はなんでもよいです。

  'default' => 'production',

  'connections' => [
        'production' => [
            'host'      => '',
            'username'  => '',
            'password'  => '',
            'key'       => '',
            'keytext'   => '',
            'keyphrase' => '',
            'agent'     => '',
            'timeout'   => 10,
        ],

        'acme' => [
            'host'      => env('ACME_HOST'), // sftp先
            'username'  => env('ACME_USERNAME'), // ログイン名
            'password'  => '', // 
            'key'       => base_path(env('ACME_KEY')), // パブリックキーファイルのパス名',
            'keytext'   => '',
            'keyphrase' => '', 
            'agent'     => '',
            'timeout'   => 10,
        ],
    ],

見ての通り、このファイルにはデフォルトとして、すでにproductionがありますが、それには触らず、ここではacmeの配列を新設しました。開発サイトでのテストも考えて、いろいろな情報をここで設定できるのは便利です。

今回は、バージョン管理にプライベートの値が入らないように、.envで指定するようにしました。特に、大きい値のパブリックキーを含むファイルを、keyで指定できるのも便利です。このファイルは、.gitignoreで必ずバージョン管理から排除が必要ですね。

keyphraseは、パブリックキーを生成したときに指定したパスワードを入れますが、パスワード指定なしで作成することが多いです。それでも十分セキュアであるのでここでは空とします。

.envでは、以下のような変数を追加します。

..
ACME_HOST=foo.google.com
ACME_USERNAME=foo
ACME_KEY=config/keys/test.pem
..

以上でテスト可能となります。もし、sftp先でsftpのみで設定されていないなら、

$ php artisan tinker

を実行して、接続テストができます。以下では、sshでacmeに接続して、lsコマンドを実行しました。

Psy Shell v0.7.2 (PHP 5.6.25 — cli) by Justin Hileman
>>> use SSH;
=> null
>>> SSH::into('acme')->run(['ls']);
[foo@acme] (acme) data.csv
=> null
>>> 

sftpでファイルのダウンロード・アップロード

ここまで来たら、あとは簡単です。プログラムの中で以下を実行するだけです。

ファイルのダウンロードは、

SSH::info('acme')->get('data.csv', storage_path('data.csv'));

ファイルのアップロードは、

SSH::info('acme')->put(storage_path('data.csv'), 'data.csv');

あたかも、コマンドラインで実行するような軽さですね。

グローバル変数をダイナミックに管理

Laravelのバージョンが5になってから、グローバルスコープを持つ変数の設定は、.envconfig/のディレクトリの設定ファイルで、実用的しかも綺麗にまとまりました。変数の使用も、config('app.url')と、プログラムのでどこでも簡単に取得できます。

また、DBの設定などのデフォルト設定ファイル以外にも、独自の設定ファイルも作成できます。

例えば、

config/local.phpというファイルを作成して、


return [
        'convert' => '/usr/bin/convert', // 画像変換のプログラムのパス名
        'items_per_page' => 50,          // 1ページのアイテム数
        'max_upload_size' => 20,         // 最大アップロードの画像サイズ(MB)
        'company' => env('LOCAL_COMPANY', 'Lots of Bytes'), // 会社名
];

とすれば、config('local.company')と参照できます。

しかし、これらのグローバル変数設定の問題は、プログラムとして設定ファイルあるいは.envでハードコードしなければならないことです。

グローバル変数をDBに値を保存して、管理画面で以下のように値を編集できて、プログラムの各所で使用できるようにするには、どうしたらよいのでしょうか?

my-application

幸いにも、config()の関数は、取得だけでなく書き込みも可能なのです。


config(['global.admin_email' => 'abc@gmail.com']);

というように実行すれば、config('global.admin')abc@gmail.comを返すことができます。

global.とプリフィックスしたのは、単に他の変数と区別やグループ分けがしたいがためです。

ということは、DBからデータを読んでプログラムの実行時の最初に、configして入れしまえば良いわけです。

このための適切な場所は、プログラムの初期に実行される、app/Providers/AppServiceProvider.phpの中です。


namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use DB;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
	$setting = DB::table('setting')->pluck('value', 'key');

	config(['setting' => $setting]);        
    }
...
}

DBテーブルsettingの中身は、以下とすると、

+------------+---------------------+---------------------+------------------+-------------------+
| setting_id | created_at          | updated_at          | key              | value             |
+------------+---------------------+---------------------+------------------+-------------------+
|          1 | 2016-12-03 01:09:51 | 2016-12-03 01:09:51 | email_admin      | admin@gmail.com   | 
|          2 | 2016-12-03 01:09:51 | 2016-12-03 01:09:51 | email_from       | from@gmail.com    | 
|          3 | 2016-12-03 01:09:51 | 2016-12-03 01:09:51 | site_name        | ララジャパン       | 
+------------+---------------------+---------------------+------------------+-------------------+

例えば、config('setting')['site_name']の実行では、ララジャパンの値が返ってきます。

ログイン成功のイベント

ユーザー認証(11)Laravel 5.2 ログインの記録で、ログイン成功後の処理に関して説明しました。

しかし、前回のログインのRemember Meのポストのための調査で、この「Remember Me」がオンになっているときは、先のログイン成功後の処理は、最初のログインのときだけしか実行しないことを見つけました。

つまり、最悪のケース、5年間ログイン成功後の処理は実行されません。同じブラウザを使用してもIPアドレスが変わることがあるし、記録としても不十分となり不都合です。

さて、どう解決したらよいでしょうか?

これにはLaravelのイベントのメカニズムを利用します。イベントの仕組みは、イベントとリスナーの2つのクラスの定義から構成され、イベントのクラスのオブジェクトをプログラムの中でコール(ファイヤー)することでリスナーのオブジェクトのアクションが実行されます。

例えば、ログインが成功したときに希望するアクション(ログインの記録)を実行したいときは、ログイン成功のイベントとそのリスナーを作成します。

ありがたいことに、ユーザー認証に関してのイベントは、すでにIlluminiate\Auth\Eventsで定義されており、Illuminiate\Auth\SessionGuardのクラス内のメソッドで要所要所でファイヤーされています。

イベントの種類としては、以下が揃っています。

Attempting ログインを試みたとき
Authenticated ログイン成功後のセッションにアクセスするとき
Failed ログインが失敗したとき
Lockout ログイン連続失敗でロックアウトされたとき
Login ログインが成功したとき
Logout ログアウトしたとき
Registered 会員登録完了したとき

今回は、Loginのイベントが使えそうです。

コードをチェックしたところ、Remember Meがオンのときも、このイベントをファイヤーします。

最初にログイン成功したときだけでなく、デフォルトでは2時間アイドル後にセッションが切れるときや、ブラウザを閉じて再度オープンするときなど、再度ログインが必要なときにRemember Meの情報を使用して自動ログインするときにも、このイベントがファイヤーされます。

ということで、イベントのクラスはすでにあるので、このイベントに対応するリスナークラスを作成して登録すれば作業終了です。

ログイン成功のイベントLoginに対して、リスナーを作成してみましょう。逆になりますが、まず登録から始めます。

登録は、

namespace App\Providers;

use Illuminate\Support\Facades\Event;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        'Illuminate\Auth\Events\Login' => [
            'App\Listeners\LoginListener',
        ],
    ];

    /**
     * Register any events for your application.
     *
     * @return void
     */
    public function boot()
    {
        parent::boot();

        //
    }
}

$listenの変数に、イベントに対応するクラスを指定するだけです。

次にリスナーのクラスを作成しますが、先の登録が完了していれば、便利なことに以下をコマンドラインで実行するだけで作成してくれます。

$ php artisan event:generate

もちろん、以下でも作成可能です。

$ php artisan make:listener LoginListner --event=Illuminate\Auth\Events\Login

作成された、リスナーを以下のように編集します。


namespace App\Listeners;

use Illuminate\Auth\Events\Login;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Request;

use App\UserLog;

class LoginListener
{
    /**
     * Create the event listener.
     *
     * @return void
     */
    public function __construct(Request $request)
    {
        $this->request = $request;
    }

    /**
     * Handle the event.
     *
     * @param  Illuminate\Auth\Events\Login  $event
     * @return void
     */
    public function handle(Login $event)
    {
        $user = $event->user;

        $ip = $this->request->ip();
        $agent = $this->request->header('User-Agent');

        UserLog::crate([
            'user_id'     => $user->id,
            'ip'          => $ip,
            'hostname'    => gethostbyaddr($ip),
            'agent'       => $agent
        ]);
    }
}

これで、ユーザーがログインをしたときや、セッションが期限切れで失って自動ログインされるときに、ログインの記録のレコードを作成してくれます。

ログインのRemember Me

Laravel 5.3になって、認証の部分が変りました。それに関していっぱい紹介したいことありますが、それは将来でのポストとして、今回は、「ログインのRemember Me」に関して学んだことを紹介します。

以下の画面のようにログインには、

my-application

「Remember Me」あるいは「次から入力を省略」、ログインのときにオンとすると次回から毎回毎回ログインする必要ないよという便利な機能。巷ではどこでも見かけます。

Laravelでも、認証のパッケージのインストール、

$ php artisan make:auth

を実行すると、デフォルトでついてくる機能です。

さて、この「Remember Me」でどうやってログインを要らなくするのか、そのメカニズムを探ってみましょう。

まず、「Remember Me」をクリックしてログイン成功すると、以下のように2つのクッキーを作成します。

  • laravel_session

    通常のセッションのためのクッキー。期限は、

    ..
        'lifetime' => 120,
        'expire_on_close' => true,
    ..
        'cookie' => 'laravel_session',
    ..
    

    lifetimeあるいはexpires_on_closeの値で決定されます。

    デフォルトでは、前者が2時間(120分)、後者は、fale。後者をtrueとするとブラウザをクローズしたときに期限切れとなります。ちなみに、このクッキーの名前は、cookieの値で変更可能です。

  • remember_web_59ba36addc2b2f9401580f014c7f58ea4e30989d

    こちらは、「Remember Me」のためのクッキーで、Laravelではログインした日から5年間と期限はハードコードされています。つまり、同じブラウザを使用し続けるなら、ログオフしない限り5年間ログインなしでアクセス可能です。

このクッキーが使用されるシナリオは簡単にはこうです。

ログイン後、2時間アイドルが続いたらあるいはブラウザを閉じたら、現在のセッションは無効になります。

セッションが無効となると、ユーザーの認証が不可能となります。なぜなら、セッションの中に含まれていたユーザーのIDの取得が不可能となるからです。そうなるとログインの画面に遷移して、再度ログインしなければなりません。

しかし、「Remember Me」をオンとしてログインしていたなら、この時点で、まだ期限までたっぷり時間がある「Remember Me」のクッキーを見に行きます。

このクッキーの中には、暗号化されたユーザーIDが入っているので、それを非暗号化して再度セッションを作成します。これにより、再度ログインをすることなしに、ログインした状態をキープすることが可能となるのです。

バリデーション (9) 配列のDistinctと暗黙の拡張

配列のDistinctバリデーション

前回の例題とした商品オプションに、オプション名に同じ値が入ることを禁じるバリデーションを追加します。

distinct

これには、配列プレースホルダー .* ともに laravel 5.2 で追加された distinct バリデーションが利用できます。よく考えられていますね。

$rules = [
  'option_id'     => 'required',
  'option_name.*' => 'required_with:option_id.*|distinct',
  'unit_price.*'  => 'required_with:option_id.*|integer|min:0',
  'inventory.*'   => 'required_with:option_id.*|integer|min:0',
];

 

空の入力に対するdistinct?

ところで、ここまで「オプション名」は必須入力であることを前提としてきましたが、商品オプションが1つだけなら「オプション名」は空のまま「販売単価」と「在庫数」だけ入力できるようにしたい、という要望が現場から上がってくるかもしれません。

もう少し仕様追求すると、商品オプションが2つ以上であっても、先頭(とは限らないかもしれません)の1つは空でよいかもしれません。

このために空の入力が1個まで可というバリデーションルールを追加するとロジックが複雑化します。required_with を外したとき distinct が空文字列もあわせて重複チェックしてくれれば話が簡単ですね。こんなふうに・・・

distinct_with_blank

3つの入力欄のうち2つを空にした場合のリクエストは次のようになります。

Array
(
    [option_name] => Array
        (
            [11] => 
            [12] => 
            [13] => オレンジ
        )

option_name のキー 11 と 12 は同じ値(空文字列)なのだから重複チェックにかかりそうなものです。

しかしこのバリデーションはそんな都合よく機能しません。

 

distinct バリデーションは、option_name 全体ではなく、配列の値それぞれに働きます。キー 11 の値は配列内で重複してるか? キー 12 の値は・・・と。

そして、バリデーションの原則は値が入力された項目にのみ働きます。このケースでは、そもそもキー 11 と 12 は調査されることなく、キー 13 の値が配列内で重複してるかだけが調べられ、重複なしと判定されるのです。

そう、リクエストが空でも働くのは、required 系バリデーションだけでした。

 

暗黙の拡張 implicitRules

ここまでくると次の展開が読めましたか?

この課題は解決には、distinctrequired と同様に 空リクエストに対してもバリデーションが働く 必要があり、これを実現するのが 暗黙の拡張 ルールです。

この暗黙の拡張を「暗黙の必須」と読んでしまうと理解を間違えます。空リクエストに対するバリデーション、それ以上でも以下でもありません。

暗黙の拡張ルールを作るには、Validator::extendImplicit() メソッドを使います。通常の拡張ルールとして登録されると同時に、暗黙の拡張ルールの名前を収めた配列 $implicitRules にルール名が追加されます。

Validator::extendImplicit('foo', function($attribute, $value, $parameters, $validator) {
    return $value == 'foo';
});

ということは、Validator を継承して作成した customValidaor クラスでは、直接 $implicitRules にルール名を追加することで暗黙の拡張を作ることができます。

以下は、空文字列を含んだ重複判定ができる distinct_with_blank バリデーションの作成例です。

class CustomValidator extends \Illuminate\Validation\Validator {

    public function __construct($translator, $data, $rules, $messages = [])
    {
        parent::__construct($translator, $data, $rules, $messages);

        // 暗黙の拡張に追加
        $this->implicitRules[] = 'DistinctWithBlank';
    }

    /**
     * 空文字列も含む重複判定 distinct_with_blank
     * @param  string $attribute
     * @param  string $value
     * @param  array  $parameters
     * @return true
     */
    public function validateDistinctWithBlank($attribute, $value, $parameters)
    {
        // 標準distinctを呼ぶだけ
        return parent::validateDistinct($attribute, $value, $parameters);
    }

これを用いて option_name.* から required_with を外すと、最初のルール定義は次のようになります。無事に、オプション名の空は1つだけ許可されるようになりました。

$rules = [
  'option_id'     => 'required',
  'option_name.*' => 'distinct_with_blank',
  'unit_price.*'  => 'required_with:option_id.*|integer|min:0',
  'inventory.*'   => 'required_with:option_id.*|integer|min:0',
];

 

この他にも、DB上の重複をチェックする unique に対し、空入力でも機能する unique_with_blank も状況によっては(DB定義がNULL OKではないなど)ニーズがあるかもしれませんね。

バリデーション (8) 配列をバリデーションする

laravel 5.2 で、バリデーションルールに配列を表す .* というプレースホルダーが使えるようになりました。

例えば、次のような商品オプションのバリデーションを考えます。

option_name

入力行の追加UIやドラッグ&ドロップによるソートはjQueryなどで実装することにします(laravel から離れるので解説は省きます)

この場合、項目数がいくつになるかわからないので、エレメントの属性名を name="option_name[{{$id}}]" などとし、配列を返すように作りますよね。

リクエストは次のようなものになるでしょう。配列のキーは編集データに依存しますので不定です(ここでは仮に 11, 12, 13 としました)

最低必要なバリデーションとしては、「保存」が少なくとも1つチェックされることと、「保存」がチェックされた行は必須入力となること、そして「販売単価」と「在庫数」の数値判定です。

苦労していた配列のバリデーションですが、5.2 からは次のようなルール定義が可能になったのです。

$rules = [
  'option_id'     => 'required',
  'option_name.*' => 'required_with:option_id.*',
  'unit_price.*'  => 'required_with:option_id.*|integer|min:0',
  'inventory.*'   => 'required_with:option_id.*|integer|min:0',
];

$messages = [
  'option_id.required' => '保存するレコードを少なくとも1つ選択してください',
];

「オプション名」、「販売単価」、「在庫数」のルール定義には属性名に .* を加えることで、配列の値それぞれにバリデーションが適用されるようになります。

「保存」がチェックされた行だけ入力が必要なので、required_with の引数も option_id.* となります。一方で「保存」の必須チェックは配列全体の required です。

配列部分のエラーは array_dot() による配列ドット記法で返ってきますので、Bladeテンプレートのエラーメッセージ表示は次のような書き方になります。

{{ $errors->first("unit_price.$id") }}

 

laravel 4 や 5.1 以前での配列バリデーション

後出しジャンケンでの自慢話となってしまいますが、私たちのプロジェクトでは lavavel 4 のころから配列に対するバリデーションを、5.2 と同じルールの書き方で処理してきました。

もともと laravel のバリデーションでは、リクエストは配列のまま処理されるでのはなく、array_dot によるドット記法の一次元データに変換されて内部処理されていました。

例えば次のように、配列のキーが固定されてる入力フォームの場合ならば、

<input type="text" name="name[last]" value="{{ old('name.last') }}">
<input type="text" name="name[first]" value="{{ old('name.first') }}">
Array
(
     [name] => Array
        (
            [last] => 山田
            [first] => 太郎
        )
)

以下のようにドット記法でルールを定義することで、配列データもバリデーションできたのです。

$rules = [
  'name.last'  => 'required',
  'name.first' => 'required',
];

 

最初に、「5.2 で .* というプレースホルダーに対応した」と書き、配列バリデーションそのものに対応したと書かなかった意味がここにあります。
このプレースホルダーの変換を自分で対応すれば、5.1 以前や 4 でも同様の処理ができるわけです。

定義するルール:

$rules = [
  'option_id'     => 'required',
  'option_name.*' => 'required_with:option_id.*',
  'unit_price.*'  => 'required_with:option_id.*|integer|min:0',
  'inventory.*'   => 'required_with:option_id.*|integer|min:0',
];

変換後のルール:

$rules = [
  'option_id'      => 'required',
  'option_name.11' => 'required_with:option_id.11',
  'option_name.12' => 'required_with:option_id.12',
  'option_name.13' => 'required_with:option_id.13',
  'unit_price.11'  => 'required_with:option_id.11|integer|min:0',
  'unit_price.12'  => 'required_with:option_id.12|integer|min:0',
  'unit_price.13'  => 'required_with:option_id.13|integer|min:0',
  'inventory.11'   => 'required_with:option_id.11|integer|min:0',
  'inventory.12'   => 'required_with:option_id.12|integer|min:0',
  'inventory.13'   => 'required_with:option_id.13|integer|min:0',
];

この変換は次のような関数で対応できます。ベースコントローラかトレイトに入れて、バリデーション直前にルールの変換を挿入してみてください。

/**
 * 配列バリデーションルールの変換
 * @param \Illuminate\Http\Request $request
 * @param array                    $rules
 * @param array                    $messages
 */
public static function setArrayRules($request, &$rules, &$messages)
{
    foreach($rules as $field => $rule)
    {
        // field文字列は foo.* か?
        if (!preg_match('/^(.+)\.\\*$/', $field, $m)) continue;

        // fooはリクエストに存在して配列か?
        $name = $m[1];
        if (!($req = $request->get($name)) || !is_array($req)) continue;

        foreach(array_keys($req) as $i)
        {
            // rulesに foo.$i を複写
            $rules["$name.$i"] = str_replace('*', $i, $rule);

            // messagesにfoo.$i.barがあれば複写
            foreach($messages as $key => $message)
            {
                if (!preg_match("/^$name\.\\*\.(.+)$/", $key, $m)) continue;
                $messages["$name.$i.{$m[1]}"] = $message;
            }
        }
        unset($rules[$field]);
    }
}

 

p.s.

上で紹介した setArrayRules() では、次のような配列の記述には対応してません。もし必要とするならばご自身で考えてみてくださいね。

Bladeソース:

<input type="text" name="option[{{$id}}][name]" value="old("opton.$id.name")">

バリデーションルール:

 'option.*.name' => 'required_with:option_id.*',

Laravel 5.3 resourceでの名前付きrouteの変更

以前に、Route::resourceの便利さを紹介しました。

routesを使いこなす(1)resourceを使う

routesを使いこなす(2)resourceを使いこなす

また、名前付きrouteがもたらす便宜さも紹介しました。

routesを使いこなす(4)routeを名付ける

しかし、Laravel 5.3のバージョンアップで「ちょっと、それはないよ」みたいな問題が出てきました。

Route::resourceでは、名前付きrouteが自動で作成されます。

例えば、Laravel 5.2では、routes.phpにこう設定すると、

Route::group(['prefix' => 'admin', 'middleware' => 'web', 'namespace' => 'Admin'], function () {

    Route::resource('product', 'ProductController');

});

以下のように名前付きroutesがprefixをもとに設定されます。「Name」の列です。

+--------+-----------+------------------------------+-----------------------+---------------------------------------------------------------+------------+
| Domain | Method    | URI                          | Name                  | Action                                                        | Middleware |
+--------+-----------+------------------------------+-----------------------+---------------------------------------------------------------+------------+
|        | POST      | admin/product                | admin.product.store   | App\Http\Controllers\Admin\ProductController@store            | web,web    |
|        | GET|HEAD  | admin/product                | admin.product.index   | App\Http\Controllers\Admin\ProductController@index            | web,web    |
|        | GET|HEAD  | admin/product/create         | admin.product.create  | App\Http\Controllers\Admin\ProductController@create           | web,web    |
|        | DELETE    | admin/product/{product}      | admin.product.destroy | App\Http\Controllers\Admin\ProductController@destroy          | web,web    |
|        | PUT|PATCH | admin/product/{product}      | admin.product.update  | App\Http\Controllers\Admin\ProductController@update           | web,web    |
|        | GET|HEAD  | admin/product/{product}      | admin.product.show    | App\Http\Controllers\Admin\ProductController@show             | web,web    |
|        | GET|HEAD  | admin/product/{product}/edit | admin.product.edit    | App\Http\Controllers\Admin\ProductController@edit             | web,web    |
+--------+-----------+------------------------------+-----------------------+---------------------------------------------------------------+------------+

しかし、おなじ、routes.phpをLaravel 5.3に持っていくと(もちろん、web.phpと改名して、app/Httpからroutes/のディレクトリに移して)、

+--------+-----------+------------------------------+-----------------+---------------------------------------------------------------+------------+
| Domain | Method    | URI                          | Name            | Action                                                        | Middleware |
+--------+-----------+------------------------------+-----------------+---------------------------------------------------------------+------------+
|        | POST      | admin/product                | product.store   | App\Http\Controllers\Admin\ProductController@store            | web,web    |
|        | GET|HEAD  | admin/product                | product.index   | App\Http\Controllers\Admin\ProductController@index            | web,web    |
|        | GET|HEAD  | admin/product/create         | product.create  | App\Http\Controllers\Admin\ProductController@create           | web,web    |
|        | DELETE    | admin/product/{product}      | product.destroy | App\Http\Controllers\Admin\ProductController@destroy          | web,web    |
|        | PUT|PATCH | admin/product/{product}      | product.update  | App\Http\Controllers\Admin\ProductController@update           | web,web    |
|        | GET|HEAD  | admin/product/{product}      | product.show    | App\Http\Controllers\Admin\ProductController@show             | web,web    |
|        | GET|HEAD  | admin/product/{product}/edit | product.edit    | App\Http\Controllers\Admin\ProductController@edit             | web,web    |
+--------+-----------+------------------------------+-----------------+---------------------------------------------------------------+------------+

なんと!
admin.product.store ⇒ product.store とadmin.が皆消えてしまいました。

ドキュメントを読むと、どうもこのprefixの設定は、resourceの名前付きrouteに反映される意図はなかったとのことで、5.3で修正という訳です。

以前と同様な、名前付きのrouteとするためには、asを使用して、以下のように設定します。

Route::group(['prefix' => 'admin', 'as' => 'admin.', 'middleware' => 'web', 'namespace' => 'Admin'], function () {

    Route::resource('product', 'ProductController');

});

注意:adminでなく、admin.です。最後にドットが必要。

あるいは、ひとつひとつを命名するというオプションもあります。しかし、これはちょっと。。

Route::group(['prefix' => 'admin', 'middleware' => 'web', 'namespace' => 'Admin'], function () {

    Route::resource('product', 'ProductController', ['names' => ['index' => 'admin.product.index']]);

});

バリデーション (7) エラーメッセージのリプレーサー

等号を含む日付の最大最小

日付の最大最小 after, before の比較はなぜか等号を含みません。英単語の意味を厳格にプログラム仕様に落とし込んだようですが、実際の使い勝手としては等号を含んでほしかったところです。

これを等号を含むように拡張してしまうと、前回の min, max を拡張したのと違って意味が変わってしまいます。
そこで、start, end を新たに作成して追加してみましょう。

'start_date' => 'date|start:today',      // 今日を含んでそれ以降
'end_date'   => 'date|start:start_date', // 開始日を含んでそれ以降

最低限の追加で済むように、内部で標準バリデーションの after, before を呼び出すようにします。標準が等号を含まないのですから、普通に考えると after に与えられた引数を -1(秒)し、before の引数を +1(秒)するのがわかりやすいでしょう。

しかし、これらの引数は日付そのものだけではなく、上に示したように比較対象の属性名が与えられることがありますから、加減算をするには属性名から参照先の日付を取得するコードを自分で書かなければなりません。

そこで発想を逆転して、検査する入力値の日付を +1(秒)して after に、-1(秒)して before に与えることにします。意味を考えると分かりづらいですが、相対的なものなので正負を逆転しただけです。

作成したバリデーションルールは次のとおりです。

public function validateStart($attribute, $value, $parameters)
{
    $value = date('Y-m-d H:i:s', strtotime($value.' +1 sec'));

    return parent::validateAfter($attribute, $value, $parameters);
}

public function validateEnd($attribute, $value, $parameters)
{
    $value = date('Y-m-d H:i:s', strtotime($value.' -1 sec'));
/*
    // 比較相手が 日付のみ場合は 23:59:59を補完する
    if ((($date = $this->getValue($parameters[0])) || ($date = $parameters[0]))
        && !preg_match('/:/', $date))
    {
        $parameters[0] = date('Y-m-d 23:59:59', strtotime($date));
    }
*/
    return parent::validateBefore($attribute, $value, $parameters);
}

validateEnd() のコメントされたコード部分は、DatetimeDate を比較したときに起きる問題の補正です。

例えば、DBレコードの販売期間が Datetime で、出荷期間が Date のときなど、異なる日付型の間で比較が必要な場合には有効にしてください。
補正は end のみに必要で、start には必要ありません。

$tests = [
    ['2016-10-10', '2016-10-10 10:00:00'],
    ['2016-10-10 10:00:00', '2016-10-10'],
];
foreach($tests as $i => $date) {
    printf("%d => %d %d\n", 
    $i, ($date[0] >= $date[1]), ($date[0] <= $date[1]));
}

結果

0 => 0 1 // ('2016-10-10' >=' 2016-10-10 10:00:00') == FALSE は正しい!
1 => 1 0 // ('2016-10-10 10:00:00' <= '2016-10-10 23:59:59'に補正が必要

 

メッセージへの引数の展開

さて、前回、比較対象の属性名を引数に持てるように拡張した min, max において、エラーメッセージの引数部分が、バリデーション言語ファイルの定義通りの日本語に展開されませんでした。
今回追加作成した start, end にいたっては、英文字の属性名にすら展開されません。この問題を解決しましょう。

今、バリデーション言語ファイルが次のように定義されているとします。

resources/lang/ja/validation.php

'after'  => ':attributeが:dateより前',
'before' => ':attributeが:dateより後',
'max' => [
  'numeric' => ':attributeが:maxよりも大きい',
],
'min' => [
  'numeric' => ':attributeが:minよりも小さい',
], 

// 追加

'start'  => ':attributeが:dateより前',
'end'    => ':attributeが:dateより後',

// 項目名

'attibutes' => [
  'end_date'   => '終了日',
  'start_date' => '開始日',
  'unit_cost'  => '仕入単価',
  'unit_price' => '販売単価',
],

このとき、それぞれのルールに対するエラーメッセージは次のように展開されます。

区分 ルール エラーメッセージ 課題
標準 ‘unit_price’ => ‘min:0’ 販売単価が0より小さい
拡張 ‘unit_cost’ => ‘max:unit_price’ 仕入単価がunit_priceより大きい 日本語にはならない
標準 ‘end_date’ => ‘before:start_date’ 終了日が開始日より後
追加 ‘end_date’ => ‘end:start_date’ 終了日が:dateより後 属性名への展開すらない

属性名を引数に取れるように拡張した min, max で英文字の属性名が展開できるのは、数値が与えられたときの標準の展開がそのまま適用されているためです。
 

カスタムプレースホルダー

エラーメッセージの中の :attribute:min, :date はプレースホルダーと呼ばれます。

カスタムのプレースホルダーを追加するには、サービルプロバイダーの boot メソッドの中で、Validator::replacer() メソッドを呼び出します。

Validator::replacer('foo', function($message, $attribute, $rule, $parameters) {
    return str_replace(...);
});

もしくは、Validator を継承した CustomVlidator クラスでは、addReplacer() メソッドか、複数のリプレーサーを配列にしてまとめて登録できる addReplacers() メソッドで登録できます。

$this->addReplacer('foo', function($message, $attribute, $rule, $parameters) {
    return str_replace(...);
});

リプレーサーの中身は簡単な文字列置換です。

与えられたエラーメッセージ($message)に対して、プレースホルダー文字列(":min"":date" など)を引数の文字列($parameters[0])に置き換えるコードを記述します。

引数($parameters[0])は与えられた最小値の値や、比較相手の英文字の属性名です。これを日本語に展開するには、バリデーション言語ファイルの $attributes 配列から対応語を参照する getAttribute() メソッドを使います。

 

以下、CustomValidator クラスのコンストラクタで min, max, start, end にリプレーサーを登録するサンプルです。共通部分が多いのでクロージャを変数に代入し、setReplacers() でまとめて配列登録しました。

これで、エラーメッセージは期待通りすべて日本語に展開されるようになりました。

<?php

namespace App\Services;

class CustomValidator extends \Illuminate\Validation\Validator
{
    public function __construct($translator, $data, $rules, $messages = [])
    {
        parent::__construct($translator, $data, $rules, $messages);

        // 汎用(ルールと同名)
        $replacer = function($message, $attribute, $rule, $parameters) {
            return str_replace(':'.$rule, $this->getAttribute($parameters[0]), $message);
        };
        // 日付
        $date = function($message, $attribute, $rule, $parameters) {
            return str_replace(':date', $this->getAttribute($parameters[0]), $message);
        };

        $this->addReplacers([
            'min' => $replacer, 'max' => $replacer, 'start' => $date, 'end' => $date
        ]);
    }

バリデーション (6) 他の属性の値を参照

日付の最大最小 after, before では、引数に値(日付)を与えるだけでなく、比較対象の属性の名前を与えることができます。
むしろ具体的な日付を引数にすることのほうがまれでしょう。

'date_start' => 'date|before:today',
'date_end'   => 'date|after:date_start',

このような使い方は、数値型の最大最小 min, max でも使用したいこともありますよね?

例えば、次のような関係です。

unit_cost

'unit_price' => 'required|integer|min:0',
'unit_cost'  => 'required|integer|min:0|max:unit_price',

標準バリデーションの minmax を拡張して、比較対象の属性名を引数に持てるようにしてみましょう。

 

getValue()

バリデーションクラスにルールを登録するときの雛形は次のようなものでした。
一見すると引数には、ルールを定義した属性に関する情報しか与えられません。

/**
 * 追加バリデーション
 * @param  string  $attribute  検査する属性名
 * @param  string  $value      入力された値
 * @param  array   $parameters 引数の配列
 * @return boolean
 */
public function validateHoge($attribute, $value, $parameters)
{
  //
}

しかし、バリデーションクラスにはリクエストされたすべての属性に関する情報が与えれていて、他の属性に入力された値も getValue() で得ることができます。getValue() の引数は属性名です。

よって、次のバリデーションは必ず TRUE を返します。

public function validateHoge($attribute, $value, $parameters)
{
   return ($value == $this->getValue($attribute));
}

 

min と max の拡張

min, max の引数は、属性の型が何であろうと必ず数値を取ります。

そこで、引数が数値(is_numeric)でないときを限定し、引数を属性名とした値が存在するならそれを新たなしきい値としました。

/**
 * 最小値 min
 * @param string $attribute
 * @param string $value
 * @param array $parameters	0 => 比較する属性名
 * @return true
 */
protected function validateMin($attribute, $value, $parameters)
{
    if (!is_numeric($parameters[0]) && 
        !is_null($val = $this->getValue($parameters[0])))
    {
        $parameters[0] = $val;
    }
 
    return parent::validateMin($attribute, $value, $parameters);
}

/**
 * 最大値 max
 * @param string $attribute
 * @param string $value
 * @param array $parameters	0 => 比較する属性名
 * @return true
 */
protected function validateMax($attribute, $value, $parameters)
{
    if (!is_numeric($parameters[0]) &&
        !is_null($val = $this->getValue($parameters[0])))
    {
        $parameters[0] = $val;
    }
 
    return parent::validateMax($attribute, $value, $parameters);
}

 
* * * *

さて、拡張した最大最小バリデーションですが、このままではエラーメッセージが与えられた引数の属性名のままとなり、言語ファイルによる置き換えが行われません。

unit_cost2

本来の最大最小が与えられた引数そのものを、メッセージのプレースホルダー :max と置き換えることしか想定していないからです。
この部分の解決については次回にまとめる予定です。

Top