LoginSignup
30
17

More than 5 years have passed since last update.

LaravelのEloquent モデルで動的にアクセサを生成する方法

Last updated at Posted at 2018-07-18

こんにちはみなさん

メタプログラミングっていい響きですよね。
そこはかとないオシャレ感とかっこよさを感じます。

メタプログラミングといえば、動的なメソッドの生成もその一つです。
とても明快な例として、Rubyのattr_accessorというメソッドがあります。
このメソッドを定義すると、自動的にアクセス可能なプロパティが定義されます。

class Something
    attr_accessor :price
end

というものがあった時、以下のようなメソッドを定義したのと同様になります。

class Something
    def price
        @price
    end

    def price=(val)
        @price = val
    end
end

こんな感じで、簡単に書くだけで、動的にアクセサができたら楽にならんかなぁって思いました。

TL;DR

  • Eloquent modelに動的にメソッドを追加する機構を作ってみた
  • 複数フィールドの暗号化をEloquentに導入してみたよ
  • もっと安全に書けないものだろうか

問題設定: DBの暗号化

ずいぶん昔の話ですが、個人情報を保護するために、それに関わるDBのカラムを暗号化するなんて言う話をしました。
https://qiita.com/niisan-tokyo/items/a8b230d31ebf94fb232d
例えば、ユーザーの名前は個人情報なので、DBに名前を格納するときは暗号化してほしいものですし、システムで使用する際は復号したものを使用したいものです。

LaravelではMutatorを使うことで暗号化を意識せずに各プロパティを使用することができます。例えば、name フィールドが暗号化対象であれば、Eloquentのmodelの定義の中で

public function getNameAttribute($val)
{
    \Crypt::decrypt($val);
}

public function setNameAttribute($val)
{
    $this->attributes['name'] = \Crypt::encrypt($val);
}

と書けば$user->nameのようなプロパティへのアクセスに対し、暗号化・復号を自動でやってくれるようになります。

しかし、暗号化の対象フィールドが多かったり、複数のテーブルにあったりすると、こんな単純なMutatorを書くのも面倒になります。
例えば、暗号化対象のフィールドが配列でcrypt_targets = ['name', 'addres', ...]のように定義されていれば、これらが自動的に上述したような暗号化・復号の対象になる、というように作れたら、いいんじゃないかなって思うのです。

動的なアクセサ

Eloquent model 内部のアクセサ

プロパティへのアクセスが来た時、Eloquent model 内部の挙動を見るのが手っ取り早いです。
基本的に、アクセスできないプロパティにアクセスしようとすると、マジックメソッドが走ります。参照するときは__getが走り、代入するときは__setが走るという塩梅ですな。

vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php
    /**
     * Dynamically retrieve attributes on the model.
     *
     * @param  string  $key
     * @return mixed
     */
    public function __get($key)
    {
        return $this->getAttribute($key);
    }

    /**
     * Dynamically set attributes on the model.
     *
     * @param  string  $key
     * @param  mixed  $value
     * @return void
     */
    public function __set($key, $value)
    {
        $this->setAttribute($key, $value);
    }

マジックメソッドは、ともに別のメソッドを参照しているようです。
これらのメソッドは別のtraitに記述されているので、そちらを参照します。
試しにgetの方を見に行ってみましょう。

vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
/**
     * Get an attribute from the model.
     *
     * @param  string  $key
     * @return mixed
     */
    public function getAttribute($key)
    {
        if (! $key) {
            return;
        }

        // If the attribute exists in the attribute array or has a "get" mutator we will
        // get the attribute's value. Otherwise, we will proceed as if the developers
        // are asking for a relationship's value. This covers both types of values.
        if (array_key_exists($key, $this->attributes) ||
            $this->hasGetMutator($key)) {
            return $this->getAttributeValue($key);
        }

        // Here we will determine if the model base class itself contains this given key
        // since we don't want to treat any of those methods as relationships because
        // they are all intended as helper methods and none of these are relations.
        if (method_exists(self::class, $key)) {
            return;
        }

        return $this->getRelationValue($key);
    }

まず、attributesにフィールドがあるかMutatorが存在するかを確かめ、あったらそこから値を取得し、なければリレーションを見に行くという仕組みですね。
setの方も仕組みは似たようなものです。

アクセサの改造

