热心读者
最后更新于:2022-04-01 02:20:36
在本文的撰写过程中,得到了许多热心读者无私地的支持和帮助, 大家今天所看到的《深入理解Yii2.0》,得益于他们的贡献。在此,向你们真心实意地说一声:“谢谢!”
* 2014年9月17日:Ankh
* 2014年9月20日:未来,什么是微笑
* 2014年9月25日:Eric
* 2014年9月30日:lssmlssm
* 2014年10月1日:黑白猜
* 2014年10月5日:始雪
* 2014年10月9日:旧思绪
* 2014年10月10日:AbrahamGreyson
* 2014年10月11日:情已空
* 2014年10月12日:fangzhoou
* 2014年10月18日:战北
* 2014年10月21日:明
* 2014年10月25日:chamlinsun
* 2014年10月27日:乡村瑞士卷
* 2014年11月3日:change,凯轩
* 2014年11月6日:晴空末岛
* 2014年11月19日:大女乃兔白米唐
* 2014年11月23日:息息相关
* 2014年11月24日:孤独的反叛
* 2014年11月25日:F1FDFF166691
* 2014年12月1日:季末
* 2014年12月5日:GuoLei
* 2014年12月8日:MichaelDuJiu
* 2014年12月11日:醒后的困意
* 2014年12月15日:Kennyxu
* 2015年2月10日:木易水北
* 2015年2月22日:土拨鼠
也想榜上有名?So Easy,为《深入理解Yii2.0》的内容不断完善作出你的贡献吧。 包括但不仅限于用你的火眼金睛指出文章中的错误。
附录2:Yii的安装
最后更新于:2022-04-01 02:20:34
由于Yii2.0使用了许多PHP的新特性,因此,Yii需要PHP5.4.0以上版本。
有两种方法可以安装Yii,一种是使用Composer,另一种是直接下载压缩包。
## 使用Composer安装Yii[](http://www.digpage.com/install.html#composeryii "Permalink to this headline")
官方推荐使用Composer安装Yii。这样更方便后期维护,如果需要添加新的扩展或者升级Yii,只要一句命令就OK了。
用过Yii1.1的读者可能还有印象,安装Yii和构建应用是两个步骤:安装Yii,运行Yii搭建应用框架。 但是,如果使用Composer方法安装Yii,安装和构建应用是一步完成了。
这里并不打算介绍Composer的用法,这方面的内容通过官方文档或者搜索引擎都可以找到。 使用Composer安装Yii,也有两种选择:使用基本模版或者高级模版。 这两者最主要的区别在于高级模版提供了环境切换和前后台分离。 对于团队开发而言,环境切换功能很实用。对于大型应用,前后台分离既是逻辑上的划分,也是安全上的需要。 高级模版功能相对丰富,因此,本书在大多数情况下,都使用高级模版所创建应用进行讲解。 至于基本模版,原理上是相通的。
Yii2.0要求Composer必须安装 composer asset 插件。 这个插件使得Composer可以兼容实现NPM和BOWER包管理器的功能。 [NPM](https://www.npmjs.org/) 和 [BOWER](http://bower.io/) 主要用于前端资源(如JS库等)的管理。
~~~
# 安装Composer,如果已经安装过,可不必再安装
# curl -s http://getcomposer.org/installer | php
# 对于已经安装过Composer的,可以对其进行更新
php ../composer.phar self-update
# 为Composer 安装 composer asset 插件
php ../composer.phar global require "fxp/composer-asset-plugin:1.0.0-beta2"
# 使用高级模版安装Yii应用到 digpage.com 目录下
php ../composer.phar create-project --prefer-dist yiisoft/yii2-app-advanced digpage.com
# 使用基础模版安装
# composer create-project --prefer-dist yiisoft/yii2-app-basic digpage.com
~~~
如果想使用最新的开发版本的Yii基础模版,可加入 --stability=dev 参数。
## 从压缩包安装[](http://www.digpage.com/install.html#id1 "Permalink to this headline")
如果使用压缩包安装方式,请按以下步骤:
1. 从yiiframework.com下载最新的压缩包。
2. 将压缩包解压缩到 /path/to/digpage.com 目录。
3. 修改 config/web.php 文件,输入 cookieValidationKey 配置项密钥。 这个密钥主要用于cookie验证。 如果使用Composer安装,则Composer会自动设置一个密钥:
~~~
// !!! insert a secret key in the following (if it is empty) -
// this is required by cookie validation
'cookieValidationKey' => 'enter your secret key here',
~~~
## 设置Web服务器[](http://www.digpage.com/install.html#web "Permalink to this headline")
常用的Web服务器有nginx + php-fpm和Apache。而且从趋势上看,前者的比重正不断提高。
本教程不打算介绍nginx和Apache的配置安装,这些内容从官方文档和搜索引擎都可以找到相关内容。 这里大体上介绍如何配置Web服务器,使其能够让Yii跑起来。
由于高级模版应用具有前台和后台。 一般来讲,前台和后台分离可以使用不同的主机名、端口,或者使用不同的路径名。使用不同的主机名的,如:
~~~
http://frontend.example.com/
http://backend.example.com/
~~~
使用不同的路径名的,如:
~~~
http://www.example.com/frontend/
http://www.example.com/backend/
~~~
无论使用何种方式,目的都是分离前台和后台。由于是在本地开发,我们使用不同的端口来分离前台和后台。 体现在服务器上,采不同主机名、端口进行的分离方式,意味着不同的虚拟主机,甚至是不同的物理服务器。 而不同的路径名的,则表现为同一台主机,不同目录。 这里,我们使用不同的端口来区别前后台,但在物理上,前端和后端面都部署在同一台服务器上,也就是使用虚拟主机。
使用Nginx的配置如下:
~~~
# for frontend
server {
charset utf-8;
client_max_body_size 128M;
listen 80; ## listen for ipv4
server_name localhost;
root /path/to/digpage.com/frontend/web;
index index.php;
access_log /path/to/digpage.com/frontend/log/access.log main;
error_log /path/to/digpage.com/frontend/log/error.log;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi.conf;
fastcgi_pass 127.0.0.1:9000;
}
location ~ /\.(ht|svn|git) {
deny all;
}
}
# for backend
server {
# ...other settings...
listen 81;
server_name localhost;
root /path/to/digpage.com/backend/web;
# ...other settings...
}
~~~
如果使用Apache,也是分前台和后前的配置,以前台为例:
~~~
DocumentRoot "path/to/digpage.com/frontend/web"
<Directory "path/to/digpage.com/frontend/web">
RewriteEngine on
# If a directory or a file exists, use the request directly
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
# Otherwise forward the request to index.php
RewriteRule . index.php
# ...other settings...
</Directory>
~~~
对于后台,也是设置一个虚拟机,路径改为 path/to/digpage.com/backend/web 即可。
## Yii中的前后台[](http://www.digpage.com/install.html#id2 "Permalink to this headline")
Yii从来不认得什么是前台,什么是后台。从本质来讲,前台和后台都是应用。 换句话说,你可以先用基本模板开发出一个应用,具有前台的全部功能,然后部署。 如法炮制另一个独立的,具有后台全部功能的应用。 这在原理上是一样一样的。只是,按照我们的经验,前台和后台从逻辑上讲,组成一应用比较符合我们的认知。 而且,从代码复用的角度来讲,我们更希望前台和后台的代码可以互通互用,尽量不要重复造轮子。 但是,对于Yii来讲,前台就是一个完备的应用,后台又是另一个应用。这点区别请读者朋友们留意。
那么Yii的高级模版是如何实现把两个应用整合成一个我们认识上的应用的呢。 我们观察一下/path/to/digpage.com/ 目录,看看这个高级模版是如何组织代码的。不难发现其中有二个目录frontend backend 分别代表了前台、后台。
其实你把 frontend backend 中的任意1个目录删除,是不影响剩下的目录的正常运转的。 也就是说,他们相互间是独立的。只不过在代码组织上,他们都放在了 /path/to/application/ 目录下面。
如果深入下去,你会发现不光 frontend backend 其实 console 也是一个完备的Yii应用, 它通常用于维护,是个命令行应用。
总的说,Yii的前台、后台什么的,是我们命名的概念,他们都是独立而完备的应用。 同时,他们又都具有一定的联系,这些联系突出体现在了 common 目录上。 这个目录从字面的意思看,就是通用,对于组织到一起的 frontend backend console 而言, common 中的内容,他们都可以使用。这是Yii中实现代码复用的技巧所在。
更多关于Yii应用的目录结构的内容,请看 [_Yii应用的目录结构和入口脚本_](http://www.digpage.com/app_struct.html#app-struct) 部分。
## 配置应用环境[](http://www.digpage.com/install.html#id3 "Permalink to this headline")
还差最后一步就完成Yii的安装了。这最后一步,就是设置应用环境。在Yii的高级模版应用中,引入了环境的概念。
环境就是指开发环境、测试环境、产品环境等。 对于Yii而言,所谓的环境,就是一组与运行环境相关的配置文件和入口脚本。
Yii对于环境的使用是这样一个原理:采用一个自动化的脚本,每次需要切换环境时,就运行脚本, 由开发者确定要采用何种环境,然后将对应环境的所有配置文件都覆盖当前的配置文件。 在Yii中,与环境相关的文件其实就只有两个:一个是入口脚本 index.php 另一个就是各类配置文件。
在切换环境时,只需要一行命令就全部搞定:
~~~
php /path/to/digpage.com/init
~~~
这行命令会提示你选择何种开发环境,并确认是否覆盖当前的配置文件。下面是输出的内容:
~~~
Yii Application Initialization Tool v1.0
Which environment do you want the application to be initialized in?
[0] Development
[1] Production
Your choice [0-1, or "q" to quit] 0 // 这里选择了 Development 环境
Initialize the application under 'Development' environment? [yes|no] yes
Start initialization ...
generate yii
generate common/config/main-local.php
generate common/config/params-local.php
generate backend/config/main-local.php
generate backend/config/params-local.php
generate backend/web/index.php
generate backend/web/index-test.php
generate frontend/config/main-local.php
generate frontend/config/params-local.php
generate frontend/web/index.php
generate frontend/web/index-test.php
generate console/config/main-local.php
generate console/config/params-local.php
generate cookie validation key in backend/config/main-local.php
generate cookie validation key in frontend/config/main-local.php
chmod 0777 backend/runtime
chmod 0777 backend/web/assets
chmod 0777 frontend/runtime
chmod 0777 frontend/web/assets
chmod 0755 yii
... initialization completed.
~~~
从上面的输出可以看出来, init 脚本其实做了3件事:
* 复制文件到相应位置,覆盖当前配置。
* 生成 cookieValidationKey 并写入相应文件。
* 设置相关文件和目录的权限。
如果想更加便捷,可以直接指定相关的参数:
~~~
php /path/to/yii-application/init --env=Production overwrite=All
~~~
第二种方式直接在命令行中指明使用的环境,并要求全部覆盖当前配置文件。
## 检验安装情况[](http://www.digpage.com/install.html#id4 "Permalink to this headline")
在这一篇,我们已经完成了Yii的安装,可能使用了Composer,也可能使用了压缩包。 接着,我们配置好了Web服务器。 最后,我们运行了 init 命令。 那么,Yii应用的基本框架就已经搭建好了。你可在你的浏览器中试试效果。 使用 http://localhost:80/ 可以打开网站前台,使用http://localhost:81 可以打开后台。
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
附录1:Yii2.0 对比 Yii1.1 的重大改进
最后更新于:2022-04-01 02:20:31
这部分内容是专门为已经有Yii1.1基础的读者朋友写的。将Yii2.0与Yii1.1的不同点着重写出来,对比学起来会快得多。 而对于从未接触过Yii的读者朋友,这部分内容扫一扫就可以了,作为对过往历史的一个了解就够了。 如果有的内容你一时没看明白,也不要紧,本书的正文部分会讲清楚的。 另外,没有Yii1.1的经验,并不妨碍对Yii2.0的学习。
Yii官方有专门的文档归纳总结1.1版本和2.0版本的不同。以下内容,主要来自于官方的文档,我做了下精简, 选择比较重要的变化,并加入了一些个人的经验。
## PHP新特性[](http://www.digpage.com/improvement.html#php "Permalink to this headline")
从对PHP新特性的使用上,两者就存在很大不同。Yii2.0大量使用了PHP的新特性,这在Yii1.1中是没有的。因此,Yii2.0对于PHP的版本要求更高,要求PHP5.4及以上。Yii2.0中使用到的PHP新特性,主要有:
* 命名空间(Namespace)
* 匿名函数
* 数组短语法形式: [1,2,3] 取代 array(1,2,3) 。这在多维数组、嵌套数组中,代码更清晰、简短。
* 在视图文件中使用PHP的 标签,取代 echo 语句。
* 标准PHP库(SPL) 类和接口,具体可以查看 [SPL Class and Interface](http://php.net/manual/en/book.spl.php)
* 延迟静态绑定, 具体可以查看 [Late Static Bindings](http://php.net/manual/en/language.oop5.late-static-bindings.php)
* [PHP标准日期时间](http://php.net/manual/en/book.datetime.php)
* [特性(Traits)](http://php.net/manual/en/language.oop5.traits.php)
* 使用PHP intl 扩展实现国际化支持, 具体可以查看 [PECL init](http://php.net/manual/en/book.intl.php) 。
了解Yii2.0使用了PHP的新特性,可以避免开发时由于环境不当,特别是开发生产环境切换时,产生莫名其妙的错误。 同时,也是让读者朋友借机学习PHP新知识的意思。
## 命名空间(Namespace)[](http://www.digpage.com/improvement.html#namespace "Permalink to this headline")
Yii2.0与Yii1.1之间最显著的不同是对于PHP命名空间的使用。Yii1.1中没有命名空间一说, 为避免Yii核心类与用户自定义类的命名冲突,所有的Yii核心类的命名,均冠以 C 前缀,以示区别。
而Yii2.0中所有核心类都使用了命名空间,因此, C 前缀也就人老珠黄,退出历史舞台了。
命名空间与实际路径相关联,比如 yii\base\Object 对应Yii目录下的 base/Object.php 文件。
## 基础类[](http://www.digpage.com/improvement.html#id2 "Permalink to this headline")
Yii1.1中使用了一个基础类 CComponent ,提供了属性支持等基本功能,因此几乎所有的Yii核心类都派生自该类。 到了Yii2.0,将一家独大的 CComponent 进行了拆分。拆分成了 yii\base\Object 和yii\base\Component 。 拆分的考虑主要是 CComponent 尾大不掉,有影响性能之嫌。 于是,Yii2.0中,把 yii\base\Object 定位于只需要属性支持,无需事件、行为。 而 yii\base\Component 则在前者的基础上,加入对于事件和行为的支持。 这样,开发者可以根据需要,选择继承自哪基础类。
这一功能上的明确划分,带来了效率上的提升。在仅表示基础数据结构,而非反映客观事物的情况下, yii\base\Object 比较适用。
值得一提的是, yii\base\Object 与 yii\base\Component 两者并不是同一层级的,前者是后者他爹。
## 事件(Event)[](http://www.digpage.com/improvement.html#event "Permalink to this headline")
在Yii1.1中,通过一个 on 前缀的方法来创建事件,比如 CActiveRecord 中的 onBeforeSave() 。 在Yii2.0中,可以任意定义事件的名称,并自己触发它:
~~~
$event = new \yii\base\Event;
// 使用 trigger() 触发事件
$component->trigger($eventName, $event);
// 使用 on() 前事件handler与对象绑定
$component->on($eventName, $handler);
// 使用 off() 解除绑定
$component->off($eventName, $handler);
~~~
## 别名(Alias)[](http://www.digpage.com/improvement.html#alias "Permalink to this headline")
Yii2.0中改变了Yii1.1中别名的使用形式,并扩大了别名的范畴。 Yii1.1中,别名以 . 的形式使用:
~~~
RootAlias.path.to.target
~~~
而在Yii2.0中,别名以 @ 前缀的方式使用:
~~~
@yii/jui
~~~
另外,Yii2.0中,不仅有路径别名,还有URL别名:
~~~
// 路径别名
Yii::setAlias('@foo', '/path/to/foo');
// URL别名
Yii::setAlias('@bar', 'http://www.example.com');
~~~
别名与命名空间是紧密相关的,Yii建议为所有根命名空间都定义一个别名,比如上面提到的yii\base\Object , 事实上是定义了 @yii 的别名,表示Yii在系统中的安装路径。 这样一来,Yii就能根据命名空间找到实际的类文件所在路径,并自动加载。这一点上,Yii2.0与Yii1.1并没有本质区别。
而如果没有为根命名空间定义别名,则需要进行额外的配置。将命名空间与实际路径的映射关系,告知Yii。
关于别名的更详细内容请看 [_别名(Alias)_](http://www.digpage.com/aliases.html#aliases) 。
## 视图(View)[](http://www.digpage.com/improvement.html#view "Permalink to this headline")
Yii1.1中,MVC(model-view-controller)中的视图一直是依赖于Controller的,并非是真正意义上独立的View。 Yii2.0引入了 yii\web\View 类,使得View完全独立。这也是一个相当重要变化。
首先,Yii2.0中,View作为Application的一个组件,可以在全局中代码中进行访问。 因此,视图渲染代码不必再局限于Controller中或Widget中。
其次,Yii1.1中视图文件中的 $this 指的是当前控制器,而在 Yii2.0中,指的是视图本身。 要在视图中访问控制器,可以使用 $this->context 。这个 $this->context 是指谁调用了yii\base\View::renderFile() 来渲染这个视图。 一般是某个控制器,也可以是其他实现了yii\base\ViewContextInterface 接口的对象。
同时,Yii1.1中的 CClientScript 也被淘汰了,相关的前端资源及其依赖关系的管理,交由Assert Bundle yii\web\AssertBundle 来专职处理。 一个Assert Bundle代表一系列的前端资源,这些前端资源以目录形式进行管理,这样显得更有序。 更为重要的是,Yii1.1中需要你格外注意资源在HTML中的顺序,比如CSS文件的顺序(后面的会覆盖前面的), JavaScript文件的顺序(前后顺序出错会导致有的库未加载)等。 而在Yii2.0中,使用一个Assert Bundle可以定义依赖于另外的一个或多个Assert Bundle的关系, 这样在向HTML页面注册这些CSS或者JavaScript时,Yii2.0会自动把所依赖的文件先注册上。
在视图模版引擎方面,Yii2.0仍然使用PHP作为主要的模版语言。 同时官方提供了两个扩展以支持当前两大主流PHP模版引擎:Smarty和Twig,而对于Pardo引擎官方不再提供支持了。 当然,开发者可以通过设置 yii\web\View::$renderers 来使用其他模版。
另外,Yii1.1中,调用 $this->render('viewFile', ...) 是不需要使用 echo 命令的。 而Yii2.0中,记得 render() 只是返回视图渲染结果,并不对直接显示出来,需要自己调用 echo
~~~
echo $this->render('_item', ['item' => $item]);
~~~
如果有一天你发现怎么Yii输出了个空白页给你,就要注意是不是忘记使用 echo 了。 还别说,这个错误很常见,特别是在对Ajax请求作出响应时,会更难发现这一错误。请你们编程时留意。
在视图的主题(Theme)化方面,Yii2.0的运作机理采用了完全不同的方式。 在Yii2.0中,使用路径映射的方式,将一个源视图文件路径,映射成一个主题化后的视图文件路径。 因此,['/web/views' => '/web/themes/basic'] 定义了一个主题映射关系, 源视图文件/web/views/site/index.php 主题化后将是 /web/themes/basic/site/index.php 。 因此, Yii1.1中的CThemeManager 也被淘汰了。
## 模型(Model)[](http://www.digpage.com/improvement.html#model "Permalink to this headline")
MVC中的M指的就是模型,Yii1.1中使用 CModel 来表示,而Yii2.0使用 yii\base\Model 来表示。
Yii1.1中, CFormModel 用来表示用户的表单输入,以区别于数据库中的表。 这在Yii2.0中也被淘汰,Yii2.0倾向于使用继承自 yii\base\Model 来表示提交的表单数据。
另外,Yii2.0为Model引入了 yii\base\Model::load() 和 yii\base\Model::loadMutiple() 两个新的方法, 用于简化将用户输入的表单数据赋值给Model:
~~~
// Yii2.0使用load()等同于下面Yii1.1的用法
$model = new Post;
if ($model->load($_POST)) {
... ...
}
// Yii1.1中常用的套路
if (isset($_POST['Post'])) {
$model->attributes = $_POST['Post'];
}
~~~
另外一个重要变化就是Yii2.0中改变了Model应用于不同场景的逻辑。通过引入yii\base\Model::scenarios() 来集中管理场景,使得一个Model所有适用的场景都比较清晰,一目了然。而Yii1.1是没有一个统一管理场景的方法的。
由此带来的一个很容易出现的问题就是,当你声明一个Model处于某一场景时,可能由于拼写错误, 不小心将场景的名称写错了,那么在Yii1.1中,这个错误的场景并没有任何的提示。假设有以下情况:
~~~
class UserForm extends CFormModel
{
public $username;
public $email;
public $password;
public $password_repeat;
public $rememberMe=false;
public function rules()
{
return array(
// username 和 password 在所有场景中都要验证
array('username, password', 'required'),
// email 和 password_repeat 只在注册场景中验证
array('email, password_repeat', 'required', 'on'=>'Registration'),
array('email', 'email', 'on'=>'Registration'),
// rememberMe 仅在登陆场景中验证
array('rememberMe', 'boolean', 'on'=>'Login'),
);
}
}
~~~
这里针对UserForm的注册和登陆两个场景,设定了不同的验证规则。接下来,你要在注册场景中使用这个UserForm, 但你一不小心将 Registration 场景设定成了 SignUp , 说实在,我不是学英文出身的,这两个单词的意思在我眼里是一样一样的。只是Yii不会智能到把这两个场景等同起来。 那么Yii1.1将不会有任何的提示,并自动地使用第一个验证规则,而用户注册时填写的 email 和password_repeat 字段就被抛弃了。这在实际编程中,是经常出现的一个低级错误。
从这里可以看到,Yii1.1中对于场景,没有一个集中统一的管理,也就是说一个Model可适用的场景, 是不确定的、任意的。通过 rules() 你很难一眼看出来一个Model可以适用于多少个场景,每个场景下都有哪些字段是有效的、需要验证的。
而在Yii2.0中,由于引入了 yii\base\Model::scenarios() 新的方法, 将本Model所有适用的场景,及不同场景下的有效字段都进行了声明, 这个逻辑就显得清晰了。而且,如果使用了一个未声明的场景,Yii2.0会有相应的提示, 这避免了上面这个低级错误的可能:
~~~
namespace app\models;
use yii\db\ActiveRecord;
class User extends ActiveRecord
{
public function scenarios()
{
return [
'login' => ['username', 'password', 'rememberMe'],
'registration' => ['username', 'email', 'password', 'password_repeat'],
];
}
}
~~~
这样看来,是不是很清晰?这个User仅有两种场景,每种场景的有效字段也一目了然。 而至于具体场景下每个字段的验证规则,仍然由 yii\base\Model::rules() 来确定。 这也意味着, unsafe 验证在Yii2.0中也没有了立足之地,凡是 unsafe 的字段,就不在特定的场景中列出来。 或者为了更加明显的表示某一字段在特定场景下是无效的,可以给这个字段加上 ! 前缀。
在默认情况下, yii\base\Model::scenarios() 所有适用的场景和对应的字段由yii\base\Model::rules() 的内容自动生成。也就是说,如果你的 rules() 很完备、很清晰,那么也是不需要重载这个 scenarios() 的。 这种情况下,Yii1.1和Yii2.0在这一点上的表现形式,是一样的。但是,个人经验看, 我更倾向于将 scenarios() 声明清楚,而在 rules() 中,仅指定字段的验证规则,而不涉及场景的内容。 这样的逻辑更加清晰,便于其他团队成员阅读你的代码,也便于后续的维护和开发。
## 控制器(Controller)[](http://www.digpage.com/improvement.html#controller "Permalink to this headline")
除了上面讲到的控制器中要使用 echo 来显示渲染视图的输出这点区别外, Yii1.1与Yii2.0的控制器还表现出更为明显的区别,那就是动作过滤器(Action Filter) 的不同。
在Yii2.0中,动作过滤器以行为(behavior)的方式出现, 一般继承自 yii\base\ActionFilter ,并注入到一个控制器中,以发生作用。比如,Yii1.1中很常见的:
~~~
public function behaviors()
{
return [
'access' => [
'class' => 'yii\filters\AccessControl',
'rules' => [
['allow' => true, 'actions' => ['admin'], 'roles' => ['@']],
],
],
];
}
~~~
看着是不是有点像,但又确实不一样?
## Active Record[](http://www.digpage.com/improvement.html#active-record "Permalink to this headline")
还记得么?在Yii1.1中,数据库查询被分散成 CDbCommand , CDbCriteria 和 CDbCommandBuilder 。 所谓天下大势分久必合,到了Yii2.0,采用 yii\db\Query 来表示数据库查询:
~~~
$query = new \yii\db\Query();
$query->select('id, name')
->from('user')
->limit(10);
$command = $query->createCommand();
$sql = $command->sql;
$rows = $command->queryAll();
~~~
最最最爽的是, yii\db\Query 可以在 Active Record中使用,而在Yii1.1中,要结合两者,并不容易。
Active Record在Yii2.0中最大的变化一个是查询的构建,另一个是关联查询的处理。
Yii1.1中的 CDbCriteria 在Yii2.0中被 yii\db\ActiveQuery 所取代, 这个把前辈拍死在沙滩上的家伙,继承自 yii\db\Query ,所以可以进行类似上面代码的查询。 调用 yii\db\ActiveRecord::find() 就可以启动查询的构建了:
~~~
$customers = Customer::find()
->where(['status' => $active])
->orderBy('id')
->all();
~~~
这在Yii1.1中,是不容易实现的。特别是比较复杂的查询关系。
在关联查询方面,Yii1.1是在一个统一的地方 relations() 定义关联关系。 而Yii2.0改变了这一做法,定义一个关联关系:
* 定义一个getter方法
* getter方法的方法名表示关联关系的名称,如 getOrders() 表示关系 orders
* getter方法中定义关联的依据,通常是外键关系
* getter返回一个 yii\db\ActiveQuery 对象
比如以下代码就定义了 Customer 的 orders 关联关系:
~~~
class Customer extends \yii\db\ActiveRecord
{
... ...
public function getOrders()
{
// 关联的依据是 Order.customer_id = Customer.id
return $this->hasMany('Order', ['customer_id' => 'id']);
}
}
~~~
这样的话,可以通过 Customer 访问关联的 Order
~~~
// 获取所有与当前 $customer 关联的 orders
$orders = $customer->orders;
// 获取所有关联 orders 中,status=1 的 orders
$orders = $customer->getOrders()->andWhere('status=1')->all();
~~~
对于关联查询,有积极的方式也有消极的方式。区别在于采用积极方式时,关联的查询会一并执行, 而消极方式时,仅在显示调用关联记录时材会执行关联的查询。
在积极方式的实现上,Yii2.0与Yii1.1也存在不同。Yii1.1使用一个JOIN查询, 来实现同时查询主记录及其关联的记录。 而Yii2.0弃用JOIN查询的方式,而使用两个顺序的SQL语句, 第一个语句查询主记录,第二个语句根据第一个语句的返回结果进行过滤。
同时,Yii2.0为Active Record引入了 asArray() 方法。在返回大量记录时,可以以数组形式保存, 而不再以对象形式保存,这样可以节约大量的空间,提高效率。
另外一个变化是,在Yii1.1中,字段的默认值可以通过为类的public 成员变量赋初始值来指定。 而在Yii2.0中,这样的方式是行不通的,必须通过重载 init() 成员函数的方式实现了。
Yii2.0还淘汰掉了原来的 CActiveRecordBehavior 类。在Yii2.0中,将行为与类进行绑定采用了统一的方式进行, 具体请参考 [_行为(Behavior)_](http://www.digpage.com/behavior.html#behavior) 的有关内容。
Yii2.0中,ActiveRecord得到极大的加强,在相关的章节中我们已经进行专门的讲解。
这里的内容主要是点一点Yii2.0之于Yii1.1的变化。大致了解下就可以了, 主要还是要看正文专门针对每个知识点的深入讲解。
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
附录
最后更新于:2022-04-01 02:20:29
第三讲:文章及评论的模型
最后更新于:2022-04-01 02:20:27
第二讲:用户登录
最后更新于:2022-04-01 02:20:25
第一讲:基础配置
最后更新于:2022-04-01 02:20:22
《深入理解Yii2.0》视频教程
最后更新于:2022-04-01 02:20:20
视频教程主要演示使用Yii2开发一个具有前台、后台、API平台的典型网站的过程。
请切换到高清或超清模式进行播放,以免看不清代码。
视频中涉及的源代码托管在 [GitHub](https://github.com/linuor/digpage) 。
乐观锁与悲观锁
最后更新于:2022-04-01 02:20:18
Web应用往往面临多用户环境,这种情况下的并发写入控制, 几乎成为每个开发人员都必须掌握的一项技能。
在并发环境下,有可能会出现脏读(Dirty Read)、不可重复读(Unrepeatable Read)、 幻读(Phantom Read)、更新丢失(Lost update)等情况。具体的表现可以自行搜索。
为了应对这些问题,主流数据库都提供了锁机制,并引入了事务隔离级别的概念。 这里我们都不作解释了,拿这些关键词一搜,网上大把大把的。
但是,就于具体开发过程而言,一般分为悲观锁和乐观锁两种方式来解决并发冲突问题。
## 乐观锁[](http://www.digpage.com/lock.html#id2 "Permalink to this headline")
乐观锁(optimistic locking)表现出大胆、务实的态度。使用乐观锁的前提是, 实际应用当中,发生冲突的概率比较低。他的设计和实现直接而简洁。 目前Web应用中,乐观锁的使用占有绝对优势。
因此,Yii也为ActiveReocrd提供了乐观锁支持。
根据Yii的官方文档,使用乐观锁,总共分4步:
* 为需要加锁的表增加一个字段,用于表示版本号。 当然相应的Model也要为该字段的加入,作出适当调整。比如, rules() 中要加入该字段。
* 重载 yii\db\ActiveRecord::optimisticLock() 方法,返回上一步中的字段名。
* 在记录的修改页面表单中,加入一个 type="hidden"> 用于暂存读取时的记录的版本号。
* 在保存代码的地方,使用 try ... catch 看看是否能捕获一个 yii\db\StaleObjectException 异常。如果是,说明在本次修改这个记录的过程中, 该记录已经被修改过了。简单应对的话,可以作出相应提示。智能点的话, 可以合并不冲突的修改,或者显示一个diff页面。
从本质上来讲,乐观锁并没有像悲观锁那样使用数据库的锁机制。 乐观锁通过在表中增加一个计数字段,来表示当前记录被修改的次数(版本号)。
然后在更新、删除前通过比对版本号来实现乐观锁。
### 声明版本号字段[](http://www.digpage.com/lock.html#id3 "Permalink to this headline")
版本号是实现乐观锁的根本所在。所以第一步,我们要告诉Yii,哪个字段是版本号字段。 这个由yii\db\BaseActiveRecord 负责:
~~~
public function optimisticLock()
{
return null;
}
~~~
这个方法返回 null ,表示不使用乐观锁。那么我们的Model中,要对此进行重载。 返回一个字符串,表示我们用于标识版本号的字段。比如可以这样:
~~~
public function optimisticLock()
{
return 'ver';
}
~~~
说明当前的ActiveRecord中,有一个 ver 字段,可以为乐观锁所用。 那么Yii具体是如何借助这个ver 字段实现乐观锁的呢?
### 更新过程[](http://www.digpage.com/lock.html#id4 "Permalink to this headline")
具体来讲,使用乐观锁之后的更新过程,就是这么一个流程:
* 读取要更新的记录。
* 对记录按照用户的意愿进行修改。当然,这个时候不会修改 ver 字段。 这个字段对用户是没意义的。
* 在保存记录前,再次读取这个记录的 ver 字段,与之前读取的值进行比对。
* 如果 ver 不同,说明在用户修改过程中,这个记录被别人改动过了。那么, 我们要给出提示。
* 如果 ver 相同,说明这个记录未被修改过。那么,对 ver +1, 并保存这个记录。这样子就完成了记录的更新。同时,该记录的版本号也加了1。
由于ActiveRecord的更新过程最终都需要调用 yii\db\BaseActiveRecord::updateInteranl() ,理所当然地,处理乐观锁的代码, 也就隐藏在这个方法中:
~~~
protected function updateInternal($attributes = null)
{
if (!$this->beforeSave(false)) {
return false;
}
// 获取等下要更新的字段及新的字段值
$values = $this->getDirtyAttributes($attributes);
if (empty($values)) {
$this->afterSave(false, $values);
return 0;
}
// 把原来ActiveRecord的主键作为等下更新记录的条件,
// 也就是说,等下更新的,最多只有1个记录。
$condition = $this->getOldPrimaryKey(true);
// 获取版本号字段的字段名,比如 ver
$lock = $this->optimisticLock();
// 如果 optimisticLock() 返回的是 null,那么,不启用乐观锁。
if ($lock !== null) {
// 这里的 $this->$lock ,就是 $this->ver 的意思;
// 这里把 ver+1 作为要更新的字段之一。
$values[$lock] = $this->$lock + 1;
// 这里把旧的版本号作为更新的另一个条件
$condition[$lock] = $this->$lock;
}
$rows = $this->updateAll($values, $condition);
// 如果已经启用了乐观锁,但是却没有完成更新,或者更新的记录数为0;
// 那就说明是由于 ver 不匹配,记录被修改过了,于是抛出异常。
if ($lock !== null && !$rows) {
throw new StaleObjectException('The object being updated is outdated.');
}
$changedAttributes = [];
foreach ($values as $name => $value) {
$changedAttributes[$name] = isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null;
$this->_oldAttributes[$name] = $value;
}
$this->afterSave(false, $changedAttributes);
return $rows;
}
~~~
从上面的代码中,我们不难得出:
* 当 optimisticLock() 返回 null 时,乐观锁不会被启用。
* 版本号只增不减。
* 通过乐观锁的条件有2个,一是主键要存在,二是要能够完成更新。
* 当启用乐观锁后,只有下列两种情况会抛出 StaleObjectException 异常:
* 当记录在被别人删除后,由于主键已经不存在,更新失败。
* 版本号已经变更,不满足更新的第二个条件。
### 删除过程[](http://www.digpage.com/lock.html#id5 "Permalink to this headline")
与更新过程相比,删除过程的乐观锁,更简单,更好理解。代码仍在 yii\db\BaseActiveRecord 中:
~~~
public function delete()
{
$result = false;
if ($this->beforeDelete()) {
// 删除的SQL语句中,WHERE部分是主键
$condition = $this->getOldPrimaryKey(true);
// 获取版本号字段的字段名,比如 ver
$lock = $this->optimisticLock();
// 如果启用乐观锁,那么WHERE部分再加一个条件,版本号
if ($lock !== null) {
$condition[$lock] = $this->$lock;
}
$result = $this->deleteAll($condition);
if ($lock !== null && !$result) {
throw new StaleObjectException('The object being deleted is outdated.');
}
$this->_oldAttributes = null;
$this->afterDelete();
}
return $result;
}
~~~
比起更新过程,删除过程确实要简单得多。唯一的区别就是省去了版本号+1的步骤。 都要删除了,版本号+1有什么意义?
## 乐观锁失效[](http://www.digpage.com/lock.html#id6 "Permalink to this headline")
乐观锁存在失效的情况,属小概率事件,需要多个条件共同配合才会出现。如:
* 应用采用自己的策略管理主键ID。如,常见的取当前ID字段的最大值+1作为新ID。
* 版本号字段 ver 默认值为 0 。
* 用户A读取了某个记录准备修改它。该记录正好是ID最大的记录,且之前没被修改过, ver 为默认值 0。
* 在用户A读取完成后,用户B恰好删除了该记录。之后,用户C又插入了一个新记录。
* 此时,阴差阳错的,新插入的记录的ID与用户A读取的记录的ID是一致的, 而版本号两者又都是默认值 0。
* 用户A在用户C操作完成后,修改完成记录并保存。由于ID、ver均可以匹配上, 因此用户A成功保存。但是,却把用户C插入的记录覆盖掉了。
乐观锁此时的失效,根本原因在于应用所使用的主键ID管理策略, 正好与乐观锁存在极小程度上的不兼容。
两者分开来看,都是没问题的。组合到一起之后,大致看去好像也没问题。 但是bug之所以成为bug,坑之所以能够坑死人,正是由于其隐蔽性。
对此,也有一些意见提出来,使用时间戳作为版本号字段,就可以避免这个问题。 但是,时间戳的话,如果精度不够,如毫秒级别,那么在高并发,或者非常凑巧情况下, 仍有失效的可能。而如果使用高精度时间戳的话,成本又太高。
使用时间戳,可靠性并不比使用整型好。问题还是要回到使用严谨的主键成生策略上来。
## 悲观锁[](http://www.digpage.com/lock.html#id7 "Permalink to this headline")
正如其名字,悲观锁(pessimistic locking)体现了一种谨慎的处事态度。其流程如下:
* 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。
* 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。 具体响应方式由开发者根据实际需要决定。
* 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
* 其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。
悲观锁确实很严谨,有效保证了数据的一致性,在C/S应用上有诸多成熟方案。 但是他的缺点与优点一样的明显:
* 悲观锁适用于可靠的持续性连接,诸如C/S应用。 对于Web应用的HTTP连接,先天不适用。
* 锁的使用意味着性能的损耗,在高并发、锁定持续时间长的情况下,尤其严重。 Web应用的性能瓶颈多在数据库处,使用悲观锁,进一步收紧了瓶颈。
* 非正常中止情况下的解锁机制,设计和实现起来很麻烦,成本还很高。
* 不够严谨的设计下,可能产生莫名其妙的,不易被发现的, 让人头疼到想把键盘一巴掌碎的死锁问题。
总体来看,悲观锁不大适应于Web应用,Yii团队也认为悲观锁的实现过于麻烦, 因此,ActiveRecord也没有提供悲观锁。
作为Yii的构成基因之一的Ruby on rails,他的ActiveReocrd模型,倒是提供了悲观锁, 但是使用起来也很麻烦。
## 悲观锁的实现[](http://www.digpage.com/lock.html#id8 "Permalink to this headline")
虽然悲观锁在Web应用上存在诸多不足,实现悲观锁也需要解决各种麻烦。但是, 当用户提出他就是要用悲观锁时,牙口再不好的码农,就是咬碎牙也是要啃下这块骨头来。
对于一个典型的Web应用而言,这里提供个人常用的方法来实现悲观锁。
首先,在要锁定的表里,加一个字段如 locked_at ,表示当前记录被锁定时的时间, 当为 0 时,表示该记录未被锁定,或者认为这是1970年时加的锁。
当要修改某个记录时,先看看当前时间与 locked_at 字段相差是否超过预定的一个时长T,比如 30 min ,1 h 之类的。
如果没超过,说明该记录有人正在修改,我们暂时不能打开(读取)他来修改。 否则,说明可以修改,我们先将当前时间戳保存到该记录的 locked_at 字段。 那么之后的时长T内如果有人要来改这个记录,他会由于加锁失败而无法读取, 从而无法修改。
我们在完成修改后,即将保存时,要比对现在的 locked_at 。只有在 locked_at 一致时,才认为刚刚是我们加的锁,我们才可以保存。 否则,说明在我们加锁后,又有人加了锁正在修改, 或者已经完成了修改,使得 locked_at 归 0。
这种情况主要是由于我们的修改时长过长,超过了预定的T。原先的加锁自动解开, 其他用户可以在我们加锁时刻再过T之后,重新加上自己的锁。换句话说, 此时悲观锁退化为乐观锁。
大致的原理性代码如下:
~~~
// 悲观锁AR基类,需要使用悲观锁的AR可以由此派生
class PLockAR extends \yii\db\BaseActiveRecord {
// 声明悲观锁使用的标记字段,作用类似于 optimisticLock() 方法
public function pesstimisticLock() {
return null;
}
// 定义锁定的最大时长,超过该时长后,自动解锁。
public function maxLockTime() {
return 0;
}
// 尝试加锁,加锁成功则返回true
public function lock() {
$lock = $this->pesstimisticLock();
$now = time();
$values = [$lock => $now];
// 以下2句,更新条件为主键,且上次锁定时间距现在超过规定时长
$condition = $this->getOldPrimaryKey(true);
$condition[] = ['<', $lock, $now - $this->maxLockTime()];
$rows = $this->updateAll($values, $condition);
// 加锁失败,返回 false
if (! $rows) {
return false;
}
return true;
}
// 重载updateInternal()
protected function updateInternal($attributes = null)
{
// 这些与原来代码一样
if (!$this->beforeSave(false)) {
return false;
}
$values = $this->getDirtyAttributes($attributes);
if (empty($values)) {
$this->afterSave(false, $values);
return 0;
}
$condition = $this->getOldPrimaryKey(true);
// 改为获取悲观锁标识字段
$lock = $this->pesstimisticLock();
// 如果 $lock 为 null,那么,不启用悲观锁。
if ($lock !== null) {
// 等下保存时,要把标识字段置0
$values[$lock] = 0;
// 这里把原来的标识字段值作为更新的另一个条件
$condition[$lock] = $this->$lock;
}
$rows = $this->updateAll($values, $condition);
// 如果已经启用了悲观锁,但是却没有完成更新,或者更新的记录数为0;
// 那就说明之前的加锁已经自动失效了,记录正在被修改,
// 或者已经完成修改,于是抛出异常。
if ($lock !== null && !$rows) {
throw new StaleObjectException('The object being updated is outdated.');
}
$changedAttributes = [];
foreach ($values as $name => $value) {
$changedAttributes[$name] = isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null;
$this->_oldAttributes[$name] = $value;
}
$this->afterSave(false, $changedAttributes);
return $rows;
}
}
~~~
上面的代码对比乐观锁,主要不同点在于:
* 新增加了一个加锁方法,一个获取锁定最大时长的方法。
* 保存时不再是把标识字段+1,而是把标识字段置0。
在具体使用方法上,可以参照以下代码:
~~~
// 从PLockAR派生模型类
class Post extends PLockAR {
// 重载定义悲观锁标识字段,如 locked_at
public function pesstimisticLock() {
return 'locked_at';
}
// 重载定义最大锁定时长,如1小时
public function maxLockTime() {
return 3600000;
}
}
// 修改前要尝试加锁
class SectionController extends Controller {
public function actionUpdate($id)
{
$model = $this->findModel($id);
if ($model->load(Yii::$app->request->post()) && $model->save()) {
return $this->redirect(['view', 'id' => $model->id]);
} else {
// 加入一个加锁的判断
if (!$model->lock()) {
// 加锁失败
// ... ...
}
return $this->render('update', [
'model' => $model,
]);
}
}
}
~~~
上述方法实现的悲观锁,避免了使用数据库自身的锁机制,契合Web应用的特点, 具有一定的适用性,但是也存在一定的缺陷:
* 最长允许锁定时长会带来一定的副作用。时间定得长了,可能要等很长时间, 才能重新编辑非正常解锁的记录。时间定得短了,则经常退化成乐观锁。
* 时间戳精度问题。如果精度不够,那么在加锁时,与我们讨论过的乐观锁失效存, 在同样的漏洞。
* 这种形式的锁定,只是应用层面的锁定,并非数据库层面的锁定。 如果存在应用之外对于数据库的写入操作。这个锁定机制是无效的。
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
AcitveReocrd事件和关联操作
最后更新于:2022-04-01 02:20:15
ActiveRecord预定义的事件,都在 yiidbBaseActiveRecord 中进行了明确:
~~~
abstract class BaseActiveRecord extends Model implements ActiveRecordInterface
{
const EVENT_INIT = 'init'; // 初始化对象时触发
const EVENT_AFTER_FIND = 'afterFind'; // 执行查询结束时触发
const EVENT_BEFORE_INSERT = 'beforeInsert'; // 插入结束时触发
const EVENT_AFTER_INSERT = 'afterInsert'; // 插入之前触发
const EVENT_BEFORE_UPDATE = 'beforeUpdate'; // 更新记录前触发
const EVENT_AFTER_UPDATE = 'afterUpdate'; // 更新记录后触发
const EVENT_BEFORE_DELETE = 'beforeDelete'; // 删除记录前触发
const EVENT_AFTER_DELETE = 'afterDelete'; // 删除记录后触发
// ... ...
}
~~~
上述常量,定义了ActiveRecord对象常用的几个事件。这是预定义事件,我们可以直接拿来 用。事件的定义具体看 [_事件(Event)_](http://www.digpage.com/event.html#event) 部分的内容。
此外,作为ActiveRecord类的祖宗, yiibaseModel 类也定义了2个事件:
~~~
class Model extends Component implements IteratorAggregate, ArrayAccess, Arrayable
{
const EVENT_BEFORE_VALIDATE = 'beforeValidate'; // 在验证之前触发
const EVENT_AFTER_VALIDATE = 'afterValidate'; // 在验证之后触发
// ... ...
}
~~~
因此,上述总共10个事件,可供开发者在写入业务流程时使用。
从上述事件来看,可以看出大部分事件是分别以before和after打头的成对事件。 有些是“读”操作时才会触发的事件,有些是“写”操作时发生的事件。
而且,“写”与“写”之间也是相互区别的。比如,增、改、删3个写操作, 都各自有一对事件先后在不同场景下触发。但这3种“写”操作不会被同时触发。
## 初始化事件[](http://www.digpage.com/active_record.html#id1 "Permalink to this headline")
首先,第一个事件,无可争议的,是 EVENT_INIT 。这是由 yii\base\Object 所决定的。该事件在init() 方法中被触发。而我们在 [_属性(Property)_](http://www.digpage.com/property.html#property) 中已经说过这个方法是最早调用的几个方法之一。具体代码:
~~~
public function init()
{
parent::init();
// 这里触发EVENT_INIT事件
$this->trigger(self::EVENT_INIT);
}
~~~
虽然这个事件触发得早,但是实际使用中,这个事件使用频率不高。 仅是因为有的代码不得不在初始化阶段执行,所以才提供了这个事件。 而且,这个事件由于所处阶段特殊,不像有的事件,可以有一定的替代性。
比如, EVENT_AFTER_VALIDATE 和 EVENT_BEFORE_UPDATE 尽管泾渭分明, 但是由于是相继触发,所以某些情况下可以在一定程度上互相替代。但是, 上述10个事件中,仅有 EVENT_INIT 是在初始化阶段触发。所以,其具有不可替代性。
EVENT_INIT 事件通常用于初始化一些东西,从模块化的角度, 可以简单看成是将当前类的 init()方法的内容, 作为一个Event Handler单独划分为一个模块。
## AfterFind事件[](http://www.digpage.com/active_record.html#afterfind "Permalink to this headline")
EVENT_AFTER_FIND 事件在完成查询后触发,注意该事件少有地没有对应的Before事件。
另外一个区别于其他事件的不同在于,该事件并非由 ActiveRecord 自身触发。 而是由yii\db\ActiveQuery 触发。准确的触发时点,是在查询完成后, 向ActiveRecord填充字段全部内容后触发。
具体代码在 yii\db\ActiveQuery::populate()
~~~
// 该方法为ActiveQuery将查询到的内容 $rows 填充到ActiveReocrd中去的方法
public function populate($rows)
{
if (empty($rows)) {
return [];
}
$models = $this->createModels($rows);
if (!empty($this->join) && $this->indexBy === null) {
$models = $this->removeDuplicatedModels($models);
}
if (!empty($this->with)) {
$this->findWith($this->with, $models);
}
if (!$this->asArray) {
// 重点在这个foreach里面的afterFind(),
// afterFind()不干别的,就是专门调用
// $this->trigger(self::EVENT_AFTER_FIND) 来触发事件的。
foreach ($models as $model) {
$model->afterFind();
}
}
return $models;
}
~~~
上面的代码我们可以看出,在完成查询之后,查询到了多少个记录, 就会触发多少次实例级别的EVENT_AFTER_FIND 事件。 事件的级别,请看 [_事件(Event)_](http://www.digpage.com/event.html#event) 部分的内容。
EVENT_AFTER_FIND 事件,用于查询后一些内容的填充。比如,有一个ActiveRecord, 专门用于表示博客文章,那么通常他有一个字段,用于表示发布的确切时间。
假设客户希望在前台显示文章时,不直接显示确切时间,而是显示如“3分钟前” “2个月前”之类的相对时间。那么,我们就需要有一个将绝对时间转化成相对时间的过程。
那么,就可以把这个转换过程的代码,写在 EVENT_AFTER_FIND 事件的Event Handler里。
## 验证事件[](http://www.digpage.com/active_record.html#id2 "Permalink to this headline")
验证事件是在验证时先后触发的2个事件,这2个事件均由 yii\base\Model::validate 触发:
~~~
public function validate($attributeNames = null, $clearErrors = true)
{
if ($clearErrors) {
$this->clearErrors();
}
// 这里的 beforeValidate() 会调用
// $this->trigger(self::EVENT_BEFORE_VALIDATE, $event)
// 来触发 EVENT_BEFORE_VALIDATE 事件。
if (!$this->beforeValidate()) {
return false;
}
// 下面是后续的验证代码,这里不用过多关注
$scenarios = $this->scenarios();
$scenario = $this->getScenario();
if (!isset($scenarios[$scenario])) {
throw new InvalidParamException("Unknown scenario: $scenario");
}
if ($attributeNames === null) {
$attributeNames = $this->activeAttributes();
}
foreach ($this->getActiveValidators() as $validator) {
$validator->validateAttributes($this, $attributeNames);
}
// 这里的 afterValidate() 会调用
// $this->trigger(self::EVENT_AFTER_VALIDATE)
// 来触发 EVENT_AFTER_VALIDATE 事件。
$this->afterValidate();
return !$this->hasErrors();
}
~~~
这两个事件正如其名称所表示的,触发顺序为先 EVENT_BEFORE_VALIDATE 后 EVENT_AFTER_VALIDATE 。
这两个事件中, EVENT_BEFORE_VALIDATE 常用于验证前的一些规范化处理。 仍以博客文章的发布时间字段为例,在接收用户输入时, 我们的应用接收一个字符类似“2015年3月8日”之类的字符串。
但是数据库中我们一般并不以字符串形式保存时间,而是使用一个整型字段来保存。 这主要涉及存储空间,日期比较和排序,检索效率等数据库优化问题,具体不展开。 反正我们就是想把时间以整型形式进行保存。
那么,在验证用户输入之前,我们就需要将字符串类型的日期时间, 转换成整型类型的日期时间。否则的话,验证就通不过。
这个转换过程,就可以写在 EVENT_BEFORE_VALIDATE 的 Event Handler里面。
EVENT_BEFORE_VALIDATE 还有一个比较吸引人的特性, 它的Event Handler可以返回一个 boolean 值,当为 false 时, 表示后续的验证没必要进行了:
~~~
public function beforeValidate()
{
// 创建一个 ModelEvent,并交给 trigger 在触发事件时使用
$event = new ModelEvent;
$this->trigger(self::EVENT_BEFORE_VALIDATE, $event);
return $event->isValid;
}
~~~
上面的代码中, trigger() 将传入的第二个 $event 传递给 Event Handler, 使得相关的这些个 Event Handler 可以在必要时修改 $event->isValid 的值。 以此来决定是否可以取消后续的验证,直接视为验证没有通过。
EVENT_AFTER_VALIDATE 通常用于用户输入验证后的一些处理。比如, 用于写入操作前的一些通用处理。因为后头接下来的事件, 会分成插入、更新等独立事件。如果有一些写入前的通用处理,放在EVENT_AFTER_VALIDATE 阶段是比较合适的。
至于验证通过与否,与 EVENT_AFTER_VALIDATE 事件没有关系,只要执行完所有验证了, 这个事件就会被触发。而且,该事件的Event Handler没有返回值,无法干预验证结果。
## “写”事件[](http://www.digpage.com/active_record.html#id3 "Permalink to this headline")
“写”事件是指插入、更新、删除等写入操作时触发的事件。一般情况下, 验证事件先于“写”事件被触发。
但这不是绝对的。Yii允许在执行“写”操作时,不调用 validate() 进行验证, 也就不触发验证事件。
下面,我们以更新操作update为例,来分析“写”事件。
首先,来看看 yii\db\BaseActiveRecord 里的有关代码:
~~~
public function save($runValidation = true, $attributeNames = null)
{
// insert() 和 update() 具体实现由ActiveRecord定义
if ($this->getIsNewRecord()) {
return $this->insert($runValidation, $attributeNames);
} else {
return $this->update($runValidation, $attributeNames) !== false;
}
}
// updateInternal() 由 update() 调用,
// 类似的有deleteInternal() ,由ActiveRecord定义,这里略去。
protected function updateInternal($attributes = null)
{
// beforeSave() 会触发相应的before事件
// 而且如果beforeSave()返回false,就可以中止更新过程。
if (!$this->beforeSave(false)) {
return false;
}
$values = $this->getDirtyAttributes($attributes);
// 没有字段有修改,那么实际上是不需要更新的。
// 因此,直接调用afterSave()来触发相应的after事件。
if (empty($values)) {
$this->afterSave(false, $values);
return 0;
}
// 以下为实际更新操作,不必细究。
$condition = $this->getOldPrimaryKey(true);
$lock = $this->optimisticLock();
if ($lock !== null) {
$values[$lock] = $this->$lock + 1;
$condition[$lock] = $this->$lock;
}
$rows = $this->updateAll($values, $condition);
if ($lock !== null && !$rows) {
throw new StaleObjectException('The object being updated is outdated.');
}
$changedAttributes = [];
foreach ($values as $name => $value) {
$changedAttributes[$name] = isset($this->_oldAttributes[$name]) ? $this->_oldAttributes[$name] : null;
$this->_oldAttributes[$name] = $value;
}
// 重点看这里,触发了事件
$this->afterSave(false, $changedAttributes);
return $rows;
}
// 下面的beforeSave()和afterSave() 会根据判断是更新操作还是插入操作,
// 以此来决定是触发 INSERT 事件还是 UPDATE 事件。
public function beforeSave($insert)
{
$event = new ModelEvent;
// $insert 为 true 时,表示当前是插入操作,是个新记录,要触发INSERT事件
// $insert 为 false时,表示当前是插入操作,是个新记录,要触发INSERT事件
$this->trigger($insert ? self::EVENT_BEFORE_INSERT : self::EVENT_BEFORE_UPDATE, $event);
return $event->isValid;
}
public function afterSave($insert, $changedAttributes)
{
// $insert 为 true 时,表示当前是插入操作,是个新记录,要触发INSERT事件
// $insert 为 false时,表示当前是插入操作,是个新记录,要触发INSERT事件
$this->trigger($insert ? self::EVENT_AFTER_INSERT : self::EVENT_AFTER_UPDATE, new AfterSaveEvent([
'changedAttributes' => $changedAttributes
]));
}
~~~
就“写”操作而言,表面上调用的是 ActiveRecord 的 update() insert() delete() 等方法。
但是,更新最终调用的是 BaseActiveRecord::updateInteranl() , 插入最终调用的是ActiveRecord::insertInternal() , 而删除最终调用的是 ActiveRecord::deleteInternal() 。
这些 internal 方法,会触发相应的“写”事件,但不会调用验证方法, 也不会触发验证事件。验证方法 validation() 由 update() insert() 调用。 因此,验证事件也由这两个方法触发。
而且,这些 update() insert() 可以选择不进行验证,在压根不触发验证事件的情况下,就可以完成“写”操作。
因此,虽然 EVENT_AFTER_VALIDATE 和 EVENT_BEFORE_UPDATE 相继发生, 在使用上有时可以有一定程度的替代。但是,其实两者是有严格界限的。 原因就是验证事件可能在“写”操作过程中不被触发。
此外,删除过程不触发验证事件。都要删掉的东西了,还需要验证么?
对于 internal 方法们,只是触发了相应的before和after“写”事件。
其中,before事件的Event Handler可以通过将 $event->isValid 设为 false 来中止“写”操作。
与在验证事件阶段中止时,视为验证没通过不同,这里的中止视为“写”操作失败。
与验证事件阶段类似,after事件时由于生米已成熟饭,再也无法干预“写”操作的结果。
## 响应事件[](http://www.digpage.com/active_record.html#id4 "Permalink to this headline")
前面提到的诸多预定义事件,为我们开发提供了方便。基本上使用这些预定义事件, 就可以满足各种开发需求了。
但是凡事总有例外,特别是对于业务逻辑复杂的情况。 比如,默认的删除事件,会在确确实实地要从数据表中删除记录时触发。 但是,有的时候,我们并非真的想从数据表中删除记录,我们可能使用一个类似于“状态” 的字段,在想要删除时,只是将记录的“状态”标记为“删除”。
这种需求并不少见。这样便于我们在后悔时,可以“恢复”删除。
从实质上是看,这其实是一个更新操作。那么预定义的 EVENT_BEFORE_DELETE 和 EVENT_AFTER_DELETE就不适用了。
对此,我们可以自己定义事件来使用。具体的方法可以参见 [_事件(Event)_](http://www.digpage.com/event.html#event) 部分的内容。
大致的代码可以是这样的:
~~~
class Post extends \yii\db\ActiveRecord {
// 第一步:定义自己的事件
const EVENT_BEFORE_MARK_DELETE = 'beforeMarkDelete';
const EVENT_AFTER_MARK_DELETE = 'afterMarkDelete';
// 第二步:定义Event Handler
public function onBeforeMarkDelete () {
// ... do sth ...
}
// 第三步:在初始化阶段绑定事件和Event Handler
public function init()
{
parent::init();
$this->trigger(self::EVENT_INIT);
// 完成绑定
$this->on(self::EVENT_BEFORE_MARK_DELETE, [$this, 'onBeforeMarkDelete']);
}
// 第四步:触发事件
public function beforeSave($insert) {
// 注意,重载之后要调用父类同名函数
if (parent::beforeSave($insert)) {
$status = $this->getDirtyAttributes(['status']);
// 这个判断意会即可
if (!empty($status) && $status['status'] == self::STATUS_DELETE) {
// 触发事件
$this->trigger(self::EVENT_BEFORE_MARK_DELETE);
}
return true;
} else {
return false;
}
}
}
~~~
上面的代码理解个大致流程就OK了,不用细究。
在事件的响应上,我们有2个方法来写入我们的代码。
最直观的方式,是使用 [_事件(Event)_](http://www.digpage.com/event.html#event) 中介绍的 Event Handler。也就是上面代码展现的, 为类定义一个成员函数,作为Event Handler。同时,在类的构造函数或初始化方法中, 把事件和Event Handler绑定起来。最后,在合适的时候,触发事件即可。
另一种方式,是直接重载上面多次提到的各种 beforeSave() afterSave() beforeValidate()afterValidate() 等方法。比如,上面的例子可以改成:
~~~
class Post extends \yii\db\ActiveRecord {
// 不需要定义自己的事件
//const EVENT_BEFORE_MARK_DELETE = 'beforeMarkDelete';
//const EVENT_AFTER_MARK_DELETE = 'afterMarkDelete';
// 不需要定义Event Handler
//public function onBeforeMarkDelete () {
// ... do sth ...
//}
// 不需要绑定事件和Event Handler
//public function init()
//{
// parent::init();
// $this->trigger(self::EVENT_INIT);
// $this->on(self::EVENT_BEFORE_MARK_DELETE, [$this, 'onBeforeMarkDelete']);
//}
// 只需要重载
public function beforeSave($insert) {
// 注意,重载之后要调用父类同名函数
if (parent::beforeSave($insert)) {
$status = $this->getDirtyAttributes(['status']);
// 这个判断意会即可
if (!empty($status) && $status['status'] == self::STATUS_DELETE) {
// 不需要触发事件
//$this->trigger(self::EVENT_BEFORE_MARK_DELETE);
// 但是需要把原来 Event Handler的内容放到这里来
// ... do sth ...
}
return true;
} else {
return false;
}
}
}
~~~
对比来看,重载 beforeSave() 的方式要简洁很多。但是这种方式从严格意义上来讲, 并不是正规的事件处理机制。只不过是利用了Yii已经预先定义好的函数调用流程。 在使用中,需要格外注意的是,一定要在重载的函数中,调用父类的同名函数。否则的话, trigger() 不再被自动调用,相关事件就不会再被触发。整个类的事件机制, 就全被破坏了。
## 关联操作[](http://www.digpage.com/active_record.html#id5 "Permalink to this headline")
在实际开发中,有一种典型的场景,即对数据库某个表的某个记录进行修改时,需要对关联 的表中的相关记录做相应的修改。
比如,一个典型的博客,表示文章的数据表中有一个字段用于记录当前文章有多少条评论。 那么,当用户发表新评论时,另一个用于表示评论的表中,理所当然地要插入一条新记录。 不可避免的,文章表中,被评论文章所对应的记录,其评论计数字段应当加1。
那么这一过程怎么编程实现呢?
最直白的方法,是在操作评论记录的代码之前(后),写入相应的增加文章评论计数的代码 。 这样好理解,但是不同功能代码的界限不清晰。
另一种方法,是借助事件(Event),将增加文章评论计数的代码,写到 评论ActiveReocrd的相应Event Handler中。比如, EVENT_AFTER_INSERT 。
这样子代码功能界限清晰,便于查找、修改和扩展。 缺点是可能需要多看几个方法才能了解整个业务流程。实际中我们多采用这种方法。
在实现数据库记录的关联操作时,第一步就是要利用上述的各种事件,来产生关联性。 其次,是要把这些关联性绑死在一起。也就是用数据库的事务。具体的原理, 参考 [_事务(Transaction)_](http://www.digpage.com/transaction.html#transaction) 部分的内容。
下面,我们以上面提到的博客文章新增一个评论为例,讲解如何实现关联操作。
### 声明需要事务支持的操作[](http://www.digpage.com/active_record.html#id6 "Permalink to this headline")
在ActiveRecord中有一个方法,用于告诉Yii我们的哪些操作需要事务支持。对于插入、 更新、删除的1个或多个操作需要事务支持时,可以在这个方法中进行声明。 这个方法就是ActiveRecord::transactions()
~~~
class ActiveRecord extends BaseActiveRecord
{
// 定义插入、更新、删除操作,及表示3合1的ALL
const OP_INSERT = 0x01;
const OP_UPDATE = 0x02;
const OP_DELETE = 0x04;
const OP_ALL = 0x07;
// 需要事务支持时,重载这个方法。
public function transactions()
{
return [];
}
// ... ...
}
~~~
默认情况下,这个 transactions() 返回一个空数组,表示不需要任何的事务支持。
我们的博客文章增加评论的案例中是要用到的,那么,我们可以在评论模型 Comment 中,作如下声明:
~~~
public function transactions() {
return [
'addComment' => self::OP_INSERT,
];
}
~~~
这个方法所返回的数组中,元素的键,如上面的 addComment 表示场景(scenario), 元素值,表示的是操作的类型,即 OP_INSERT 等。
ActiveRecord定义了3种可能会用到事务支持的操作 OP_INSERT OP_UPDATE OP_DELETE 分别表示插入、更新、删除。
可以把这3个操作两两组合作为 transactions() 所返回数组元素的值。 如,self::OP_INSERT|self::OP_UPDATE `` 表示插入和更新操作。 也可以直接使用 ``OP_ALL 表示3种操作都包含。
### 启用事务[](http://www.digpage.com/active_record.html#id7 "Permalink to this headline")
上一步中的 transactions() 被 ActiveRecord::isTransactional() 所调用:
~~~
// $operation就是预定义的OP_INSERT 等3种单一操作类型
public function isTransactional($operation)
{
// 获取当前的scenario
$scenario = $this->getScenario();
$transactions = $this->transactions();
return isset($transactions[$scenario]) && ($transactions[$scenario] & $operation);
}
~~~
这个 isTransactional() 就是判断当前场景下,当前操作类型,是否已经在 transactions() 中声明为需要事务支持。
而这个 isTransactional() 又被各种“写”操作方法所调用。 在我们的博客文章新增评论的案例中,就是被 insert() 所调用:
~~~
public function insert($runValidation = true, $attributes = null)
{
if ($runValidation && !$this->validate($attributes)) {
Yii::info('Model not inserted due to validation error.', __METHOD__);
return false;
}
// 这里调用了 isTransactional(),判断当前场景下,
// 插入操作是否需要事务支持
if (!$this->isTransactional(self::OP_INSERT)) {
// 无需事务支持,那就直接insert了事
return $this->insertInternal($attributes);
}
// 以下是需要事务支持的情况,那就启用事务
$transaction = static::getDb()->beginTransaction();
try {
$result = $this->insertInternal($attributes);
if ($result === false) {
$transaction->rollBack();
} else {
$transaction->commit();
}
return $result;
} catch (\Exception $e) {
$transaction->rollBack();
throw $e;
}
}
~~~
很明显的,我们只需要在 transactions() 中声明需要事务支持的操作就足够了。 后续的怎么使声明生效的,Yii框架已经替我们写好了。
在上面 insert() 的代码中,通过我们的声明,Yii发现需要事务支持, 于是就调用了static::getDb()->beginTransaction() 来启用事务。 事务的原理,请看 [_事务(Transaction)_](http://www.digpage.com/transaction.html#transaction) 部分的内容。
### 在事件响应中写入关联操作[](http://www.digpage.com/active_record.html#id8 "Permalink to this headline")
接下来,我们在关联的事件,如 EVENT_AFTER_INSERT 中,写入关联操作。 这里,我们就是要更新博客文章模型 Post 的评论计数字段。
因此,可以在评论模型 Comment 完成插入后的 EVENT_AFTER_INSERT 阶段, 写入更新Post::comment_counter 的代码。如果使用简洁形式的事件响应方式, 那么代码可以是:
~~~
class Comment extends \yii\db\ActiveRecord {
// 通过重载afterSave来“响应”事件
public function afterSave($insert) {
if (parent::beforeSave($insert)) {
// 新增一个评论
if ($insert) {
// 关联Post的操作,评论计数字段+1
$post = Post::find($this->postId);
$post->comment_counter += 1;
$post->save(false);
}
}
}
}
~~~
回顾下实现关联操作的过程,其实就2步:
* 先是在 transactions() 中声明要事务支持的操作类型,比如上面的例子, 声明的是插入操作。
* 在合适事件响应函数中,写下关联操作代码。
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
事务(Transaction)
最后更新于:2022-04-01 02:20:13
在Yii中,使用 yii\db\Transaction 来表示数据库事务。
一般情况下,我们从数据库连接启用事务,通常采用如下的形式:
~~~
$transaction = $connection->beginTransaction();
try {
$connection->createCommand($sql1)->execute();
$connection->createCommand($sql2)->execute();
// ... executing other SQL statements ...
$transaction->commit();
} catch (Exception $e) {
$transaction->rollBack();
}
~~~
在上面的代码中,先是获取一个 yii\db\Transaction 对象,之后执行若干SQL 语句,然后调用之前Transaction 对象的 commit() 方法。这一过程中, 如果捕获了异常,那么调用 rollBack() 进行回滚。
## 创建事务[](http://www.digpage.com/transaction.html#id2 "Permalink to this headline")
在上面代码中,我们使用数据库连接的 beginTransaction() 方法, 创建了一个 yii\db\Trnasaction对象,具体代码在 yii\db\Connection 中:
~~~
public function beginTransaction($isolationLevel = null)
{
$this->open();
// 尚未初始化当前连接使用的Transaction对象,则创建一个
if (($transaction = $this->getTransaction()) === null) {
$transaction = $this->_transaction = new Transaction(['db' => $this]);
}
// 获取Transaction后,就可以启用事务
$transaction->begin($isolationLevel);
return $transaction;
}
~~~
从创建 Transaction 对象的 new Transaction(['db' => $this]) 形式来看, 这也是Yii一贯的风格。这里简单的初始化了 yii\db\Transaction::db 。
这表示的是当前的 Transaction 所依赖的数据库连接。如果未对其进行初始化, 那么将无法正常使用事务。
在获取了 Transaction 之后,就可以调用他的 begin() 方法,来启用事务。 必要的情况下,还可以指定事务隔离级别。
事务隔离级别的设定,由 yii\db\Schema::setTransactionIsolationLevel() 方法来实现,而这个方法,无非就是执行了如下的SQL语句:
~~~
SET TRANSACTION ISOLATION LEVEL ...
~~~
对于隔离级别,yii\db\Transaction 也提前定义了几个常量:
~~~
const READ_UNCOMMITTED = 'READ UNCOMMITTED';
const READ_COMMITTED = 'READ COMMITTED';
const REPEATABLE_READ = 'REPEATABLE READ';
const SERIALIZABLE = 'SERIALIZABLE';
~~~
如果开发者没有给出隔离级别,那么,数据库会使用默认配置的隔离级别。 比如,对于MySQL而言,就是使用 transaction-isolation 配置项的值。
## 启用事务[](http://www.digpage.com/transaction.html#id3 "Permalink to this headline")
上面的代码告诉我们,启用事务,最终是靠调用 Transaction::begin() 来实现的。 那么就让我们来看看他的代码吧:
~~~
public function begin($isolationLevel = null)
{
// 没有初始化数据库连接的滚粗
if ($this->db === null) {
throw new InvalidConfigException('Transaction::db must be set.');
}
$this->db->open();
// _level 为0 表示的是最外层的事务
if ($this->_level == 0) {
// 如果给定了隔离级别,那么就设定之
if ($isolationLevel !== null) {
// 设定事务隔离级别
$this->db->getSchema()->setTransactionIsolationLevel($isolationLevel);
}
Yii::trace('Begin transaction' . ($isolationLevel ? ' with isolation level ' . $isolationLevel : ''), __METHOD__);
$this->db->trigger(Connection::EVENT_BEGIN_TRANSACTION);
$this->db->pdo->beginTransaction();
$this->_level = 1;
return;
}
// 以下 _level>0 表示的是嵌套的事务
$schema = $this->db->getSchema();
// 要使用嵌套事务,前提是所使用的数据库要支持
if ($schema->supportsSavepoint()) {
Yii::trace('Set savepoint ' . $this->_level, __METHOD__);
// 使用事务保存点
$schema->createSavepoint('LEVEL' . $this->_level);
} else {
Yii::info('Transaction not started: nested transaction not supported', __METHOD__);
}
// 结合 _level == 0 分支中的 $this->_level = 1,
// 可以得知,一旦调用这个方法, _level 就会自增1
$this->_level++;
}
~~~
对于最外层的事务,即当 _level 为 0 时,最终落到PDO的 beginTransaction() 来启用事务。在启用前,如果开发者给定了隔离级别,那么还需要设定隔离级别。
当 _level > 0 时,表示的是嵌套的事务,并非最外层的事务。 对此,Yii使用 SQL 的 SAVEPOINT 和ROLLBACK TO SAVEPOINT 来实现设置事务保存点和回滚到保存点的操作。
## 嵌套事务[](http://www.digpage.com/transaction.html#id4 "Permalink to this headline")
在开头的例子中,展现的是事务最简单的使用形式。Yii还允许把事务嵌套起来使用。 比如,可以采用如下形式来使用事务:
~~~
$outerTransaction = $db->beginTransaction();
try {
$db->createCommand($sql1)->execute();
$innerTransaction = $db->beginTransaction();
try {
$db->createCommand($sql2)->execute();
$db->createCommand($sql3)->execute();
$innerTransaction->commit();
} catch (Exception $e) {
$innerTransaction->rollBack();
}
$db->createCommand($sql4)->execute();
$outerTransaction->commit();
} catch (Exception $e) {
$outerTransaction->rollBack();
}
~~~
为了实现这一嵌套,Yii使用 yii\db\Transaction::_level 来表示嵌套的层级。 当层级为 0 时,表示的是最外层的事务。
一般情况下,整个Yii应用使用了同一个数据库连接,或者说是使用了单例。 具体可以看 [_服务定位器(Service Locator)_](http://www.digpage.com/service_locator.html#service-locator) 部分。
而在 yii\db\Connection 中,又对事务对象进行了缓存:
~~~
class Connection extends Component
{
// 保存当前连接的有效Transaction对象
private $_transaction;
// 已经缓存有事务对象,且事务对象有效,则返回该事务对象
// 否则返回null
public function getTransaction()
{
return $this->_transaction && $this->_transaction->getIsActive() ? $this->_transaction : null;
}
// 看看启用事务时,是如何使用事务对象的
public function beginTransaction($isolationLevel = null)
{
$this->open();
// 缓存的事务对象有效,则使用缓存中的事务对象
// 否则创建一个新的事务对象
if (($transaction = $this->getTransaction()) === null) {
$transaction = $this->_transaction = new Transaction(['db' => $this]);
}
$transaction->begin($isolationLevel);
return $transaction;
}
}
~~~
因此,可以认为整个Yii应用,使用了同一个 Transaction 对象,也就是说, Transaction::_level 在整个应用的生命周期中,是有延续性的。 这是实现事务嵌套的关键和前提。
在这个 Transaction::_level 的基础上,Yii实现了事务的嵌套:
* 事务对象初始化时,设 _level 为0,表示如果要启用事务, 这是一个最外层的事务。
* 每当调用 Transaction::begin() 来启用具体事务时, _level 自增1。 表示如再启用事务,将是层级为1的嵌套事务。
* 每当调用 Transaction::commit() 或 Transaction::rollBack() 时, _level 自减1,表示当前层级的事务处理完毕,返回上一层级的事务中。
* 当调用了一次 begin() 且还没有调用匹配的 commit() 或 rollBack() , 就再次调用 begin()时,会使事务进行更深一层级的嵌套中。
因此,就有了我们上面代码中,当 _level 为 0 时,需要设定事务隔离级别。 因为这是最外层事务。
而当 _level > 0 时,由于是“嵌套”的事务,一个大事务中的小“事务”,那么, 就使用保存点及其回滚、释放操作,来模拟事务的启用、回滚和提交操作。
要注意,在这一节的开头,我们使用2对嵌套的 try ... catch 来实现事务的嵌套。 由于内层的catch 把可能抛出的异常吞了,不再继续抛出。那么, 外层的 catch ,是捕获不到内层的异常的。
也就是说,这种情况下,外层中的 $sql1 $sql4 不会由于 $sql2 或 $sql3 的失败而中止, $sql1$sql4 可以继续执行并 commit 。
这是嵌套事务的正确使用形式,即内外层之间应当是不相干的。
如果内层事务的异常,会导致外层事务需要回滚,那么我们不应该使用事务嵌套, 而是应该把内外层当成一个事务。这个道理很浅显,但是事实开发中,一个不小心, 就会出昏招。所以,不要动不动就来个 beginTransaction() 。
当然,为了使代码功能有一定的层次感,在必要时,也可以使用嵌套的事务。 但要考虑好,子事务是否真的要吞掉异常?有没有必要继续抛出异常, 使得上一层级的事务也产生回滚?这个要根据实际的情形来确定。
## 提交和回滚[](http://www.digpage.com/transaction.html#id5 "Permalink to this headline")
提交和回滚通过 Transaction::commit() 和 Transaction::rollBack() 来实现:
~~~
public function commit()
{
if (!$this->getIsActive()) {
throw new Exception('Failed to commit transaction: transaction was inactive.');
}
// 与begin()对应,只要调用 commit(),_level 自减1
$this->_level--;
// 如果回到了最外层事务,那么应当使用PDO的commit
if ($this->_level == 0) {
Yii::trace('Commit transaction', __METHOD__);
$this->db->pdo->commit();
$this->db->trigger(Connection::EVENT_COMMIT_TRANSACTION);
return;
}
// 以下是尚未回到最外层的情形
$schema = $this->db->getSchema();
if ($schema->supportsSavepoint()) {
Yii::trace('Release savepoint ' . $this->_level, __METHOD__);
// 释放那么保存点
$schema->releaseSavepoint('LEVEL' . $this->_level);
} else {
Yii::info('Transaction not committed: nested transaction not supported', __METHOD__);
}
}
public function rollBack()
{
if (!$this->getIsActive()) {
return;
}
// 调用 rollBack() 也会使 _level 自减1
$this->_level--;
// 如果已经返回到最外层,那么调用 PDO 的 rollBack
if ($this->_level == 0) {
Yii::trace('Roll back transaction', __METHOD__);
$this->db->pdo->rollBack();
$this->db->trigger(Connection::EVENT_ROLLBACK_TRANSACTION);
return;
}
// 以下是未返回到最外层的情形
$schema = $this->db->getSchema();
if ($schema->supportsSavepoint()) {
Yii::trace('Roll back to savepoint ' . $this->_level, __METHOD__);
// 那么就回滚到保存点
$schema->rollBackSavepoint('LEVEL' . $this->_level);
} else {
Yii::info('Transaction not rolled back: nested transaction not supported', __METHOD__);
throw new Exception('Roll back failed: nested transaction not supported.');
}
}
~~~
对于提交和回滚:
* 提交时,会使层级+1,回滚时,会使层级-1
* 对于最外层的提交和回滚,使用的是数据库事务的 commit 和 rollBack
* 对于嵌套的内层的提交和回滚,使用的其实是事务保存点的释放和回滚
* 释放保存点时,会释放保存点的标识符,这个标识符在下次事务嵌套达到这个层级时, 会被再次使用。
## 有效的事务[](http://www.digpage.com/transaction.html#id6 "Permalink to this headline")
在上面的提交、回滚等方法的代码中,我们多次看到了一个 this->getIsActive() 。 这是用于判断当前事务是否有效的一个方法,我们通过它,来看看什么样的一个事务, 算是有效的:
~~~
public function getIsActive()
{
return $this->_level > 0 && $this->db && $this->db->isActive;
}
~~~
方法很简单明了,一个有效的事务必须同时满足3个条件:
* _level > 0 。这是由于为0是,要么是刚刚初始化, 要么是所有的事务已经提交或回滚了。也就是说,只有调用过了 begin() 但还没有调用过匹配的 commit() 或 rollBack() 的事务对象,才是有效的。
* 数据库连接要已经初始化。
* 数据库连接也必须是有效的。
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
数据类型
最后更新于:2022-04-01 02:20:11
各DBMS间,最明显、最常见的差异就在于所支持、实现的数据类型不同。Yii的一个重要任务,就是消 除这些区别,提供一个统一的开发界面供开发者使用。所以,我们先来看看Yii是怎么克服这一拦路虎 ,实现天下的大一统的。
## 抽象数据类型[](http://www.digpage.com/db_datatype.html#id2 "Permalink to this headline")
在使用Yii进行数据库开发时,涉及到2个方面的数据类型:PHP自身的数据类型,DBMS的数据类型。 其中,PHP数据类型比较好整,反正就那么几个,跟平台、环境的关系也比较清楚。 复杂的地方在于DBMS的数据类型,龙生九子,各有不同。
因此,Yii不但需要搞定各DBMS间数据类型的差异,还需要搞定PHP与DBMS间数据类 类型的差异。因此,Yii引入了一个逻辑层面的数据类型,来统一PHP与DBMS,以及各DBMS之间数据类型 的差异。这里我们把这个逻辑层面的数据类型称为抽象类型,在 yii\db\Schema 中进行定义:
~~~
abstract class Schema extends Object
{
// 预定义16种抽象字段类型
const TYPE_PK = 'pk';
const TYPE_BIGPK = 'bigpk';
const TYPE_STRING = 'string';
const TYPE_TEXT = 'text';
const TYPE_SMALLINT = 'smallint';
const TYPE_INTEGER = 'integer';
const TYPE_BIGINT = 'bigint';
const TYPE_FLOAT = 'float';
const TYPE_DECIMAL = 'decimal';
const TYPE_DATETIME = 'datetime';
const TYPE_TIMESTAMP = 'timestamp';
const TYPE_TIME = 'time';
const TYPE_DATE = 'date';
const TYPE_BINARY = 'binary';
const TYPE_BOOLEAN = 'boolean';
const TYPE_MONEY = 'money';
// ... ...
}
~~~
yii\db\Schema 一上来就先针对各DBMS间差异最明显的字段数据类型进行统一,提供了16种抽象的 字段类型。这16种类型与DBMS无关,在具体到特定的DBMS时,Yii会自动转换成合适的数据库字段类型 。我们在编程中,若需要指定字段类型,比如创建数据库之类,就使用这16种抽象类型。这样的话, 就不用考虑使用的类型具体的DBMS是否支持的问题了,可以使我们更加专注于开发。
这16种类型看着就知道是什么意思,我们就不展开讲了。只是有一点,这个数据类型是抽象的,也就是 说,只是一个逻辑意义上的数据类型,不涉及到具体实现。比如上面的 Schema::TYPE_BIGINT 所 要表示的是一个大数,但具体实现上,可能是用一个字符串来表示。
## 数据类型转换[](http://www.digpage.com/db_datatype.html#id3 "Permalink to this headline")
既然Yii使用抽象数据类型来一统江山,那么无可避免的,涉及到PHP数据类型和DBMS数据类型与抽象类 型的转换问题。
### 抽象类型转数据库类型[](http://www.digpage.com/db_datatype.html#id4 "Permalink to this headline")
前面提到过,在Yii开发中,我们使用16种抽象数据类型,而不使用具体的DBMS的数据类型。那么,在 我们要创建一个数据库时,Yii是怎么为我们所定义的字段指定合适的数据类型的呢?
首先来看看一个基类 yii\db\QueryBuilder
~~~
class QueryBuilder extends \yii\base\Object
{
// $typeMap用于定义抽象数据类型到DBMS数据类型的映射关系,
// 具体由各QueryBuilder子类实现。
public $typeMap = [];
// 将抽象数据类型转换成合适的DBMS数据类型
public function getColumnType($type)
{
// 映射表中已经有的,直接使用映射的类型
if (isset($this->typeMap[$type])) {
return $this->typeMap[$type];
// 映射表中没有的类型,看看是不是形如 "Schema::TYPE_INT(11) DEFAULT 0" 之类的
} elseif (preg_match('/^(\w+)\((.+?)\)(.*)$/', $type, $matches)) {
if (isset($this->typeMap[$matches[1]])) {
return preg_replace('/\(.+\)/', '(' . $matches[2] . ')', $this->typeMap[$matches[1]]) . $matches[3];
}
// 看看是不是形如 "Schema::TYPE_INT NOT NULL" 之类的,
// 注意这一分支在第二分支之后
} elseif (preg_match('/^(\w+)\s+/', $type, $matches)) {
if (isset($this->typeMap[$matches[1]])) {
return preg_replace('/^\w+/', $this->typeMap[$matches[1]], $type);
}
}
// 实在匹配不上映射表中的类型,那就原封不动返回吧
return $type;
}
// ... ...
}
~~~
上面的代码只列出了 yii\db\QueryBuilder 的部分内容。特别是其中的 $typeMap[] 是由子 类来具体实现的。比如,对于MySQL数据库, yii\db\mysql\QueryBuilder 中:
~~~
namespace yii\db\mysql;
class QueryBuilder extends \yii\db\QueryBuilder
{
public $typeMap = [
Schema::TYPE_PK => 'int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY',
Schema::TYPE_BIGPK => 'bigint(20) NOT NULL AUTO_INCREMENT PRIMARY KEY',
Schema::TYPE_STRING => 'varchar(255)',
Schema::TYPE_TEXT => 'text',
Schema::TYPE_SMALLINT => 'smallint(6)',
Schema::TYPE_INTEGER => 'int(11)',
Schema::TYPE_BIGINT => 'bigint(20)',
Schema::TYPE_FLOAT => 'float',
Schema::TYPE_DECIMAL => 'decimal(10,0)',
Schema::TYPE_DATETIME => 'datetime',
Schema::TYPE_TIMESTAMP => 'timestamp',
Schema::TYPE_TIME => 'time',
Schema::TYPE_DATE => 'date',
Schema::TYPE_BINARY => 'blob',
Schema::TYPE_BOOLEAN => 'tinyint(1)',
Schema::TYPE_MONEY => 'decimal(19,4)',
];
// ... ...
}
~~~
因此,对于16种抽象数据类型而言,都可以转换成MySQL的特定数据类型。这里我们看到,TYPE_MONEY 本来是MySQL所没有的数据类型,但通过将其映射成了 decimal(19,4) ,使得 MySQL也可以支持 TYPE_MONEY 类型了。
同样的,在 yii\db\pgsql\QueryBuilder yii\db\mssql\QueryBuilder 等QueryBuilder子类 中,也有相同的数据类型映射表 $typeMap[] ,以实现从抽象数据类型到具体数据库数据类型的映 射。
对于抽象数据类型的具体定义,我们选一个自己比较熟悉的DBMS的Schema进行记忆就可以了。如果实在 吃不准,比如 TYPE_STRING 是定长还是变长,保留不保留空格等,可以看看Schema中的定义。建 议读者朋友们还是熟记这16个基本类型,起码仔细过一遍,在具体开发时,能省时不少。
yii\db\QueryBuilder::getColumnType() 实现了抽象数据类型到具体DBMS数据类型的转换。 在具体的转换过程中,如果指定一个字段为 Schema::TYPE_STRING 之类的,那么就会被转换成 varchar(255) 。有的读者朋友可能会问,那要是想指定成 varchar(64) 该怎么做呢?
那就直接使用 Schema::TYPE_STRING(64) 。 yii\db\QueryBuilder::getColumnType() 会识 别出来Schema::TYPE_STRING ,并将原来的 varchar(255) 转换成 varchar(64) 。 这点适用于其他数据类型。当然,其本身要支持。指定一个 Schema::TYPE_BOOLEAN(2) 又有什么 意义呢?
我们还可以在16种抽象类型后面使用 NOT NULL , DEFAULT 等。yii\db\QueryBuilder::getColumnType() 也是能够识别出来并加以处理的。
### 数据库类型转抽象类型[](http://www.digpage.com/db_datatype.html#id5 "Permalink to this headline")
所谓来而不往非礼也,说完了抽象类型转DBMS数据类型,就该说说反过来数据库的数据类型, 怎么转换成抽象数据类型了。仍然以MySQL数据库为例,具体代码在 yii\db\mysql\Schema 中:
~~~
class Schema extends \yii\db\Schema
{
// 定义从数据库数据类型到16个抽象数据类型间的映射关系
public $typeMap = [
'tinyint' => self::TYPE_SMALLINT,
'bit' => self::TYPE_INTEGER,
'smallint' => self::TYPE_SMALLINT,
'mediumint' => self::TYPE_INTEGER,
'int' => self::TYPE_INTEGER,
'integer' => self::TYPE_INTEGER,
'bigint' => self::TYPE_BIGINT,
'float' => self::TYPE_FLOAT,
'double' => self::TYPE_FLOAT,
'real' => self::TYPE_FLOAT,
'decimal' => self::TYPE_DECIMAL,
'numeric' => self::TYPE_DECIMAL,
'tinytext' => self::TYPE_TEXT,
'mediumtext' => self::TYPE_TEXT,
'longtext' => self::TYPE_TEXT,
'longblob' => self::TYPE_BINARY,
'blob' => self::TYPE_BINARY,
'text' => self::TYPE_TEXT,
'varchar' => self::TYPE_STRING,
'string' => self::TYPE_STRING,
'char' => self::TYPE_STRING,
'datetime' => self::TYPE_DATETIME,
'year' => self::TYPE_DATE,
'date' => self::TYPE_DATE,
'time' => self::TYPE_TIME,
'timestamp' => self::TYPE_TIMESTAMP,
'enum' => self::TYPE_STRING,
];
// ... ...
}
~~~
在 yii\db\mysql\Schema::$typeMap 中,定义了MySQL数据类型与16种抽象数据类型的映射关系。 当然,由于抽象数据类型只有16种,所以,有一些MySQL数据类型被映射成同一种抽象类型。比如year date 都被映射成了 self::TYPE_DATE 了。
同样的,你可以在 yii\db\mssql\Schema 和 yii\db\pgsql\Schema 中找到类似的映射代码。 在各Schema子类中,Yii针对不同的DBMS,实现了数据库数据类型到抽象数据类型间的映射关系。
从数据库类型到抽象类型的映射关系,只要掌握前面16种抽象类型与数据库数据类型的映射关系, 那么,其逆向映射就几乎不用刻意去记忆了。额外的,凡是没有合适的抽象类型的,就用 TYPE_STRING来表示。
上面只是一个映射表,具体转换过程在生成字段信息 yii\db\ColumnSchema 时进行。yii\db\ColumnSchema 保存了一个字段的各种相关信息,包括字段类型等。
字段信息的获取和填充,又发生在 yii\db\Schema::loadTableSchema() 中。 该函数用于加载数据表信息 yii\db\TableSchema ,这是一个抽象函数,具体由各子类实现。
字段信息和表信息我们后面再讲,这里大致知道就是用于保存字段和数据表的各种信息就可以了。而且 在获取表信息时,必然会调用到获取字段信息的相关代码。字段信息和表信息的获取,都由Schema子类 来具体实现。
对于MySQL数据库而言,字段信息的获取在 yii\db\mysql\Schema::loadColumnSchema() 中,也是 他实现了从数据库类型到抽象类型的转换:
~~~
// $info数组 由 SQL 语句 "SHOW FULL COLUMNS FROM ..." 而来,形式如下:
// Field: id
// Type: int(11)
// Collation: NULL
// Null: NO
// Key: PRI
// Default: NULL
// Extra: auto_increment
// Privileges: select,insert,update,references
// Comment:
protected function loadColumnSchema($info)
{
$column = $this->createColumnSchema();
// 字段名
$column->name = $info['Field'];
// 是否允许为NULL
$column->allowNull = $info['Null'] === 'YES';
// 是否是主键
$column->isPrimaryKey = strpos($info['Key'], 'PRI') !== false;
// 是否 auto_increment
$column->autoIncrement = stripos($info['Extra'], 'auto_increment') !== false;
// 获取字段注释
$column->comment = $info['Comment'];
// 重点是这里,获取数据库字段类型,如上面的 int(11)
$column->dbType = $info['Type'];
// 是否是 unsigned
$column->unsigned = stripos($column->dbType, 'unsigned') !== false;
// 以下将把数据库类型,转换成对应的抽象类型,默认为 TYPE_STRING
$column->type = self::TYPE_STRING;
if (preg_match('/^(\w+)(?:\(([^\)]+)\))?/', $column->dbType, $matches)) {
// 获取 int(11) 的 "int" 部分
$type = strtolower($matches[1]);
// 如果映射表里有,那就直接映射成抽象类型
if (isset($this->typeMap[$type])) {
$column->type = $this->typeMap[$type];
}
// 形如int(11) 的括号中的内容
if (!empty($matches[2])) {
// 枚举类型,还需要将所有枚举值写入 $column->enumValues
if ($type === 'enum') {
$values = explode(',', $matches[2]);
foreach ($values as $i => $value) {
$values[$i] = trim($value, "'");
}
$column->enumValues = $values;
// 如果不是枚举类型,那么括号中的内容就是精度了,如 decimal(19,4)
} else {
$values = explode(',', $matches[2]);
$column->size = $column->precision = (int) $values[0];
if (isset($values[1])) {
$column->scale = (int) $values[1];
}
// bit(1) 类型的,转换成 boolean
if ($column->size === 1 && $type === 'bit') {
$column->type = 'boolean';
} elseif ($type === 'bit') {
// 由于bit最多64位,如果超过 32 位,那么用一个 bigint 足以。
if ($column->size > 32) {
$column->type = 'bigint';
// 如果正好32位,那么用一个 interger 来表示。
} elseif ($column->size === 32) {
$column->type = 'integer';
}
}
}
}
}
// 获取PHP数据类型
$column->phpType = $this->getColumnPhpType($column);
// 处理默认值
if (!$column->isPrimaryKey) {
// timestamp 的话,要实际获取当前时间戳,而不能是字符串 'CURRENT_TIMESTAMP'
if ($column->type === 'timestamp' && $info['Default'] === 'CURRENT_TIMESTAMP') {
$column->defaultValue = new Expression('CURRENT_TIMESTAMP');
// bit 的话,要截取对应的内容,并进行进制转换
} elseif (isset($type) && $type === 'bit') {
$column->defaultValue = bindec(trim($info['Default'],'b\''));
// 其余类型的,直接转换成PHP类型的值
} else {
$column->defaultValue = $column->phpTypecast($info['Default']);
}
}
return $column;
}
~~~
上面的代码是完整获取字段信息的过程,这里重点要看的,是获取数据类型部分的代码。结合映射表和 上面的代码,我们能够得出:
* 通过 SHOW FULL COLUMNS FROM SQL语句获取字段信息,并存储在 $info 数组中。
* 根据 $info['Type'] 获取字段类型信息。
* 如果映射表里已经有映射关系的,直接通过映射表,获取相应的抽象类型。
* 如果映射表没有的,默认地视字段的抽象类型为 TYPE_STRING 。
* 对于枚举类型,除了转换成 TYPE_STRING 外,还要获取其枚举值,否则,类型信息不完整 。
* 对于bit类型,在32位及32位以下时,使用 TYPE_INTEGER 抽象类型,在32位以上(bit最大为64 位)时,使用 TYPE_BIGINT 类型。
至于其他字段信息的获取,如是否允许为 NULL 之类,上面的代码中注释已经交待清楚了,大家这么聪 明,相信难不倒你们。需要稍稍注意的,就是默认值的处理,特别是 timestamp 类型默认值的处 理。
### 抽象类型转PHP类型[](http://www.digpage.com/db_datatype.html#php "Permalink to this headline")
前面讲数据库类型转抽象类型时,在 yii\db\mysql\Schema::loadColumnSchema() 中,有一个语 句$column->phpType = $this->getColumnPhpType($column); 我们只是简单地说是转换成 PHP 类型,就一笔带来了。其实,他就是把抽像类型转换成PHP类型的关键,让我们来看看这个yii\db\Schema::getColumnPhpType()
~~~
protected function getColumnPhpType($column)
{
// 定义从抽象类型到PHP类型的映射
static $typeMap = [
'smallint' => 'integer',
'integer' => 'integer',
'bigint' => 'integer',
'boolean' => 'boolean',
'float' => 'double',
'binary' => 'resource',
];
// 除了上面的映射关系外,还有几个特殊情况:
// 1\. bigint字段,在64位环境下,且为singed时,使用integer来表示,否则string
// 2\. integer字段,在32位环境下,且为unsinged时,使用string表示,否则integer
// 3\. 映射中不存在的字段类型均使用string
if (isset($typeMap[$column->type])) {
if ($column->type === 'bigint') {
return PHP_INT_SIZE == 8 && !$column->unsigned ? 'integer' : 'string';
} elseif ($column->type === 'integer') {
return PHP_INT_SIZE == 4 && $column->unsigned ? 'string' : 'integer';
} else {
return $typeMap[$column->type];
}
} else {
return 'string';
}
}
~~~
首先不要惊讶于为什么这个方法不像数据库类与抽象类型转换时,放在MySQL子类Schema中实现。这是 由于我们现在讨论的是抽象类型到PHP类型的转换。我们说过,抽象类型是与数据库无关的,PHP类型更 是与数据库没有半毛钱关系,所以,放在基类 yii\db\Schema 中是合理的。这也是我们在平时在 编程时经常采用的划分基类与子类的一个常用准则。
虽然,我们说抽象数据类型有16种,但是到了PHP范畴,有的类型是没有意义的。在PHP中,数据库的字 段类型,都可以归结了 integer boolean double resourece string 5种。
* TYPE_PK 和 TYPE_BIGPK 表示主键,Yii使用 yii\db\ColumnSchema::isPrimaryKey 属性来表示,不存在将其转换成何种数据类型的问题。
* TYPE_SMALLINT TYPE_INTEGER TYPE_BIGINT 等字段一般都转换成PHP的 integer 类型。特别是TYPE_SMALLINT ,PHP的 integer 完全可以满足要求。
* TYPE_BIGINT 字段,如果是在64位环境下,且该字段并非为 unsigned 时,PHP的 integer 足够存储。但是,在32位环境下,PHP的 integer 只有4个字节,不够存储8个字节的TYPE_BIGINT 。而如果字段是 unsigned 的,由于PHP并没有 unsigned 一说,就算是 64位环境,也少了1 bit。在不够存储时,就只能选用PHP的 string 类型了。
* TYPE_INTEGER 字段,在32位环境下,如果是 unsigned 的,那也会少1 bit来存储。而如 果是64位环境,或者并非是 unsigned 的,PHP的 integer 都是够用的。同样,不够存储时 ,就使用字符串类型了。
* TYPE_FLOAT 字段,注意是转换成PHP的 double 而非 float , float 精度不够。
* TYPE_BOOLEAN 字段,顺理成章对应PHP的 boolean 类型。
* TYPE_BINARY 字段,理所当然对应PHP的 resource 类型。
* TYPE_STRING 和 TYPE_TEXT 字段,显而易见对应 PHP的 string 类型。
* TYPE_DECIMAL 和 TYPE_MONEY 字段由于PHP的数值类型精度都不够,所以, 只能使用 string类型来表示。
* 对于日期、时间等字段,尽管PHP提供了丰富的日期、时间函数,但事实上,PHP中并没有专门的表示 日期、时间的数据类型。因此,对于这类字段,又只能求助于万能的 string 类型了。
### 字段内容转PHP变量[](http://www.digpage.com/db_datatype.html#id6 "Permalink to this headline")
前面我们有了数据库类型转抽象类型,抽象类型转PHP类型,那么,我们就可以完成数据库类型到PHP类 型的转换,也就是说,能够将数据库中的内容读取到PHP中供我们编程时使用啦。
假设有如下的MySQL数据表:
~~~
CREATE TABLE `tbl_news` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`userid` int(10) unsigned NOT NULL DEFAULT '0',
`status` enum('Draft','Publish','Archive','Unpublish') NOT NULL DEFAULT 'Draft',
`title` varchar(256) NOT NULL DEFAULT '',
`tags` varchar(256) NOT NULL DEFAULT '',
`createtime` int(10) unsigned NOT NULL DEFAULT '0',
`publishtime` int(10) unsigned NOT NULL DEFAULT '0',
`abstract` varchar(512) NOT NULL DEFAULT '',
`content` text,
`lastupdate` int(10) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=8 DEFAULT CHARSET=utf8
~~~
那么,通过PDO获取其中一个记录,DUMP出来后,各字段的类型为:
~~~
array(10) {
["id"]=>
string(1) "7"
["userid"]=>
string(1) "1"
["status"]=>
string(5) "Draft"
["title"]=>
string(6) "teste2"
["tags"]=>
string(4) "test"
["createtime"]=>
string(10) "1382152003"
["publishtime"]=>
string(10) "1381161600"
["abstract"]=>
string(3) "abs"
["content"]=>
string(12) "<p>cont</p>"
["lastupdate"]=>
string(10) "1382152003"
}
~~~
你能看到,所有的字段通过PDO读取后,再DUMP出来都是以 string 保存的。这就需要Yii根据 ColumnSchema中字段信息,去把各字段以合适的PHP类型表现出来。而这个过程, 就是yii\db\ColumnSchema::phpTypeCast()
~~~
// 该方法用于把 $value 转换成 php 变量,其中 $value 是PDO从数据库中读取的内容
// 主要参考的是字段的抽象类型 $type 和 PHP类型 $phpType
public function phpTypecast($value)
{
// 内容为空时,若不是字符串或二进制抽象类型,则是NULL的意思。
if ($value === '' && $this->type !== Schema::TYPE_TEXT && $this->type !== Schema::TYPE_STRING && $this->type !== Schema::TYPE_BINARY) {
return null;
}
// 内容为null,或者 $value 的类型与PHP类型一致,或者 $value 是一个数据库表达式,
// 那么可以直接返回
if ($value === null || gettype($value) === $this->phpType || $value instanceof Expression) {
return $value;
}
// 否则,需要根据PHP类型来完成类型转换
switch ($this->phpType) {
case 'resource':
case 'string':
return is_resource($value) ? $value : (string) $value;
case 'integer':
return (int) $value;
case 'boolean':
return (bool) $value;
case 'double':
return (double) $value;
}
return $value;
}
~~~
在上面的转换过程中,PHP类型信息已经在上一步的 yii\db\Schema::getColumnPhpType() 中获 取了。这里就是把从数据库中得到的数据,转换成 phpType 所指定的PHP变量类型。
前面我们讲过,16种抽象类型最终对应于PHP的5种数据类型。但是,从上面的 phpTypeCast() 来 看,还要多出2种类型来:
* 一是 null ,对于既不是文本类型,又不是二进制类型的字段,如果其内容为空,那么他的意思 其实是 NULL 。
* 二是 yii\db\Expression 数据库表达式类型。如前面我们提到的 timestamp 的默认值是CURRENT_TIMESTAMP 时,就用到了一个 new Expression('CURRENT_TIMESTAMP') 。
### PHP类型转PDO类型[](http://www.digpage.com/db_datatype.html#phppdo "Permalink to this headline")
前面,我们已经实现了创建数据库时,使用抽象类型来指定字段类型,也实现了从数据库读取数据到 PHP(数据库类型-抽象类型-PHP类型)的转换过程。
还有一个情景我们没有涉及到,那就是PHP类型转数据库类型的问题。由于Yii通过PDO操作数据库,因 此,这个问题就成为了PHP类型怎么转换成PDO数据类型的问题,至于PDO转数据库字段类型,由PDO自己 实现,我们就不用操心了。
这里,比如说的SQL查询参数绑定, PDO::bindParam() 要求提供参数类型。Yii是能过PHP类型, 直接就转换成PDO的类型了,具体的代码在 yii\db\Schema::getPdoType()
~~~
// 将一个PHP数据类型转换成PDO数据类型
public function getPdoType($data)
{
// 定义一个PHP类型到PDO类型的映射表
static $typeMap = [
'boolean' => \PDO::PARAM_BOOL,
'integer' => \PDO::PARAM_INT,
'string' => \PDO::PARAM_STR,
'resource' => \PDO::PARAM_LOB,
'NULL' => \PDO::PARAM_NULL,
];
$type = gettype($data);
// 在匹配不上映射表时,采用字符串类型
return isset($typeMap[$type]) ? $typeMap[$type] : \PDO::PARAM_STR;
}
~~~
前面我们一直在说,16种抽象数据类型,可以用来表示各种DBMS的各种字段类型。而这16种抽象类型, 具体到PHP数据类型时,逃不过 integer boolean resource string 和 double 5种,再加上特殊的 null 一共是6种。
而在上面的 getPdoType() 中,与6种PHP数据类型对应的,PDO提供了5种,其中 double 类型 用PDO::PARAM_STR 来表示。
至此,PHP、Yii、DBMS、PDO间的数据类型转换问题就完全解决了。
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
Yii与数据库(TBD)
最后更新于:2022-04-01 02:20:08
Web应用Request
最后更新于:2022-04-01 02:20:06
前面 [_请求(Reqeust)_](http://www.digpage.com/request.html#request) 部分我们讲了用户请求的基础知识和命令行应用的Request,接下来继续讲Web应用的Request。
Web应用Request由 yii\web\Request 实现,这个类的代码将近1400行,主要是一些功能的封装罢了, 原理上没有很复杂的东西。只是涉及到许多HTTP的有关知识,读者朋友们可以自行查看相关的规范文档, 如 [HTTP 1.1 协议](https://tools.ietf.org/html/rfc2616) , [CGI 1.1 规范](https://tools.ietf.org/html/rfc3875.html) 等。
同时,Yii大量引用了 $_SERVER , 具体可以查看 [PHP文档关于$_SERVER的内容](http://php.net/manual/en/reserved.variables.server.php) , 此外,还涉及到PHP运行于不同的环境和模式下的一些细微差别。 这些内容比较细节,不影响大局,但是很影响理解,不过没关系,我们在涉及到的时候,会点一点。
## 请求的方法[](http://www.digpage.com/web_request.html#id1 "Permalink to this headline")
根据 [HTTP 1.1 协议](https://tools.ietf.org/html/rfc2616) ,HTTP的请求可以有:GET, POST, PUT等8种方法 (Request Method)。除了用不到的 CONNECT 外,Yii支持全部的HTTP请求方法。
要获取当前用户请求的方法,可以使用 yii\web\Request::getMethod()
~~~
// 返回当前请求的方法,请留意方法名称是大小写敏感的,按规范应转换为大写字母
public function getMethod()
{
// $this->methodParam 默认值为 '_method'
// 如果指定 $_POST['_method'] ,表示使用POST请求来模拟其他方法的请求。
// 此时 $_POST['_method'] 即为所模拟的请求类型。
if (isset($_POST[$this->methodParam])) {
return strtoupper($_POST[$this->methodParam]);
// 或者使用 $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] 的值作为方法名。
} elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
return strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
// 或者使用 $_SERVER['REQUEST_METHOD'] 作为方法名,未指定时,默认为 GET 方法
} else {
return isset($_SERVER['REQUEST_METHOD']) ?
strtoupper($_SERVER['REQUEST_METHOD']) : 'GET';
}
}
~~~
这个方法使用了3种方法来获取当前用户的请求,优先级从高到低依次为:
* 当使用POST请求来模拟其他请求时,以 $_POST['_method'] 作为当前请求的方法;
* 否则,如果存在 X_HTTP_METHOD_OVERRIDE HTTP头时,以该HTTP头所指定的方法作为请求方法, 如 X-HTTP-Method-Override: PUT 表示该请求所要执行的是 PUT 方法;
* 如果 X_HTTP_METHOD_OVERRIDE 不存在,则以 REQUEST_METHOD 的值作为当前请求的方法。 如果连REQUEST_METHOD 也不存在,则视该请求是一个 GET 请求。
前面两种方法,主要是针对一些只支持GET和POST等有限方法的User Agent而设计的。
其中第一种方法是从Ruby on Rails中借鉴过来的, 通过在发送POST请求时,加入一个$_POST['_method'] 的隐藏字段,来表示所要模拟的方法, 如PUT,DELETE等。这样,就可以使得这些功能有限的User Agent也可以正常与服务器交互。 这种方法胜在简便,随手就来。
第二种方法则是使用 X_HTTP_METHOD_OVERRIDE HTTP头的办法来指定所使用的请求类型。 这种方法胜在直接明了,约定俗成,更为规范、合理。
至于 REQUEST_METHOD 是 [CGI 1.1 规范](https://tools.ietf.org/html/rfc3875.html) 所定义的环境变量, 专门用来表明当前请求方法的。上面的代码只是在未指定时默认为GET请求罢了。
当然,我们在开发过程中,其实并不怎么在乎当前的用户请求是什么类型的请求,我们更在乎是不是某一类型的请求。 比如,对于同一个URL地址 http://api.digpage.com/post/123 , 如果是正常的GET请求,应该是查看编号为123的文章的意思。 但是如果是一个DELETE请求,则是表示删除编号为123的文章的意思。我们在开发中,很可能就会这么写:
~~~
if ($app->request->isDelete()){
$post->delete();
} else {
$post->view();
}
~~~
上面的代码只是一个示意,与实际编码是有一定出入的,主要看判断分支的用法。 就是判断请求是否是某一特定类型的请求。这些判断在实际开发中,是很常用的。 于是Yii为我们封装了许多方法专门用于执行这些判断:
* getIsAjax() 是否是AJAX请求,这其实不是HTTP请求方法,但是实际使用上,这个是用得最多的。
* getIsDelete() 是否是DELETE请求
* getIsFlash() 是否是Adobe Flash 或 Adobe Flex 发出的请求,这其实也不是HTTP请求方法。
* getIsGet() 是否是一个GET请求
* getIsHead() 是否是一个HEAD请求
* getIsOptions() 是否是一个OPTIONS请求
* getIsPatch() 是否是PATCH请求
* getIsPjax() 是否是一个PJAX请求,这也并非是HTTP请求方法。
* getIsPost() 是否是一个POST请求
* getIsPut() 是否是一个PUT请求
上面10个方法请留意其中有3个并未是HTTP请求方法,主要是用于特定HTTP请求类型(AJAX、Flash、PJAX)的判断。
除了这3个之外的其余7个方法,正好对应于HTTP 1.1 协议定义的7个方法。 而CONNECT方法由于Web开发在用不到,主要用于HTTP代理, 因此,Yii也就没有为其设计一个所谓的 isConnect() 了,这是无用功。
上面的10个方法,再加一开始说的 getMehtod() 一共是11个方法,按照我们在 [_属性(Property)_](http://www.digpage.com/property.html#property) 部分所说的, 这相当于定义了11个只读属性。我们以其中几个为例,看看具体实现:
~~~
// 这个SO EASY,啥也不说了,Yii实现的7个HTTP方法都是这个路子。
public function getIsOptions()
{
// 注意在getMethod()时,输出的是全部大写的字符串
return $this->getMethod() === 'OPTIONS';
}
// AJAX请求是通过 X_REQUESTED_WITH 消息头来判断的
public function getIsAjax()
{
// 注意这里的XMLHttpRequest没有全部大写
return isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
$_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest';
}
// PJAX请求是AJAX请求的一种,增加了X_PJAX消息头的定义
public function getIsPjax()
{
return $this->getIsAjax() && !empty($_SERVER['HTTP_X_PJAX']);
}
// HTTP_USER_AGENT消息头中包含 'Shockwave' 或 'Flash' 字眼的(不区分大小写),
// 就认为是FLASH请求
public function getIsFlash()
{
return isset($_SERVER['HTTP_USER_AGENT'])
&& (stripos($_SERVER['HTTP_USER_AGENT'], 'Shockwave') !== false
|| stripos($_SERVER['HTTP_USER_AGENT'], 'Flash') !== false);
}
~~~
上面提到的AJAX、PJAX、FLASH请求比较特殊,并非是HTTP协议所规定的请求类型,但是在实现中是会使用到的。 比如,对于一个请求,在非AJAX时,需要整个页面返回给客户端,而在AJAX请求时,只需要返回页面片段即可。
这些特殊请求是通过特殊的消息头实现的,具体的可以自行搜索相关的定义和规范。 至于那7个HTTP方法的判断,摆明了是同一个路子,换瓶不换酒, getMethod() 前人栽树,他们后人乘凉。
## 请求的参数[](http://www.digpage.com/web_request.html#id4 "Permalink to this headline")
在实际开发中,开发者如果需要引用request,最常见的情况是为了获取请求参数,以便作相应处理。 PHP有众所周知的 $_GET 和 $_POST 等。相应地,Yii提供了一系列的方法用于获取请求参数:
~~~
// 用于获取GET参数,可以指定参数名和默认值
public function get($name = null, $defaultValue = null)
{
if ($name === null) {
return $this->getQueryParams();
} else {
return $this->getQueryParam($name, $defaultValue);
}
}
// 用于获取所有的GET参数
// 所有的GET参数保存在 $_GET 或 $this->_queryParams 中。
public function getQueryParams()
{
if ($this->_queryParams === null) {
// 请留意这里并未使用 $this->_queryParams = $_GET 进行缓存。
// 说明一旦指定了 $_queryParams 则 $_GET 会失效。
return $_GET;
}
return $this->_queryParams;
}
// 根据参数名获取单一的GET参数,不存在时,返回指定的默认值
public function getQueryParam($name, $defaultValue = null)
{
$params = $this->getQueryParams();
return isset($params[$name]) ? $params[$name] : $defaultValue;
}
// 类以于get(),用于获取POST参数,也可以指定参数名和默认值
public function post($name = null, $defaultValue = null)
{
if ($name === null) {
return $this->getBodyParams();
} else {
return $this->getBodyParam($name, $defaultValue);
}
}
// 根据参数名获取单一的POST参数,不存在时,返回指定的默认值
public function getBodyParam($name, $defaultValue = null)
{
$params = $this->getBodyParams();
return isset($params[$name]) ? $params[$name] : $defaultValue;
}
// 获取所有POST参数,所有POST参数保存在 $this->_bodyParams 中
public function getBodyParams()
{
if ($this->_bodyParams === null) {
// 如果是使用 POST 请求模拟其他请求的
if (isset($_POST[$this->methodParam])) {
$this->_bodyParams = $_POST;
// 将 $_POST['_method'] 删掉,剩余的$_POST就是了
unset($this->_bodyParams[$this->methodParam]);
return $this->_bodyParams;
}
// 获取Content Type
// 对于 'application/json; charset=UTF-8',得到的是 'application/json'
$contentType = $this->getContentType();
if (($pos = strpos($contentType, ';')) !== false) {
$contentType = substr($contentType, 0, $pos);
}
// 根据Content Type 选择相应的解析器对请求体进行解析
if (isset($this->parsers[$contentType])) {
// 创建解析器实例
$parser = Yii::createObject($this->parsers[$contentType]);
if (!($parser instanceof RequestParserInterface)) {
throw new InvalidConfigException(
"The '$contentType' request parser is invalid.
It must implement the yii\\web\\RequestParserInterface.");
}
// 将请求体解析到 $this->_bodyParams
$this->_bodyParams = $parser->parse($this->getRawBody(), $contentType);
// 如果没有与Content Type对应的解析器,使用通用解析器
} elseif (isset($this->parsers['*'])) {
$parser = Yii::createObject($this->parsers['*']);
if (!($parser instanceof RequestParserInterface)) {
throw new InvalidConfigException(
"The fallback request parser is invalid.
It must implement the yii\\web\\RequestParserInterface.");
}
$this->_bodyParams = $parser->parse($this->getRawBody(),
$contentType);
// 连通用解析器也没有
// 看看是不是POST请求,如果是,PHP已经将请求参数放到$_POST中了,直接用就OK了
} elseif ($this->getMethod() === 'POST') {
$this->_bodyParams = $_POST;
// 以上情况都不是,那就使用PHP的 mb_parse_str() 进行解析
} else {
$this->_bodyParams = [];
mb_parse_str($this->getRawBody(), $this->_bodyParams);
}
}
return $this->_bodyParams;
}
~~~
在上面的代码中,将所有的请求参数划分为两类, 一类是包含在URL中的,称为查询参数(Query Parameter),或GET参数。 另一类是包含在请求体中的,需要根据请求体的内容类型(Content Type)进行解析,称为POST参数。
其中, get() , getQueryParams() 和 getQueryParam() 用于获取查询参数:
* get() 用于获取GET参数,可以指定所要获取的特定参数的参数名,在这个参数名不存在时,可以指定默认值。 当不指定参数名时,获取所有的GET参数。 具体功能是由下面2个函数来实现的。
* getQueryParams() 用于获取所有的GET参数。 这些参数的内容,保存在 $_GET 或$this->_queryParams 中。优先使用 $this->_queryParams 的。
* getQueryParam() 对应于 get() 用于获取特定的GET参数的情况。
而 post() , getPostParams() 和 getPostParam() 用于获取POST参数:
* post() 与 get() 类似,可以指定所要获取的特定参数的参数名,在这个参数名不存在时,可以指定默认值。 当不指定参数名时,获取所有的POST参数。 具体功能是由下面2个函数来实现的。
* getPostParam() 用于通过参数名获取特定的POST参数,需要调用 getPostParams() 获取所有的POST参数。
* getPostParams() 用于获取所有的POST参数。
上面稍微复杂点的,可能就是 getPostParams() 了,我们就稍稍剖析下Yii是怎么解析POST参数的。 先讲讲这个方法所涉及到的一些东东:内容类型、请求解析器、请求体。
### 内容类型(Content-Type)[](http://www.digpage.com/web_request.html#content-type "Permalink to this headline")
在 getPostParams() 中,需要先获取请求体的内容类型,然后采用相应的解析器对内容进行解析。
获取内容类型,使用 getContentType()
~~~
public function getContentType()
{
if (isset($_SERVER["CONTENT_TYPE"])) {
return $_SERVER["CONTENT_TYPE"];
} elseif (isset($_SERVER["HTTP_CONTENT_TYPE"])) {
return $_SERVER["HTTP_CONTENT_TYPE"];
}
return null;
}
~~~
根据 [CGI 1.1 规范](https://tools.ietf.org/html/rfc3875.html) , 内容类型由 CONTENT_TYPE 环境变量来表示。 而根据 [HTTP 1.1 协议](https://tools.ietf.org/html/rfc2616) , 内容类型则是放在 CONTENT_TYPE 头部中,然后由PHP赋值给 $_SERVER['HTTP_CONTENT_TYPE'] 。 这里一般没有冲突,因此发现哪个用哪个,就怕客户端没有给出(这种情况返回 null )。
### 请求解析器[](http://www.digpage.com/web_request.html#id7 "Permalink to this headline")
在 getPostParams() 中,根据不同的Content Type 创建了相应的内容解析器对请求体进行解析。yii\web\Request 使用成员变量 public $parsers 来保存一系列的解析器。 这个变量在配置时进行指定:
~~~
'request' => [
... ...
'parsers' => [
'application/json' => 'yii\web\JsonParser',
],
]
~~~
$parsers 是一个数组,数组的键是Content Type,如 applicaion/json 之类。 而数组的值则是对应于特定Content Type 的解析器,如 yii\web\JsonParser 。 这也是Yii实现的唯一一个现成的Parser,其他Content-Type,需要开发者自己写了。
而且,可以以 * 为键指定一个解析器。那么该解析器将在一个Content Type找不到任何匹配的解析器后被使用。
yii\web\JsonParser 其实很简单:
~~~
namespace yii\web;
use yii\base\InvalidParamException;
use yii\helpers\Json;
// 所有的解析器都要实现 RequestParserInterface
// 这个接口也只是要求实现 parse() 方法
class JsonParser implements RequestParserInterface
{
public $asArray = true;
public $throwException = true;
// 具体实现 parse()
public function parse($rawBody, $contentType)
{
try {
return Json::decode($rawBody, $this->asArray);
} catch (InvalidParamException $e) {
if ($this->throwException) {
throw new BadRequestHttpException(
'Invalid JSON data in request body: '
. $e->getMessage(), 0, $e);
}
return null;
}
}
}
~~~
这里使用 yii\helpers\Json::decode() 对请求体进行解析。这个 yii\helpers\Json 是个辅助类, 专门用于处理JSON格式数据。具体的内容我们这里就不做讲解了,只需要了解这里可以将JSON格式数据解析出来就OK了, 学有余力的读者朋友可以自己看看代码。
### 请求体[](http://www.digpage.com/web_request.html#id8 "Permalink to this headline")
在 yii\web\Reqeust::getBodyParams() 和 yii\web\RequestParserInterface::parse() 中, 我们可以看到,需要将请求体传入 parse() 进行解析,且请求体由 yii\web\Request::getRawBody() 可得。
yii\web\Request::getRawBody():
~~~
public function getRawBody()
{
if ($this->_rawBody === null) {
$this->_rawBody = file_get_contents('php://input');
}
return $this->_rawBody;
}
~~~
这个方法使用了 php://input 来获取请求体,这个 php://input 有这么几个特点:
* php://input 是个只读流,用于获取请求体。
* php://input 是返回整个HTTP请求中,除去HTTP头部的全部原始内容, 而不管是什么Content Type(或称为编码方式)。 相比较之下, $_POST 只支持 application/x-www-form-urlencoded 和multipart/form-data-encoded 两种Content Type。其中前一种就是简单的HTML表单以method="post" 提交时的形式, 后一种主要是用于上传文档。因此,对于诸如 application/json等Content Type,这往往是在AJAX场景下使用, 那么使用 $_POST 得到的是空的内容,这时就必须使用 php://input 。
* 相比较于 $HTTP_RAW_POST_DATA , php://input 无需额外地在php.ini中 激活always-populate-raw-post-data ,而且对于内存的压力也比较小。
* 当编码方式为 multipart/form-data-encoded 时, php://input 是无效的。这种情况一般为上传文档。 这种情况可以使用传统的 $_FILES 或者 yii\web\UploadedFile 。
## 请求的头部[](http://www.digpage.com/web_request.html#id9 "Permalink to this headline")
yii\web\Request 使用一个成员变量 private $_headers 来存储请求头。 而这个 $_header 其实是一个yii\web\HeaderCollection ,这是一个集合类的基本数据结构, 实现了SPL的 IteratorAggregate ,ArrayAccess 和 Countable 等接口。 因此,这个集合可以进行迭代、像数组一样进行访问、可被用于conut() 函数等。
这个数据结构相对简单,我们就不展开占用篇幅了。我们要讲的是怎么获取请求的头部。 这个是由yii\web\Request::getHeaders() 来实现的:
~~~
public function getHeaders()
{
if ($this->_headers === null) {
// 实例化为一个HeaderCollection
$this->_headers = new HeaderCollection;
// 使用 getallheaders() 获取请求头部,以数组形式返回
if (function_exists('getallheaders')) {
$headers = getallheaders();
// 使用 http_get_request_headers() 获取请求头部,以数组形式返回
} elseif (function_exists('http_get_request_headers')) {
$headers = http_get_request_headers();
// 使用 $_SERVER 数组获取头部
} else {
foreach ($_SERVER as $name => $value) {
// 针对所有 $_SERVER['HTTP_*'] 元素
if (strncmp($name, 'HTTP_', 5) === 0) {
// 将 HTTP_HEADER_NAME 转换成 Header-Name 的形式
$name = str_replace(' ', '-',
ucwords(strtolower(str_replace('_', ' ',
substr($name, 5)))));
$this->_headers->add($name, $value);
}
}
return $this->_headers;
}
// 将数组形式的请求头部变成集合的元素
foreach ($headers as $name => $value) {
$this->_headers->add($name, $value);
}
}
return $this->_headers;
}
~~~
这里用3种方法来尝试获取请求的头部:
* getallheaders() ,这个方法仅在将PHP作为Apache的一个模块运行时有效。
* http_get_request_headers() ,要求PHP启用HTTP扩展。
* $_SERVER 数组的方法,需要遍历整个数组,并将所有以 HTTP_* 元素加入到集合中去。 并且,要将所有 HTTP_HEADER_NAME 转换成 Header-Name 的形式。
就是根据不同的PHP环境,采用有效的方法来获取请求头部,如此而已。
## 请求的解析[](http://www.digpage.com/web_request.html#id10 "Permalink to this headline")
我们前面就说过了,无论是命令行应用还是Web应用,他们的请求都要实现接口要求的 resolve() , 以便明确这个用户请求的路由和参数。下面就是 yii\web\Request::resolve() 的代码:
~~~
public function resolve()
{
// 使用urlManager来解析请求
$result = Yii::$app->getUrlManager()->parseRequest($this);
if ($result !== false) {
list ($route, $params) = $result;
// 将解析出来的参数与 $_GET 参数进行合并
$_GET = array_merge($_GET, $params);
return [$route, $_GET];
} else {
throw new NotFoundHttpException(Yii::t('yii', 'Page not found.'));
}
}
~~~
看着很简单吧?这才几行,还没有 getBodyParams() 的代码多呢。
虽然简单,但是有一个细节我们要留意,就是在解析出路由信息和参数的时候, 会把参数的内容加入到 $_GET 中去,这是合理的。
比如,对于 http://www.digpage.com/post/view/100 这个 100 在路由规则中,其实定义为一个 参数。其原始的形式应当是 http://www.digpage.com/index.php?r=post/view&id=100 。你说该 不该把 id = 100重新写回 $_GET 去?至于路由规则的内容,可以看看 [_路由(Route)_](http://www.digpage.com/route.html#route) 的内 容。
从这个 resolve() 是看不出来解析过程的复杂的,这个 yii\web\Request::resolve() 是个没担当的家伙,他把解析过程推给了 urlManager。 那我们就顺藤摸瓜,一睹这个yii\web\UrlManager::parseRequest() 吧:
~~~
public function parseRequest($request)
{
// 启用了 enablePrettyUrl 的情况
if ($this->enablePrettyUrl) {
// 获取路径信息
$pathInfo = $request->getPathInfo();
// 依次使用所有路由规则来解析当前请求
// 一旦有一个规则适用,后面的规则就没有被调用的机会了
foreach ($this->rules as $rule) {
if (($result = $rule->parseRequest($this, $request)) !== false) {
return $result;
}
}
// 所有路由规则都不适用,又启用了 enableStrictParsing ,
// 那只能返回 false 了。
if ($this->enableStrictParsing) {
return false;
}
// 所有路由规则都不适用,幸好还没启用 enableStrictParing,
// 那就用默认的解析逻辑
Yii::trace(
'No matching URL rules. Using default URL parsing logic.',
__METHOD__);
// 配置时所定义的fake suffix,诸如 ".html" 等
$suffix = (string) $this->suffix;
if ($suffix !== '' && $pathInfo !== '') {
// 这个分支的作用在于确保 $pathInfo 不能仅仅是包含一个 ".html"。
$n = strlen($this->suffix);
// 留意这个 -$n 的用法
if (substr_compare($pathInfo, $this->suffix, -$n, $n) === 0) {
$pathInfo = substr($pathInfo, 0, -$n);
// 仅包含 ".html" 的$pathInfo要之何用?掐死算了。
if ($pathInfo === '') {
return false;
}
// 后缀没匹配上
} else {
return false;
}
}
return [$pathInfo, []];
// 没有启用 enablePrettyUrl的情况,那就更简单了,
// 直接使用默认的解析逻辑就OK了
} else {
Yii::trace(
'Pretty URL not enabled. Using default URL parsing logic.',
__METHOD__);
$route = $request->getQueryParam($this->routeParam, '');
if (is_array($route)) {
$route = '';
}
return [(string) $route, []];
}
}
~~~
从上面代码中可以看到,urlManager是按这么一个顺序来解析用户请求的:
* 先判断是否启用了 enablePrettyUrl,如果没启用,所有的路由和参数信息都在URL的查询参数中, 很简单就可以处理了。
* 通常都会启用 enablePrettyUrl,由于路由和参数信息部分或全部变成了URL路径。 经过了美化,使得URL看起来更友好,但化妆品总是比清水芙蓉要烧银子,解析起来就有点费功夫了。
* 既然路由和参数信息变成了URL路径,那么就先从URL路径下手获取路径信息。
* 然后依次使用已经定义好的路由规则对当前请求进行解析,一旦有一个规则适用, 后续的路由规则就不会起作用了。
* 然后再对配置的 .html 等fake suffix进行处理。
这一过程中,有两个重点,一个是获取路径信息,另一个就是使用路由规则对请求进行解析。下面我们依次进行讲解。
### 获取路径信息[](http://www.digpage.com/web_request.html#id11 "Permalink to this headline")
在大多数情况下,我们还是会启用 enablePrettyUrl 的,特别是在产品环境下。那么从上面的代码来看, yii\web\Request::getPathInfo() 的调用就不可避免。其实涉及到获取路径信息的方法有很多, 都在 yii\web\Request 中,这里暴露出来的,只是一个 getPathInfo() ,相关的方法有:
~~~
// 这个方法其实是调用 resolvePathInfo() 来获取路径信息的
public function getPathInfo()
{
if ($this->_pathInfo === null) {
$this->_pathInfo = $this->resolvePathInfo();
}
return $this->_pathInfo;
}
// 这个才是重点
protected function resolvePathInfo()
{
// 这个 getUrl() 调用的是 resolveRequestUri() 来获取当前的URL
$pathInfo = $this->getUrl();
// 去除URL中的查询参数部分,即 ? 及之后的内容
if (($pos = strpos($pathInfo, '?')) !== false) {
$pathInfo = substr($pathInfo, 0, $pos);
}
// 使用PHP urldecode() 进行解码,所有 %## 转成对应的字符, + 转成空格
$pathInfo = urldecode($pathInfo);
// 这个正则列举了各种编码方式,通过排除这些编码,来确认是 UTF-8 编码
// 出处可参考 http://w3.org/International/questions/qa-forms-utf-8.html
if (!preg_match('%^(?:
[\x09\x0A\x0D\x20-\x7E] # ASCII
| [\xC2-\xDF][\x80-\xBF] # non-overlong 2-byte
| \xE0[\xA0-\xBF][\x80-\xBF] # excluding overlongs
| [\xE1-\xEC\xEE\xEF][\x80-\xBF]{2} # straight 3-byte
| \xED[\x80-\x9F][\x80-\xBF] # excluding surrogates
| \xF0[\x90-\xBF][\x80-\xBF]{2} # planes 1-3
| [\xF1-\xF3][\x80-\xBF]{3} # planes 4-15
| \xF4[\x80-\x8F][\x80-\xBF]{2} # plane 16
)*$%xs', $pathInfo)
) {
$pathInfo = utf8_encode($pathInfo);
}
// 获取当前脚本的URL
$scriptUrl = $this->getScriptUrl();
// 获取Base URL
$baseUrl = $this->getBaseUrl();
if (strpos($pathInfo, $scriptUrl) === 0) {
$pathInfo = substr($pathInfo, strlen($scriptUrl));
} elseif ($baseUrl === '' || strpos($pathInfo, $baseUrl) === 0) {
$pathInfo = substr($pathInfo, strlen($baseUrl));
} elseif (isset($_SERVER['PHP_SELF']) && strpos($_SERVER['PHP_SELF'],
$scriptUrl) === 0) {
$pathInfo = substr($_SERVER['PHP_SELF'], strlen($scriptUrl));
} else {
throw new InvalidConfigException(
'Unable to determine the path info of the current request.');
}
// 去除 $pathInfo 前的 '/'
if ($pathInfo[0] === '/') {
$pathInfo = substr($pathInfo, 1);
}
return (string) $pathInfo;
}
~~~
从 resolvePathInfo() 来看,需要调用到的方法有 getUrl() resolveRequestUri() getScriptUrl()getBaseUrl() 等,这些都是与路径信息密切相关的,让我们分别都看一看。
#### Request URI[](http://www.digpage.com/web_request.html#request-uri "Permalink to this headline")
yii\web\Request::getUrl() 用于获取Request URI的,实际上这只是一个属性的封装, 实质的代码是在 yii\web\Request::resolveRequestUri() 中:
~~~
// 这个其实调用的是 resolveRequestUri() 来获取当前URL
public function getUrl()
{
if ($this->_url === null) {
$this->_url = $this->resolveRequestUri();
}
return $this->_url;
}
// 这个方法用于获取当前URL的URI部分,即主机或主机名之后的内容,包括查询参数。
// 这个方法参考了 Zend Framework 1 的部分代码,通过各种环境下的HTTP头来获取URI。
// 返回值为 $_SERVER['REQUEST_URI'] 或 $_SERVER['HTTP_X_REWRITE_URL'],
// 或 $_SERVER['ORIG_PATH_INFO'] + $_SERVER['QUERY_STRING']。
// 即,对于 http://www.digpage.com/index.html?helloworld,
// 得到URI为 index.html?helloworld
protected function resolveRequestUri()
{
// 使用了开启了ISAPI_Rewrite的IIS
if (isset($_SERVER['HTTP_X_REWRITE_URL'])) {
$requestUri = $_SERVER['HTTP_X_REWRITE_URL'];
// 一般情况,需要去掉URL中的协议、主机、端口等内容
} elseif (isset($_SERVER['REQUEST_URI'])) {
$requestUri = $_SERVER['REQUEST_URI'];
// 如果URI不为空或以'/'打头,则去除 http:// 或 https:// 直到第一个 /
if ($requestUri !== '' && $requestUri[0] !== '/') {
$requestUri = preg_replace('/^(http|https):\/\/[^\/]+/i',
'', $requestUri);
}
// IIS 5.0, PHP以CGI方式运行,需要把查询参数接上
} elseif (isset($_SERVER['ORIG_PATH_INFO'])) {
$requestUri = $_SERVER['ORIG_PATH_INFO'];
if (!empty($_SERVER['QUERY_STRING'])) {
$requestUri .= '?' . $_SERVER['QUERY_STRING'];
}
} else {
throw new InvalidConfigException('Unable to determine the request URI.');
}
return $requestUri;
}
~~~
从上面的代码我们可以知道,Yii针对不同的环境下,PHP的不同表现形式,通过一些分支判断, 给出一个统一的路径名或文件名。 辛辛苦苦那么多,其实就是为了消除不同环境对于开发的影响,使开发者可以更加专注于核心工作。
其实,作为一个开发框架,无论是哪种语言、用于哪个领域, 都需要为开发者提供在各种环境下的都表现一致的编程界面。 这也是开发者可以放心使用的基础条件,如果在使用框架之后, 开发者仍需要考虑各种环境下会怎么样怎么样,那么这个框架注定短命。
这里有必要点一点涉及到的几个 $_SERVER 变量。这里面提到的,读者朋友可以自行阅读 [PHP文档关于$_SERVER的内容](http://php.net/manual/en/reserved.variables.server.php) , 也可以看看 [CGI 1.1 规范的内容](https://tools.ietf.org/html/rfc3875.html) 。
REQUEST_URI
由HTTP 1.1 协议定义,指访问某个页面的URI,去除开头的协议、主机、端口等信息。 如http://www.digpage.com:8080/index.php/foo/bar?queryParams , REQUEST_URI为/index.php/foo/bar?queryParams 。
X-REWRITE-URL
当使用以开启了ISAPI_Rewrite 的IIS作为服务器时,ISAPI_Rewrite会在未对原始URI作任何修改前, 将原始的 REQUEST_URI 以 X-REWRITE-URL HTTP头保存起来。
PATH_INFO
CGI 1.1 规范所定义的环境变量。 从形式上看,http://www.digpage.com:8080/index.php?queryParams 。 它是整个URI中,在脚本标识之后、查询参数 ? 之前的部分。 对于Apache,需要设置 AcceptPathInfo On ,且在一个URL没有 部分的时候, PATH_INFO 无效。特殊的情况,如 http://www.digpage.com/index.php/, PATH_INFO 为 / 。 而对于Nginx,则需要设置:
~~~
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
~~~
ORIG_PATH_INFO
在PHP文档中对它的解释语焉不详,“指未经PHP处理过的原始的PATH_INFO”。 这个在Apache和Nginx需要配置一番才行,但一般用不到,已经有PATH_INFO可以用了嘛。而在IIS中则有点怪, 对于http://www.digpage.com/index.php/ ORIG_PATH_INFO 为 /index.php/ ; 对于http://www.digapge.com/index.php ORIG_PATH_INFO 为 /index.php 。
根据上面这些背景知识,再来看 resolveRequestUri() 就简单了:
* 最广泛的情况,应当是使用 REQUEST_URI 来获取。但是 resolveRequestUri() 却先使用 X-REWRITE-URL, 这是为了防止REQUEST_URI被rewrite。
* 其次才是使用 REQUEST_URI,这对于绝大多数情况是完全够用的了。
* 但REQUEST_URI毕竟只是规范的要求,Web服务器很有可能店大欺客、另立山头,我们又不是第一次碰见了是吧? 所以,Yii使用了平时比较少用到的ORIG_PATH_INFO。
* 最后,按照规范要求进行规范化,该去头的去头,该续尾的续尾。去除主机信息段和查询参数段后, 就大功告成了。
#### 入口脚本路径[](http://www.digpage.com/web_request.html#id14 "Permalink to this headline")
yii\web\Request::getScriptUrl() 用于获取入口脚本的相对路径,也涉及到不同环境下PHP的不同表现。 我们还是先从代码入手:
~~~
// 这个方法用于获取当前入口脚本的相对路径
public function getScriptUrl()
{
if ($this->_scriptUrl === null) {
// $this->getScriptFile() 用的是 $_SERVER['SCRIPT_FILENAME']
$scriptFile = $this->getScriptFile();
$scriptName = basename($scriptFile);
// 下面的这些判断分支代码,为各主流PHP framework所用,
// Yii, Zend, Symfony等都是大同小异。
if (basename($_SERVER['SCRIPT_NAME']) === $scriptName) {
$this->_scriptUrl = $_SERVER['SCRIPT_NAME'];
} elseif (basename($_SERVER['PHP_SELF']) === $scriptName) {
$this->_scriptUrl = $_SERVER['PHP_SELF'];
} elseif (isset($_SERVER['ORIG_SCRIPT_NAME']) &&
basename($_SERVER['ORIG_SCRIPT_NAME']) === $scriptName) {
$this->_scriptUrl = $_SERVER['ORIG_SCRIPT_NAME'];
} elseif (($pos = strpos($_SERVER['PHP_SELF'], '/' . $scriptName))
!== false) {
$this->_scriptUrl = substr($_SERVER['SCRIPT_NAME'], 0, $pos)
. '/' . $scriptName;
} elseif (!empty($_SERVER['DOCUMENT_ROOT'])
&& strpos($scriptFile, $_SERVER['DOCUMENT_ROOT']) === 0) {
$this->_scriptUrl = str_replace('\\', '/',
str_replace($_SERVER['DOCUMENT_ROOT'], '', $scriptFile));
} else {
throw new InvalidConfigException(
'Unable to determine the entry script URL.');
}
}
return $this->_scriptUrl;
}
~~~
上面的代码涉及到了一些环境问题,点一点,大家了解下就OK了:
SCRIPT_FILENAME
当前脚本的实际物理路径,比如 /var/www/digpage.com/frontend/web/index.php , 或WIN平台的D:\www\digpage.com\frontend\web\index.php 。 以Nginx为例,一般情况下,SCRIPT_FILENAME有以下配置项:
~~~
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
# 使用 document root 来得到物理路径
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name
~~~
SCRIPT_NAME
CGI 1.1 规范所定义的环境变量,用于标识CGI脚本(而非脚本的输出), 如http://www.digapge.com/path/index.php 中的 /path/index.php 。 仍以Nginx为例,SCRIPT_NAME一般情况下有 fastcgi_param SCRIPT_NAME $fastcgi_script_name 的设置。 绝大多数情况下,使用 SCRIPT_NAME 即可获取当前脚本。
PHP_SELF
PHP_SELF 是PHP自己实现的一个 $_SERVER 变量,是相对于文档根目录(document root)而言的。 对于 http://www.digpage.com/path/index.php?queryParams ,PHP_SELF 为 /path/index.php 。 一般SCRIPT_NAME 与 PHP_SELF 无异。但是,在 PHP.INI 中,如 cgi.fix_pathinfo=1 (默认即为1)时, 对于形如 http://www.digpage.com/path/index.php/post/view/123 , 则PHP_SELF 为/path/index.php/post/view/123 。 而根据 CGI 1.1 规范,SCRIPT_NAME 仅为 /path/index.php ,至于剩余的 /post/view/123 则为PATH_INFO。
ORIG_SCRIPT_NAME
当PHP以CGI模式运行时,默认会对一些环境变量进行调整。 首当其冲的,就是 SCRIPT_NAME 的内容会变成 php.cgi 等二进制文件,而不再是CGI脚本文件。 当然,设置 cgi.fix_pathinfo=0 可以关闭这一默认行为。但这导致的副作用比较大,影响范围过大,不宜使用。 但天无绝人之路,九死之地总留一线生机,那就是ORIG_SCRIPT_NAME,他保留了调整前 SCRIPT_NAME 的内容。 也就是说,在CGI模式下,可以使用 ORIG_SCRIPT_NAME 来获取想要的SCRIPT_NAME。 请留意使用 ORIG_SCRIPT_NAME 前一定要先确认它是否存在。
交待完这些背景知识后,我们再来看看 yii\web\Request::getScriptUrl() 的逻辑:
* 先调用 yii\web\Request::getScriptFile() , 通过 basename($_SERVER['SCRIPT_FILENAME']) 获取脚本文件名。一般都是我们的入口脚本 index.php 。
* 绝大多数情况下, base($_SERVER('SCRIPT_NAME')) 是与第一步获取的 index.php 相同的。 如果这样的话,则认为这个 SCRIPT_NAME 就是我们所要的脚本URL。
这也是规范的定义。但是既然称为规范,说明并非是事实。 事实是由Web服务器来实现的,也就是说Web服务器可能进行修改。
另外,对于运行于CGI模式的PHP而言,使用 SCRIPT_NAME 也无法获得脚本名。
* 那么我们转而向PHP_SELF求助,这个是PHP内部实现的,不受Web服务器的影响。一般这个PHP_SELF也是可堪一用的, 但也不是放之四海而皆准,对于带有PATH_INFO的URI,basename() 获取的并不是脚本名。
* 于是我们再转向 ORIG_SCRIPT_NAME 求助,如果PHP是运行于CGI模式,那么就可行。
* 再不成功,可能PHP并非运行于CGI模式(否则第4步就可以成功),且URI中带有PATH_INFO(否则第二步就可以成功)。 对于这种情形,PHP_SELF的前面一截就是我们要的脚本URL 。
* 万一以上情况都不符合,说明当前PHP运行的环境诡异莫测。 那只能寄希望于将 SCRIPT_FILENAME 中前面那截可能是Document Root的部分去掉,余下的作为脚本URL了。 前提是要有Document Root,且SCRIPT_FILENAME前面的部分可以匹配上。
#### Base Url[](http://www.digpage.com/web_request.html#base-url "Permalink to this headline")
获取路径信息的最后一个相关方法,就是 yii\web\Request::getBaseUrl():
~~~
// 获取Base Url
public function getBaseUrl()
{
if ($this->_baseUrl === null) {
// 用上面的脚本路径的父目录,再去除末尾的 \ 和 /
$this->_baseUrl = rtrim(dirname($this->getScriptUrl()), '\\/');
}
return $this->_baseUrl;
}
~~~
这个Base Url很简单,相信聪明如你肯定一目了然,我就不浪费篇幅了。
好了,上面就是 yii\web\Request::resolve() 中有关获取路径信息的内容。 下一步就是使用路由规则去解析当前请求了。
### 使用路由规则解析[](http://www.digpage.com/web_request.html#id15 "Permalink to this headline")
上面这么多有关从请求获取路径信息的内容,其实只完成了请求解析的第一步而已。 接下来,urlManager就要遍历所有的路由规则来解析当前请求,直到有一个规则适用为止。
路由规则层面对于请求的解析,我们在 [_路由(Route)_](http://www.digpage.com/route.html#route) 的 [_解析URL_](http://www.digpage.com/route.html#parse-url) 部分已经讲得很清楚了。
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
请求(Reqeust)
最后更新于:2022-04-01 02:20:04
## 获取用户请求[](http://www.digpage.com/request.html#id1 "Permalink to this headline")
PHP并未提供集中的、统一的界面以获取用户请求,而是分散在 $_SERVER $_POST 等变量和其他代码中。 万能的Yii怎么会允许群雄割据这种局面出现呢?他肯定是要一统江湖的。 那么对于任何Yii应用而言,初始化后第一件正事,就是获取用户请求。 这个代码在 yii\base\Application::run() 中:
~~~
public function run()
{
try {
$this->state = self::STATE_BEFORE_REQUEST;
$this->trigger(self::EVENT_BEFORE_REQUEST);
$this->state = self::STATE_HANDLING_REQUEST;
// 获取用户请求,并进行处理,处理的过程也是产生响应内容的过程
$response = $this->handleRequest($this->getRequest());
$this->state = self::STATE_AFTER_REQUEST;
$this->trigger(self::EVENT_AFTER_REQUEST);
$this->state = self::STATE_SENDING_RESPONSE;
// 将响应内容发送回用户
$response->send();
$this->state = self::STATE_END;
return $response->exitStatus;
} catch (ExitException $e) {
$this->end($e->statusCode, isset($response) ? $response : null);
return $e->statusCode;
}
}
~~~
上面的代码主要看注释的两个地方,聪明的读者朋友们一定都猜出来了, $this->getRequest() 就是用于获取用户请求的嘛。
其实这是一个getter,用于获取Application的request组件 (component) 。Yii用这个组件来代表用户请求, 他承载着所有的用户输入信息。
我们知道,Yii应用有命令行(Console)应用和Web应用之分。因此,这个Request类其实涉及到了以下的类:
* yii\base\Request Request类基类
* yii\console\Request 表示Console应用的的Request
* yii\web\Request 表示Web应用的Request
下面我们逐一进行讲解。
## 基类Request[](http://www.digpage.com/request.html#id2 "Permalink to this headline")
基类是对Console应用和Web应用Request的抽象,他仅仅定义了两个属性和一个虚函数:
~~~
abstract class Request extends Component
{
// 属性scriptFile,用于表示入口脚本
private $_scriptFile;
// 属性isConsoleRequest,用于表示是否是命令行应用
private $_isConsoleRequest;
// 虚函数,要求子类来实现
// 这个函数的功能主要是为了把Request解析成路由和相应的参数
abstract public function resolve();
// isConsoleRequest属性的getter函数
// 使用 PHP_SAPI 常量判断当前应用是否是命令行应用
public function getIsConsoleRequest()
{
// 一切 PHP_SAPI 不为 'cli' 的,都不是命令行
return $this->_isConsoleRequest !== null ?
$this->_isConsoleRequest : PHP_SAPI === 'cli';
}
// isConsoleRequest属性的setter函数
public function setIsConsoleRequest($value)
{
$this->_isConsoleRequest = $value;
}
// scriptFile属性的getter函数
// 通过 $_SERVER['SCRIPT_FILENAME'] 来获取入口脚本名
public function getScriptFile()
{
if ($this->_scriptFile === null) {
if (isset($_SERVER['SCRIPT_FILENAME'])) {
$this->setScriptFile($_SERVER['SCRIPT_FILENAME']);
} else {
throw new InvalidConfigException(
'Unable to determine the entry script file path.');
}
}
return $this->_scriptFile;
}
// scriptFile属性的setter函数
public function setScriptFile($value)
{
$scriptFile = realpath(Yii::getAlias($value));
if ($scriptFile !== false && is_file($scriptFile)) {
$this->_scriptFile = $scriptFile;
} else {
throw new InvalidConfigException(
'Unable to determine the entry script file path.');
}
}
}
~~~
yii\base\Request 通过getter和setter提供了两个可读写的属性, isConsoleRequest 和 scriptFile 。 同时,要求子类实现一个 resolve() 方法。
基类的代码相对简单,主要涉及到PHP的一些知识,如 PHP_SAPI $_SERVER['SCRIPT_FILENAME'] 等, 读者朋友们可以通过搜索引擎或PHP手册了解下相关的知识,相信上面的代码难不倒你们的。
## 命令行应用Request[](http://www.digpage.com/request.html#id3 "Permalink to this headline")
命令行应用Request由 yii\console\Request 负责实现,相比较于 yii\base\Request 稍有丰富:
~~~
class Request extends \yii\base\Request
{
// 属性 params,用于表示命令行参数
private $_params;
// params属性的getter函数
// 通过 $_SERVER['argv'] 来获取命令行参数
public function getParams()
{
if (!isset($this->_params)) {
if (isset($_SERVER['argv'])) {
$this->_params = $_SERVER['argv'];
// 删除数组的第一个元素,这个元素是PHP脚本名。
// 因此,属性params中全部是参数,不带脚本名
array_shift($this->_params);
} else {
$this->_params = [];
}
}
return $this->_params;
}
// params属性的setter函数
public function setParams($params)
{
$this->_params = $params;
}
// 父类虚函数的实现
public function resolve()
{
// 获取全部的命令行参数
$rawParams = $this->getParams();
// 第一个命令行参数作为路由
if (isset($rawParams[0])) {
$route = $rawParams[0];
array_shift($rawParams);
} else {
$route = '';
}
$params = [];
// 遍历剩余的全部命令行参数
foreach ($rawParams as $param) {
// 正则匹配每一个参数
if (preg_match('/^--(\w+)(=(.*))?$/', $param, $matches)) {
// 参数名
$name = $matches[1];
// yii\console\Application::OPTION_APPCONFIG = 'appconfig'
if ($name !== Application::OPTION_APPCONFIG) {
$params[$name] = isset($matches[3]) ? $matches[3] : true;
}
// 无名参数,直接作为参数值
} else {
$params[] = $param;
}
}
return [$route, $params];
}
}
~~~
相比较于 yii\base\Request , yii\console\Request 提供了一个 params 属性, 该属性以数组形式保存了入口脚本 Yii 的命令行参数。这是通过 $_SERVER['argv'] 获取的。 注意 params 属性不保存入口脚本名。入口脚本名由基类的 scriptName 属性保存。
同时, yii\console\Request 还实现了父类的 resolve() 虚函数, 这个函数主要做了这么几件事:
* 将 params 属性的第一个元素作为路由。如果入口脚本未提供任何参数,也即 params 是个空数组, 那么将路由置为一个空字符串。
* 遍历 params 中剩余的参数,使用正则匹配Yii应用的参数名和参数值,看看是不是--参数名=参数值 形式。 其中,以 -- 打头的任意字母、数字、下划线的组合,就是参数名。 紧跟参数名的 = 后面的内容,则为参数值。 对于仅有参数名,没有参数值的,视参数值为true 。
* 如果正则匹配不成功,则将这个命令行参数作为Yii应用的一个无名参数的值。
* 如果第二步中的参数名为 appconfig 则忽略该参数,Console Application会专门针对该参数进行处理。
* 上面步骤中的参数和参数值,被保存进一个数组中。数组的键表示参数名,数组的值表示参数值。
* 最终 resolve() 返回一个数组,第一个元素是一个表示路由的字符串,第二元素则是参数数组。 该方法由Application在处理Request时调用。
关于 appconfig 参数的问题,只要在调用 yii 时,指定了 appconfig 参数, 就表明不使用默认的参数配置文件,而使用该参数所指定的配置文件。相关的代码在 yii\console\Application 中:
~~~
// 定义一个常量
const OPTION_APPCONFIG = 'appconfig';
// yii\console\Application类的构造函数
public function __construct($config = [])
{
// 重点看这句,会调用loadConfig() 成员函数
$config = $this->loadConfig($config);
parent::__construct($config);
}
// 如果指定的配置文件存在,那么返回其配置数组
// 否则,返回构造函数调用时的数组
protected function loadConfig($config)
{
if (!empty($_SERVER['argv'])) {
// 设定了一个字符串 "--appconfig="
$option = '--' . self::OPTION_APPCONFIG . '=';
// 遍历所有命令行参数,看看能不能找到上面说的这个字符串
foreach ($_SERVER['argv'] as $param) {
if (strpos($param, $option) !== false) {
// 截取参数值部分
$path = substr($param, strlen($option));
if (!empty($path) && is_file($file = Yii::getAlias($path))) {
// 将指定文件的内容引入进来
return require($file);
} else {
die("The configuration file does not exist: $path\n");
}
}
}
}
return $config;
}
~~~
讲完了Request基类和命令行应用的Request只是热身而已,接下来要讲的Web应用Request才是重头。 毕竟最最主要的,还是Web开发嘛。考虑到Web Request的内容较多,还是单独成 [_Web应用Request_](http://www.digpage.com/web_request.html#web-request) 来讲吧。
Url管理
最后更新于:2022-04-01 02:20:01
在Web开发中,对于URL有一些共性的需求,如:
* 统一、简洁的URL创建方式
* URL的伪静态化(美化)处理
* 从URL中解析出相应的路由信息,引导应用执行后续处理
这些功能在 前面我们讲的 UrlRule 层面已经得到了一定程度的实现。 但从层次上来讲,UrlRule 更偏向于基础一些,直接使用 UrlRule 相对而言还不是很方便。
比如,针对各种类型的URL,我们需要提供相应的 UrlRule 实例来进行处理。 这些实例如何进行统一管理,相互关系怎么处理?都无法在 UrlRule 自身层面解决。
我们需要更贴近开发的接口。于是Yii把Web应用中对于URL的常用要求抽象到了urlManager中, 并作为Web应用的核心组件,更便于开发者使用。
## urlManager概览[](http://www.digpage.com/urlmanager.html#id1 "Permalink to this headline")
urlManager组件由 yii\web\UrlManager 类定义:
~~~
class UrlManager extends Component
{
// 用于表明urlManager是否启用URL美化功能,在Yii1.1中称为path格式URL,
// Yii2.0中改称美化。
// 默认不启用。但实际使用中,特别是产品环境,一般都会启用。
public $enablePrettyUrl = false;
// 是否启用严格解析,如启用严格解析,要求当前请求应至少匹配1个路由规则,
// 否则认为是无效路由。
// 这个选项仅在 enablePrettyUrl 启用后才有效。
public $enableStrictParsing = false;
// 保存所有路由规则的配置数组,并不在这里保存路由规则的实例
public $rules = [];
// 指定续接在URL后面的一个后缀,如 .html 之类的。仅在 enablePrettyUrl 启用时有效。
public $suffix;
// 指定是否在URL在保留入口脚本 index.php
public $showScriptName = true;
// 指定不启用 enablePrettyUrl 情况下,URL中用于表示路由的查询参数,默认为 r
public $routeParam = 'r';
// 指定应用的缓存组件ID,编译过的路由规则将通过这个缓存组件进行缓存。
// 由于应用的缓存组件默认为 cache ,所以这里也默认为 cache 。
// 如果不想使用缓存,需显式地置为 false
public $cache = 'cache';
// 路由规则的默认配置,注意上面的 rules[] 中的同名规则,优先于这个默认配置的规则。
public $ruleConfig = ['class' => 'yii\web\UrlRule'];
private $_baseUrl;
private $_scriptUrl;
private $_hostInfo;
// urlManager 初始化
public function init()
{
parent::init();
// 如果未启用 enablePrettyUrl 或者没有指定任何的路由规则,
// 这个urlManager不需要进一步初始化。
if (!$this->enablePrettyUrl || empty($this->rules)) {
return;
}
// 初始化前, $this->cache 是缓存组件的ID,是个字符串,需要获取其实例。
if (is_string($this->cache)) {
// 如果获取不到实例,说明应用不提供缓存功能,
// 那么置这个 $this->cache 为false
$this->cache = Yii::$app->get($this->cache, false);
}
// 如果顺利引用到了缓存组件,那么就将路由规则缓存起来
if ($this->cache instanceof Cache) {
// 以当前urlManager类的类名为缓存的键
$cacheKey = __CLASS__;
// urlManager所有路由规则转换为json格式编码后的HASH值,
// 用于确保缓存中的路由规则没有变化。
// 即外部没有对已经缓存起来的路由规则有增加、修改、
// 删除、调整前后位置等操作。
$hash = md5(json_encode($this->rules));
// cache中是一个数组, 0号元素用于缓存创建好的路由规则,
// 1号元素用于保存HASH值。这个判断用于确认是否有缓存、且缓存仍有效。
// 是的话,直接使用缓存中的内容作为当前的路由规则数组。
if (($data = $this->cache->get($cacheKey)) !== false
&& isset($data[1]) && $data[1] === $hash) {
$this->rules = $data[0];
// 如果尚未缓存或路由规则已经被修改导致缓存失效,
// 那么重新创建路由规则并缓存。
} else {
$this->rules = $this->buildRules($this->rules);
$this->cache->set($cacheKey, [$this->rules, $hash]);
}
// 要么是应用不提供缓存功能,要么是开发者将 $this->cache 手动置为false,
// 总之,就是不使用缓存。那么就直接创建吧,也无需缓存了。
} else {
$this->rules = $this->buildRules($this->rules);
}
}
// 增加新的规则
public function addRules($rules, $append = true){ ... }
// 创建路由规则
protected function buildRules($rules){ ... }
// 用于解析请求
public function parseRequest($request){ ... }
// 这2个用于创建URL
public function createUrl($params){ ... }
public function createAbsoluteUrl($params, $scheme = null){ ... }
}
~~~
在urlManager的使用上,用得最多的配置项就是:
* $enablePrettyUrl ,是否开启URL美化功能。关于美化功能,我们在 [_路由(Route)_](http://www.digpage.com/route.html#route) 部分已经介绍过了。 注意如果 $enablePrettyUrl 不开启,表明使用原始的格式,那么所有路由规则都是无效的。
* $showScriptName ,是否在URL中显示入口脚本。是对美化功能的进一步补充。
* suffix 设置一个 .html 之类的假后缀,是对美化功能的进一步补充。
* rules 保存路由规则们的声明,注意并非保存其实例。
* $enableStrictParsing 是否开启严格解析。该选项仅在开启美化功能后生效。在开启严格解析模式时, 所有请求必须匹配 $rules[] 所声明的至少一个路由规则。 如果未开启,请求的PATH_INFO部分将作为所请求的路由进行后续处理。
在 UrlManager::init() 初始化过程中,可以发现 urlManager 使用了应用所提供的缓存组件(有果有的话), 对所有路由规则的实例进行缓存。
从架构上来讲,将所有请求交由入口脚本统一接收,再分发到相应模块进行处理的这种方式, 就注定了入口脚本有产生性能瓶颈的可能。但是带来的开发上的便利,却是实实在在的。 可以想像,在由Web Server进行请求分发的情景下,每个接收请求的脚本都要执行相同或类似的代码, 这会造成很冗余。而且会将权限控制、日志记录等逻辑上就应当作为所有请求第一关的的模块都分散到各处去。
因此,目前这种单一入口脚本的设计成为事实上的标准,几乎所有的Web开发框架都采用这种方式。 但这同时也对各框架的性能提出挑战。
在前面讲路由规则时,我们就体会到了初始化过程的繁琐,转换来转换去的。 如果采用简单粗暴的方式,Yii完全可以牺牲一定的开发便利性,在代码层面提高路由规则的性能。 比如,直接使用正则表达式。
但是,Yii没有这样做,而是很好地平稳了性能与开发便利性,通过将路由规则进行缓存来克服这个瓶颈。
TBD
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
路由(Route)
最后更新于:2022-04-01 02:19:59
Web开发中不可避免的要使用到URL。用得最多的,就是生成一个指向应用中其他某个页面的URL了。 开发者需要一个简洁的、集中的、统一的方法来完成这一过程。
否则的话,在代码中写入大量的诸如 http://www.digpage.com/post/view/100 的代码,一是过于冗长,二是易出错且难排查, 三是日后修改起来容易有遗漏。因此,从开发角度来讲,需要一种更简洁、可以统一管理、 又能排查错误的解决方案。
同时,我们在 [_附录2:Yii的安装_](http://www.digpage.com/install.html#install) 部分讲解了如何为Yii配置Web服务器,从中可以发现, 所有的用户请求都是发送给入口脚本 index.php 来处理的。那么,Yii需要提供一种高效的分派 请求的方法,来判断请求应当采用哪个 controller 哪个 action 进行处理。
结合以上2点需求,Yii为开发者提供了路由和URL管理组件。
所谓路由是指URL中用于标识用于处理用户请求的module, controller, action的部分, 一般情况下由 r查询参数来指定。 如 http://www.digpage.com/index.php?r=post/view&id=100 , 表示这个请求将由PostController 的 actionView来处理。
同时,Yii也提供了一种美化URL的功能,使得上面的URL可以用一个比较整洁、美观的形式表现出来, 如 http://www.digpage.com/post/view/100 。 这个功能的实现是依赖于一个称为 urlManager 的应用组件。
使用 urlManager 开发者可以解析用户的请求,并指派相应的module, controller和action来进行处理, 还可以根据预义的路由规则,生成需要的URL返回给用户使用。 简而言之,urlManger具有解析请求以便确定指派谁来处理请求和根据路由规则生成URL 2个功能。
## 美化URL[](http://www.digpage.com/route.html#url "Permalink to this headline")
一般情况下,Yii应用生成和接受形如 http://www.digpage.com/index.php?r=post/view&id=100 的URL。这个URL分成几个部分:
* 表示主机信息的 http://www.digapge.com
* 表示入口脚本的 index.php
* 表示路由的 r=post/view
* 表示普通查询参数的 id=100
其中,主机信息部分从URL来讲,一般是不能少的。当然内部链接可以使用相对路径,这种情况下看似 可以省略,但是User Agent最终发出Request时,也是包含主机信息的。换句话说,Web Server接收并 转交给Yii处理的URL,是完整的、带有主机信息的URL。
而入口脚本 index.php 我们知道,Web Server会将所有的请求都是交由其进行处理。 也就是说,Web Server应当视所有的URL为请求 index.php 脚本。这在 :ref:install 部分我们 已经对Web Server进行过相应配置了。如Nginx:
~~~
location / {
try_files $uri $uri/ /index.php?$args;
}
~~~
即然这样,URL中有没有指定 index.php 已经不重要了,反正都是请求的它。 在URL里面假惺惺地留个 index.php ,实在是画蛇添足。 因此,Yii允许我们不在URL中出现入口脚本 index.php 。
其次,路由信息对于Yii应用而言也必不可少,表明应当使用哪个controller和action来处理请求, 否则Yii只能使用默认的路由来处理请求。这个形式比较固定,采用的是一种类似路径的形式, 一般为module/controller/action 之类的。
如果将URL省略掉入口脚本,并将路由信息转换成路径,上面的URL就会变成:http://www.digpage.com/post/view?id=100 , 是不是看起来舒服很多?
这样的链接看起来简洁美观,对于用户比较友好。同时,也比较适合搜索引擎的胃口, 据说是SEO的手段之一。
但到了这里还没完,对于查询参数 id=100 而言,这个URL请求的是编号为100的一个POST, 并执行view操作。那么我们可以再进一步改成 http://www.digpage.com/post/view/100 。 这样是不是更爽?
有处女座的说了,这个编号100跟前面的字母们放一起显得另类呀,要是都是字母的就更好了。 那我们假如所请求的编号100的文章,其标题为 Route , 那么不妨使用用http://www.digpage.com/post/view/Route 来访问。
这样的话,干脆再加上 .html 好了。 变成 http://www.digpage.com/post/view/Route.html , 这样的URL对比原来,堪称完美了吧?岂不是连处女座也满意了?
我们把 URL http://www.digpage.comindex.php?r=post/view&id=100 变成http://www.digpage.com/post/view/Route.html 的过程就称为URL美化。
Yii有专门的 yii\web\UrlManager 来进行处理,其中:
* 隐藏入口脚本可以通过 yii\web\UrlManager::showScriptName = false 来实现
* 路由的路径化可以通过 yii\web\UrlManager::enablePrettyUrl = true 来实现
* 参数的路径化可以通过路由规则来实现
* 假后缀(fake suffix) .html 可以通过 yii\web\UrlManager::suffix = '.html' 来实现
这里点一点,有个印象就可以下,在 [_Url管理_](http://www.digpage.com/urlmanager.html#urlmanager) 部分就会讲到了。
## 路由规则[](http://www.digpage.com/route.html#id2 "Permalink to this headline")
所谓孤掌难鸣,urlManager要发挥功能靠单打独斗是不行的,还要有另外一个的东东来配合。 这就是我们本篇要重点讲的:路由规则。
路由规则是指 urlManager 用于解析请求或生成URL的规则。 一个路由规则必须实现yii\web\UrlRuleInterface 接口,这个接口定义了两个方法:
* 用于解析请求的 yii\web\UrlRuleInterface::parseRequest()
* 用于生成URL的 yii\web\UrlRuleInterface::createUrl()
Yii中,使用 yii\web\UrlRule 来表示路由规则,一般这个类是足够开发者使用的。 但是,如果开发者想自己实现解析请求或生成URL的逻辑,可以以这个类为基类进行派生, 并重载 parseRuquest() 和createUrl() 。
以下是配置文件中urlManager组件的路由规则配置部分,以几个相对简单、典型的路由规则的为例, 先有个感性认识:
~~~
'rules' => [
// 为路由指定了一个别名,以 post 的复数形式来表示 post/index 路由
'posts' => 'post/index',
// id 是命名参数,post/100 形式的URL,其实是 post/view&id=100
'post/<id:\d+>' => 'post/view',
// controller action 和 id 以命名参数形式出现
'<controller:(post|comment)>/<id:\d+>/<action:(create|update|delete)>'
=> '<controller>/<action>',
// 包含了 HTTP 方法限定,仅限于DELETE方法
'DELETE <controller:\w+>/<id:\d+>' => '<controller>/delete',
// 需要将 Web Server 配置成可以接收 *.digpage.com 域名的请求
'http://<user:\w+>.digpage.com/<lang:\w+>/profile' => 'user/profile',
]
~~~
上面的例子并没有穷尽路由规则的例子,可以玩的花样还有很多。至于这些例子所表达的规则, 读者朋友们可以发挥想像去猜测,相信你们绝对可以猜个八九不离十。
目前不需要了解太多,只需大致了解上面这个数组用于为urlManager声明路由规则。 数组的键相当于请求(需要解析的或将要生成的),而元素的值则对应的路由, 即 controller/action 。请求部分可称为pattern,路由部分则可称为route。 对于这2个部分的形式,大致上可以这么看:
* pattern 是从正则表达式变形而来。去除了两端的 / # 等分隔符。 特别注意别在pattern两端画蛇添足加上分隔符。
* pattern 中可以使用正则表达式的命名参数,以供route部分引用。这个命名参数也是变形了的。 对于原来 (?Ppattern) 的命名参数,要变形成 。
* pattern 中可以使用HTTP方法限定。
* route 不应再含有正则表达式,但是可以按 的形式引用命名参数。
也就是说,解析请求时,Yii从左往右使用这个数组;而生成URL时Yii从右往左使用这个数组。
至于具体实现过程,我们马上就会讲。
首先是 yii\web\UrlRule 的代码,让我们来大致看一看:
~~~
class UrlRule extends Object implements UrlRuleInterface
{
// 用于 $mode 表示路由规则的2种工作模式:仅用于解析请求和仅用于生成URL。
// 任意不为1或2的值均表示两种模式同时适用,
// 一般未设定或为0时即表示两种模式均适用。
const PARSING_ONLY = 1;
const CREATION_ONLY = 2;
// 路由规则名称
public $name;
// 用于解析请求或生成URL的模式,通常是正则表达式
public $pattern;
// 用于解析或创建URL时,处理主机信息的部分,如 http://www.digpage.com
public $host;
// 指向controller 和 action 的路由
public $route;
// 以一组键值对数组指定若干GET参数,在当前规则用于解析请求时,
// 这些GET参数会被注入到 $_GET 中去
public $defaults = [];
// 指定URL的后缀,通常是诸如 ".html" 等,
// 使得一个URL看起来好像指向一个静态页面。
// 如果这个值未设定,使用 UrlManager::suffix 的值。
public $suffix;
// 指定当前规则适用的HTTP方法,如 GET, POST, DELETE 等。
// 可以使用数组表示同时适用于多个方法。
// 如果未设定,表明当前规则适用于所有方法。
// 当然,这个属性仅在解析请求时有效,在生成URL时是无效的。
public $verb;
// 表明当前规则的工作模式,取值可以是 0, PARSING_ONLY, CREATION_ONLY。
// 未设定时等同于0。
public $mode;
// 表明URL中的参数是否需要进行url编码,默认是进行。
public $encodeParams = true;
// 用于生成新URL的模板
private $_template;
// 一个用于匹配路由部分的正则表达式,用于生成URL
private $_routeRule;
// 用于保存一组匹配参数的正则表达式,用于生成URL
private $_paramRules = [];
// 保存一组路由中使用的参数
private $_routeParams = [];
// 初始化
public function init() {...}
// 用于解析请求,由UrlRequestInterface接口要求
public function parseRequest($manager, $request) {...}
// 用于生成URL,由UrlRequestInterface接口要求
public function createUrl($manager, $route, $params) {...}
}
~~~
从上面代码看, UrlRule 的属性(可配置项)比较多。各属性的意义在注释中已经写清楚了,这里就不再复述。 但是我们要着重分析一下初始化函数 yii\web\UrlRule::init() ,来加深对这些属性的理解:
~~~
public function init()
{
// 一个路由规则必定要有 pattern ,否则是没有意义的,
// 一个什么都没规定的规定,要来何用?
if ($this->pattern === null) {
throw new InvalidConfigException('UrlRule::pattern must be set.');
}
// 不指定规则匹配后所要指派的路由,Yii怎么知道将请求交给谁来处理?
// 不指定路由,Yii怎么知道这个规则可以为谁创建URL?
if ($this->route === null) {
throw new InvalidConfigException('UrlRule::route must be set.');
}
// 如果定义了一个或多个verb,说明规则仅适用于特定的HTTP方法。
// 既然是HTTP方法,那就要全部大写。
// verb的定义可以是字符串(单一的verb)或数组(单一或多个verb)。
if ($this->verb !== null) {
if (is_array($this->verb)) {
foreach ($this->verb as $i => $verb) {
$this->verb[$i] = strtoupper($verb);
}
} else {
$this->verb = [strtoupper($this->verb)];
}
}
// 若未指定规则的名称,那么使用最能区别于其他规则的 $pattern
// 作为规则的名称
if ($this->name === null) {
$this->name = $this->pattern;
}
// 删除 pattern 两端的 "/",特别是重复的 "/",
// 在写 pattern 时,虽然有正则的成分,但不需要在两端加上 "/",
// 更不能加上 "#" 等其他分隔符
$this->pattern = trim($this->pattern, '/');
// 如果定义了 host ,将 host 部分加在 pattern 前面,作为新的 pattern
if ($this->host !== null) {
// 写入的host末尾如果已经包含有 "/" 则去掉,特别是重复的 "/"
$this->host = rtrim($this->host, '/');
$this->pattern = rtrim($this->host . '/' . $this->pattern, '/');
// 既未定义 host ,pattern 又是空的,那么 pattern 匹配任意字符串。
// 而基于这个pattern的,用于生成的URL的template就是空的,
// 意味着使用该规则生成所有URL都是空的。
// 后续也无需再作其他初始化工作了。
} elseif ($this->pattern === '') {
$this->_template = '';
$this->pattern = '#^$#u';
return;
// pattern 不是空串,且包含有 '://',以此认定该pattern包含主机信息
} elseif (($pos = strpos($this->pattern, '://')) !== false) {
// 除 '://' 外,第一个 '/' 之前的内容就是主机信息
if (($pos2 = strpos($this->pattern, '/', $pos + 3)) !== false) {
$this->host = substr($this->pattern, 0, $pos2);
// '://' 后再无其他 '/',那么整个 pattern 其实就是主机信息
} else {
$this->host = $this->pattern;
}
// pattern 不是空串,且不包含主机信息,两端加上 '/' ,形成一个正则
} else {
$this->pattern = '/' . $this->pattern . '/';
}
// route 也要去掉两头的 '/'
$this->route = trim($this->route, '/');
// 从这里往下,请结合流程图来看
// route 中含有 <参数> ,则将所有参数提取成 [参数 => <参数>]
// 存入 _routeParams[],
// 如 ['controller' => '<controller>', 'action' => '<action>'],
// 留意这里的短路判断,先使用 strpos(),快速排除无需使用正则的情况
if (strpos($this->route, '<') !== false &&
preg_match_all('/<(\w+)>/', $this->route, $matches)) {
foreach ($matches[1] as $name) {
$this->_routeParams[$name] = "<$name>";
}
}
// 这个 $tr[] 和 $tr2[] 用于字符串的转换
$tr = [
'.' => '\\.',
'*' => '\\*',
'$' => '\\$',
'[' => '\\[',
']' => '\\]',
'(' => '\\(',
')' => '\\)',
];
$tr2 = [];
// pattern 中含有 <参数名:参数pattern> ,
// 其中 ':参数pattern' 部分是可选的。
if (preg_match_all('/<(\w+):?([^>]+)?>/', $this->pattern, $matches,
PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
foreach ($matches as $match) {
// 获取 “参数名”
$name = $match[1][0];
// 获取 “参数pattern” ,如果未指定,使用 '[^\/]' ,
// 表示匹配除 '/' 外的所有字符
$pattern = isset($match[2][0]) ? $match[2][0] : '[^\/]+';
// 如果 defaults[] 中有同名参数,
if (array_key_exists($name, $this->defaults)) {
// $match[0][0] 是整个 <参数名:参数pattern> 串
$length = strlen($match[0][0]);
$offset = $match[0][1];
// pattern 中 <参数名:参数pattern> 两头都有 '/'
if ($offset > 1 && $this->pattern[$offset - 1] === '/'
&& $this->pattern[$offset + $length] === '/') {
// 留意这个 (?P<name>pattern) 正则,这是一个命名分组。
// 仅冠以一个命名供后续引用,使用上与直接的 (pattern) 没有区别
// 见:http://php.net/manual/en/regexp.reference.subpatterns.php
$tr["/<$name>"] = "(/(?P<$name>$pattern))?";
} else {
$tr["<$name>"] = "(?P<$name>$pattern)?";
}
// defaults[]中没有同名参数
} else {
$tr["<$name>"] = "(?P<$name>$pattern)";
}
// routeParams[]中有同名参数
if (isset($this->_routeParams[$name])) {
$tr2["<$name>"] = "(?P<$name>$pattern)";
// routeParams[]中没有同名参数,则将 参数pattern 存入 _paramRules[] 中。
// 留意这里是怎么对 参数pattern 进行处理后再保存的。
} else {
$this->_paramRules[$name] = $pattern === '[^\/]+' ? '' :
"#^$pattern$#u";
}
}
}
// 将 pattern 中所有的 <参数名:参数pattern> 替换成 <参数名> 后作为 _template
$this->_template = preg_replace('/<(\w+):?([^>]+)?>/', '<$1>', $this->pattern);
// 将 _template 中的特殊字符及字符串使用 tr[] 进行转换,并作为最终的pattern
$this->pattern = '#^' . trim(strtr($this->_template, $tr), '/') . '$#u';
// 如果指定了 routePrams 还要使用 tr2[] 对 route 进行转换,
// 并作为最终的 _routeRule
if (!empty($this->_routeParams)) {
$this->_routeRule = '#^' . strtr($this->route, $tr2) . '$#u';
}
}
~~~
上面的代码难点在于pattern等的转换过程,有点翻来覆去,转换过去、转换回来的感觉,这里我们先放一放, 秋后再找他们来算帐,注意力先放在 init() 的前半部分,这些代码提醒我们:
* 规则的 $pattern 和 $route 是必须配置的。
* 规则的名称 $name 和主机信息 $host 在未配置的情况下,可以从 $pattern 来获取。
* $pattern 虽然含有正则的成分,但不需要在两端加入 / ,更不能使用 # 等其他分隔符。 Yii会自动为我们加上。
* 指定 $pattern 为空串,可以使该规则匹配任意的URL。此时基于该规则所生成的所有URL也都是空串。
* $pattern 中含有 :\\ 时,Yii会认为其中包含了主机信息。此时就不应当再指定 host 。 否则,Yii会将 host 接在这个 pattern 前,作为新的pattern。这会造成该pattern 两段 :\\ , 而这显然不是我们要的。
接下来要啃稍硬点的骨头了,就是 init() 的后半段, 我们以一个普通的['post//' => 'post/'] 为例。 同时,我们假设这个路由规则默认有$defaults['id'] = 100 ,表示在未指定 post 的 id 时, 使用100作为默认的id。那么这个UrlRule的初始过程如 [_UrlRule路由规则初始化过程示意图_](http://www.digpage.com/route.html#img-urlrule-init) 所示。
[![UrlRule路由规则初始化过程示意图](http://www.digpage.com/_images/UrlRule_init.png)](http://www.digpage.com/_images/UrlRule_init.png)
UrlRule路由规则初始化过程示意图
后续的初始化过程具体如下:
1. 从 ['post//' => 'post/'] , 我们有$pattern = '/post///' 和 $route = 'post/' 。
2. 首先从 $route 中提取出由 > 所包含的部分。这里可以得到 ['action' => ''] 。 将其存入 $_routeParams[ ] 中。
3. 再从 $pattern 中提取出由 > 所包含的部分,这里匹配2个部分,1个 和1个 。下面对这2个部分进行分别处理。
4. 对于 由于 $defaults[ ] 中不存在下标为 action 的元素,于是向 $tr[ ] 写入(?P$pattern) 形式的元素,得到 $tr[''] = '(?P\w+)' 。 而对于 ,由于 $defaults['id'] = 100 ,所以写入 $tr[ ] 的元素形式有所不同, 变成(/(?P$pattern))? 。于是有 $tr[''] = (/(?P\d+))? 。
5. 由于在第1步只有 $_routeParams['action'] = '' ,而没有下标为 id 的元素。 所以对于 ,往 tr2[ ] 中写入 ['' => '(?P\w+)'] , 而对于 则往$_paramRules[ ] 中写入 ['id' => '#^\d+$#u'] 。
6. 上面只是准备工作,接下来开始各种替换。首先将 $pattern 中所有 替换成 并作为 $_template 。因此, $_template = '/post///' 。
7. 接下来用 $tr[ ] 对 $_template 进行替换,并在两端加上分隔符作为 $pattern 。 于是有$pattern = '#^post/(?P\w+)(/(?P\d+))?$#u' 。
8. 最后,由于第1步中 $_routeParams 不为空,所以需要使用 $tr2[ ] 对 $route 进行替换, 并在两端加上分隔符后,作为 $_routeRule ,于是有 $_routeRule = '#^post/(?P\w+)$#' 。
这些替换的意义在于方便开发者以简洁的方式在配置文件中书写路由规则,然后将这些简洁的规则, 再替换成规范的正则表达式。让我们来看看这个 init() 的成果吧。仍然以上面的['post//' => 'post/'] 为例,经过 init() 处理后,我们最终得到了:
~~~
$urlRule->route = 'post/<action>';
$urlRule->pattern = '#^post/(?P<action>\w+)(/(?P<id>\d+))?$#u';
$urlRule->_template = '/post/<action>/<id>/';
$urlRule->_routeRule = '#^post/(?P<action>\w+)$#';
$urlRule->_routeParams = ['action' => '<action>'];
$urlRule->_paramRules = ['id' => '#^\d+$#u'];
// $tr 和 $tr2 作为局部变量已经完成历史使命光荣退伍了
~~~
下面我们来讲讲 UrlRule 是如何创建和解析URL的。
## 创建URL[](http://www.digpage.com/route.html#id3 "Permalink to this headline")
URL的创建就 UrlRule 层面来讲,是由 yii\web\UrlRule::createUrl() 负责的, 这个方法可以根据传入的路由和参数创建一个相应的URL来。具体代码如下:
~~~
public function createUrl($manager, $route, $params)
{
// 判断规则是否仅限于解析请求,而不适用于创建URL
if ($this->mode === self::PARSING_ONLY) {
return false;
}
$tr = [];
// 如果传入的路由与规则定义的路由不一致,
// 如 post/view 与 post/<action> 并不一致
if ($route !== $this->route) {
// 使用 $_routeRule 对 $route 作匹配测试
if ($this->_routeRule !== null && preg_match($this->_routeRule,
$route, $matches)) {
// 遍历所有的 _routeParams
foreach ($this->_routeParams as $name => $token) {
// 如果该路由规则提供了默认的路由参数,
// 且该参数值与传入的路由相同,则可以省略
if (isset($this->defaults[$name]) &&
strcmp($this->defaults[$name], $matches[$name]) === 0) {
$tr[$token] = '';
} else {
$tr[$token] = $matches[$name];
}
}
// 传入的路由完全不能匹配该规则,返回
} else {
return false;
}
}
// 遍历所有的默认参数
foreach ($this->defaults as $name => $value) {
// 如果默认参数是路由参数,如 <action>
if (isset($this->_routeParams[$name])) {
continue;
}
// 默认参数并非路由参数,那么看看传入的 $params 里是否提供该参数的值。
// 如果未提供,说明这个规则不适用,直接返回。
if (!isset($params[$name])) {
return false;
// 如果 $params 提供了该参数,且参数值一致,则 $params 可省略该参数
} elseif (strcmp($params[$name], $value) === 0) {
unset($params[$name]);
// 且如果有该参数的转换规则,也可置为空。等下一转换就消除了。
if (isset($this->_paramRules[$name])) {
$tr["<$name>"] = '';
}
// 如果 $params 提供了该参数,但又与默认参数值不一致,
// 且规则也未定义该参数的正则,那么规则无法处理这个参数。
} elseif (!isset($this->_paramRules[$name])) {
return false;
}
}
// 遍历所有的参数匹配规则
foreach ($this->_paramRules as $name => $rule) {
// 如果 $params 传入了同名参数,且该参数不是数组,且该参数匹配规则,
// 则使用该参数匹配规则作为转换规则,并从 $params 中去掉该参数
if (isset($params[$name]) && !is_array($params[$name])
&& ($rule === '' || preg_match($rule, $params[$name]))) {
$tr["<$name>"] = $this->encodeParams ?
urlencode($params[$name]) : $params[$name];
unset($params[$name]);
// 否则一旦没有设置该参数的默认值或 $params 提供了该参数,
// 说明规则又不匹配了
} elseif (!isset($this->defaults[$name]) || isset($params[$name])) {
return false;
}
}
// 使用 $tr 对 $_template 时行转换,并去除多余的 '/'
$url = trim(strtr($this->_template, $tr), '/');
// 将 $url 中的多个 '/' 变成一个
if ($this->host !== null) {
// 再短的 host 也不会短于 8
$pos = strpos($url, '/', 8);
if ($pos !== false) {
$url = substr($url, 0, $pos) . preg_replace('#/+#', '/',
substr($url, $pos));
}
} elseif (strpos($url, '//') !== false) {
$url = preg_replace('#/+#', '/', $url);
}
// 加上 .html 之类的假后缀
if ($url !== '') {
$url .= ($this->suffix === null ? $manager->suffix : $this->suffix);
}
// 加上查询参数们
if (!empty($params) && ($query = http_build_query($params)) !== '') {
$url .= '?' . $query;
}
return $url;
}
~~~
我们以上面提到 ['post//' => 'post/'] 路由规则来创建一个URL, 就假设要创建路由为 post/view , id=100 的URL吧。具体的流程如 [_UrlRule创建URL的流程示意图_](http://www.digpage.com/route.html#img-urlrule-create) 所示。
[![UrlRule创建URL的流程示意图](http://www.digpage.com/_images/UrlRule_create.png)](http://www.digpage.com/_images/UrlRule_create.png)
UrlRule创建URL的流程示意图
结合代码 [_UrlRule创建URL的流程示意图_](http://www.digpage.com/route.html#img-urlrule-create) ,URL的创建过程大体上分4个阶段:
第一阶段
调用 createUrl(Yii::$app->urlManager, 'post/view', ['id'=>101]) 。
传入的路由为 post/view 与规则定义的路由 post/ 不同。 但是, post/view 可以匹配路由规则的 $_routeRule = '#^post/(?P\w+)$#' 。 所以,认为该规则是适用的,可以接着处理。而如果连正则也匹配不上,那就说明该规则不适用,返回 false 。
遍历路由规则的所有 $_routeParams ,这个例子中, $_routeParams['action' => ''] 。 这个我们称为路由参数规则,即出现在路由部分的参数。
对于这个路由参数规则,我们并未为其设置默认值。但实际使用中,有的时候可能会提供默认的路由参数, 比如对于形如 post/index 之类的,我们经常想省略掉 index ,那么就可以为 提供一个默认值 index 。
对于有默认值的情况,我们的头脑要清醒,目前是要用路由规则来创建URL, 规则所定义的默认值并非意味着可以处理不提供这个路由参数值的路由, 而是说在处理路由参数值与默认值相等的路由时,最终生成的URL中可以省略该默认值。
即默认是体现在最终的URL上,是体现在URL解析过程中的默认,而不是体现在创建URL的过程中。 也就是说,对于 post/index 类的路由,如果默认 index ,则生成的URL中可以不带有 index 。
这里没有默认值,相对简单。由于 $_routeRule 正则中,使用了命名分组,即 (?P...) 。 所以,可以很方便地使用 $matches['action'] 来捕获 \w+ 所匹配的部分,这里匹配的是 view 。 故写入 $tr[''] = view 。
第二阶段
接下来遍历所有的默认参数,当然不包含路由参数部分,因为这个在前面已经处理过了。这里只处理余下的参数。 注意这个默认值的含义,如同我们前面提到的,这里是创建时必须提供,而生成出来的URL可以省略的意思。 因此,对于 $params 中未提供相应参数的,或提供了参数值但与默认值不一致,且规则没定义参数的正则的, 均说明规则不适用。 只有在 $params 提供相应参数,且参数值与默认值一致或匹配规则时方可进行后续处理。
这里我们有一个默认参数 ['id' => 100] 。传入的 $params['id'] => 100 。两者一致,规则适用。
于将该参数从 $params 中删去。
接下来,看看是否为参数 id 定义了匹配规则。还真有 $_paramRules['id'] => '#^\d+$#u' 。 但这也用不上了,因为这是在创建URL,该参数与默认值一致,等下的 id 是要从URL中去除的。 因此,写入 $tr[''] = '' 。
第三阶段
再接下来,就是遍历所有参数匹配规则 $_paramRules 了。对于这个例子, 只有$_paramRules = ['id' => '#^\d+$#u'] 。
如果 $params 中并未定义该 id 参数,那么这一步什么也不用做,因为没有东西要写到URL中去。
而一旦定义了 id ,那么就需要看看当前路由规则是否适用了。 判断的标准是所提供的参数不是数组,且匹配 $_paramRules 所定义的规则。 而如果 $parasm['id'] 是数组,或不与规则匹配,或定义了 id 的参数规则却没有定义其默认值而 $params['id'] 又未提供,则规则不适用。
这里,我们在是在前面处理默认参数时,已经将 id 从 $params 中删去。 但判断到规则为 id 定义了默认值的,所以认为规则仍然适用。只是,这里实际上不用做任何处理。 如果需要处理的情况,也是将该参数从 $params 中删去,然后写入 $tr[''] = 100 。
第四阶段
上面一切准备就绪之后,就可以着手生成URL了。主要用 $tr 对路由规则的 $_template 进行转换。 这里, $_template = '/post///' ,因此,转换后再去除多余的 / 就变成了$url = 'post/view' 。其中 id=100 被省略掉了。
最后再分别接上 .html 的后缀和查询参数,一个URL post/view.html 就生成了。 其中,查询参数串是 $params 中剩下的内容,使用PHP的 http_build_query( ) 生成的。
从创建URL的过程来看,重点是完成这么几项工作:
* 看规则是否适用,主要标准是路由与规则定义的是否匹配,规则通过默认值或正则所定义的参数,是否都提供了。
* 看看当前要创建的URL是否与规则定义的默认的路由参数和查询参数一致,对于一致的,可以省略。
* 看将这些与默认值一致的,规则已经定义了的参数从 $params 删除,余下的,转换成最终URL的查询参数串。
## 解析URL[](http://www.digpage.com/route.html#parse-url "Permalink to this headline")
说完了路由规则生成URL的过程,再来看看其逻辑上的逆过程,即URL的解析。
先从路由规则 yii\web\UrlRule::parseRequest() 的代码入手:
~~~
public function parseRequest($manager, $request)
{
// 当前路由规则仅限于创建URL,直接返回 false。
// 该方法返回false表示当前规则不适用于当前的URL。
if ($this->mode === self::CREATION_ONLY) {
return false;
}
// 如果规则定义了适用的HTTP方法,则要看当前请求采用的方法是否可以接受
if (!empty($this->verb) && !in_array($request->getMethod(),
$this->verb, true)) {
return false;
}
// 获取URL中入口脚本之后、查询参数 ? 号之前的全部内容,即为PATH_INFO
$pathInfo = $request->getPathInfo();
// 取得配置的 .html 等假后缀,留意 (string)null 转成空串
$suffix = (string) ($this->suffix === null ? $manager->suffix :
$this->suffix);
// 有假后缀且有PATH_INFO
if ($suffix !== '' && $pathInfo !== '') {
$n = strlen($suffix);
// 当前请求的 PATH_INFO 以该假后缀结尾,留意 -$n 的用法
if (substr_compare($pathInfo, $suffix, -$n, $n) === 0) {
$pathInfo = substr($pathInfo, 0, -$n);
// 整个PATH_INFO 仅包含一个假后缀,这是无效的。
if ($pathInfo === '') {
return false;
}
// 应用配置了假后缀,但是当前URL却不包含该后缀,返回false
} else {
return false;
}
}
// 规则定义了主机信息,即 http://www.digpage.com 之类,那要把主机信息接回去。
if ($this->host !== null) {
$pathInfo = strtolower($request->getHostInfo()) .
($pathInfo === '' ? '' : '/' . $pathInfo);
}
// 当前URL是否匹配规则,留意这个pattern是经过 init() 转换的
if (!preg_match($this->pattern, $pathInfo, $matches)) {
return false;
}
// 遍历规则定义的默认参数,如果当前URL中没有,则加入到 $matches 中待统一处理,
// 默认值在这里发挥作用了,虽然没有,但仍视为捕获到了。
foreach ($this->defaults as $name => $value) {
if (!isset($matches[$name]) || $matches[$name] === '') {
$matches[$name] = $value;
}
}
$params = $this->defaults;
$tr = [];
// 遍历所有匹配项,注意这个 $name 的由来是 (?P<name>...) 的功劳
foreach ($matches as $name => $value) {
// 如果是匹配一个路由参数
if (isset($this->_routeParams[$name])) {
$tr[$this->_routeParams[$name]] = $value;
unset($params[$name]);
// 如果是匹配一个查询参数
} elseif (isset($this->_paramRules[$name])) {
// 这里可能会覆盖掉 $defaults 定义的默认值
$params[$name] = $value;
}
}
// 使用 $tr 进行转换
if ($this->_routeRule !== null) {
$route = strtr($this->route, $tr);
} else {
$route = $this->route;
}
Yii::trace("Request parsed with URL rule: {$this->name}", __METHOD__);
return [$route, $params];
}
~~~
我们以 http://www.digpage.com/post/view.html 为例,来看看上面的代码是如何解析成路由['post/view', ['id'=>100]] 的。注意,这里我们仍然假设路由规则提供了 id=100 默认值。 而如果路由规则未提供该默认值,则请求形式要变成 http://www.digapge.com/post/view/100.html 。 同时,规则的 $pattern 也会不同:
~~~
// 未提供默认值,id 必须提供,否则匹配不上
$pattern = '#^post/(?P<action>\w+)/(?P<id>\d+)?$#u';
// 提供了默认值,id 可以不提供,照样可以匹配上
$pattern = '#^post/(?P<action>\w+)(/(?P<id>\d+))?$#u';
~~~
这个不同的原因在于 UrlRule::init() ,读者朋友们可以回头看看。
在讲URL的解析前,让我们先从请求的第一个经手人Web Server说起,在 [_附录2:Yii的安装_](http://www.digpage.com/install.html#install) 中讲到Web Server的配置时, 我们将所有未命中的请求转交给入口脚本来处理:
~~~
location / {
try_files $uri $uri/ /index.php?$args;
}
# fastcgi.conf
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
~~~
以Nginx为例, try_files 会依次尝试处理:
1. /post/view.html ,这个如果真有一个也就罢了,但其实多半不存在。
2. /post/view.html/ ,这个目录一般也不存在。
3. /index.php ,这个正是入口脚本,可以处理。至此,Nginx与Yii顺利交接。
由于请求最终交给入口脚本来处理,且我们隐藏了URL中入口脚本名,上述请求还原回来的话, 应该是 http://www.digapge.com/index.php/post/view.html 。 自然,这 post/view.html 就是PATH_INFO了。 有关PATH_INFO的更多知识,请看 [_Web应用Request_](http://www.digpage.com/web_request.html#web-request) 部分的内容。
好了,在Yii从Web Server取得控制权之后,就是我们大显身手的时候了。在解析过程中,UrlRule主要做了这么几件事:
* 通过 PATH_INFO 还原请求,如去除假后缀,开头接上主机信息等。还原后的请求为 post/view。
* 看看当前请求是否匹配规则,这个匹配包含了主机、路由、参数等各方面的匹配。如不匹配,说明规则不适用, 返回 false 。在这个例子中,规则并未定义主机信息方面的规则, 规则中$pattern = '#^post/(?P\w+)(/(?P\d+))?$#u' 。这与还原后的请求完全匹配。 如果URL没有使用默认值 id = 100 ,如 post/view/101.html ,也是同样匹配的。
* 看看请求是否提供了规则已定义了默认值的所有参数,如果未提供,视为请求提供了这些参数,且他的值为默认值。 这里URL中并未提供 id 参数,所以,视为他提供了 id = 100 的参数。简单粗暴而有效。
* 使用规则定义的路由进行转换,生成新的路由。 再把上一步及当前所有参数作为路由的参数,共同组装成一个完整路由。
具体的转换流程可以看看 [_UrlRule路由规则解析URL的过程示意图_](http://www.digpage.com/route.html#img-urlrule-parse) 。
![UrlRule解析URL过程示意图](http://www.digpage.com/_images/UrlRule_parse.png)
UrlRule路由规则解析URL的过程示意图
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
请求与响应(TBD)
最后更新于:2022-04-01 02:19:57
服务定位器(Service Locator)
最后更新于:2022-04-01 02:19:54
跟DI容器类似,引入Service Locator目的也在于解耦。有许多成熟的设计模式也可用于解耦,但在Web应用上, Service Locator绝对占有一席之地。 对于Web开发而言,Service Locator天然地适合使用, 主要就是因为Service Locator模式非常贴合Web这种基于服务和组件的应用的运作特点。 这一模式的优点有:
* Service Locator充当了一个运行时的链接器的角色,可以在运行时动态地修改一个类所要选用的服务, 而不必对类作任何的修改。
* 一个类可以在运行时,有针对性地增减、替换所要用到的服务,从而得到一定程度的优化。
* 实现服务提供方、服务使用方完全的解耦,便于独立测试和代码跨框架复用。
## Service Locator的基本功能[](http://www.digpage.com/service_locator.html#id2 "Permalink to this headline")
在Yii中Service Locator由 yii\di\ServiceLocator 来实现。 从代码组织上,Yii将Service Locator放到与DI同一层次来对待,都组织在 yii\di 命名空间下。 下面是Service Locator的源代码:
~~~
class ServiceLocator extends Component
{
// 用于缓存服务、组件等的实例
private $_components = [];
// 用于保存服务和组件的定义,通常为配置数组,可以用来创建具体的实例
private $_definitions = [];
// 重载了 getter 方法,使得访问服务和组件就跟访问类的属性一样。
// 同时,也保留了原来Component的 getter所具有的功能。
// 请留意,ServiceLocator 并未重载 __set(),
// 仍然使用 yii\base\Component::__set()
public function __get($name)
{
... ...
}
// 对比Component,增加了对是否具有某个服务和组件的判断。
public function __isset($name)
{
... ...
}
// 当 $checkInstance === false 时,用于判断是否已经定义了某个服务或组件
// 当 $checkInstance === true 时,用于判断是否已经有了某人服务或组件的实例
public function has($id, $checkInstance = false)
{
return $checkInstance ? isset($this->_components[$id]) :
isset($this->_definitions[$id]);
}
// 根据 $id 获取对应的服务或组件的实例
public function get($id, $throwException = true)
{
... ...
}
// 用于注册一个组件或服务,其中 $id 用于标识服务或组件。
// $definition 可以是一个类名,一个配置数组,一个PHP callable,或者一个对象
public function set($id, $definition)
{
... ...
}
// 删除一个服务或组件
public function clear($id)
{
unset($this->_definitions[$id], $this->_components[$id]);
}
// 用于返回Service Locator的 $_components 数组或 $_definitions 数组,
// 同时也是 components 属性的getter函数
public function getComponents($returnDefinitions = true)
{
... ...
}
// 批量方式注册服务或组件,同时也是 components 属性的setter函数
public function setComponents($components)
{
... ...
}
}
~~~
从代码可以看出,Service Locator继承自 yii\base\Component ,这是Yii中的一个基础类, 提供了属性、事件、行为等基本功能,关于Component的有关知识,可以看看 [_属性(Property)_](http://www.digpage.com/property.html#property) 、 [_事件(Event)_](http://www.digpage.com/event.html#event) 和 [_行为(Behavior)_](http://www.digpage.com/behavior.html#behavior) 。
Service Locator 通过 __get() __isset() has() 等方法, 扩展了 yii\base\Component 的最基本功能,提供了对于服务和组件的属性化支持。
从功能来看,Service Locator提供了注册服务和组件的 set() setComponents() 等方法, 用于删除的clear() 。用于读取的 get() 和 getComponents() 等方法。
细心的读者可能一看到 setComponents() 和 getComponents() 就猜到了, Service Locator还具有一个可读写的 components 属性。
### Service Locator的数据结构[](http://www.digpage.com/service_locator.html#id3 "Permalink to this headline")
从上面的代码中,可以看到Service Locator维护了两个数组, $_components 和 $_definitions 。这两个数组均是以服务或组件的ID为键的数组。
其中, $_components 用于缓存存Service Locator中的组件或服务的实例。 Service Locator 为其提供了getter和setter。使其成为一个可读写的属性。 $_definitions 用于保存这些组件或服务的定义。这个定义可以是:
* 配置数组。在向Service Locator索要服务或组件时,这个数组会被用于创建服务或组件的实例。 与DI容器的要求类似,当定义是配置数组时,要求配置数组必须要有 class 元素,表示要创建的是什么类。不然你让Yii调用哪个构造函数?
* PHP callable。每当向Service Locator索要实例时,这个PHP callable都会被调用,其返回值,就是所要的对象。 对于这个PHP callable有一定的形式要求,一是它要返回一个服务或组件的实例。 二是它不接受任何的参数。 至于具体原因,后面会讲到。
* 对象。这个更直接,每当你索要某个特定实例时,直接把这个对象给你就是了。
* 类名。即,使得 is_callable($definition, true) 为真的定义。
从 yii\di\ServiceLocator::set() 的代码:
~~~
public function set($id, $definition)
{
// 当定义为 null 时,表示要从Service Locator中删除一个服务或组件
if ($definition === null) {
unset($this->_components[$id], $this->_definitions[$id]);
return;
}
// 确保服务或组件ID的唯一性
unset($this->_components[$id]);
// 定义如果是个对象或PHP callable,或类名,直接作为定义保存
// 留意这里 is_callable的第二个参数为true,所以,类名也可以。
if (is_object($definition) || is_callable($definition, true)) {
// 定义的过程,只是写入了 $_definitions 数组
$this->_definitions[$id] = $definition;
// 定义如果是个数组,要确保数组中具有 class 元素
} elseif (is_array($definition)) {
if (isset($definition['class'])) {
// 定义的过程,只是写入了 $_definitions 数组
$this->_definitions[$id] = $definition;
} else {
throw new InvalidConfigException(
"The configuration for the \"$id\" component must contain a \"class\" element.");
}
// 这也不是,那也不是,那么就抛出异常吧
} else {
throw new InvalidConfigException(
"Unexpected configuration type for the \"$id\" component: "
. gettype($definition));
}
}
~~~
服务或组件的ID在Service Locator中是唯一的,用于区别彼此。在任何情况下,Service Locator中同一ID只有一个实例、一个定义。也就是说,Service Locator中,所有的服务和组件,只保存一个单例。 这也是正常的逻辑,既然称为服务定位器,你只要给定一个ID,它必然返回一个确定的实例。这一点跟DI容器是一样的。
Service Locator 中ID仅起标识作用,可以是任意字符串,但通常用服务或组件名称来表示。 如,以db 来表示数据库连接,以 cache 来表示缓存组件等。
至于批量注册的 yii\di\ServiceLocator::setCompoents() 只不过是简单地遍历数组,循环调用 set()而已。 就算我不把代码贴出来,像你这么聪明的,一下子就可以自己写出来了。
向Service Locator注册服务或组件,其实就是向 $_definitions 数组写入信息而已。
### 访问Service Locator中的服务[](http://www.digpage.com/service_locator.html#id4 "Permalink to this headline")
Service Locator重载了 __get() 使得可以像访问类的属性一样访问已经实例化好的服务和组件。 下面是重载的 __get() 方法:
~~~
public function __get($name)
{
// has() 方法就是判断 $_definitions 数组中是否已经保存了服务或组件的定义
// 请留意,这个时候服务或组件仅是完成定义,不一定已经实例化
if ($this->has($name)) {
// get() 方法用于返回服务或组件的实例
return $this->get($name);
// 未定义的服务或组件,那么视为正常的属性、行为,
// 调用 yii\base\Component::__get()
} else {
return parent::__get($name);
}
}
~~~
在注册好了服务或组件定义之后,就可以像访问属性一样访问这些服务(组件)。 前提是已经完成注册,不要求已经实例化。 访问这些服务或属性,被转换成了调用 yii\di\ServiceLocator::get() 来获取实例。 下面是使用这种形式访问服务或组件的例子:
~~~
// 创建一个Service Locator
$serviceLocator = new yii\di\ServiceLocator;
// 注册一个 cache 服务
$serviceLocator->set('cache', [
'class' => 'yii\cache\MemCache',
'servers' => [
... ...
],
]);
// 使用访问属性的方法访问这个 cache 服务
$serviceLocator->cache->flushValues();
// 上面的方法等效于下面这个
$serviceLocator->get('cache')->flushValues();
~~~
在Service Locator中,并未重载 __set() 。所以,Service Locator中的服务和组件看起来就好像只读属性一样。 要向Service Locator中“写”入服务和组件,没有 setter 可以使用,需要调用yii\di\ServiceLocator::set() 对服务和组件进行注册。
## 通过Service Locator获取实例[](http://www.digpage.com/service_locator.html#id5 "Permalink to this headline")
与注册服务和组件的简单之极相反,Service Locator在创建获取服务或组件实例的过程要稍微复杂一点。 这一点和DI容器也是很像的。 Service Locator通过 yii\di\ServiceLocator::get() 来创建、获取服务或组件的实例:
~~~
public function get($id, $throwException = true)
{
// 如果已经有实例化好的组件或服务,直接使用缓存中的就OK了
if (isset($this->_components[$id])) {
return $this->_components[$id];
}
// 如果还没有实例化好,那么再看看是不是已经定义好
if (isset($this->_definitions[$id])) {
$definition = $this->_definitions[$id];
// 如果定义是个对象,且不是Closure对象,那么直接将这个对象返回
if (is_object($definition) && !$definition instanceof Closure) {
// 实例化后,保存进 $_components 数组中,以后就可以直接引用了
return $this->_components[$id] = $definition;
// 是个数组或者PHP callable,调用 Yii::createObject()来创建一个实例
} else {
// 实例化后,保存进 $_components 数组中,以后就可以直接引用了
return $this->_components[$id] = Yii::createObject($definition);
}
} elseif ($throwException) {
throw new InvalidConfigException("Unknown component ID: $id");
// 即没实例化,也没定义,万能的Yii也没办法通过一个任意的ID,
// 就给你找到想要的组件或服务呀,给你个 null 吧。
// 表示Service Locator中没有这个ID的服务或组件。
} else {
return null;
}
}
~~~
Service Locator创建获取服务或组件实例的过程是:
* 看看缓存数组 $_components 中有没有已经创建好的实例。有的话,皆大欢喜,直接用缓存中的就可以了。
* 缓存中没有的话,那就要从定义开始创建了。
* 如果服务或组件的定义是个对象,那么直接把这个对象作为服务或组件的实例返回就可以了。 但有一点要注意,当使用一个PHP callable定义一个服务或组件时,这个定义是一个Closure类的对象。 这种定义虽然也对象,但是可不能把这种对象直接当成服务或组件的实例返回。
* 如果定义是一个数组或者一个PHP callable,那么把这个定义作为参数,调用Yii::createObject() 来创建实例。
这个 Yii::createObject() 在讲配置时我们介绍过,当时只是点一点,这里会讲得更深一点。但别急,先放一放, 知道他能为Service Locator创建对象就OK了。我们等下还会讲这个方法的。
## 在Yii应用中使用Service Locator和DI容器[](http://www.digpage.com/service_locator.html#yiiservice-locatordi "Permalink to this headline")
我们在讲DI容器时,提到了Yii中是把Service Locator和DI容器结合起来用的,Service Locator是建立在DI容器之上的。 那么一个Yii应用,是如何使用Service Locator和DI容器的呢?
### DI容器的引入[](http://www.digpage.com/service_locator.html#di "Permalink to this headline")
我们知道,每个Yii应用都有一个入口脚本 index.php 。在其中,有一行不怎么显眼:
~~~
require(__DIR__ . '/../../vendor/yiisoft/yii2/Yii.php');
~~~
这一行看着普通,也就是引入一个 Yii.php 的文件。但是,让我们来看看这个 Yii.php
~~~
<?php
require(__DIR__ . '/BaseYii.php');
class Yii extends \yii\BaseYii
{
}
spl_autoload_register(['Yii', 'autoload'], true, true);
Yii::$classMap = include(__DIR__ . '/classes.php');
// 重点看这里。创建一个DI 容器,并由 Yii::$container 引用
Yii::$container = new yii\di\Container;
~~~
Yii 是一个工具类,继承自 yii\BaseYii 。 但这里对父类的代码没有任何重载,意味之父类和子类在功能上其实是相同的。 但是,Yii提供了让你修改默认功能的机会。 就是自己写一个 Yii 类,来扩展、重载Yii默认的、由 yii\BaseYii 提供的特性和功能。 尽管实际使用中,我们还从来没有需要改写过这个类,主要是因为没有必要在这里写代码,可以通过别的方式实现。 但Yii确实提供了这么一个可能。这个在实践中不常用,有这么个印象就足够了。
这里重点看最后一句代码,创建了一个DI容器,并由 Yii::$container 引用。 也就是说, Yii 类维护了一个DI容器,这是DI容器开始介入整个应用的标志。 同时,这也意味着,在Yii应用中,我们可以随时使用 Yii::$container 来访问DI容器。 一般情况下,如无必须的理由,不要自己创建DI容器,使用 Yii::$container 完全足够。
### Application的本质[](http://www.digpage.com/service_locator.html#application "Permalink to this headline")
再看看入口脚本 index.php 的最后两行:
~~~
$application = new yii\web\Application($config);
$application->run();
~~~
创建了一个 yii\web\Application 实例,并调用其 run() 方法。 那么,这个 yii\web\Application 是何方神圣? 首先, yii\web\Application 继承自 yii\base\Application ,这从 yii\web\Application 的代码可以看出来
~~~
class Application extends \yii\base\Application
{
... ...
}
~~~
而 yii\base\Application 又继承自 yii\base\Module ,说明所有的Application都是Module
~~~
abstract class Application extends Module
{
... ...
}
~~~
那么 yii\base\Module 又继承自哪个类呢?不知道你猜到没,他继承自 yii\di\ServiceLocator
~~~
class Module extends ServiceLocator
{
... ...
}
~~~
所有的Module都是服务定位器Service Locator,因此,所有的Application也都是Service Locator。
同时,在Application的构造函数中, yii\base\Application::__construct()
~~~
public function __construct($config = [])
{
Yii::$app = $this;
... ...
}
~~~
第一行代码就把Application当前的实例,赋值给 Yii::$app 了。 这意味着Yii应用创建之后,可以随时通过 Yii::$app 来访问应用自身,也就是访问Service Locator。
至此,DI容器有了,Service Locator也出现了。那么Yii是如何摆布这两者的呢?这两者又是如何千里姻缘一线牵的呢?
### 实例创建方法[](http://www.digpage.com/service_locator.html#id6 "Permalink to this headline")
Service Locator和DI容器的亲密关系就隐藏在 yii\di\ServiceLocator::get() 获取实例时, 调用的Yii::createObject() 中。 前面我们说到这个 Yii 继承自 yii\BaseYii ,因此这个函数实际上是BaseYii::createObject() , 其代码如下:
~~~
// static::$container就是上面说的引用了DI容器的静态变量
public static function createObject($type, array $params = [])
{
// 字符串,代表一个类名、接口名、别名。
if (is_string($type)) {
return static::$container->get($type, $params);
// 是个数组,代表配置数组,必须含有 class 元素。
} elseif (is_array($type) && isset($type['class'])) {
$class = $type['class'];
unset($type['class']);
// 调用DI容器的get() 来获取、创建实例
return static::$container->get($class, $params, $type);
// 是个PHP callable则调用其返回一个具体实例。
} elseif (is_callable($type, true)) {
// 是个PHP callable,那就调用它,并将其返回值作为服务或组件的实例返回
return call_user_func($type, $params);
// 是个数组但没有 class 元素,抛出异常
} elseif (is_array($type)) {
throw new InvalidConfigException(
'Object configuration must be an array containing a "class" element.');
// 其他情况,抛出异常
} else {
throw new InvalidConfigException(
"Unsupported configuration type: " . gettype($type));
}
}
~~~
这个 createObject() 提供了一个向DI容器获取实例的接口, 对于不同的定义,除了PHP callable外,createObject() 都是调用了DI容器的 yii\di\Container::get() , 来获取实例的。Yii::createObject() 就是Service Locator和DI容器亲密关系的证明, 也是Service Locator构建于DI容器之上的证明。而Yii中所有的Module, 包括Application都是Service Locator,因此,它们也都构建在DI容器之上。
同时,在Yii框架代码中,只要创建实例,就是调用 Yii::createObject() 这个方法来实现。 可以说,Yii中所有的实例(除了Application,DI容器自身等入口脚本中实例化的),都是通过DI容器来获取的。
同时,我们不难发现, Yii 的基类 yii\BaseYii ,所有的成员变量和方法都是静态的, 其中的DI容器是个静态成员变量 $container 。 因此,DI容器就形成了最常见形式的单例模式,在内存中仅有一份,所有的Service Locator (Module和Application)都共用这个DI容器。 就就节省了大量的内存空间和反复构造实例的时间。
更为重要的是,DI容器的单例化,使得Yii不同的模块共用组件成为可能。 可以想像,由于共用了DI容器,容器里面的内容也是共享的。因此,你可以在A模块中改变某个组件的状态,而B模块中可以了解到这一状态变化。 但是,如果不采用单例模式,而是每个模块(Module或Application)维护一个自己的DI容器, 要实现这一点难度会大得多。
所以,这种共享DI容器的设计,是必然的,合理的。
另外,前面我们讲到,当Service Locator中服务或组件的定义是一个PHP callable时,对其形式有一定要求。 一是返回一个实例,二是不接收任何参数。 这在 Yii::createObject() 中也可以看出来。
由于 Yii::createObject() 为 yii\di\ServiceLocator::get() 所调用,且没有提供第二参数, 因此,当使用 Service Locator获取实例时, Yii::createObject() 的 $params 参数为空。 因此,使用call_user_func($type, $params) 调用这个PHP callable时, 这个PHP callable是接收不到任何参数的。
## Yii创建实例的全过程[](http://www.digpage.com/service_locator.html#yii "Permalink to this headline")
可能有的读者朋友会有疑问:不对呀,前面讲过DI容器的使用是要先注册依赖,后获取实例的。 但Service Locator在注册服务、组件时,又没有向DI容器注册依赖。那在获取实例的时候, DI容器怎么解析依赖并创建实例呢?
请留意,在向DI容器索要一个没有注册过依赖的类型时, DI容器视为这个类型不依赖于任何类型可以直接创建, 或者这个类型的依赖信息容器本身可以通过Reflection API自动解析出来,不用提前注册。
可能还有的读者会想:还是不对呀,在我开发Yii的过程中,又没有写过注册服务的代码:
~~~
Yii::$app->set('db', [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=db.digpage.com;dbname=digpage.com',
'username' => 'www.digpage.com',
'password' => 'www.digapge.com',
'charset' => 'utf8',
]);
Yii::$app->set('cache', [
'class' => 'yii\caching\MemCache',
'servers' => [
[
'host' => 'cache1.digpage.com',
'port' => 11211,
'weight' => 60,
],
[
'host' => 'cache2.digpage.com',
'port' => 11211,
'weight' => 40,
],
],
]);
~~~
为何可以在没有注册的情况下获取服务的实例并使用服务呢?
其实,你也不是什么都没写,至少肯定是在某个配置文件中写了有关的内容的:
~~~
return [
'components' => [
'db' => [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=localhost;dbname=yii2advanced',
'username' => 'root',
'password' => '',
'charset' => 'utf8',
],
'cache' => [
'class' => 'yii\caching\MemCache',
'servers' => [
[
'host' => 'cache1.digpage.com',
'port' => 11211,
'weight' => 60,
],
[
'host' => 'cache2.digpage.com',
'port' => 11211,
'weight' => 40,
],
],
],
... ...
],
];
~~~
只不过,在 [_配置项(Configuration)_](http://www.digpage.com/configuration.html#configuration) 和 [_Object的配置方法_](http://www.digpage.com/property.html#object-config) 部分, 我们了解了配置文件是如何产生作用的,配置到应用当中的。 这个数组会被 Yii::configure($config) 所调用,然后会变成调用Application的 setComponents(), 而Application其实就是一个Service Locator。setComponents()方法又会遍历传入的配置数组, 然后使用使用 Service Locator 的set() 方法注册服务。
到了这里,就可以了解到:每次在配置文件的 components 项写入配置信息, 最终都是在向Application这个 Service Locator注册服务。
让我们回顾一下,DI容器、Service Locator是如何配合使用的:
* Yii 类提供了一个静态的 $container 成员变量用于引用DI容器。 在入口脚本中,会创建一个DI容器,并赋值给这个 $container 。
* Service Locator通过 Yii::createObject() 来获取实例, 而这个 Yii::createObject() 是调用了DI容器的 yii\di\Container::get() 来向 Yii::$container 索要实例的。 因此,Service Locator最终是通过DI容器来创建、获取实例的。
* 所有的Module,包括Application都继承自 yii\di\ServiceLocator ,都是Service Locator。 因此,DI容器和Service Locator就构成了整个Yii的基础。
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!
依赖注入和依赖注入容器
最后更新于:2022-04-01 02:19:52
为了降低代码耦合程度,提高项目的可维护性,Yii采用多许多当下最流行又相对成熟的设计模式,包括了依赖注入(Denpdency Injection, DI)和服务定位器(Service Locator)两种模式。 关于依赖注入与服务定位器, [Inversion of Control Containers and the Dependency Injection pattern](http://martinfowler.com/articles/injection.html) 给出了很详细的讲解,这里结合Web应用和Yii具体实现进行探讨,以加深印象和理解。 这些设计模式对于提高自身的设计水平很有帮助,这也是我们学习Yii的一个重要出发点。
## 有关概念[](http://www.digpage.com/di.html#id2 "Permalink to this headline")
在了解Service Locator 和 Dependency Injection 之前,有必要先来了解一些高大上的概念。 别担心,你只需要有个大致了解就OK了,如果展开来说,这些东西可以单独写个研究报告:
**依赖倒置原则(Dependence Inversion Principle, DIP)**
DIP是一种软件设计的指导思想。传统软件设计中,上层代码依赖于下层代码,当下层出现变动时, 上层代码也要相应变化,维护成本较高。而DIP的核心思想是上层定义接口,下层实现这个接口, 从而使得下层依赖于上层,降低耦合度,提高整个系统的弹性。这是一种经实践证明的有效策略。
**控制反转(Inversion of Control, IoC)**
IoC就是DIP的一种具体思路,DIP只是一种理念、思想,而IoC是一种实现DIP的方法。 IoC的核心是将类(上层)所依赖的单元(下层)的实例化过程交由第三方来实现。 一个简单的特征,就是类中不对所依赖的单元有诸如 $component = new yii\component\SomeClass() 的实例化语句。
**依赖注入(Dependence Injection, DI)**
DI是IoC的一种设计模式,是一种套路,按照DI的套路,就可以实现IoC,就能符合DIP原则。 DI的核心是把类所依赖的单元的实例化过程,放到类的外面去实现。
**控制反转容器(IoC Container)**
当项目比较大时,依赖关系可能会很复杂。 而IoC Container提供了动态地创建、注入依赖单元,映射依赖关系等功能,减少了许多代码量。 Yii 设计了一个 yii\di\Container 来实现了 DI Container。
**服务定位器(Service Locator)**
Service Locator是IoC的另一种实现方式, 其核心是把所有可能用到的依赖单元交由Service Locator进行实例化和创建、配置, 把类对依赖单元的依赖,转换成类对Service Locator的依赖。 DI 与 Service Locator并不冲突,两者可以结合使用。 目前,Yii2.0把这DI和Service Locator这两个东西结合起来使用,或者说通过DI容器,实现了Service Locator。
是不是云里雾里的?没错,所谓“高大上”的玩意往往就是这样,看着很炫,很唬人。 卖护肤品的难道会跟你说其实皮肤表层是角质层,不具吸收功能么?这玩意又不考试,大致意会下就OK了。 万一哪天要在妹子面前要装一把范儿的时候,张口也能来这么几个“高大上”就行了。 但具体的内涵,我们还是要要通过下面的学习来加深理解,毕竟要把“高大上”的东西用好,发挥出作用来。
## 依赖注入[](http://www.digpage.com/di.html#id3 "Permalink to this headline")
首先讲讲DI。在Web应用中,很常见的是使用各种第三方Web Service实现特定的功能,比如发送邮件、推送微博等。 假设要实现当访客在博客上发表评论后,向博文的作者发送Email的功能,通常代码会是这样:
~~~
// 为邮件服务定义抽象层
interface EmailSenderInterface
{
public function send(...);
}
// 定义Gmail邮件服务
class GmailSender implements EmailSenderInterface
{
...
// 实现发送邮件的类方法
public function send(...)
{
...
}
}
// 定义评论类
class Comment extend yii\db\ActiveRecord
{
// 用于引用发送邮件的库
private $_eMailSender;
// 初始化时,实例化 $_eMailSender
public function init()
{
...
// 这里假设使用Gmail的邮件服务
$this->_eMailSender = GmailSender::getInstance();
...
}
// 当有新的评价,即 save() 方法被调用之后中,会触发以下方法
public function afterInsert()
{
...
//
$this->_eMailSender->send(...);
...
}
}
~~~
上面的代码只是一个示意,大致是这么个流程。
那么这种常见的设计方法有什么问题呢? 主要问题在于 Comment 对于 GmailSender 的依赖(对于EmailSenderInterface的依赖不可避免), 假设有一天突然不使用Gmail提供的服务了,改用Yahoo或自建的邮件服务了。 那么,你不得不修改 Comment::init() 里面对 $_eMailSender 的实例化语句:
~~~
$this->_eMailSender = MyEmailSender::getInstance();
~~~
这个问题的本质在于,你今天写完这个Comment,只能用于这个项目,哪天你开发别的项目要实现类似的功能, 你还要针对新项目使用的邮件服务修改这个Comment。代码的复用性不高呀。 有什么办法可以不改变Comment的代码,就能扩展成对各种邮件服务都支持么? 换句话说,有办法将Comment和GmailSender解耦么?有办法提高Comment的普适性、复用性么?
依赖注入就是为了解决这个问题而生的,当然,DI也不是唯一解决问题的办法,毕竟条条大路通罗马。 Service Locator也是可以实现解耦的。
在Yii中使用DI解耦,有2种注入方式:构造函数注入、属性注入。
### 构造函数注入[](http://www.digpage.com/di.html#id4 "Permalink to this headline")
构造函数注入通过构造函数的形参,为类内部的抽象单元提供实例化。 具体的构造函数调用代码,由外部代码决定。具体例子如下:
~~~
// 这是构造函数注入的例子
class Comment extend yii\db\ActiveRecord
{
// 用于引用发送邮件的库
private $_eMailSender;
// 构造函数注入
public function __construct($emailSender)
{
...
$this->_eMailSender = $emailSender;
...
}
// 当有新的评价,即 save() 方法被调用之后中,会触发以下方法
public function afterInsert()
{
...
//
$this->_eMailSender->send(...);
...
}
}
// 实例化两种不同的邮件服务,当然,他们都实现了EmailSenderInterface
sender1 = new GmailSender();
sender2 = new MyEmailSender();
// 用构造函数将GmailSender注入
$comment1 = new Comment(sender1);
// 使用Gmail发送邮件
$comment1.save();
// 用构造函数将MyEmailSender注入
$comment2 = new Comment(sender2);
// 使用MyEmailSender发送邮件
$comment2.save();
~~~
上面的代码对比原来的代码,解决了Comment类对于GmailSender等具体类的依赖,通过构造函数,将相应的实现了 EmailSenderInterface接口的类实例传入Comment类中,使得Comment类可以适用于不同的邮件服务。 从此以后,无论要使用何何种邮件服务,只需写出新的EmailSenderInterface实现即可, Comment类的代码不再需要作任何更改,多爽的一件事,扩展起来、测试起来都省心省力。
### 属性注入[](http://www.digpage.com/di.html#id5 "Permalink to this headline")
与构造函数注入类似,属性注入通过setter或public成员变量,将所依赖的单元注入到类内部。 具体的属性写入,由外部代码决定。具体例子如下:
~~~
// 这是属性注入的例子
class Comment extend yii\db\ActiveRecord
{
// 用于引用发送邮件的库
private $_eMailSender;
// 定义了一个 setter()
public function setEmailSender($value)
{
$this->_eMailSender = $value;
}
// 当有新的评价,即 save() 方法被调用之后中,会触发以下方法
public function afterInsert()
{
...
//
$this->_eMailSender->send(...);
...
}
}
// 实例化两种不同的邮件服务,当然,他们都实现了EmailSenderInterface
sender1 = new GmailSender();
sender2 = new MyEmailSender();
$comment1 = new Comment;
// 使用属性注入
$comment1->eMailSender = sender1;
// 使用Gmail发送邮件
$comment1.save();
$comment2 = new Comment;
// 使用属性注入
$comment2->eMailSender = sender2;
// 使用MyEmailSender发送邮件
$comment2.save();
~~~
上面的Comment如果将 private $_eMailSender 改成 public $eMailSender 并删除 setter函数, 也是可以达到同样的效果的。
与构造函数注入类似,属性注入也是将Comment类所依赖的EmailSenderInterface的实例化过程放在Comment类以外。 这就是依赖注入的本质所在。为什么称为注入?从外面把东西打进去,就是注入。什么是外,什么是内? 要解除依赖的类内部就是内,实例化所依赖单元的地方就是外。
## DI容器[](http://www.digpage.com/di.html#id6 "Permalink to this headline")
从上面DI两种注入方式来看,依赖单元的实例化代码是一个重复、繁琐的过程。 可以想像,一个Web应用的某一组件会依赖于若干单元,这些单元又有可能依赖于更低层级的单元, 从而形成依赖嵌套的情形。那么,这些依赖单元的实例化、注入过程的代码可能会比较长,前后关系也需要特别地注意, 必须将被依赖的放在需要注入依赖的前面进行实例化。 这实在是一件既没技术含量,又吃力不出成果的工作,这类工作是高智商(懒)人群的天敌, 我们是不会去做这么无聊的事情的。
就像极其不想洗衣服的人发明了洗衣机(我臆想的,未考证)一样,为了解决这一无聊的问题,DI容器被设计出来了。 Yii的DI容器是 yii\di\Container ,这个容器继承了发明人的高智商, 他知道如何对对象及对象的所有依赖,和这些依赖的依赖,进行实例化和配置。
### DI容器中的内容[](http://www.digpage.com/di.html#id7 "Permalink to this headline")
#### DI容器中实例的表示[](http://www.digpage.com/di.html#id8 "Permalink to this headline")
容器顾名思义是用来装东西的,DI容器里面的东西是什么呢?Yii使用 yii\di\Instance 来表示容器中的东西。 当然Yii中还将这个类用于Service Locator,这个在讲Service Locator时再具体谈谈。
yii\di\Instance 本质上是DI容器中对于某一个类实例的引用,它的代码看起来并不复杂:
~~~
class Instance
{
// 仅有的属性,用于保存类名、接口名或者别名
public $id;
// 构造函数,仅将传入的ID赋值给 $id 属性
protected function __construct($id)
{
}
// 静态方法创建一个Instance实例
public static function of($id)
{
return new static($id);
}
// 静态方法,用于将引用解析成实际的对象,并确保这个对象的类型
public static function ensure($reference, $type = null, $container = null)
{
}
// 获取这个实例所引用的实际对象,事实上它调用的是
// yii\di\Container::get()来获取实际对象
public function get($container = null)
{
}
}
~~~
对于 yii\di\Instance ,我们要了解:
* 表示的是容器中的内容,代表的是对于实际对象的引用。
* DI容器可以通过他获取所引用的实际对象。
* 类仅有的一个属性 id 一般表示的是实例的类型。
#### DI容器的数据结构[](http://www.digpage.com/di.html#id9 "Permalink to this headline")
在DI容器中,维护了5个数组,这是DI容器功能实现的基础:
~~~
// 用于保存单例Singleton对象,以对象类型为键
private $_singletons = [];
// 用于保存依赖的定义,以对象类型为键
private $_definitions = [];
// 用于保存构造函数的参数,以对象类型为键
private $_params = [];
// 用于缓存ReflectionClass对象,以类名或接口名为键
private $_reflections = [];
// 用于缓存依赖信息,以类名或接口名为键
private $_dependencies = [];
~~~
DI容器的5个数组内容和作用如 [_DI容器5个数组示意图_](http://www.digpage.com/di.html#img-di-data) 所示。
![DI容器5个数组示意图](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-08-12_55cb0c4b82962.png)
DI容器5个数组示意图
### 注册依赖[](http://www.digpage.com/di.html#id10 "Permalink to this headline")
使用DI容器,首先要告诉容器,类型及类型之间的依赖关系,声明一这关系的过程称为注册依赖。 使用 yii\di\Container::set() 和 yii\di\Container::setSinglton() 可以注册依赖。 DI容器是怎么管理依赖的呢?要先看看 yii\di\Container::set() 和 yii\Container::setSinglton()
~~~
public function set($class, $definition = [], array $params = [])
{
// 规范化 $definition 并写入 $_definitions[$class]
$this->_definitions[$class] = $this->normalizeDefinition($class,
$definition);
// 将构造函数参数写入 $_params[$class]
$this->_params[$class] = $params;
// 删除$_singletons[$class]
unset($this->_singletons[$class]);
return $this;
}
public function setSingleton($class, $definition = [], array $params = [])
{
// 规范化 $definition 并写入 $_definitions[$class]
$this->_definitions[$class] = $this->normalizeDefinition($class,
$definition);
// 将构造函数参数写入 $_params[$class]
$this->_params[$class] = $params;
// 将$_singleton[$class]置为null,表示还未实例化
$this->_singletons[$class] = null;
return $this;
}
~~~
这两个函数功能类似没有太大区别,只是 set() 用于在每次请求时构造新的实例返回, 而setSingleton() 只维护一个单例,每次请求时都返回同一对象。
表现在数据结构上,就是 set() 在注册依赖时,会把使用 setSingleton() 注册的依赖删除。 否则,在解析依赖时,你让Yii究竟是依赖续弦还是原配?因此,在DI容器中,依赖关系的定义是唯一的。 后定义的同名依赖,会覆盖前面定义好的依赖。
从形参来看,这两个函数的 $class 参数接受一个类名、接口名或一个别名,作为依赖的名称。$definition 表示依赖的定义,可以是一个类名、配置数组或一个PHP callable。
这两个函数,本质上只是将依赖的有关信息写入到容器的相应数组中去。 在 set() 和setSingleton() 中,首先调用 yii\di\Container::normalizeDefinition() 对依赖的定义进行规范化处理,其代码如下:
~~~
protected function normalizeDefinition($class, $definition)
{
// $definition 是空的转换成 ['class' => $class] 形式
if (empty($definition)) {
return ['class' => $class];
// $definition 是字符串,转换成 ['class' => $definition] 形式
} elseif (is_string($definition)) {
return ['class' => $definition];
// $definition 是PHP callable 或对象,则直接将其作为依赖的定义
} elseif (is_callable($definition, true) || is_object($definition)) {
return $definition;
// $definition 是数组则确保该数组定义了 class 元素
} elseif (is_array($definition)) {
if (!isset($definition['class'])) {
if (strpos($class, '\\') !== false) {
$definition['class'] = $class;
} else {
throw new InvalidConfigException(
"A class definition requires a \"class\" member.");
}
}
return $definition;
// 这也不是,那也不是,那就抛出异常算了
} else {
throw new InvalidConfigException(
"Unsupported definition type for \"$class\": "
. gettype($definition));
}
}
~~~
规范化处理的流程如下:
* 如果 $definition 是空的,直接返回数组 ['class' => $class]
* 如果 $definition 是字符串,那么认为这个字符串就是所依赖的类名、接口名或别名, 那么直接返回数组 ['class' => $definition]
* 如果 $definition 是一个PHP callable,或是一个对象,那么直接返回该 $definition
* 如果 $definition 是一个数组,那么其应当是一个包含了元素 $definition['class'] 的配置数组。 如果该数组未定义 $definition['class'] 那么,将传入的 $class 作为该元素的值,最后返回该数组。
* 上一步中,如果 definition['class'] 未定义,而 $class 不是一个有效的类名,那么抛出异常。
* 如果 $definition 不属于上述的各种情况,也抛出异常。
总之,对于 $_definitions 数组中的元素,它要么是一个包含了”class” 元素的数组,要么是一个PHP callable, 再要么就是一个具体对象。这就是规范化后的最终结果。
在调用 normalizeDefinition() 对依赖的定义进行规范化处理后, set() 和 setSingleton() 以传入的$class 为键,将定义保存进 $_definition[] 中, 将传入的 $param 保存进 $_params[] 中。
对于 set() 而言,还要删除 $_singleton[] 中的同名依赖。 对于 setSingleton() 而言,则要将$_singleton[] 中的同名依赖设为 null , 表示定义了一个Singleton,但是并未实现化。
这么讲可能不好理解,举几个具体的依赖定义及相应数组的内容变化为例,以加深理解:
~~~
$container = new \yii\di\Container;
// 直接以类名注册一个依赖,虽然这么做没什么意义。
// $_definition['yii\db\Connection'] = 'yii\db\Connetcion'
$container->set('yii\db\Connection');
// 注册一个接口,当一个类依赖于该接口时,定义中的类会自动被实例化,并供
// 有依赖需要的类使用。
// $_definition['yii\mail\MailInterface', 'yii\swiftmailer\Mailer']
$container->set('yii\mail\MailInterface', 'yii\swiftmailer\Mailer');
// 注册一个别名,当调用$container->get('foo')时,可以得到一个
// yii\db\Connection 实例。
// $_definition['foo', 'yii\db\Connection']
$container->set('foo', 'yii\db\Connection');
// 用一个配置数组来注册一个类,需要这个类的实例时,这个配置数组会发生作用。
// $_definition['yii\db\Connection'] = [...]
$container->set('yii\db\Connection', [
'dsn' => 'mysql:host=127.0.0.1;dbname=demo',
'username' => 'root',
'password' => '',
'charset' => 'utf8',
]);
// 用一个配置数组来注册一个别名,由于别名的类型不详,因此配置数组中需要
// 有 class 元素。
// $_definition['db'] = [...]
$container->set('db', [
'class' => 'yii\db\Connection',
'dsn' => 'mysql:host=127.0.0.1;dbname=demo',
'username' => 'root',
'password' => '',
'charset' => 'utf8',
]);
// 用一个PHP callable来注册一个别名,每次引用这个别名时,这个callable都会被调用。
// $_definition['db'] = function(...){...}
$container->set('db', function ($container, $params, $config) {
return new \yii\db\Connection($config);
});
// 用一个对象来注册一个别名,每次引用这个别名时,这个对象都会被引用。
// $_definition['pageCache'] = anInstanceOfFileCache
$container->set('pageCache', new FileCache);
~~~
setSingleton() 对于 $_definition 和 $_params 数组产生的影响与 set() 是一样一样的。 不同之处在于,使用 set() 会unset $_singltons 中的对应元素,Yii认为既然你都调用 set() 了,说明你希望这个依赖不再是单例了。 而 setSingleton() 相比较于 set() ,会额外地将 $_singletons[$class] 置为 null 。 以此来表示这个依赖已经定义了一个单例,但是尚未实例化。
从 set() 和 setSingleton() 来看, 可能还不容易理解DI容器,比如我们说DI容器中维护了5个数组,但是依赖注册过程只涉及到其中3个。 剩下的 $_reflections 和 $_dependencies 是在解析依赖的过程中完成构建的。
从DI容器的5个数组来看也好,从容器定义了 set() 和 setSingleton() 两个定义依赖的方法来看也好, 不难猜出DI容器中装了两类实例,一种是单例,每次向容器索取单例类型的实例时,得到的都是同一个实例; 另一类是普通实例,每次向容器索要普通类型的实例时,容器会根据依赖信息创建一个新的实例给你。
单例类型主要用于节省构建实例的时间、节省保存实例的内存、共享数据等。而普通类型主要用于避免数据冲突。
### 对象的实例化[](http://www.digpage.com/di.html#id11 "Permalink to this headline")
对象的实例化过程要比依赖的定义过程复杂得多。毕竟依赖的定义只是往特定的数据结构$_singletons $_definitions 和 $_params 3个数组写入有关的信息。 稍复杂的东西也就是定义的规范化处理了。其它真没什么复杂的。像你这么聪明的,肯定觉得这太没挑战了。
而对象的实例化过程要相对复杂,这一过程会涉及到复杂依赖关系的解析、涉及依赖单元的实例化等过程。 且让我们抽丝剥茧地进行分析。
#### 解析依赖信息[](http://www.digpage.com/di.html#id12 "Permalink to this headline")
容器在获取实例之前,必须解析依赖信息。 这一过程会涉及到DI容器中尚未提到的另外2个数组$_reflections 和 $_dependencies 。 yii\di\Container::getDependencies() 会向这2个数组写入信息,而这个函数又会在创建实例时,由 yii\di\Container::build() 所调用。 如它的名字所示意的,yii\di\Container::getDependencies() 方法用于获取依赖信息,让我们先来看看这个函数的代码
~~~
protected function getDependencies($class)
{
// 如果已经缓存了其依赖信息,直接返回缓存中的依赖信息
if (isset($this->_reflections[$class])) {
return [$this->_reflections[$class], $this->_dependencies[$class]];
}
$dependencies = [];
// 使用PHP5 的反射机制来获取类的有关信息,主要就是为了获取依赖信息
$reflection = new ReflectionClass($class);
// 通过类的构建函数的参数来了解这个类依赖于哪些单元
$constructor = $reflection->getConstructor();
if ($constructor !== null) {
foreach ($constructor->getParameters() as $param) {
if ($param->isDefaultValueAvailable()) {
// 构造函数如果有默认值,将默认值作为依赖。即然是默认值了,
// 就肯定是简单类型了。
$dependencies[] = $param->getDefaultValue();
} else {
$c = $param->getClass();
// 构造函数没有默认值,则为其创建一个引用。
// 就是前面提到的 Instance 类型。
$dependencies[] = Instance::of($c === null ? null :
$c->getName());
}
}
}
// 将 ReflectionClass 对象缓存起来
$this->_reflections[$class] = $reflection;
// 将依赖信息缓存起来
$this->_dependencies[$class] = $dependencies;
return [$reflection, $dependencies];
}
~~~
前面讲了 $_reflections 数组用于缓存 ReflectionClass 实例,$_dependencies 数组用于缓存依赖信息。 这个 yii\di\Container::getDependencies() 方法实质上就是通过PHP5 的反射机制, 通过类的构造函数的参数分析他所依赖的单元。然后统统缓存起来备用。
为什么是通过构造函数来分析其依赖的单元呢? 因为这个DI容器设计出来的目的就是为了实例化对象及该对象所依赖的一切单元。 也就是说,DI容器必然构造类的实例,必然调用构造函数,那么必然为构造函数准备并传入相应的依赖单元。 这也是我们开头讲到的构造函数依赖注入的后续延伸应用。
可能有的读者会问,那不是还有setter注入么,为什么不用解析setter注入函数的依赖呢? 这是因为要获取实例不一定需要为某属性注入外部依赖单元,但是却必须为其构造函数的参数准备依赖的外部单元。 当然,有时候一个用于注入的属性必须在实例化时指定依赖单元。 这个时候,必然在其构造函数中有一个用于接收外部依赖单元的形式参数。 使用DI容器的目的是自动实例化,只是实例化而已,就意味着只需要调用构造函数。 至于setter注入可以在实例化后操作嘛。
另一个与解析依赖信息相关的方法就是 yii\di\Container::resolveDependencies() 。 它也是关乎$_reflections 和 $_dependencies 数组的,它使用 yii\di\Container::getDependencies() 在这两个数组中写入的缓存信息,作进一步具体化的处理。从函数名来看,他的名字表明是用于解析依赖信息的。 下面我们来看看它的代码:
~~~
protected function resolveDependencies($dependencies, $reflection = null)
{
foreach ($dependencies as $index => $dependency) {
// 前面getDependencies() 函数往 $_dependencies[] 中
// 写入的是一个 Instance 数组
if ($dependency instanceof Instance) {
if ($dependency->id !== null) {
// 向容器索要所依赖的实例,递归调用 yii\di\Container::get()
$dependencies[$index] = $this->get($dependency->id);
} elseif ($reflection !== null) {
$name = $reflection->getConstructor()
->getParameters()[$index]->getName();
$class = $reflection->getName();
throw new InvalidConfigException(
"Missing required parameter \"$name\" when instantiating \"$class\".");
}
}
}
return $dependencies;
}
~~~
上面的代码中可以看到, yii\di\Container::resolveDependencies() 作用在于处理依赖信息, 将依赖信息中保存的Istance实例所引用的类或接口进行实例化。
综合上面提到的 yii\di\Container::getDependencies() 和 yii\di\Container::resolveDependencies() 两个方法,我们可以了解到:
* $_reflections 以类(接口、别名)名为键, 缓存了这个类(接口、别名)的ReflcetionClass。一经缓存,便不会再更改。
* $_dependencies 以类(接口、别名)名为键,缓存了这个类(接口、别名)的依赖信息。
* 这两个缓存数组都是在 yii\di\Container::getDependencies() 中完成。这个函数只是简单地向数组写入数据。
* 经过 yii\di\Container::resolveDependencies() 处理,DI容器会将依赖信息转换成实例。 这个实例化的过程中,是向容器索要实例。也就是说,有可能会引起递归。
#### 实例的创建[](http://www.digpage.com/di.html#id13 "Permalink to this headline")
解析完依赖信息,就万事俱备了,那么东风也该来了。实例的创建,秘密就在yii\di\Container::build() 函数中:
~~~
protected function build($class, $params, $config)
{
// 调用上面提到的getDependencies来获取并缓存依赖信息,留意这里 list 的用法
list ($reflection, $dependencies) = $this->getDependencies($class);
// 用传入的 $params 的内容补充、覆盖到依赖信息中
foreach ($params as $index => $param) {
$dependencies[$index] = $param;
}
// 这个语句是两个条件:
// 一是要创建的类是一个 yii\base\Object 类,
// 留意我们在《Yii基础》一篇中讲到,这个类对于构造函数的参数是有一定要求的。
// 二是依赖信息不为空,也就是要么已经注册过依赖,
// 要么为build() 传入构造函数参数。
if (!empty($dependencies) && is_a($class, 'yii\base\Object', true)) {
// 按照 Object 类的要求,构造函数的最后一个参数为 $config 数组
$dependencies[count($dependencies) - 1] = $config;
// 解析依赖信息,如果有依赖单元需要提前实例化,会在这一步完成
$dependencies = $this->resolveDependencies($dependencies, $reflection);
// 实例化这个对象
return $reflection->newInstanceArgs($dependencies);
} else {
// 会出现异常的情况有二:
// 一是依赖信息为空,也就是你前面又没注册过,
// 现在又不提供构造函数参数,你让Yii怎么实例化?
// 二是要构造的类,根本就不是 Object 类。
$dependencies = $this->resolveDependencies($dependencies, $reflection);
$object = $reflection->newInstanceArgs($dependencies);
foreach ($config as $name => $value) {
$object->$name = $value;
}
return $object;
}
}
~~~
从这个 yii\di\Container::build() 来看:
* DI容器只支持 yii\base\Object 类。也就是说,你只能向DI容器索要 yii\base\Object 及其子类。 再换句话说,如果你想你的类可以放在DI容器里,那么必须继承自 yii\base\Object 类。 但Yii中几乎开发者在开发过程中需要用到的类,都是继承自这个类。 一个例外就是上面提到的yii\di\Instance 类。但这个类是供Yii框架自己使用的,开发者无需操作这个类。
* 递归获取依赖单元的依赖在于dependencies = $this->resolveDependencies($dependencies, $reflection) 中。
* getDependencies() 和 resolveDependencies() 为 build() 所用。 也就是说,只有在创建实例的过程中,DI容器才会去解析依赖信息、缓存依赖信息。
#### 容器内容实例化的大致过程[](http://www.digpage.com/di.html#id14 "Permalink to this headline")
与注册依赖时使用 set() 和 setSingleton() 对应,获取依赖实例化对象使用yii\di\Container::get() ,其代码如下:
~~~
public function get($class, $params = [], $config = [])
{
// 已经有一个完成实例化的单例,直接引用这个单例
if (isset($this->_singletons[$class])) {
return $this->_singletons[$class];
// 是个尚未注册过的依赖,说明它不依赖其他单元,或者依赖信息不用定义,
// 则根据传入的参数创建一个实例
} elseif (!isset($this->_definitions[$class])) {
return $this->build($class, $params, $config);
}
// 注意这里创建了 $_definitions[$class] 数组的副本
$definition = $this->_definitions[$class];
// 依赖的定义是个 PHP callable,调用之
if (is_callable($definition, true)) {
$params = $this->resolveDependencies($this->mergeParams($class,
$params));
$object = call_user_func($definition, $this, $params, $config);
// 依赖的定义是个数组,合并相关的配置和参数,创建之
} elseif (is_array($definition)) {
$concrete = $definition['class'];
unset($definition['class']);
// 合并将依赖定义中配置数组和参数数组与传入的配置数组和参数数组合并
$config = array_merge($definition, $config);
$params = $this->mergeParams($class, $params);
if ($concrete === $class) {
// 这是递归终止的重要条件
$object = $this->build($class, $params, $config);
} else {
// 这里实现了递归解析
$object = $this->get($concrete, $params, $config);
}
// 依赖的定义是个对象则应当保存为单例
} elseif (is_object($definition)) {
return $this->_singletons[$class] = $definition;
} else {
throw new InvalidConfigException(
"Unexpected object definition type: " . gettype($definition));
}
// 依赖的定义已经定义为单例的,应当实例化该对象
if (array_key_exists($class, $this->_singletons)) {
$this->_singletons[$class] = $object;
}
return $object;
}
~~~
get() 用于返回一个对象或一个别名所代表的对象。可以是已经注册好依赖的,也可以是没有注册过依赖的。 无论是哪种情况,Yii均会自动解析将要获取的对象对外部的依赖。
get() 接受3个参数:
* $class 表示将要创建或者获取的对象。可以是一个类名、接口名、别名。
* $params 是一个用于这个要创建的对象的构造函数的参数,其参数顺序要与构造函数的定义一致。 通常用于未定义的依赖。
* $config 是一个配置数组,用于配置获取的对象。通常用于未定义的依赖,或覆盖原来依赖中定义好的配置。
get() 解析依赖获取对象是一个自动递归的过程,也就是说,当要获取的对象依赖于其他对象时, Yii会自动获取这些对象及其所依赖的下层对象的实例。 同时,即使对于未定义的依赖,DI容器通过PHP的Reflection API,也可以自动解析出当前对象的依赖来。
get() 不直接实例化对象,也不直接解析依赖信息。而是通过 build() 来实例化对象和解析依赖。
get() 会根据依赖定义,递归调用自身去获取依赖单元。 因此,在整个实例化过程中,一共有两个地方会产生递归:一是 get() , 二是 build() 中的 resolveDependencies() 。
DI容器解析依赖实例化对象过程大体上是这么一个流程:
* 以传入的 $class 看看容器中是否已经有实例化好的单例,如有,直接返回这一单例。
* 如果这个 $class 根本就未定义依赖,则调用 build() 创建之。具体创建过程等下再说。
* 对于已经定义了这个依赖,如果定义为PHP callable,则解析依赖关系,并调用这个PHP callable。 具体依赖关系解析过程等下再说。
* 如果依赖的定义是一个数组,首先取得定义中对于这个依赖的 class 的定义。 然后将定义中定义好的参数数组和配置数组与传入的参数数组和配置数组进行合并, 并判断是否达到终止递归的条件。从而选择继续递归解析依赖单元,或者直接创建依赖单元。
从 get() 的代码可以看出:
* 对于已经实例化的单例,使用 get() 时只能返回已经实例化好的实例, $params 参数和$config 参数失去作用。这点要注意,Yii不会提示你,所给出的参数不会发生作用的。 有的时候发现明明已经给定配置数组了,怎么配置不起作用呀?就要考虑是不是因为这个原因了。
* 对于定义为数组的依赖,在合并配置数组和构造函数参数数组过程中, 定义中定义好的两个数组会被传入的 $config 和 $params 的同名元素所覆盖, 这就提供了获取不同实例的可能。
* 在定义依赖时,无论是使用 set() 还是使用 setSingleton() 只要依赖定义为特定对象或特定实例的, Yii均将其视为单例。在获取时,也将返回这一单例。
### 实例分析[](http://www.digpage.com/di.html#id15 "Permalink to this headline")
为了加深理解,我们以官方文档上的例子来说明DI容器解析依赖的过程。假设有以下代码:
~~~
namespace app\models;
use yii\base\Object;
use yii\db\Connection;
// 定义接口
interface UserFinderInterface
{
function findUser();
}
// 定义类,实现接口
class UserFinder extends Object implements UserFinderInterface
{
public $db;
// 从构造函数看,这个类依赖于 Connection
public function __construct(Connection $db, $config = [])
{
$this->db = $db;
parent::__construct($config);
}
public function findUser()
{
}
}
class UserLister extends Object
{
public $finder;
// 从构造函数看,这个类依赖于 UserFinderInterface接口
public function __construct(UserFinderInterface $finder, $config = [])
{
$this->finder = $finder;
parent::__construct($config);
}
}
~~~
从依赖关系看,这里的 UserLister 类依赖于接口 UserFinderInterface , 而接口有一个实现就是UserFinder 类,但这类又依赖于 Connection 。
那么,按照一般常规的作法,要实例化一个 UserLister 通常这么做:
~~~
$db = new \yii\db\Connection(['dsn' => '...']);
$finder = new UserFinder($db);
$lister = new UserLister($finder);
~~~
就是逆着依赖关系,从最底层的 Connection 开始实例化,接着是 UserFinder 最后是 UserLister 。 在写代码的时候,这个前后顺序是不能乱的。而且,需要用到的单元,你要自己一个一个提前准备好。 对于自己写的可能还比较清楚,对于其他团队成员写的,你还要看他的类究竟是依赖了哪些,并一一实例化。 这种情况,如果是个别的、少量的还可以接受,如果有个10-20个的,那就麻烦了。 估计光实例化的代码,就可以写满一屏幕了。
而且,如果是团队开发,有些单元应当是共用的,如邮件投递服务。 不能说你写个模块,要用到邮件服务了,就自己实例化一个邮件服务吧?那样岂不是有N模块就有N个邮件服务了? 最好的方式是使邮件服务成为一个单例,这样任何模块在需要邮件服务时,使用的其实是同一个实例。 用传统的这种实例化对象的方法来实现的话,就没那么直接了。
那么改成DI容器的话,应该是怎么样呢?他是这样的:
~~~
use yii\di\Container;
// 创建一个DI容器
$container = new Container;
// 为Connection指定一个数组作为依赖,当需要Connection的实例时,
// 使用这个数组进行创建
$container->set('yii\db\Connection', [
'dsn' => '...',
]);
// 在需要使用接口 UserFinderInterface 时,采用UserFinder类实现
$container->set('app\models\UserFinderInterface', [
'class' => 'app\models\UserFinder',
]);
// 为UserLister定义一个别名
$container->set('userLister', 'app\models\UserLister');
// 获取这个UserList的实例
$lister = $container->get('userLister');
~~~
采用DI容器的办法,首先各 set() 语句没有前后关系的要求, set() 只是写入特定的数据结构, 并未涉及具体依赖关系的解析。所以,前后关系不重要,先定义什么依赖,后定义什么依赖没有关系。
其次,上面根本没有在DI容器中定义 UserFinder 对于 Connection 的依赖。 但是DI容器通过对UserFinder 构造函数的分析,能了解到这个类会对 Connection 依赖。这个过程是自动的。
最后,上面只有一个 get() 看起来好像根本没有实例化其他如 Connection 单元一样,但事实上,DI容器已经安排好了一切。 在获取 userLister 之前, Connection 和 UserFinder 都会被自动实例化。 其中, Connection 是根据依赖定义中的配置数组进行实例化的。
经过上面的几个 set() 语句之后,DI容器的 $_params 数组是空的, $_singletons 数组也是空的。$_definintions 数组却有了新的内容:
~~~
$_definitions = [
'yii\db\Connection' => [
'class' => 'yii\db\Connection', // 注意这里
'dsn' => ...
],
'app\models\UserFinderInterface' => ['class' => 'app\models\UserFinder'],
'userLister' => ['class' => 'app\models\UserLister'] // 注意这里
];
~~~
在调用 get('userLister') 过程中又发生了什么呢?说实话,这个过程不是十分复杂, 但是由于涉及到递归和回溯,写这里的时候,我写了改,改了写,示意图画了好几回,折腾了好久,都不满意, 就怕说不清楚,读者朋友们理解起来费劲。 最后画了一个简单的示意图,请你们对照 [_DI容器解析依赖获取实例的过程示意图_](http://www.digpage.com/di.html#img-di) , 以及前面关于 get() build() getDependencies() resolveDependencies()等函数的源代码, 了解大致流程。如果有任何疑问、建议,也请在底部留言。
[![DI容器解析依赖获取实例的过程示意图](http://www.digpage.com/_images/DI.png)](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2015-08-12_55cb0c500315e.png)
DI容器解析依赖获取实例的过程示意图
在 [_DI容器解析依赖获取实例的过程示意图_](http://www.digpage.com/di.html#img-di) 中绿色方框表示DI容器的5个数组,浅蓝色圆边方框表示调用的函数和方法。 蓝色箭头表示读取内存,红色箭头表示写入内存,虚线箭头表示参照的内存对象,粗线绿色箭头表示回溯过程。 图中3个圆柱体表示实例化过程中,创建出来的3个实例。
对于 get() 函数:
* 在第1步中调用 get('userLister') 表示要获得一个 userLister 实例。 这个 userLister 不是一个有效的类名,说明这是一个别名。 那么要获取的是这个别名所代表的类的实例。
* 查找 $_definitions 数组,发现$_definitions['userLister'] = ['class'=>'app\models\UserLister'] 。 这里 userLister 不等于app\models\UserLister , 说明要获取的这个 userLister 实例依赖于 app\models\UserLister 。 这是查找依赖定义数组的第一种情况。
* 而在第22、23步中, get(yii\db\Connection) 调用 get() 时指定要获取的实例的类型, 与依赖定义数组 $_definitions 定义的所依赖的类型是相同的,都是 yii\db\Connection 。 也就是说,自己依赖于自己,这就基本达到了停止递归调用 get() 的条件,差不多可以开始反溯了。 这是查找依赖定义数组的第二种情况。
* 第三种情况是第3、4步、第13、14步查找依赖定义数组,发现依赖不存在。 说明所要获取的类型的依赖关系未在容器中注册。 对于未注册依赖关系的,DI容器认为要么是一个没有外部依赖的简单类型, 要么是一个容器自身可以自动解析其依赖关系的类型。
* 对于第一种情况,要获取的类型依赖于其他类型的,递归调用 get() 获取所依赖的类型。
* 对于第二、三种情况,直接调用 build() 尝试获取该类型的实例。
build() 在实例化过程中,干了这么几件事:
* 调用 getDependencies() 获取依赖信息。
* 调用 resolveDependencies() 解析依赖信息。
* 将定义中的配置数组、构造函数参数与调用 get() 时传入的配置数组和构造参数进行合并。 这一步并未在上面的示意图中体现,请参阅 build() 的源代码部分。
* 根据解析回来的依赖单元,调用 newInstanceArgs() 创建实例。 请留意第36、42步,并非直接由 resolveDependencies() 调用 newInstanceArgs() 。 而是 resolveDependencies() 将依赖单元返回后,由 build() 来调用。就像第31步一样。
* 将获取的类型实例返回给调用它的 get() 。
getDependencies() 函数总是被 build() 调用,他干了这么几件事:
* 创建ReflectionClass,并写入 $_reflections 缓存数组。如第6步中,$_reflections['app\models\UserLister'] = new ReflectionClass('app\models\UserLister') 。
* 利用PHP的Reflection API,通过分析构造函数的形式参数,了解到当前类型对于其他单元、默认值的依赖。
* 将上一步了解到的依赖,在 $_dependencies 缓存数组中写入一个 Instance 实例。如第7、8步。
* 当一个类型的构造函数的参数列表中,没有默认值、参数都是简单类型时,得到一个 [null] 。 如第28步。
resolveDependencies() 函数总是被 build() 调用,他在实例化时,干了这么几件事:
* 根据缓存在 $_dependencies 数组中的 Instance 实例的 id , 递归调用容器的 get() 实例化依赖单元。并返回给 build() 接着运行。
* 对于像第28步之类的依赖信息为 [null] 的,则什么都不干。
newInstanceArgs() 函数是PHP Reflection API的函数,用于创建实例,具体请看 [PHP手册](http://php.net/manual/zh/reflectionclass.newinstanceargs.php) 。
这里只是简单的举例子而已,还没有涉及到多依赖和单例的情形,但是在原理上是一样的。 希望继续深入了解的读者朋友可以再看看上面有关函数的源代码就行了,有疑问请随时留言。
从上面的例子中不难发现,DI容器维护了两个缓存数组 $_reflections 和 $_dependencies 。这两个数组只写入一次,就可以无限次使用。 因此,减少了对ReflectionClass的使用,提高了DI容器解析依赖和获取实例的效率。
另一方面,我们看到,获取一个实例,步骤其实不少。但是,对于典型的Web应用而言, 有许多模块其实应当注册为单例的,比如上面的 yii\db\Connection 。 一个Web应用一般使用一个数据库连接,特殊情况下会用多几个,所以这些数据库连接一般是给定不同别名加以区分后, 分别以单例形式放在容器中的。因此,实际获取实例时,步骤会简单得。对于单例, 在第一次 get() 时,直接就返回了。而且,省去不重复构造实例的过程。
这两个方面,都体现出Yii高效能的特点。
上面我们分析了DI容器,这只是其中的原理部分,具体的运用,我们将结合 [_服务定位器(Service Locator)_](http://www.digpage.com/service_locator.html#service-locator) 来讲。
如果觉得《深入理解Yii2.0》对您有所帮助,也请[帮助《深入理解Yii2.0》](http://www.digpage.com/donate.html#donate)。 谢谢!