写一个“特殊”的查询构造器 - (二、第一条语句)

构造、执行第一条语句

上一篇完成了代码结构的搭建和 PDO 的基础封装,这一篇我们来讲如何构造一个最基本的 SQL 语句,并执行得到结果。

query sql 构造目标: SELECT * FROM test_table;

查询构造器执行语法构造目标: $drivers->table('test_table')->select('\*')->get();

测试用的数据表请大家自己建立,这里就不单独演示了。

我们回顾下 PDO 执行这个 query 语句的基本用法:

1、PDO::query() 方法获取结果集:

$pdo->query("SELECT * FROM test_table;");

2、PDO::prepare()、PDOStatement::execute() 方法:

$pdoSt = $pdo->prepare("SELECT * FROM test_table;");
$pdoSt->execute();
$pdoSt->fetchAll(PDO::FETCH_ASSOC);

PDO::prepare() 方法提供了防注入、参数绑定的机制,可以指定结果集的返回格式,更加灵活易于封装,我们选这种。

query sql 字符串构造:

要构造 query sql 语句,那么不妨先观察一下它的基本构造:

SELECT、 要查找的字段(列)、 FROM、 要查找的表、 关联子句、 条件子句、 分组子句、 排序子句、 LIMIT 子句。

除了 SELECT 和 FROM 是固定不变,我们只需构造好查询字段、表名和一系列子句的字符串,然后按照 query sql 的语法拼接在一起即可。

在基类 PDODriver.php 中添加属性作为构造字符串:

protected $_table = '';        // table 名
protected $_prepare_sql = '';  // prepare 方法执行的 sql 语句
protected $_cols_str = ' * ';  // 需要查询的字段,默认为 * (全部)
protected $_where_str = '';    // where 子句
protected $_orderby_str = '';  // order by 子句
protected $_groupby_str = '';  // group by 子句
protected $_having_str = '';   // having 子句 (配合 group by 使用)
protected $_join_str = '';     // join 子句
protected $_limit_str = '';    // limit 子句

基础方法的创建

有了基本的构造字符串属性,可以开始构造一条 sql 了。

添加 _buildQuery() 方法,用来构造 sql 字符串:

protected function _buildQuery()
{
    $this->_prepare_sql = 'SELECT '.$this->_cols_str.' FROM '.$this->_table.
        $this->_join_str.
        $this->_where_str.
        $this->_groupby_str.$this->_having_str.
        $this->_orderby_str.
        $this->_limit_str;
}

添加 table() 方法,用来设置表名:

public function table($table)
{
    $this->_table = $table;

    return $this; // 为了链式调用,返回当前实例
}

添加 select() 方法,这里使用可变参数灵活处理传入:

public function select()
{
    // 获取传入方法的所有参数
    $cols = func_get_args();

    if( ! func_num_args() || in_array('*', $cols)) {
        // 如果没有传入参数,默认查询全部字段
        $this->_cols_str = ' * ';
    } else {

        $this->_cols_str = ''; // 清除默认的 * 值
        // 构造 "field1, filed2 ..." 字符串
        foreach ($cols as $col) {
            $this->_cols_str .= ' '.$col.',';
        }
        $this->_cols_str = rtrim($this->_cols_str, ',');
    }

    return $this;
}

构造、执行

sql 字符串构造完毕,接下来就需要一个执行 sql 并取得结果的方法来收尾。

添加 get() 方法:

public function get()
{
    try {
        $this->_buildQuery();   // 构建 sql
        // prepare 预处理
        $pdoSt = $this->_pdo->prepare($this->_prepare_sql);
        // 执行
        $pdoSt->execute();
    } catch (PDOException $e) {
        throw $e;
    }

    return $pdoSt->fetchAll(PDO::FETCH_ASSOC); // 获取一个以键值数组形式的结果集
}

测试

修改 test/test.php:

require_once dirname(dirname(__FILE__)) . '/vendor/autoload.php';

