Diggid's Blog

ThinkPHP 系列框架梳理

字数统计: 5.5k阅读时长: 23 min
2021/05/26 Share

ThinkPHP 5.0.x

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
project  应用部署目录
├─application 应用目录(可设置)
│ ├─common 公共模块目录(可更改)
│ ├─index 模块目录(可更改) module
│ │ ├─config.php 模块配置文件
│ │ ├─common.php 模块函数文件
│ │ ├─controller 控制器目录 controller
│ │ ├─model 模型目录
│ │ ├─view 视图目录
│ │ └─ ... 更多类库目录
│ ├─command.php 命令行工具配置文件
│ ├─common.php 应用公共(函数)文件
│ ├─config.php 应用(公共)配置文件
│ ├─database.php 数据库配置文件
│ ├─tags.php 应用行为扩展定义文件
│ └─route.php 路由配置文件
├─extend 扩展类库目录(可定义)
├─public WEB 部署目录(对外访问目录)**
│ ├─static 静态资源存放目录(css,js,image)
│ ├─index.php 应用入口文件
│ ├─router.php 快速测试文件
│ └─.htaccess 用于 apache 的重写
├─runtime 应用的运行时目录(可写,可设置)
├─vendor 第三方类库目录(Composer)**
├─thinkphp 框架系统目录
│ ├─lang 语言包目录
│ ├─library 框架核心类库目录
│ │ ├─think Think 类库包目录 **
│ │ └─traits 系统 Traits 目录
│ ├─tpl 系统模板目录
│ ├─.htaccess 用于 apache 的重写
│ ├─.travis.yml CI 定义文件
│ ├─base.php 基础定义文件
│ ├─composer.json composer 定义文件
│ ├─console.php 控制台入口文件
│ ├─convention.php 惯例配置文件
│ ├─helper.php 助手函数文件(可选)
│ ├─LICENSE.txt 授权说明文件
│ ├─phpunit.xml 单元测试配置文件
│ ├─README.md README 文件
│ └─start.php 框架引导文件 **
├─build.php 自动生成定义文件(参考)
├─composer.json composer 定义文件 **
├─LICENSE.txt 授权说明文件
├─README.md README 文件
├─think 命令行入口文件

生命周期

这里根据官方文档的说明,一步步跟进调试源码,来梳理TP5.0.x的整个执行流程,并熟悉核心类库的各个核心类。

入口文件

  • 通常是public/index.php,可以更改
  • 常用于定义系统常量以及加载引导文件

引导文件

默认是/thinkphp/start.php,并且加载同目录下base.php文件,主要完成以下操作

1.加载系统常量、环境常量和变量(.env)

常见的常量有:

2.注册自动加载机制

任何关于类加载的操作,核心处理类为\think\Loader,该类定义了autoload方法,并在引导文件时注册该方法为自动类加载机制的处理方法。

image-20210526180904722

常见类库文件的位置及其根命名空间(默认):

根命名空间 描述 类库目录
think 系统核心类库 thinkphp/library/think
traits 系统Trait类库 thinkphp/library/traits
app 应用类库 application
think composer第三方类库 vendor/topthink
自定义 自定义类库 extend/自定义名

3.

路由

路由模式

三种路由模式

  • 普通模式(默认)

该模式下关闭路由'url_route_on' => false,不会解析任何路由规则,而是完全通过默认的PATH_INFO模式来访问,即

1
http://serverName/index.php/module/controller/action/param/value/...

对于参数解析,默认成对解析,也可以改为顺序解析,即每个参数都是值,索引递增,'url_param_type' => 1

  • 混合模式
1
2
'url_route_on'  =>  true,
'url_route_must'=> false,

该模式下,定义路由规则的则按路由规则访问,未定义的则按普通模式访问。

  • 强制模式
1
2
'url_route_on'  		=>  true,
'url_route_must' => true,

该模式默认不开启,每一个访问必须严格根据路由定义进行dispatch,如果为定义相关路由,则直接抛出异常

路由定义

  • 动态注册
1
think\Route::rule('路由表达式','路由地址','请求类型','路由参数(数组)','变量规则(数组)');

默认在应用目录下的route.php文件,如application/route.php,用法

1
2
// 注册路由到index模块的News控制器的read操作
Route::rule('new/:id','index/News/read');

对应不同请求类型的还有便利方法有

1
2
3
4
5
Route::get('new/:id','News/read'); // 定义GET请求路由规则
Route::post('new/:id','News/update'); // 定义POST请求路由规则
Route::put('new/:id','News/update'); // 定义PUT请求路由规则
Route::delete('new/:id','News/delete'); // 定义DELETE请求路由规则
Route::any('new/:id','News/read'); // 所有请求都支持的路由规则
  • 路由表达式

