Yii2学习笔记系列12——Application Structure-Models(应用结构之模型)

日 17 四月 2016

模型

模型是MVC设计模式中的一部分,是代表业务数据、规则和逻辑的对象。我们可以通过继承yii\base\Model或它的子类定义模型类,基类yii\base\Model支持许多实用的特性:

  • 属性(Attributes):代表业务数据,可以像普通类属性或者数组一样被访问;
  • 属性标签(Attribute labels):为属性指定的展示标签
  • 块赋值(Massive assignment):支持一步内给多个属性同时赋值
  • 验证规则(Validation rules):确保输入的数据都是通过了验证规则的
  • 数据导出(Data Exporting):允许模型数据导出为自定义格式的数组

Model类也是更多高级模型例如Active Record活动记录类的基类,更多关于高级模型的详情请参考相关手册。

注意:模型并不强制一定要继承yii\base\Model,但是由于很多组件支持yii\base\Model,所以最好使用它作为模型基类。

属性

模型通过属性来代表业务数据,每个属性都像是模型的公有可访问属性,yii\base\Modell::attributes()方法制定了模型类包含的属性。

我们可以像访问一个对象的属性一样来访问模型的属性:

<?php
$model = new \app\models\ContactForm;

// "name"是ContactForm模型的属性
$model->name = 'example';
echo $model->name;

感谢yii\base\Model提供的数组访问(ArrayAccess)数组迭代器(ArrayIterator),我们可以像访问数组项一样访问属性:

<?php
$model = new \app\models\ContactForm;

// 像访问数组项一样访问属性
$model['name'] = 'example';
echo $model['name'];

// 数组迭代器
foreach ($model as $name => $value) {
    echo "$name: $value\n";
}
定义属性

默认情况下,如果你的模型类直接从yii\base\Model继承,所有非静态的公有成员变量都是属性。例如,下述ContactForm模型类中有四个属性nameemailsubjectbodyContactForm模型用来代表HTML表单获取的输入数据。

<?php
namespace app\models;

use yii\base\Model;

class ContactForm extends Model
{
    public $name;
    public $email;
    public $subject;
    public $body;
}

另一种方式是通过重写yii\base\Model::attributes()来定义属性,该方法返回模型的属性名,例如yii\db\ActiveRecord返回对应数据表列名作为它的属性名,注意,可能需要重写魔术方法如__get()__set()以便属性可以像普通对象的属性一样被访问。

属性标签

当属性显示或获取输入时,经常要显示属性相关标签,例如如果一个属性名为firstName,在某些地方如表单输入或错误信息处,你可能想显示对终端用户来说更友好的First Name标签。

我们可以通过调用yii\base\Model::getAttributeLabel()方法来获取属性的标签,例如:

<?php
$model = new \app\models\ContactForm;

// 展示为“Name”
echo $model->getAttributeLabel('name');

默认情况下,属性标签通过yii\base\Model::generateAttributeLabel()方法自动从属性名生成,它会自动将驼峰式大小写变量名转换为多个首字母大写的单词,例如userName转换成Username,firstName转换为First Name。

如果你不想用自动生成的标签,可以通过重写yii\base\Model::attributeLabels()方法明确指定属性标签,例如:

<?php
namespace app\models;

use yii\base\Model;

class ContactForm extends Model
{
    public $name;
    public $email;
    public $subject;
    public $body;

    public function attributeLabels()
    {
        return [
            'name' => 'Your name',
            'email' => 'Your email address',
            'subject' => 'Subject',
            'body' => 'Content',
        ];
    }
}

对于支持多语言的应用,我们可能需要对属性标签进行翻译,这个需求也可以通过yii\base\Model::attributeLabels()方法来实现,如下所示:

<?php
public function attributeLabels()
{
    return [
        'name' => \Yii::t('app', 'Your name'),
        'email' => \Yii::t('app', 'Your email address'),
        'subject' => \Yii::t('app', 'Subject'),
        'body' => \Yii::t('app', 'Content'),
    ];
}

