草庐IT

一次 Hyperf 注解失效问题分析

她和她的猫 2023-03-28 原文

问题环境

PHP: 8.0.13
Swoole: 4.6.2
Hyperf: 2.2.33
运行环境: Docker Desktop on WSL2  

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

问题背景

有同事说我之前使用注解实现的某个功能有问题,具体表现就是有部分使用了注解的类没有被 Hyperf 收集到注解收集器中,导致出现了不符合预期的结果。

由于这个功能已经运行了一段时间,并且我在自己的电脑(Mac)上测试是正常的,找另外一个跟他同样使用 Windows + Docker 开发的同事进行测试也是正常的,所以可以排除业务代码和环境的问题。

简化后的代码如下:

#[Attribute(Attribute::TARGET_CLASS)]  
class CustomAnnotation extends AbstractAnnotation
{
}  
  
#[CustomAnnotation]  
class Foo
{  
}  
  
#[CustomAnnotation]  
class Bar
{  
}  

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

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

于是我俩开始在他的电脑上排查是不是 Hyperf 的问题。

源码分析

在 Hyperf 启动时, ClassLoader 类加载器会扫描项目中所有的类文件,并将元数据(注解与类之间的关系)收集到相应的注解收集器中,如果没有自定义注解收集器,则默认统一收集到 Hyperf\Di\Annotation\AnnotationCollector 类中。

下面是完成收集注解的主要逻辑:

  • 使用 symfony/finder 组件提供的 Finder 类遍历指定目录下所有的 PHP 类文件。
  • 通过反射读取每个文件中的类及其属性、方法上使用的注解。
  • 依次检查这些注解是否实现了 Hyperf\Di\Annotation\AnnotationInterface 接口,该接口定义了三个方法分别用于收集类、方法、属性的元数据。
  • 如果注解实现了该接口,根据注解使用位置调用相应的方法将其收集到注解收集器中。

完成收集后,我们就能使用注解收集器提供的静态方法的获取对应的元数据用于实现一些自定义的逻辑和功能。

第一步就是先检查类文件是否被 Finder 类读取到了,这部分的逻辑在 ReflectionManager::getAllClasses() 静态方法中。

public static function getAllClasses(array $paths): array  
{  
    $finder = new Finder();  
    // 设置读取指定目录下的 PHP 文件
    $finder->files()->in($paths)->name('*.php');  
    $parser = new Ast();  
  
    $reflectionClasses = [];  
    foreach ($finder as $file) {  
        try {  
	        // 解析文件内容获取类名称
            $stmts = $parser->parse($file->getContents());  
            if (! $className = $parser->parseClassByStmts($stmts)) {
	            // 没获取到说明没有定义类
                continue;  
            }
            $reflectionClasses[$className] = static::reflectClass($className);  
        } catch (\Throwable) {  
        }    
    }    
    return $reflectionClasses;  
}

将获取目录下文件的这段代码提出来单独进行测试。由于 Finder 类实现了 IteratorAggregate 接口,所以在上面的代码中可以直接对 Finder 类进行遍历,也可以使用 iterator_to_array() 函数直接获取迭代器的结果。

$finder = new Finder();  
// 设置读取指定目录下的 PHP 文件
$finder->files()->in('出现问题的目录路径')->name('*.php'); 
var_dump(iterator_to_array($finder));

通过观察打印的结果就发现了问题所在:没有读取到 Bar 的类文件。

当时就在想,这么流行的一个组件包总不能出现这么低级的 Bug 吧?抱着怀疑的心态继续分析 Finder 类实现迭代器的代码,最后将问题定位到了 PHP 内置的 RecursiveDirectoryIterator 类上,Finder 类实际上就是对 PHP 的这些类做了一层封装。

RecursiveDirectoryIterator 提供了一个用于递归迭代文件系统目录的功能,用这个类再次进行上面的测试,依然没有读取到 Bar 的类文件。

$iter = new RecursiveDirectoryIterator('出现问题的目录路径');
var_dump(iterator_to_array($iter));

