数据实体、查找器、保存库

在 XF2 中,有许多与数据交互的方法。 在 XF1 中,这主要是针对在 Model 文件中写出原始 SQL 语句。 XF2 中的方法已经脱离了这一点,我们增加了一些新的方法。 我们首先来看一下运行数据库查找的首选方法 - Finder。

Finder (查找器系统)

我们引入了一个新的 "Finder" 系统,它允许以物件导向的方式以编程方式创建查找,这样就不需要编写原始数据库查找。 Finder 系统与 Entity 系统携手合作,我们在下面详细介绍。 传入 finder 方法的第一个参数是你要处理的 Entity 的短类名。 我们就把上面一节中提到的一些查找転换为使用 finder 系统来代替。 例如,要访问単个用户记录:

$finder = \XF::finder('XF:User');
$user = $finder->where('user_id', 1)->fetchOne();

直接查找方法与使用 Finder 之间的主要区别之一是 Finder 返回的数据基本単位不是一个数组。 在 Finder 物件调用 fetchOne 方法的情况下(该方法只从数据库中返回単个 row ),将返回単个 Entity 物件。

让我们看看一个稍微不同的方法,它将返回多个 row:

$finder = \XF::finder('XF:User');
$users = $finder->limit(10)->fetch();

这个例子将从 xf_user 资料表中查找 10 条记录,并以 ArrayCollection 物件的形式返回。 这是一个特殊的物件,它的作用类似于一个数组,因为它是可以遍历的(你可以在它里面循环),而且它有一些特殊的方法,可以告诉你它所拥有的条目总数,通过某些值进行分组,或者其他类似于数组的操作,如过滤、合并、获取第一个或最后一个条目等。

Finder 查找,一般应该是期望从资料表中检索所有的 Column,因此,没有特定的对应关系仅获取某些 Column 中的某些值。

相反,如果要获得単个值,你只需要获取一个 Entity,然后直接从这个 Entity 中读取值:

$finder = \XF::finder('XF:User');
$username = $finder->where('user_id', 1)->fetchOne()->username;

同样地,如果要从 Column 中获取一个数组的值,可以使用 pluckFrom 方法:

$finder = \XF::finder('XF:User');
$usernames = $finder->limit(10)->pluckFrom('username')->fetch();

到当前为止,我们已经看到 Finder 应用了一些简単的 where 和 limit 约束。 所以我们来看看 Finder 的更多详细内容,包括关于 where 方法本身的更多细节。

where 方法

where 方法最多可以支援三个参数。 第一个参数是条件本身,例如你要搜寻的 Column。 第二个参数通常是运算符。 第三个是被搜寻的 value。 如果你只提供了两个参数,就象你在上面看到的那样,那麽它自动意味着运算符是 =。 以下是其他有效的运算符列表:

  • =
  • <>
  • !=
  • >
  • >=
  • <
  • <=
  • LIKE
  • BETWEEN

所以,如果我们想要得到最近 7 天内注册的有效用户名単:

$finder = \XF::finder('XF:User');
$users = $finder->where('user_state', 'valid')->where('register_date', '>=', time() - 86400 * 7)->fetch();

正如你所看到的,你可以随意调用 where 方法,但除此之外,你还可以选择传入一个数组做为该方法的唯一参数,并在一次调用中创建你的条件。 数组方法支援两种类型,我们可以在上面创建的查找中使用这两种类型:

$finder = \XF::finder('XF:User');
$users = $finder->where([
    'user_state' => 'valid',
    ['register_date', '>=', time() - 86400 * 7]
])
->fetch();

通常不建议或明确地这样混合使用,但它确实在一定程度上展示了该方法的霊活性。 现在条件已经在一个数组中,我们可以指定 Column 名(作为数组的 key)和隐式的 = 运算符的 value,或者我们可以实际定义另一个包含 column、运算符和 value 的数组。

whereOr 方法

在上面的例子中,两个条件都需要满足,即每个条件都由 AND 运算符连接。 然而,有时需要只满足部分条件,这可以通过使用 whereOr 方法来实现。 例如,如果你想搜寻那些无效的用户或者是发布了零则留言的用户,你可以创建如下:

$finder = \XF::finder('XF:User');
$users = $finder->whereOr(
    ['user_state', '<>', 'valid'],
    ['message_count', 0]
)->fetch();

与上一节的例子类似,除了传递最多两个条件作为単独的参数外,你也可以只传递一个条件数组到第一个参数:

