Laravel Database——資料庫的 CRUD 操作原始碼分析

leoyang發表於2017-09-17

前言

本文 GitBook 地址: https://www.gitbook.com/book/leoyang90/lar...

connection 物件構建初始化完成後,我們就可以利用 DB 來進行資料庫的 CRUD ( CreateRetrieveUpdateDelete)操作。本篇文章,我們將會講述 laravel 如何與 pdo 互動,實現基本資料庫服務的原理。

 

run

laravel 中任何資料庫的操作都要經過 run 這個函式,這個函式作用在於重新連線資料庫、記錄資料庫日誌、資料庫異常處理:

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

    $start = microtime(true);

    try {
        $result = $this->runQueryCallback($query, $bindings, $callback);
    } catch (QueryException $e) {
        $result = $this->handleQueryException(
            $e, $query, $bindings, $callback
        );
    }

    $this->logQuery(
        $query, $bindings, $this->getElapsedTime($start)
    );

    return $result;
}

重新連線資料庫 reconnect

如果當期的 pdo 是空,那麼就會呼叫 reconnector 重新與資料庫進行連線:

protected function reconnectIfMissingConnection()
{
    if (is_null($this->pdo)) {
        $this->reconnect();
    }
}

public function reconnect()
{
    if (is_callable($this->reconnector)) {
        return call_user_func($this->reconnector, $this);
    }

    throw new LogicException('Lost connection and no reconnector available.');
}

執行資料庫操作

資料庫的 curd 操作會被包裝成為一個閉包函式,作為 runQueryCallback 的一個引數,當執行正常時,會返回結果,如果遇到異常的話,會將異常轉化為 QueryException,並且丟擲。

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

    catch (Exception $e) {
        throw new QueryException(
            $query, $this->prepareBindings($bindings), $e
        );
    }

    return $result;
}

資料庫異常處理

pdo 查詢返回異常的時候,如果當前是事務進行時,那麼直接返回異常,讓上一層事務來處理。

如果是由於與資料庫事情連線導致的異常,那麼就要重新與資料庫進行連線:

protected function handleQueryException($e, $query, $bindings, Closure $callback)
{
    if ($this->transactions >= 1) {
        throw $e;
    }

    return $this->tryAgainIfCausedByLostConnection(
        $e, $query, $bindings, $callback
    );
}

與資料庫失去連線:

protected function tryAgainIfCausedByLostConnection(QueryException $e, $query, $bindings, Closure $callback)
{
    if ($this->causedByLostConnection($e->getPrevious())) {
        $this->reconnect();

        return $this->runQueryCallback($query, $bindings, $callback);
    }

    throw $e;
}

protected function causedByLostConnection(Exception $e)
{
    $message = $e->getMessage();

    return Str::contains($message, [
        'server has gone away',
        'no connection to the server',
        'Lost connection',
        'is dead or not enabled',
        'Error while sending',
        'decryption failed or bad record mac',
        'server closed the connection unexpectedly',
        'SSL connection has been closed unexpectedly',
        'Error writing data to the connection',
        'Resource deadlock avoided',
        'Transaction() on null',
        'child connection forced to terminate due to client_idle_limit',
    ]);
}

資料庫日誌

public function logQuery($query, $bindings, $time = null)
{
    $this->event(new QueryExecuted($query, $bindings, $time, $this));

    if ($this->loggingQueries) {
        $this->queryLog[] = compact('query', 'bindings', 'time');
    }
}

想要開啟或關閉日誌功能:

public function enableQueryLog()
{
    $this->loggingQueries = true;
}

public function disableQueryLog()
{
    $this->loggingQueries = false;
}

 

Select 查詢

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();
    });
}

資料庫的查詢主要有一下幾個步驟:

  • 獲取 $this->pdo 成員變數,若當前未連線資料庫,則進行資料庫連線,獲取 pdo 物件。
  • 設定 pdo 資料 fetch 模式
  • pdo 進行 sql 語句預處理,pdo 繫結引數
  • sql 語句執行,並獲取資料。

getPdoForSelect 獲取 pdo 物件

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;
}

