Laravel核心程式碼學習 -- Database QueryBuilder

kevinyan發表於2018-05-30

Database 查詢構建器

前面的文章我們介紹了Laravel Database的基礎元件,這篇文章詳細地介紹一下Database裡非常重要的基礎元件查詢構建器 文章裡我就直接用QueryBuilder來指代查詢構建器了。

上文我們說到執行DB::table('users')->get()是由Connection物件執行table方法返回了一個QueryBuilder物件,QueryBuilder提供了一個方便的介面來建立及執行資料庫查詢語句,開發者在開發時使用QueryBuilder不需要寫一行SQL語句就能運算元據庫了,使得書寫的程式碼更加的物件導向,更加的優雅。

class MySqlConnection extends Connection
{
     ......
}

class Connection implements ConnectionInterface
{
    public function __construct($pdo, $database = '', $tablePrefix = '', array $config = [])
    {
        $this->pdo = $pdo;

        $this->database = $database;

        $this->tablePrefix = $tablePrefix;

        $this->config = $config;

        $this->useDefaultQueryGrammar();

        $this->useDefaultPostProcessor();
    }
    ......   
    public function table($table)
    {
        return $this->query()->from($table);
    }
    ......
    
    public function query()
    {
        return new QueryBuilder(
            $this, $this->getQueryGrammar(), $this->getPostProcessor()
        );
    }
    ......
    public function useDefaultQueryGrammar()
    {
        $this->queryGrammar = $this->getDefaultQueryGrammar();
    }
    
    protected function getDefaultQueryGrammar()
    {
        return new QueryGrammar;
    }
    
    public function useDefaultPostProcessor()
    {
        $this->postProcessor = $this->getDefaultPostProcessor();
    }
    
    protected function getDefaultPostProcessor()
    {
        return new Processor;
    }
    
    
}
複製程式碼

通過上面的程式碼段可以看到Connection類的構造方法裡出了注入了Connector資料庫聯結器(就是引數裡的$pdo),還載入了兩個重要的元件Illuminate\Database\Query\Grammars\Grammar: SQL語法編譯器Illuminate\Database\Query\Processors\Processor: SQL結果處理器。 我們看一下Connection的table方法,它返回了一個QueryBuilder例項, 其在例項化的時候Connection例項、Grammer例項和Processor例項會被作為引數傳人QueryBuilder的構造方法中。

接下我們到QueryBuilder類檔案\Illuminate\Database\Query\Builder.php裡看看它裡面的原始碼

namespace Illuminate\Database\Query;

class Builder
{
    public function __construct(ConnectionInterface $connection,
                                Grammar $grammar = null,
                                Processor $processor = null)
    {
        $this->connection = $connection;
        $this->grammar = $grammar ?: $connection->getQueryGrammar();
        $this->processor = $processor ?: $connection->getPostProcessor();
    }
    
    //設定query目標的table並返回builder例項自身
    public function from($table)
    {
        $this->from = $table;

        return $this;
    }
    
}
複製程式碼

QueryBuilder構建SQL引數

下面再來看看where方法裡都執行裡什麼, 為了方便閱讀我們假定執行條件where('name', '=', 'James')

//class \Illuminate\Database\Query\Builder
public function where($column, $operator = null, $value = null, $boolean = 'and')
{
    //where的引數可以是一維陣列或者二維陣列
    //應用一個條件一維陣列['name' => 'James']
    //應用多個條件用二維陣列[['name' => 'James'], ['age' => '28']]
    if (is_array($column)) {
        return $this->addArrayOfWheres($column, $boolean);
    }

    //當這樣使用where('name', 'James')時,會在這裡把$operator賦值為"="
    list($value, $operator) = $this->prepareValueAndOperator(
        $value, $operator, func_num_args() == 2 // func_num_args()為3,3個引數
    );

    // where()也可以傳閉包作為引數
    if ($column instanceof Closure) {
        return $this->whereNested($column, $boolean);
    }

    // 如果$operator不合法會預設使用者是想省略"="操作符,然後把原來的$operator賦值給$value
    if ($this->invalidOperator($operator)) {
        list($value, $operator) = [$operator, '='];
    }

    // $value是閉包時,會生成子查詢
    if ($value instanceof Closure) {
        return $this->whereSub($column, $operator, $value, $boolean);
    }

    // where('name')相當於'name' = null作為過濾條件
    if (is_null($value)) {
        return $this->whereNull($column, $boolean, $operator != '=');
    }

    // $column沒有包含'->'字元
    if (Str::contains($column, '->') && is_bool($value)) {
        $value = new Expression($value ? 'true' : 'false');
    }
    $type = 'Basic';                

    //每次呼叫where、whereIn、orWhere等方法時都會把column operator和value以及對應的type組成一個陣列append到$wheres屬性中去
    //['type' => 'basic', 'column' => 'name', 'operator' => '=', 'value' => 'James', 'boolean' => 'and']
    $this->wheres[] = compact('type', 'column', 'operator', 'value', 'boolean');

    if (! $value instanceof Expression) {
        // 這裡是把$value新增到where的繫結值中
        $this->addBinding($value, 'where');
    }

    return $this;
}

