From 716e331b8a6b0412de3d7abd4d9d5dc3bc83570b Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 1 Sep 2026 16:40:28 +0800 Subject: [PATCH 1/5] =?UTF-8?q?refactor(Swagger):=20=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E7=B1=BB=E6=96=B9=E6=B3=95=E8=B7=AF=E5=BE=84=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除不必要的类名缩写处理逻辑 - 直接使用完整类名和方法名构建路径字符串 - 更新方法注释描述 (cherry picked from commit 88fd5063220d475ed61b5c05f24851d39cefe8eb) --- src/Swagger/SwaggerPaths.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Swagger/SwaggerPaths.php b/src/Swagger/SwaggerPaths.php index 6410e32..d8c4993 100644 --- a/src/Swagger/SwaggerPaths.php +++ b/src/Swagger/SwaggerPaths.php @@ -130,17 +130,11 @@ public function addPath(string $className, string $methodName, string $route, st } /** - * 获取类方法路径(快速定位后端代码). + * 获取类方法路径(定位后端代码). */ protected function getClassMethodPath(string $fullClassName, string $methodName): string { - $parts = explode('\\', $fullClassName); - $shortParts = []; - for ($i = 0; $i < count($parts) - 1; ++$i) { - $shortParts[] = $parts[$i][0] ?? ''; - } - $shortParts[] = end($parts); - return sprintf('%s', implode('.', $shortParts) . '::' . $methodName); + return sprintf('%s::%s',$fullClassName,$methodName); } /** From 0829d994835aeaa1406b9f9f1e8df8440686f01e Mon Sep 17 00:00:00 2001 From: tw Date: Tue, 18 Aug 2026 10:17:56 +0800 Subject: [PATCH 2/5] =?UTF-8?q?refactor(Swagger):=20=E8=B0=83=E6=95=B4API?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E8=B7=AF=E5=BE=84=E4=BF=A1=E6=81=AF=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将类方法路径信息从description字段移至x属性中存储 - 移除description字段中的HTML标签拼接逻辑 - 直接使用apiOperation描述覆盖description字段 (cherry picked from commit 64381062c6f009bd1c11cd0482da7c3330013f90) --- src/Swagger/SwaggerPaths.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Swagger/SwaggerPaths.php b/src/Swagger/SwaggerPaths.php index d8c4993..6e2f583 100644 --- a/src/Swagger/SwaggerPaths.php +++ b/src/Swagger/SwaggerPaths.php @@ -104,9 +104,9 @@ public function addPath(string $className, string $methodName, string $route, st $operation->path = $route; $operation->tags = $tags; $operation->summary = $apiOperation->summary ?: Generator::UNDEFINED; - $operation->description = $this->getClassMethodPath($className, $methodName); + $operation->x = ['code-path' => $this->getClassMethodPath($className, $methodName)]; if ($apiOperation->description) { - $operation->description .= '
' . $apiOperation->description; + $operation->description = $apiOperation->description; } $operation->operationId = $this->getOperationId($route, $methods); From ae11bdac1c52c0ec1c62499b3fbcf939b537cb32 Mon Sep 17 00:00:00 2001 From: tw Date: Wed, 9 Sep 2026 17:34:24 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(api-docs):=20=E6=B7=BB=E5=8A=A0DI?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E6=98=A0=E5=B0=84=E5=85=B3=E7=B3=BB=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=9B=91=E5=90=AC=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DiMapGenerateListener 类用于收集 DI 容器映射关系 - 实现服务启动后生成 di-map.json 文件功能 - 支持路径标准化处理,兼容 WSL 和 Windows 环境 - 添加对 AOP 代理类的原始文件查找支持 - 实现工厂定义和对象定义的类型区分与路径记录 - 提供原子写入机制避免文件读写冲突 --- src/ConfigProvider.php | 2 + src/Listener/DiMapGenerateListener.php | 292 +++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/Listener/DiMapGenerateListener.php diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index b7c8a02..4341d53 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -7,6 +7,7 @@ use Hyperf\ApiDocs\Listener\AfterDtoStartListener; use Hyperf\ApiDocs\Listener\AfterWorkerStartListener; use Hyperf\ApiDocs\Listener\BootAppRouteListener; +use Hyperf\ApiDocs\Listener\DiMapGenerateListener; class ConfigProvider { @@ -19,6 +20,7 @@ public function __invoke(): array AfterDtoStartListener::class, BootAppRouteListener::class, AfterWorkerStartListener::class, + DiMapGenerateListener::class, ], 'annotations' => [ 'scan' => [ diff --git a/src/Listener/DiMapGenerateListener.php b/src/Listener/DiMapGenerateListener.php new file mode 100644 index 0000000..7c7fbdb --- /dev/null +++ b/src/Listener/DiMapGenerateListener.php @@ -0,0 +1,292 @@ +workerId !== 0) { + return; + } + try { + $this->generate(); + } catch (\Throwable $e) { + $this->logger->error('Generate Di Map file failed: ' . $e->getMessage()); + } + } + + /** + * 生成 di-map.json 到 api_docs 配置的 output_dir。 + */ + public function generate(): void + { + if (! $this->swaggerConfig->isEnable() || ! $outputDir = $this->swaggerConfig->getOutputDir()) { + return; + } + + $path = rtrim($outputDir, '/\\') . '/di-map.json'; + $payload = [ + 'base_path' => BASE_PATH, + 'generated_at' => date('c'), + ] + $this->collect(); + // 原子写:避免 IDE 插件读到写了一半的文件 + $tmpPath = $path . '.tmp'; + file_put_contents($tmpPath, json_encode( + $payload, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + )); + rename($tmpPath, $path); + $this->logger->debug('Generate Di Map file: ' . $path); + } + + /** + * 收集 DI 容器中的所有映射关系(只读,不执行工厂闭包)。 + * resolved 为调用时刻的快照,之后运行时 set() 的条目不包含在内。 + * + * @return array{definitions: array, resolved: array} + */ + public function collect(): array + { + if (! $this->container instanceof Container) { + return ['definitions' => [], 'resolved' => []]; + } + + $definitions = []; + foreach ($this->readProperty($this->container, 'definitionSource')->getDefinitions() as $name => $definition) { + // getDefinition() 的 autowire 缓存会把不存在的类写入 null,跳过 + if (! $definition instanceof DefinitionInterface) { + continue; + } + $definitions[$name] = $this->formatDefinition($definition); + } + + $resolved = []; + foreach ($this->readProperty($this->container, 'resolvedEntries') as $name => $value) { + $class = is_object($value) ? $value::class : null; + $resolved[$name] = [ + 'type' => $class ? 'object' : gettype($value), + 'class' => $class, + 'path' => $class ? $this->getClassPath($class) : null, + ]; + } + + return [ + 'definitions' => $this->filterSelfMapped($definitions), + 'resolved' => $this->filterSelfMapped($resolved), + ]; + } + + /** + * 过滤掉 name 与目标类相同的自映射条目,并按 name 排序。 + * 工厂定义除外:即使产出类与 name 相同,其 path 指向工厂文件,有定位价值。 + * + * @param array $map + * @return array + */ + private function filterSelfMapped(array $map): array + { + $map = array_filter($map, fn (array $d, string $name): bool => $d['class'] !== $name || $d['type'] === 'factory', ARRAY_FILTER_USE_BOTH); + ksort($map); + return $map; + } + + /** + * @return array{type: string, class: ?string, path: ?string} + */ + private function formatDefinition(DefinitionInterface $definition): array + { + if ($definition instanceof ObjectDefinition) { + $class = $definition->getClassName(); + return [ + 'type' => 'object', + 'class' => $class, + 'path' => $this->getClassPath($class), + ]; + } + if ($definition instanceof FactoryDefinition) { + $factory = $definition->getFactory(); + return [ + 'type' => 'factory', + 'class' => $this->getFactoryClass($factory), + 'path' => $this->getFactoryPath($factory), + ]; + } + return [ + 'type' => class_basename($definition), + 'class' => null, + 'path' => null, + ]; + } + + /** + * 工厂定义位置:闭包记录定义文件,类形式(类名、[类, 方法]、类::方法、__invoke 对象)记录类文件路径。 + */ + private function getFactoryPath(callable|string $factory): ?string + { + if ($factory instanceof \Closure) { + $file = (new \ReflectionFunction($factory))->getFileName(); + return $this->localizePath($file ?: null); + } + if (is_array($factory)) { + // [类名或对象, 方法名] + return $this->getClassPath(is_object($factory[0]) ? $factory[0]::class : $factory[0]); + } + if (is_string($factory)) { + // 类名 或 类名::方法 + return $this->getClassPath(explode('::', $factory)[0]); + } + // 实现了 __invoke 的工厂对象 + return $this->getClassPath($factory::class); + } + + /** + * 推断工厂产出的类:反射工厂 callable 声明的返回类型,是类(非标量)则记录,否则为 null。 + * 覆盖闭包、[类, 方法]、类::方法、带 __invoke 的类名或对象;未声明返回类型时为 null。 + */ + private function getFactoryClass(callable|string $factory): ?string + { + try { + $callable = match (true) { + $factory instanceof \Closure => new \ReflectionFunction($factory), + is_array($factory) => new \ReflectionMethod($factory[0], $factory[1]), + is_string($factory) && str_contains($factory, '::') => new \ReflectionMethod(...explode('::', $factory, 2)), + is_string($factory) && method_exists($factory, '__invoke') => new \ReflectionMethod($factory, '__invoke'), + is_object($factory) => new \ReflectionMethod($factory, '__invoke'), + default => null, + }; + } catch (\ReflectionException) { + return null; + } + $type = $callable?->getReturnType(); + if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) { + return null; + } + $name = $type->getName(); + $lower = strtolower($name); + if (! in_array($lower, ['self', 'static', 'parent'], true)) { + return $name; + } + // self/static/parent 需结合声明类解析 + if (! $callable instanceof \ReflectionMethod) { + return null; + } + if ($lower === 'parent') { + $parent = $callable->getDeclaringClass()->getParentClass(); + return $parent ? $parent->getName() : null; + } + return $callable->getDeclaringClass()->getName(); + } + + private function getClassPath(string $class): ?string + { + if (! class_exists($class) && ! interface_exists($class) && ! enum_exists($class)) { + return null; + } + $file = (new \ReflectionClass($class))->getFileName(); + if (! $file) { + return null; + } + // AOP 代理类的反射路径指向 runtime/container/proxy/*.proxy.php(代理保留原类名); + // 运行时 composer classmap 已被代理路径覆盖(ClassLoader::init 的 addClassMap), + // 只能从磁盘上的 autoload 文件反查原始文件 + if (str_contains(str_replace('\\', '/', $file), '/runtime/container/proxy/')) { + $file = $this->findOriginalClassFile($class) ?? $file; + } + return $this->localizePath($file); + } + + /** + * 路径转相对 BASE_PATH(IDE 插件按应用根解析,规避 WSL/Windows 路径差); + * 不在 BASE_PATH 下(如宿主编译路径、外部挂载)保留绝对路径。 + */ + private function localizePath(?string $file): ?string + { + if ($file === null) { + return null; + } + $prefix = BASE_PATH . '/'; + return str_starts_with($file, $prefix) ? substr($file, strlen($prefix)) : $file; + } + + /** + * 从磁盘上的 composer autoload 文件反查类的原始文件(绕开运行时被代理覆盖的 classmap)。 + */ + private function findOriginalClassFile(string $class): ?string + { + $classMapFile = BASE_PATH . '/vendor/composer/autoload_classmap.php'; + if (is_file($classMapFile)) { + $classMap = include $classMapFile; + if (isset($classMap[$class])) { + return $classMap[$class]; + } + } + $psr4File = BASE_PATH . '/vendor/composer/autoload_psr4.php'; + if (is_file($psr4File)) { + foreach ((array) include $psr4File as $prefix => $dirs) { + if (! str_starts_with($class, (string) $prefix)) { + continue; + } + $relative = str_replace('\\', '/', substr($class, strlen((string) $prefix))) . '.php'; + foreach ((array) $dirs as $dir) { + if (is_file($file = rtrim((string) $dir, '/') . '/' . $relative)) { + return $file; + } + } + } + } + return null; + } + + private function readProperty(object $object, string $property): mixed + { + return (new \ReflectionProperty($object, $property))->getValue($object); + } +} From 49e1f5a29990c9cea3d669e38c8ef8c2f52181ab Mon Sep 17 00:00:00 2001 From: tw Date: Wed, 9 Sep 2026 22:48:03 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat(di-map):=20=E6=B7=BB=E5=8A=A0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E6=A3=80=E6=9F=A5=E6=9C=BA=E5=88=B6=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=94=9F=E6=88=90di-map.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 绑定来源只有 dependencies.php 与 lazy_loader.php:两者未更新且已有产物时跳过重写 - 实现 shouldRegenerate 方法判断是否需要重新生成 - 产物不存在时必须生成;否则 dependencies.php 或 lazy_loader.php 比产物新才重新生成 - 避免不必要的文件重写操作 --- src/Listener/DiMapGenerateListener.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Listener/DiMapGenerateListener.php b/src/Listener/DiMapGenerateListener.php index 7c7fbdb..ca8aaf3 100644 --- a/src/Listener/DiMapGenerateListener.php +++ b/src/Listener/DiMapGenerateListener.php @@ -73,6 +73,10 @@ public function generate(): void } $path = rtrim($outputDir, '/\\') . '/di-map.json'; + // 绑定来源只有 dependencies.php 与 lazy_loader.php:两者未更新且已有产物时跳过重写 + if (! $this->shouldRegenerate($path)) { + return; + } $payload = [ 'base_path' => BASE_PATH, 'generated_at' => date('c'), @@ -87,6 +91,24 @@ public function generate(): void $this->logger->debug('Generate Di Map file: ' . $path); } + /** + * 是否需要重新生成:产物不存在时必须生成;否则 dependencies.php(项目绑定表) + * 或 lazy_loader.php(懒加载代理配置,可选文件)比产物新才重新生成。 + */ + private function shouldRegenerate(string $diMapPath): bool + { + if (! is_file($diMapPath)) { + return true; + } + $generatedAt = filemtime($diMapPath); + foreach ([BASE_PATH . '/config/autoload/dependencies.php', BASE_PATH . '/config/lazy_loader.php'] as $source) { + if (is_file($source) && filemtime($source) > $generatedAt) { + return true; + } + } + return false; + } + /** * 收集 DI 容器中的所有映射关系(只读,不执行工厂闭包)。 * resolved 为调用时刻的快照,之后运行时 set() 的条目不包含在内。 From 8697100a67055fdd81d86a30387b72ee2df34652 Mon Sep 17 00:00:00 2001 From: tw Date: Mon, 14 Sep 2026 13:37:22 +0800 Subject: [PATCH 5/5] =?UTF-8?q?chore(ci):=20=E6=9B=B4=E6=96=B0=20GitHub=20?= =?UTF-8?q?Actions=20=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将测试环境从多操作系统切换到统一的 Hyperf 容器环境 - 更新 PHP 版本矩阵为 8.1、8.2、8.3 的 Alpine 镜像 - 移除手动安装 Swoole 和 Swow 扩展的步骤 - 使用官方 Hyperf 镜像简化环境设置流程 - 更新 Actions 步骤名称以更好地反映执行的操作 - 提高并行执行数量从 5 到 15 优化测试效率 --- .github/workflows/test.yml | 79 +++++++++++++------------------------- 1 file changed, 27 insertions(+), 52 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 184788b..5a0e138 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,64 +2,39 @@ name: PHPUnit on: [ push, pull_request ] -env: - SWOOLE_VERSION: '5.1.4' - SWOW_VERSION: 'develop' - jobs: - ci: - name: Test PHP ${{ matrix.php-version }} on ${{ matrix.engine }} - runs-on: "${{ matrix.os }}" + ci31: + runs-on: ubuntu-latest + container: hyperf/hyperf:${{ matrix.hyperf-version }} strategy: matrix: - os: [ ubuntu-latest ] - php-version: [ '8.1','8.2','8.3' ] - engine: [ 'swoole' ] - max-parallel: 5 + hyperf-version: + - "8.1-alpine-v3.19-swoole" + - "8.2-alpine-v3.22-swoole" + - "8.3-alpine-v3.23-swoole" + fail-fast: false + max-parallel: 15 + steps: - name: Checkout - uses: actions/checkout@v2 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - tools: phpize - ini-values: opcache.enable_cli=1 - coverage: none - - name: Setup Swoole - if: ${{ matrix.engine == 'swoole' }} + uses: actions/checkout@v4 + - name: Setup Environment run: | - sudo apt-get update - sudo apt-get install libcurl4-openssl-dev - wget https://github.com/swoole/swoole-src/archive/v${SWOOLE_VERSION}.tar.gz -O swoole.tar.gz - mkdir -p swoole - tar -xf swoole.tar.gz -C swoole --strip-components=1 - rm swoole.tar.gz - cd swoole - phpize - ./configure --enable-openssl --enable-http2 --enable-swoole-curl --enable-swoole-json - make -j$(nproc) - sudo make install - sudo sh -c "echo extension=swoole > /etc/php/${{ matrix.php-version }}/cli/conf.d/swoole.ini" + pwd + ls -al + php -v + php -m php --ri swoole - - name: Setup Swow - if: ${{ matrix.engine == 'swow' }} - run: | - wget https://github.com/swow/swow/archive/"${SWOW_VERSION}".tar.gz -O swow.tar.gz - mkdir -p swow - tar -xf swow.tar.gz -C swow --strip-components=1 - rm swow.tar.gz - cd swow/ext || exit + composer -V - phpize - ./configure --enable-debug - make -j "$(nproc)" - sudo make install - sudo sh -c "echo extension=swow > /etc/php/${{ matrix.php-version }}/cli/conf.d/swow.ini" - php --ri swow - - name: Setup Packages - run: composer require "tangwei/dto:dev-master" && composer update -o - - name: Run Test Cases + - name: Install Dependencies run: | - composer analyse - composer test + composer require hyperf/di:3.1.* + composer update -o + composer info + + - name: Static Analysis + run: composer analyse + + - name: Run Tests + run: composer test \ No newline at end of file