Inspirer

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

来继续填坑(上个月没发,因此这个月至少两篇)。

上一篇 laravel 学习笔记 —— 查询构造器(下) 中我们正在开始分析查询构造器的 where 方法,作为出场率最高的方法,其中相关的玩意儿足以帮助我们去理解整个组件大致的设计思路和实现细节。由于整体分析实在是太过麻烦(我其实就是懒),因此我们在上一篇的末尾提了三个问题,本文也将顺着这三个问题来进行解析,当然其余的思路类似的,就不在赘述,毕竟精力有限。

上一篇提到的问题是:

  • where 方法中多余的参数是干什么的
  • 方法中的代码为什么要拆分(或者封装)的那么细 ?
  • Expression 这个类是干嘛的 ?

我们就从多余的参数是做什么的开始吧。

开始

where 方法所包含的参数有 4 个(Laravel 5.1,这几篇文章都是基于这个和 5.2 两个版本讲述),分别为: $column, $operator, $value, $boolean

顾名思义,第一个参数必然是指的字段名称,第二个参数则是操作符,第三个是值,第四个指的是布尔逻辑。除了第一个参数,其余皆包含默认值意味着可选填。从参数名和你们所阅读 Laravel 文档中所知晓的,where 的用法基本已经了解的差不多了,但是第四个参数对于各位应该没在实际项目中使用过,实际上如果你们愿意去翻一翻源码,比如 orWhere,你就会发现这些方法本质还是调用的 where,只是这参数 4 变了,至于为什么封装那么细,这就是第二个问题,我们后文会一并讲。

为了方便分析,我还是把 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;
}

源码简析

执行过程的开始

代码中,首先开始就是判断参数 1 是否为数组(有的人可能以其他版本作参考,有差异很正常,但原理类似,这里是 5.2 的写法),若为数组则类似于 TP 框架的处理方案,将数组的 key 作为字段, value 作为值,操作符为 = 的 WHERE 语句,布尔逻辑为 AND,例如 $query->where(['a' => 100, 'b' => 200]); 转换的结果即为 WHERE a = 100 AND b = 200

若参数 1 不是数组,且参数只填写了两个,那么操作符默认为 =,并将参数 2 的值用于实际的参数 3。这种使用形式主要用于快速建立 WHERE 中的等值判断。

我们继续阅读代码,可以看到判断参数 1 是否为 Closure 的代码,若满足条件则会调用一个叫做 whereNested 的方法,这个方法的作用十分重要,实现了在上一篇中我们提到的 Laravel 查询构造器优雅特性中的关键部分,即可以在保证生成复杂层级关系的 SQL 语句的前提下,PHP 部分的代码调用非常直观,通俗点说就是能让语句中 带括号 ,虽然不是什么不得了的功能,但是其他框架或类似模块或多或少调用不直观,甚至对于这种功能还是逃不了原生写法,在这样的对比下,就显得难能可贵。

在这里需要强调一点:并不是所有情况都建议和鼓励使用 PHP 的方式和写法去替代 SQL 原生语句,考虑到性能以及其他业务情形,在没有框架或框架性能较低的情况,完全没必要用复杂的东西去做,怎么快怎么好。至于在本文中对这些特性的讲述,更多是帮助 学习 PHP 的朋友,了解一些思想,包括很多 PHP 的新特性和功能函数是如何被巧妙运用并解决问题的,这才是这一系列文章的作用。

关于 whereNested 的实现原理,实际和 where 方法的实现原理类似,只是有逻辑变动,设计思想是一样的,因此继续分析 where 的实现。

为什么拆分那么细

随着代码往后阅读,我们可以看到更多的对于值类型的判断以及在种种条件下,将处理的东西交由另一个对应的方法处理。在这里就讲讲之前的问题,拆分这么细的意义。

我们封装以及再封装(通俗所说的拆分)代码的目的有很多,每个人的出发点或多或少都有着区别,但目的不是便于维护(或复用)就是转换调用。转换调用的典型就是我们在 where 方法的参数 4,一般是不会用的,我们常常在 where 或 orWhere 中切换,而 orWhere 就是 where 的参数 4 的值为 or 而已,但是很显然的 where(x, x, x, 'or') 和 orWhere(x, x, x) 哪个更直观一眼便知,转换调用还往往用于创建方法以及函数的别名,对于一些项目重构和调整 API 规则时常常用到。

更多的情况,封装是为了便于维护,毕竟重复代码不会总是去每个地方都去改。这时候就存在一个问题,由于这种目的本身就是一个没有标准的,因此封装质量参差不齐,出现注入所谓的一切都要封装的过度现象(最终导致和没有封装毫无差别,甚至更糟)。

任何程序都是实现一定功能的。那么我们看到的,Laravel 在做封装的时候,往往会将一类特定功能的局部代码进行封装,而且这些封装出来的方法和函数, 往往只会做一件事 !这样的好处就使得在思路上不存在混乱的可能,而且由于只做一件事,给方法命名就按其所做工作命名即可,一眼就知道这行代码是做什么的,那么便于维护的目的才真的达到了。

