eee
This commit is contained in:
+493
-124
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user