我们甚至可以根据条件定义属性标签,例如通过使用模型的场景, 可对相同的属性返回不同的标签。

补充:属性标签是视图一部分,但是在模型中申明标签通常非常方便,并可行程非常简洁可重用代码。

场景

模型可以在多个场景下使用,例如User模块可能会在收集用户登录输入或者用户注册时使用,在不同的场景下,模型可能会使用不同的业务规则和逻辑,例如email属性在注册时强制必须有,但在登录时则非必要。

模型使用yii\base\Model::scenario属性保持使用场景的跟踪,默认情况下,模型支持一个名为default的场景,如下展示了两种设置场景的方法:

<?php
// 场景作为属性来设置
$model = new User;
$model->scenario = 'login';

// 场景通过构造初始化配置来设置
$model = new User(['scenario' => 'login']);

默认情况下,模型支持的场景由模型中申明的验证规则来决定, 但你可以通过重写yii\base\Model::scenarios()方法来自定义行为,如下所示:

<?php
namespace app\models;

use yii\db\ActiveRecord;

class User extends ActiveRecord
{
    public function scenarios()
    {
        return [
            'login' => ['username', 'password'],
            'register' => ['username', 'email', 'password'],
        ];
    }
}

补充:在上述和下述的例子中,模型类都是继承yii\db\ActiveRecord, 因为多场景的使用通常发生在活动记录类(Active Record)类中。

scenarios()方法返回一个数组,数组的键是场景名,值为对应的活动属性(active attributes)。活动属性可被块赋值并且遵循验证规则,在上面的例子中,usernamepasswordlogin场景中启用,而在register场景中,除了usernamepassword之外,email也被启用。

scenarios()方法的默认实现方法会返回所有yii\base\Model::rules()方法中声明的验证规则中的场景,当重写scenarios()方法时,如果你想在默认场景外使用新场景,可以编写类似如下代码:

<?php
namespace app\models;

use yii\db\ActiveRecord;

class User extends ActiveRecord
{
    public function scenarios()
    {
        $scenarios = parent::scenarios();
        $scenarios['login'] = ['username', 'password'];
        $scenarios['register'] = ['username', 'email', 'password'];
        return $scenarios;
    }
}

场景特性主要在验证和属性块赋值中使用,你也可以用于其他目的,例如可基于不同的场景定义不同的属性标签。

验证规则

当接收到终端用户输入的模型数据时,这些数据应该通过验证以便确保它满足特定的规则(称为验证规则或者业务规则)。例如,给定一个ContactForm模型,你可能需要确保所有的属性不能为空,并且email属性包含一个合法的邮件地址。如果某些属性的值不满足相应的业务规则,系统会展示相应的错误信息,以便帮助用户修正错误。

我们可以通过调用yii\base\Model::validate()方法来验证接收到的数据,该方法使用yii\base\Model::rules()方法中声明的验证规则来验证每个相关属性,如果没有找到错误会返回true,否则它会将错误信息保存在yii\base\Model::errors属性中,并且返回false,例如:

<?php
$model = new \app\models\ContactForm;

// 用户输入数据赋值到模型属性
$model->attributes = \Yii::$app->request->post('ContactForm');

if ($model->validate()) {
    // 所有输入数据都有效 all inputs are valid
} else {
    // 验证失败:$errors 是一个包含错误信息的数组
    $errors = $model->errors;
}

我们可以通过重写yii\base\Model::rules()方法,指定模型属性应该满足的规则来声明模型相关验证规则,下面的例子展示了ContactForm模型声明的验证规则:

<?php
public function rules()
{
    return [
        // name, email, subject 和 body 属性必须有值
        [['name', 'email', 'subject', 'body'], 'required'],

        // email 属性必须是一个有效的电子邮箱地址
        ['email', 'email'],
    ];
}

一条规则可用来验证一个或多个属性,一个属性可以对应一条或者多条规则,详情参考验证输入一节。