protected function addArrayOfWheres($column, $boolean, $method = 'where')
{
    return $this->whereNested(function ($query) use ($column, $method, $boolean) {
        foreach ($column as $key => $value) {
            //上面where方法的$column引數為二維陣列時這裡會去遞迴呼叫where方法
            if (is_numeric($key) && is_array($value)) {
                $query->{$method}(...array_values($value));
            } else {
                $query->$method($key, '=', $value, $boolean);
            }
        }
    }, $boolean);
}

public function whereNested(Closure $callback, $boolean = 'and')
{
    call_user_func($callback, $query = $this->forNestedWhere());

    return $this->addNestedWhereQuery($query, $boolean);
}

//新增執行query時要繫結到query裡的值
public function addBinding($value, $type = 'where')
{
    if (! array_key_exists($type, $this->bindings)) {
        throw new InvalidArgumentException("Invalid binding type: {$type}.");
    }

    if (is_array($value)) {
        $this->bindings[$type] = array_values(array_merge($this->bindings[$type], $value));
    } else {
        $this->bindings[$type][] = $value;
    }

    return $this;
}
複製程式碼

所以上面DB::table('users')->where('name', '=', 'James')執行後QueryBuilder物件裡的幾個屬性分別有了一下變化:

public $from = 'users';

public $wheres = [
	   ['type' => 'basic', 'column' => 'name', 'operator' => '=', 'value' => 'James', 'boolean' => 'and']
]

public $bindings = [
    'select' => [],
    'join'   => [],
    'where'  => ['James'],
    'having' => [],
    'order'  => [],
    'union'  => [],
];
複製程式碼

通過bindings屬性裡陣列的key大家應該都能猜到如果執行select、orderBy等方法,那麼這些方法就會把要繫結的值分別append到select和order這些陣列裡了,這些程式碼我就不貼在這裡了,大家看原始碼的時候可以自己去看一下,下面我們主要來看一下get方法裡都做了什麼。

//class \Illuminate\Database\Query\Builder
public function get($columns = ['*'])
{
    $original = $this->columns;

    if (is_null($original)) {
        $this->columns = $columns;
    }

    $results = $this->processor->processSelect($this, $this->runSelect());

    $this->columns = $original;

    return collect($results);
}

protected function runSelect()
{
    return $this->connection->select(
        $this->toSql(), $this->getBindings(), ! $this->useWritePdo
    );
}

public function toSql()
{
    return $this->grammar->compileSelect($this);
}

//將bindings屬性的值轉換為一維陣列
public function getBindings()
{
    return Arr::flatten($this->bindings);
}
複製程式碼

在執行get方法後,QueryBuilder首先會利用grammar例項編譯SQL語句並執行,然後利用Processor例項處理結果集,最後返回經過處理後的結果集。 我們接下來看下這兩個流程。

Grammar將構建的SQL引數編譯成SQL語句

我們接著從toSql()方法開始接著往下看Grammar類

public function toSql()
{
    return $this->grammar->compileSelect($this);
}

/**
 * 將Select查詢編譯成SQL語句
 * @param  \Illuminate\Database\Query\Builder  $query
 * @return string
 */
public function compileSelect(Builder $query)
{
	
    $original = $query->columns;
    //如果沒有QueryBuilder裡沒制定查詢欄位,那麼預設將*設定到查詢欄位的位置
    if (is_null($query->columns)) {
        $query->columns = ['*'];
    }
    //遍歷查詢的每一部份,如果存在就執行對應的編譯器來編譯出那部份的SQL語句
    $sql = trim($this->concatenate(
        $this->compileComponents($query))
    );

    $query->columns = $original;

    return $sql;
}

/**
 * 編譯Select查詢語句的各個部分
 * @param  \Illuminate\Database\Query\Builder  $query
 * @return array
 */
protected function compileComponents(Builder $query)
{
    $sql = [];

    foreach ($this->selectComponents as $component) {
        //遍歷查詢的每一部份,如果存在就執行對應的編譯器來編譯出那部份的SQL語句
        if (! is_null($query->$component)) {
            $method = 'compile'.ucfirst($component);

            $sql[$component] = $this->$method($query, $query->$component);
        }
    }

    return $sql;
}