public function getReadPdo()
{
    if ($this->transactions > 0) {
        return $this->getPdo();
    }

    if ($this->getConfig('sticky') && $this->recordsModified) {
        return $this->getPdo();
    }

    if ($this->readPdo instanceof Closure) {
        return $this->readPdo = call_user_func($this->readPdo);
    }

    return $this->readPdo ?: $this->getPdo();
}

getPdo 這裡邏輯比較簡單,值得我們注意的是 getReadPdo。為了減緩資料庫的壓力,我們常常對資料庫進行讀寫分離,也就是隻要當寫資料庫這種操作發生時,才會使用寫資料庫,否則都會用讀資料庫。這種措施減少了資料庫的壓力,但是也帶來了一些問題,那就是讀寫兩個資料庫在一定時間內會出現資料不一致的情況,原因就是寫庫的資料未能及時推送給讀庫,造成讀庫資料延遲的現象。為了在一定程度上解決這類問題,laravel 增添了 sticky 選項,

從程式中我們可以看出,當我們設定選項 sticky 為真,並且的確對資料庫進行了寫操作後,getReadPdo 會強制返回主庫的連線,這樣就避免了讀寫分離造成的延遲問題。

還有一種情況,當資料庫在執行事務期間,所有的讀取操作也會被強制連線主庫。

prepared 設定資料獲取方式

protected $fetchMode = PDO::FETCH_OBJ;
protected function prepared(PDOStatement $statement)
{
    $statement->setFetchMode($this->fetchMode);

    $this->event(new Events\StatementPrepared(
        $this, $statement
    ));

    return $statement;
}

pdosetFetchMode 函式用於為語句設定預設的獲取模式,通常模式有一下幾種:

  • PDO::FETCH_ASSOC //從結果集中獲取以列名為索引的關聯陣列。
  • PDO::FETCH_NUM //從結果集中獲取一個以列在行中的數值偏移量為索引的值陣列。
  • PDO::FETCH_BOTH //這是預設值,包含上面兩種陣列。
  • PDO::FETCH_OBJ //從結果集當前行的記錄中獲取其屬性對應各個列名的一個物件。
  • PDO::FETCH_BOUND //使用fetch()返回TRUE,並將獲取的列值賦給在bindParm()方法中指定的相應變數。
  • PDO::FETCH_LAZY //建立關聯陣列和索引陣列,以及包含列屬性的一個物件,從而可以在這三種介面中任選一種。

pdo 的 prepare 函式

prepare 函式會為 PDOStatement::execute() 方法準備要執行的 SQL 語句,SQL 語句可以包含零個或多個命名(:name)或問號(?)引數標記,引數在SQL執行時會被替換。

不能在 SQL 語句中同時包含命名(:name)或問號(?)引數標記,只能選擇其中一種風格。

預處理 SQL 語句中的引數在使用 PDOStatement::execute() 方法時會傳遞真實的引數。

之所以使用 prepare 函式,是因為這個函式可以防止 SQL 注入,並且可以加快同一查詢語句的速度。關於預處理與引數繫結防止 SQL 漏洞注入的原理可以參考:Web安全之SQL隱碼攻擊技巧與防範.

pdo 的 bindValues 函式

在呼叫 pdo 的引數繫結函式之前,laravel 對引數值進一步進行了優化,把時間型別的物件利用 grammer 的設定重新格式化,false 也改為0。

pdo 的引數繫結函式 bindValue,對於使用命名佔位符的預處理語句,應是類似 :name 形式的引數名。對於使用問號佔位符的預處理語句,應是以1開始索引的引數位置。

public function prepareBindings(array $bindings)
{
    $grammar = $this->getQueryGrammar();

    foreach ($bindings as $key => $value) {
        if ($value instanceof DateTimeInterface) {
            $bindings[$key] = $value->format($grammar->getDateFormat());
        } elseif ($value === false) {
            $bindings[$key] = 0;
        }
    }

    return $bindings;
}

public function bindValues($statement, $bindings)
{
    foreach ($bindings as $key => $value) {
        $statement->bindValue(
            is_string($key) ? $key : $key + 1, $value,
            is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR
        );
    }
}

 

