人人都能看懂的全栈开发教程——ORM

人人都能看懂的全栈开发教程——ORM

Chris Yue No Comment
Posts

ORM 是 Object Relational Mapping 的缩写,直接翻译是『对象关系映射』,不过定义就是这样,光字面去理解完全不知道啥意思,我们还是来看看实际是怎么一回事儿。

在我们的老项目里,我们获取用户数据的时候,通过 PDO 获取到的是一个数组:

...
$statement = $this->pdo->prepare('select * from user where username = :username');
$statement->execute(compact('username'));

$user = $statement->fetch(Pdo::FETCH_ASSOC);
// 如果数据库里能找到对应的用户记录,那么 $user 值应该是类似于
/*
[
    'id' => '1',
    'username' => 'chris',
    'password' => 'xxx...',
]
*/

假如说,我们有一个用户类 Domain\Entity\User,类的定义如下:

<?php

namespace Domain\Entity;

class User
{
    private $username;

    private $password;

    private $id;

    public function getId(): int
    {
        return $this->id;
    }

    public function getUsername(): string
    {
        return $this->username;
    }
}

我们也可以通过下面的方式,来获取用户的数据:

...
$statement->setFetchMode(Pdo::FETCH_CLASS, 'Domain\Entity\User');
$user = $statement->fetch();
// 如果数据库里能找到对应的用户记录,那么 $user 值是 User 类的对象。

可能有人就会问了,同样都是获取数据,这两种方式有什么区别吗。如果返回的值是我们自己定义的一个类的对象,对比数组这种简单数据结构,还是有一些好处的。

如果值是一个我们自己定义的类的对象,我们就可以限定属性的读写权限。比如我们上面通过对 Domain\Entity\User 的定义,限定了用户的 idusername 只能读(通过 getXxx 方法,一般称为『getter』),不能写, password 则读写都不行,而数组是做不到这点的。

另外,对象不但可以有属性,还可以有方法,假如说我们的用户类有一个字段叫做『生日』,那么我们不仅可以有一个方法叫『获取生日』,而且还可以有个方法叫『获取年龄』:

class User
{
    ...
    private $birthday;

    ...
    public function getAge(): int
    {
        $now = new \DateTime();

        return $now->diff(new \DateTime($this->birthday))->y;
    }
}

这样我们就可以很方便得直接通过 $user->getAge() 来获取年龄了,调用者无需知道年龄获取的细节是什么。而数组这方面也做不到。

这里简单提一下,我们把这种可以保存的数据类,习惯性叫做『实体』,也就是我们类名(更准确说应该是命名空间)里的 Entity

这种将数据库的字段,映射(Mapping)到实体对象字段上的做法,就是 ORM。那是不是说 PDO 已经实现了 ORM 的功能呢?上面的例子,只是从数据库里获取数据,但保存的数据的时候,是没法直接通过对象来保存的。从这个角度来说,PDO 只能算有『半个』ORM 的功能吧。

正因为 PDO 对 ORM 的支持不完整,所以 PHP 有第三方的库,在 PDO 的基础上继续增加功能,让 ORM 的特性得以全部的实现。今天要介绍的 Doctrine 就是这么一个第三方库。实际上,PDO 对 ORM 的支持还有很多其他局限性,大家可以通过后面的例子看出来。

Doctrine 也是作为 Symfony 框架默认的依赖库,已经被安装在我们的项目当中了,有了 Doctrine 之后,我们来看看如何改进我们的项目代码,能用上 ORM 的功能。

我们首先应当让我们的新项目知道如何连接数据库。在 Symfony 里,数据库连接配置被放在了根目录的 .env 文件下。还记得我说 . 开头的文件都是隐藏文件吗?你需要用 ls -a 命令才能看到它,当然,因为你已经知道有这么个文件,你也可以直接通过 gedit .env 命令来编辑它:

...
DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7
# 这是默认的内容,其实已经提醒你如何修改了,只用把你自己的数据库用户、密码、以及库名替换上面的 db_user、db_password、db_name 即可
# 如果你没有设置密码,则直接去掉 db_password 以及前面的冒号
# 最后 serverVersion 是数据库的版本号,MySQL 可以通过执行 SQL 语句 
# select version();
# 来确认

现在我们来为『用户』和『任务』表创建对应的类,用户类我们就延续之前的例子 Domain\Entity\User,只不过新增一个构造函数:

<?php
// 代码位于 domain/Entity/User.php

...
class User
{
    public function __construct(string $username, string $password)
    {
        $this->username = $username;
        $this->password = $password;
    }
    ...
}

这里稍作解释,增加构造函数的目的是为了传达一个信息,即一个『用户』必须要有用户名和密码。这并不是必须的,只是我个人的一种代码风格,你也可以通过增加 setUsername 方法去设置 username 属性,然后通过注释的方式告诉使用者,创建用户是必须设置用户名,但我认为,如果可以通过代码本身来传递信息,就不要通过注释去传达,不但更简洁,而且还有『强制性』(构造函数要求你创建用户对象之前,就得把用户名和密码先准备好)。