$finder = \XF::finder('XF:User');
$users = $finder->whereOr([
    ['user_state', '<>', 'valid'],
    ['message_count', 0],
    ['is_banned', 1]
])->fetch();

with 方法

with 方法本质上等同于使用 INNER|LEFT JOIN 语句,尽管它依赖于 Entity 已经定义了它的 "Relation"。 我们在下一页才会讨论这个问题,但这应该只是让你了解它是如何运作的。 现在让我们使用主题 finder 来检索一个特定的主题:

$finder = \XF::finder('XF:Thread');
$thread = $finder->with('Forum', true)->where('thread_id', 123)->fetchOne();

这个查找将获取 thread_id = 123 的主题实体,但它也会在背景与 xf_forum 资料表进行 join。 在控制如何做 INNER JOIN 而不是 LEFT JOIN 方面,这就是第二个参数的作用。 在本例中,我们将 "must exist" 参数设置为 true,所以它会把连接语句転换为使用 INNER 而不是默认的 LEFT

我们将在下一节详细介绍如何访问从这个 join 获取的数据。

也可以在 with 方法中传递一个 Relation 数组来进行多重 join。

$finder = \XF::finder('XF:Thread');
$thread = $finder->with(['Forum', 'User'], true)->where('thread_id', 123)->fetchOne();

这将会 join 到 xf_user 资料表以获得主题作者。 然而,由于第二个参数仍然是 true,我们可能不需要为 User join 做一个 INNER join,所以,我们可以用 chain 方法代替:

$finder = \XF::finder('XF:Thread');
$thread = $finder->with('Forum', true)->with('User')->where('thread_id', 123)->fetchOne();

order, limit 和 limitByPage 方法

order 方法

这个方法允许你按照特定的顺序来修改你的查找获取结果。 它需要两个参数,第一个是 column 名,第二个是可选的排序方向。 所以,如果你想列出拥有最多留言的 10 个用户,你可以创建像这样的查找:

$finder = \XF::finder('XF:User');
$users = $finder->order('message_count', 'DESC')->limit(10);

Note

现在可能是一个很好的时机来提及,finder 方法大多可以按照任何顺序调用。 例如: $threads = $finder->limit(10)->where('thread_id', '>', 123)->order('post_date')->with('User')->fetch(); 虽然如果你按照这个顺序写一个 MySQL 查找,肯定会遇到一些语法问题,但 Finder 系统还是会按照正确的顺序来构建,上面的代码虽然看起来很奇怪,可能也不建议使用,但那样写确实是完全正确的。

与标准的 MySQL 查找一样,可以对多个 Column 的结果集进行排序。 要做到这一点,你可以再次调用 order 方法。 也可以使用数组将多个 order 子句传递到 order 方法中。

$finder = \XF::finder('XF:User');
$users = $finder->order('message_count', 'DESC')->order('register_date')->limit(10);

limit 方法

我们已经看到了如何将查找 limit 在特定数量的记录上:

$finder = \XF::finder('XF:User');
$users = $finder->limit(10)->fetch();

不过,其实还有一个办法可以直接调用 limit 方法:

$finder = \XF::finder('XF:User');
$users = $finder->fetch(10);

可以直接将你的 limit 传入 fetch() 方法。 另外值得注意的是,limit(和 fetch)方法支援两个参数。 第一个显然是 limit,第二个是 offset。

$finder = \XF::finder('XF:User');
$users = $finder->limit(10, 100)->fetch();

这里的 offset 基本上意味着前 100 个结果将被弃用,之后的前 10 个结果将被返回。 这种方法对于提供分页结果是很有用的,不过我们其实也有一个更简単的方法...

limitByPage 方法

这个方法是一种辅助方法,最终会根据你当前视图的 "页面" 和你所需要的 "每页" 数量来设置相应的 limit 和 offset。

$finder = \XF::finder('XF:User');
$users = $finder->limitByPage(3, 20);

在这种情况下,limit 将被设置为 20(这是我们每页的值),offset 将被设置为 40,因为我们从第 3 页开始。

偶尔,我们需要抓取超过 limit 的额外数据。 Over-fetching 可以帮助侦测你在当前页面之后是否有额外的数据要显示,或者你是否需要根据权限过滤设置下来的初始结果。 我们可以通过第三个参数来实现:

$finder = \XF::finder('XF:User');
$users = $finder->limitByPage(3, 20, 1);

这样一来,从第 3 页开始,总共最多可获得 21个 用户(20+1)。

