Inspirer

laravel 学习笔记 —— 数据和模型之数据连接层

所谓万丈高楼平地起,再强大的功能组件,也是建立在许多功能很基础的模块之上。而且越是强大、富有扩展性,则拆分越细,分层越明显。Laravel 的许多组件都是这样,数据库组件当然更能直观体现出来这种设计思路。关于分层,我在上文已经给出了一定的解释,不再赘述。本文是对数据库的连接层进行讲述,在此,希望各位确保对 PDO 已经有了一定的认识和理解。

这种基础篇大多是讲故事和一些设计思想,慢慢看吧~

无聊的增删改查

说到数据库,我们接触的绝大多数就是增删改查,无论是什么类型 Web 程序,都逃离不了对数据库的读写,就像前端,在花哨的技能也往往是用在对 DOM 的各种操作。但是就是这么说起来简单的事情,却往往是一个最容易出现问题的地方。

在没有框架以前我们大多是直接使用各种数据库的原始扩展提供的各种函数来进行操作,直接使用 SQL 语句查询。当然由于 MySQL 和 PHP 的亲密关系,MySQL 扩展是最易用的。我们常用的 MySQL 函数无非是 mysql_connectmysql_querymysql_fetch。当然,随着时代变革,“ mysql ” 打头的函数早已过时十年有余,虽然国内慢了很多很多很多,总算是认识到了这个问题。跟随脚步的,早在几年前就应当已经使用上了 MySQLi。不过,作为框架,往往考虑到的是用户群体可能会用到的各种数据库类型,而当时各种关系型数据库的扩展中函数都或多或少有区别,因此最初框架在这一层面往往还要封装一堆数据库兼容层,且非常繁杂,可能也是因为这个原因,才使得那一时间段的数据库组件的底层实现在如今看来总是有那么些别扭的原因。

当然问题远不止这么简单。我们都知道,直接通过 SQL 语句查询往往可能会产生各种注入问题,因为语句中存在随上下文环境影响的变量,而我们往往是通过语句拼接来实现的。原始的做法是对数据进行各种转义,或者从框架层面,实现一个 查询构造器 ,所有语句通过其实现最终的 SQL 语句生成,在这个构造器内实现统一入口,在那里面进行转义,减少问题发生的概率。但是这种实现着实不够优雅。不过随着各类数据库逐步支持 参数绑定SQL 语句预处理,这种问题已经从底层被解决,而且效率还有了一步提升。不过依旧没有一个更完美的方案来解决各种数据库通过一个更为便利的方式进行访问的接口(不要提 ODBC,真的不好用,限制太多)。

于是 PDO 诞生了。期初只是随着 PHP 5.0 开篇,作为一枚三方扩展出现,而由于其面向对象的设计且功能强大、接口统一,收到了极大的欢迎,而后被集成至 PHP 中随 PHP 一同发行。PDO 直接统一了大多数常用关系型数据库的访问接口,封装的非常易用,而且文档也十分清晰,没有过高的学习门槛。PDO 主要提供了常用的事务、查询操作和结果集处理,查询操作的方法使用了参数绑定和预处理(需要数据库支持)。有了 PDO 后,各大框架分分采纳作为数据库底层,也正是因为 PDO 的良好基础,才使得那些牛逼的框架作者有更多的心思去调整数据库组件的分层逻辑。Laravel 当然也是这其中的一员。

Laravel 对 PDO 做了什么?

使用过 PDO 的都知道,已经很难再做更多有趣的底层封装了,以前的框架底层封装由于工作量巨大,因此在这一部分有很多可操作性,而自从 PDO 把这些事儿都做了后,大家有更多的精力去实现好用的 ORM,但是由于被传统的思路局限,似乎数据库组件并没有什么喜人的变化。但是传统的设计思路总是需要有人打破,Ruby on Rails 的优秀设计终归是点醒了浑浑噩噩的 PHPer,Laravel 诞生了,也带了与以往完全不一样的 数据库组件 和 其最为耀眼的 Eloquent ORM

源自于对 优雅 的追求,即使是底层也应当优美。Laravel 的数据库组件从三个层面下手:

  • 简单直观易用的数据库连接方式
  • 分别对待增删改查四个操作,以保证其对上下文的影响更易把握
  • 再底层,所有操作也应当美,而不是繁琐

