请选择 进入手机版 | 继续访问电脑版

 找回密码
 立即注册

QQ登录

只需一步,快速开始

查看: 3|回复: 0

[PHP] 一次 Hyperf 注解失效问题分析

[复制链接]

221

主题

0

回帖

221

积分

中级会员

积分
221
威望
0
金币
614
贡献
0
注册时间
2022-7-17
最后登录
2022-8-29
发表于 5 天前 | 显示全部楼层 |阅读模式

人人为我,我为人人。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
问题环境
  1. PHP: 8.0.13
  2. Swoole: 4.6.2
  3. Hyperf: 2.2.33
  4. 运行环境: Docker Desktop on WSL2  
复制代码


文章会持续修订,转载请注明来源地址:https://her-cat.com/posts/2023/03/02/hyperf-annotation-failure-problem-analysis/

问题背景
有同事说我之前使用注解实现的某个功能有问题,具体表现就是有部分使用了注解的类没有被 Hyperf 收集到注解收集器中,导致出现了不符合预期的结果。
由于这个功能已经运行了一段时间,并且我在自己的电脑(Mac)上测试是正常的,找另外一个跟他同样使用 Windows + Docker 开发的同事进行测试也是正常的,所以可以排除业务代码和环境的问题。
简化后的代码如下:
  1. #[Attribute(Attribute::TARGET_CLASS)]  
  2. class CustomAnnotation extends AbstractAnnotation
  3. {
  4. }  
  5.   
  6. #[CustomAnnotation]  
  7. class Foo
  8. {  
  9. }  
  10.   
  11. #[CustomAnnotation]  
  12. class Bar
  13. {  
  14. }  
复制代码

在上面的代码中,定义了一个注解类
  1. CustomAnnotation
复制代码
,并且在两个类上使用了这个注解。期望的结果是
  1. Foo
复制代码
  1. Bar
复制代码
都能够被 Hyperf 收集到注解收集器中,但实际上只有
  1. Foo
复制代码
被收集到了。

Foo 和 Bar 分别在不同的文件中,但是都在同一个目录下,该目录下的文件数量有 60+。

于是我俩开始在他的电脑上排查是不是 Hyperf 的问题。
源码分析
在 Hyperf 启动时,
  1. ClassLoader
复制代码
类加载器会扫描项目中所有的类文件,并将元数据(注解与类之间的关系)收集到相应的注解收集器中,如果没有自定义注解收集器,则默认统一收集到
  1. Hyperf\Di\Annotation\AnnotationCollector
