Typecho源码分析4 - 数据库操作层的实现与SQL注入防御
提到 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_Mysql
和 Typecho_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
了。
分类: 编程 安全
标签: Typecho 源码分析
很溜,学习了。感谢分享