接下来是我们的任务类,这里依然使用构造函数来表示一个任务数据必须有用户和内容:

<?php
// 代码位于 domain/Entity/Task.php
namespace Domain\Entity;

class Task
{
    private $content;

    private $createdAt;

    private $user;

    public function __construct(User $user, string $content)
    {
        $this->user = $user;
        $this->content = $content;
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getContent(): string
    {
        return $this->content;
    }

    public function getCreatedAt(): \DateTimeInterface
    {
        return $this->createdAt;
    }

    public function getUser(): User
    {
        return $this->user;
    }
}

这里依然有一个小改进也需要跟大家解释一下。不知道大家是否还有印象,原来添加任务时,任务的创建时间我们并没有指定,实际是 MySQL 自动生成的,这里我们改成了在创建对象的时候由 PHP 来生成创建时间。虽然用 MySQL 自动生成创建时间也没有问题,但这有一个前提是『MySQL 的时间必须跟 PHP 的时间一致』。大家始终记得不同的服务是可以装在不同的机器上的。可以想象一下,假如 MySQL 服务器不知道什么原因,时间比 PHP 服务器的晚了近一年,按照我们之前的做法,在 MySQL 里,我们刚生成的任务数据的创建时间都被记录成了去年。如果我们有另外一个需求是『列出今日创建的任务』,日期筛选条件又是 PHP 生成的,我们就看不到刚创建的那条任务。所以为了解决这种因服务器时间不同造成的冲突,我们就不要让时间不同,而能从根本解决时间不同的方案也很简单,就是时间都统一由 PHP 来生成。

可能又有人会问,为什么用户和任务两个类是放在 domain 下的,我们不是因为要用 ORM 才改的代码吗?虽然我们的确是为了用 ORM 才改的代码,好像是跟具体实现有关系,但这两个类我们是可以看成就是业务逻辑的数据,无论你用什么方式来实现数据存储,都可以用用户和任务这两个类来表示业务部分。虽然我们到现在才用用户类和任务类来表示我们的用户和任务,实际上我们早就应该这么做了,只不过前期为了让读者能先集中在『基本功』上,并没有引入这个话题而已。

而 ORM 的目的也就是为了让业务数据类能在『不知道自己是怎么被存储』的前提下,实现存储业务数据类。

为了实现业务数据的存储,肯定需要告诉 ORM 如何将类和表映射起来,我们来看看 Doctrine 是怎么做的。

我们先在 config 目录下再创建目录,叫 doctrine/mappings/domain,意思是 Doctrine 的业务数据相关的映射配置。我们先创建第一个文件 User.orm.xml,包含以下代码:

<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
        https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
    <entity name="Domain\Entity\User">
        <id name="id" type="integer">
            <generator strategy="AUTO" />
        </id>
        <field name="username" type="string" length="255" />
        <field name="password" type="string" length="255" />
    </entity>
</doctrine-mapping>

这是一个 XML 格式的配置文件,doctrine-mapping 是根节点。根节点有很多属性,并且值都是一些 URL,大家先不用去细究这些 URL,直接忽略掉就行。

接下来是 entity 节点,定义了是针对哪个类做的配置,节点里面定义了 id 是自增的整数,usernamepassword 是字符长度为 255 的字符串。这样就把 Domain\Entity\User 类和数据库的 user 表怎么关联起来,就说清楚了。注意虽然配置文件里没有说表名时什么,但 Doctrine 还是有一定『智能』的,它会猜测表名跟类名同名,并且都是小写字母。如果表名跟类名不一样,你也可以在 entity 节点上添加属性 table 指明表名。

接下来我们把 Task.orm.xml 也添加上:

<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
        https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
    <entity name="Domain\Entity\Task">
        <field name="content" type="string" length="255" />
        <field name="createdAt" type="datetime" />
        <many-to-one field="user" target-entity="User" />
    </entity>
</doctrine-mapping>

差不多跟 User.orm.xml 雷同,除了 many-to-one 节点,它表示 Task 类的 user 属性,实际上就是一个 User,并且 TaskUser 有多对一的关系。

最后,我们打开 config/packages/doctrine.yaml 文件,并作出以下调整:

# 此文件格式为 yaml,这是 yaml 文件的注释
doctrine:
    ...
    orm:
        ...
        # 这句不用改,但需要注意,正因为这句配置,能让 createdAt 映射到表里的 created_at 字段,这在 PDO 里没法直接做到
        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
        mappings:
            # 默认这里是 App,现在改成 Domain
            Domain:
                is_bundle: false
                type: xml # 这里也需要改,原来是 annotation
                # dir 是指 mapping 文件的路径
                # %kernel.project_dir% 是指我们的项目根目录
                dir: '%kernel.project_dir%/config/doctrine/mappings/domain'
                # 下面俩也都是把 App 改成 Domain 就行
                prefix: 'Domain\Entity'
                alias: Domain

YAML 也是一种表示数据的文件,作用同 XML 一样(从名字上也能看出一丝类似来,XML 是 eXtensible Markup Language,而 YAML 是 Yet Another Markup Language),只不过 YAML 通过缩进来表示节点和节点的层级关系。YAML 文件的可读性还是很强的,我觉得没必要做解释,大家可以在例子中直接感受。

最后,我们需要检查以下我们的所有配置是否已经可以被正常识别,这里我们可以直接在命令行运行 Doctrine 提供的工具来检查配置:

bin/console doctrine:schema:validate

需要提醒一下,虽然 bin/console 是 Symfony 提供的工具,不过 Symfony 为了让 Doctrine 的使用更方便,做了一些『粘合』的工作,这里不细说具体做了什么工作,总之这里运行的命令的『核心』依然是 Doctrine 提供的,跟之前提到过的 simple-phpunit 有点类似。

另外再提一个小技巧,Symfony 提供的命令行工具也是有一些『小智能』的,Symfony 的所有命令,都类似于 xxx:yyy:zzz 这种写法,实际上每一段,都是可以不用写完的,只要不会有冲突,也可以运行,比如上面的命令写成

bin/console doc:sch:va
# 甚至
bin/console d:s:v

都是可以的。

运行完命令我们查看结果,会被告知:

No identifier/primary key specified for Entity “Domain\Entity\Task”. Every Entity must have an identifier/primary key.

意思是 Domain\Entity\Task 类没有 id,每一个实体都必须个 ID。虽然我们现在的 Task 实体的确是用不上 ID,但以后做删除的时候,为了好区分到底是删除哪个任务,肯定还是需要 ID 的,所以我们还是先把 ID 给添加上:

<?php

namespace Domain\Entity;

class Task
{
    private $id;