/**
 * 構成SELECT語句的各個部分
 * @var array
 */
protected $selectComponents = [
    'aggregate',
    'columns',
    'from',
    'joins',
    'wheres',
    'groups',
    'havings',
    'orders',
    'limit',
    'offset',
    'unions',
    'lock',
];
複製程式碼

在Grammar中,將SELECT語句分成來很多單獨的部分放在了$selectComponents屬性裡,執行compileSelect時程式會檢查QueryBuilder設定了$selectComponents裡的哪些屬性,然後執行已設定屬性的編譯器編譯出每一部分的SQL來。 還是用我們之前的例子DB::table('users')->where('name', 'James')->get(),在這個例子中QueryBuilder分別設定了cloums(預設*)、fromwheres屬性,那麼我們見先來看看這三個屬性的編譯器:

/**
 * 編譯Select * 部分的SQL
 * @param  \Illuminate\Database\Query\Builder  $query
 * @param  array  $columns
 * @return string|null
 */
protected function compileColumns(Builder $query, $columns)
{
    // 如果SQL中有聚合,那麼SELECT部分的編譯教給aggregate部分的編譯器去處理
    if (! is_null($query->aggregate)) {
        return;
    }

    $select = $query->distinct ? 'select distinct ' : 'select ';

    return $select.$this->columnize($columns);
}

//將QueryBuilder $columns欄位陣列轉換為字串
public function columnize(array $columns)
{	
	//為每個欄位呼叫Grammar的wrap方法
    return implode(', ', array_map([$this, 'wrap'], $columns));
}
複製程式碼

compileColumns執行完後compileComponents裡的變數$sql的值會變成['columns' => 'select * '] 接下來看看fromwheres部分

protected function compileFrom(Builder $query, $table)
{
    return 'from '.$this->wrapTable($table);
}

/**
 * Compile the "where" portions of the query.
 *
 * @param  \Illuminate\Database\Query\Builder  $query
 * @return string
 */
protected function compileWheres(Builder $query)
{
    if (is_null($query->wheres)) {
        return '';
    }
	//每一種where查詢都有它自己的編譯器函式來建立SQL語句,這幫助保持裡程式碼的整潔和可維護性
    if (count($sql = $this->compileWheresToArray($query)) > 0) {
        return $this->concatenateWhereClauses($query, $sql);
    }

    return '';
}

protected function compileWheresToArray($query)
{
    return collect($query->wheres)->map(function ($where) use ($query) {
    	//對於我們的例子來說是 'and ' . $this->whereBasic($query, $where)  
        return $where['boolean'].' '.$this->{"where{$where['type']}"}($query, $where);
    })->all();
}
複製程式碼

每一種where查詢(orWhere, WhereIn......)都有它自己的編譯器函式來建立SQL語句,這幫助保持裡程式碼的整潔和可維護性. 上面我們說過在執行DB::table('users')->where('name', 'James')->get()時$wheres屬性裡的值是:

public $wheres = [
	   ['type' => 'basic', 'column' => 'name', 'operator' => '=', 'value' => 'James', 'boolean' => 'and']
]
複製程式碼

在compileWheresToArray方法裡會用$wheres中的每個陣列元素去回撥執行閉包,在閉包裡:

$where = ['type' => 'basic', 'column' => 'name', 'operator' => '=', 'value' => 'James', 'boolean' => 'and']
複製程式碼

然後根據type值把$where和QeueryBuilder作為引數去呼叫了Grammar的whereBasic方法:

protected function whereBasic(Builder $query, $where)
{
    $value = $this->parameter($where['value']);

    return $this->wrap($where['column']).' '.$where['operator'].' '.$value;
}

public function parameter($value)
{
    return $this->isExpression($value) ? $this->getValue($value) : '?';
}
複製程式碼

whereBasic的返回為字串'where name = ?', compileWheresToArray方法的返回值為:

['and where name = ?']
複製程式碼

然後通過concatenateWhereClauses方法將compileWheresToArray返回的陣列拼接成where語句'where name = ?'

protected function concatenateWhereClauses($query, $sql)
{
    $conjunction = $query instanceof JoinClause ? 'on' : 'where';
	//removeLeadingBoolean 會去掉SQL裡首個where條件前面的邏輯運算子(and 或者 or)
    return $conjunction.' '.$this->removeLeadingBoolean(implode(' ', $sql));
}
複製程式碼

所以編譯完fromwheres部分後compileComponents方法裡返回的$sql的值會變成

['columns' => 'select * ', 'from' => 'users', 'wheres' => 'where name = ?']
複製程式碼