于是,我又一次陷入了怀疑中,难道 PHP 实现的这个类有问题?还得继续看 PHP 的源码?我在犹豫了一会后打开了 Google,抱着肯定有人也遇到过这个问题的想法输入了「RecursiveDirectoryIterator bug」,按下回车,在短暂的页面加载后...

嘿,还真有人已经遇到过这个问题。

真相大白

在前几条搜索结果中,赫然发现有人在 PHP 官方的 Bug 系统反馈了这个问题:RecursiveDirectoryIterator returns incorrect results for Docker Desktop on WSL2,并贴心的附带了可以复现问题的代码。

下面是精简过后的复现代码。

$filesPath = __DIR__.'/files';  
  
if (! mkdir($filesPath) && ! is_dir($filesPath)) {  
    throw new \RuntimeException(sprintf('Directory "%s" was not created', 'files'));  
}  
  
$max = 1;  
$stop = 5000;  
  
// 生成测试文件,模拟目录中文件较多的情况  
foreach(range(1, $stop) as $index) {  
    $message = sprintf("creating %s\n", $index);  
    echo $message;  
    file_put_contents(__DIR__ . '/files/file' . $index, str_repeat('A', 100));  
}  
  
$iter = new \RecursiveDirectoryIterator($filesPath, FilesystemIterator::KEY_AS_PATHNAME|FilesystemIterator::CURRENT_AS_FILEINFO|FilesystemIterator::SKIP_DOTS);  
var_dump(iterator_count($iter));
// 打印出来的数字小于 5000 说明复现成功了

PHP 官方给出了回复:这是 WSL 的 Bug,并提供了相关的 issue:WSL2: Seek of directory entry by lseek does not work on v9fs。里面的实际输出跟我们发现这个问题时的打印结果几乎一模一样,感兴趣的可以去看看。

有人可能会问,lseek() 函数跟 RecursiveDirectoryIterator 类有什么关系吗 ?

当然有!将上面的代码保存到 test.php 文件,然后执行 strace php test.php 命令查看 PHP 代码的系统调用情况。