getQuery 方法

当你第一次开始使用 finder 时,尽管它很直観,但你可能偶尔会想知道你是否正确地使用了它,以及它是否会创建你期望的查找。 我们有一个名为 getQuery 的方法,它可以告诉我们当前的 finder 物件将创建的查找。 例如:

$finder = \XF::finder('XF:User')
    ->where('user_id', 1);

\XF::dumpSimple($finder->getQuery());

这将输出类似于以下的语句:

string(67) "SELECT `xf_user`.*
FROM `xf_user`
WHERE (`xf_user`.`user_id` = 1)"

你可能不会经常需要它,但如果 finder 没有返回你所期望的结果,那它可能会很有帮助。 在 Dump 变量 部分阅读更多关于 dumpSimple 方法的内容。

自定义 finder 方法

到当前为止,我们已经看到 finder 物件被设置了一个类似于 XF:UserXF:Thread 的参数。 在大多数情况下,这标识了 finder 正在处理的 Entity 类,并将解析为,例如,XF/Entity/User。 然而,它也可以另外代表一个 finder 类。 finder 类是可选的,但它们可以作为一种方法来为特定的 finder 类型添加自定义 finder 方法。 为了看到这一点,让我们看看与 XF:User 相关的 finder 类,它可以在 XF\FinderUser 类中找到。

下面示例是该 class 中的一个 finder 方法:

public function isRecentlyActive($days = 180)
{
    $this->where('last_activity', '>', time() - ($days * 86400));
    return $this;
}

现在,我们可以在任何 User finder 物件上调用该方法。 所以,如果我们拿前面的一个例子来说:

$finder = \XF::finder('XF:User');
$users = $finder->isRecentlyActive(20)->order('message_count', 'DESC')->limit(10);

这个查找,之前只是按照留言数降序返回 10 个用户,现在将按这个顺序返回最近 20 天内活跃的 10 个用户。

尽管对于很多 Entity 类型来说,finder 类是不存在的,但仍然可以通过 继承类 一节中提到的相同方式来继承这些不存在的类。

The Entity system (实体系统)

如果您熟悉 XF1,您可能会熟悉 Entity 背后的一些概念,因为它们最终是从那里的 DataWriter 系统衍生出来的。 如果您对它们不是很熟悉,下面的部分应该会让您有所了解。

Entity structure (实体结构)

Structure 物件由一些属性组成,这些属性定义了 Entity 的 Structure 和它所涉及的资料表。 Structure 物件本身就设置在它所涉及的 Entity 内部。 我们来看看 User Entity 中的一些常用属性:

Table (资料表)

$structure->table = 'xf_user';

它说明了 Entity 在 Update 和 Insert 记录时使用哪张资料表,也告诉我们 Finder 在构建查找运行时从哪张资料表读取。 此外,它还知道你的查找需要 join 到哪些其他资料表中起到了作用。

Short name (简短名)

$structure->shortName = 'XF:User';

这只是 Entity 本身和 Finder 类(如果适用)的短类名。

Content type (内容类型)

$structure->contentType = 'user';

这定义了这个 Entity 代表的内容类型。 在大多数 Entity 结构中不会需要这个。 它用于连接到 "content type" 系统使用的特定事物(这将在另一节中介绍)。

Primary key (主键)

$structure->primaryKey = 'user_id';

定义了代表资料表中 Primary key 的 Column,如果一个资料表支援多个 Column 作为 Primary key,那麽可以定义为数组。

Columns (多行数据)

$structure->columns = [
    'user_id' => ['type' => self::UINT, 'autoIncrement' => true, 'nullable' => true, 'changeLog' => false],
    'username' => ['type' => self::STR, 'maxLength' => 50,
        'required' => 'please_enter_valid_name'
    ]
    // 还有更多的 columns...
];

这是 Entity 设置的关键部分,因为这里面会详细解释 Entity 负责的每个数据库 column 的具体情况。 这告诉我们预期的数据类型,是否需要一个值,它应该匹配什麽格式,是否应该是唯一值,它的默认值是什麽,等等。

根据 type,Entity 管理器知道是否以某种方式对一个值进行编码或解码。 这可以是一个简単的过程,将一个值転换为一个字符串或整数,或者稍微复杂一点,例如在向数据库写入时,对一个数组使用 json_encode(),或者在从数据库读取时,对一个 JSON 字符串使用 json_decode(),以便将该值作为一个数组正确地返回给 Entity 物件,而不需要我们手动这样做。 它还可以支援对逗号分隔的值进行适当的编码/解码。