数据库的连接部分,对于原生的 PDO 组件,我们常常会根据不同的数据库类型,去调整一个 DSN 参数就可以做到使用不同的数据库驱动,但是创建连接的过程往往没有这么简单,比如一些初始化的设置,亦或者设置连接的字符集等等。Laravel 在 Illuminate\Database\Connectors\Connector 以及继承自该类的一些对应数据库的驱动,就是做了这样的事情,我们可以通过其源码显而易见的看出,比如 Illuminate\Database\Connectors\MySqlConnector,除了继承 Connector 类还实现了 ConnectorInterface 接口。

class MySqlConnector extends Connector implements ConnectorInterface
{
    public function connect(array $config)
    {
        $dsn = $this->getDsn($config);

        $options = $this->getOptions($config);

        $connection = $this->createConnection($dsn, $config, $options);

        if (isset($config['unix_socket'])) {
            $connection->exec("use `{$config['database']}`;");
        }

        $collation = $config['collation'];

        $charset = $config['charset'];

        $names = "set names '$charset'".
            (! is_null($collation) ? " collate '$collation'" : '');

        $connection->prepare($names)->execute();

        if (isset($config['timezone'])) {
            $connection->prepare(
                'set time_zone="'.$config['timezone'].'"'
            )->execute();
        }

        $this->setModes($connection, $config);

        return $connection;
    }

    // 省略
}

从我们节选的 Illuminate\Database\Connectors\MySqlConnector 一部分代码,是不是很容易就看出了一些我们曾经写过的代码?因此,我在看待任何一个框架时,都要时时刻刻的、清醒的意识到这本质都是 PHP 程序,只是框架作者帮你写了一些你曾反复写来写去的代码而已。

Connector 也仅仅是用于创建了一个底层的 PDO 连接实例,真真正正对其进行第一步封装的,应该是 Illuminate\Database\Connection。在这个里面,实现了我们所用到的增删改查操作的四个封装,还有事务、读写分离等功能。从源码上来看,Laravel 封装的十分细粒,无论是一个简单的 PDO 实例获取还是一个 SQL 语句的执行过程,都进行了封装,并且代码十分美观。我们以常见的 select 方法为参考,来讲述一下 Laravel 的代码,并从中学习一些设计思想:

public function select($query, $bindings = [], $useReadPdo = true)
{
    return $this->run($query, $bindings, function ($me, $query, $bindings) use ($useReadPdo) {
        if ($me->pretending()) {
            return [];
        }
        $statement = $this->getPdoForSelect($useReadPdo)->prepare($query);
        $statement->execute($me->prepareBindings($bindings));
        $fetchArgument = $me->getFetchArgument();
        return isset($fetchArgument) ?
            $statement->fetchAll($me->getFetchMode(), $fetchArgument, $me->getFetchConstructorArgument()) :
            $statement->fetchAll($me->getFetchMode());
    });
}

select 方法和 insert 、 update 、 delete 四个方法从参数上来看长得如此像,但是内部却又有着一些比较分明的区别。先说说不同点,select 操作是查询,因此是一个读取操作,因此在代码中可以明显看到 获取 “ 可读 ” 的数据库连接实例,用于实现读写分离,同理,insert 则是获取 “ 可写 ” 的数据库连接实例。查询操作必然需要返回一个查询的结果集,因此可在代码中看到相关操作。对于结果集,laravel 会根据配置,进行后续的处理。

优雅的事务的实现

经过前面的了解,估计大多数朋友都对 laravel 的设计思路有了一定的了解,随着各位项目开发的经验逐渐丰富,就会越发体会到这种分治思想的优点。有了前面的基础,再来看看 laravel 框架对于 PDO 事务处理的封装,就更加容易了。用过 PDO 的都知道,事务处理的三个核心操作:启动提交回滚,启动事务后,中间出现回滚操作,这这段时间内的操作都将还原,这种特性十分适合用在需要保障数据完整性的操作,比如订单的创建。