...省略其他部分...
openat(AT_FDCWD, "/home/ubuntu/files", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 4
fstat(4, {st_mode=S_IFDIR|0775, st_size=135168, ...}) = 0
brk(0x55d84733f000)                     = 0x55d84733f000
getdents(4, /* 1024 entries */, 32768)  = 32752
lseek(4, 0, SEEK_SET)                   = 0
getdents(4, /* 1024 entries */, 32768)  = 32752
getdents(4, /* 1024 entries */, 32768)  = 32768
getdents(4, /* 1024 entries */, 32768)  = 32768
getdents(4, /* 1024 entries */, 32768)  = 32768
getdents(4, /* 906 entries */, 32768)   = 28992
getdents(4, /* 0 entries */, 32768)     = 0
write(1, "int(5000)\n", 10int(5000)
)             = 10
close(3)                                = 0
close(4)                                = 0
...省略其他部分...

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

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

用 PHP 代码来举个例子,这里使用 PHP 的 rewinddir() 函数代替 lseek() 函数,实际上底层调用的还是 lseek() 函数。

$dh = opendir(__DIR__ . '/files');  
  
echo '开始读取目录中的所有文件:' . PHP_EOL;  
while (($file = readdir($dh)) !== false) {  
    echo 'filename:' . $file . PHP_EOL;  
}
  
echo '再次读取目录中的所有文件:' . PHP_EOL;  
// 这时文件偏移量已经到达文件的末尾,再次读取目录将不会有任何输出,模拟 lseek() 函数无法工作的情况 
while (($file = readdir($dh)) !== false) {  
    echo 'filename:' . $file . PHP_EOL;  
}  
  
// 将文件偏移量重置到文件的开头  
rewinddir($dh);  
  
echo '重置偏移量后读取目录中的所有文件:' . PHP_EOL;  
// 与第一次读取的结果相同,模拟 lseek() 函数正常工作的情况
while (($file = readdir($dh)) !== false) {
    echo 'filename:' . $file . PHP_EOL;  
}  
  
closedir($dh);

在 WSL2 以外的系统中运行以上代码,可以得到与预期一致的结果。那么在 WSL2 中运行的结果是什么?

解决问题

当然,最好是 WSL 官方能够修复这个问题,但是从有人提出这个问题到现在已经快三年了依然没有被解决的情况来看,不知道得等到猴年马月。

提问的作者也给出了一种解决方案,开启 Hyper V。但是经过测试后发现开启 Hyper V 依然会出现这个问题,所以最后直接从 WSL2 回滚到 WSL1,从另一种「根本上」解决这个问题。

总结

等等,文章开头不是说已经排除是环境的问题了吗?怎么最后又是环境的问题了?

是的,这是由于我当时并没有问清楚,只是确认了另一个同事是用 Docker 运行的,我怎么也没想到他是本地运行了个虚拟机,然后在虚拟机里面运行 Docker...

当然,后面的源码分析也不是一点作用都没有,至少将问题的范围从 Hyperf 框架缩小到了 Finder 类,再到 RecursiveDirectoryIterator 类。否则直接 Google 搜索「Hyperf 注解失效」是很难找到正确答案的。

在这篇文章中,讲述了我排查「Hyperf 注解失效」问题的过程,整个排查过程看似一气呵成,但实际上要曲折得多,甚至一度觉得这是个玄学问题。

最后,没有 Bug 的程序是不存在的,不要过度迷信那些看似很可靠的系统。

有关一次 Hyperf 注解失效问题分析的更多相关文章

  1. ruby - 在 64 位 Snow Leopard 上使用 rvm、postgres 9.0、ruby 1.9.2-p136 安装 pg gem 时出现问题 - 2

    我想为Heroku构建一个Rails3应用程序。他们使用Postgres作为他们的数据库,所以我通过MacPorts安装了postgres9.0。现在我需要一个postgresgem并且共识是出于性能原因你想要pggem。但是我对我得到的错误感到非常困惑当我尝试在rvm下通过geminstall安装pg时。我已经非常明确地指定了所有postgres目录的位置可以找到但仍然无法完成安装:$envARCHFLAGS='-archx86_64'geminstallpg--\--with-pg-config=/opt/local/var/db/postgresql90/defaultdb/po

  2. ruby - 通过 rvm 升级 ruby​​gems 的问题 - 2

    尝试通过RVM将RubyGems升级到版本1.8.10并出现此错误:$rvmrubygemslatestRemovingoldRubygemsfiles...Installingrubygems-1.8.10forruby-1.9.2-p180...ERROR:Errorrunning'GEM_PATH="/Users/foo/.rvm/gems/ruby-1.9.2-p180:/Users/foo/.rvm/gems/ruby-1.9.2-p180@global:/Users/foo/.rvm/gems/ruby-1.9.2-p180:/Users/foo/.rvm/gems/rub

  3. ruby - 使用 Vim Rails,您可以创建一个新的迁移文件并一次性打开它吗? - 2

    使用带有Rails插件的vim,您可以创建一个迁移文件,然后一次性打开该文件吗?textmate也可以这样吗? 最佳答案 你可以使用rails.vim然后做类似的事情::Rgeneratemigratonadd_foo_to_bar插件将打开迁移生成的文件,这正是您想要的。我不能代表textmate。 关于ruby-使用VimRails,您可以创建一个新的迁移文件并一次性打开它吗?,我们在StackOverflow上找到一个类似的问题: https://sta

  4. ruby - 如何每月在 Heroku 运行一次 Scheduler 插件? - 2

    在选择我想要运行操作的频率时,唯一的选项是“每天”、“每小时”和“每10分钟”。谢谢!我想为我的Rails3.1应用程序运行调度程序。 最佳答案 这不是一个优雅的解决方案,但您可以安排它每天运行,并在实际开始工作之前检查日期是否为当月的第一天。 关于ruby-如何每月在Heroku运行一次Scheduler插件?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/8692687/

  5. ruby - 通过 RVM (OSX Mountain Lion) 安装 Ruby 2.0.0-p247 时遇到问题 - 2

    我的最终目标是安装当前版本的RubyonRails。我在OSXMountainLion上运行。到目前为止,这是我的过程:已安装的RVM$\curl-Lhttps://get.rvm.io|bash-sstable检查已知(我假设已批准)安装$rvmlistknown我看到当前的稳定版本可用[ruby-]2.0.0[-p247]输入命令安装$rvminstall2.0.0-p247注意:我也试过这些安装命令$rvminstallruby-2.0.0-p247$rvminstallruby=2.0.0-p247我很快就无处可去了。结果:$rvminstall2.0.0-p247Search

  6. ruby - Fast-stemmer 安装问题 - 2

    由于fast-stemmer的问题,我很难安装我想要的任何ruby​​gem。我把我得到的错误放在下面。Buildingnativeextensions.Thiscouldtakeawhile...ERROR:Errorinstallingfast-stemmer:ERROR:Failedtobuildgemnativeextension./System/Library/Frameworks/Ruby.framework/Versions/2.0/usr/bin/rubyextconf.rbcreatingMakefilemake"DESTDIR="cleanmake"DESTDIR=

  7. ruby - 安装 Ruby 时遇到问题(无法下载资源 "readline--patch") - 2

    当我尝试安装Ruby时遇到此错误。我试过查看this和this但无济于事➜~brewinstallrubyWarning:YouareusingOSX10.12.Wedonotprovidesupportforthispre-releaseversion.Youmayencounterbuildfailuresorotherbreakages.Pleasecreatepull-requestsinsteadoffilingissues.==>Installingdependenciesforruby:readline,libyaml,makedepend==>Installingrub

  8. java - 从 JRuby 调用 Java 类的问题 - 2

    我正在尝试使用boilerpipe来自JRuby。我看过guide从JRuby调用Java,并成功地将它与另一个Java包一起使用,但无法弄清楚为什么同样的东西不能用于boilerpipe。我正在尝试基本上从JRuby中执行与此Java等效的操作:URLurl=newURL("http://www.example.com/some-location/index.html");Stringtext=ArticleExtractor.INSTANCE.getText(url);在JRuby中试过这个:require'java'url=java.net.URL.new("http://www

  9. ruby-on-rails - 简单的 Ruby on Rails 问题——如何将评论附加到用户和文章? - 2

    我意识到这可能是一个非常基本的问题,但我现在已经花了几天时间回过头来解决这个问题,但出于某种原因,Google就是没有帮助我。(我认为部分问题在于我是一个初学者,我不知道该问什么......)我也看过O'Reilly的RubyCookbook和RailsAPI,但我仍然停留在这个问题上.我找到了一些关于多态关系的信息,但它似乎不是我需要的(尽管如果我错了请告诉我)。我正在尝试调整MichaelHartl'stutorial创建一个包含用户、文章和评论的博客应用程序(不使用脚手架)。我希望评论既属于用户又属于文章。我的主要问题是:我不知道如何将当前文章的ID放入评论Controller。

  10. 【高数】用拉格朗日中值定理解决极限问题 - 2

    首先回顾一下拉格朗日定理的内容:函数f(x)是在闭区间[a,b]上连续、开区间(a,b)上可导的函数,那么至少存在一个,使得:通过这个表达式我们可以知道,f(x)是函数的主体,a和b可以看作是主体函数f(x)中所取的两个值。那么可以有,  也就意味着我们可以用来替换 这种替换可以用在求某些多项式差的极限中。方法: 外层函数f(x)是一致的,并且h(x)和g(x)是等价无穷小。此时,利用拉格朗日定理,将原式替换为 ,再进行求解,往往会省去复合函数求极限的很多麻烦。使用要注意:1.要先找到主体函数f(x),即外层函数必须相同。2.f(x)找到后,复合部分是等价无穷小。3.要满足作差的形式。如果是加

随机推荐