有时候我们只想在某个场景下应用一条规则,为此我们可以指定规则的on属性,如下所示:

<?php
public function rules()
{
    return [
        // 在"register" 场景下 username, email 和 password 必须有值
        [['username', 'email', 'password'], 'required', 'on' => 'register'],

        // 在 "login" 场景下 username 和 password 必须有值
        [['username', 'password'], 'required', 'on' => 'login'],
    ];
}

如果没有指定on属性,规则会在所有场景下应用,在当前yii\base\Model::scenario下应用的规则被称之为活动规则(acrive rule)。

一个属性只有在属于scenarios()方法中定义的活动属性并且在rules()方法中声明对应一条或多条活动规则的情况下被验证。

块赋值

块赋值只用一行代码就可以将用户的所有输入填充到一个模型中,非常方便,它直接将输入数据填充到对应的yii\base\Model::attributes属性中。以下两段代码的效果是相同的,都是将终端用户输入的表单数据赋值到ContactForm模型的属性,明显地前一段块赋值的代码比后一段代码简洁并且不容易出错。

<?php
$model = new \app\models\ContactForm;
$model->attributes = \Yii::$app->request->post('ContactForm');
<?php
$model = new \app\models\ContactForm;
$data = \Yii::$app->request->post('ContactForm', []);
$model->name = isset($data['name']) ? $data['name'] : null;
$model->email = isset($data['email']) ? $data['email'] : null;
$model->subject = isset($data['subject']) ? $data['subject'] : null;
$model->body = isset($data['body']) ? $data['body'] : null;
安全属性

块赋值只会应用在安全属性上(所谓的安全属性,指的是一个模型的当前场景下yii\base\Model::scenarios()中列出的属性),例如,如果User模型声明以下场景,当当前场景是login的时候,只有usernamepassword可以被块赋值,其他属性不会被赋值。

<?php
public function scenarios()
{
    return [
        'login' => ['username', 'password'],
        'register' => ['username', 'email', 'password'],
    ];
}

补充:块赋值只应用在安全属性上,因为当我们想控制那些属性会被终端用户输入数据所修改,例如,如果当User模型中有一个permisssion属性对应用户的权限,我们可能只想让这个属性在后台被管理员修改。

由于默认情况yii\base\Model::scenarios()的实现方法会返回yii\base\Model::rules()中所有的属性和数据,如果不重写这个方法,则表示只要出现在活动规则中的属性都是安全的。

为了,Yii提供了一个特别的别名为safe的验证器来声明哪些属性是安全的并且不需要被验证的,如下示例的规则声明titledescription属性都是安全属性。

<?php
public function rules()
{
    return [
        [['title', 'description'], 'safe'],
    ];
}
非安全属性

如上所述,yii\base\Model::scenarios()方法主要有两个用处:定义哪些属性应该被验证,定义哪些属性是安全属性。但在某些情况下,我们可能想验证一个属性但不想让它是安全属性,这时候我们可以在scenarios()方法中为属性名添加一个叹号!,例如:

<?php
public function scenarios()
{
    return [
        'login' => ['username', 'password', '!secret'],
    ];
}

当模型在login场景下,三个属性都会被验证,但只有usernamepassword属性会被块赋值,如果想要对secret属性赋值,需要使用如下的方式:

<?php
$model->secret = $secret;

数据导出

模型通常要导出成不同的格式,例如,我们可能需要将模型的一个集合转换成JSON或者Excel格式,导出过程可以分解为两个步骤,第一步,模型转换成数组;第二步,数组转换成所需要的格式。我们只需要关注第一步,因为第二步可恶意通过数据转换器yii\web\JsonResponseFormatter来完成。

将模型转换为数组最简单的方式是使用yii\base\Model::attibutes属性,例如:

<?php
$post = \app\models\Post::findOne(100);
$array = $post->attributes;

yii\base\Model::attributes属性会返回所有yii\base\Model::attributes()中声明的属性的值。