insert

public function insert($query, $bindings = [])
{
    return $this->statement($query, $bindings);
}

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

        $statement = $this->getPdo()->prepare($query);

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

        $this->recordsHaveBeenModified();

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

這部分的程式碼與 select 非常相似,不同之處有一下幾個:

  • 直接獲取寫庫的連線,不會考慮讀庫
  • 由於不需要返回任何資料庫資料,因此也不必設定 fetchMode
  • recordsHaveBeenModified 函式標誌當前連線資料庫已被寫入。
  • 不需要呼叫函式 fetchAll
public function recordsHaveBeenModified($value = true)
{
    if (! $this->recordsModified) {
        $this->recordsModified = $value;
    }
}

 

update、delete

affectingStatement 這個函式與上面的 statement 函式一致,只是最後會返回更新、刪除影響的行數。

public function update($query, $bindings = [])
{
    return $this->affectingStatement($query, $bindings);
}

public function delete($query, $bindings = [])
{
    return $this->affectingStatement($query, $bindings);
}

public function affectingStatement($query, $bindings = [])
{
    return $this->run($query, $bindings, function ($query, $bindings) {
        if ($this->pretending()) {
            return 0;
        }

        $statement = $this->getPdo()->prepare($query);

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

        $statement->execute();

        $this->recordsHaveBeenModified(
            ($count = $statement->rowCount()) > 0
        );

        return $count;
    });
}

 

transaction 資料庫事務

為保持資料的一致性,對於重要的資料我們經常使用資料庫事務,transaction 函式接受一個閉包函式,與一個重複嘗試的次數:

public function transaction(Closure $callback, $attempts = 1)
{
    for ($currentAttempt = 1; $currentAttempt <= $attempts; $currentAttempt++) {
        $this->beginTransaction();

        try {
            return tap($callback($this), function ($result) {
                $this->commit();
            });
        }

        catch (Exception $e) {
            $this->handleTransactionException(
                $e, $currentAttempt, $attempts
            );
        } catch (Throwable $e) {
            $this->rollBack();

            throw $e;
        }
    }
}

開始事務

資料庫事務中非常重要的成員變數是 $this->transactions,它標誌著當前事務的程式:

public function beginTransaction()
{
    $this->createTransaction();

    ++$this->transactions;

    $this->fireConnectionEvent('beganTransaction');
}

可以看出,當建立事務成功後,就會累加 $this->transactions,並且啟動 event,建立事務:

protected function createTransaction()
{
    if ($this->transactions == 0) {
        try {
            $this->getPdo()->beginTransaction();
        } catch (Exception $e) {
            $this->handleBeginTransactionException($e);
        }
    } elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) {
        $this->createSavepoint();
    }
}

如果當前沒有任何事務,那麼就會呼叫 pdo 來開啟事務。

如果當前已經在事務保護的範圍內,那麼就會建立 SAVEPOINT,實現資料庫巢狀事務:

protected function createSavepoint()
{
    $this->getPdo()->exec(
        $this->queryGrammar->compileSavepoint('trans'.($this->transactions + 1))
    );
}

public function compileSavepoint($name)
{
    return 'SAVEPOINT '.$name;
}

如果建立事務失敗,那麼就會呼叫 handleBeginTransactionException

protected function handleBeginTransactionException($e)
{
    if ($this->causedByLostConnection($e)) {
        $this->reconnect();

        $this->pdo->beginTransaction();
    } else {
        throw $e;
    }
}

如果建立事務失敗是由於與資料庫失去連線的話,那麼就會重新連線資料庫,否則就要丟擲異常。

事務異常

事務的異常處理比較複雜,可以先看一看程式碼:

protected function handleTransactionException($e, $currentAttempt, $maxAttempts)
{
    if ($this->causedByDeadlock($e) &&
        $this->transactions > 1) {
        --$this->transactions;

        throw $e;
    }

    $this->rollBack();

    if ($this->causedByDeadlock($e) &&
        $currentAttempt < $maxAttempts) {
        return;
    }

    throw $e;
}