    ...

    public function getId(): int
    {
        return $this->id;
    }
}

映射配置里也加上对 ID 的描述:

...
<doctrine-mapping ...>
    <entity name="Domain\Entity\Task">
        <id name="id" type="integer">
            <generator strategy="AUTO" />
        </id>
        ...
    </entity>
</doctrine-mapping>

再此执行检查,就应该会显示 OK 没问题了。

还有一种情况,有一些字段是我们业务肯定不需要的,但具体实现必须要求要加,其实也是有方法可以做到的,现在先暂时不说,我们后面会遇到这种情况。

我们是通过 XML 文件来配置实体和数据库表的关系的,实际上我们也可以通过 YAML 以及被我们改过的 annotation 也就是注解的方式来配置(还记得我们怎么定义控制器某个方法映射哪个路径的吗?),用 XML 只是我的一个习惯,大家也可以用别的方式实现。不过这里也有一个需要注意的地方,假如你选择使用注解的方式,因注解是直接写在实体类里面的,实际上是把 mapping 的方式和业务类绑定在一起了,这样做就已经不是 DDD 了,我这里并不是说 DDD 就一定好,或者说用注解的方式做 mapping 一定不好,而只是为了告诉大家使用注解跟使用 XML 或者 YAML 还是有开发方式上的不一样。如果一个项目,本身就选择了以数据库为中心的开发方式,那使用注解做 mapping 我认为是最方便的。

之前我们创建数据库表和字段,都是通过 SQL 语句直接完成的,但有了 Doctrine2 之后,这个过程还会更方便。为了展示 Doctrine2 这一功能,我们先将我们已经创建好的数据库删掉。我们可以直接执行 SQL 语句删除,但有了 Doctrine 我们也可以通过运行命令来删除:

bin/console doctrine:database:drop

命令结果会提示我们,如果确定要删库,需要执行命令时添加 --force 参数。

我们再次以添加 --force 参数的方式执行上面命令之后,数据库会被成功删除,然后,我们还可以通过创建库命令来重新生成我们的 minetodo 库:

bin/console doctrine:database:create

然后我们再通过创建表命令,通过 mapping 的配置来自动生成表:

bin/console doctrine:schema:create

注意我们之前讨论过的字符集也被自动设置成了 utf8mb4,那是因为 Doctrine 默认设置就是这样了,如果你不用 utf8mb4 也是可以通过在 doctrine.yaml 里设置的:

doctrine:
    dbal:
        charset: UTF8

另外还有一个需要注意的小变化,我们的 create_at 字段,以前是 timestamp 类型的,现在变成了 datetime 类型,如果我们直接修改 Task.orm.xml 里相关配置为 timestamp ,校验的时候会告诉你没有 timestamp 这个类型,所以我们只能用 datetime 来代替。Doctrine 强行使用 datetime 有好处也有坏处,坏处是 datetime 能用来表示的时间范围比 timestamp 要大得多(大家可以自行查询具体区别是什么),所以存储的空间也比 timestamp 要大,对于像『创建时间』这种字段来说,datetime 类型就是杀鸡用牛刀,多出来的时间范围是完全用不上的;而好处是什么,则可以通过 Doctrine 要解决的问题之一来说明:Doctrine 的其中一个功能,就是完全将存储层细节隐藏,即如果有一天你想从 MySQL 无感迁移到 SQLite 或者其他任何 Doctrine 支持的数据库,你只用改数据库连接配置,而不用改任何其他配置和代码,但这么做就意味着,某些只有 MySQL 支持但别的数据库不支持的功能,Doctrine 就无法提供了。在开发过程当中,其实经常都会出现这种选择,我们应当结合实际来考虑我们应该如何处理。对于我来说,除非是对数据量极其敏感的情况,一般来说放弃 timestamp 数据类型,而选择如此方便的 ORM 功能,还是很划算的。

现在 mapping 配置还有一点小问题,我们并没有在数据库层面限制一个 Task 必须得有一个 User,而不能为 null,我们先更新 Task.orm.xml

...
<doctrine-mapping ...>
    <entity name="Domain\Entity\Task">
        ...
        <many-to-one field="user" target-entity="User">
            <join-column nullable="false" />
        </many-to-one>
    </entity>
</doctrine-mapping>

这里我们也不需要删除库重建,我们使用另外一个 Doctrine 提供的命令来更新表结构即可:

bin/console doctrine:schema:update

此命令首先会提示你,不应该在生产环境执行,另外会提示你 mapping 配置的变化导致需要执行一个更新数据库的 SQL,并且可以通过添加 --force 参数更新数据库,或者 --dump-sql 来查看到底要执行什么 SQL。

我们先用 --dump-sql 参数查看一下要执行的 SQL 是什么。如果一切正常,会返回一句:

ALTER TABLE task CHANGE user_id user_id INT NOT NULL;

这正是我们想要执行的 SQL,确认好之后使用 --force 参数执行它。

在我们执行创建表,升级表字段命令的时候,Doctrine 都温馨提示了我们在生产环境最好不用使用这些命令,至于为什么不能使用这些命令,以及在生产环境应当如何升级,我们后面的章节再说。

由于我们目前还没实现登录,所以我们现在只能在数据库里自己添加一条用户记录和若干任务记录

insert into user (username, password) values ('chris', '');
insert into task (user_id, content, created_at) values (1, 'hello world', '2020-2-16 00:00:00');
...

为了测试通过 Doctrine 获取数据,修改 src/Controller/TaskController.php 的代码:

<?php

namespace App\Controller;

use Domain\Entity\Task;
...

class TaskController extends AbstractController
{
    /**
     * @Route("/", methods={"GET"})
     */
    public function index()
    {
        // $this->getDoctrine()->getRepository(Task::class) 你可以理解成返回了 Task 表
        // findByUser 则是 Task 表『通过 user 或者 userId 作为条件找符合条件的 tasks』的意思,是 findBy(['user' => 1]) 的快捷方式
        // 此方法不是真正存在的方法,PHP 可以通过 __call 魔术方法来实现让类提供一些根本不存在的方法,而且 PHP 的『魔术方法』有很多,具体可见
        // https://www.php.net/manual/en/language.oop5.magic.php
        $tasks = $this->getDoctrine()->getRepository(Task::class)->findByUser(1);

        return $this->render('task/index.html.twig', [
            'tasks' => $tasks,
        ]);
    }
}

响应的模板文件 templates/task/index.html.twig 也需要修改:

{% extends 'base.html.twig' %}

{% block body %}
    <ul>
        {% for task in tasks %}
            <li>{{ task.content }}</li>
        {% endfor %}
    </ul>
{% endblock body %}

为了保持跟原来的显示风格一致,将老项目里的 web/main.css 文件复制到新项目 public/main.css 里,并且更新 templates/base.html.twig 文件:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{% block title %}Welcome!{% endblock %}</title>
        <!-- 引入 main.css -->
        <link rel="stylesheet" type="text/css" href="/main.css">
    </head>
    <body>
        {% block body %}{% endblock %}
        {% block javascripts %}{% endblock %}
    </body>
</html>

本章知识点非常多,毕竟 ORM 这个话题本身就是很大的,如果读者不能立马完全消化也没有关系,后面还会提供更多的例子让大家感受到 ORM 的用法。

本章完整代码见这里

人人都能看懂的全栈开发教程——ORM by Chris Yue is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

微信赞赏码

如果觉得文章还不错,就请扫码鼓励一下作者吧
天使打赏人

发表评论

9 + 1 =