提到 WEB 应用程序开发,必定会提到 MVC 开发模式。其中的 M 也就是 Model 即是指数据库操作层,往往会单独编写一个模块专门封装数据库操作接口,避免裸 SQL 的使用,Typecho中也有这一封装。同时,这一层也往往起到SQL注入防御的作用。

Typecho 的数据库操作层 API

首先得看看,Typecho 所提供的数据库 API 是如何使用的,如下代码所示:

/** 首先插入部分数据 */
$insertId = $this->db->query($this->db->insert('table.comments')->rows($insertStruct));

/** 更新评论数 */
$num = $this->db->fetchObject($this->db->select(array('COUNT(coid)' => 'num'))->from('table.comments')
->where('status = ? AND cid = ?', 'approved', $comment['cid']))->num;

$this->db->query($this->db->update('table.contents')->rows(array('commentsNum' => $num))
->where('cid = ?', $comment['cid']));
/**
 * 删除自定义字段
 * 
 * @param integer $cid 
 * @access public
 * @return integer
 */
public function deleteFields($cid)
{
    return $this->db->query($this->db->delete('table.fields')
        ->where('cid = ?', $cid));
}

以上截取的两段代码涵盖了增删改查的基本操作,还有复杂点的多表关联查询等这里先不关注。通过以上代码,很显然的是其只对基本操作进行了封装,避免裸 SQL 语句的拼接及代码的重复,同时统一化安全防御。这种封装类似于 Laravel 的 Database: Query Builder

整体来说,就是通过 $db->query()$db->fetchObject() 来执行操作,通过 $db->insert()/delete()/update()/select()->from() 这种调用来确定具体操作类型及部分限定条件,通过 $db->where()/orWhere() 来限定条件,通过 $db->rows() 送入插入、更新等操作需要的数据。显而易见接口的设计就是按照 SQL 语法来的,熟悉基本的 SQL 语法即非常好上手。同时可见 Typecho 的数据库操作层并没有实现 ORM ,可能因为其定位本身就是博客程序,不会有太复杂的数据库操作,Query Builder已经够用了。

Typecho 的数据库操作层模块划分

Typecho 的数据库操作层主要分三个模块 —— 查询操作封装、查询构造、数据库适配。对于业务操作来说,所有操作均通过 Typecho_Db 进行调用,其会根据对应的操作调用 Typecho_Db_Query 设定好操作类型、操作限定条件、操作数据等,在设定完毕最后执行时会由 Typecho_Db_Query 先生成 SQL 语句,送入适配器的对应方法内,适配器连接数据库并执行语句返回结果,最后由 Typecho_Db 返回给业务代码。当然除此以外里面也有一些细节,例如对于列的预处理不同的数据库会稍有不同,所以具体实现是在数据库适配器里面,但是会在 Typecho_Db_Query 里面调用,毕竟最后是由其生成 SQL 语句。上述 Typecho_Db 所提供给业务代码的 API 代码大都如下:

/**
 * 更新记录操作(UPDATE)
 *
 * @param string $table 需要更新记录的表
 * @return Typecho_Db_Query
 */
public function update($table)
{
    return $this->sql()->update($table);
}

。。。

/**
 * 选择查询字段
 *
 * @access public
 * @param mixed $field 查询字段
 * @return Typecho_Db_Query
 */
public function select()
{
    $args = func_get_args();
    return call_user_func_array(array($this->sql(), 'select'), $args ? $args : array('*'));
}

其实仅仅就是对 Typecho_Db_Query 的 SQL 信息提供接口进行了封装,跟进其中的 $this->sql() 即可见:

/**
 * 获取SQL词法构建器实例化对象
 *
 * @return Typecho_Db_Query
 */
public function sql()
{
    return new Typecho_Db_Query($this->_adapter, $this->_prefix);
}

这波操作可谓是简单粗暴。那么也就是说,所有的增删改查的操作都是由 Typecho_Db_Query 来构造的。拧出来一看就很明显了:

/**
 * 选择查询字段
 *
 * @access public
 * @param mixed $field 查询字段
 * @return Typecho_Db_Query
 */
public function select($field = '*')
{
    $this->_sqlPreBuild['action'] = Typecho_Db::SELECT;
    $args = func_get_args();

    $this->_sqlPreBuild['fields'] = $this->getColumnFromParameters($args);
    return $this;
}

/**
 * 查询记录操作(SELECT)
 *
 * @param string $table 查询的表
 * @return Typecho_Db_Query
 */
public function from($table)
{
    $this->_sqlPreBuild['table'] = $this->filterPrefix($table);
    return $this;
}

/**
 * 更新记录操作(UPDATE)
 *
 * @param string $table 需要更新记录的表
 * @return Typecho_Db_Query
 */
public function update($table)
{
    $this->_sqlPreBuild['action'] = Typecho_Db::UPDATE;
    $this->_sqlPreBuild['table'] = $this->filterPrefix($table);
    return $this;
}