偶尔需要在写入一个值之前对其进行一些额外的验证或修改。 举个例子,在 User Entity 中,请看 verifyStyleId() 方法。 当在 style_id 字段上设置一个值时,我们会自动检查是否存在一个名为 verifyStyleId() 的方法,如果存在,我们会先通过该方法运行该值。

Behaviors (行为)

$structure->behaviors = [
    'XF:ChangeLoggable' => []
];

这是该 Entity 应使用的 Behaviors 类数组。 Behaviors 类是一种允许某些代码在多种 Entity 类型中通用重复使用的方式(仅在 Entity 变化时,而不是在读取时)。 一个很好的例子是 XF:Likeable Behaviors,它能够对支援可被 "点賛" 的内容实体自动运行某些操作。 这包括当内容中发生可见性变化时自动重新计数,以及当内容被删除时自动删除点賛数。

Getters (获取器)

$structure->getters = [
    'is_super_admin' => true,
    'last_activity' => true
];

当调用 name 字段时,会自动调用 Getter 方法。 例如,如果我们从 User Entity 中请求 is_super_admin,就会自动检查并使用 getIsSuperAdmin() 方法。 有趣的是,xf_user 资料表实际上并没有一个名为 is_super_admin 的字段。 这个字段实际上存在于 Admin Entity 中,但我们将其作为一个 getter 方法添加到了资料表中,作为访问该值的一种简写方式。 Getter 方法也可以用来直接复盖现有字段的值,这里的 last_activity 值就是如此。 last_activity 实际上是一个缓存值,通常在用户登出时才会更新。 然而,我们在 xf_session_activity 资料表中保存了用户最新的活动日期,所以我们可以使用 getLastActivity 方法来返回该值而不是缓存的最后活动值。 如果你需要完全绕过 getter 方法,只需要得到真正的 Entity 值,只需要在 column 名后加上下划线,例如 $user->last_activity_

因为一个 Entity 就象其他的 PHP 物件一样,你可以给它们添加更多的方法。 一个常见的使用案例是添加一些象权限检查这样的方法,这些方法可以在 Entity 自身上面调用。

Relations (关联)

$structure->relations = [
    'Admin' => [
        'entity' => 'XF:Admin',
        'type' => self::TO_ONE,
        'conditions' => 'user_id',
        'primary' => true
    ]
];

这就是 Relations 的定义。 什麽是 Relations? 它们定义了 Entity 之间的关联,这些关联可以被用来对其他资料表运行 join 查找,或者快速获取与 Entity 相关的记录。 如果我们还记得 finder 中的 with 方法,假如我们想获取一个特定的用户,并预先获取该用户的 Admin 记录(如果它存在的话),那麽我们将做如下操作:

$finder = \XF::finder('XF:User');
$user = $finder->where('user_id', 1)->with('Admin')->fetchOne();

这将使用 User Entity 中定义的 Admin 相关的信息和 XF:Admin Entity Structure 的细节,知道这个用户查找应该在 xf_admin 资料表和 user_id column 上运行 LEFT JOIN。 要从 User Entity 中获取 Admin 最后登录日期,请运行以下操作:

$lastLogin = $user->Admin->last_login; // 返回最后一次 Admin 登录的时间戳

然而,并不总是需要在 finder 中进行 join 来获取 Entity 的相关信息。 例如,如果我们以上面的例子为例,不调用 with 方法:

$finder = \XF::finder('XF:User');
$user = $finder->where('user_id', 1)->fetchOne();
$lastLogin = $user->Admin->last_login; // 返回最后一次 Admin 登录的时间戳

我们在这里仍然得到 last_login 值。 它是通过运行额外的查找来快速获取 Admin Entity 的。

上面的例子使用了 TO_ONE 类型,因此,这种关联将一个 Entity 与另一个 Entity 联系起来。 我们还有一个 TO_MANY 类型。

它不可能获取整个 TO_MANY 关联(例如在 finder 上使用 join / with 方法),但以查找为代价,它可以在任何时候快速读取,例如在上面最后的 last_login 例子中。

在 User Entity 上定义的一个这样的 Relation 是 ConnectedAccounts 关联:

$structure->relations = [
    'ConnectedAccounts' => [
        'entity' => 'XF:UserConnectedAccount',
        'type' => self::TO_MANY,
        'conditions' => 'user_id',
        'key' => 'provider'
    ]
];