复制代码
类中。
下面是完成收集注解的主要逻辑:
  • 使用 symfony/finder 组件提供的
    1. Finder
    复制代码
    类遍历指定目录下所有的 PHP 类文件。
  • 通过反射读取每个文件中的类及其属性、方法上使用的注解。
  • 依次检查这些注解是否实现了
    1. Hyperf\Di\Annotation\AnnotationInterface
    复制代码
    接口,该接口定义了三个方法分别用于收集类、方法、属性的元数据。
  • 如果注解实现了该接口,根据注解使用位置调用相应的方法将其收集到注解收集器中。

    完成收集后,我们就能使用注解收集器提供的静态方法的获取对应的元数据用于实现一些自定义的逻辑和功能。
    第一步就是先检查类文件是否被
    1. Finder
    复制代码
    类读取到了,这部分的逻辑在
    1. ReflectionManager::getAllClasses()
    复制代码
    静态方法中。
    1. public static function getAllClasses(array $paths): array  
    2. {  
    3.     $finder = new Finder();  
    4.     // 设置读取指定目录下的 PHP 文件
    5.     $finder-files()-in($paths)-name('*.php');  
    6.     $parser = new Ast();  
    7.   
    8.     $reflectionClasses = [];  
    9.     foreach ($finder as $file) {  
    10.         try {  
    11.                 // 解析文件内容获取类名称
    12.             $stmts = $parser-parse($file-getContents());  
    13.             if (! $className = $parser-parseClassByStmts($stmts)) {
    14.                     // 没获取到说明没有定义类
    15.                 continue;  
    16.             }
    17.             $reflectionClasses[$className] = static::reflectClass($className);  
    18.         } catch (\Throwable) {  
    19.         }   
    20.     }   
    21.     return $reflectionClasses;  
    22. }
    复制代码

    将获取目录下文件的这段代码提出来单独进行测试。由于
    1. Finder
    复制代码
    类实现了
    1. IteratorAggregate
    复制代码
    接口,所以在上面的代码中可以直接对
    1. Finder
    复制代码
    类进行遍历,也可以使用
    1. iterator_to_array()
    复制代码
    函数直接获取迭代器的结果。
    1. $finder = new Finder();  
    2. // 设置读取指定目录下的 PHP 文件
    3. $finder-files()-in('出现问题的目录路径')-name('*.php');
    4. var_dump(iterator_to_array($finder));
    复制代码

    通过观察打印的结果就发现了问题所在:没有读取到
    1. Bar
    复制代码
    的类文件。
    当时就在想,这么流行的一个组件包总不能出现这么低级的 Bug 吧?抱着怀疑的心态继续分析
    1. Finder
    复制代码
    类实现迭代器的代码,最后将问题定位到了 PHP 内置的
    1. RecursiveDirectoryIterator
    复制代码
    类上,
    1. Finder
    复制代码
    类实际上就是对 PHP 的这些类做了一层封装。
    RecursiveDirectoryIterator 提供了一个用于递归迭代文件系统目录的功能,用这个类再次进行上面的测试,依然没有读取到
    1. Bar
    复制代码
    的类文件。
    1. $iter = new RecursiveDirectoryIterator('出现问题的目录路径');
    2. var_dump(iterator_to_array($iter));
    复制代码

    于是,我又一次陷入了怀疑中,难道 PHP 实现的这个类有问题?还得继续看 PHP 的源码?我在犹豫了一会后打开了 Google,抱着肯定有人也遇到过这个问题的想法输入了「RecursiveDirectoryIterator bug」,按下回车,在短暂的页面加载后...
    嘿,还真有人已经遇到过这个问题。
    真相大白
    在前几条搜索结果中,赫然发现有人在 PHP 官方的 Bug 系统反馈了这个问题:RecursiveDirectoryIterator returns incorrect results for Docker Desktop on WSL2,并贴心的附带了可以复现问题的代码。
    下面是精简过后的复现代码。
    1. $filesPath = __DIR__.'/files';  
    2.   
    3. if (! mkdir($filesPath) && ! is_dir($filesPath)) {  
    4.     throw new \RuntimeException(sprintf('Directory "%s" was not created', 'files'));  
    5. }  
    6.   
    7. $max = 1;  
    8. $stop = 5000;  
    9.   
    10. // 生成测试文件,模拟目录中文件较多的情况  
    11. foreach(range(1, $stop) as $index) {  
    12.     $message = sprintf("creating %s\n", $index);  
    13.     echo $message;  
    14.     file_put_contents(__DIR__ . '/files/file' . $index, str_repeat('A', 100));  
    15. }  
    16.   
    17. $iter = new \RecursiveDirectoryIterator($filesPath, FilesystemIterator::KEY_AS_PATHNAME|FilesystemIterator::CURRENT_AS_FILEINFO|FilesystemIterator::SKIP_DOTS);  
    18. var_dump(iterator_count($iter));
    19. // 打印出来的数字小于 5000 说明复现成功了
    复制代码

    PHP 官方给出了回复:这是 WSL 的 Bug,并提供了相关的 issue:WSL2: Seek of directory entry by lseek does not work on v9fs。里面的实际输出跟我们发现这个问题时的打印结果几乎一模一样,感兴趣的可以去看看。
    有人可能会问,
    1. lseek()
    复制代码
    函数跟
    1. RecursiveDirectoryIterator
    复制代码
    类有什么关系吗 ?
    当然有!将上面的代码保存到 test.php 文件,然后执行
    1. strace php test.php
    复制代码
    命令查看 PHP 代码的系统调用情况。
    1. ...省略其他部分...
    2. openat(AT_FDCWD, "/home/ubuntu/files", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 4
    3. fstat(4, {st_mode=S_IFDIR|0775, st_size=135168, ...}) = 0
    4. brk(0x55d84733f000)                     = 0x55d84733f000
    5. getdents(4, /* 1024 entries */, 32768)  = 32752
    6. lseek(4, 0, SEEK_SET)                   = 0
    7. getdents(4, /* 1024 entries */, 32768)  = 32752
    8. getdents(4, /* 1024 entries */, 32768)  = 32768
    9. getdents(4, /* 1024 entries */, 32768)  = 32768
    10. getdents(4, /* 1024 entries */, 32768)  = 32768
    11. getdents(4, /* 906 entries */, 32768)   = 28992
    12. getdents(4, /* 0 entries */, 32768)     = 0
    13. write(1, "int(5000)\n", 10int(5000)
    14. )             = 10
    15. close(3)                                = 0
    16. close(4)                                = 0
    17. ...省略其他部分...
    复制代码

    可以看到,
    1. RecursiveDirectoryIterator
    复制代码
    类在底层中调用了
    1. lseek()
    复制代码
    函数,它的作用是设置文件偏移量。
    1. lseek(4, 0, SEEK_SET)
    复制代码
    表示将文件偏移量设置为 0,即文件开头的位置,该函数无法工作会导致下次操作依然使用的是原来的文件偏移量。

    Linux 中万物皆为文件,包括目录。

    用 PHP 代码来举个例子,这里使用 PHP 的
    1. rewinddir()
    复制代码
    函数代替
    1. lseek()
    复制代码
    函数,实际上底层调用的还是
    1. lseek()
    复制代码
    函数。
    1. $dh = opendir(__DIR__ . '/files');  
    2.   
    3. echo '开始读取目录中的所有文件:' . PHP_EOL;  
    4. while (($file = readdir($dh)) !== false) {  
    5.     echo 'filename:' . $file . PHP_EOL;  
    6. }
    7.   
    8. echo '再次读取目录中的所有文件:' . PHP_EOL;  
    9. // 这时文件偏移量已经到达文件的末尾,再次读取目录将不会有任何输出,模拟 lseek() 函数无法工作的情况
    10. while (($file = readdir($dh)) !== false) {  
    11.     echo 'filename:' . $file . PHP_EOL;  
    12. }  
    13.   
    14. // 将文件偏移量重置到文件的开头  
    15. rewinddir($dh);  
    16.   
    17. echo '重置偏移量后读取目录中的所有文件:' . PHP_EOL;  
    18. // 与第一次读取的结果相同,模拟 lseek() 函数正常工作的情况
    19. while (($file = readdir($dh)) !== false) {
    20.     echo 'filename:' . $file . PHP_EOL;  
    21. }  
    22.   
    23. closedir($dh);
    复制代码

    在 WSL2 以外的系统中运行以上代码,可以得到与预期一致的结果。那么在 WSL2 中运行的结果是什么?
    解决问题
    当然,最好是 WSL 官方能够修复这个问题,但是从有人提出这个问题到现在已经快三年了依然没有被解决的情况来看,不知道得等到猴年马月。
    提问的作者也给出了一种解决方案,开启 Hyper V。但是经过测试后发现开启 Hyper V 依然会出现这个问题,所以最后直接从 WSL2 回滚到 WSL1,从另一种「根本上」解决这个问题。
    总结
    等等,文章开头不是说已经排除是环境的问题了吗?怎么最后又是环境的问题了?
    是的,这是由于我当时并没有问清楚,只是确认了另一个同事是用 Docker 运行的,我怎么也没想到他是本地运行了个虚拟机,然后在虚拟机里面运行 Docker...
    当然,后面的源码分析也不是一点作用都没有,至少将问题的范围从 Hyperf 框架缩小到了
    1. Finder
    复制代码
    类,再到
    1. RecursiveDirectoryIterator
    复制代码
    类。否则直接 Google 搜索「Hyperf 注解失效」是很难找到正确答案的。
    在这篇文章中,讲述了我排查「Hyperf 注解失效」问题的过程,整个排查过程看似一气呵成,但实际上要曲折得多,甚至一度觉得这是个玄学问题。
    最后,没有 Bug 的程序是不存在的,不要过度迷信那些看似很可靠的系统。
  • 我爱编程论坛www.woaibiancheng.cn
    回复

    使用道具 举报

    侵权举报|手机版|我爱编程论坛 ( 蜀ICP备2022018035号-1 )

    GMT+8, 2023-3-23 00:48

    Powered by Discuz! X3.5

    © 2001-2023 Discuz! Team.

    快速回复 返回顶部 返回列表