use Drivers\Mysql;

$config = [
    'host'        => 'localhost',
    'port'        => '3306',
    'user'        => 'username',
    'password'    => 'password',
    'dbname'      => 'database',
    'charset'     => 'utf8',
    'timezone'    => '+8:00',
    'collection'  => 'utf8_general_ci',
    'strict'      => false,
];

$driver = new Mysql($config);
// 执行 SELECT * FROM test_table; 的查询
$results = $driver->table('test_table')->select('*')->get();

var_dump($results);

注:上述代码中由于 _cols_str 属性默认为 ' * ',所以在查询全部字段时省略 select() 方法的调用也是可以的。

之后为了节省篇幅,一些通用的方法只使用 Mysql 驱动类作为测试对象,PostgreSql 和 Sqlite 请读者自己进行测试,之后不会再单独说明。

优化

1、解耦

get 方法中的 prepare、execute 过程是通用的 (查询、插入、删除、更新等操作),我们可以将这部分代码提出来,在其它执行 sql 取结果的方法中复用。

基类中新建 _execute() 方法:

protected function _execute()
{
    try {
        $this->_pdoSt = $this->_pdo->prepare($this->_prepare_sql);
        $this->_pdoSt->execute();

    } catch (PDOException $e) {
        throw $e;
    }
}

由于将逻辑分离到另一个方法中,get() 方法获取不到 PDOStatement 实例,因此将 PDOStatement 实例保存到基类的属性中:

protected $_pdoSt = NULL;

修改后的 get() 方法:

public function get()
{
    $this->_buildQuery();
    $this->_execute();

    return $this->_pdoSt->fetchAll(PDO::FETCH_ASSOC);
}

2、参数重置

使用查询构造器一次查询后,各个构造字符串的内容已经被修改,为了不影响下一次查询,需要将这些构造字符串恢复到初始状态。

注:在常驻内存单例模式下,这种多次用一个类进行查询的情形很常见。

添加 _reset() 方法:

protected function _reset()
{   
    $this->_table = '';
    $this->_prepare_sql = '';
    $this->_cols_str = ' * ';
    $this->_where_str = '';
    $this->_orderby_str = '';
    $this->_groupby_str = '';
    $this->_having_str = '';
    $this->_join_str = '';
    $this->_limit_str = '';
    $this->_bind_params = [];
}

修改 _execute() 方法:

protected function _execute()
{
    try {
        $this->_pdoSt = $this->_pdo->prepare($this->_prepare_sql);
        $this->_pdoSt->execute();
        $this->_reset(); // 每次执行 sql 后将各构造字符串恢复初始状态,保证下一次查询的正确性
    } catch (PDOException $e) {
        throw $e;
    }

}

row() 方法

上述的 get() 方法是直接取得整个结果集。而有一些业务逻辑希望只取一行结果,那么就需要一个 row() 方法来实现这个需求了。

row() 方法并不难,只需把 get() 方法中的 PDOStatement::fetchAll() 方法改为 PDOStatement::fetch() 方法即可:

public function row()
{
    $this->_buildQuery();
    $this->_execute();

    return $this->_pdoSt->fetch(PDO::FETCH_ASSOC);
}

这里就不多说了,大家可以自己测试一下结果。

断线重连

对于典型 web 环境而言,一次 sql 的查询已经随着 HTTP 的请求而结束,PHP 的垃圾回收功能会回收一次请求周期内的数据。而一次 HTTP 请求的时间也相对较短,基本不用考虑数据库断线的问题。

但在常驻内存的环境下,尤其是单例模式下,数据库驱动类可能一直在内存中不被销毁。如果很长时间内没有对数据库进行访问的话,由数据库驱动类建立的数据库连接会被数据库作为空闲连接切断 (具体时间由数据库设置决定),此时如果依旧使用旧的连接对象,会出现持续报错的问题。也就是说,我们要对数据库断线的情况进行处理,在检测到断线的同时新建一个连接代替旧的连接继续使用。【1】