调用转换为 SQL 的开始

我们在阅读到代码最后,看到了这样一行 $this->wheres[] = compact('type', 'column', 'operator', 'value', 'boolean');

如果你读的代码足够多,你会发现所有框架或类似模块中实现查询构造器时,都会有类似的部分,很显然,我们目的是使用构造器生成 SQL 语句,那么必然要在某个地方记录我们在程序中的参数作为生成 SQL 语句的依据。

这没什么神秘的。

Laravel 实现的方式需要保证功能够强,够直观,因此在这里记录的东西也显得非常丰富。注意到一个函数没有,就是 compact,估计读本文的大多数读者一般都未曾接触过,建议各位去文档上查一下,在这里就不多讲。我们来看看在此处记录的东西分别是什么。

首先是 type,type 记录的是该条 where 语句的类型,如果我们调用的是 where 方法,则该值为 "Basic",当然,我们前面还提到了 whereNested,如果调用了这个,则 type 为 "Netsed"。

不同的 type 则记录的东西也有很大不同,对于 "Basic",则还需要记录 column (字段)、operator(操作符)、value(值)、boolean(布尔逻辑)。而另外的比如 Nested 则会记录 query(子查询,又是一个 Builder 对象哦)、boolean。

当记录完这一切,我们看到了另一个方法 addBinding,向这个方法传递了 where 语句中的 value 值,这一步是做什么的呢?我们知道,对于 PDO,SQL 语句是可以预编译的,这样也很容易防御来自 SQL 语句注入的攻击,毕竟预编译将语句和语句中的变量彻底的分离,变量就是变量,不参与语句执行,这样安全系数大大提高。以上,我们可见到的再使用 PDO 时,我们会先写类似这样的 SQL 语句:SELECT * FROM table WHERE a = ? AND b = ?,问号是一个表示该处为变量的位置,在 SQL 执行时传入,即所谓的参数绑定。

回到先前的问题,我们已经可以回答,addBinding 顾名思义就是添加一个参数的绑定,因为最终构造器以及我们后一篇文章中会讲述的(语法)生成器所产生的目标 SQL 语句,就是一个这样的 SQL 语句,在执行时才会传入参数。而 问号 的位置必然需要和所包含的值顺序对应,因此在添加 where 语句参数至 $query->wheres 数组时,同时也需要对应一个绑定参数以此对应。

额外细节

我们分析了 where 这个比较具有代表性的查询构造器的方法后,其实可以看出查询构造器的目的就是提供一系列直观的方法调用,这些方法的参数就是我们用于查询语句的参数,但是却不需要我们考虑诸如字段名加 `、表名称的前缀以及子查询、JOIN 语句的别名或者一些杂七杂八足以使你无限纠结的 SQL 语句细节。查询构造器在提供了这些方法的同时,还要保证调用足够直观和优雅。但最终在实际逻辑中,就是将参数收集并按需要排列组合成一定形式,最后交由(语法)生成器生成目标 SQL 语句。

不过这里有个细节,就是如何在这种形式下,依旧能够使用原生语句?开头的第三个问题,Expression 类是干什么的?

上面那一大段,如果各位仔细读,就会发现我提到了字段和表名称。在查询构造器中,有个方法:select 和 table。对于 select 我们常规就传递字段名就好,但对于特别的情形下,比如我们需要这样 SELECT count(x) as c FROM table,我们很显然不可以 $query->select('count(x) as c'),因为在(语法)生成器阶段,会将参数中属于字段、表名称做一些处理,例如在查询语句中存在调用了多个表的情形下,字段 x 可能会变成 table.x,意味着 count(x) as c 会变成 table.count(x) as c,当然实际情况不一定如此,但必定会发生类似情况,因为框架不可能将判断细化到如此到可以区分格式合不合理,那样性能开销会更加大。那么如何避免大面积原生语句的情况下在局部使用原生呢?

Expression 类就是这个用处。在构造这个类时,我们可以将局部的原生语句作为参数传入,并将其作为如 select、table、where 等一系列查询构造器方法的值,在最终由(语法)生成器生成目标 SQL 语句时,会判断是一个普通值还是一个 Expression 类,若为后者,则不再做转换处理。

instance of 这个语句用处十分广泛。

后面的内容

查询构造器不在多讲,更多内容可举一反三,因为落到实际原理,真的不复杂,未讲到的部分不代表不重要,因为查询构造器的细节十分多,但是只要愿意去看源码,很多东西很容易理解,这得益于 Laravel 的代码无论从命名还是逻辑结构,都看起来赏心悦目,所以,不要看到此就结束探索,继续看代码吧。

下一篇预告:查询构造器是生成语句的开始,真正生成语句是(语法)生成器,生成器和构造器都是一个在简单原理上,运用巧妙地办法实现复杂功能的,(语法)生成器最大的特点就是如何适应各种数据库驱动下的目标语句生成,这关系到一些架构设计的思路,我们下篇慢慢讲。