虽然事务的三个操作不是很多,但实际情况远没有这么简单。要知道,实际项目中的事务,层级复杂,可能事务中还会开启新的事务,层层嵌套,这时候就需要设置 save point(保存点),用于标识区间,在提交事务、回滚事务时便于区分,而这种方式,若是在代码里进行手工设置,则会非常不易于维护,因为会用到复杂事务的,往往代码逻辑也是异常复杂,而且在成堆的代码中,管理好每个异常并进行回滚,基本上可以说是一种噩梦般的体验。不过,laravel 巧妙地利用了 匿名函数(Closure) 解决了这一个难题。Laravel 的事务操作:

<?php
DB::transcation(function () use ($pdo) {
    $order = new Order();
    $order->customer = Auth::user();

    $orderItems = [];
    foreach (ShoppingCart::getItems() as $item) {
        $orderItems[] = $orderItem = OrderItem::createFromShoppingCartItem($item);
        $goods = $orderItem->goods;
        if (!$goods->salesVolumeIncertment($orderItem->total)) {
            throw new RuntimeException();
        }
    }

    $order->addItems($orderItems);
    $order->save();
    return $order;
});

DB::transcation() 接收了个 Closure,在这个匿名函数里,任意位置抛出异常都会触发回滚操作,即使异常是来自于非数据库操作。这使得我们的程序更加清晰,不再会陷入无限的 if else 这种原始的判断和无尽的 savepoint 设置。我们可以看到 transcation() 方法的代码十分简洁:

<?php
public function transaction(Closure $callback)
{
    $this->beginTransaction();

    try {
        $result = $callback($this);
        $this->commit();
    }

    catch (Exception $e) {
        $this->rollBack();
        throw $e;
    } catch (Throwable $e) {
        $this->rollBack();
        throw $e;
    }

    return $result;
}

是不是发现了这种功能实现的奥妙?有人说,这并没有什么技术含量,但是能够灵活使用 PHP 的特性并使用的很好的,真的不多见,因此 laravel 框架不仅仅只是一个功能强大的框架,她在很多设计思路上提供了更多的可能,让大家去学习一些曾经未曾看到、想到的东西。而且,如果你们仔细发现,上面那个实现事务的功能的方法,其实真没那么简单,实际上你所看到的 beginTranscation 、 commit 、 rollback 这三个方法虽然和 PDO 里的方法同名但不是直接使用 PDO,而是再次封装。有人会疑惑这种过度封装合适吗?这可不是过渡封装哦,通过观察,你们会看到我在前面提到的设置 savepoint 的实现,这使得每一处代码都显得异常简洁,易于阅读和维护。

我们并不提倡任何地方都应该封装,而是针对性的将任何独立可复用的功能拆开封装。比如这个二次封装的事务启动 beginTranscation,既然有了很好用的 transcation,直接写在这个里面接好了啊,反正这个方法在此处也调用了那三个操作。不过你们注意到没,这种拆分有利于任何人单独使用,比如在有些情景,自带的 transcation 方法有颇多限制,比如在这个事务操作区间内做更多的异常区分和记录统计,或者跨度很广时,就需要单独拆开调用事务的三大操作,但是直接调用 PDO 的事务操作,会破坏程序中可能已经调用的 transcation 构建的事务,因此调用二次封装的,则会避免这种情况。

当然还有一点,这个不止是针对数据库组件。我们在程序设计中,往往伴随着无数的测试,其中有一种测试十分重要 —— 单元测试。单元测试能够极大程度保障构成大型项目的每一个小模块、功能组件作为独立个体运作时的 “零” 故障,而要更加便于进行单元测试,首先要保证的就是每一个功能的细粒度。在上下文无误的情况,其单元测试结果的正确意味着其用于构建更加复杂的组件时,无需再做验证,而只需关注上下文的合法性。这种开发流程会使得在项目迭代中变得更加迅速且不易出现错误,也能够配合强大的自动部署工具和其他工具链实现持续集成。

总结

本文实际上更多是讲 laravel 的设计思路,不过为什么还是要讲,是因为如果大家没有心思去了解一些比较基层的东西,对于后续的学习会造成极大地瓶颈。而作为整个数据库组件的底层,其提供了很多功能的基石,在保障基础稳固后,再去基于此创建更为强大的组件时更为自信。

在下一篇我将会开始讲解查询构造器和语法生成器,请大家继续关注!谢谢!