通过这些操作,所有需要的信息都存储在 Typecho_Db_Query 对象的几个属性数组里面了,所以后面关注如何构造 SQL 语句即可。

SQL 语句构造

Typecho_Db_Query 的构造分两两种情况 —— 查询语句和其他语句。查询语句的构造可能更复杂一点且部分语句与具体所用数据库相关,故而单独作为一类。见如下代码:

/**
 * 构造最终查询语句
 *
 * @return string
 */
public function __toString()
{
    switch ($this->_sqlPreBuild['action']) {
        case Typecho_Db::SELECT:
            return $this->_adapter->parseSelect($this->_sqlPreBuild);
        case Typecho_Db::INSERT:
            return 'INSERT INTO '
            . $this->_sqlPreBuild['table']
            . '(' . implode(' , ', array_keys($this->_sqlPreBuild['rows'])) . ')'
            . ' VALUES '
            . '(' . implode(' , ', array_values($this->_sqlPreBuild['rows'])) . ')'
            . $this->_sqlPreBuild['limit'];
        case Typecho_Db::DELETE:
            return 'DELETE FROM '
            . $this->_sqlPreBuild['table']
            . $this->_sqlPreBuild['where'];
        case Typecho_Db::UPDATE:
            $columns = array();
            if (isset($this->_sqlPreBuild['rows'])) {
                foreach ($this->_sqlPreBuild['rows'] as $key => $val) {
                    $columns[] = "$key = $val";
                }
            }

            return 'UPDATE '
            . $this->_sqlPreBuild['table']
            . ' SET ' . implode(' , ', $columns)
            . $this->_sqlPreBuild['where'];
        default:
            return NULL;
    }
}

select 语句的构造由上述原因由数据库适配器模块完成,其他类型的语句根据操作类型进行相应拼接即可,都很简单明了。剩下 select 语句的构造:return $this->_adapter->parseSelect($this->_sqlPreBuild); 跟进数据库适配器,代码如下(以 Typecho_Db_Adapter_Pdo_MysqlTypecho_Db_Adapter_Pdo_SQLite 为例):

  • Typecho_Db_Adapter_Pdo_Mysql
    /**
     * 合成查询语句
     *
     * @access public
     * @param array $sql 查询对象词法数组
     * @return string
     */
    public function parseSelect(array $sql)
    {
        if (!empty($sql['join'])) {
            foreach ($sql['join'] as $val) {
                list($table, $condition, $op) = $val;
                $sql['table'] = "{$sql['table']} {$op} JOIN {$table} ON {$condition}";
            }
        }

        $sql['limit'] = (0 == strlen($sql['limit'])) ? NULL : ' LIMIT ' . $sql['limit'];
        $sql['offset'] = (0 == strlen($sql['offset'])) ? NULL : ' OFFSET ' . $sql['offset'];

        return 'SELECT ' . $sql['fields'] . ' FROM ' . $sql['table'] .
        $sql['where'] . $sql['group'] . $sql['having'] . $sql['order'] . $sql['limit'] . $sql['offset'];
    }
  • Typecho_Db_Adapter_Pdo_SQLite
/**
 * 合成查询语句
 *
 * @access public
 * @param array $sql 查询对象词法数组
 * @return string
 */
public function parseSelect(array $sql)
{
    if (!empty($sql['join'])) {
        foreach ($sql['join'] as $val) {
            list($table, $condition, $op) = $val;
            $sql['table'] = "{$sql['table']} {$op} JOIN {$table} ON {$condition}";
        }
    }

    $sql['limit'] = (0 == strlen($sql['limit'])) ? NULL : ' LIMIT ' . $sql['limit'];
    $sql['offset'] = (0 == strlen($sql['offset'])) ? NULL : ' OFFSET ' . $sql['offset'];

    return Typecho_Common::filterSQLite2CountQuery('SELECT ' . $sql['fields'] . ' FROM ' . $sql['table'] .
    $sql['where'] . $sql['group'] . $sql['having'] . $sql['order'] . $sql['limit'] . $sql['offset']);
}

可见其唯一不同在于对于 SQLite ,生成 SQL 语句之后还要经过相关处理,这个方法放在 Typecho_Common 显然是不合适的,就应该作为 Typecho_Db_Adapter_Pdo_SQLite 的方法之一。跟进去代码如下:

/**
 * 处理sqlite2的distinct count
 *
 * @param $sql
 * @return string
 */
public static function filterSQLite2CountQuery($sql)
{
    if (preg_match("/SELECT\s+COUNT\(DISTINCT\s+([^\)]+)\)\s+(AS\s+[^\s]+)?\s*FROM\s+(.+)/is", $sql, $matches)) {
        return 'SELECT COUNT(' . $matches[1] . ') ' . $matches[2] . ' FROM SELECT DISTINCT '
            . $matches[1] . ' FROM ' . $matches[3];
    }

    return $sql;
}

可见其为替换操作,将 SQL 语句形如(不区分大小写):

