自製多個參數驗證(Custom validator for multiple attributes)

Yii 2.0 教學裡面已經解釋了怎麼設計自己的驗證,不過有些時候,你可能需要同時驗證多個互相關聯的參數。比方說,參數可能都很重要,難以抉擇哪個更加重要;或者規則比較複雜,導致參數之間會互相影響。

這邊,我們實做一個CustomValidator,來同時驗證多個參數。

1. 怎麼做

如果要檢查多個參數,預設會將所有的參數用相同的方式驗證。我們這邊使用不同的特性(trait),並覆蓋yii\base\Validator:validateAttributes()

<?php

namespace app\components;

trait BatchValidationTrait
{
    /**
     * @var bool whether to validate multiple attributes at once
     */
    public $batch = false;

    /**
     * Validates the specified object.
     * @param \yii\base\Model $model the data model being validated.
     * @param array|null $attributes the list of attributes to be validated.
     * Note that if an attribute is not associated with the validator, or is is prefixed with `!` char - it will be
     * ignored. If this parameter is null, every attribute listed in [[attributes]] will be validated.
     */
    public function validateAttributes($model, $attributes = null)
    {
        if (is_array($attributes)) {
            $newAttributes = [];
            foreach ($attributes as $attribute) {
                if (in_array($attribute, $this->attributes) || in_array('!' . $attribute, $this->attributes)) {
                    $newAttributes[] = $attribute;
                }
            }
            $attributes = $newAttributes;
        } else {
            $attributes = [];
            foreach ($this->attributes as $attribute) {
                $attributes[] = $attribute[0] === '!' ? substr($attribute, 1) : $attribute;
            }
        }

        foreach ($attributes as $attribute) {
            $skip = $this->skipOnError && $model->hasErrors($attribute)
                || $this->skipOnEmpty && $this->isEmpty($model->$attribute);
            if ($skip) {
                // Skip validation if at least one attribute is empty or already has error
                // (according skipOnError and skipOnEmpty options must be set to true
                return;
            }
        }

        if ($this->batch) {
            // Validate all attributes at once
            if ($this->when === null || call_user_func($this->when, $model, $attribute)) {
                // Pass array with all attributes instead of one attribute
                $this->validateAttribute($model, $attributes);
            }
        } else {
            // Validate each attribute separately using the same validation logic
            foreach ($attributes as $attribute) {
                if ($this->when === null || call_user_func($this->when, $model, $attribute)) {
                    $this->validateAttribute($model, $attribute);
                }
            }
        }
    }
}

然後,我們建立自己的驗證類別,並使用剛剛的特性:

<?php

namespace app\components;

use yii\validators\Validator;

class CustomValidator extends Validator
{
    use BatchValidationTrait;
}

如果我們希望即時驗證(inline validation),我們可以用相同的特性,但是改繼承即時驗證器:

<?php

namespace app\components;

use yii\validators\InlineValidator;

class CustomInlineValidator extends InlineValidator
{
    use BatchValidationTrait;
}

之後,還有幾個地方要修改。

首先,換掉原本的InlineValidator,使用自製的CustomInlineValidator,我們需要覆蓋CustomValidator的[[\yii\validators\Validator::createValidator()]] 函式:

public static function createValidator($type, $model, $attributes, $params = [])
{
    $params['attributes'] = $attributes;

    if ($type instanceof \Closure || $model->hasMethod($type)) {
        // method-based validator
        // The following line is changed to use our CustomInlineValidator
        $params['class'] = __NAMESPACE__ . '\CustomInlineValidator';
        $params['method'] = $type;
    } else {
        if (isset(static::$builtInValidators[$type])) {
            $type = static::$builtInValidators[$type];
        }
        if (is_array($type)) {
            $params = array_merge($type, $params);
        } else {
            $params['class'] = $type;
        }
    }

    return Yii::createObject($params);
}

最後,要在模型中支援我們的驗證,我們建立自製的特性,並覆蓋 [[\yii\base\Model::createValidators()]] 如下:

<?php

namespace app\components;

use yii\base\InvalidConfigException;

