diff --git a/Scanner.php b/Scanner.php index d86ca0b..906fd77 100644 --- a/Scanner.php +++ b/Scanner.php @@ -2,7 +2,6 @@ declare(strict_types=1); - namespace Kiri\Di; use Kiri\Di\Inject\Container; @@ -11,145 +10,515 @@ use Kiri\Di\Inject\Skip; use Psr\Container\ContainerInterface; use ReflectionClass; use ReflectionMethod; +use DirectoryIterator; +use Throwable; class Scanner extends Component { + #[Container(ContainerInterface::class)] + public ContainerInterface $container; + /** + * @var array 已处理的文件路径列表 + */ + public array $files = []; - /** - * @var ContainerInterface - */ - #[Container(ContainerInterface::class)] - public ContainerInterface $container; + /** + * @var array 文件修改时间缓存 + */ + private array $fileMtimes = []; + /** + * @var bool|null OPcache可用性缓存 + */ + private ?bool $hasOpcache = null; - /** - * @var array - */ - public array $files = []; + /** + * @var array 配置选项 + */ + private array $config + = [ + 'skip_patterns' => ['/vendor/', '/tests/', '/cache/', '/node_modules/'], + 'skip_directories' => [], + 'extensions' => ['php'], + 'max_depth' => 20, + 'follow_links' => false, + 'cache_enabled' => true, + 'cache_ttl' => 3600, + 'debug' => false, + ]; + /** + * 设置扫描器配置 + */ + public function setConfig(array $config): void + { + $this->config = array_merge($this->config, $config); + } - /** - * @param string $path - * @return void - * @throws - */ - public function load_directory(string $path): void - { - $dir = new \DirectoryIterator($path); - $skip = \config('site.scanner.skip', []); - foreach ($dir as $value) { - if ($value->isDot() || str_starts_with($value->getFilename(), '.')) { - continue; - } - if ($value->isDir()) { - if (in_array($value->getRealPath() . '/', $skip)) { - continue; - } - $this->load_directory($value->getRealPath()); - } else if ($value->getExtension() == 'php') { - if (in_array($value->getRealPath(), $this->files)) { - continue; - } - $this->files[] = $value->getRealPath(); - $this->load_file($value->getRealPath()); - } - } - } + /** + * 主扫描方法,支持缓存 + */ + public function scan(string $path, ?string $cacheFile = null): void + { + if ($this->config['cache_enabled']) { + $cacheFile = $cacheFile ?? $this->getDefaultCacheFile(); + if ($this->loadFromCache($cacheFile, $path)) { + $this->processCachedFiles(); + return; + } + } + $this->scanDirectory($path); - /** - * @param string $file - * @return string - */ - private function rename(string $file): string - { - $filter = array_filter(explode('/', $file), function ($value) { - if (empty($value)) { - return false; - } - return ucfirst($value); - }); - return ucfirst(implode('\\', $filter)); - } + if ($this->config['cache_enabled']) { + $this->saveToCache($cacheFile); + } + } + /** + * 扫描目录(替换原有的load_directory) + */ + private function scanDirectory(string $path, int $depth = 0): void + { + if ($depth > $this->config['max_depth']) { + return; + } - /** - * @param string $path - * @return void - * @throws - */ - private function load_file(string $path): void - { - try { - if (function_exists('opcache_invalidate') && function_exists('opcache_compile_file')) { - opcache_invalidate($path); - opcache_compile_file($path); - } + try { + $dir = new DirectoryIterator($path); + } catch (Throwable $e) { + $this->logError($e, ['path' => $path, 'action' => 'open_directory']); + return; + } - require_once "$path"; - if (!isset($_SERVER['PWD'])) { - $_SERVER['PWD'] = APP_PATH; - } - $path = str_replace($_SERVER['PWD'], '', $path); - $path = str_replace('.php', '', $path); - $this->parseFile($path); - } catch (\Throwable $throwable) { - error($throwable); - } - } + foreach ($dir as $item) { + if ($this->shouldSkipItem($item)) { + continue; + } + $realPath = $item->getRealPath(); - /** - * @param $file - * @return void - * @throws - */ - protected function parseFile($file): void - { - $class = $this->rename($file); - if (class_exists($class)) { - $reflect = $this->container->getReflectionClass($class); - if ($reflect->isInstantiable()) { - if ($reflect->isTrait() || $reflect->isEnum() || $reflect->isInterface()) { - return; - } - $attributes = $this->skipNames($reflect); - if (in_array(Skip::class, $attributes) || in_array(\Attribute::class, $attributes)) { - return; - } - foreach ($reflect->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { - if ($method->isStatic() || $method->getDeclaringClass()->getName() != $class) { - continue; - } - $attributes = $method->getAttributes(); - foreach ($attributes as $attribute) { - if (!class_exists($attribute->getName())) { - continue; - } - $instance = $attribute->newInstance(); - if (!method_exists($instance, 'dispatch')) { - continue; - } - $instance->dispatch($class, $method->getName()); - } - } - } - } - } + if ($item->isDir()) { + if ($this->shouldSkipDirectory($realPath)) { + continue; + } + // 如果是符号链接且不跟随链接,则跳过 + if ($item->isLink() && !$this->config['follow_links']) { + continue; + } - /** - * @param ReflectionClass $reflect - * @return array - */ - protected function skipNames(ReflectionClass $reflect): array - { - $attributes = $reflect->getAttributes(); - $names = []; - foreach ($attributes as $attribute) { - $names[] = $attribute->getName(); - } - return $names; - } + $this->scanDirectory($realPath, $depth + 1); + } elseif ($item->isFile()) { + $this->processFile($realPath); + } + } + } + + /** + * 判断是否应该跳过该目录项 + */ + private function shouldSkipItem(DirectoryIterator $item): bool + { + return $item->isDot() || str_starts_with($item->getFilename(), '.'); + } + + /** + * 判断是否应该跳过目录 + */ + private function shouldSkipDirectory(string $path): bool + { + $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + + // 检查配置文件中的跳过目录 + $skipDirs = array_merge( + $this->config['skip_directories'], + config('site.scanner.skip', []) + ); + + if (in_array($path, $skipDirs)) { + return true; + } + + // 检查跳过模式 + foreach ($this->config['skip_patterns'] as $pattern) { + if (strpos($path, $pattern) !== false) { + return true; + } + } + + return false; + } + + /** + * 处理单个文件 + */ + private function processFile(string $path): void + { + // 检查文件扩展名 + if (!in_array(pathinfo($path, PATHINFO_EXTENSION), $this->config['extensions'])) { + return; + } + + // 检查是否已处理 + if (in_array($path, $this->files)) { + return; + } + + // 检查文件是否需要重新处理(基于修改时间) + if (!$this->shouldProcessFile($path)) { + return; + } + + $this->files[] = $path; + + try { + $this->loadAndParseFile($path); + } catch (Throwable $e) { + $this->logError($e, [ + 'file' => $path, + 'action' => 'process_file' + ]); + + // 如果是语法错误,可以选择抛出或记录 + if ($e instanceof \ParseError) { + throw $e; + } + } + } + + /** + * 判断文件是否需要处理 + */ + private function shouldProcessFile(string $path): bool + { + if (!file_exists($path)) { + return false; + } + + $mtime = filemtime($path); + + // 如果没有缓存过修改时间,或者文件已更新,则需要处理 + if (!isset($this->fileMtimes[$path]) || $this->fileMtimes[$path] < $mtime) { + $this->fileMtimes[$path] = $mtime; + return true; + } + + return false; + } + + /** + * 加载并解析文件 + */ + private function loadAndParseFile(string $path): void + { + // OPcache优化 + $this->optimizeWithOpcache($path); + + // 加载文件 + require_once $path; + + // 尝试从文件内容提取类名(更准确) + $class = $this->extractClassNameFromFile($path); + + if ($class && class_exists($class)) { + $this->analyzeClass($class); + } + } + + /** + * 使用OPcache优化文件 + */ + private function optimizeWithOpcache(string $path): void + { + if ($this->hasOpcache === null) { + $this->hasOpcache = function_exists('opcache_invalidate') + && function_exists('opcache_compile_file'); + } + + if ($this->hasOpcache) { + try { + opcache_invalidate($path, true); + opcache_compile_file($path); + } catch (Throwable $e) { + // 静默处理OPcache错误,不影响主要功能 + if ($this->config['debug']) { + $this->logError($e, [ + 'file' => $path, + 'action' => 'opcache_optimize' + ]); + } + } + } + } + + /** + * 从文件内容提取完整的类名(命名空间+类名) + */ + private function extractClassNameFromFile(string $path): ?string + { + $content = @file_get_contents($path); + if (!$content) { + return null; + } + + $tokens = token_get_all($content); + $namespace = ''; + $class = ''; + $classToken = false; + + foreach ($tokens as $token) { + if (is_array($token)) { + if ($token[0] === T_NAMESPACE) { + $namespace = ''; + $collecting = true; + } elseif ($collecting && $token[0] === T_STRING) { + $namespace .= $token[1]; + } elseif ($collecting && $token[0] === T_NS_SEPARATOR) { + $namespace .= '\\'; + } elseif ($collecting && $token[0] === T_WHITESPACE) { + // 忽略空白 + } else { + $collecting = false; + } + + if ($token[0] === T_CLASS || $token[0] === T_INTERFACE || $token[0] === T_TRAIT) { + $classToken = true; + } elseif ($classToken && $token[0] === T_STRING) { + $class = $token[1]; + break; + } + } else { + if ($token === ';' || $token === '{') { + $collecting = false; + } + } + } + + if (!$class) { + return null; + } + + return $namespace ? $namespace . '\\' . $class : $class; + } + + /** + * 分析类及其方法 + */ + private function analyzeClass(string $class): void + { + try { + $reflect = $this->container->getReflectionClass($class); + + if (!$reflect->isInstantiable() || + $reflect->isTrait() || + $reflect->isEnum() || + $reflect->isInterface()) { + return; + } + + // 检查跳过注解 + if ($this->shouldSkipClass($reflect)) { + return; + } + + // 分析类的方法 + $this->analyzeClassMethods($reflect, $class); + + } catch (Throwable $e) { + $this->logError($e, [ + 'class' => $class, + 'action' => 'analyze_class' + ]); + } + } + + /** + * 判断是否应该跳过该类 + */ + private function shouldSkipClass(ReflectionClass $reflect): bool + { + $attributes = array_map( + fn($attr) => $attr->getName(), + $reflect->getAttributes() + ); + + return in_array(Skip::class, $attributes, true) || + in_array(\Attribute::class, $attributes, true); + } + + /** + * 分析类的方法 + */ + private function analyzeClassMethods(ReflectionClass $reflect, string $class): void + { + foreach ($reflect->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + // 跳过静态方法和继承的方法 + if ($method->isStatic() || $method->getDeclaringClass()->getName() !== $class) { + continue; + } + + $this->processMethodAttributes($method, $class); + } + } + + /** + * 处理方法上的注解 + */ + private function processMethodAttributes(ReflectionMethod $method, string $class): void + { + foreach ($method->getAttributes() as $attribute) { + $attributeName = $attribute->getName(); + + if (!class_exists($attributeName)) { + continue; + } + + try { + $instance = $attribute->newInstance(); + + if (method_exists($instance, 'dispatch')) { + $instance->dispatch($class, $method->getName()); + } + } catch (Throwable $e) { + $this->logError($e, [ + 'class' => $class, + 'method' => $method->getName(), + 'attribute' => $attributeName, + 'action' => 'process_attribute' + ]); + } + } + } + + /** + * 缓存相关方法 + */ + private function getDefaultCacheFile(): string + { + $cacheDir = sys_get_temp_dir() . '/kiri-scanner-cache/'; + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0755, true); + } + + $projectHash = md5(__DIR__); + return $cacheDir . 'scanner_' . $projectHash . '.json'; + } + + /** + * 从缓存加载 + */ + private function loadFromCache(string $cacheFile, string $path): bool + { + if (!file_exists($cacheFile)) { + return false; + } + + // 检查缓存是否过期 + if (filemtime($cacheFile) + $this->config['cache_ttl'] < time()) { + return false; + } + + // 检查目录是否有更新 + if ($this->hasDirectoryChanged($path, $cacheFile)) { + return false; + } + + $data = json_decode(file_get_contents($cacheFile), true); + if (!is_array($data) || !isset($data['files'], $data['mtimes'])) { + return false; + } + + $this->files = $data['files']; + $this->fileMtimes = $data['mtimes']; + + return true; + } + + /** + * 检查目录是否有更新 + */ + private function hasDirectoryChanged(string $path, string $cacheFile): bool + { + $cacheMtime = filemtime($cacheFile); + + // 简单的实现:检查目录本身和一级子目录 + $dirIterator = new DirectoryIterator($path); + foreach ($dirIterator as $item) { + if ($item->isDot()) { + continue; + } + + $itemPath = $item->getRealPath(); + if (filemtime($itemPath) > $cacheMtime) { + return true; + } + } + + return false; + } + + /** + * 保存到缓存 + */ + private function saveToCache(string $cacheFile): void + { + $data = [ + 'files' => $this->files, + 'mtimes' => $this->fileMtimes, + 'timestamp' => time(), + 'version' => '1.0' + ]; + + file_put_contents($cacheFile, json_encode($data, JSON_PRETTY_PRINT)); + + // 清理旧缓存文件 + $this->cleanupOldCacheFiles(dirname($cacheFile)); + } + + /** + * 清理旧的缓存文件 + */ + private function cleanupOldCacheFiles(string $cacheDir): void + { + $files = glob($cacheDir . '/scanner_*.json'); + $now = time(); + + foreach ($files as $file) { + if (filemtime($file) + (7 * 24 * 3600) < $now) { // 清理7天前的缓存 + unlink($file); + } + } + } + + /** + * 处理缓存的文件(批量处理,避免重复加载) + */ + private function processCachedFiles(): void + { + foreach ($this->files as $file) { + if ($this->shouldProcessFile($file)) { + $this->loadAndParseFile($file); + } + } + } + + /** + * 错误记录方法 + */ + private function logError(Throwable $e, array $context = []): void + { + json_log($e, $context); + } + + /** + * 重置扫描器状态 + */ + public function reset(): void + { + $this->files = []; + $this->fileMtimes = []; + $this->hasOpcache = null; + } }