このgetAttributesetAttributeを上書きすることで、プロパティアクセスの方法をいじることができます。
ただ、影響範囲の大きな改造でもあるので、慎重にやりたいところです。
今回は動的アクセサの機構が必要なmodelのみにこの改造を施したいので、traitにしてみます。

ExtendsAccessor.php
<?php
namespace App\Daos\Behavior;

trait ExtendsAccessor
{

    protected static $ex_getter = [];
    protected static $ex_setter = [];

    /**
     * アクセサを定義する
     *
     * @param string $key    対象のフィールド
     * @param string $method 紐付けるメソッド
     * @param string $mode   get|set
     * @return void
     */
    protected static function setAccessor($key, $method, $mode = 'get')
    {
        $arr = 'ex_' . $mode . 'ter';
        static::$$arr[$key] = $method;
    }

    /**
     * getAttributeメソッドの書き換え
     *
     * @param string $key
     * @return mixed
     */
    public function getAttribute($key)
    {
        if (isset(static::$ex_getter[$key])) {
            $method = static::$ex_getter[$key];
            return $this->$method($key);
        }

        return parent::getAttribute($key);
    }

    /**
     * setAttributeメソッドの書き換え
     *
     * @param string $key   
     * @param mixed  $value
     * @return void
     */
    public function setAttribute($key, $value)
    {
        if (isset(static::$ex_setter[$key])) {
            $method = static::$ex_setter[$key];
            return $this->$method($key, $value);
        }

        return parent::setAttribute($key, $value);
    }
}

コード自体はすごく単純です。
動作も単純で、プロパティへのアクセスが発生した際、まず、ex_getterex_setterに設定されたプロパティかどうかの判断が入り、設定されていればそれに対応するメソッドが実行されるという形式です。

導入: 暗号化フィールドの動的アクセサ作成

先ほど作成した動的アクセサですが、こいつを生のままモデルに突っ込んでもいいのですが、暗号化のように他のところでもよく使いそうなものは、そのような用途に特化したtraitを作って、それをモデルに使わせたほうが後々楽ちんです。

暗号化フィールドを指定するtrait

これを実装してみましょう。

CryptField.php
<?php
namespace App\Daos\Behavior;

use Crypt;

trait CryptField
{

    use ExtendsAccessor;

    /**
     * boot時に呼ばれ、定義されたフィールドを暗号化フィールドとして登録する
     *
     * @return void
     */
    public static function bootCryptField()
    {
        if (!isset(static::$crypt_targets)) {
            abort(500, '暗号化対象がない');
        }

        foreach (static::$crypt_targets as $target) {
            static::setAccessor($target, 'getWithDecrypt', 'get');
            static::setAccessor($target, 'setWithEncrypt', 'set');
        }
    }

    /**
     * 暗号化フィールドを取得する
     * 取得する瞬間に復号する
     *
     * @param string $key
     * @return string
     */
    protected function getWithDecrypt($key)
    {
        $val = $this->attributes[$key] ?? null;
        return ($val === null) ? '': Crypt::decrypt($val);
    }

    /**
     * 暗号化フィールドにデータを格納する
     * 格納する瞬間に暗号化する
     *
     * @param [type] $key
     * @param [type] $value
     * @return void
     */
    protected function setWithEncrypt($key, $value)
    {
        $this->attributes[$key] = Crypt::encrypt($value);
    }
}

まあ、たいしたことないですね。
bootCryptField関数はモデルのboot時に一緒に呼ばれる初期化関数ですね。
詳しくは昔書いた記事を参照してやってください
この動作は、つまるところ$crypt_targetsという静的プロパティに暗号化対象のフィールドを突っ込んでやると、そいつが入出力時にいい感じに処理されるようになるというものです。

モデルに入れてみる

このトレイトをモデルに入れてテストしてみましょう。

Someone.php
<?php

namespace App\Daos;

use Illuminate\Database\Eloquent\Model;

class Someone extends Model
{
    use Behavior\CryptField;

    protected static $crypt_targets = ['firstname', 'lastname', 'address'];
}

まあ、そりゃシンプルですよね。
ほぼ全部CryptFieldがやっているもので。

テストしてみる

動作確認するとき、どうせ後で書くのだから、テスト書いちゃったほうが早いです。
最近はUnitテストか普通の挙動のテストなのか簡単に分離できるようになっていて、更に楽になりました。

