Laravel 服务提供者检测注解逻辑
riven/laravel-amqp - Packagist
扫描变更加载缓存
namespace App\Providers;
use App\Annotation\Impl;
use Exception;
use FilesystemIterator;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\ServiceProvider;
use Psr\SimpleCache\InvalidArgumentException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ImplServiceProvider extends ServiceProvider
{
/**
* @throws InvalidArgumentException
*/
public function register(): void
{
$this->bindImplClasses();
}
/**
* @throws InvalidArgumentException
*/
protected function bindImplClasses(): void
{
$cache = $this->getCache();
$cacheKey = 'impl_bindings';
$cacheTtl = 3600; // 缓存 1 小时
// 尝试从缓存中获取数据
$cached = $cache->get($cacheKey);
$bindings = [];
if ($cached) {
$cachedData = json_decode($cached, true);
$lastModifiedFiles = $cachedData['last_modified_files'] ?? [];
$bindings = $cachedData['bindings'] ?? [];
// 检查文件修改时间是否一致
$currentLastModified = $this->getLastModifiedTimes();
if ($this->isCacheValid($lastModifiedFiles, $currentLastModified)) {
foreach ($bindings as $interface => $class) {
$this->app->singletonIf($interface, $class); // 绑定类「默认单例」
}
return;
}
}
// 重新生成绑定数据
$targetDirs = [app_path('Services')];
$files = [];
foreach ($targetDirs as $dir) {
$files = array_merge($files, $this->getPhpFilesInDir($dir));
}
// 处理类
foreach ($files as $file) {
$className = $this->getClassFromFilePath($file);
if (!class_exists($className)) {
continue;
}
$reflection = new ReflectionClass($className);
$attributes = $reflection->getAttributesclass;
foreach ($attributes as $attribute) {
$impl = $attribute->newInstance();
// 绑定接口到类
$bindKey = $impl->key ? "$impl->interface@$impl->key" : $impl->interface;
if (isset($bindings[$bindKey])) {
Log::warning("接口 [{$impl->interface}] 的键 [{$impl->key}] 已绑定到其他类,将被覆盖。类:[{$className}]");
}
$bindings[$bindKey] = $className;
}
}
// 获取当前文件修改时间
$currentLastModified = $this->getLastModifiedTimes();
// 写入缓存
$cacheData = [
'last_modified_files' => $currentLastModified,
'bindings' => $bindings,
];
$cache->put($cacheKey, json_encode($cacheData), $cacheTtl);
// 绑定到容器
foreach ($bindings as $interface => $class) {
$this->app->singletonIf($interface, $class);
}
}
/**
* 获取缓存实例(优先 Redis,失败则降级为 file)
*/
private function getCache(): Repository
{
try {
return Cache::store('redis');
} catch (Exception $e) {
Log::warning('Redis 缓存不可用,已降级为文件缓存', ['exception' => $e]);
return Cache::store('file');
}
}
/**
* 获取指定目录下的所有 PHP 文件
*/
protected function getPhpFilesInDir(string $dir): array
{
$files = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
return $files;
}
/**
* 根据文件路径获取类名
*/
protected function getClassFromFilePath(string $file): string
{
$appPath = app_path();
if (strpos($file, $appPath) !== 0) {
throw new \InvalidArgumentException("文件 [$file] 不在 app 目录下,无法解析类名。");
}
$relativePath = substr($file, strlen($appPath) + 1);
$relativePath = str_replace('.php', '', $relativePath);
$className = str_replace('/', '\\', $relativePath);
return "App\\$className";
}
/**
* 获取所有目标目录下 PHP 文件的最后修改时间
*/
protected function getLastModifiedTimes(): array
{
$targetDirs = [app_path('Services')];
$lastModified = [];
foreach ($targetDirs as $dir) {
$files = $this->getPhpFilesInDir($dir);
foreach ($files as $file) {
$lastModified[$file] = filemtime($file);
}
}
return $lastModified;
}
/**
* 检查缓存是否有效(文件修改时间是否一致)
*/
protected function isCacheValid(array $cachedLastModified, array $currentLastModified): bool
{
// 检查所有文件的修改时间是否一致
foreach ($cachedLastModified as $file => $mtime) {
if (!isset($currentLastModified[$file]) || $currentLastModified[$file] > $mtime) {
return false;
}
}
// 检查是否有新文件在缓存中未记录
foreach ($currentLastModified as $file => $mtime) {
if (!isset($cachedLastModified[$file])) {
return false;
}
}
return true;
}
}
开发环境直接扫描,生产环境使用缓存
namespace App\Providers;
use App\Annotation\Impl;
use App\Helper\Invoke\Annotation\Callee;
use App\Helper\Invoke\CalleeCollector;
use Exception;
use FilesystemIterator;
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
use Psr\SimpleCache\InvalidArgumentException;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
class ImplServiceProvider extends ServiceProvider
{
// 接口实现注解
const string IMPL = 'impl';
// 回调注解
const string Callee = 'callee';
/**
* 启动服务提供者
*/
public function boot(): void
{
// 调用绑定实现类的方法
$this->bindImplClasses();
// 注册 Callee 收集逻辑(注解方法)
$this->registerCalleeMethods();
}
/**
* 绑定实现类到 Laravel 服务容器
* @return void
*/
protected function bindImplClasses(): void
{
if (app()->environment('local')) {
// 开发环境:直接扫描指定目录下的类并绑定,不使用缓存,方便开发调试
$bindings = $this->discoverImplBindings();
} else {
// 生产环境:尝试从缓存中获取绑定信息,如果缓存不存在或读取失败,则扫描并写入缓存
$bindings = $this->getCachedBindings();
}
// 遍历获取到的绑定信息,并将接口绑定到对应的实现类(如果尚未绑定)
foreach ($bindings as $interface => $class) {
$this->app->singletonIf($interface, $class); // 使用 singletonIf 确保每个接口只会被实例化一次(单例模式)
}
}
/**
* 收集 Callee 注解方法到 CalleeCollector
*/
protected function registerCalleeMethods(): void
{
if (app()->environment('local')) {
// 开发环境:实时扫描
$calleeMethods = $this->discoverCalleeMethods();
} else {
// 生产环境:使用缓存
$calleeMethods = $this->getCachedBindingsCallee;
}
// 注册方法到 CalleeCollector
foreach ($calleeMethods as $callable) {
CalleeCollector::addCallee(...$callable);
}
}
/**
* 扫描并发现 Callee 注解方法
*/
protected function discoverCalleeMethods(): array
{
$targetDirs = [app_path('Services')];
$files = [];
foreach ($targetDirs as $dir) {
$files = array_merge($files, $this->getPhpFilesInDir($dir));
}
$calleeMethods = [];
foreach ($files as $file) {
$className = $this->getClassFromFilePath($file);
if (!class_exists($className)) {
continue;
}
$reflection = new ReflectionClass($className);
$methods = $reflection->getMethods();
foreach ($methods as $method) {
$attributes = $method->getAttributesclass;
foreach ($attributes as $attribute) {
$callee = $attribute->newInstance();
$event = $callee->event;
$scope = $callee->scope;
$calleeMethods[] = [[$className, $method->getName()], $event, $scope];
}
}
}
return $calleeMethods;
}
/**
* 扫描指定目录下的类,查找带有 Impl 注解的类,并将其实现的接口绑定到自身
* @return array 接口到实现类的绑定数组
*/
private function discoverImplBindings(): array
{
$targetDirs = [app_path('Services')]; // 定义需要扫描的目录
$files = []; // 初始化文件数组
// 遍历目标目录,获取所有 PHP 文件
foreach ($targetDirs as $dir) {
$files = array_merge($files, $this->getPhpFilesInDir($dir));
}
$bindings = []; // 初始化绑定数组
// 遍历所有 PHP 文件
foreach ($files as $file) {
$className = $this->getClassFromFilePath($file); // 根据文件路径获取完整的类名
// 检查类是否存在
if (!class_exists($className)) {
continue; // 如果类不存在,则跳过
}
$reflection = new ReflectionClass($className); // 创建反射类
$attributes = $reflection->getAttributesclass; // 获取类上所有 Impl 注解
// 如果类上存在 Impl 注解
if ($attributes) {
$interfaceNames = $reflection->getInterfaceNames(); // 获取当前类实现的所有接口名称
// 遍历实现的接口
foreach ($interfaceNames as $interfaceName) {
$bindings[$interfaceName] = $className; // 将接口名称作为 key,类名作为 value 存储到绑定数组中
}
}
}
return $bindings; // 返回接口到实现类的绑定数组
}
/**
* 生产环境:从缓存中获取绑定信息(优先使用 Redis 缓存,如果 Redis 不可用则降级为文件缓存)
* @return array 接口到实现类的绑定数组
*/
private function getCachedBindings(string $type = self::IMPL): array
{
try {
// 尝试从缓存中获取数据
$cached = $this->getCache()->get($type);
if ($cached) {
// 返回缓存中的绑定信息,如果不存在则返回空数组
return $cached;
}
} catch (InvalidArgumentException $e) {
// 缓存读取失败(可能是 Redis 连接问题等),记录警告日志并继续尝试扫描
Log::warning('缓存读取失败,已降级为文件缓存, key=' . self::IMPL, ['exception' => $e]);
}
// 缓存不存在或读取失败时,强制扫描指定目录下的类并生成新地绑定信息
$bindings = $type === self::IMPL ? $this->discoverImplBindings() : $this->discoverCalleeMethods();
// 将新地绑定信息永久存储到缓存中
$this->getCache()->forever($type, $bindings);
return $bindings; // 返回新生成的绑定数组
}
/**
* 获取缓存实例(优先使用 Redis 缓存,如果 Redis 连接失败则降级为文件缓存)
* @return Repository 缓存仓库实例
*/
private function getCache(): Repository
{
try {
// 尝试获取 Redis 缓存存储实例
return Cache::store('redis');
} catch (Exception $e) {
// 如果获取 Redis 缓存实例失败(例如 Redis 服务未运行),记录警告日志并返回文件缓存存储实例
Log::warning('Redis 缓存不可用,已降级为文件缓存', ['exception' => $e]);
return Cache::store('file');
}
}
/**
* 获取指定目录下的所有 PHP 文件
* @param string $dir 目录路径
* @return array 包含所有 PHP 文件路径的数组
*/
protected function getPhpFilesInDir(string $dir): array
{
$files = []; // 初始化文件路径数组
// 递归遍历指定目录,跳过 . 和 .. 目录
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
);
// 遍历迭代器
foreach ($iterator as $file) {
// 如果是文件且扩展名为 .php
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname(); // 将文件完整路径添加到数组
}
}
return $files; // 返回包含所有 PHP 文件路径的数组
}
/**
* 根据文件路径获取完整的类名
* @param string $file 文件路径
* @return string 完整的类名
* @throws \InvalidArgumentException 如果文件不在 app 目录下
*/
protected function getClassFromFilePath(string $file): string
{
$appPath = app_path(); // 获取 app 目录的路径
// 检查文件路径是否以 app 目录开头
if (!str_starts_with($file, $appPath)) {
throw new \InvalidArgumentException("文件 [$file] 不在 app 目录下,无法解析类名。");
}
// 获取相对于 app 目录的路径
$relativePath = substr($file, strlen($appPath) + 1);
// 移除 .php 扩展名
$relativePath = str_replace('.php', '', $relativePath);
// 将路径分隔符 / 替换为命名空间分隔符 \
$className = str_replace('/', '\\', $relativePath);
// 返回完整的类名,加上根命名空间 App
return "App\\$className";
}
}