protected function causedByDeadlock(Exception $e)
{
    $message = $e->getMessage();

    return Str::contains($message, [
        'Deadlock found when trying to get lock',
        'deadlock detected',
        'The database file is locked',
        'database is locked',
        'database table is locked',
        'A table in the database is locked',
        'has been chosen as the deadlock victim',
        'Lock wait timeout exceeded; try restarting transaction',
    ]);
}

這裡可以分為四種情況:

  • 單一事務,非死鎖導致的異常

單一事務就是說,此時的事務只有一層,沒有巢狀事務的存在。資料庫的異常也不是死鎖導致的,一般是由於 sql 語句不正確引起的。這個時候,handleTransactionException 會直接回滾事務,並且丟擲異常到外層:

try {
      return tap($callback($this), function ($result) {
          $this->commit();
      });
}
catch (Exception $e) {
     $this->handleTransactionException(
         $e, $currentAttempt, $attempts
     );
} catch (Throwable $e) {
       $this->rollBack();

       throw $e;
}

接到異常之後,程式會再次回滾,但是由於 $this->transactions 已經為 0,因此回滾直接返回,並未真正執行,之後就會丟擲異常。

  • 單一事務,死鎖異常

有死鎖導致的單一事務異常,一般是由於其他程式同時更改了資料庫,這個時候,就要判斷當前重複嘗試的次數是否大於使用者設定的 maxAttempts,如果小於就繼續嘗試,如果大於,那麼就會丟擲異常。

  • 巢狀事務,非死鎖異常

如果出現巢狀事務,例如:

\DB::transaction(function(){
    ...
    //directly or indirectly call another transaction:
    \DB::transaction(function() {
        ...
        ...
    }, 2);//attempt twice
}, 2);//attempt twice

如果是非死鎖導致的異常,那麼就要首先回滾內層的事務,丟擲異常到外層事務,再回滾外層事務,丟擲異常,讓使用者來處理。也就是說,對於巢狀事務來說,內部事務異常,一定要回滾整個事務,而不是僅僅回滾內部事務。

  • 巢狀事務,死鎖異常

巢狀事務的死鎖異常,仍然和巢狀事務非死鎖異常一樣,內部事務異常,一定要回滾整個事務。

但是,不同的是,mysql 對於巢狀事務的回滾會導致外部事務一併回滾:InnoDB Error Handling,因此這時,我們僅僅將 $this->transactions 減一,並丟擲異常,使得外層事務回滾丟擲異常即可。

回滾事務

如果事務內的資料庫更新操作失敗,那麼就要進行回滾:

public function rollBack($toLevel = null)
{
    $toLevel = is_null($toLevel)
                ? $this->transactions - 1
                : $toLevel;

    if ($toLevel < 0 || $toLevel >= $this->transactions) {
        return;
    }

    $this->performRollBack($toLevel);

    $this->transactions = $toLevel;

    $this->fireConnectionEvent('rollingBack');
}

回滾的第一件事就是要減少 $this->transactions 的值,標誌當前事務失敗。

回滾的時候仍然要判斷當前事務的狀態,如果當前處於巢狀事務的話,就要進行回滾到 SAVEPOINT,如果是單一事務的話,才會真正回滾退出事務:

protected function performRollBack($toLevel)
{
    if ($toLevel == 0) {
        $this->getPdo()->rollBack();
    } elseif ($this->queryGrammar->supportsSavepoints()) {
        $this->getPdo()->exec(
            $this->queryGrammar->compileSavepointRollBack('trans'.($toLevel + 1))
        );
    }
}

public function compileSavepointRollBack($name)
{
    return 'ROLLBACK TO SAVEPOINT '.$name;
}

提交事務

提交事務比較簡單,僅僅是呼叫 pdocommit 即可。需要注意的是對於巢狀事務的事務提交,commit 函式僅僅更新了 $this->transactions,而並沒有真正提交事務,原因是內層事務的提交對於 mysql 來說是無效的,只有外部事務的提交才能更新整個事務。

public function commit()
{
    if ($this->transactions == 1) {
        $this->getPdo()->commit();
    }

    $this->transactions = max(0, $this->transactions - 1);

    $this->fireConnectionEvent('committed');
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章