php artisan make:test SomeoneTest --unit

勝手にテストディレクトリにUnitができて、その下にテストクラスができているので、

SomeoneTest.php
<?php

namespace Tests\Unit;

use Tests\TestCase;

use App\Daos\Someone;

class SomeoneTest extends TestCase
{
    public function testCrypt()
    {
        $model = new class extends Someone {
            public function getAllAttributes()
            {
                return $this->attributes;
            }
        };

        $model->firstname = 'にーさん';
        $model->lastname = 'トーキョー';
        $model->address = '中央区';
        $model->age = '451';

        $this->assertEquals('にーさん', $model->firstname);
        $this->assertEquals('トーキョー', $model->lastname);
        $this->assertEquals('中央区', $model->address);
        $this->assertEquals('451', $model->age);

        $attributes = $model->getAllAttributes();

        $this->assertNotEquals('にーさん', $attributes['firstname']);
        $this->assertNotEquals('トーキョー', $attributes['lastname']);
        $this->assertNotEquals('中央区', $attributes['address']);
        $this->assertEquals('451', $attributes['age']);

        var_dump($attributes);
    }
}

テストする時にオブジェクト内部のattributesを確認したくなると思いますが、そんなときは無名クラスを使ってメソッド追加しちゃうのが楽ですね
テスト内容は

  1. 暗号化フィールドにデータを入れる
  2. プロパティにアクセスした時、暗号化フィールドであっても入れたデータが取得できる
  3. 内部に格納されたデータは暗号化されている -> DBに投入されるのはこっち

みたいな感じです。
var_dumpは一応中身みたいので出していますが、内容一回見たら消しときましょう。

# ./vendor/bin/phpunit tests/Unit/SomeoneTest.php
PHPUnit 7.2.4 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)array(4) {
  ["firstname"]=>
  string(216) "eyJpdiI6InZzWWM4bklQdUcxRkxyNEgxa0JUOGc9PSIsInZhbHVlIjoibW8xNEtZWXVkdjg1Qmc0NUNkZjVqa29yaFNOcFEwNjZkRHpKeVlsU2ZyZz0iLCJtYWMiOiJjMDQyMmJlYmQwNThiMTc5ZjIwZmU1OTI1MjE0YzM4YTNiMzc5NTNlNDFiYmUxOGIyNGQ1MmI3ODM3OTAxOTI0In0="
  ["lastname"]=>
  string(216) "eyJpdiI6InNsVjBUYlQ5aENSVTN6WGJjZFYyZWc9PSIsInZhbHVlIjoicmh2NHEyQklHUWZSWElPNXNRamdZblVaRXF2RkJyWDl5YzBQcytZXC9aYlk9IiwibWFjIjoiMGViOWZhYjRlMmVlYTk2NTdmOTRmNjAxYmVkM2Y3MDU0YjQ5M2IxYTVmNmJjMGI0MmQ2OWE4MDc3N2VlNTQyZSJ9"
  ["address"]=>
  string(216) "eyJpdiI6Imo4WlRkNGUrTlpVVmFMR1pCMk5HZlE9PSIsInZhbHVlIjoiYUJ1SlBkYWtLSkxjMm9aU09TbE5CbWEwT2dCVEsxbWlScmt0dGhKaXVkND0iLCJtYWMiOiI0MmM1MmNkZjNjMGY1MmQ5OGUxYjdkNDJhYjMxZWUyMDgxNzA5YzY3MTRlZWFhZTdjOTkwODhkMjdlNGYxNGMxIn0="
  ["age"]=>
  string(3) "451"
}


Time: 1.46 seconds, Memory: 12.00MB

OK (1 test, 8 assertions)

ちゃんと暗号化されていますね。
暗号化対象でないものは、生のまま入ってます。

まとめ

Laravel の Eloquent モデルの getAttributesetAttribute を改造して、動的なアクセサを生成する仕組みを実装してみました。
各フィールドごとに getNantokaAttributeみたいな Mutator を書くのは面倒なので、trait 使って一括生成できるようになると、結構ストレス軽減できますぞ。
ちょっと深い部分に手を入れているので、そのあたりはかなり心配ですが...

もっと面白い実装方法とか、安全なやり方があったら、教えてくれると嬉しい限りです。
今回はこんなところですね。

30
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
17