Files
kiri-container/Scanner.php
T

608 lines
14 KiB
PHP
Raw Normal View History

2023-04-16 16:40:44 +08:00
<?php
declare(strict_types=1);
namespace Kiri\Di;
2026-04-17 16:30:52 +08:00
use DirectoryIterator;
2023-04-16 16:40:44 +08:00
use Kiri\Abstracts\Component;
2026-04-17 16:30:52 +08:00
use Kiri\Di\Inject\Container;
2023-04-24 22:31:08 +08:00
use Kiri\Di\Inject\Skip;
2026-06-12 23:57:19 +08:00
use Kiri\Di\Interface\InjectMethodInterface;
2023-12-12 10:56:43 +08:00
use Psr\Container\ContainerInterface;
2023-12-18 16:28:00 +08:00
use ReflectionClass;
2023-08-03 14:08:16 +08:00
use ReflectionMethod;
2025-12-31 00:19:29 +08:00
use Throwable;
2023-04-16 16:40:44 +08:00
class Scanner extends Component
{
2025-12-31 00:19:29 +08:00
#[Container(ContainerInterface::class)]
public ContainerInterface $container;
public array $files = [];
2026-04-17 16:30:52 +08:00
private array $fileMtimes = [];
private ?bool $hasOpcache = null;
2025-12-31 01:50:09 +08:00
private string $basePath;
2025-12-31 00:19:29 +08:00
2026-04-17 16:30:52 +08:00
private ScanManifest $manifest;
private ?ChangeSet $changeSet = null;
private array $visitedFiles = [];
2025-12-31 01:07:57 +08:00
private array $config = [
2026-04-17 16:30:52 +08:00
'skip_patterns' => ['/vendor/', '/tests/', '/cache/', '/node_modules/'],
'skip_directories' => [],
'extensions' => ['php'],
'max_depth' => 20,
'follow_links' => false,
'cache_enabled' => false,
'cache_ttl' => 3600,
'debug' => false,
2025-12-31 01:07:57 +08:00
];
2025-12-31 00:19:29 +08:00
2025-12-31 01:50:09 +08:00
public function __construct()
{
2026-04-17 16:30:52 +08:00
$this->basePath = $this->normalizePath($_SERVER['PWD'] ?? APP_PATH ?? getcwd());
$this->manifest = new ScanManifest();
2025-12-31 01:50:09 +08:00
}
2025-12-31 00:19:29 +08:00
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
2026-04-17 16:30:52 +08:00
public function scan(string $path, ?string $cacheFile = null): ChangeSet
2025-12-31 00:19:29 +08:00
{
2026-04-17 16:30:52 +08:00
$path = $this->normalizePath($path);
$this->changeSet = new ChangeSet();
$this->visitedFiles = [];
$cacheLoaded = false;
2025-12-31 00:19:29 +08:00
if ($this->config['cache_enabled']) {
$cacheFile = $cacheFile ?? $this->getDefaultCacheFile();
if ($this->loadFromCache($cacheFile, $path)) {
2026-04-17 16:30:52 +08:00
$this->replayManifest($path);
return $this->changeSet;
2025-12-31 00:19:29 +08:00
}
2026-04-17 16:30:52 +08:00
$cacheLoaded = $this->manifest->all() !== [];
2025-12-31 00:19:29 +08:00
}
$this->scanDirectory($path);
2026-04-17 16:30:52 +08:00
$this->detectRemovedFiles($path);
if ($cacheLoaded) {
$this->replayManifest($path, $this->changeSet->getChangedFiles());
}
$this->syncLegacyState();
2025-12-31 00:19:29 +08:00
if ($this->config['cache_enabled']) {
$this->saveToCache($cacheFile);
}
2026-04-17 16:30:52 +08:00
return $this->changeSet;
}
public function scanFiles(array $files, ?string $scopePath = null, ?string $cacheFile = null): ChangeSet
{
$this->changeSet = new ChangeSet();
$this->visitedFiles = [];
$cacheLoaded = false;
if ($scopePath !== null) {
$scopePath = $this->normalizePath($scopePath);
}
if ($this->config['cache_enabled'] && $scopePath !== null) {
$cacheFile = $cacheFile ?? $this->getDefaultCacheFile();
$this->loadFromCache($cacheFile, $scopePath);
$cacheLoaded = $this->manifest->all() !== [];
}
foreach (array_values(array_unique($files)) as $file) {
$path = $this->normalizePath($file);
if (file_exists($path)) {
$this->processFile($path);
continue;
}
$this->markRemovedFile($path);
}
if ($cacheLoaded && $scopePath !== null) {
$this->replayManifest($scopePath, $this->changeSet->getChangedFiles());
}
$this->syncLegacyState();
return $this->changeSet;
2025-12-31 00:19:29 +08:00
}
private function scanDirectory(string $path, int $depth = 0): void
{
if ($depth > $this->config['max_depth']) {
return;
}
try {
$dir = new DirectoryIterator($path);
} catch (Throwable $e) {
$this->logError($e, ['path' => $path, 'action' => 'open_directory']);
return;
}
foreach ($dir as $item) {
if ($this->shouldSkipItem($item)) {
continue;
}
2026-04-17 16:30:52 +08:00
$realPath = $this->normalizePath($item->getRealPath() ?: $item->getPathname());
2025-12-31 00:19:29 +08:00
if ($item->isDir()) {
if ($this->shouldSkipDirectory($realPath)) {
continue;
}
if ($item->isLink() && !$this->config['follow_links']) {
continue;
}
$this->scanDirectory($realPath, $depth + 1);
2026-04-17 16:30:52 +08:00
continue;
}
if ($item->isFile()) {
2025-12-31 00:19:29 +08:00
$this->processFile($realPath);
}
}
}
private function shouldSkipItem(DirectoryIterator $item): bool
{
return $item->isDot() || str_starts_with($item->getFilename(), '.');
}
private function shouldSkipDirectory(string $path): bool
{
2026-04-17 16:30:52 +08:00
$path = rtrim($this->normalizePath($path), '/') . '/';
2025-12-31 00:19:29 +08:00
$skipDirs = array_merge(
$this->config['skip_directories'],
config('site.scanner.skip', [])
);
2026-04-17 16:30:52 +08:00
if (in_array($path, $skipDirs, true)) {
2025-12-31 00:19:29 +08:00
return true;
}
foreach ($this->config['skip_patterns'] as $pattern) {
if (strpos($path, $pattern) !== false) {
return true;
}
}
return false;
}
private function processFile(string $path): void
{
2026-04-17 16:30:52 +08:00
$path = $this->normalizePath($path);
if (!in_array(pathinfo($path, PATHINFO_EXTENSION), $this->config['extensions'], true)) {
2025-12-31 00:19:29 +08:00
return;
}
2026-04-17 16:30:52 +08:00
$this->visitedFiles[$path] = true;
2025-12-31 00:19:29 +08:00
if (!$this->shouldProcessFile($path)) {
return;
}
try {
2026-04-17 16:30:52 +08:00
$oldClasses = $this->manifest->getClasses($path);
$this->invalidateClasses($oldClasses);
$newClasses = $this->loadAndParseFile($path);
$this->manifest->set($path, (int)filemtime($path), $newClasses);
$this->changeSet?->addChangedFile($path);
foreach (array_diff($oldClasses, $newClasses) as $class) {
$this->changeSet?->addRemovedClass($class);
}
foreach ($newClasses as $class) {
$this->changeSet?->addChangedClass($class);
}
2025-12-31 00:19:29 +08:00
} catch (Throwable $e) {
$this->logError($e, [
2026-04-17 16:30:52 +08:00
'file' => $path,
2025-12-31 01:07:57 +08:00
'action' => 'process_file',
2025-12-31 00:19:29 +08:00
]);
if ($e instanceof \ParseError) {
throw $e;
}
}
}
private function shouldProcessFile(string $path): bool
{
if (!file_exists($path)) {
return false;
}
2026-04-17 16:30:52 +08:00
$mtime = (int)filemtime($path);
$cachedMtime = $this->manifest->getMtime($path);
2025-12-31 00:19:29 +08:00
2026-04-17 16:30:52 +08:00
return $cachedMtime === null || $cachedMtime < $mtime;
2025-12-31 00:19:29 +08:00
}
2026-04-17 16:30:52 +08:00
private function loadAndParseFile(string $path): array
2025-12-31 00:19:29 +08:00
{
$this->optimizeWithOpcache($path);
2026-06-12 23:57:19 +08:00
$before = get_declared_classes();
2025-12-31 00:19:29 +08:00
require_once $path;
2026-06-12 23:57:19 +08:00
$after = get_declared_classes();
2025-12-31 00:19:29 +08:00
2026-06-12 23:57:19 +08:00
$classes = array_values(array_diff($after, $before));
2026-04-17 16:30:52 +08:00
foreach ($classes as $class) {
if (class_exists($class)) {
$this->analyzeClass($class);
}
2025-12-31 00:19:29 +08:00
}
2026-04-17 16:30:52 +08:00
return $classes;
2025-12-31 00:19:29 +08:00
}
2025-12-31 01:50:09 +08:00
private function optimizeWithOpcache(string $path): void
{
if ($this->hasOpcache === null) {
2026-04-17 16:30:52 +08:00
$this->hasOpcache = function_exists('opcache_invalidate') && function_exists('opcache_compile_file');
2025-12-31 01:50:09 +08:00
}
2026-04-17 16:30:52 +08:00
if (!$this->hasOpcache) {
return;
}
try {
opcache_invalidate($path, true);
opcache_compile_file($path);
} catch (Throwable $e) {
if ($this->config['debug']) {
$this->logError($e, [
'file' => $path,
'action' => 'opcache_optimize',
]);
2025-12-31 01:50:09 +08:00
}
}
}
2025-12-31 00:19:29 +08:00
private function analyzeClass(string $class): void
{
try {
$reflect = $this->container->getReflectionClass($class);
2026-04-17 16:30:52 +08:00
if (!$reflect->isInstantiable() || $reflect->isTrait() || $reflect->isEnum() || $reflect->isInterface()) {
2025-12-31 00:19:29 +08:00
return;
}
if ($this->shouldSkipClass($reflect)) {
return;
}
$this->analyzeClassMethods($reflect, $class);
} catch (Throwable $e) {
$this->logError($e, [
2026-04-17 16:30:52 +08:00
'class' => $class,
2025-12-31 01:07:57 +08:00
'action' => 'analyze_class',
2025-12-31 00:19:29 +08:00
]);
}
}
private function shouldSkipClass(ReflectionClass $reflect): bool
{
2026-04-17 16:30:52 +08:00
$attributes = array_map(fn($attr) => $attr->getName(), $reflect->getAttributes());
2025-12-31 00:19:29 +08:00
2026-04-17 16:30:52 +08:00
return in_array(Skip::class, $attributes, true)
|| in_array(\Attribute::class, $attributes, true);
2025-12-31 00:19:29 +08:00
}
private function analyzeClassMethods(ReflectionClass $reflect, string $class): void
{
2026-06-12 23:57:19 +08:00
foreach ($reflect->getMethods() as $method) {
2025-12-31 00:19:29 +08:00
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();
2026-06-12 23:57:19 +08:00
if ($instance instanceof InjectMethodInterface) {
2025-12-31 00:19:29 +08:00
$instance->dispatch($class, $method->getName());
}
} catch (Throwable $e) {
$this->logError($e, [
2026-04-17 16:30:52 +08:00
'class' => $class,
'method' => $method->getName(),
2025-12-31 00:19:29 +08:00
'attribute' => $attributeName,
2026-04-17 16:30:52 +08:00
'action' => 'process_attribute',
2025-12-31 00:19:29 +08:00
]);
}
}
}
private function getDefaultCacheFile(): string
{
2026-04-17 16:30:52 +08:00
$cacheDir = defined('APP_PATH')
? rtrim(str_replace('\\', '/', APP_PATH), '/') . '/storage/.kiri-scanner-cache/'
: sys_get_temp_dir() . '/kiri-scanner-cache/';
2025-12-31 00:19:29 +08:00
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;
}
$data = json_decode(file_get_contents($cacheFile), true);
2026-04-17 16:30:52 +08:00
if (!is_array($data) || !isset($data['manifest'])) {
2025-12-31 00:19:29 +08:00
return false;
}
2026-04-17 16:30:52 +08:00
$this->manifest->fromArray($data['manifest']);
$this->syncLegacyState();
2025-12-31 00:19:29 +08:00
2026-04-17 16:30:52 +08:00
return !$this->hasDirectoryChanged($path);
2025-12-31 00:19:29 +08:00
}
2026-04-17 16:30:52 +08:00
private function hasDirectoryChanged(string $path): bool
2025-12-31 00:19:29 +08:00
{
2026-04-17 16:30:52 +08:00
$currentFiles = $this->collectFiles($path);
$cachedFiles = $this->manifest->paths(rtrim($path, '/\\') . '/');
2025-12-31 00:19:29 +08:00
2026-04-17 16:30:52 +08:00
sort($currentFiles);
sort($cachedFiles);
if ($currentFiles !== $cachedFiles) {
return true;
}
foreach ($currentFiles as $file) {
if (!file_exists($file)) {
return true;
2025-12-31 00:19:29 +08:00
}
2026-04-17 16:30:52 +08:00
if ($this->manifest->getMtime($file) !== (int)filemtime($file)) {
2025-12-31 00:19:29 +08:00
return true;
}
}
return false;
}
private function saveToCache(string $cacheFile): void
{
$data = [
2026-04-17 16:30:52 +08:00
'manifest' => $this->manifest->all(),
2025-12-31 00:19:29 +08:00
'timestamp' => time(),
2026-04-17 16:30:52 +08:00
'version' => '2.0',
2025-12-31 00:19:29 +08:00
];
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');
2026-04-17 16:30:52 +08:00
$now = time();
2025-12-31 00:19:29 +08:00
foreach ($files as $file) {
2025-12-31 01:50:09 +08:00
if (filemtime($file) + (7 * 24 * 3600) < $now) {
2025-12-31 00:19:29 +08:00
unlink($file);
}
}
}
2025-12-31 01:50:09 +08:00
private function logError(Throwable $e, array $context = []): void
{
$fullContext = array_merge($context, [
'message' => $e->getMessage(),
2026-04-17 16:30:52 +08:00
'code' => $e->getCode(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
'time' => date('Y-m-d H:i:s'),
2025-12-31 01:50:09 +08:00
]);
if (function_exists('error')) {
error($e, $fullContext);
} else {
error_log(json_encode($fullContext));
}
if ($this->config['debug'] && php_sapi_name() === 'cli') {
echo "Scanner Error: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}\n";
2026-04-17 16:30:52 +08:00
if ($context !== []) {
2025-12-31 01:50:09 +08:00
echo 'Context: ' . json_encode($context) . "\n";
}
}
}
public function load_directory(string $path): void
2025-12-31 00:19:29 +08:00
{
2025-12-31 01:50:09 +08:00
$this->scan($path);
2025-12-31 00:19:29 +08:00
}
2023-04-16 16:40:44 +08:00
2025-12-31 01:50:09 +08:00
protected function parseFile($file): void
{
2026-04-17 16:30:52 +08:00
$absolutePath = $this->normalizePath($this->basePath . '/' . ltrim((string)$file, '/')) . '.php';
2025-12-31 01:50:09 +08:00
if (file_exists($absolutePath)) {
$this->processFile($absolutePath);
}
}
protected function skipNames(ReflectionClass $reflect): array
{
2026-04-17 16:30:52 +08:00
return array_map(fn($attr) => $attr->getName(), $reflect->getAttributes());
2025-12-31 01:50:09 +08:00
}
2026-06-24 20:45:36 +08:00
public function getStats(): array
{
return [
'total_files' => count($this->files),
'cached_mtimes' => count($this->fileMtimes),
'base_path' => $this->basePath,
'config' => $this->config,
'manifest_entries' => count($this->manifest->all()),
];
}
/**
* 返回 Master 扫描产生的完整清单数据,供 Worker 轻量重建注解路由
* @return array{string: array{mtime: int, classes: string[]}}
*/
public function getManifestClasses(): array
{
return $this->manifest->all();
}
2025-12-31 01:50:09 +08:00
2025-12-31 00:19:29 +08:00
public function reset(): void
{
2026-04-17 16:30:52 +08:00
$this->files = [];
2025-12-31 00:19:29 +08:00
$this->fileMtimes = [];
$this->hasOpcache = null;
2026-04-17 16:30:52 +08:00
$this->manifest = new ScanManifest();
$this->changeSet = null;
$this->visitedFiles = [];
}
private function detectRemovedFiles(string $path): void
{
$prefix = rtrim($path, '/\\') . '/';
foreach ($this->manifest->paths($prefix) as $file) {
if (!isset($this->visitedFiles[$file])) {
$this->markRemovedFile($file);
}
}
}
private function markRemovedFile(string $path): void
{
if (!$this->manifest->has($path)) {
return;
}
$classes = $this->manifest->remove($path);
$this->invalidateClasses($classes);
foreach ($classes as $class) {
$this->changeSet?->addRemovedClass($class);
}
$this->changeSet?->addRemovedFile($path);
}
private function syncLegacyState(): void
{
$this->files = array_keys($this->manifest->all());
$this->fileMtimes = [];
foreach ($this->manifest->all() as $path => $entry) {
$this->fileMtimes[$path] = (int)$entry['mtime'];
}
}
private function collectFiles(string $path, int $depth = 0): array
{
if ($depth > $this->config['max_depth']) {
return [];
}
$files = [];
try {
$dir = new DirectoryIterator($path);
} catch (Throwable) {
return [];
}
foreach ($dir as $item) {
if ($this->shouldSkipItem($item)) {
continue;
}
$realPath = $this->normalizePath($item->getRealPath() ?: $item->getPathname());
if ($item->isDir()) {
if ($this->shouldSkipDirectory($realPath)) {
continue;
}
if ($item->isLink() && !$this->config['follow_links']) {
continue;
}
$files = array_merge($files, $this->collectFiles($realPath, $depth + 1));
continue;
}
if ($item->isFile() && in_array(pathinfo($realPath, PATHINFO_EXTENSION), $this->config['extensions'], true)) {
$files[] = $realPath;
}
}
return $files;
}
private function normalizePath(string $path): string
{
$resolved = realpath($path) ?: $path;
return str_replace('\\', '/', $resolved);
}
private function replayManifest(string $path, array $skipPaths = []): void
{
$skip = array_fill_keys(array_map([$this, 'normalizePath'], $skipPaths), true);
$prefix = rtrim($path, '/\\') . '/';
foreach ($this->manifest->paths($prefix) as $file) {
if (isset($skip[$file])) {
continue;
}
foreach ($this->manifest->getClasses($file) as $class) {
if (class_exists($class)) {
$this->analyzeClass($class);
}
}
}
}
private function invalidateClasses(array $classes): void
{
foreach ($classes as $class) {
if (method_exists($this->container, 'forgetClass')) {
$this->container->forgetClass($class);
}
2026-06-28 20:20:20 +08:00
if (class_exists(\Kiri\Router\Defer\DeferRegistry::class)) {
\Kiri\Router\Defer\DeferRegistry::removeClass($class);
2026-06-24 20:11:11 +08:00
}
2026-04-17 16:30:52 +08:00
}
2025-12-31 00:19:29 +08:00
}
2023-04-16 16:40:44 +08:00
}