yii2源码分析系列,核心部分的讲解已经差不多了。后面,我们准备更新一些yii2与数据库的那些事。
今天我们来看一下数据访问层是怎么实现的,也就是DAO(Data Access Objects)。
之前见过一些公司,在不熟悉DAO操作的情况下,选择在yii2内直接使用PDO与数据库交互。这其实是一个不好的选择。选择框架的目的就是利用框架的便捷性,一来写PDO操作麻烦,二来如果后期有更换数据库驱动的想法,与数据库交互的语句基本就要重写了。
相对而言,DAO十分友好。比如说事务、读写分离、预处理等等,使用都非常简单。
在yii2内,DAO的封装基于db组件,下面我们配置一个mysql的db组件1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21'components' => [
'db' => [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=your host;dbname=your dbname',
'username' => 'your username',
'password' => 'your password',
'charset' => 'utf8',
],
],
如果想更换数据库驱动,只需要修改dsn属性即可。
db组件,顾名思义,指的是 yii\db\Connection。其中,dsn、username、password、charset, 是 yii\db\Connection 类的属性,当然还有很多其他的属性,比如 tablePrefix(表前缀)、enableSchemaCache(是否开启csheme缓存)、enableQueryCache(是否开启query缓存)、slaves(从库)、masters(主库) 等等,下面我们都会碰到这些。
我们准备以一条简单的sql查询来讲述 yii\db\Connection 以及相关联的类的实现。
sql如下
$sql = "SELECT id, username FROM user";
$users = Yii::$app->db->createCommand($sql)->queryAll();
不要小看这条简单的sql,它应该值得我们思考很多东西。比方说:
如果db组件配置了表前缀,最终执行的sql语句会不会添加上表前缀?
如果字段写成 user.id 的方式,类似这样的表前缀(pre_user.id)是否会自动加上?
如果字段加了双引号(select “id”, …),结果是字符串”id”还是id对应的数据?
如果表名或者列名是mysql预留关键字,最终执行的sql是否有反引号`的存在?
等等。如果我们自己实现框架,底层的实现不仅要实现的完美,更重要的是易于扩展。
我们看一下yii2的实现。
看一下 yii\db\Connection::createCommand 方法1
2
3
4
5
6
7
8
9public function createCommand($sql = null, $params = [])
{
/** @var Command $command */
$command = new $this->commandClass([
'db' => $this,
'sql' => $sql,
]);
return $command->bindValues($params);
}
话痨:注释是一种很好的习惯,记得培养。
yii\db\Connection::createCommand 方法的实现上,把sql语句以及参数一并交给了 yii\db\Command 类处理(yii\db\Connection::commandClass 属性,指的就是 yii\db\Command )。如果你有需要改写command类,在db配置中注入自己的commandClass就可以了。
有同学注意到了,yii\db\Command 类并没有sql属性,这一点依赖 yii\db\Command::setSql 方法的实现。
我们看到,yii\db\Command::setSql 方法,除了一些重置的操作之外,还调用了 yii\db\Connection::quoteSql 方法,对sql语句做了处理。
yii\db\Connection::quoteSql 方法,其实对我们前面列举的疑惑都做了相应的处理,一起来看一下。1
2
3
4
5
6
7
8
9
10
11
12
13
14public function quoteSql($sql)
{
return preg_replace_callback(
'/(\\{\\{(%?[\w\-\. ]+%?)\\}\\}|\\[\\[([\w\-\. ]+)\\]\\])/',
function ($matches) {
if (isset($matches[3])) {
return $this->quoteColumnName($matches[3]);
} else {
return str_replace('%', $this->tablePrefix, $this->quoteTableName($matches[2]));
}
},
$sql
);
}
这里主要看正则的实现,正则表达式表明,如果我们写成%tablename、或者tablename%,%都会被替换成相应的表前后缀。
而对于需要处理的字段,加双方括号即可会自动处理,比如 [[username]]。
而具体又都有哪些处理细节呢?这个我们要说一下。
我们知道,mysql有scheme的概念,不清楚scheme的可以先去了解一下。yii2对scheme也有封装,不同的数据库驱动也对应有不同的scheme子类。在yii2内,scheme大家应该并不陌生,至少下面的sql语句你应该见到过:1
2
3
4
5
6
7
8
9SHOW FULL COLUMNS FROM xxx,
SELECT
kcu.constraint_name,
kcu.column_name,
kcu.referenced_table_name,
kcu.referenced_column_name
FROM information_schema.referential_constraints AS rc
JOIN information_schema.key_column_usage AS kcu ON
......
其实这些都是由于要通过cheme获取数据表元数据引起的,所以也就有了schema缓存优化的问题。scheme这里会涉及到很多东西,我们并不打算详细介绍,在分析的过程中,碰到了我们就细细的提一下。
回到 yii\db\Connection::quoteSql 方法,这里分别调用了 yii\db\Connection::quoteColumnName 和 yii\db\Connection::quoteTableName 方法对列和表进行了处理。
以表名为例,找到对应的 yii\db\Connection::quoteTableName 方法,这里首先会调用 yii\db\Connection::getSchema() 获取scheme对应,以mysql为例,从 yii\db\Connection::schemaMap 属性的配置可以看出,对应的schame是yii\db\mysql\Schema,所以下面执行的是 yii\db\mysql\Schema::quoteTableName 方法。
yii\db\mysql\Schema::quoteTableName 方法存在其父类 yii\db\Scheme 中,各位找到 yii\db\Scheme::quoteTableName 方法,我们看具体实现。
因为表名也可能会有库的前缀,比如我们在多库的情况下,可以写成 “front.user” 表示 front 库的user表。
yii\db\Scheme::quoteTableName 方法主要把执行逻辑交给 yii\db\mysql\Schema::quoteSimpleTableName 方法处理。这个方法只有一个重点,就是对拿到的表名、库名添加反引号处理。字段的处理逻辑同样如此,有兴趣的可以试着从 yii\db\Connection::quoteColumnName 方法看一遍。
目前,我们只是把 yii\db\Command::setSql 方法构建sql的流程说完,也就是 yii\db\Connection::createCommand 方法中构建 command的过程。
下面我们看一下 yii\db\Command::bindValues 方法。
在web开发中,大家应该避免sql注入的风险。比方说如果我们把先前的sql改成下面这样1
2
3
4$id = Yii::$app->request->get('id', 0);
$sql = 'SELECT id, username FROM user WHERE id = ' . $id;
$command = Yii::$app->db->createCommand($sql);
$users = $command->queryAll();
这样的风险就很高,我们本身只想获取具体某个id的获取,但是结果却可能是整个库都被端掉。
yii\db\Command::bindValues 方法的作用就在于避免类似情况的发生。我们把上面的高危写法改一下1
2
3$sql = 'SELECT id, username FROM user WHERE id = :id';
$command = Yii::$app->db->createCommand($sql, [':id' => $id]);
$users = $command->queryAll();
注:为了跟刚才的写法对比,注意这里的写法也不是最好的,还可以再优化一下。
以id=’7 or 1=1’为例,我们看下两次最终执行的sql1
2
3
4#前者
SELECT id, username FROM user WHERE id = 7 or 1=1
#后者
SELECT id, username FROM user WHERE id = '7 or 1=1'
差别还是很明显的。
具体的实现,找到 yii\db\Command::bindValues 方法,我们发现并没有特殊的地方,这里仅仅是以键值对的形式,把参数和参数值注册到了 yii\db\Command::_pendingParams 和 yii\db\Command::params 属性。
既然路走不通,那我们继续看 yii\db\Command::queryAll 方法,继而找到 yii\db\Command::queryInternal 方法。
像这个方法一样,yii2内有很多埋点一样的log\debug等,都为我们调试开启了极大的方便,使用yii2开发我们也应如此。
整体看一下 yii\db\Command::queryInternal 方法,大致做了下面几件事
yii\db\Command::logQuery,对sql做日志记录,可以可以在debug中看到,利于调试
queryCache 缓存
yii\db\Command::prepare,sql语句执行前做一些处理
执行 pdoStatement,并调用具体的方法处理
我们主要看第3部分的实现,找到 yii\db\Command::prepare 方法。
首次调用时,yii\db\Command::pdoStatement 的值是null,但是一个生命周期下来,queryInternal方法可能会被执行多次,所以我们首先看 yii\db\Command::bindPendingParams 方法的调用。1
2
3foreach ($this->_pendingParams as $name => $value) {
$this->pdoStatement->bindValue($name, $value[0], $value[1]);
}
这里处理 yii\db\Command::bindValues 方法的结果,执行的是 pdoStatement::bindValue ,也就是说,先前通过 yii\db\Command::bindValues 绑定参数的形式的sql语句,是 pdoStatement 预处理的实现,所以它是安全的。
yii\db\Command::prepare 方法的第二部分,是获取pdo对象。
有同学可能会说,那直接new PDO不就好了么?问题在于如果我们想让框架支持主从,简单的配置就可以实现,你会怎么做?
在不考虑从库的时候,我们先看主库 master 的实现。
yii\db\Command::prepare 方法中获取pdo: $pdo = $this->db->getSlavePdo();
假设 yii\db\Connection::getSlavePdo 方法内获取到的$db是null, yii\db\Connection::getMasterPdo 方法会被调起;
yii\db\Connection::getMasterPdo::open 开启db连接,从该方法中可以看到,如果我们没有配置 yii\db\Connection::master 属性,会通过 yii\db\Connection::createPdoInstance 以db配置为准,获取pdo对象;
如果我们配置了 yii\db\Connection::master 属性,即有多台主库的时候,默认会根据 yii\db\Connection::shuffleMasters 属性即是否打乱master的方式随机获取一台master主库的配置,最终会调用 yii\db\Connection::openFromPoolSequentially 方法,从master配置的配置池中找到该主库的配置并打开新的连接,重复 yii\db\Connection::open 后面的步骤,并最终返回该pdo对象;
yii\db\Connection::open 的最后一步,调用 yii\db\Connection::initConnection 方法,初始化db连接,包括pdo设置等,如果我们也需要设置pdo,可以绑定事件 yii\db\Connection::EVENT_AFTER_OPEN
至此,pdo初始化之前的工作已经准备妥当。再后面就是 pdoStatement 的执行工作。
整个过程,有点像一条简单SQL引发的“血案”。
对于我们没有细说的scheme、事务、从库等的实现,大家可以自己试着分析一下,不懂的留言我们一起讨论。