Inspirer

laravel 学习笔记 —— 查询构造器(上)

当下所有包含数据库组件的框架,都提供了一套流畅的操作方式去生成查询语句,这一部分我们称作 查询构造器 。查询构造器的存在,使得在数据库操作这一层面彻底的和原生开发区分开来。原生开发中,查询语句都是人为地根据业务需求手工写的,没有对查询语句进行规范的封装(即使有也不是人人能够做得到且做得好),随着项目扩展,这种原生的写法会导致项目愈发难以维护,而且由于操作原始,更加容易引发很多不必要的 BUG 以及安全隐患。查询构造器针对每一类语句关键字进行封装,通过流畅的链式方法组合,形成一个树状的数据结构,最终由生成器生成目标查询语句。

我向来认为 Laravel 框架的组件永远不是最优秀的,但由于其他框架或多或少不优雅、实现差、功能残缺,才使得 Laravel 的每个组件无论是单个拿出来看还是组合起来看,都是那么的好用,这其中的典型就是 查询构造器

大多数优秀框架提供的查询构造器 功能 都已经十分完善,基本上在绝大多数业务上可以替换手工书写查询语句,但是对于复杂的查询语句,很多框架的查询构造器提供的方法要么是根本无法实现(比如多种条件和条件之间的逻辑运算、嵌套查询或子查询),要么是实现的方式不够优雅和直观,使得在对于复杂的查询条件的情况下,不得不回归原生的 SQL 语句查询。虽然一部分人认为原生的查询语句才是最直观且合适的,亦或者认为复杂的查询语句就应当使用原生,但是我们不应该孤立在某一类项目或者一类人群下看待这个问题。要知道,对于大型项目,合理的封装和可维护性,开发的便捷和效率这些因素往往十分重要,查询构造器的诞生本来就是因为这个原因。

Laravel 给出了一个令人满意的实现方法,使得之前那些框架的查询构造器黯然失色(当然,不包括 Doctrine)。

清晰地结构

若是不看文档和任何介绍,你能够得出下面查询构造器最终生成的 SQL 语句或者看得出这段代码的意图吗:

<?php
$collection = DB::table('articles')
                     ->select(['title', 'name', 'author', 'created_at', 'published_at'])
                     ->where('name', 'like', "%$name%")
                     ->where(function ($query) {
                        $query->where('top', '>', 5)
                              ->orWhere('status', '=', 1);
                     })
                     ->orderBy('updated_at', 'desc')
                     ->take(5)
                     ->get()

相信很多人都非常容易看得出这个代码的含义,上述代码仅仅只是个例子,若移步至官方文档和查询构造器的 API 文档,还有更多的方法可以使用。

Laravel 的查询构造器基本涵盖了日常所用到的所有语句的可能,也提供了很多有用的封装,用于细粒的操作。这些大多在文档里体现,本文不作重点讲述。

复杂逻辑下的思维

我不是文档的搬运工,再详尽的开发教程也永远教不会那些只会复制粘贴、乱问问题的人,作为一枚不断学习的 PHPer,只会用永远不够,任何一个易用的框架和组件,思想永远是首要学习的东西。但愿认真看完以下章节和后续文章的 PHPer 不再会问出一些愚蠢的问题(当然,没能一下子懂并不是愚蠢,也可能是……你和我的思路不一致,稍微转换思维或许会发现新的有趣的世界)。

我第一次使用查询构造器是在运用 ThinkPHP 3.1 开发项目时用到的,当时笔者水平很烂,为了加强学习,我忍着枯燥无味,将 TP 的数据库组件的源代码一行一行的吃了个透。虽然现在来看 TP 的实现并不是很优秀,但是依旧给了我无限的帮助,使得我阅读源码的能力提升了一个很大的台阶。

回过头来,有了之前的经验,我依旧选择阅读源码的方式来学习框架,而不仅仅只依靠文档,因为我更想知道作者的思路和每一个功能实现的方式,以及为什么会这么用。

我对比了很多个数据库组件的实现,包括著名的 Doctrine,在很多地方都有大同小异,当然 Laravel 的也不例外,不过一旦考究起细节,才不得不赞叹,越是国际化的认可度高的,确实有它值得认可的道理。

查询构造器都会提供一种 Chain(链式) 访问的方法,这样使得开发者可以更为直观的对应目标查询语句,因此方法名往往和 SQL 语句差距不大(NoSQL 驱动的存在特殊性在此不做讨论),但是查询构造器本身的目的是为了生成查询语句,因此如何组织这些方法的参数,保证在易于理解和记忆的情况下,又能有着无比强大的功能,便成了考究设计者水平的东西。

在一些早期框架,查询构造器一般都会提供 whereorder bygroup by 等很直观的的语句对应的方法,尤其是 order by 这类参数结构单一的,十分容易设计,比如第一个参数是排序的字段,后者则是排序方式,亦或者参数是一个数组,数组下有很多项,这样就可以生成多重排序。

不过一旦到了 where 这种结构复杂的情况,传统的思路就容易吃瘪,比如多个条件如何体现?传统的代码如下

$query->where(['a' => 'foo', 'b' => 'bar']);

这种语句的 where 最终是这样 WHERE a = 'foo' AND b = 'bar',但是如果我们要这样 WHERE a = 'foo' OR b = 'bar' 或者 WHERE a > 100 AND b = 'foo' 上面的那种通过数组来组织的方式,就很难实现。