一个更灵活更强大的将模型转换为数组的方式是使用yii\base\Model::toArray()方法,它的行为默认和yii\base\Model::attributes相同,但是它允许我们选择哪些数据项放入到结果数组中并且同时被格式化。实际上,这是导出模型到RESTful网页服务开发的默认方法,详情参考响应格式

字段

字段是模型通过调用yii\base\Model::toArray()生成的数组的单元名。

默认情况下,字段名对应属性名,但是我们可以通过重写yii\base\Model::fields()yii\base\Model::extraFields()方法来改变这种行为,两个方法都返回一个字段定义列表,fields()方法定义的字段是默认字段,表示toArray()方法默认会返回这些字段。 extraFields()方法定义额外的可用字段,通过toArray()方法指定$expand参数来返回这些额外可用字段。例如如下代码会返回fields()方法定义的所有字段和extraFields()方法定义的prettyNamefullAddress字段。

<?php
$array = $model->toArray([], ['prettyName', 'fullAddress']);

我们可以通过重写fields()来增加、删除、重命名和重定义字段,fields()方法返回值应为数组,数组的键为字段名,数组的值为对应的可为属性名或匿名函数返回的字段定义对应的值。 特使情况下,如果字段名和属性定义名相同,可以省略数组键,例如:

<?php
// 明确列出每个字段,特别用于你想确保数据表或模型属性改变不会导致你的字段改变(保证后端的API兼容).
public function fields()
{
    return [
        // 字段名和属性名相同
        'id',

        // 字段名为 "email",对应属性名为 "email_address"
        'email' => 'email_address',

        // 字段名为 "name", 值通过PHP代码返回
        'name' => function () {
            return $this->first_name . ' ' . $this->last_name;
        },
    ];
}

// 过滤掉一些字段,特别用于你想继承父类实现并不想用一些敏感字段
public function fields()
{
    $fields = parent::fields();

    // 去掉一些包含敏感信息的字段
    unset($fields['auth_key'], $fields['password_hash'], $fields['password_reset_token']);

    return $fields;
}

注意:由于模型的所有属性会被包含在导出数组,最好检查数据确保没包含敏感数据, 如果有敏感数据,应覆盖fields()方法过滤掉,在上述列子中,我们选择过滤掉auth_keypassword_hashpassword_reset_token

最佳实践

模型是代表业务数据、规则和逻辑的核心地带,模型经常需要在不同的地方被重用,在一个设计良好的应用中,模型通常比控制器的代码要多不少。

总结起来就是,模型:

  • 可以包含属性来展示业务数据;
  • 可以包含验证规则确保数据有效和完整;
  • 可以包含方法实现业务逻辑;
  • 不应该直接访问请求、session会话以及其他环境数据,这些数据应该由控制器传入到模型中;
  • 应该避免嵌入HTML或者其他展示代码,与HTML或者展示相关的代码最好在视图中进行处理;
  • 单个模型中避免过多的场景。

在开发大型复杂系统时,应该经常需要考虑最后一条建议,在这些系统中,模型会很大并且会在很多地方被使用,因此会包含必要的规则集和业务逻辑,最后维护这些模型代码会成为一个噩梦,因为一个简单的修改可能会影响很多地方,为确保模型便于维护,最好使用以下策略:

  • 定义可以被多个应用主体或者模块共享的模型基类集合,这些模型类应该包含通用的最小规则集合和逻辑。
  • 在每个使用模型的应用主体或者模块中,通过继承对应的模型基类来定义具体的模型类,具体模型类包含应用主体或者模块指定的规则和逻辑。

例如,在Yii高级模板中,我们可以定义一个模型基类common\models\Post,然后在前台应用中,定义并使用一个继承common\models\Post的具体模型类frontend\models\Post,在后台应用中可以类似地定义backend\models\Post,通过这种策略,我们可以清楚地知道frontend\models\Post只对应前台应用,如果我们修改它,无需担心会影响后台应用。

Category: PHP Develop

Comments