trait CustomValidationTrait
{
    /**
     * Creates validator objects based on the validation rules specified in [[rules()]].
     * Unlike [[getValidators()]], each time this method is called, a new list of validators will be returned.
     * @return ArrayObject validators
     * @throws InvalidConfigException if any validation rule configuration is invalid
     */
    public function createValidators()
    {
        $validators = new ArrayObject;
        foreach ($this->rules() as $rule) {
            if ($rule instanceof Validator) {
                $validators->append($rule);
            } elseif (is_array($rule) && isset($rule[0], $rule[1])) { // attributes, validator type
                // The following line is changed in order to use our CustomValidator
                $validator = CustomValidator::createValidator($rule[1], $this, (array) $rule[0], array_slice($rule, 2));
                $validators->append($validator);
            } else {
                throw new InvalidConfigException('Invalid validation rule: a rule must specify both attribute names and validator type.');
            }
        }
        return $validators;
    }
}

現在,我們可以透過繼承CustomValidator,實做我們自己的驗證器ChildrenFundsValidator

<?php

namespace app\validators;

use app\components\CustomValidator;

class ChildrenFundsValidator extends CustomValidator
{
    public function validateAttribute($model, $attribute)
    {
        // 這邊 $attribute 不是單一參數,而是包含所有相關參數的陣列
        $totalSalary = $this->personalSalary + $this->spouseSalary;
        // 如果有配偶薪資,成人最小需要量加倍
        $minAdultFunds = $this->spouseSalary ? self::MIN_ADULT_FUNDS * 2 : self::MIN_ADULT_FUNDS;
        $childFunds = $totalSalary - $minAdultFunds;
        if ($childFunds / $this->childrenCount < self::MIN_CHILD_FUNDS) {
            $this->addError('*', '這個家庭的薪資不足以育兒');
        }
    }
}

因為$attribute為相關參數的列表,我們可使用迴圈來為所有需要加上錯誤訊息的參數進行處理:

foreach ($attribute as $singleAttribute) {
    $this->addError($attribute, '這個家庭的薪資不足以育兒');
}

現在,我們可以在模型的驗證規則裡面,加上這個規則:

[
    ['personalSalary', 'spouseSalary', 'childrenCount'],
    \app\validators\ChildrenFundsValidator::className(),
    'batch' => `true`,
    'when' => function ($model) {
        return $model->childrenCount > 0;
    }
],

即時驗證的程式碼則是:

[
    ['personalSalary', 'spouseSalary', 'childrenCount'],
    'validateChildrenFunds',
    'batch' => `true`,
    'when' => function ($model) {
        return $model->childrenCount > 0;
    }
],

以及對應的驗證函式:

public function validateChildrenFunds($attribute, $params)
{
    // 這邊 $attribute 不是單一參數,而是包含所有相關參數的陣列
    $totalSalary = $this->personalSalary + $this->spouseSalary;
    // 如果有配偶薪資,成人最小需要量加倍
    $minAdultFunds = $this->spouseSalary ? self::MIN_ADULT_FUNDS * 2 : self::MIN_ADULT_FUNDS;
    $childFunds = $totalSalary - $minAdultFunds;
    if ($childFunds / $this->childrenCount < self::MIN_CHILD_FUNDS) {
        $this->addError('childrenCount', '這個家庭的薪資不足以育兒');
    }
}

2. 總結

這樣做的好處:

  • 程式碼能更清楚表示出所有相關的參數,規則可讀性更高。
  • 這種作法讓選項 [[yii\validators\Validator::skipOnError]] 和 [[yii\validators\Validator::skipOnEmpty]] 適用於每一個使用的參數,不僅僅是某一個我們認為比較重要的參數。

如果實做驗證的部份有問題,我們可以:

  • 結合 [[yii\widgets\ActiveForm::enableAjaxValidation|enableClientValidation]] 和[[yii\widgets\ActiveForm::enableAjaxValidation|enableAjaxValidation]] 選項,讓多個參數可以同時透過AJAX驗證,不需要重新載入頁面。
  • 從頭實做自己的驗證方式,畢竟 [[yii\validators\Validator::clientValidateAttribute]] 本來就是設計給單一參數驗證的。

results matching ""

    No results matching ""