/为参数分割符,支持一下几种形式

1
2
3
4
5
6
7
8
9
10
11
12
13
'/' => 'index' // 首页访问路由

'my' => 'Member/myinfo' // 静态地址路由

'blog/:id' => 'Blog/read' // 静态地址和动态地址结合

':user/:blog_id'=>'Blog/read' // 全动态地址

'blog/:year/[:month]'=>'Blog/archive' //可选路径

'new/:cate$'=> 'News/category' //规则匹配检测的时候只是对URL从头开始匹配,只要URL地址包含了定义的路由规则就会匹配成功,如果希望完全匹配,可以在路由表达式最后使用$符号,配置'route_complete_match' => true,全部使用完全匹配

'blog/:id'=>'blog/read?status=1&app_id=5' //额外参数,相当于隐式设定初值
  • 路由参数

数组形式,对路由规则的补充,常见有以下配置

1
2
3
4
5
[
'ext' => 'html',
'route_complete_match' => false,

]
  • 变量规则

数组形式,对路由表达式中定义的变量增加约束规则(正则),比如限制为数字、字符串等,通常是一系列简单正则的组合,如

1
2
3
4
[
'id'=>'\d+',
'name'=>'\w+'
]
  • 定义路由配置文件

在我们所说的route.php的最后通过返回数组的方式直接定义路由规则,所有跟配置相关的php形式,都是返回数组(底层实现就是使用include包含并赋值的)

默认情况下,只会加载一个路由配置文件route.php,如果你需要定义多个路由文件,可以修改route_config_file配置参数

1
'route_config_file' =>  ['route', 'route1', 'route2'],

变量规则

  • 类方法
1
2
3
Route::pattern(name, pattern) // 定义全局变量,即所有路由的同名name变量都使用该规则,会被局部变量定义覆盖

Route::rule系列 // 局部变量
  • 规则
1
2
3
4
5
6
7
8
9
10
11
12
全局变量
Route::pattern('name', '\w+')

局部变量
Route::get('new/:id','News/read',[],['id'=>'\w+']);

完整URL检查
['__url__'=>'new\/\w+$'] //完整URL检查,用在注册路由时
如:Route::get('new/:id','News/read',[],['__url__'=>'new\/\w+$']);

组合变量:相当于一个/x/的x部分由多种类型的x=abc组成,因此要为abc分别制定规则,abc需要用<a>来包裹
Route::get('item-<name>-<id>','product/detail',[],['name'=>'\w+','id'=>'\d+']);

路由参数

常用的有