在 PDO 中,数据库断线后继续访问会相应的抛出一个 PDOException 异常 (也可以是一个错误,由 PDO 的错误处理设置决定)。

当数据库出现错误时,PDOException 实例的 errorInfo 属性中保存了错误的详细信息数组,第一个元素返回 SQLSTATE error code,第二个元素是具体驱动错误码,第三个元素是具体的错误信息。参见 PDO::errorInfo

Mysql 断线相关的错误码有两个:

PostgreSql 断线相关的错误码有一个:

当具体驱动错误码为 7 时 PostgreSql 断线 (此驱动错误码根据 PDOException 实测得出,暂时未找到相关文档)

Sqlite 基于内存和文件,不存在断线一说,不做考虑。

这里我们使用 PDO 的具体驱动错误码作为判断断线的依据。

基类添加 _isTimeout() 方法:

protected function _isTimeout(PDOException $e)
{
    // 异常信息满足断线条件,则返回 true
    return (
        $e->errorInfo[1] == 2006 ||   // MySQL server has gone away (CR_SERVER_GONE_ERROR)
        $e->errorInfo[1] == 2013 ||   // Lost connection to MySQL server during query (CR_SERVER_LOST)
        $e->errorInfo[1] == 7         // no connection to the server (for postgresql)
    );
}

修改 _execute() 方法,添加断线重连功能:

protected function _execute()
{
    try {
        $this->_pdoSt = $this->_pdo->prepare($this->_prepare_sql);
        $this->_pdoSt->execute();
        $this->_reset();
    } catch (PDOException $e) {
        // PDO 抛出异常,判断是否是数据库断线引起
        if($this->_isTimeout($e)) { 
            // 断线异常,清除旧的数据库连接,重新连接
            $this->_closeConnection();
            $this->_connect();
            // 重试异常前的操作    
            try {
                $this->_pdoSt = $this->_pdo->prepare($this->_prepare_sql);
                $this->_pdoSt->execute();
                $this->_reset();
            } catch (PDOException $e) {
                // 还是失败、向外抛出异常
                throw $e;
            }
        } else {
            // 非断线引起的异常,向外抛出,交给外部逻辑处理
            throw $e;
        }
    }
}

顺便把之前暴露的 PDO 的原生接口也支持断线重连:

public function query($sql)
{
    try {
        return $this->_pdo->query($sql);
    } catch (PDOException $e) {
        // when time out, reconnect
        if($this->_isTimeout($e)) {

            $this->_closeConnection();
            $this->_connect();

            try {
                return $this->_pdo->query($sql);
            } catch (PDOException $e) {
                throw $e;
            }

        } else {
            throw $e;
        }
    }
}

public function exec($sql)
{
    try {
        return $this->_pdo->exec($sql);
    } catch (PDOException $e) {
        // when time out, reconnect
        if($this->_isTimeout($e)) {

            $this->_closeConnection();
            $this->_connect();

            try {
                return $this->_pdo->exec($sql);
            } catch (PDOException $e) {
                throw $e;
            }

        } else {
            throw $e;
        }
    }
}

public function prepare($sql, array $driver_options = [])
{
    try {
        return $this->_pdo->prepare($sql, $driver_options);
    } catch (PDOException $e) {
        // when time out, reconnect
        if($this->_isTimeout($e)) {

            $this->_closeConnection();
            $this->_connect();

            try {
                return $this->_pdo->prepare($sql, $driver_options);
            } catch (PDOException $e) {
                throw $e;
            }

        } else {
            throw $e;
        }
    }
}

如何模拟断线?

在内存常驻模式中 (如 workerman 的 server 监听环境下):

  • 访问数据库
  • 重启服务器的数据库软件
  • 再次访问数据库,看看是否能正常获取数据。

Just do it

参考

【1】Workerman mysql

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容