查询构造器想要实现上面例子的这个 where 方法,实际上只需要循环参数提供的数组,记录到查询构造器对象的一个私有属性里,在最终生成输出 SQL 语句的阶段,进行拼接即可。可以自己尝试着实现一个简单的查询构造器。

通过数组组织等式,很容易,但数组结构本身存在局限,而且一旦结构复杂,可读性便会极大地降低,框架的本质是提供一种方便的机制来提升开发效率降低错误概率,这样很显然背道而驰。有的框架提出了另外一种方案,在不破坏整体的链式访问的结构下,在局部使用原生语句来实现复杂的查询:

$query->where("a > 100 OR b = 'foo'");

这种思路是一个不错的解决办法,但是就像我之前提到的,这种方式虽然比直接全盘原生语句好了些,但不可避免这种写法无法过滤,一旦用于查询判断的条件式中有外部变量,就很容易出现注入问题,或许你可以说利用 PDO,以以下方式实现:

$query->where("a > ? OR b = ?", [$parameter1, $parameter2]);

但是如果你是框架或组件的作者,你一定会苦恼于另外一件事:这个方法如何适用于上述各种情况(比如简单条件下的、复杂条件下的等等)?有的人想到了判断参数类型来实现,比如参数 0 是数组时,就用常规的办法(上面的 blockquote 有讲),若参数 0 是文本,就用原生的方式集成。

TP 就是这么一个思路:

protected function parseWhere($where)
{
    $whereStr = '';
    if (is_string($where)) {
        // 直接使用字符串条件
        $whereStr = $where;
    } else {
        // 使用数组表达式
        $operate = isset($where['_logic']) ? strtoupper($where['_logic']) : '';
        if (in_array($operate, array('AND', 'OR', 'XOR'))) {
            // 定义逻辑运算规则 例如 OR XOR AND NOT
            $operate = ' ' . $operate . ' ';
            unset($where['_logic']);
        } else {
            // 默认进行 AND 运算
            $operate = ' AND ';
        }
    // 更多请参考 https://github.com/top-think/thinkphp/blob/master/ThinkPHP/Library/Think/Db/Driver.class.php#L562

好了,重点来了,我说过这种方式不是不好,而是还可以更好,因为复合条件存在的可能非常多,基本是常态了,因此我认为常态的形式,就应当进行 简化 和摆脱原生写法的困扰,这样才更为合理。Laravel 封装的这一系列方法,更为 优雅,相信各位能够感觉得到,因为你无须过多参阅文档,都能领会每个调用的含义,直观、简洁。更重要的,是在做到直观简洁的同时,兼具了强大的功能,使得其在复杂的开发情境下依旧保持原有的风格。

Laravel 的 where 方法的参数很多,实际用到的只有前三个参数,WHERE a = x,这个表达式中 “a” 是参数 0,“=” 是参数 1,“b” 是参数 2。在参数 1 为 “=” 时,可以省略,而后直接将参数 2 挪至参数 1 的位置。无论如何,都很直观。

复杂的条件逻辑的实现也很简单,Laravel 还提供了另外几种 where 方法:orWhere(or)whereIn(or)whereNotNull(or)whereNull(or)whereBetween。看过一次的人基本无需翻阅文档两次。更为重要的,Laravel 可以很轻松地实现 WHERE (a = x AND b =y) OR c = z 这种形式,并且依旧优雅和富有表现力,不再赘述,前文已经体现过了 Laravel 利用匿名函数实现这种括号包裹和子查询的功能。

我们来看看 Laravel 是如何书写它的 where 代码的:

public function where($column, $operator = null, $value = null, $boolean = 'and')
{
    if (is_array($column)) {
        return $this->addArrayOfWheres($column, $boolean);
    }

    if (func_num_args() == 2) {
        list($value, $operator) = [$operator, '='];
    } elseif ($this->invalidOperatorAndValue($operator, $value)) {
        throw new InvalidArgumentException('Illegal operator and value combination.');
    }

    if ($column instanceof Closure) {
        return $this->whereNested($column, $boolean);
    }

    if (! in_array(strtolower($operator), $this->operators, true) &&
        ! in_array(strtolower($operator), $this->grammar->getOperators(), true)) {
        list($value, $operator) = [$operator, '='];
    }

    if ($value instanceof Closure) {
        return $this->whereSub($column, $operator, $value, $boolean);
    }

    if (is_null($value)) {
        return $this->whereNull($column, $boolean, $operator != '=');
    }

    $type = 'Basic';
    if (Str::contains($column, '->') && is_bool($value)) {
        $value = new Expression($value ? 'true' : 'false');
    }
    $this->wheres[] = compact('type', 'column', 'operator', 'value', 'boolean');
    if (! $value instanceof Expression) {
        $this->addBinding($value, 'where');
    }
    return $this;
}

上述代码中有着对另外部分方法的调用,但并不影响我们看出一些端倪。不过 Laravel 的东西拆的太细,如果要将其调用的方法全部展示篇幅会很大,我将会着重分析几个点。

我们注意到这个 where 方法其中有些和其他框架组件不太一致的设计:

  • 多余的参数是干什么的?
  • 为什么要拆分的那么细?
  • Expression 这个类是干嘛的?

这也是这部分的重点。关于这部分,下一篇再讲。

关于查询构造器,我们后几篇文章中除了分析这个 where 的代码,还有包括一些承接性质的方法比如 addBinding 这些不是很起眼的但却非常重要的方法,当然也会引入语法生成器的介绍,谢谢各位的关注!