参数 说明 实例
method 请求类型检测,支持多个请求类型 用于Route::any
ext URL后缀检测,支持匹配多个后缀 V5.0.7版本以上,extdeny_ext参数允许设置为空,分别表示不允许任何后缀以及必须使用后缀访问。
deny_ext URL禁止后缀检测,支持匹配多个后缀 同上
https 检测是否https请求
domain 域名检测 支持完整域名检测、子域名检测(部分匹配即可)
before_behavior 前置行为(检测) 支持使用行为对路由进行检测是否匹配,如果行为方法返回false表示当前路由规则无效。
after_behavior 后置行为(执行) 路由匹配后,对路由的额外处理
callback 自定义检测方法 使用自定义函数进行检测,false不通过,有点类似前置行为检测
merge_extra_vars 合并额外参数 通常用于完整路由,如果有额外的参数则合并作为变量值,即多余的部分合并为一个整体,如new/:name$规则下new/thinkphp/hello,则name匹配thinkphp/hello
bind_model 绑定模型(V5.0.1+ 'bind_model'=>['User','name'],当前路由匹配后绑定一个模型,依赖注入获取
cache 请求缓存(V5.0.1+ 'cache'=>3600,单位 s
param_depr 路由参数分隔符(V5.0.2+
ajax Ajax检测(V5.0.2+
pjax Pjax检测(V5.0.2+

V5.0.7版本以上,extdeny_ext参数允许设置为空,分别表示不允许任何后缀以及必须使用后缀访问。

路由地址*

这一部分涉及TP5.0.13的RCE,利用captcha路由到方法,绕过module分支,不用开启debug下即可RCE。

1
\think\Route::get('captcha/[:id]', "\\think\\captcha\\CaptchaController@index");

用以下方式访问即可触发RCE

1
2
3
?s=captcha
POST:
a=calc&filter[]=system&_method=__construct

这里比较绕,需要理清楚\/的区别,命名空间用\,默认PATH_INFO的分隔符用/

类型 格式(指的是路由定义中的路由地址格式,不是URL传入格式) 用法说明
路由到模块/控制器 ‘[模块/控制器/操作]?额外参数1=值1&额外参数2=值2…’ 默认用法,不开启路由模式时也按该方式解析。支持额外参数,支持多级控制器,如index/group.blog/read,相当于访问二级的group/blog控制器
路由到重定向地址 ‘外部地址’(默认301重定向) 或者 [‘外部地址’,’重定向代码’] 开头为/,则本地重定向;为http:s?则重定向到外部,有利于网站前移,注意区分开头有无/,若无则表示默认的路由方式,若有则表示重定向
路由到控制器方法 ‘@[模块/控制器/]操作’ 模块/控制器若绑定的话可以省略,该方式不会初始化模块,直接调用操作,相当于调用Loader::action()方法
路由到类的方法 ‘\完整的命名空间类::静态方法’ 或者 ‘\完整的命名空间类@动态方法’ 这个是上面方式的拓展,路由到的类方法不局限于模块中的控制器类,而是可以路由到任意类的方法,注意此处用命名空间,如\app\index\service\Blog::read
路由到闭包函数 闭包函数定义(支持参数传入) 路由地址的位置变成闭包函数,那么直接调用闭包函数而不去寻找控制器。如Route::get(‘hello/:name’,function($name){ return ‘Hello,’.$name; });

资源路由

需要在配置文件中用__rest__来定义资源路由

1
2
3
4
'__rest__'=>[
// 指向index模块的blog控制器,相当于在写路由规则时,blog等同于index/blog,也就是URL访问可以省略模块了
'blog'=>'index/blog',
],

这个比较难理解的地方是资源路由的嵌套,还是要注意区分rule()方法的几个参数。我理解的资源路由相当于一种标准的资源对象,有点类似于java Bean,其类方法由特定的规则或类型组成(比如规定方法名称必须是什么样)。然后通过URL中特定的访问规则(即路由规则),从而定位到规定资源路由控制器类的特定操作。这里的资源路由类需要由以下方法。

标识 请求类型 生成路由规则 对应操作方法(默认)
index GET blog index
create GET blog/create create
save POST blog save
read GET blog/:id 注意id变量 read
edit GET blog/:id/edit edit
update PUT blog/:id update
delete DELETE blog/:id delete

两个方法

方法1

1
Route::resource('路由规则','资源路由控制器类',选项)

比如:

1
Route::resource('blog','index/blog',['var'=>['blog'=>'blog_id']]);

访问(GET方法访问):

1
2
3
4
http://serverName/blog/         //对应index/blog/index
http://serverName/blog/128 //对应index/blog/read/:id
http://serverName/blog/28/edit //对应index/blog/edit/:id
http://serverName/blog/create //对应index/blog/edit/:id

而进一步对应

1
2
3
4
public function index()
public function read($id)
public function edit($id)
public function create

方法2

1
Route::rest('原操作名', ['method', '/更改后的操作名', '更改后的操作名'])

该方法用于更改默认的资源操作,支持批量更改,如

1
Route::rest('create',['GET', '/add','add']);

那么访问

1
2
3
http://serverName/blog/create
变成
http://serverName/blog/add

资源嵌套

1
Route::resource('blog.comment','index/comment');

最后指向资源处理控制器还是comment,所以在__rest__中注册时要注册comment的,不同的是路由规则变了,且允许两个变量,一个变量对应blog的,一个变量对应comment的,比如

1
blog/:blog_id/comment/:id/edit

前缀的变量名默认为xxx_id

对应的操作

1
2
3
4
5
namespace app\index\controller;
class Comment{
public function edit($id,$blog_id){
}
}

其他特性

其他特性大部分都是为了简化URL的特性。

  • 快捷路由

给模块/控制器一个别名,且针对操作可以指定method前缀

1
Route::controller('user','index/User');

定义

1
2
3
4
5
class User {
public function getInfo() //get类型
{
}
}

访问

1
get http://localhost/user/info
  • 路由别名

快捷路由第一个功能的plus版,且额外支持其他的路由参数

  • 路由绑定

三种绑定方式:绑定到模块/控制器/操作(直接访问操作)、绑定到命名空间(访问类/操作)、绑定到类(直接访问操作)

控制器

请求

请求的核心类是\think\Request类,可以使用以下两种方式生成一个该类对象

1
2
$request = Request::instance();
$request = request();

获取请求/变量信息*

支持一堆获取请求信息的方式。包括获取$_GET等外部请求信息,以及路由、模块、控制器等其他信息内置请求信息。有几种类型:

1
2
3
4
5
6
7
8
9
10
11
12
1.针对$_GET$_POST$_REQUEST$_SERVER$_SESSION$_COOKIE$_ENV等系统变量
Request::instance()->变量类型方法('变量名/变量修饰符','默认值','过滤方法')
助手函数:request()->变量类型方法('变量名/变量修饰符','默认值','过滤方法')
助手函数:input('变量类型方法.变量名');

2.针对其他具体的请求信息,如ip,pathinfo等
Request::instance()->信息名()
助手函数:request()->信息名()

3.获取HTTP头部信息,返回数组
$head = Request::instance()->header(["具体信息"])
$head['user-agent'] $head['accept']

第一种类型的变量类型方法有以下几种:

方法 描述
param 获取当前请求的变量
get 获取 $_GET 变量
post 获取 $_POST 变量
put 获取 PUT 变量
delete 获取 DELETE 变量
session 获取 $_SESSION 变量
cookie 获取 $_COOKIE 变量
request 获取 $_REQUEST 变量
server 获取 $_SERVER 变量
env 获取 $_ENV 变量
route 获取 路由(包括PATHINFO) 变量
file 获取 $_FILES 变量

这里特别注意一下过滤方法,即第三个参数,其默认配置为

1
2
默认全局过滤方法
default_filter' => 'htmlspecialchars'

这里可以看到,过滤方法名就是php内置函数htmlspecialchars,所以这里可以联想一下,其底层实现有没有可能使用call_user_func之类的函数调用方法,而TP5的调用任意method的RCE的Sink点确实在filterValue方法中使用了call_user_func。所以针对过滤函数、回调方法之类的关键词,可以稍微关注一下其底层实现,从特性定义来思考到其底层实现的一些潜在的危险操作。

更改变量

1
2
3
4
5
// 更改GET变量
Request::instance()->get(['id'=>10]);
// 更改POST变量
Request::instance()->post(['name'=>'thinkphp']);
// 无法更改param方法变量

请求类型

使用isXxx的函数形式,返回bool值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 是否为 GET 请求
if (Request::instance()->isGet()) echo "当前为 GET 请求";
// 是否为 POST 请求
if (Request::instance()->isPost()) echo "当前为 POST 请求";
// 是否为 PUT 请求
if (Request::instance()->isPut()) echo "当前为 PUT 请求";
// 是否为 DELETE 请求
if (Request::instance()->isDelete()) echo "当前为 DELETE 请求";
// 是否为 Ajax 请求
if (Request::instance()->isAjax()) echo "当前为 Ajax 请求";
// 是否为 Pjax 请求
if (Request::instance()->isPjax()) echo "当前为 Pjax 请求";
// 是否为手机访问
if (Request::instance()->isMobile()) echo "当前为手机访问";
// 是否为 HEAD 请求
if (Request::instance()->isHead()) echo "当前为 HEAD 请求";
// 是否为 Patch 请求
if (Request::instance()->isPatch()) echo "当前为 PATCH 请求";
// 是否为 OPTIONS 请求
if (Request::instance()->isOptions()) echo "当前为 OPTIONS 请求";
// 是否为 cli
if (Request::instance()->isCli()) echo "当前为 cli";
// 是否为 cgi
if (Request::instance()->isCgi()) echo "当前为 cgi";

助手函数:
request()->isGet()

请求伪装*

这个也涉及TP5调用Request类方法来RCE。其中我们传入的_method就是请求伪装变量,其会保存在$this->method中,从而通过

1
$this->{$this->method}($_POST);

导致调用任意Requst类方法方法

有以下几种伪装形式

1
2
3
4
5
6
7
8
9
1.伪装method:
POST: _method=xxx
可以设置为任何合法的请求类型,包括GET、POST、PUT和DELETE等。
可更改伪装变量名: 'var_method' => '_m'

2.伪装AJAX/PJAX
GET : ?_ajax=1
GET : ?_pjax=1
可更改伪装变量名: 'var_ajax' => '_a'

伪静态

伪静态即给访问的URL地址添加特定后缀,具有静态页面的URL特征,也有利于优化SEO

设置

1
2
'url_html_suffix' => 'html|...|...' //默认为html
'url_html_suffix' => false //关闭

访问

1
2
http://serverName/index/blog/3
http://serverName/index/blog/3.html

获取

1
Request::instance()->ext();

几种注入

1.方法注入

即动态自定义Request类的方法

1
Request::hook('方法调用名','方法定义名');

定义

1
2
3
4
5
function getUserInfo(Request $request, $userId) //第一个参数必须为Request $xxx
{
// 根据$userId获取用户信息
return $info;
}

调用

1
Request::instance()->user("xxx");

2.属性注入

顾名思义,可以为当前Request类注入一些属性

绑定

1
2
Request::instance()->bind('user',new User);
Request::instance()->user = new User;

获取

1
2
3
Request::instance()->user;
$this->request->user; //控制器注入请求对象的话
request()->user; //助手函数

3.依赖注入

依赖注入相当于将一个对象通过方法的参数注入进该方法中,从而在该方法中可以获取/调用这个注入对象的所有属性/方法,主要是通过bindParams和getParamValue来完成底层实现,最后通过反射类的invokeArgs方法完成调用,支持的依赖注入方式有

  • 自动注入请求

即在操作方法中定义Request $request,即可自动注入Request类,其底层先通过ReflectionMethod类的getParameters获取被注入的操作方法的参数,包含request,然后获取该参数名后再调用ReflectionMethod类的getClass获取参数的类型提示类,若该类不为空。则先判断该类是否可以通过Request::instance()->$name的方式直接获取(即之前是否属性注入),若有,则直接返回,若无,则进一步判断该类是否有public static属性的invoke方法,若有,则直接注入Request对象,若无则实例化要注入的对象,最后返回。

因此根据底层分析,也就衍生出了一系列功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
1.架构方法注入
namespace app\index\controller;

use think\Request;

class Index
{
protected $request;

public function __construct(Request $request)
{
$this->request = $request;
}

public function hello()
{
return 'Hello,' . $this->request->param('name') . '!';
}

}

2.操作方法注入
public function hello(Request $request)
{
return 'Hello,' . $request->param('name') . '!';
}
  • 其他对象注入

其他对象注入支持事先的属性注入,若未绑定,则注入一个默认初始化的对象,相当于

1
2
3
4
5
# 事先属性注入
Request::instance()->bind('user', new \app\index\model\User(1));

# 未注入,则默认
Request::instance()->bind('user', new \app\index\model\User());

同样地

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1.架构方法注入
namespace app\index\controller;

use app\index\model\User;
use think\Request;

class Index
{
protected $request;
protected $user;

public function __construct(Request $request, User $user)
{
$this->request = $request;
$this->user = $user;
}

}

2.操作方法注入
  • invoke方法自动注入Request对象
1
2
3
4
5
6
7
8
9
10
11
namespace app\index\model;

use think\Model;
class User extends Model
{
public static function invoke(Request $request)
{
$id = $request->param('id');
return User::get($id);
}
}

参数绑定

支持名称对参数绑定(默认)和顺序参数绑定(简化URL),相当于URL中的变量与操作参数定义的同名变量一一对应,有点类似于函数的名称参数。底层实现还是依靠ReflectionMethod类的getParameters的方法,然后再getName获取名称,如果存在则直接带名称调用invokeArgs,否则抛出索引不存在异常,其实现模板应该是参考的这个

https://www.php.net/manual/zh/reflectionmethod.invokeargs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
We can do black magic, which is useful in templating block calls:

<?php
$object->__named('methodNameHere', array('arg3' => 'three', 'arg1' => 'one'));

...

/**
* Pass method arguments by name //注意这里
*
* @param string $method
* @param array $args
* @return mixed
*/
public function __named($method, array $args = array())
{
$reflection = new ReflectionMethod($this, $method);

$pass = array();
foreach($reflection->getParameters() as $param)
{
/* @var $param ReflectionParameter */
if(isset($args[$param->getName()]))
{
$pass[] = $args[$param->getName()];
}
else
{
$pass[] = $param->getDefaultValue();
}
}

return $reflection->invokeArgs($this, $pass);
}
?>
CATALOG
  1. 1. ThinkPHP 5.0.x
    1. 1.1. 目录结构
    2. 1.2. 生命周期
      1. 1.2.1. 入口文件
      2. 1.2.2. 引导文件
    3. 1.3. 路由
      1. 1.3.1. 路由模式
      2. 1.3.2. 路由定义
      3. 1.3.3. 变量规则
      4. 1.3.4. 路由参数
      5. 1.3.5. 路由地址*
      6. 1.3.6. 资源路由
      7. 1.3.7. 其他特性
    4. 1.4. 控制器
    5. 1.5. 请求
      1. 1.5.1. 获取请求/变量信息*
      2. 1.5.2. 更改变量
      3. 1.5.3. 请求类型
      4. 1.5.4. 请求伪装*
      5. 1.5.5. 伪静态
      6. 1.5.6. 几种注入
      7. 1.5.7. 参数绑定