# thinkphp8源码精讲 **Repository Path**: extraordinary-x/thinkphp8 ## Basic Information - **Project Name**: thinkphp8源码精讲 - **Description**: 深入thinkphp8框架源码,让你使用起来更加如鱼得水 - **Primary Language**: PHP - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 8 - **Forks**: 2 - **Created**: 2024-03-11 - **Last Updated**: 2025-06-25 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 前言 很开心你能看到这个笔记,相信你对`thinkphp`是有一定兴趣的,正好大家都是志同道合的人。 `thinkphp`是我入门学习的第一个框架,经过这么多年了,还没好好的研究它,今年利用了空闲的时间狠狠的深入源码学习了一把,顺便就把学习的过程都记录下来,分享给有兴趣的同学。 我阅读源码使用的`thinkphp`版本是最新的版本8,我的目标是对框架源码进行一次全面的解读,目前已经完成了大部分的源码的学习,后续会继续更新更多的笔记。 如果有兴趣的同学可以私信我,大家相互交流学习! **还有,创作不易,如果对你有帮助,帮忙点个赞~** # 探索类的自动加载 在传统的 `PHP` 开发中,当需要使用某个类时,开发人员需要手动引入这个类所在的文件,例如使用 `require` 或 `include` 函数。但随着项目规模的增大,类文件数量增多,手动管理类文件引入变得非常繁琐和容易出错,为了解决这一弊端,框架就引入了类的自动加载机制。 接下来我们开始`Thinkphp`源码的探索之路~ ## 什么是PSR-4规范 `Thinkphp`类的自动加载主要遵循了`PSR-4`规范,个别地方使用了`PSR-0`规范 ,我们主要是了解一下`PSR-4`规范。什么是`PSR-4`规范?相信很多人都不知道。 **`PSR-4`规范了如何指定文件路径从而自动加载类定义,同时规范了自动加载文件的位置** ,简单的讲,就是把需要加载的类文件放在指定的目录。 接下来我们深入源码,看看这个`PSR-4`规范是如何定义的,打开我们的入口文件`index.php` ```php namespace think; require __DIR__ . '/../vendor/autoload.php'; // 省略代码 ``` 我们打开`autoload.php`,再进入`autoload_real.php`,会看到这样的一个方法`getLoader()`,其中有一行代码 ```php public static function getLoader() { // 省略代码 // `PSR-4`规范 require __DIR__ . '/autoload_static.php'; // 省略代码 } ``` 我们进入`autoload_static.php`类,了解其规范 ```php class ComposerStaticInit71a72e019c6be19dac67146f3f5fb8de { // 使用composer加载进来的类,你可以使用composer require topthink/think-captcha // 引入验证码类,你会发现这里会多了一项内容,试试看! public static $files = array ( '9b552a3cc426e3287cc811caefa3cf53' => __DIR__ . '/..' . '/topthink/think-helper/src/helper.php', '35fab96057f1bf5e7aba31a8a6d5fdde' => __DIR__ . '/..' . '/topthink/think-orm/stubs/load_stubs.php', '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php', '667aeda72477189d0494fecd327c3641' => __DIR__ . '/..' . '/symfony/var-dumper/Resources/functions/dump.php', ); // 一时半会不知道怎么描述这个东西,只好直白一点了 // $prefixLengthsPsr4是个二维数组,think\\trace\\这是命名空间,作为键名,然后长度作为值,注意这里 // “\\”只能算一个字符,因为反斜杠是转义符,最外层是使用命名空间的第一个字符作为键名 public static $prefixLengthsPsr4 = array ( 't' => array ( 'think\\trace\\' => 12, 'think\\' => 6, ), // 省略部分代码 ); // 这个变量定义的是命名空间对应的目录,就是对目录进行归类,后面自动加载类的时候,只有满足了这些对应关系的 // 类才能被加载 public static $prefixDirsPsr4 = array ( 'think\\trace\\' => array ( 0 => __DIR__ . '/..' . '/topthink/think-trace/src', ), 'think\\' => array ( 0 => __DIR__ . '/..' . '/topthink/framework/src/think', 1 => __DIR__ . '/..' . '/topthink/think-filesystem/src', 2 => __DIR__ . '/..' . '/topthink/think-helper/src', 3 => __DIR__ . '/..' . '/topthink/think-orm/src', ), // 省略部分代码 ); // extend是不是很熟悉,自定义的类就是放在这个目录 public static $fallbackDirsPsr0 = array ( 0 => __DIR__ . '/../..' . '/extend', ); // 这个可以理解为缓存变量,后面也会用到 public static $classMap = array ( 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', ); // 省略代码 } ``` 从这个类中我们大概可以猜到,在`$prefixDirsPsr4`、`$fallbackDirsPsr0`、`$classMap`这些变量中定义的目录下面的类都会被自动加载,例如入口文件的`new App()`中的`App.php`文件,它就位于`topthink/framework/src/think` ```php 'think\\' => array ( 0 => __DIR__ . '/..' . '/topthink/framework/src/think',// `App.php`在这个目录下 1 => __DIR__ . '/..' . '/topthink/think-filesystem/src', 2 => __DIR__ . '/..' . '/topthink/think-helper/src', 3 => __DIR__ . '/..' . '/topthink/think-orm/src', ), ``` ## spl_autoload_register函数 上面`PSR-4`规范定义了类文件所在的目录,那么如何把这些类自动引入进来呢?要实现这个功能,那就要使用`php`内置函数`spl_autoload_register `。 我们简单说明一下这个函数 ```php spl_autoload_register(?callable $callback = null, bool $throw = true, bool $prepend = false): bool ``` 该函数有三个参数 | 参数名 | 描述 | | -------- | ------------------------------------------------------------ | | callback | 自动装载函数:callback([string](https://www.php.net/manual/zh/language.types.string.php) `$class`): [void](https://www.php.net/manual/zh/language.types.void.php),`$class`参数是类名 | | throw | 此参数指定 **spl_autoload_register()** 在无法注册 `callback` 时是否应抛出异常。 | | prepend | 如果是 **`true`**,**spl_autoload_register()** 会添加函数到队列之首,而不是队列尾部。 | 其实最重要的是第一个参数, 当`PHP`解析器遇到一个未知的类名时,会自动调用已注册的自动加载函数来加载对应的类文件,我们源码中的`\Composer\Autoload\ClassLoader`类就是使用此这个函数进行类的自动引入。 我们还是回到前面提到的`getLoader()`方法 ```php class ComposerAutoloaderInit71a72e019c6be19dac67146f3f5fb8de { private static $loader; public static function loadClassLoader($class) { if ('Composer\Autoload\ClassLoader' === $class) { // 这里就引入了ClassLoader require __DIR__ . '/ClassLoader.php'; } } public static function getLoader() { // 省略代码 // 注册一个自动装载函数,这个函数是当前类里面的loadClassLoader方法 spl_autoload_register(array('ComposerAutoloaderInit71a72e019c6be19dac67146f3f5fb8de', 'loadClassLoader'), true, true); // 当程序运行到这里,new ClassLoader时,因为这个类在前面没有被引入过,因此会自动调用我们使用 // spl_autoload_register函数注册的那个自动装载函数,也就是来到了loadClassLoader方法 self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__)); // 注销自动装载函数,回收资源 spl_autoload_unregister(array('ComposerAutoloaderInit71a72e019c6be19dac67146f3f5fb8de', 'loadClassLoader')); // `PSR-4`规范 require __DIR__ . '/autoload_static.php'; // 省略代码 } } ``` 我们看到装载函数`loadClassLoader()`里面有个`if`判断,为什么要那样写?而不是这样: ```php public static function loadClassLoader($class) { require __DIR__ . $class; } ``` 这是因为`spl_autoload_register`可以注册多个装载函数,然后在调用的时候这些函数都会被遍历。但是这里好像也可以不用`if`,因为在`new ClassLoader`之后,框架已经使用`spl_autoload_unregister`注销掉了`loadClassLoader`,框架加上`if`判断,大概是为了以防万一吧。 `spl_autoload_register `基本用法已经介绍完,这个方法在后面有大用,如果你想更加详细的了解这个方法,那么可以去官方文档看看:https://www.php.net/manual/zh/function.spl-autoload-register ## 自动加载流程分析 前面我们讲了本章两个很重要的知识点`PSR-4`规范和`spl_autoload_register `函数,前者规定了需要被自动加载的类存放的目录,后者是注册一个装载函数,用于执行类的自动加载,接下来我们分析一下类自动加载的整个流程。 我们还是打开`getLoader()`方法,看看其完整代码 ```php public static function getLoader() { if (null !== self::$loader) { return self::$loader; } require __DIR__ . '/platform_check.php'; // 实例化一个‘类’的管理类 spl_autoload_register(array('ComposerAutoloaderInit71a72e019c6be19dac67146f3f5fb8de', 'loadClassLoader'), true, true); self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__)); spl_autoload_unregister(array('ComposerAutoloaderInit71a72e019c6be19dac67146f3f5fb8de', 'loadClassLoader')); // PSR-4规范 require __DIR__ . '/autoload_static.php'; // 这里也要重点分析一下 // 首先这里是初始化$loader,我们可以看到ClassLoader类里面有$prefixLengthsPsr4、prefixDirsPsr4 // $classMap成员变量 // 这里调用getInitializer方法的目的就是把PSR-4规范里面对应的变量赋值给ClassLoader里面这几个变量 // 因为getInitializer方法返回的是一个Closure对象,因此这里使用了call_user_func函数去执行这个 // Closure对象 // call_user_func具体用法:https://www.php.net/manual/zh/function.call-user-func call_user_func(\Composer\Autoload\ComposerStaticInit71a72e019c6be19dac67146f3f5fb8de::getInitializer($loader)); // 自动加载的核心 $loader->register(true); // 获取使用composer加载进来的类,大家可以进入这个类看看,这是定义`PSR-4`规范的那个类 $filesToLoad = \Composer\Autoload\ComposerStaticInit71a72e019c6be19dac67146f3f5fb8de::$files; // 这里定义了一个Closure对象,你可以理解为一个匿名函数,它的作用就是引入类 // Closure::bind的用法大家可以看官方文档: // https://www.php.net/manual/zh/closure.bind,看不明白的可以留言 $requireFile = \Closure::bind(static function ($fileIdentifier, $file) { if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) { $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true; require $file; } }, null, null); // 循环调用Closure对象 foreach ($filesToLoad as $fileIdentifier => $file) { $requireFile($fileIdentifier, $file); } return $loader; } ``` 接下来我们进入核心方法`register()` ```php public function register($prepend = false) { // 类的自动加载注册函数,一切逻辑都在loadClass这个函数里面 spl_autoload_register(array($this, 'loadClass'), true, $prepend); // 后面代码省略不讲 } ``` 我们进入`loadClass()`函数看看 ```php // 这个函数的主要逻辑是:需要被加载的类文件是否存在于“自动加载”规范中指定的目录下,如果是就直接引入 public function loadClass($class) { // 判断“被引入的类”文件是否存在 if ($file = $this->findFile($class)) { // self::$includeFile当前类的成员变量,它是一个Closure对象,在初始化构造函数__construct // 里面被定义了,其中有个这样的方法self::initializeIncludeClosure(); $includeFile = self::$includeFile; // 这里其实就是include文件 $includeFile($file); return true; } return null; } ``` 接下来我们看看`$this->findFile($class)` ```php public function findFile($class) { // 判断当前类的成员变量classMap是否存储了“被引入类”的路径,这个变量的初始化内容其实就 // 是 autoload_static.php的$classMap ,这就是我们前面说的,这个变量里面定义的目录,它里面的类 // 都会被自动加载 if (isset($this->classMap[$class])) { return $this->classMap[$class]; } // 判断“被引入类”是否存在,不存在直接返回false if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { return false; } // 这段代码其实就是从缓存中获取类的路径,目的就是提高框架的初始化速度,因为框架每次运行都要引入几十个类。 if (null !== $this->apcuPrefix) { // 获取缓存内容,apcu_fetch函数大家可以看官方文档 // https://www.php.net/manual/zh/function.apcu-fetch $file = apcu_fetch($this->apcuPrefix.$class, $hit); if ($hit) { return $file; } } // 这个函数的核心代码 $file = $this->findFileWithExtension($class, '.php'); // 这段代码是跟黑客相关的,防止黑客入侵一些hh类型文件 if (false === $file && defined('HHVM_VERSION')) { $file = $this->findFileWithExtension($class, '.hh'); } // 这里就是把加载类路径缓存起来 if (null !== $this->apcuPrefix) { // apcu_add跟apcu_fetch一样,去看看官方文档 apcu_add($this->apcuPrefix.$class, $file); } if (false === $file) { // 如果这个文件不存在,就存一个标识,下次就直接返回false即可 $this->missingClasses[$class] = true; } return $file; } ``` 下面我们来看看这个函数中最核心的一行代码 ```php $file = $this->findFileWithExtension($class, '.php'); ``` 进入`findFileWithExtension` ```php // 我们以new App()作为一个例子来讲,此时传进来$class是think\App private function findFileWithExtension($class, $ext) { // $logicalPathPsr4 = think\App.php $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; // 获取第一个字符"t",为什么? $first = $class[0]; // 判断prefixLengthsPsr4这个数组中是否存在“t”这个元素,这里的prefixLengthsPsr4就是我们前面提到 // psr4协议规范的内容,你可以打开autoload_static.php看看,很显然是存在的 /* 't' => array ( 'think\\trace\\' => 12, 'think\\' => 6, ), */ if (isset($this->prefixLengthsPsr4[$first])) { $subPath = $class; while (false !== $lastPos = strrpos($subPath, '\\')) { $subPath = substr($subPath, 0, $lastPos); // 这里的目的就是得到think\\这样的一个命名空间 $search = $subPath . '\\'; // 那接下来就是找该命名空间下面的目录 /* 'think\\' => array ( 0 => __DIR__ . '/..' . '/topthink/framework/src/think', 1 => __DIR__ . '/..' . '/topthink/think-filesystem/src', 2 => __DIR__ . '/..' . '/topthink/think-helper/src', 3 => __DIR__ . '/..' . '/topthink/think-orm/src', ), */ if (isset($this->prefixDirsPsr4[$search])) { $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); foreach ($this->prefixDirsPsr4[$search] as $dir) { // 遍历这四个目录,看看是否可以找到think\Exception.php if (file_exists($file = $dir . $pathEnd)) { // 最后返回F:\phpstudy_pro\WWW\thinkphp8\vendor // \composer/../topthink/framework/src/think\App.php return $file; } } } } } // PSR-4 fallback dirs foreach ($this->fallbackDirsPsr4 as $dir) { if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { return $file; } } // 后面这部分代码是涉及到PSR-0,这里就不讲了,框架好像也并没有使用这种协议,但好像有个比较特别的地方 // PSR-0 fallback dirs // 我们在autoload_static.php中看到$fallbackDirsPsr0这样一个变量而不是$fallbackDirsPsr4, // 这样很让人费解,我也不知道是什么原因 // 这段代码其实就是定义了类的扩展目录,也就是说你自己的类放在extend这个目录里面会被框架自动加载 foreach ($this->fallbackDirsPsr0 as $dir) { if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { return $file; } } return false; } ``` 到这里本章的内容就讲完了。 ## 总结 本章内容首先是介绍了`Thinkphp`框架类的自动加载遵循的规范`PSR-4`,它规定了类文件所在的目录,接着我们又介绍了一个非常重要的函数`spl_autoload_register `,它是实现类的自动加载的核心,最后我们对加载过程的源码也进行了阅读分析。 本章中使用了许多我们很少接触到的`php`内置函数或内部类,除`spl_autoload_register `外,还有一个很重要内部类`Closure`,其中使用最多的就是`bind`方法,`Closure::bind`,后续的章节也会用到,所以大家一定要掌握这个知识点。 # 利用反射机制实例化类 这一章的内容相对来说比较复杂,对于初学者,甚至一些有经验的人来说,如果你不了解魔术方法、反射机制,依赖注入,可能就无法看懂框架的这部分代码。 在框架中有大量的类利用了反射机制来实例化成一个对象,例如我们入口文件有这样的一行代码 ```php // 执行HTTP应用并响应 $http = (new App())->http; ``` 实例化`App`之后,就获取web管理应用的一个实例`http`。按照我们常规的理解,在`App`或其父类`Container`里面应该会定义一个这样的成员变量,然后在初始化的时候实例化`Http`,比如下面代码 ```php private $http; public function __construct(string $rootPath = ''){ $this->http = new Http(); } ``` 但事实上我们会发现在`App`和其父类`Container`里面根本都找不到`http`这样的一个属性,那么这里是如何实现的呢? ## 魔术方法__get() 相信大家当初学习`php`的时候应该学习或了解过魔术方法,只不过后来在工作中很少接触到,可能就忘记了了,下面我带大家回顾一下`php`内置了哪些魔术方法: [__construct()](https://www.php.net/manual/zh/language.oop5.decon.php#object.construct)、[__destruct()](https://www.php.net/manual/zh/language.oop5.decon.php#object.destruct)、[__call()](https://www.php.net/manual/zh/language.oop5.overloading.php#object.call)、[__callStatic()](https://www.php.net/manual/zh/language.oop5.overloading.php#object.callstatic)、[__get()](https://www.php.net/manual/zh/language.oop5.overloading.php#object.get)、[__set()](https://www.php.net/manual/zh/language.oop5.overloading.php#object.set)、[__isset()](https://www.php.net/manual/zh/language.oop5.overloading.php#object.isset)、[__unset()](https://www.php.net/manual/zh/language.oop5.overloading.php#object.unset)、[__sleep()](https://www.php.net/manual/zh/language.oop5.magic.php#object.sleep)、[__wakeup()](https://www.php.net/manual/zh/language.oop5.magic.php#object.wakeup)、[__serialize()](https://www.php.net/manual/zh/language.oop5.magic.php#object.serialize)、[__unserialize()](https://www.php.net/manual/zh/language.oop5.magic.php#object.unserialize)、[__toString()](https://www.php.net/manual/zh/language.oop5.magic.php#object.tostring)、[__invoke()](https://www.php.net/manual/zh/language.oop5.magic.php#object.invoke)、[__set_state()](https://www.php.net/manual/zh/language.oop5.magic.php#object.set-state)、[__clone()](https://www.php.net/manual/zh/language.oop5.cloning.php#object.clone)、[__debugInfo()](https://www.php.net/manual/zh/language.oop5.magic.php#object.debuginfo)。 可以看到我们经常接触到的初始化构造方法`__construct()`也是魔术方法之一。这些方法有个特点,它不需要被调用,而是在满足一定条件后会自动调用。 上面的方法中有个[__get()](https://www.php.net/manual/zh/language.oop5.overloading.php#object.get)方法,它被调用的条件是: - 访问类中的私有属性 - 访问类中不存在的属性 那我们看看`App`和其父类`Container`是否有这样的一个方法,不出意外,在`Container`中存在这样的一个方法 ```php public function __get($name) { return $this->get($name); } ``` 当我们访问`App`中的`http`属性时,会自动调用这个方法,这个方法里面的实现逻辑实在当前类的`get()`方法中。 ```php public function get(string $abstract) { if ($this->has($abstract)) { // 实例化Http类 return $this->make($abstract); } throw new ClassNotFoundException('class not exists: ' . $abstract, $abstract); } ``` 魔术方法[__get()](https://www.php.net/manual/zh/language.oop5.overloading.php#object.get)很重要,框架中大量使用了该方法,掌握了它对于后面框架的阅读是很有帮助的。 ## 类的反射机制 `php`内置了一个反射类`ReflectionClass`,里面有许多成员方法,大家可以去官网看看: https://www.php.net/manual/zh/class.reflectionclass.php 这里我讲一下本章用到的两个方法: **1.getConstructor** 获取类的构造函数,比如说我们反射的类是`Http`,其实就是获取`Http`的构造函数 **2.newInstanceArgs** 创建一个类的新实例,给出的参数将传递到类的构造函数。 简单了解了这个知识点后,我们继续我们的流程分析,前面我们提到`get()`里面调用了`has()`方法,我们进入看看 ```php public function bound(string $abstract): bool { return isset($this->bind[$abstract]) || isset($this->instances[$abstract]); } public function has(string $name): bool { return $this->bound($name); } ``` 进入`has()`方法,我们发现最终调用的是`bound()`方法。这个方法就是判断传入进来的这个标识(`http`)是否存在于`$bind`属性中,或者是否已经被实例化存储到`$instances`中。 我们看看`App`类中的$bind的属性 ```php /** * 容器绑定标识 * @var array */ protected $bind = [ 'app' => App::class, 'cache' => Cache::class, 'config' => Config::class, 'console' => Console::class, 'cookie' => Cookie::class, 'db' => Db::class, 'env' => Env::class, 'event' => Event::class, 'http' => Http::class, 'lang' => Lang::class, 'log' => Log::class, 'middleware' => Middleware::class, 'request' => Request::class, 'response' => Response::class, 'route' => Route::class, 'session' => Session::class, 'validate' => Validate::class, 'view' => View::class, 'think\DbManager' => Db::class, 'think\LogManager' => Log::class, 'think\CacheManager' => Cache::class, // 接口依赖注入 'Psr\Log\LoggerInterface' => Log::class, ]; ``` 很显然,存在`http`这样的一个标识 因此当`has()`方法返回true之后,那就调用`make()`方法 ```php // 分析这个函数时,我们以http为参数,作为一个例子讲解 public function make(string $abstract, array $vars = [], bool $newInstance = false) { // 根据标识获取真实类名,在new app的时候已经详细讲过这个函数,这里将会获取到think\Http $abstract = $this->getAlias($abstract); if (isset($this->instances[$abstract]) && !$newInstance) { // 如果容器中存在http的实例,那么将直接返回该实例 return $this->instances[$abstract]; } if (isset($this->bind[$abstract]) && $this->bind[$abstract] instanceof Closure) { // 如果绑定的标识存在、并且是一个Closure对象 $object = $this->invokeFunction($this->bind[$abstract], $vars); } else { // 调用反射执行类的实例化 $object = $this->invokeClass($abstract, $vars); } if (!$newInstance) { // 把这个http实例绑定到容器中 $this->instances[$abstract] = $object; } // 最终返回一个http实例 return $object; } ``` 下面重点讲一下这个利用反射机制实例化类的方法`invokeClass()`,这个方法很重要,一定要搞懂 ```php // 还是以上面的http为例,这里的class传入的是think\Http public function invokeClass(string $class, array $vars = []) { try { // 实例化一个反射类 // 返回的是这样的一个对象,你可以理解为它就是代表了Http这个类 // object(ReflectionClass)#4 (1) { // ["name"]=> // string(10) "think\Http" // } $reflect = new ReflectionClass($class); } catch (ReflectionException $e) { throw new ClassNotFoundException('class not exists: ' . $class, $class, $e); } if ($reflect->hasMethod('__make')) { // 判断Http中是否存在__make方法,你打开这个类看看,很显然不存在,因此不走这个逻辑 $method = $reflect->getMethod('__make'); if ($method->isPublic() && $method->isStatic()) { $args = $this->bindParams($method, $vars); $object = $method->invokeArgs(null, $args); $this->invokeAfter($class, $object); return $object; } } // 获取Http类中的构造函数 // 打开Http.php // 构造函数的参数是App,其实这里就我们所说的依赖注入,这个词看上去高大上,其实它就是把一个类当 // 作参数在被另一个类调用而已 // public function __construct(protected App $app) // { // $this->routePath = $this->app->getRootPath() . 'route' . DIRECTORY_SEPARATOR; // } $constructor = $reflect->getConstructor(); // 获取构造函数的的参数,而它的参数其实就是当前应用的实例app,这里的bindParams函数,大家自行阅读 $args = $constructor ? $this->bindParams($constructor, $vars) : []; // 实例化Http,其实就是new Http($app) // 同时这里在执行new的时候已经触发了自动装载函数,动态的引入了Http类,如果大家还不理解,可以返回去看看 // 类的自动加载那一节 $object = $reflect->newInstanceArgs($args); // 执行invokeClass回调,Http实例化并没有走这个函数的逻辑,这里的话先不讲 $this->invokeAfter($class, $object); return $object; } ``` 到这里,一个类的实例化就完成了。 ## 总结 本章主要讲了实例化类的另一总途径:利用反射的原理。其实只要大家掌握了魔术方法`__get()`和`reflectionclass`用法,那么本章内容就很好理解了,结合类的自动加载,它给框架带来了非常大的好处。比如说你想使用下面的某些类 ```php protected $bind = [ 'app' => App::class, 'cache' => Cache::class, 'config' => Config::class, 'console' => Console::class, 'cookie' => Cookie::class, 'db' => Db::class, 'env' => Env::class, 'event' => Event::class, 'http' => Http::class, 'lang' => Lang::class, 'log' => Log::class, 'middleware' => Middleware::class, 'request' => Request::class, 'response' => Response::class, 'route' => Route::class, 'session' => Session::class, 'validate' => Validate::class, 'view' => View::class, 'think\DbManager' => Db::class, 'think\LogManager' => Log::class, 'think\CacheManager' => Cache::class, // 接口依赖注入 'Psr\Log\LoggerInterface' => Log::class, ]; ``` 你不需要去手动引入它们、不需要去一个个new,你只需要这样写 ```php (new App)->config // 其实后面我们会大量看到这样的形式$this->config,其实$this指得就是app (new App)->cache // 这样写就非常的方便 ``` 当前这里得前提条件是这些类中必须注入`App`,你可以随便打开几个类,例如`cache`、`config`,你看它们的构造函数`__construct`或者是`__make`函数,都要`App`注入其中。 # 门面 ## 什么是门面 门面到底是个啥?刚接触那会还以为是个什么高大上的东西,后来发现它只是为动态类提供一个静态调用的接口,例如 ```php namespace app\common; class Test { public function hello($name) { return 'hello,' . $name; } } ``` 这样的一个类,正常来说我们是这么调用: ```php $test = new \app\common\Test; echo $test->hello('thinkphp'); ``` 后来发现每次调用方法的时候都要去`new`一个类,这样显得很麻烦,所以就引入了一个静态代理类,这也就是所谓的**门面**。 下面就是这个静态代理类 `app\facade\Test`(这个类名不一定要和`Test`类一致,但通常为了便于管理,建议保持名称统一)。 ```php make($class, $args, $newInstance); } ``` 这段代码的逻辑也很多简单,就是根据Facade对应类名或已经绑定的容器对象标识来实例化相应的类。现在知道了为什么要有`getFacadeClass()`这样的一个方法了吧,主要是用来区分**门面**,不同**门面**代理了不同类。 至于`make()`方法,前面《利用反射机制实例化类》已经重点讲过了。 ## 总结 说实话,**门面**确实非常有用,让我们使用起来相当方便,下面我用一张图来描述一下门面的过程 ![](img/05.jpg) # 一个接口的请求流程 比如说我们访问这样的一个接口:`tp8-dev.com/index/test`,相信很多人都不知道这个`Http`请求的整个过程,在平时做项目大家也不会关心这个,但如果你想进一步提升自己,快速定位一些错误,那么这个请求流程还是有必要学习一下的。 本章带大家认识一下这个流程,让大家知道框架执行的每个步骤,也方便我们阅读后续一些章节的源码。 > 本章主要是描述`Http`请求的过程,并不会深入去讲解每个过程,因为这是我们后续章节需要讲的内容 ## 实例化Http 我们从入口文件开始看 ```php // 步骤1:类的自动加载 require __DIR__ . '/../vendor/autoload.php'; // 步骤2:实例化一个应用及Http请求类 $http = (new App())->http; $response = $http->run(); $response->send(); $http->end($response); ``` **步骤1**和**步骤2**这两个过程在前面已经单独的详细讲解过,相信大家已经看过 ## 初始化 这部分的内容很重要,我们进入`run()`里面的`initialize()`方法 ```php public function initialize() { $this->initialized = true; $this->beginTime = microtime(true); $this->beginMem = memory_get_usage(); // 步骤3:加载env配置 $this->loadEnv($this->envName); $this->configExt = $this->env->get('config_ext', '.php'); $this->debugModeInit(); // 步骤4:加载全局初始化文件 $this->load(); // 步骤5:加载应用默认语言包 $this->loadLangPack(); // 监听AppInit $this->event->trigger(AppInit::class); date_default_timezone_set($this->config->get('app.default_timezone', 'Asia/Shanghai')); // 步骤6:实例化初始化器并自行初始化方法 foreach ($this->initializers as $initializer) { $this->make($initializer)->init($this); } return $this; } ``` 我们详细看看**步骤4**,看看其加载了哪些文件 ```php protected function load(): void { $appPath = $this->getAppPath(); // 公共文件,位于app目录下,一般我们把一些自定义函数写在这个文件里面 if (is_file($appPath . 'common.php')) { include_once $appPath . 'common.php'; } // 系统助手函数,如果你不知道框架自带了哪些函数,你就可以找这个文件了 include_once $this->thinkPath . 'helper.php'; $configPath = $this->getConfigPath(); $files = []; if (is_dir($configPath)) { $files = glob($configPath . '*' . $this->configExt); } // 加载config目下的所有配置 foreach ($files as $file) { $this->config->load($file, pathinfo($file, PATHINFO_FILENAME)); } // 如果app目录下存在事件定义文件,那么就加载此文件 if (is_file($appPath . 'event.php')) { $this->loadEvent(include $appPath . 'event.php'); } // 如果app目录下存在系统服务定义文件,那么就加载此文件 if (is_file($appPath . 'service.php')) { $services = include $appPath . 'service.php'; foreach ($services as $service) { $this->register($service); } } } ``` 看到这里不知道有没有解开你多年的疑问,比如说我们常用的`common.php`文件,`config`目录下的配置,原来是在这一步加载进来的。 接下来还有个**步骤6**,这也是框架非常重要的一个环节,一些composer扩展服务都是通过这段代码接入的,我们看看有哪些初始化器 ```php protected $initializers = [ Error::class, // 下面两个比较重要,一个是注册服务,一个是启动服务 RegisterService::class, BootService::class, ]; ``` 这些初始化器的用法,后面的章节会详细讲到,大家阅读到这里的时候一定要留意这里。 ## 执行应用程序 初始化完成后,接下来就是执行应用程序 ```php protected function runWithRequest(Request $request) { // 步骤7:加载全局中间件,中间件相信大家不陌生 $this->loadMiddleware(); // 监听HttpRun $this->app->event->trigger(HttpRun::class); return $this->app->middleware->pipeline() ->send($request) ->then(function ($request) { return $this->dispatchToRoute($request); }); } ``` 我们进入`dispatchToRoute()`方法里面的`dispatch()` ```php public function dispatch(Request $request, Closure|bool $withRoute = true) { $this->request = $request; $this->host = $this->request->host(true); // 步骤8: 路由检测、解析 if ($withRoute) { //加载路由 if ($withRoute instanceof Closure) { $withRoute(); } $dispatch = $this->check(); } else { $dispatch = $this->url($this->path()); } $dispatch->init($this->app); // 步骤9:实例化控制器并执行接口方法 return $this->app->middleware->pipeline('route') ->send($request) ->then(function () use ($dispatch) { return $dispatch->run(); }); } ``` 最后回到入口文件 ```php // 步骤10:向客户端发送响应数据及记录日志 $response->send(); $http->end($response); ``` 整个`Http`请求流程就到这里为止,我们可以看到整个流程还是挺长的,所以框架也是不断的迭代、优化以达到最快的响应速度。 ## 总结 这一章内容并不是很难,大家只要记住这个流程,主要时方便后面在深入阅读源码时,快速定位到相应的地方,下面我总结一下步骤: 1. 类的自动加载 2. 实例化一个应用及Http请求类 3. 加载env配置 4. 加载全局初始化文件 5. 加载应用默认语言包 6. 实例化初始化器并执行init方法 7. 加载全局中间件 8. 路由检测、解析 9. 实例化控制器并执行接口方法 10. 向客户端发送响应数据及记录日志 # 服务 系统服务这个概念对于许多人来说很抽象,平时我们基本上都用不到,但是在开发`composer`扩展插件却大有用处,比如说多应用模式、还有我们比较熟悉的图像验证码插件等,本章我们来学习一下框架中**服务**的用法及源码。 ## 服务类的定义与注册 **定义服务类** 你可以通过命令行生成一个服务类,例如: ``` php think make:service AppService ``` 默认生成的服务类会继承系统的`think\Service`,并且自动生成了系统服务类最常用的两个空方法:`register`和`boot`方法。例如下面的一个例子 ```php /** * 应用服务类 */ class AppService extends Service { public function register() { // 服务注册 echo "服务注册
"; } public function boot() { // 服务启动 echo "服务启动
"; } } ``` `register`方法通常用于注册系统服务,也就是将服务绑定到容器中,例如 ```php public function register() { $this->app->bind('AppService', AppService::class); } ``` `boot`方法是在所有的系统服务注册完成之后调用,用于定义启动某个系统服务之前需要做的操作,这句话看起来很抽象,我们先有个印象,后面讲到源码你可能会明白。 > 将服务绑定到容器中这并不是必须的,得看实际开发需求,很多时候我们会发现服务类里面只有boot()方法 **服务注册到应用中** 在`app`目录下有这样的一个文件:`service.php`,这是框架自动生成的服务文件。 ```php use app\AppService; // 系统服务定义文件 // 服务在完成全局初始化之后执行 return [ AppService::class, ]; ``` 引入了当前应用类服务`AppService.php`,这样才能执行`register()`和`boot()`这两个方法,比如说我们访问这样的一个接口 ```php class Index extends BaseController { public function test(){ echo "首页"; } } ``` 最后会输出 ``` 服务注册 服务启动 首页 ``` 从输出结果看,在执行接口前就执行力相关的`register()`和`boot()` 那接下来我们的问题就是:**它是如何调用`register()`和`boot()`这个两个方法的**,这也是本章的重点。 ## 框架是如何启动服务 想要调用`register()`和`boot()`,那框架必须实例化,而要实例化`AppService::class`,那必须引入`service.php`,那框架在哪里引入了此文件呢? 如果你阅读了《一个接口的请求流程》,你就能轻而易举的定位到源码所在的地方,那就是在**加载全局初始化文件**这一步里面 ```php protected function load(): void { // 省略代码 if (is_file($appPath . 'service.php')) { // 此次引入了service.php $services = include $appPath . 'service.php'; foreach ($services as $service) { $this->register($service); } } } public function register(Service|string $service, bool $force = false) { $registered = $this->getService($service); if ($registered && !$force) { return $registered; } if (is_string($service)) { // 实例化 $service = new $service($this); } if (method_exists($service, 'register')) { // 调用了register(),目的就是把服务绑定到容器中 $service->register(); } // 服务绑定到容器中,跟上面的目是一样的,只不过实现的方式不一样 if (property_exists($service, 'bind')) { $this->bind($service->bind); } // 最后把实例化的服务存储到一个全局变量中 $this->services[] = $service; } ``` 那`boot()`,也就是启动服务这个方法是在哪执行的? 我们回到`initialize()`方法,我们看看初始化器这部分代码 ```php // 初始化 // protected $initializers = [ // Error::class, // RegisterService::class, // BootService::class, // ]; foreach ($this->initializers as $initializer) { // 实例化初始化器,并调用里面的init方法 $this->make($initializer)->init($this); } ``` 这里我们看`BootService::class`这个初始化器, ```php public function init(App $app) { $app->boot(); } ``` 进入`boot()`方法 ```php public function boot(): void { array_walk($this->services, function ($service) { // 遍历时,那肯定会遍历到我们例子中的AppService::class $this->bootService($service); }); } ``` `array_walk`是`php`的内置函数,它的作用是:**使用用户自定义函数对数组中的每个元素做回调处理** ,如果你不理解我变换一下写法 ```php foreach($this->services as $service) { $this->bootService($service); } ``` 这个函数的具体用法大家可以看官网:https://www.php.net/array_walk 最后我们进入`bootService()`方法 ```php public function bootService(Service $service) { // 判断是否存在boot方法 if (method_exists($service, 'boot')) { // 调用反射方法,执行方法。 invoke方法后面有专门的章节去详细解读这个函数 return $this->invoke([$service, 'boot']); } } ``` 到这里服务是如何启动已经讲完了~ ## 案例:图像验证码 经过前面的阅读,相信大家对**服务**掌握的七七八八了,下面举个我们熟悉的例子,我们经常用到的图像验证码。 ```shell composer require topthink/think-captcha ``` 然后我们直接访问链接:`tp8-dev.com/captcha`,就会输出图像验证码 > 注意:此例子只适用于单应用模块,另外需要把`AppService.php`里面的`echo`注释掉,否则会出现异常 接下来我们看看框架是如何注册验证码这样一个服务。前面我们讲到的初始化器 ```php // 初始化 // protected $initializers = [ // Error::class, // RegisterService::class, // BootService::class, // ]; foreach ($this->initializers as $initializer) { // 实例化初始化器,并调用里面的init方法 $this->make($initializer)->init($this); } ``` 这会我们看看`RegisterService::class`这个初始化器 ```php class RegisterService { // 内置了三个服务 protected $services = [ PaginatorService::class, ValidateService::class, ModelService::class, ]; // 初始化方法,这个方法其实就是注册服务,最后保存到app类的成员变量$services中 public function init(App $app) { // 获取vendor目录下的服务文件 $file = $app->getRootPath() . 'vendor/services.php'; $services = $this->services; if (is_file($file)) { // 然后就是将其和内置服务合并 $services = array_merge($services, include $file); } foreach ($services as $service) { // 最后就是注册服务 if (class_exists($service)) { $app->register($service); } } } } ``` 我们看看`vendor/services.php`, ```php return array ( // 验证码服务类,当使用composer安装验证码的时候,这个文件会生成这样的一个内容 0 => 'think\\captcha\\CaptchaService', 1 => 'think\\trace\\Service', ); ``` 到这里我们大概清楚了,注册服务有两个地方:一个在`load()`方法里,一个在`RegisterService`服务类里面的`init()`方法。 我们看看`CaptchaService.php`的内容 ```php class CaptchaService extends Service { public function boot() { // 验证参数 Validate::maker(function ($validate) { $validate->extend('captcha', function ($value) { return captcha_check($value); }, ':attribute错误!'); }); // 注册一个验证码路由 $this->registerRoutes(function (Route $route) { $route->get('captcha/[:config]', "\\think\\captcha\\CaptchaController@index"); }); } } ``` 这个服务启动方法里面就注册了路由,其实不单单是验证码是这样,多应用模式的接入也是如此。 ## 总结 看到这里相信大家已经掌握了**服务**的知识,我们再来回顾一下,首先我们需要定义一个服务类,一般来说会有两个方法 `register`和`boot` ,其中`register`是把服务绑定到容器中,`boot` 是系统服务注册完成之后调用,用于定义启动某个系统服务之前需要做的操作,另外我们需要把服务注册到应用中,一般在应用的全局公共文件`service.php`中定义需要注册的系统服务。 # 事件 相对于**服务**,我感觉**事件**在我们实际开发中更加容易用到,不熟悉的它的人觉得很难,但实际上它是很简单的。本章会教大家如何使用事件,并且我们会深入源码去了解事件的机制,下面我们通过一个简单的例子来说明一下事件的使用,然后再深入源码去了解一下事件的运行机制。 ## 事件的定义、发布与监听 **定义一个事件** 比如说你有这样的一个需求:用户每天登录需要赠送积分 此时我们定义登录事件,建议使用命令行快速生成 ```shell php think make:event UserLogin ``` 最后生成这样的一个文件 ```php namespace app\event; class UserLogin { } ``` 你可以添加一个方法,用于参数的传递 ```php class UserLogin { public $user; public function __construct(User $user) { $this->user = $user; } } ``` 事件的定义就是这么简单。 **发布一个事件** 发布一个事件,你在什么时候发布这个事件呢?比如说上面的用户登录,那肯定是在用户登录后发布一个登录事件了,其实发布事件的代码很多简单,你可以使用以下方式 ```php // 触发UserLogin事件 用于执行用户登录后的一系列操作 Event::trigger('UserLogin'); event('UserLogin'); // 直接使用事件的真实名字 event('app\event\UserLogin'); ``` 这里`UserLogin`表示一个事件标识 ,如果你要使用标识,那么必须加上以下配置,打开`app`目录下的`event.php`配置文件 ```php [ 'UserLogin' => 'app\event\UserLogin', //可以理解为给这个事件定义一个标识(别名) ], // 省略 ]; ``` **监听事件** 事件发布后,需要被监听,这样你才能知道后续要干什么,比如说前面发布了一个登录事件,也就是说用户已经执行了登录的逻辑,接下来就是要给用户赠送积分了,因此这一部分的逻辑就在监听类里面开发,这样就实现了核心代码与非核心代码的隔离,同时也方便后续扩展,比如后续需要发送短信给用户、记录登录日志等等。 同样的我们可以使用命令行快速生成一个监听类 ```shell php think make:listener UserLogin ``` 最后生成以下文件 ```php namespace app\listener; class UserLogin { // $user是参数,它也可以是没有参数,主要是看你定义事件的时候是否有定义参数 public function handle($user) { // 业务逻辑 } } ``` 接下来我们还需完成最后一步,需要注册这个监听类,这样这个监听才能生效,同样的,打开`app`目录下的`event.php`配置文件,注册监听器 ```php [ 'UserLogin' => 'app\event\UserLogin', ], 'listen' => [ // 前面这些都是框架内置的监听事件标识 'AppInit' => [], 'HttpRun' => [], 'HttpEnd' => [], 'LogLevel' => [], 'LogWrite' => [], 'UserLogin' => ['app\listener\UserLogin'], // 注册监听器 ], 'subscribe' => [ ], ]; ``` 最后我们可以在控制器中测试一下 ```php public function login(){ echo "登录成功后,触发UserLogin事件
"; Event::trigger('UserLogin'); } ``` 最后会输出 ``` 登录成功后,触发UserLogin事件 记录登录用户日志 ``` ## 监听类是如何监听发布的事件 上面我们讲了事件的用法,但我们可能比较好奇的是当一个发布一个事件时,为什么会执行监听类里面的`handle()`方法。 我们先看看发布事件的源码 ```php Event::trigger('UserLogin'); ``` 我们进入`trigger()`方法 ```php public function trigger($event, $params = null, bool $once = false) { // 判断是否是一个实例对象,例如传进来的是new \app\event\UserLogin() if (is_object($event)) { $params = $event; // 这里将解析为app\event\UserLogin $event = $event::class; } // app目录下的event.php配置的事件标识会存储到bind中 if (isset($this->bind[$event])) { // 如果bind存在标识别名,例如传进来UserLogin,最后得到的是app\event\UserLogin $event = $this->bind[$event]; } $result = []; // 获取该事件的所有的监听器,比如app\listener\UserLogin $listeners = $this->listener[$event] ?? []; // 这里是支持通配符的,例如监听所有模型事件 // Event::listen('model.*', 'app\listener\ModelListen'); if (str_contains($event, '.')) { [$prefix, $event] = explode('.', $event, 2); if (isset($this->listener[$prefix . '.*'])) { $listeners = array_merge($listeners, $this->listener[$prefix . '.*']); } } // 排序、去重 $listeners = array_unique($listeners, SORT_REGULAR); foreach ($listeners as $key => $listener) { // 执行调度方法 $result[$key] = $this->dispatch($listener, $params); if (false === $result[$key] || (!is_null($result[$key]) && $once)) { break; } } return $once ? end($result) : $result; } ``` 我们打开`dispatch()` ```php protected function dispatch($event, $params = null) { if (!is_string($event)) { $call = $event; } elseif (str_contains($event, '::')) { $call = $event; } else { // 传入进来的是app\listener\UserLogin,实例化对象 $obj = $this->app->make($event); $call = [$obj, 'handle']; } // 调用反射方法,之后执行handle方法 return $this->app->invoke($call, [$params]); } ``` 发布事件的逻辑大概就是这样:传入一个事件,例如`UserLogin`,然后通事件标识或真实名字获取监听该事件的监听器,找到监听器后通过反射的方式实例化它,并且调用`handle`方法。 我们回看代码,就是获取事件的监听器 ```php // 获取该事件的所有监听器,比如app\listener\UserLogin $listeners = $this->listener[$event] ?? []; ``` 我们的监听器是什么时候保存到`listener`变量中的呢? 其实我们前面也提到,就是在`event.php`中配置上我们的监听器,那这里的配置又是什么时候加载进来的呢? 如果你有看《一个接口的请求流程》这篇文章,你可能就会知道`app`目录下的`event.php`这个文件是什么时候被加载。 在应用初始化的时候,有个这样的一个方法 ```php /** * 加载应用文件和配置 * @access protected * @return void */ protected function load(): void { // 省略代码 if (is_file($appPath . 'event.php')) { // include event.php就是该文件返回的数组内容 $this->loadEvent(include $appPath . 'event.php'); } // 省略代码 } ``` 我们进入`loadEvent()`方案 ```php // 注册应用事件 public function loadEvent(array $event): void { if (isset($event['bind'])) { // 将我们配置的事件标识,存储到一个$this->bind变量中 $this->event->bind($event['bind']); } if (isset($event['listen'])) { // 批量注册事件监听 $this->event->listenEvents($event['listen']); } // 订阅这块大家自行阅读 if (isset($event['subscribe'])) { $this->event->subscribe($event['subscribe']); } } ``` 我们先看看`bind()` ```php public function bind(array $events) { // 实际上就是把定义的别名存储到这个bind中 $this->bind = array_merge($this->bind, $events); return $this; } ``` 我们再看看下面那个方法`listenEvents()` ```php public function listenEvents(array $events) { foreach ($events as $event => $listeners) { if (isset($this->bind[$event])) { // 判断这个事件是否定义了别名,如果定义了别名,那么从bind数组中取出真实事件名称 // 比如上面例子中的userLogin,最后取出来的$event = app\event\UserLogin $event = $this->bind[$event]; } // 注册监听器,实际上就是存储到listener这个全局数组中 $this->listener[$event] = array_merge($this->listener[$event] ?? [], $listeners); } return $this; } ``` `event.php`配置加载的源码也很简单,最主要的是我们要知道它为什么这么设计,如果你看到这,相信你对事件应该了解的差不多了。 ## 总结 本章内容主要是给大家讲了事件的定义、发布和监听注册,让大家能够快速掌握事件的用法。接下来给大家讲了事件的核心源码,主要弄懂了监听器是如何被加载进来,如何触发`handle`方法。 事件的本质是`php`设计模式中的一种"观察者模式",这种模式在底层系统架构中大量被使用,我们在平时开发时,也可以适当的用一下。 # 详解容器中的invoke方法 在`Container.php`容器管理类中有个这样的一个方法invoke,之所以拿出来讲,主要是因为框架中大量调用了这个方法,因此在这里拿出来讲一下,也方便后续源码的一个阅读。 看完本章你将掌握以下两个知识点: - **类里面的成员方法的反射** - **普通函数或匿名函数的反射** ## 利用反射执行类里面的方法 利用反射机制执行类里面的成员方法是框架中最常见的方式之一,比如前两篇文章《服务》和《事件》,里面就有使用到这种方式。 我们看看这两个代码片段: ```php // <<服务的注册与启动>> public function bootService(Service $service) { // 判断这些服务类里面有没有启动服务的方法boot,如果有就执行启动操作 if (method_exists($service, 'boot')) { return $this->invoke([$service, 'boot']); } } // <<事件的监听与发布>> protected function dispatch($event, $params = null) { // 省略代码 // 调用反射方法,之后执行handle方法 return $this->app->invoke($call, [$params]); } ``` 我们深入其源码看看: ```php /** * 调用反射执行callable 支持参数绑定 * @access public * @param mixed $callable * @param array $vars 参数 * @param bool $accessible 设置是否可访问 * @return mixed */ public function invoke($callable, array $vars = [], bool $accessible = false) { if ($callable instanceof Closure) { return $this->invokeFunction($callable, $vars); } elseif (is_string($callable) && !str_contains($callable, '::')) { return $this->invokeFunction($callable, $vars); } else { return $this->invokeMethod($callable, $vars, $accessible); } } ``` 由这个函数我们不难看成,如果传入进来的参数是一个`Closure`(匿名函数)或者是一个`不包含::的字符串`(普通函数),那么就调用`invokeFunction`,否则就调用`invokeMethod`。 我们先讲讲`invokeMethod`,我们举个例子: 在`app`目录下新建一个`User.php`类 ```php app->make('\app\User'); 也可以这么实例类 $callable = [$user, 'show']; $this->app->invoke($callable, ['李四']); } ``` 运行这个方法后,就会走到`invoke中的invokeMethod` ```php public function invokeMethod($method, array $vars = [], bool $accessible = false) { if (is_array($method)) { //我们传进来的参数肯定是个数组,$callable = [$user, 'show']; [$class, $method] = $method; // $class是经过实例化的对象,因此不会再调用invokeClass $class = is_object($class) ? $class : $this->invokeClass($class); } else { // 静态方法 [$class, $method] = explode('::', $method); } try { // 反射类,用于类里面的成员方法,官方文档 // https://www.php.net/manual/zh/class.reflectionmethod.php // 这里的reflect //ReflectionMethod Object //( // [name] => show // [class] => app\User //) $reflect = new ReflectionMethod($class, $method); } catch (ReflectionException $e) { $class = is_object($class) ? $class::class : $class; throw new FuncNotFoundException('method not exists: ' . $class . '::' . $method . '()', "{$class}::{$method}", $e); } // 获取参数 $args = $this->bindParams($reflect, $vars); if ($accessible) { $reflect->setAccessible($accessible); } // 这里实际就是执行show方法 return $reflect->invokeArgs(is_object($class) ? $class : null, $args); } ``` 看到这里相信大家已经知道,`ReflectionMethod`这个反射类是用于执行类里面的成员方法,更详细的用法大家可以看官方文档。 ## 利用反射执行普通函数和匿名函数 我们再写一个例子: ```php // app->controller目录下新建的Index.php public function test(){ // 这是一个匿名函数 $callable = \Closure::bind(function($name){ echo "你好!".$name; }, null, null); // 因为app继承Container容器类,因此这里可以通过app调用 $this->app->invoke($callable, ['张三']); } ``` 当我们运行这个方法的时候,页面会输出” 你好!张三 “这几个字 接下来我们看看`invokeFunction()`方法 ```php public function invokeFunction(string|Closure $function, array $vars = []) { try { // 反射类,用于普通函数,new这个类实际上就是调用其初始化方法__construct大家可以去 // 官网了解一下这个类:https://www.php.net/manual/zh/class.reflectionfunction.php $reflect = new ReflectionFunction($function); } catch (ReflectionException $e) { throw new FuncNotFoundException("function not exists: {$function}()", $function, $e); } // 获取函数的参数 $args = $this->bindParams($reflect, $vars); return $function(...$args); } ``` 我们进入`bindParams()` ```php protected function bindParams(ReflectionFunctionAbstract $reflect, array $vars = []): array { if ($reflect->getNumberOfParameters() == 0) { return []; } // 判断数组类型 数字数组时按顺序绑定参数 reset($vars); $type = key($vars) === 0 ? 1 : 0; // 通过反射对象,获取参数名,例如上述例子 //Array //( // [0] => ReflectionParameter Object // ( // [name] => name // ) //) $params = $reflect->getParameters(); $args = []; foreach ($params as $param) { $name = $param->getName();// 参数名,name $lowerName = Str::snake($name); // 驼峰转下划线 $reflectionType = $param->getType(); // 参数类型 if ($param->isVariadic()) { return array_merge($args, array_values($vars)); } elseif ($reflectionType && $reflectionType instanceof ReflectionNamedType && $reflectionType->isBuiltin() === false) { $args[] = $this->getObjectParam($reflectionType->getName(), $vars); } elseif (1 == $type && !empty($vars)) { // 最后会来到这个if下面 $args[] = array_shift($vars); } elseif (0 == $type && array_key_exists($name, $vars)) { $args[] = $vars[$name]; } elseif (0 == $type && array_key_exists($lowerName, $vars)) { $args[] = $vars[$lowerName]; } elseif ($param->isDefaultValueAvailable()) { $args[] = $param->getDefaultValue(); } else { throw new InvalidArgumentException('method param miss:' . $name); } } return $args; } ``` 这里我就不对每个if做讲解了,其余的大家可以自行阅读,最后调用 ```php //这里实际就是调用我们前面定义的Closure对象,而参数是 //Array //( // [0] => 张三 //) return $function(...$args); ``` 在`PHP`函数中,参数中的`...param`是可变长度参数的一种表示方式。它允许你在函数定义中接受任意数量的参数,这些参数会被作为一个数组传递给函数内部,例如你可以这也传 ```php $function("张三","李四"); // 你也可以传入一个数组 $function(["张三","李四"]); ``` ## 总结 `ReflectionFunction`用于反射执行普通函数或匿名函数 `ReflectionMethod`用于反射执行类里面的成员方法 大家一定要掌握这两个类,以后无论你阅读哪一款框架的源码,对你都会有很大帮助! # 路由的加载与解析 **路由**是框架中非常重要、也是非常复杂的一个知识点。 本章主要带你了解路由的运作流程,将涉及下面三个知识点: - **路由的加载** - **路由的检测** - **路由的解析** ## 路由的加载 我们先准备一个例子: 在`app`目录下的`controller`目新建一个`Index.php` ```php class Index extends BaseController { public function test($name){ echo "你好,".$name;die; } } ``` 然后找到`route`目录,打开`app.php` ```php Route::get('test/:name', 'index/test'); ``` 当我们访问http://tp8-dev.com/test/boys,最终输出” 你好,boys “ **接下来我们的问题是`route`目录下的`app.php`是如何被加载加载进来以及`get()`方法的逻辑是怎么样的?**弄懂了这个才算是弄懂了路由的加载机制。 要定位路由这一块的源码在哪里,那么你必须先阅读《一个接口的请求流程》这一章的内容,从这一章的内容我们很快就知道路由的调度是应用初始化后,我们直接定位到`Http.php`里面的`runWithRequest()`方法 ```php protected function runWithRequest(Request $request) { // 加载全局中间件 $this->loadMiddleware(); // 监听HttpRun $this->app->event->trigger(HttpRun::class); return $this->app->middleware->pipeline() ->send($request) ->then(function ($request) { // 执行路由调度 return $this->dispatchToRoute($request); }); } ``` 这个方法的返回值那一块代码阅读起来还是比较复杂的,后面我们会深入了解,本章我们只需了解`dispatchToRoute()`方法 ```php protected function dispatchToRoute($request) { // 是否开启路由,如果是就返回一个Closure对象,否则返回false $withRoute = $this->app->config->get('app.with_route', true) ? function () { $this->loadRoutes(); } : false; // 执行路由调度 return $this->app->route->dispatch($request, $withRoute); } ``` 我们进入`dispatch()`方法, ```php public function dispatch(Request $request, Closure|bool $withRoute = true) { // 获取请求对象 $this->request = $request; // 获取host,如上面的tp8-dev.com $this->host = $this->request->host(true); if ($withRoute) { // 因为我们开启并定义了路由,那肯定会走到这个逻辑里面 if ($withRoute instanceof Closure) { // 调用Closure对象(匿名函数),加载路由,这里重点讲一下 $withRoute(); } $dispatch = $this->check(); } else { $dispatch = $this->url($this->path()); } // 省略代码 } ``` 当程序执行`$withRoute()`,实际上就是执行的是下面这个内容 ```php // 是否开启路由,如果是就返回一个Closure对象,否则返回false $withRoute = $this->app->config->get('app.with_route', true) ? function () { // 加载路由 $this->loadRoutes(); } : false; ``` 我们进入`loadRoutes()`,这个方法就是引入`route`目录下的路由文件,例如`app.php` ```php protected function loadRoutes(): void { // 加载路由定义,获取到根目录下的route目录 $routePath = $this->getRoutePath(); if (is_dir($routePath)) { $files = glob($routePath . '*.php'); foreach ($files as $file) { // 这里引入route目录下的路由定义文件,如例子中的app.php include $file; } } // 发布一个路由加载完成的事件 $this->app->event->trigger(RouteLoaded::class); } ``` 到这里我们已经知道了路由文件`app.php`是这么被加载进来的。 当你引入这个文件的时候,实际上顺便就执行了这个路由 ```php Route::get('test/:name', 'index/test'); ``` 我们进入到`Route`管理类里面的`get`方法 ```php // 注册GET路由 public function get(string $rule, $route): RuleItem { return $this->rule($rule, $route, 'GET'); } ``` 进入`rule()`方法 ```php public function rule(string $rule, $route = null, string $method = '*'): RuleItem { return $this->group->addRule($rule, $route, $method); } ``` 继续深入`addRule()`方法 ```php /** * 添加分组下的路由规则 * @access public * @param string $rule 路由规则 test/:name * @param mixed $route 路由地址 index/test * @param string $method 请求类型 GET * @return RuleItem */ public function addRule(string $rule, $route = null, string $method = '*'): RuleItem { // 省略代码 // 创建路由规则实例,最后会得到一个RuleItem对象 // think\route\RuleItem Object ( [name] => index/test [rule] => test/ [route] => index/test [method] => get [vars] => Array ( ) [option] => Array ( ) [pattern] => Array ( ) ) $ruleItem = new RuleItem($this->router, $this, $name, $rule, $route, $method); // 注册分组下的路由规则,其实就是把这个实例存储到成员变量rules中 $this->addRuleItem($ruleItem); return $ruleItem; } ``` 大家记住,路由的加载实际上就是创建路由规则实例,并且保存到`RuleGroup.php`里面的一个成员变量`$rules`中,这个在后面路由解析中会被使用到。 ## 路由的检测 接下来我们看看路由的检测,我们回到`dispatch()`方法 ```php public function dispatch(Request $request, Closure|bool $withRoute = true) { // 省略代码 if ($withRoute) { // 因为我们开启并定义了路由,那肯定会走到这个逻辑里面 if ($withRoute instanceof Closure) { // 调用Closure对象,加载路由,这里重点讲一下 $withRoute(); } // 讲完路由加载,我们接着看路由的检测 $dispatch = $this->check(); } else { // 如果没有开启路由的情况下直接解析url $dispatch = $this->url($this->path()); } // 省略代码 } ``` 我们进入`check()`方法 ```php public function check() { // 把地址中的test/boys变成test|boys $url = str_replace($this->config['pathinfo_depr'], '|', $this->path()); // 是否完全匹配 $completeMatch = $this->config['route_complete_match']; // 这里分两个方法来看 // checkDomain() 检测域名的路由规则,这个就不深入讲解 // 我们看看check(),检测域名路由 $result = $this->checkDomain()->check($this->request, $url, $completeMatch); if (false === $result && !empty($this->cross)) { // 检测跨域路由 $result = $this->cross->check($this->request, $url, $completeMatch); } // 省略代码 } ``` 进入`$this->checkDomain()->check()`,里面的`check()`方法 ```php public function check(Request $request, string $url, bool $completeMatch = false) { // 省略代码 // 最后会来到这里,调用其父类的check return parent::check($request, $url, $completeMatch); } ``` 进入父类的`check()`方法,我们会发现其父类就是`RuleGroup`类,也是说我们上面提到过的,创建的路由实例就是保存在当前类的成员变量`$rules` ```php /** * 检测分组路由 * @access public * @param Request $request 请求对象 * @param string $url 访问地址 test|boys * @param bool $completeMatch 路由是否完全匹配 false * @return Dispatch|false */ public function check(Request $request, string $url, bool $completeMatch = false) { // 省略代码 // 获取当前路由规则 $method = strtolower($request->method()); // 重点就是这里,这里拿到我们之前保存的路由实例 /** Array ( [0] => think\route\RuleItem Object ( [name] => index/test [rule] => test/ [route] => index/test [method] => get 省略部分代码 ) ) **/ $rules = $this->getRules($method); $option = $this->getOption(); //省略代码 // 检查分组路由 foreach ($rules as $item) { // 循环路由实例,调用check方法,这个方法在think\route\RuleItem.php里面 $result = $item->check($request, $url, $completeMatch); if (false !== $result) { return $result; } } // 省略代码 return $result; } ``` 我们进入`think\route\RuleItem.php`中的`check()`方法 ```php public function check(Request $request, string $url, bool $completeMatch = false) { return $this->checkRule($request, $url, null, $completeMatch); } ``` 继续进入`checkRule()` ```php /** * 检测路由 * @access public * @param Request $request 请求对象 * @param string $url 访问地址 test|boys * @param array $match 匹配路由变量 [] * @param bool $completeMatch 路由是否完全匹配 false * @return Dispatch|false */ public function checkRule(Request $request, string $url, array $match = null, bool $completeMatch = false) { // 检查参数有效性,因为我们并没有设置路由参数,因此$this->option是个空数组,所以这里会跳过检查, // 你可以尝试给它设置一个参数,例如是否为json请求 // Route::get('test/:name', 'index/test')->json(); // 一旦设置了这个参数,那么$this->option值就是Array ( [json] => 1 ) // 如果你在浏览器中方式http://tp8-dev.com/test/boys,就会报错 // 这里面就不进入checkOption方法讲解,大家可以执行去阅读 if (!$this->checkOption($this->option, $request)) { return false; } // 合并分组参数 $option = $this->getOption(); $pattern = $this->getPattern(); $url = $this->urlSuffixCheck($request, $url, $option); if (is_null($match)) { // 检测URL和规则路由是否匹配, $match = $this->checkMatch($url, $option, $pattern, $completeMatch); } if (false !== $match) { // 解析路由 return $this->parseRule($request, $this->rule, $this->route, $url, $option, $match); } return false; } ``` 最后我们来到了路由检测的核心方法`checkMatch()`,这个方法无非是检测地址及参数是否定义正确, 比如系统默认的变量规则设置是`\w+`,只会匹配字母、数字、中文和下划线字符。这个方法如果有兴趣的话大家可以自己去阅读。 ## 路由的解析 再检测路由通过后,程序就来到了路由的解析 ```php if (false !== $match) { // 解析路由 return $this->parseRule($request, $this->rule, $this->route, $url, $option, $match); } ``` 我们看看路由解析的方法`parseRule()` ```php //解析匹配到的规则路由,我们看看传进这个方法的参数值 //$rule 路由规则 test/ //$route 路由地址 index/test //$url URL地址 test|boys //$option 路由参数 Array ( [remove_slash] => ) //$matches 匹配的变量 Array ( [name] => boys ) public function parseRule(Request $request, string $rule, $route, string $url, array $option = [], array $matches = []): Dispatch { // 我们并没设置前缀,因此不会进入此判断,从$option的值就知道,并没有prefix这个元素 if (is_string($route) && isset($option['prefix'])) { // 路由地址前缀, $route = $option['prefix'] . $route; } // 替换路由地址中的变量 $extraParams = true; $search = $replace = []; $depr = $this->config('pathinfo_depr'); foreach ($matches as $key => $value) { // 对参数进行处理,比如例子中 // Route::get('test/:name', 'index/test'); 参数格式:name // 其实也可以这样设置Route::get('test/', 'index/test'); $search[] = '<' . $key . '>'; $replace[] = $value; $search[] = ':' . $key; $replace[] = $value; if (str_contains($value, $depr)) { $extraParams = false; } } if (is_string($route)) { // 进行替换 $route = str_replace($search, $replace, $route); } // 解析额外参数 if ($extraParams) { $count = substr_count($rule, '/'); $url = array_slice(explode('|', $url), $count + 1); $this->parseUrlParams(implode('|', $url), $matches); } // 把Array ( [name] => boys )保存在成员变量vars中 $this->vars = $matches; // 发起路由调度 return $this->dispatch($request, $route, $option); } ``` 我们最后看看这个路由调度 ```php /** * 发起路由调度 * @access protected * @param Request $request Request对象 * @param mixed $route 路由地址 index/test * @param array $option 路由参数 Array ( [remove_slash] => ) * @return Dispatch */ protected function dispatch(Request $request, $route, array $option): Dispatch { if (is_subclass_of($route, Dispatch::class)) { $result = new $route($request, $this, $route, $this->vars); } elseif ($route instanceof Closure) { // 执行闭包 $result = new CallbackDispatch($request, $this, $route, $this->vars); } elseif (str_contains($route, '@') || str_contains($route, '::') || str_contains($route, '\\')) { // 路由到类的方法 $route = str_replace('::', '@', $route); $result = $this->dispatchMethod($request, $route); } else { // 路由到控制器/操作,通过传入进来的参数index/test,执行的就是这个逻辑 $result = $this->dispatchController($request, $route); } return $result; } ``` 我们进入这个方法`dispatchController()` ```php /** * 解析URL地址为 模块/控制器/操作 * @access protected * @param Request $request Request对象 * @param string $route 路由地址 * @return ControllerDispatch */ protected function dispatchController(Request $request, string $route): ControllerDispatch { // 解析URL的pathinfo参数,最后得到这样一个数组 Array ( [0] => index [1] => test ) $path = $this->parseUrlPath($route); $action = array_pop($path); // test $controller = !empty($path) ? array_pop($path) : null; // index // 路由到模块/控制器/操作 // 我们可以看看think\route\dispatch\Controller 这个类,发现其没有初始化方法,但其父类是有 // 这个构造方法,这种写法是php8的特性,在定义参数的时候定义熟悉,具体可以看看官方文档 // https://www.php.net/releases/8.0/zh.php // public function __construct(protected Request $request, protected Rule $rule, protected $dispatch, protected array $param = []){} return new ControllerDispatch($request, $this, [$controller, $action], $this->vars); } ``` 最后我们得到这样的一个对象 ```php think\route\dispatch\Controller Object ( [dispatch] => Array ( [0] => index [1] => test ) [param] => Array ( [name] => boys ) [rule] => think\route\RuleItem Object ( [name] => index/test [rule] => test/ [route] => index/test [method] => get [vars] => Array ( [name] => boys ) [option] => Array ( ) [pattern] => Array ( ) ) ) ``` 然后通过`think\route\dispatch\Controller`这里面的run方法进行控制器的解析,也就是回到我们最前面的 `dispatch()`方法, ```php public function dispatch(Request $request, Closure|bool $withRoute = true) { //省略代码 return $this->app->middleware->pipeline('route') ->send($request) ->then(function () use ($dispatch) { // 控制器的解析 return $dispatch->run(); }); } ``` 路由讲到这里,这个过程基本就讲完了,这篇文章有点长,这个过程也比较复杂,文章中并没有对某些细节进行过多讲解,所以大家在阅读的时候可以思考一下,有些地方为什么要这么写。 # 路由中间件 中间件,相信大家很熟悉,开发过程中也经常会用到,比如说我们对未登录用户的拦截。 本章主要涉及下面两个知识点: - **中间件的用法** - **中间件的核心源码分析** 中间件的源码阅读起来比较困难,大家一定要跟着文章和打开源码认真看~ ## 中间件的用法 比如说有这样的一个需求,未登录无法访问首页。 我们新建一个中间件,可以直接在终端运行以下命令 ```shell php think make:middleware Check ``` 然后在`app`目录下会生成一个`middleware`目录,打开里面的`Check.php` ```php class Check { /** * 处理请求 * * @param \think\Request $request * @param \Closure $next * @return Response */ public function handle($request, \Closure $next) { // 获取用户登录信息,这里默认写死,模拟未登录 $isLogin = false; if (!$isLogin) { return json(['msg'=>'未登录']); } return $next($request); } } ``` 它的意思就是如果用户未登录,则直接返回错误信息,否则往下执行`$next($request)`方法。 我们随便写个控制器方法并注册路由 ```php public function index() { return json(['msg'=>'success']); } // 路由 Route::get('index', 'index/index')->middleware(\app\middleware\Check::class); ``` 路由中间件的使用其实就这么简单。 ## 中间件的核心源码分析 如果大家看过《路由的加载与解析》这篇文章,那我们应该知道路由在什么时候被加载进来的。当框架加载路由文件的时候,就会运行这个路由方法 ```php Route::get('index', 'index/index')->middleware(\app\middleware\Check::class); ``` 我们进入`middleware()`方法看看,这个方法所在的文件是`Rule.php` ```php public function middleware(string | array | Closure $middleware, ...$params) { if (empty($params) && is_array($middleware)) { $this->option['middleware'] = $middleware; } else { foreach ((array) $middleware as $item) { $this->option['middleware'][] = [$item, $params]; } } return $this; } ``` 这段代码比较简单,其实就是把中间件存储到`option`成员变量中。 当加载完路由及中间件之后,就是路由检查,然后就是一个初始化操作 ```php // 路由调度,是Http类中的方法 public function dispatch(Request $request, Closure|bool $withRoute = true) { // 省略 $dispatch->init($this->app); // 调度管理 return $this->app->middleware->pipeline('route') ->send($request) ->then(function () use ($dispatch) { return $dispatch->run(); }); } ``` 我们进入`init()`方法, ```php public function init(App $app) { $this->app = $app; // 执行路由后置操作 $this->doRouteAfter(); } ``` 继续进入`doRouteAfter()` ```php // 这个函数的作用就是把中间件保存到队列中 protected function doRouteAfter(): void { // 获取路由参数,这里将获取之前保存在option变量中的中间件 // Array ( [remove_slash] => [middleware] => Array ( [0] => Array ( [0] => app\middleware\Check [1] => Array ( ) ) ) ) $option = $this->rule->getOption(); // 添加中间件,这里是把中间件保存到执行队列中 // 其实就是保存在Middleware.php类中的protected $queue = []; // 大家可以自己去看看import方法,比较简单,都能看懂 if (!empty($option['middleware'])) { $this->app->middleware->import($option['middleware'], 'route'); } //省略代码 } ``` 到目前为止,我们再来总结一下:首先就是执行`middleware(\app\middleware\Check::class)`,把中间件保存到`Rule.php`里面的`option`变量中。之后就是在调度执行之前的初始化方法`init()`,这里面的工作就是把`option`中的中间件保存到`Middleware.php`里面的`$queue`变量中。 接下来就是咱们回到`dispatch()`,我们把中间件添加到对了后,接下来就是调度管理, ```php // 路由调度,是Http类中的方法 public function dispatch(Request $request, Closure|bool $withRoute = true) { //省略代码 $dispatch->init($this->app); // 调度管理 // 调用middleware.php类中的pipeline方法 return $this->app->middleware->pipeline('route') ->send($request) ->then(function () use ($dispatch) { return $dispatch->run(); }); } ``` 这段代码阅读起来有点困难,希望接下来我的分析你们能够看得懂。 ```php $this->app->middleware->pipeline('route') ``` 这个代码片段的意思就是执行`pipeline()`, 我们打开`middleware.php`中的`pipeline()`方法 ```php public function pipeline(string $type = 'global') { return (new Pipeline()) ->through(array_map(function ($middleware) { return function ($request, $next) use ($middleware) { [$call, $params] = $middleware; if (is_array($call) && is_string($call[0])) { $call = [$this->app->make($call[0]), $call[1]]; } $response = call_user_func($call, $request, $next, ...$params); if (!$response instanceof Response) { throw new LogicException('The middleware must return Response instance'); } return $response; }; }, $this->sortMiddleware($this->queue[$type] ?? []))) ->whenException([$this, 'handleException']); } ``` 很多人都被这段代码吓到,我们可以先简化一下,其实它返回值是: `(new Pipeline())->through()->whenException();`,我们到最后一个方法`whenException()` ```php // 设置异常处理器 public function whenException($handler) { $this->exceptionHandler = $handler; // 实际上返回的还是Pipeline return $this; } ``` 所以我们看到上面的那个函数返回的就是`Pipeline`的实例 那解析来我们重点分析一下`through()`这个方法,这个方法传入的参数是一个匿名函数 ```php // 我们发现它这里是使用了php的内置函数array_map的返回值作为参数 // 具体用法看:https://www.php.net/manual/zh/function.array-map.php // array_map中的第一个参数是一个回调函数(匿名函数),第二个就是函数的参数,是一个数组 // 第二个参数是$this->sortMiddleware($this->queue[$type] ?? []),对中间件进行排序 // $this->queue就是我们之前加到队列的中间件 // Array ( [0] => Array ( [0] => Array ( [0] => app\middleware\Check [1] => handle ) [1] => Array ( ) ) ) array_map(function ($middleware) { // 这个回调函数的返回值仍然是个函数,这个函数有两个参数 // $request这个很好理解,就是请求对象 // $next这个参数是什么呢? 我们接着往下看吧 // $middleware 我们注册的中间件 return function ($request, $next) use ($middleware) { // 我们看看这个函数的逻辑 // $call : Array ( [0] => app\middleware\Check [1] => handle ) [$call, $params] = $middleware; if (is_array($call) && is_string($call[0])) { // 这段代码其实就是创建app\middleware\Check实例 // make在《利用反射机制实例化》中已经详细讲过 $call = [$this->app->make($call[0]), $call[1]]; } // 这里就是执行中间件,即执行类app\middleware\Check中的handle方法了, // call_user_func这个函数在前面也讲过 $response = call_user_func($call, $request, $next, ...$params); if (!$response instanceof Response) { throw new LogicException('The middleware must return Response instance'); } return $response; }; }, $this->sortMiddleware($this->queue[$type] ?? [])) ``` 最后这里我们还是要回到`through()`方法,我们看看这个方法 ```php protected $pipes = []; public function through($pipes) { // 这里保存中间件,Closure对象 $this->pipes = is_array($pipes) ? $pipes : func_get_args(); return $this; } ``` 不知道大家能否理解,其实这个变量传入进来的值就是上面使用array_map得到的一个数组,里面的值就是`return`的那个匿名函数,也就是说pipes这个数组存储的元素是一个个匿名函数,例如你可以这样理解`pipes` ```php $this->pipes = [ function($request, $next) use ($middleware){}, function($request, $next) use ($middleware){}, ] ``` 接下来我们回到这段代码 ```php // 路由调度,是Http类中的方法 public function dispatch(Request $request, Closure|bool $withRoute = true) { //省略代码 $dispatch->init($this->app); // 调度管理 // 调用middleware.php类中的pipeline方法 return $this->app->middleware->pipeline('route') ->send($request) // $dispatch:`think\route\dispatch\Controller` // 《路由的加载和解析》这篇文章中有讲到 ->then(function () use ($dispatch) { return $dispatch->run(); }); } ``` 我们看了`pipeline()`方法,接下来进入`then()`方法,这个方法里面的逻辑也是比较复杂的 ```php /** * 执行 * @param Closure $destination * @return mixed */ public function then(Closure $destination) { // $destination 这个Closure对象是think\route\dispatch\Controller // 定义了一个匿名函数,接下来重点讲讲array_reduce内置函数的三个参数 // 文档:https://www.php.net/manual/zh/function.array-reduce $pipeline = array_reduce( // 参数,也就是我们中间件,是个数组对象 array_reverse($this->pipes), // 回调函数,会把数组对象里面的每个元素作为参数,一个个去调用,类似于一个for循环调用 $this->carry(), // 如果第一个参数为空,也就是没有中间件,那么这个函数讲作为最终的返回结果,如果有值,那么将作为回调函数的初始值,怎么去理解呢? function ($passable) use ($destination) { try { return $destination($passable); } catch (Throwable | Exception $e) { return $this->handleException($passable, $e); } } ); return $pipeline($this->passable); } ``` 这里的话我决定拆开来讲,我们看看第三个参数 ```php // 这是一个Closure对象,也就是匿名函数 // $destination这个传进来的Closure $destination其实就是下面这个匿名函数 // function () use ($dispatch) { // // $dispatch的值是`think\route\dispatch\Controller` // return $dispatch->run(); //} function ($passable) use ($destination) { try { // 这行代码就是执行了$dispatch->run();就是后面的实例化控制器 return $destination($passable); } catch (Throwable | Exception $e) { return $this->handleException($passable, $e); } } ``` 如果说没有中间件,那么就执行这个函数,作为最终的结果,这个应该可以理解吧? 现在我们是有中间件的,接下来看回调函数`carry()` ```php protected function carry() { // $stack这个参数是什么,其实通过打印,我们可以发现就是第三个参数,也是我们上面分析的那个Closure对象 // `think\route\dispatch\Controller` // $pipe 这个参数就是第一个参数中的数组元素保存的Closure对象,其中包含我们本次例子的 // app\middleware\Check这个中间件 return function ($stack, $pipe) { // $passable 这个是$request对象 return function ($passable) use ($stack, $pipe) { try { // 这里就是执行我们前面说的匿名函数,看看下面代码 return $pipe($passable, $stack); } catch (Throwable | Exception $e) { return $this->handleException($passable, $e); } }; }; } ``` ```php ->through(array_map(function ($middleware) { // 这个回调函数的返回值仍然是个函数,这个函数有两个参数 // $request这个很好理解,就是请求对象 // $next这个参数是什么呢? 我们接着往下看吧 return function ($request, $next) use ($middleware) { // 我们看看这个函数的逻辑 // $call : Array ( [0] => app\middleware\Check [1] => handle ) [$call, $params] = $middleware; if (is_array($call) && is_string($call[0])) { // 这段代码其实就是创建app\middleware\Check实例 // make在《利用反射机制实例化》中已经详细讲过 $call = [$this->app->make($call[0]), $call[1]]; } // 这里就是执行中间件,即执行类app\middleware\Check中的handle方法了, // call_user_func这个函数在前面也讲过 $response = call_user_func($call, $request, $next, ...$params); if (!$response instanceof Response) { throw new LogicException('The middleware must return Response instance'); } return $response; }; }, $this->sortMiddleware($this->queue[$type] ?? []))) ``` 执行的是这个return function ($request, $next) use ($middleware) {……} 那这个`$next`是什么,大家就应该知道了,就是这个Closure对象`think\route\dispatch\Controller` 我们看看这行代码: ```php $response = call_user_func($call, $request, $next, ...$params); ``` 其实就来到了我们定义的中间件 ```phP class Check { /** * 处理请求 * * @param \think\Request $request * @param \Closure $next Closure对象`think\route\dispatch\Controller` * @return Response */ public function handle($request, \Closure $next) { if ($request->param('name') != '张三') { echo "参数值只能是张三!!";die; } // 如果通过了上面的拦截过滤,那么这里就往下执行,解析控制器 return $next($request); } } ``` 好了,到这里路由中间件主要内容就讲完了,框架里中间件的执行,设计的很巧妙,但是代码就非常的复杂了,其实就是主要使用了几个内置函数[array_map](https://www.php.net/manual/zh/function.array-map.php)、[array_reduce](https://www.php.net/manual/zh/function.array-reduce)、[call_user_func](https://www.php.net/manual/zh/function.call-user-func),另外的话主要是参数和返回值大多数是一个Closure对象,因此阅读起来非常的绕和复杂。 # 控制器的解析 当我们执行控制器中的方法时,其主要涉及到的知识点无非是下面两个: - **实例化控制器** - **控制器中的方法如何被调用** ## 实例化控制器 我们先准备一个例子,这个是调试分析用的,。 ```php 'success']); } } //这是设置的路由 Route::get('index', 'index/index'); ``` 通过前面《一个接口的请求流程》、《路由的加载和解析》、《路由中间件》等几篇文章,我们大概知道控制器的解析是从这一行代码开始 ```php // 路由调度,是Http类中的方法 public function dispatch(Request $request, Closure|bool $withRoute = true) { $this->request = $request; $this->host = $this->request->host(true); if ($withRoute) { //加载路由 if ($withRoute instanceof Closure) { $withRoute(); } $dispatch = $this->check(); } else { $dispatch = $this->url($this->path()); } // 上面的代码是解析url的逻辑 // 初始化`think\route\dispatch\Controller` $dispatch->init($this->app); return $this->app->middleware->pipeline('route') ->send($request) ->then(function () use ($dispatch) { // $dispatch是`think\route\dispatch\Controller`这样的一个对象 // 执行控制器及方法 return $dispatch->run(); }); } ``` 当`url`地址解析完后我们可以打印一下`$dispatch`,发现它是`think\route\dispatch\Controller`,因此我们进入这个类的`init()` > 本章控制器的解析,我们是开启了路由功能,因此`$dispatch`的类是`think\route\dispatch\Controller`,如果我们不走路由,而是直接走url,那么`$dispatch`代表的类是`think\route\dispatch\Url`,这里的话我就不分析这种情况了,有兴趣的可以自己去阅读 ```php public function init(App $app) { parent::init($app); $result = $this->dispatch; if (is_string($result)) { $result = explode('/', $result); } // 获取控制器名 $controller = strip_tags($result[0] ?: $this->rule->config('default_controller')); if (str_contains($controller, '.')) { $pos = strrpos($controller, '.'); $this->controller = substr($controller, 0, $pos) . '.' . Str::studly(substr($controller, $pos + 1)); } else { $this->controller = Str::studly($controller); } // 获取操作名 $this->actionName = strip_tags($result[1] ?: $this->rule->config('default_action')); // 设置当前请求的控制器、操作。 // 这也是我们经常用到的为什么能这样获取控制器名$this->request->controller,因为在这里框架已经把他 // 们设置到了request对象中 $this->request ->setController($this->controller) ->setAction($this->actionName); } ``` 这个方法很简单,其实就是获取控制器名及操作名 看完`init()`,我们接着往下看 ```php return $this->app->middleware->pipeline('route') ->send($request) ->then(function () use ($dispatch) { // $dispatch是`think\route\dispatch\Controller`这样的一个对象 // 执行控制器及方法 return $dispatch->run(); }); ``` 我们进入`run()`方法,我们从`think\route\dispatch\Controller`父类中`Dispatch`这个抽象类找到了该方法 ```php public function run(): Response { $data = $this->exec(); return $this->autoResponse($data); } ``` 我们继续往下看看,我们进入`exec()`方法,发现其是一个抽象方法 ```php abstract public function exec(); ``` 而这个方法的实现逻辑是在子类`think\route\dispatch\Controller` ```php // 这个方法我讲分两部分进行讲解 public function exec() { // 第一部分 try { // 实例化控制器 // $this->controller这个值是Index,其实就是我们的Index控制器 $instance = $this->controller($this->controller); } catch (ClassNotFoundException $e) { throw new HttpException(404, 'controller not exists:' . $e->getClass()); } // 第二部分 } ``` 我们进入`controller()`方法 ```php /** * 实例化访问控制器 * @access public * @param string $name 资源地址 Index * @return object * @throws ClassNotFoundException */ public function controller(string $name) { // 是否使用控制器后缀 $suffix = $this->rule->config('controller_suffix') ? 'Controller' : ''; // 访问控制器层名称 $controllerLayer = $this->rule->config('controller_layer') ?: 'controller'; // 空控制器名 $emptyController = $this->rule->config('empty_controller') ?: 'Error'; // 解析应用类的类名,最后得到这样一个类名 // app\controller\Index $class = $this->app->parseClass($controllerLayer, $name . $suffix); if (class_exists($class)) { // 如果存在该类,那么就实例化它 // make方法前面已经出现过很多次了,这里就不重复讲 return $this->app->make($class, [], true); } elseif ($emptyController && class_exists($emptyClass = $this->app->parseClass($controllerLayer, $emptyController . $suffix))) { // 这里面是实例化一个空控制器 // 空控制器的概念是指当系统找不到指定的控制器名称的时候,系统会尝试定位当前应用下的 // 空控制器(Error)类 // 官方文档有提到:https://doc.thinkphp.cn/v8_0/empty_controller.html return $this->app->make($emptyClass, [], true); } // 如果都不存在则抛出异常 throw new ClassNotFoundException('class not exists:' . $class, $class); } ``` 我们看到实例化控制器,还是调用了那个常用方法`$this->app->make()`,到这里控制器实例化的流程已经完成 ## 方法的调用 我们接着往下看`exec()`,来到方法调用这部分代码 ```php public function exec() { //省略 // 第二部分 return $this->app->middleware->pipeline('controller') ->send($this->request) // then方法,我们在《路由中间件》中已经详细讲过了,这里我们只需关注这个匿名函数的内容即可 ->then(function () use ($instance) { // 获取当前操作名 $suffix = $this->rule->config('action_suffix'); // 这里的action是index $action = $this->actionName . $suffix; // is_callable这个是php内置函数 // 文档:https://www.php.net/manual/zh/function.is-callable // 检查对象方法是否可调用,也就是说Index控制器中,index()方法是否可调用 if (is_callable([$instance, $action])) { // 获取参数 $vars = $this->request->param(); try { // 执行反射方法,得到这样的一个对象 // ReflectionMethod Object // ( // [name] => index // [class] => app\controller\Index // ) $reflect = new ReflectionMethod($instance, $action); // 严格获取当前操作方法名 $actionName = $reflect->getName(); if ($suffix) { $actionName = substr($actionName, 0, -strlen($suffix)); } // 设置操作名到request对象中 $this->request->setAction($actionName); } catch (ReflectionException $e) { $reflect = new ReflectionMethod($instance, '__call'); $vars = [$action, $vars]; $this->request->setAction($action); } } else { // 操作不存在 throw new HttpException(404, 'method not exists:' . $instance::class . '->' . $action . '()'); } // 调用反射类的 // object $instance 对象实例 // mixed $reflect 反射类 // array $vars 参数 // http://tp8-dev.com/index // 这里的$data返回的是我们index方法中return的数据 json(['msg'=>'success']) // 如果你去接口那里改一下,把返回值从字符串改成一个 // return json(['code'=>0,'data'=>$name]); // 此时的data是一个Response对象 $data = $this->app->invokeReflectMethod($instance, $reflect, $vars); // 返回一个响应对象 return $this->autoResponse($data); }); } ``` 我们看看`autoResponse()`方法 ```php // 这里的$data是json(['msg'=>'success'])这样的json对象 protected function autoResponse($data): Response { if ($data instanceof Response) { // 会进入这里 $response = $data; } elseif ($data instanceof ResponseInterface) { $response = Response::create((string) $data->getBody(), 'html', $data->getStatusCode()); foreach ($data->getHeaders() as $header => $values) { $response->header([$header => implode(", ", $values)]); } } elseif (!is_null($data)) { // 默认自动识别响应输出类型 // 因为$data是字符串,因此会执行下面的代码 $type = $this->request->isJson() ? 'json' : 'html'; // 创建一个Response对象 $response = Response::create($data, $type); } else { $data = ob_get_clean(); $content = false === $data ? '' : $data; $status = '' === $content && $this->request->isJson() ? 204 : 200; $response = Response::create($content, 'html', $status); } return $response; } ``` 到这里我们的`exec()`方法就讲完了,我们回到`run()`方法 ```php public function run(): Response { // 刚刚讲完的,返回的是一个Response对象 $data = $this->exec(); // 这时又再一次调用autoResponse return $this->autoResponse($data); } ``` 我们再看看这个方法`autoResponse()` ```php // 这里的$data是Response对象 protected function autoResponse($data): Response { if ($data instanceof Response) { // 走这个判断逻辑,直接返回 $response = $data; } // 省略代码 return $response; } ``` 那最后返回的这个Response对象是干什么的? 我们打开入口文件`index.php` ```php require __DIR__ . '/../vendor/autoload.php'; // 执行HTTP应用并响应 $http = (new App())->http; // 这里是接口执行的过程,返回值就是上面的Response $response = $http->run(); // 这里通过send方法,把数据发送到客户端,例如浏览器 $response->send(); $http->end($response); ``` 到这里,本章的内容就讲完了,如果你看了前面几个章节的内容,那么本章阅读起来就没有那么困难了。 # 视图渲染 本章我们来研究一下视图渲染的源码,`thinkphp8`框架仅内置了`PHP`原生模板引擎(主要用于内置的异常页面输出) ,如果说你想渲染`html`类型的模板,那么就需要安装一下 `think-view` 模板引擎驱动 。 ```shell composer require topthink/think-view ``` 接下来我们准备一个例子 ```php class Index extends BaseController { public function index(){ View::assign('name','ThinkPHP'); return View::fetch(); } } ``` 模板位于`app/view/index/index.html` ```html thinkphp8 {$name} ``` ## 获取模板驱动 我们进入视图类`View`的源码 ```php // 视图类继承了驱动管理类Manager,这个驱动管理基类非常的重要,框架中日志写入、缓存写入、session的保存都会 // 用到这个类 class View extends Manager { // 省略了部分源码 // 模板变量 protected $data = []; // 获取模板引擎 public function engine(string $type = null) { return $this->driver($type); } // 模板变量赋值 public function assign(string|array $name, $value = null) { // 模板变量都存储到$data = []这个成员变量中,后面模板渲染的时候会用到 if (is_array($name)) { $this->data = array_merge($this->data, $name); } else { $this->data[$name] = $value; } return $this; } // 解析和获取模板内容 用于输出 public function fetch(string $template = '', array $vars = []): string { return $this->getContent(function () use ($vars, $template) { $this->engine()->fetch($template, array_merge($this->data, $vars)); }); } // 获取模板引擎渲染内容 protected function getContent($callback): string { // 页面缓存 ob_start(); ob_implicit_flush(false); // 渲染输出 try { // 执行回调函数,模板渲染 $callback(); } catch (\Exception $e) { ob_end_clean(); throw $e; } // 获取输出的内容并清空缓存 $content = ob_get_clean(); if ($this->filter) { $content = call_user_func_array($this->filter, [$content]); } // $content其实就是我们的html页面的内容 return $content; } // 默认驱动 public function getDefaultDriver() { // app应用下的config目录里面的view.php配置文件,模板引擎类型使用Think return $this->app->config->get('view.type', 'php'); } } ``` 我们重点分析一下`getContent()`方法中的回调函数,其首先通过调用`$this->engine()`获取驱动(模板引擎),我们看看其里面的`$this->driver($type)`方法,这个方法在`Manager`类里面 ```php protected function driver(string $name = null) { // 获取默认驱动类型名称,getDefaultDriver方法是抽象方法,其实现逻辑在子类View里面 $name = $name ?: $this->getDefaultDriver(); if (is_null($name)) { throw new InvalidArgumentException(sprintf( 'Unable to resolve NULL driver for [%s].', static::class )); } // 获取驱动实例,保存在成员变量drivers中 return $this->drivers[$name] = $this->getDriver($name); } // 获取驱动实例 protected function getDriver(string $name) { // 如果不存在实例,则创建实例 return $this->drivers[$name] ?? $this->createDriver($name); } ``` 这里我们进入`createDriver()`,前面我们提到过`Manager`会被其它功能模块调用,因此在调试代码的时候,如果你直接进行断点调试,那么执行的有可能并不是我们当前模块调用的方法。比如说`echo $name."
";`这时会输出 ``` file Think ``` 意思就是该方法第一次被调用是执行到日志写入的时候,使用了驱动类型是`file`,而第二次调用才是我们本章的驱动`Think`,因此调试的时候我们要灵活处理,比如我在阅读这段代码的时候是这样的 ```php protected function getDriver(string $name) { if ($name == 'Think') { // 复制一个新方法 return $this->drivers[$name] ?? $this->createDriver1($name); } return $this->drivers[$name] ?? $this->createDriver($name); } ``` 这只是我阅读源码的方式之一,好了我们继续 ```php protected function createDriver(string $name) { // 获取驱动类型Think $type = $this->resolveType($name); // 声明一个函数名称的变量,createThinkDriver // Str静态类,studly是将字符串由下划线转驼峰 $method = 'create' . Str::studly($type) . 'Driver'; // 函数的参数,其实就是view.php配置文件中的配置项 $params = $this->resolveParams($name); if (method_exists($this, $method)) { // 如果当前类存在createThinkDriver这个函数,就直接执行 return $this->$method(...$params); } // 驱动类名称 \think\view\driver\Think $class = $this->resolveClass($type); // 利用反射的原理实例化驱动 // invokeClass方法前面的一些章节已经详细讲过 return $this->app->invokeClass($class, $params); } ``` 接下来我们看看实例化`\think\view\driver\Think`时,其初始化的操作由哪些,打开其构造函数 ```php public function __construct(private App $app, array $config = []) { // 合并配置 $this->config = array_merge($this->config, (array) $config); if (empty($this->config['cache_path'])) { // 缓存路径 $this->config['cache_path'] = $app->getRuntimePath() . 'temp' . DIRECTORY_SEPARATOR; } // 初始化Template,它时ThinkPHP分离出来的模板引擎,支持XML标签和普通标签的模板解析 // 编译型模板引擎 支持动态缓存,后面会讲到 $this->template = new Template($this->config); $this->template->setCache($app->cache); // 下面定义了系统变量,$Think和$Request,这两个变量可以在模板中直接使用 // 例如{$Think.const.PHP_VERSION}获取常量 // {$Think.config.app.default_app} 获取app.php配置文件中的default_app值 $this->template->extend('$Think', function (array $vars) { $type = strtoupper(trim(array_shift($vars))); $param = implode('.', $vars); return match ($type) { 'CONST' => strtoupper($param), 'CONFIG' => 'config(\'' . $param . '\')', 'LANG' => 'lang(\'' . $param . '\')', 'NOW' => "date('Y-m-d g:i a',time())", 'LDELIM' => '\'' . ltrim($this->getConfig('tpl_begin'), '\\') . '\'', 'RDELIM' => '\'' . ltrim($this->getConfig('tpl_end'), '\\') . '\'', default => defined($type) ? $type : '\'\'', }; }); // 在模板中使用$Request对象 $this->template->extend('$Request', function (array $vars) { // 获取Request请求对象参数 $method = array_shift($vars); if (!empty($vars)) { $params = implode('.', $vars); if ('true' != $params) { $params = '\'' . $params . '\''; } } else { $params = ''; } return 'app(\'request\')->' . $method . '(' . $params . ')'; }); } ``` ## 获取模板文件名 在我们实例化了驱动类`think\view\driver\Think`后,其调用了`fetch()`方法 ```php // $this->engine()获取到了Think的实例,然后调用fetch $this->engine()->fetch($template, array_merge($this->data, $vars)); ``` 我看进入`fetch()` ```php public function fetch(string $template, array $data = []): void { // 如果没有配置模板路径,就使用默认的一些配置 if (empty($this->config['view_path'])) { // 模板目录名称,例如view $view = $this->config['view_dir_name']; if (is_dir($this->app->getAppPath() . $view)) { // 单应用,模板目录位于app目录下 $path = $this->app->getAppPath() . $view . DIRECTORY_SEPARATOR; } else { // 多应用 $appName = $this->app->http->getName(); $path = $this->app->getRootPath() . $view . DIRECTORY_SEPARATOR . ($appName ? $appName . DIRECTORY_SEPARATOR : ''); } // $path F:\phpstudy_pro\WWW\thinkphp8\app\view\ $this->config['view_path'] = $path; $this->template->view_path = $path; } if ('' == pathinfo($template, PATHINFO_EXTENSION)) { // 获取模板文件名 $template = $this->parseTemplate($template); } // 模板不存在 抛出异常 if (!is_file($template)) { throw new TemplateNotFoundException('template not exists:' . $template, $template); } $this->template->fetch($template, $data); } ``` 我们进入`parseTemplate()` ```php private function parseTemplate(string $template): string { // 分析模板文件规则 $request = $this->app['request']; // 获取视图根目录 if (strpos($template, '@')) { // 跨模块调用,例如View::fetch('admin@member/edit'); list($app, $template) = explode('@', $template); } if (isset($app)) { // 如果是跨模块,那么就获取该模块下的模板路径 $view = $this->config['view_dir_name']; $viewPath = $this->app->getBasePath() . $app . DIRECTORY_SEPARATOR . $view . DIRECTORY_SEPARATOR; if (is_dir($viewPath)) { $path = $viewPath; } else { $path = $this->app->getRootPath() . $view . DIRECTORY_SEPARATOR . $app . DIRECTORY_SEPARATOR; } $this->template->view_path = $path; } else { $path = $this->config['view_path']; } $depr = $this->config['view_depr']; // View::fetch('Index/index'); // View::fetch('Index:index'); View::fetch(); // 这些情况下会进入if的逻辑 if (0 !== strpos($template, '/')) { $template = str_replace(['/', ':'], $depr, $template); $controller = $request->controller(); if (strpos($controller, '.')) { $pos = strrpos($controller, '.'); $controller = substr($controller, 0, $pos) . '.' . Str::snake(substr($controller, $pos + 1)); } else { $controller = Str::snake($controller); } // 核心的逻辑就是获取控制器名、方法名,最后拼成这样index\index if ($controller) { if ('' == $template) { // 如果模板文件名为空 按照默认模板渲染规则定位 if (2 == $this->config['auto_rule']) { $template = $request->action(true); } elseif (3 == $this->config['auto_rule']) { $template = $request->action(); } else { $template = Str::snake($request->action()); } $template = str_replace('.', DIRECTORY_SEPARATOR, $controller) . $depr . $template; } elseif (false === strpos($template, $depr)) { $template = str_replace('.', DIRECTORY_SEPARATOR, $controller) . $depr . $template; } } } else { // View::fetch('/index'); // 这里的意思就是模板直接放在view目录下,view\index,而不是view\index\index $template = str_replace(['/', ':'], $depr, substr($template, 1)); } return $path . ltrim($template, '/') . '.' . ltrim($this->config['view_suffix'], '.'); } ``` ## 模板编译 模板的编译可以说是本章的重点了,我们的模板是`html`文件,那么框架是如何把它编译成`php`文件,而我们的变量又是如何在文件中解析的。 上面我们讲了在获取到需要渲染的模板后,就会调用`template`类的`fetch()` ```php $this->template->fetch($template, $data); ``` 我们进入`fetch()`方法 ```php // $template 模板文件 // $vars 模板变量 public function fetch(string $template, array $vars = []): void { if ($vars) { $this->data = array_merge($this->data, $vars); } if ($this->isCache($this->config['cache_id'])) { // 读取渲染缓存 echo $this->cache->get($this->config['cache_id']); return; } $template = $this->parseTemplateFile($template); if ($template) { // 缓存文件,一般位于runtime\temp目录 $cacheFile = $this->config['cache_path'] . $this->config['cache_prefix'] . md5($this->config['layout_on'] . $this->config['layout_name'] . $template) . '.' . ltrim($this->config['cache_suffix'], '.'); if (!$this->checkCache($cacheFile)) { // 缓存无效 重新模板编译 $content = file_get_contents($template); $this->compiler($content, $cacheFile); } // 页面缓存 ob_start(); ob_implicit_flush(false); // 读取编译存储,其实就是读取runtime\temp生成的编译文件 $this->storage->read($cacheFile, $this->data); // 获取并清空缓存 $content = ob_get_clean(); if (!empty($this->config['cache_id']) && $this->config['display_cache'] && null !== $this->cache) { // 缓存页面输出 $this->cache->set($this->config['cache_id'], $content, $this->config['cache_time']); } echo $content; } } ``` 这里面大概的逻辑是这样:首先会从缓存中读取页面输出的内容,如果缓存没开启或不存在,就会走进编译模板的逻辑,如果编译文件不存在,也就是说`runtime\temp`目录下不存在编译文件,就会走重写编译的逻辑 ```php if (!$this->checkCache($cacheFile)) { // 缓存无效 重新模板编译 $content = file_get_contents($template); $this->compiler($content, $cacheFile); } ``` 为了确保模板能重写编译,建议调试之前把`runtime`目录下的`temp`目录删除掉。 我们进入`compiler()` ```php // $content 模板内容,注意$content是引用传参 // $cacheFile 缓存文件名 private function compiler(string &$content, string $cacheFile): void { // 判断是否启用布局 if ($this->config['layout_on']) { if (str_contains($content, '{__NOLAYOUT__}')) { // 可以单独定义不使用布局 $content = str_replace('{__NOLAYOUT__}', '', $content); } else { // 读取布局模板 $layoutFile = $this->parseTemplateFile($this->config['layout_name']); if ($layoutFile) { // 替换布局的主体内容 $content = str_replace($this->config['layout_item'], $content, file_get_contents($layoutFile)); } } } else { $content = str_replace('{__NOLAYOUT__}', '', $content); } // 模板解析,其实就是标签的解析,例如foreach、volist数据循环标签,比较标签、条件判断标签 // 大家可以进去看看这些标签是如何解析的,方法里面都有详细的注释 $this->parse($content); if ($this->config['strip_space']) { /* 去除html空格与换行 */ $find = ['~>\s+<~', '~>(\s+\n|\r)~']; $replace = ['><', '>']; $content = preg_replace($find, $replace, $content); } // 优化生成的php代码 // {$name}会变成这样 $content = preg_replace('/\?>\s*<\?php\s(?!echo\b|\bend)/s', '', $content); // 模板过滤输出 $replace = $this->config['tpl_replace_string']; $content = str_replace(array_keys($replace), array_values($replace), $content); // 添加安全代码及模板引用记录 $content = 'includeFile) . '*/ ?>' . "\n" . $content; // 编译存储,其实就是生成编译文件 $this->storage->write($cacheFile, $content); $this->includeFile = []; } // 写入编译缓存 public function write(string $cacheFile, string $content): void { // 检测模板目录 $dir = dirname($cacheFile); if (!is_dir($dir)) { mkdir($dir, 0755, true); } // 生成模板缓存文件 if (false === file_put_contents($cacheFile, $content)) { throw new Exception('cache write error:' . $cacheFile, 11602); } } ``` 生成编译文件后,接下来就开启页面缓存,读取这个编译文件的内容输出 ## 总结 到这里,视图渲染的核心源码已经讲完,本章内容有些地方还是值得我们学习的,比如说源码中的驱动管理类`Manager`,它的设计思路值得我们学习,后续文章还会提到它。另外一个重点就是模板的编译与解析,我们知道它是如何解析标签、如何编译成一个`php`文件。 # 缓存 缓存是项目性能优化的一种重要且常用的手段之一,许多框架都内置了缓存的功能,支持多种类型的缓存,例如`Thinkphp`支持的缓存类型就有` file`、`memcache`、`wincache`、`sqlite`、`redis`,本章我们就看看`Thinkphp`是的缓存机制。 ## PSR-16 缓存规范 在官方文档中有这样的一句话:“ `ThinkPHP`的缓存类遵循`PSR-16`规范 ”,许多人可能还不知道这是什么东西。 **`PSR-16` 是 `PHP-FIG`(`PHP` 互操作性团体)提出的关于缓存的规范,用于定义缓存接口的标准** ,许多框架都是遵循这一标准。 我们可以进入源码看看,打开缓存管理类` think\facade\Cache ` ```php class Cache extends Manager implements CacheInterface{ // 省略 } ``` `Cache`类继承了`Manager`驱动管理类,这个类我们在讲《视图渲染》那一章也讲过,我们看看 `CacheInterface` ```php interface CacheInterface { // 获取缓存 public function get(string $key, mixed $default = null): mixed; // 设置缓存 public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool; // 删除缓存 public function delete(string $key): bool; // 清空所有缓存 public function clear(): bool; // 一次性获取多个缓存 public function getMultiple(iterable $keys, mixed $default = null): iterable; // 一次性设置多个缓存 public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool; // 一次性删除多个缓存 public function deleteMultiple(iterable $keys): bool; // 判断缓存是否存在 public function has(string $key): bool; } ``` 这些接口就是遵循 `PSR-16` 规范,这样的目的是为了统一缓存接口的实现,使得不同的缓存库(例如 `Redis`、`Memcached` 等)可以轻松切换和替换,同时提供了简单而灵活的缓存管理方式。符合` PSR-16` 规范的缓存库可以通过实现上述接口来实现缓存功能,并且可以在各种 `PHP` 框架和应用中通用使用。 ## 动态扩展缓存方法 我们可以看到`CacheInterface`只提供8个方法,用过框架缓存的人可能使用下面的方法 ```php // name自增(步进值为1) Cache::inc('name'); // name自减(步进值为1) Cache::dec('name'); ``` 但是我们发现`Cache`类和其继承的驱动类管理类`Manager`里面好像不存在这两个方法,那它是如何实现这两个方法的调用呢?如果说你对面向对象编程知识比较了解,那这时你可能会想到一个方法`__call()` 在` PHP` 中,`__call()` 方法是一个魔术方法(Magic Method),用于在对象中动态调用不可访问或不存在的方法时被调用。这个方法接受两个参数:要调用的方法名和一个包含传递给方法的参数的数组。 我们在驱动管理类`Manager`里面找到了`__call()`方法 ```php public function __call($method, $parameters) { // 调用驱动类里面的$method方法 return $this->driver()->$method(...$parameters); } ``` 当我们调用`inc('name')`方法时,就会调用`__call()`方法,此时方法名`$method`为`inc`,参数`$parameters`就是`name`,其最终的逻辑是在驱动类里面实现,不得不说这种设计很优秀。 ## 驱动类 前面我们提到`Thinkphp`支持的缓存类型就有` file`、`memcache`、`wincache`、`sqlite`、`redis`,每一种类型你可以理解为一个驱动类,这里我就以`think\cache\driver\File`文件缓存类作为例子讲解一下其结构和内容。 ```php class File extends Driver{ // 省略代码 } ``` 我们进入看看其父类的一些结构 ```php // 缓存基类 abstract class Driver implements CacheHandlerInterface { // 省略 } interface CacheHandlerInterface extends CacheInterface { // 自增缓存(针对数值缓存) public function inc($name, $step = 1); // 自减缓存(针对数值缓存) public function dec($name, $step = 1); // 读取缓存并删除 public function pull($name); // 如果不存在则写入缓存 public function remember($name, $value, $expire = null); // 缓存标签 public function tag($name); // 删除缓存标签 public function clearTag($keys); } ``` 我们发现驱动类里面除了实现`PSR-16`规范里面定义的几个接口外,还自定义了一些方法,比如说我们熟悉的`inc`、`dec`等这些方法,现在我们以`inc`方法为例子,理一理缓存的设置过程: `Cache::inc('name')`---->`Cache`和`Manager`看看是否存在该方法,显然该方法不存在,因为`Cache`里面只是实现了`PSR-16`规范里面定义的几个接口---->通过`Manager`的魔术方法`__call()`实现了`inc()`---->`inc()`里面的逻辑是通过调用驱动类里面的`inc()`实现的,不得不说这样设计的很巧妙,扩展性也强。 ## 缓存写入/读取/过期机制 我们以文件缓存为例子,我们分析一下缓存的写入、读取、过期机制,我们重点关注缓存的文件名、缓存内容、过期时间。 当我们使用`Cache::set('name', 'thinkphp8', 10);`设置缓存,根据配置文件`config/cache.php`里面的配置的缓存类型,通过驱动管理类`Manager`实例化缓存驱动类,例如`think\cache\driver\File`,然后调用里面的`set()` ```php public function set($name, $value, $expire = null): bool { if (is_null($expire)) { // 如果没有传入过期时间,则获取默认时间,这个时间一般是配置文件cache.php`配置的时间 $expire = $this->options['expire']; } // 因为$exprie支持int|\DateInterval|DateTimeInterface|null这些类型,因此需要做一定的转换, // 最后得到一个int的数值 $expire = $this->getExpireTime($expire); // 根据名字,设置缓存的文件名,具体逻辑大家自己去看,比较简单 // 其核心逻辑是使用hash函数进行文件名的命名,hash函数的文档: // https://www.php.net/manual/zh/function.hash.php $filename = $this->getCacheKey($name); $dir = dirname($filename); if (!is_dir($dir)) { try { // 创建缓存文件保存的目录,一般位于runtime/cache目录 mkdir($dir, 0755, true); } catch (\Exception $e) { // 创建失败 } } // 这个函数的逻辑就是使用了php内置serialize(),变量序列化为字符串的函数 // 后续我们在获取缓存的时候可以使用unserialize()反序列化,恢复为原来的数据 $data = $this->serialize($value); if ($this->options['data_compress'] && function_exists('gzcompress')) { //数据压缩 $data = gzcompress($data, 3); } // 这是最后存储的数据 $data = "\n" . $data; if (str_contains($filename, '://') && !str_starts_with($filename, 'file://')) { //虚拟文件不加锁 $result = file_put_contents($filename, $data); } else { // 文件加锁,主要防止并发操作同一个文件 $result = file_put_contents($filename, $data, LOCK_EX); } if ($result) { clearstatcache(); return true; } return false; } ``` 我们看看最后生成的缓存文件`68931cc450442b63f5b3d276ea4297.php` ```php s:9:"thinkphp8"; ``` 这里有两部分内容,使用``包含的那部分是过期时间的设置,第二部就是咱们缓存的内容了。 接下来我们看看它是如何获取缓存的,我们找到当前类的`get()`方法 ```php public function get($name, $default = null): mixed { // 获取缓存数据 $raw = $this->getRaw($name); // 反序列化输出原来字符串 return is_null($raw) ? $default : $this->unserialize($raw['content']); } // 获取缓存数据 protected function getRaw(string $name) { // 通过缓存的key,得到文件名 $filename = $this->getCacheKey($name); if (!is_file($filename)) { return; } // 读取缓存文件的内容 $content = @file_get_contents($filename); if (false !== $content) { // 截取内容,获得失效秒数,比如例子中的10秒 $expire = (int) substr($content, 8, 12); // $expire不等于0,如果等于表示永久缓存了 // 然后就是当前时间戳-失效的秒数 是否大于 文件修改的时间 if (0 != $expire && time() - $expire > filemtime($filename)) { //缓存过期删除缓存文件 $this->unlink($filename); return; } // 从32位字符串起,截取后面的字符串 $content = substr($content, 32); if ($this->options['data_compress'] && function_exists('gzcompress')) { //启用数据压缩 $content = gzuncompress($content); } return is_string($content) ? ['content' => (string) $content, 'expire' => $expire] : null; } } ``` 这里面可能有个地方大家不是很明白,就是获取失效的时间,为什么是从第8位开始,截取后面的12位字符,我们可以打开缓存文件看看 ```php s:9:"thinkphp8"; ``` `statusCode; } // 响应头信息 public function getHeaders() { return $this->headers; } } ``` 我们发现`HttpException`继承的是`RuntimeException`,它是`php`内置的一个类,它继承了Exception类,另外`HttpException`自定义了自己的状态码参数及响应头信息。 ## 异常捕获 一般来说`throw`关键字抛出的异常是会被**try……catch**捕获,我们看看源码中哪里捕获了这个异常,我们找到这个方法,我们在讲《路由中间件》和《控制器的解析》这两篇文章中已经详细讲过这个方法 ```php public function then(Closure $destination) { $pipeline = array_reduce( array_reverse($this->pipes), $this->carry(), function ($passable) use ($destination) { try { // 这里就是执行我们控制器的方法,也就是说控制器里面方法抛出的异常就会被这个try……catch // 捕获 return $destination($passable); } catch (Throwable | Exception $e) { // 这里捕获两种异常类型 // Throwable 接口:Throwable 是 PHP 7 中引入的一个顶层异常接口,它是所有异常和错误 // 的基类。Throwable 接口有两个主要的实现类:Exception 类和 Error 类 // Exception 类:Exception 是 PHP 中用于表示异常的基类,我们例子中的HttpException // 其最终的父类就是Exception return $this->handleException($passable, $e); } } ); return $pipeline($this->passable); } ``` 我们进入`handleException()`方法,分析一下异常处理的整个过程 ```php // $passable 这个参数的值是我们的请求对象app\Request Object // $e 这个就是异常处理对象 think\exception\HttpException Object protected function handleException($passable, Throwable $e) { if ($this->exceptionHandler) { // 判断是否存在$this->exceptionHandler,否则就直接抛出异常 return call_user_func($this->exceptionHandler, $passable, $e); } throw $e; } ``` `call_user_func()`这个`php`函数是把第一个参数作为回调函数使用,第二个、第三个都是这个回调函数的参数。具体使用方法大家可以看官方文档:https://www.php.net/manual/zh/function.call-user-func.php 我们主要看看`$this->exceptionHandler`这个是什么东西,你可以在程序中通过打印,你会发现它内容如下: ```php Array ( [0] => think\Middleware Object ( 省略…………), [1] => handleException ) ``` 官方文档中`call_user_func()`其中的一个例子就是调用类里面的方法,也就是说调用了`think\Middleware`里面的`handleException()`方法,我们可以进入看看这个方法的内容 ```php public function handleException($passable, Throwable $e) { // 实例化app\ExceptionHandle,为什么是app应用下的ExceptionHandle类??? // 如果你看过前面一些章节,就很容易知道原因了,不过这里我们简单的讲一下 $handler = $this->app->make(Handle::class); $handler->report($e); return $handler->render($passable, $e); } ``` 我们进入`make()`方法 ```php // $abstract Handle::class实际是think\exception\Handle public function make(string $abstract, array $vars = [], bool $newInstance = false) { // 从bind变量中获取到app\ExceptionHandle,这里为什么能获取到这个? // 在index.php中 new App()的时候加载了一个这样的文件, // if (is_file($this->appPath . 'provider.php')) { // $this->bind(include $this->appPath . 'provider.php'); // } // 我们看看这个文件的内容 // return [ // 'think\Request' => Request::class, // 'think\exception\Handle' => ExceptionHandle::class, // ]; // new App()其实就是把app目录下的ExceptionHandle类的标识保存到bind中 $abstract = $this->getAlias($abstract); if (isset($this->instances[$abstract]) && !$newInstance) { return $this->instances[$abstract]; } if (isset($this->bind[$abstract]) && $this->bind[$abstract] instanceof Closure) { $object = $this->invokeFunction($this->bind[$abstract], $vars); } else { // 利用反射机制实例化ExceptionHandle类 $object = $this->invokeClass($abstract, $vars); } if (!$newInstance) { $this->instances[$abstract] = $object; } return $object; } ``` 前面我们利用关键字`throw`抛出异常,然后使用`try……catch`捕获到异常,最后我们发现异常的处理最终来到了`app`应用的下的`ExceptionHandle.php`类,这个异常处理类我们在开发过程中一般会对其进行改造或者是自定义自己的异常处理类,以满足我们的实际需求。 ## 异常处理接管 我们先看看框架自带的异常处理类`ExceptionHandle` ```php /** * 应用异常处理类 */ class ExceptionHandle extends Handle { /** * 不需要记录信息(日志)的异常类列表 * @var array */ protected $ignoreReport = [ HttpException::class, HttpResponseException::class, ModelNotFoundException::class, DataNotFoundException::class, ValidateException::class, ]; /** * 记录异常信息(包括日志或者其它方式记录) */ public function report(Throwable $exception): void { // 使用内置的方式记录异常日志,这个我们在讲日志的时候再深入讲解 parent::report($exception); } // 最主要的还是这个方法 public function render($request, Throwable $e): Response { // 添加自定义异常处理机制 // 其他错误交给系统处理 return parent::render($request, $e); } } ``` 我们看看其父类的`render()`方法 ```php public function render(Request $request, Throwable $e): Response { $this->isJson = $request->isJson(); if ($e instanceof HttpResponseException) { return $e->getResponse(); } elseif ($e instanceof HttpException) { // 根据前面的例子,显然我们抛出的是HttpException异常,因此执行此方法 return $this->renderHttpException($e); } else { return $this->convertExceptionToResponse($e); } } ``` 我们进入`renderHttpException()`方法 ```php protected function renderHttpException(HttpException $e): Response { // 获取状态码,例子中的404 $status = $e->getStatusCode(); // 渲染的模板,这是不开启调试模式,需要渲染的模板 $template = $this->app->config->get('app.http_exception_template'); if (!$this->app->isDebug() && !empty($template[$status])) { // 不开启调试模式的情况下,执行的逻辑 return Response::create($template[$status], 'view', $status)->assign(['e' => $e]); } else { // 我们例子中执行的是这个逻辑 return $this->convertExceptionToResponse($e); } } ``` 我们进入`convertExceptionToResponse()`方法 ```php // 这里面是关于视图渲染的知识,后面会有单独的章节进行详细介绍 protected function convertExceptionToResponse(Throwable $exception): Response { if (!$this->isJson) { $response = Response::create($this->renderExceptionContent($exception)); } else { $response = Response::create($this->convertExceptionToArray($exception), 'json'); } if ($exception instanceof HttpException) { $statusCode = $exception->getStatusCode(); $response->header($exception->getHeaders()); } return $response->code($statusCode ?? 500); } protected function renderExceptionContent(Throwable $exception): string { ob_start(); $data = $this->convertExceptionToArray($exception); extract($data); // 渲染模板,如果我们要自定义模板,可以替换这个文件 include $this->app->config->get('app.exception_tmpl') ?: __DIR__ . '/../../tpl/think_exception.tpl'; return ob_get_clean(); } ``` 框架默认会把异常信息渲染到模板上,我们在调试模式下可以清楚的知道异常信息,到了生产环境,你可以自定义异常页面。 接下来给大家扩展一下,如果你的项目前端是`vue`开发,后端负责返回`json`格式的数据,比如说参数验证不通过、程序中未知异常等。 ```php {"code":500,"msg":"致命错误"} {"code":1000,"msg":"用户名不能为空"} ``` 那我们应该怎么处理成这种格式呢?其实这个也不难,一般情况下我会直接改造`ExceptionHandle`的`render()`方法 ```php public function render($request, Throwable $e): Response { // 添加自定义异常处理机制 if ($e instanceof HttpException) { return json(['code'=>$e->getStatusCode(), 'msg'=>$e->getMessage()]); } if ($e instanceof ValidateException) { return json(['code'=>$e->getCode(), 'msg'=>$e->getMessage()]); } // 更多异常 // 默认输出此信息,因此在生产环境中,你不能把真正的错误信息展示给用户看 // 一般来说我们会把真正的错误信息记录到日志里面 return json(['code'=>500, 'msg'=>"系统出错了~"]); } ``` ## 总结 本章我们学习了异常抛出、异常捕获、异常接管、异常输出整个过程的源码,其中最重要的是我们通过源码的学习,自己能否自定义异常、接管异常,这样才能说你看懂了源码,下面我给出了框架对异常处理的流程图: ![](img/04.jpg) # 日志处理 日志有多重要?相信每个开发人员都知道,它可以帮你快速定位问题,帮你“甩锅”。每个框架或系统都有自己的一套日志处理机制,本章我们就学习一下`thinkphp`中的日志处理机制,借鉴一下它的开发思路。 ## **日志类的结构关系** 我们先大概看下这张类的关系图: ![](img/02.jpg) `Log`日志类继承了`Manager`,并且通过`LoggerTrait`实现了`LoggerInterface`里面的接口。 现在我们通过这张图对日志管理类有了一定的认识,接下来我们看看各个类的作用。 我们都知道日志记录和写入由`\think\Log`类完成,通常我们使用`think\facade\Log`类进行静态调用 ,那我们主要看`\think\Log` ```php // Log类是负责整个日志管理的核心,任何其它地方使用日志管理,都是通过调用这个类来实现 class Log extends Manager implements LoggerInterface { use LoggerTrait; // 省略后面代码 } ``` 我们接口类`LoggerInterface.php` ```php interface LoggerInterface { // 用于记录系统不可用的紧急日志 public function emergency(string|\Stringable $message, array $context = []): void; // 用于记录需要立即采取行动的警报日志。 public function alert(string|\Stringable $message, array $context = []): void; // 用于记录关键条件的日志 public function critical(string|\Stringable $message, array $context = []): void; // 用于记录运行时错误的日志,不需要立即采取行动 public function error(string|\Stringable $message, array $context = []): void; // 用于记录不是错误的异常情况 public function warning(string|\Stringable $message, array $context = []): void; // 用于记录一般但重要的事件 public function notice(string|\Stringable $message, array $context = []): void; // 用于记录一般的事件 public function info(string|\Stringable $message, array $context = []): void; // 用于记录详细的调试信息 public function debug(string|\Stringable $message, array $context = []): void; // 用于记录任意级别的日志 public function log($level, string|\Stringable $message, array $context = []): void; } ``` 我们发现我们开发过程中经常用到的方法,`info`, `notice`, `warning`, `error`等都在这个接口类定义好。官方文档中提到框架的日志遵循`PSR-3` 规范,日志的级别从低到高依次为: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency` ,具体的文档可以看看它的开源文档:https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md 接下来我们看看`LoggerTrait.php` ```php trait LoggerTrait { // 省略部分代码 public function notice(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); } public function info(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); } public function debug(string|\Stringable $message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); } // 这个方法的实现在Log.php abstract public function log($level, string|\Stringable $message, array $context = []): void; } ``` 我们看到其实现了上面接口的逻辑,然后我们在`Log`类里通过`use`关键字将其引入 ```php class Log extends Manager implements LoggerInterface { // 就是这一行代码,相信很多人都没见过这种写法 use LoggerTrait; // 省略后面代码 } ``` 因为`php`是单继承的机制,也就是说`Log`继承了`Manager`类,那就不能同时继承其它类了,这时Trait就出现了,通过Trait声明的文件,然后你可以在类中添加可复用的方法和属性。Trait提供了一种在类中复用代码的机制,类似于多重继承,但比多重继承更加灵活。 最后我们看看`Manager` ```php // 驱动管理类 abstract class Manager { /** * 驱动 * @var array */ protected $drivers = []; // 省略代码 } ``` 什么是驱动?你可以理解成这样的一个东西,就是记录信息的类,比如说记录到文件,那么就使用框架提供的`think\log\driver\File`类,它就是一个驱动。 ## **日志记录的过程** 在了解了几个关键类的关系及作用后,我们来看看框架是如何将信息记录到文件的 ![](img/03.jpg) 日志记录的过程并不复杂,但这代码阅读起来,特别是调试起来是有一定难度。 ```php // Log::info() // LoggerTrait.php public function info(string|\Stringable $message, array $context = []): void { // 调用log方法,而log方法在当前类中是一个抽象方法,其实现逻辑在Log.php $this->log(LogLevel::INFO, $message, $context); } // Log.php public function log($level, $message, array $context = []): void { $this->record($message, $level, $context); } /** * 记录日志信息 * @param mixed $msg 日志信息 * @param string $type 日志级别 默认是info级别 * @param array $context 替换内容 ,其实是这张写法Log::info('日志信息{user}', ['user' => '流年']); * @param bool $lazy 表示是否延时写入 默认是true,表示在请求结束的时候写入,而不是请求进行中写入 */ public function record($msg, string $type = 'info', array $context = [], bool $lazy = true) { // 获取日志存储通道配置,我们可以看看app应用下的config目录,有个log.php配置文件,这个文件是在初始化 // new App()的时候,会加载config目录下的所有配置 // 我们可以看到'type_channel' => [],我们并没有配置其它通道,因此这里的$channel返回的是空值 $channel = $this->getConfig('type_channel.' . $type); // 实例化通道,并且记录日志 $this->channel($channel)->record($msg, $type, $context, $lazy); return $this; } ``` 我们进入`channel()` ```php // Manager.php public function channel(string|array $name = null) { if (is_array($name)) { return new ChannelSet($this, $name); } // 进入这个逻辑 return $this->driver($name); } // 获取驱动实例 protected function driver(string $name = null) { // 获取默认的通道,也就是我们配置文件log.php的'default' => 'file',就是写入到文件中 $name = $name ?: $this->getDefaultDriver(); if (is_null($name)) { throw new InvalidArgumentException(sprintf( 'Unable to resolve NULL driver for [%s].', static::class )); } return $this->drivers[$name] = $this->getDriver($name); } protected function getDriver(string $name) { // 如果不存在,就调用createDriver方法创建实例 return $this->drivers[$name] ?? $this->createDriver($name); } ``` 我们进入`createDriver()`方法,最后你会发现,程序执行的并不是这个方法,而是其子类`Log`里面的`createDriver()`,这里很多人都会踩坑。 > 在 `PHP` 中,如果子类和父类有同名方法,并且在父类中调用该方法,实际调用的是子类的方法。这就是 `PHP` 的方法重写(Override)机制。 我们进入的是子类重写的`createDriver()`方法 ```php public function createDriver(string $name) { // 调用父类即Manager里面的方法,获取实例化好的驱动,例如: // think\log\driver\File Object $driver = parent::createDriver($name); $lazy = !$this->getChannelConfig($name, "realtime_write", false) && !$this->app->runningInConsole(); $allow = array_merge($this->getConfig("level", []), $this->getChannelConfig($name, "level", [])); return new Channel($name, $driver, $allow, $lazy, $this->app->event); } ``` 我们进入`Manager`类中的`createDriver()` ```php /** * 创建驱动 * @param string $name */ protected function createDriver(string $name) { // 获取驱动类型,例如File // 这里resolveType方法不是当前类的那个方法,而是其子类Log里面的的方法 $type = $this->resolveType($name); // 声明一个创建驱动的函数名变量,Str::studly($type)下划线转驼峰,最终得到createFileDriver $method = 'create' . Str::studly($type) . 'Driver'; // 获取驱动参数,其实就是log.php里面channels配置内容 $params = $this->resolveParams($name); if (method_exists($this, $method)) { // 如果当前类存在createFileDriver这个方法,那就直接调用 return $this->$method(...$params); } // 有没有人知道上面为什么这么写? // 获取驱动类, 如果我们的通道类型是File,那么久返回这样的一个\think\cache\driver\File类, $class = $this->resolveClass($type); // 实例化驱动类,new \think\cache\driver\File() // invokeClass方法前面的章节已经讲过了,这里就不重复讲 return $this->app->invokeClass($class, $params); } ``` 我们继续回到子类`Log`的`createDriver()` ```php public function createDriver(string $name) { // 调用父类即Manager里面的方法,获取实例化好的驱动,例如: // think\log\driver\File Object $driver = parent::createDriver($name); // 是否延时写入 $lazy = !$this->getChannelConfig($name, "realtime_write", false) && !$this->app->runningInConsole(); $allow = array_merge($this->getConfig("level", []), $this->getChannelConfig($name, "level", [])); // 实例化通道,think\log\Channel Object return new Channel($name, $driver, $allow, $lazy, $this->app->event); } ``` 我们再次回到这一行代码 ```php // 获取通道,并且记录日志 $this->channel($channel)->record($msg, $type, $context, $lazy); ``` 我们获取到通道后,我们再看看`record()`方法 ```php public function record($msg, string $type = 'info', array $context = [], bool $lazy = true) { if ($this->close || (!empty($this->allow) && !in_array($type, $this->allow))) { return $this; } if ($msg instanceof Stringable) { // 如果消息是Stringable对象,则转成字符串 $msg = $msg->__toString(); } // 主要是满足这样的写法Log::info('日志信息{user}', ['user' => '流年']); if (is_string($msg) && !empty($context)) { $replace = []; foreach ($context as $key => $val) { $replace['{' . $key . '}'] = $val; } $msg = strtr($msg, $replace); } if (!empty($msg) || 0 === $msg) { $this->log[$type][] = $msg; if ($this->event) { // 发布日志记录事件,有兴趣的同学可以进入该方法看看 $this->event->trigger(new LogRecord($type, $msg)); } } // 前面已经解释过lazy了,如果为true就执行if里面的逻辑 if (!$this->lazy || !$lazy) { $this->save(); } return $this; } ``` 在分析`save()`前,我提一个问题:**上面的一些方法中,比如write()、info()、error()、record()等,哪些是实时写入的?**大家可以去看看~ 我们继续分析,接下来我们分两种情况,其实从前面的流程图也可以看出。 **当`lazy`为true时,进入`save()`方法的逻辑** ```php public function save(): bool { // 日志信息,例如Array ( [info] => Array ( [0] => 测试日志信息 ) ) $log = $this->log; if ($this->event) { $event = new LogWrite($this->name, $log); // 发布一个LogWrite日志写入事件,它的监听事件在TraceDebug.php类中的handle方法里面 $this->event->trigger($event); // 最后返回的也是日志信息 $log = $event->log; } // 调用think\log\driver\File里面的save方法保存 if ($this->logger->save($log)) { // 清空日志 $this->clear(); return true; } return false; } ``` 我们看看驱动里面的`save()`方法 ```php public function save(array $log): bool { // 日志存放的目录,例如:F:\phpstudy_pro\WWW\thinkphp8\runtime\log\202405\07.log $destination = $this->getMasterLogFile(); $path = dirname($destination); !is_dir($path) && mkdir($path, 0755, true); $info = []; // 日志信息封装, 2024-05-07T13:39:16+08:00 $time = \DateTime::createFromFormat('0.u00 U', microtime())->setTimezone(new \DateTimeZone(date_default_timezone_get()))->format($this->config['time_format']); // $log:Array ( [info] => Array ( [0] => 测试日志信息 ) ) foreach ($log as $type => $val) { $message = []; foreach ($val as $msg) { if (!is_string($msg)) { $msg = var_export($msg, true); } $message[] = $this->config['json'] ? json_encode(['time' => $time, 'type' => $type, 'msg' => $msg], $this->config['json_options']) : sprintf($this->config['format'], $time, $type, $msg); } if (true === $this->config['apart_level'] || in_array($type, $this->config['apart_level'])) { // 独立记录的日志级别 $filename = $this->getApartLevelFile($path, $type); $this->write($message, $filename); continue; } $info[$type] = $message; } // $info : Array ( [info] => Array ( [0] => [2024-05-07T13:41:53+08:00][info] 测试日志信息 ) ) if ($info) { return $this->write($info, $destination); } return true; } // 日志写入 protected function write(array $message, string $destination): bool { // 检测日志文件大小,超过配置大小则备份日志文件重新生成 $this->checkLogSize($destination); $info = []; foreach ($message as $type => $msg) { $info[$type] = is_array($msg) ? implode(PHP_EOL, $msg) : $msg; } $message = implode(PHP_EOL, $info) . PHP_EOL; // error_log是内置函数,发送错误信息到某个地方,可以查看官方文档: // https://www.php.net/manual/zh/function.error-log return error_log($message, 3, $destination); } ``` **当`lazy`为false时** 现在我们来看看当`lazy`为`false`时,不走`save()`时,那么到最后是哪里记录了日志呢?如果你有看官方文档,你会发现有这样的一段话: > 日志遵循`PSR-3`规范,除非是实时写入的日志,其它日志都是在当前请求结束的时候统一写入的 所以不要在日志写入之后使用`exit`等中断操作会导致日志写入失败。 在当前请求结束的时候统一写入,那么我们看看入口文件`index.php` ```php namespace think; require __DIR__ . '/../vendor/autoload.php'; // 省略带啊吗 // 请求结束时的处理逻辑 $http->end($response); ``` 我们进入`end()`方法 ```php public function end(Response $response): void { $this->app->event->trigger(HttpEnd::class, $response); //执行中间件 $this->app->middleware->end($response); // 写入日志 $this->app->log->save(); } public function save(): bool { /** @var Channel $channel */ foreach ($this->drivers as $channel) { // 这里最终还是调用了上面的save()方法 $channel->save(); } return true; } ``` 那有人会觉得奇怪,**如果实时写入的话,那前面写了一次,请求结束时还写一次,那不是有两条记录了吗?** 我们再看看前面的代码 ```php if ($this->logger->save($log)) { // 当我们实时写入后,会清空日志信息,那后面调用再次调用save()方法时,因为$log为空,程序就不会再次写入 $this->clear(); return true; } ``` ## 总结 本章内容主要讲了日志记录的整个流程,我觉得比较重要的是我们了解了`thinkphp`框架开发日志模块的一些思路,首先日志遵循`PSR-3` 规范,日志的级别从低到高依次为: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency` 。然后在实现接口类时,使用了关键字Trait,利用特征实现了接口,增加了架构的灵活性,最后就是引入了日志延时写入的机制,当请求结束时,才进行日志的写入,提高了接口的访问效率。 **这里为什么说引入了日志延时写入的机制,提高接口的效率呢?** 欢迎大家留言~