这个 relation 能够从 xf_user_connected_account 资料表中返回与当前用户 ID 相匹配的记录,作为一个 FinderCollection。 这类似于我们在上面 Finder 部分提到的 ArrayCollection 物件。 在 Relation 定义中,指定该集合应该由 provider 字段作为 key。

虽然在运行 finder 查找时不可能获取多条记录,但可以使用 TO_MANY 关联从该 Relation 中获取 単个 记录。 举个例子,如果我们想查看用户是否与特定的关联帐户提供商相关联,我们至少可以在查找时获取该记录:

$finder = \XF::finder('XF:User');
$user = $finder->where('user_id', 1)->with('ConnectedAccounts|facebook')->fetchOne();

Options (选项)

$structure->options = [
    'custom_title_disallowed' => preg_split('/\r?\n/', $options->disallowedCustomTitles),
    'admin_edit' => false,
    'skip_email_confirm' => false
];

Entity Option 是在某些条件下修改 Entity Behavior 的一种方式。 例如,如果我们将 admin_edit 设置为 true(在 Admin CP 中编辑用户时就是如此),那麽某些检查将被跳过,例如允许用户的电子邮件地址为空。

Entity 生命周期

Entity 在管理数据库内记录的生命周期方面扮演着重要角色。 除了从它那里读取值,向它写入值之外,Entity 还可以用来删除记录,并在所有这些操作发生时触发某些事件,从而可以运行某些任务,或者也可以更新某些相关记录。 让我们来看看 Entity 在保存时发生的一些事件:

  • _preSave() - 在保存过程开始之前发生,主要用于运行任何其他保存前的验证或在保存发生之前设置其他数据。
  • _postSave() - 在数据被保存后,但在任何交易被提交之前,将调用此方法,您可以使用此方法来运行 Entity 被保存后应触发的任何其他事。

另外还有 _preDelete()_postDelete(),它们的运作方式类似,但在删除发生时。

Entity 还能够提供有关它当前状态的信息。 例如,有一个 isInsert()isUpdate() 方法,这样你就可以检测到这是一个新记录被插入还是一个现有记录被更新。 还有一个 isChanged() 方法,可以告诉你自上次保存后,某个特定字段是否发生了变化。

让我们看看这些方法在 User Entity 中的一些实际例子。

 protected function _preSave()
 {
    if ($this->isChanged('user_group_id') || $this->isChanged('secondary_group_ids'))
    {
        $groupRepo = $this->getUserGroupRepo();
        $this->display_style_group_id = $groupRepo->getDisplayGroupIdForUser($this);
    }

    // ...
 }

 protected function _postSave()
 {
    // ...

    if ($this->isUpdate() && $this->isChanged('username') && $this->getExistingValue('username') != null)
    {
        $this->app()->jobManager()->enqueue('XF:UserRenameCleanUp', [
            'originalUserId' => $this->user_id,
            'originalUserName' => $this->getExistingValue('username'),
            'newUserName' => $this->username
        ]);
    }

    // ...

_preSave() 的例子中,我们根据用户改变后的用户组来获取并缓存新的显示 Group ID。 在 _postSave() 的例子中,我们在用户的名字被更改后触发一个作业来运行。

Repository (保存库)

对于 XF2 来说,Repository 是一个新的概念,但是如果把它们与 XF1 中的 "Model" 物件进行比较,你可能不会受到指责。 我们在 XF2 中没有 Model 物件,因为我们有更好的地方和方法来获取和写入数据到数据库。 所以,与其说我们有一个庞大的 class,其中包含了你的附加组件所需要的所有查找,以及各种不同的方法来操作这些查找,不如说我们有 finder,它增加了更多的弾性。

另外值得注意的是,在 XF1 中,Model 物件对于许多东西来说有点象 "dumping ground"。 其中很多东西现在都是多余的。 例如,在 XF1 中,所有的权限重建代码都在权限 Model 中。 在 XF2 中,我们有特定的服务和物件来处理这个问题。

那麽,什麽是 Repository 呢? 它们对应着一个 Entity 和一个 Finder,并持有方法,一般会返回一个为特定目的设置的 Finder 物件。 为什麽不直接返回 Finder 查找的结果呢? 好吧,如果我们返回 Finder 物件本身,那麽它可以作为一个有用的继承点,让附加组件在 Entity 或集合返回之前,对其进行继承并修改 Finder 物件。

Repository 也可以包含一些具体的方法,比如缓存重建。