SELECT COUNT(DISTINCT col_name...) AS new_name FROM TABLE...

的部分替换为:

SELECT COUNT(DISTINCT col_name...) AS new_name FROM SELECT DISTINCT col_name1 ... FROM TABLE...

目测是为了兼容语法支持的问题,可能比较老的版本 SQLite 不支持 COUNT(ALL | DISTINCT COL_NAME) 的语法,目前常用版本实测支持,但是只支持 COUNT 一个字段。测试过程如下:

sqlite> create table test (a int , b text);
sqlite> select count(distinct a,b) from test;
Error: wrong number of arguments to function count()
sqlite> select count(a, b) from test;
Error: wrong number of arguments to function count()
sqlite> select count(a) from test;
0
sqlite> select count(distinct a) from test;
0

总体可见,想要达到够用的程度,SQL 的构造过程并不算复杂。

数据库连接池

配置文件里面就有配置数据库连接设置,要实现这一点关注一下两处代码即可:

  • 添加数据库服务实例
/**
 * 为多数据库提供支持
 *
 * @access public
 * @param Typecho_Db $db 数据库实例
 * @param integer $op 数据库操作
 * @return void
 */
public function addServer($config, $op)
{
    $this->_config[] = Typecho_Config::factory($config);
    $key = count($this->_config) - 1;

    /** 将连接放入池中 */
    switch ($op) {
        case self::READ:
        case self::WRITE:
            $this->_pool[$op][] = $key;
            break;
        default:
            $this->_pool[self::READ][] = $key;
            $this->_pool[self::WRITE][] = $key;
            break;
    }
}
  • 取用数据库服务实例
/**
 * 选择数据库
 * 
 * @param int $op 
 * @return Typecho_Db_Adapter
 * @throws Typecho_Db_Exception
 */
public function selectDb($op)
{
    if (!isset($this->_connectedPool[$op])) {
        if (empty($this->_pool[$op])) {
            /** Typecho_Db_Exception */
            throw new Typecho_Db_Exception('Missing Database Connection');
        }
        
        //获取相应读或写服务器连接池中的一个
        $selectConnection = rand(0, count($this->_pool[$op]) - 1); 
        //获取随机获得的连接池配置
        $selectConnectionConfig = $this->_config[$this->_pool[$op][$selectConnection]];
        $selectConnectionHandle = $this->_adapter->connect($selectConnectionConfig);
        $this->_connectedPool[$op] = &$selectConnectionHandle;
        
    }

    return $this->_connectedPool[$op];
}

逻辑很简单,看一下代码即明了,但是实际意义可能不算大,一来程序并没有持久化运行,二来操作的分库效果在需要时必定会加缓存,程序需要另外改写。

关于 SQL 注入

对 Typecho 而言,用户唯一能传入可控数据的地方就是评论区用户名与内容,其次是搜索,其他数据都会由 Typecho_Validate 进行类型校验,没有造成注入的可能。除此之外,其他文章已经说过,字符集已经统一为 UTF-8 ,避免宽字节注入问题。由上所述,评论内容必然加载到 Typecho_Db_Query->_sqlPreBuild['rows'] ,过程如下:

/**
 * 指定需要写入的栏目及其值
 *
 * @param array $rows
 * @return Typecho_Db_Query
 */
public function rows(array $rows)
{
    foreach ($rows as $key => $row) {
        $this->_sqlPreBuild['rows'][$this->filterColumn($key)] = is_null($row) ? 'NULL' : $this->_adapter->quoteValue($row);
    }
    return $this;
}

显然,只有内容可控,跟进 $this->_adapter->quoteValue($row) 如下(以 MySQL 为例):

/**
 * 引号转义函数
 *
 * @param string $string 需要转义的字符串
 * @return string
 */
public function quoteValue($string)
{
    return '\'' . str_replace(array('\'', '\\'), array('\'\'', '\\\\'), $string) . '\'';
}

可见处理方式是外部为单引号包裹,内容单引号换成双,同时为内容里面的反斜线添加转义主要是避免转义掉单引号,这样规避掉了 INSERT 语句导致 SQL 注入的可能。至于搜索功能,必然会调用 where 操作,关注以下代码即可知经过了同样的处理:

$this->_sqlPreBuild['where'] .= $operator . ' (' . vsprintf($condition, $this->quoteValues($args)) . ')';

Typecho 数据库操作层分析总结

总体来说比较简洁,一来 Typecho 定位就是博客程序不需要过于复杂的数据库操作,二来 SQL 语句本身并没有很复杂的语法。封装上我认为 Typecho_Db->query()类的操作,直接封装成链式的更好点,类似于 Laravel: Query Builder 那样的话,直接写成: Typecho_Db->where()->query() 而不用单独构造 Query 对象再作为参数送入: Typecho_Db->query(Typecho_Db->where());其次就是前面提到的关于 SQLite 老版本做的语法兼容,不知为何放到 Typecho_Common 了。