然後在compileSelect方法裡將這個由查查詢語句裡每部份組成的陣列轉換成真正的SQL語句:

protected function concatenate($segments)
{
    return implode(' ', array_filter($segments, function ($value) {
        return (string) $value !== '';
    }));
}
複製程式碼

得到'select * from uses where name = ?'. toSql執行完了流程再回到QueryBuilder的runSelect裡:

protected function runSelect()
{
    return $this->connection->select(
        $this->toSql(), $this->getBindings(), ! $this->useWritePdo
    );
}
複製程式碼

Connection執行SQL語句

$this->getBindings()會獲取要繫結到SQL語句裡的值, 然後通過Connection例項的select方法去執行這條最終的SQL

public function select($query, $bindings = [], $useReadPdo = true)
{
    return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
        if ($this->pretending()) {
            return [];
        }

        $statement = $this->prepared($this->getPdoForSelect($useReadPdo)
                          ->prepare($query));

        $this->bindValues($statement, $this->prepareBindings($bindings));

        $statement->execute();

        return $statement->fetchAll();
    });
}

protected function run($query, $bindings, Closure $callback)
{
    $this->reconnectIfMissingConnection();

    $start = microtime(true);

    try {
        $result = $this->runQueryCallback($query, $bindings, $callback);
    } catch (QueryException $e) {
    	//捕獲到QueryException試著重連資料庫再執行一次SQL
        $result = $this->handleQueryException(
            $e, $query, $bindings, $callback
        );
    }
	//記錄SQL執行的細節
    $this->logQuery(
        $query, $bindings, $this->getElapsedTime($start)
    );

    return $result;
}

protected function runQueryCallback($query, $bindings, Closure $callback)
{
    try {
        $result = $callback($query, $bindings);
    }

    //如果執行錯誤丟擲QueryException異常, 異常會包含SQL和繫結資訊
    catch (Exception $e) {
        throw new QueryException(
            $query, $this->prepareBindings($bindings), $e
        );
    }

    return $result;
}
複製程式碼

在Connection的select方法裡會把sql語句和繫結值傳入一個閉包並執行這個閉包:

function ($query, $bindings) use ($useReadPdo) {
        if ($this->pretending()) {
            return [];
        }

        $statement = $this->prepared($this->getPdoForSelect($useReadPdo)
                          ->prepare($query));

        $this->bindValues($statement, $this->prepareBindings($bindings));

        $statement->execute();

        return $statement->fetchAll();
});
複製程式碼

直到getPdoForSelect這個階段Laravel才會連線上Mysql資料庫:

protected function getPdoForSelect($useReadPdo = true)
{
    return $useReadPdo ? $this->getReadPdo() : $this->getPdo();
}

public function getPdo()
{
    //如果還沒有連線資料庫,先呼叫閉包連線上資料庫
    if ($this->pdo instanceof Closure) {
        return $this->pdo = call_user_func($this->pdo);
    }

    return $this->pdo;
}
複製程式碼

我們在上一篇文章裡講過構造方法裡$this->pdo = $pdo;這個$pdo引數是一個包裝裡Connector的閉包:

function () use ($config) {
    return $this->createConnector($config)->connect($config);
};
複製程式碼

所以在getPdo階段才會執行這個閉包根據資料庫配置建立聯結器來連線上資料庫並返回PDO例項。接下來的prepare、bindValues以及最後的execute和fetchAll返回結果集實際上都是通過PHP原生的PDO和PDOStatement例項來完成的。

通過梳理流程我們知道:

  1. Laravel是在第一次執行SQL前去連線資料庫的,之所以$pdo一開始是一個閉包因為閉包會儲存建立閉包時的上下文裡傳遞給閉包的變數,這樣就能延遲載入,在用到連線資料庫的時候再去執行這個閉包連上資料庫。

  2. 在程式中判斷SQL是否執行成功最準確的方法是通過捕獲QueryException異常

Processor後置處理結果集

processor是用來對SQL執行結果進行後置處理的,預設的processor的processSelect方法只是簡單的返回了結果集:

public function processSelect(Builder $query, $results)
{
    return $results;
}
複製程式碼

之後在QueryBuilder的get方法裡將結果集轉換成了Collection物件返回給了呼叫者.

到這裡QueryBuilder大體的流程就梳理完了,雖然我們只看了select一種操作但其實其他的update、insert、delete也是一樣先由QueryBuilder編譯完成SQL最後由Connection例項去執行然後返回結果,在編譯的過程中QueryBuilder也會幫助我們進行防SQL隱碼攻擊。

本文已經收錄在系列文章Laravel核心程式碼學習裡,歡迎訪問閱讀。

相關文章