pepakriz/phpstan-exception-rules

PHPStan 的异常规则

安装次数: 2 033 945

依赖项: 36

建议者: 0

安全: 0

星级: 108

关注者: 7

分支: 9

开放问题: 8

类型:phpstan-extension


README

Build Status Latest Stable Version License

此扩展提供以下规则和功能

  • 在抛出某些检查异常时需要 @throws 注释(示例
  • 异常传播
    • 函数调用
    • 魔法、动态和静态方法调用
    • 在 foreach 和 iterator_*() 函数中使用可迭代表面(示例
    • count() 函数组合的 Countable 接口(示例
    • json_encode() 函数组合的 JsonSerializable 接口(示例
  • 忽略捕获的检查异常(示例
  • 不必要的 @throws 注释检测(示例
  • 无用 @throws 注释检测(示例
  • 可选地允许子类型中未使用的 @throws 注释(示例
  • @throws 注释方差验证(示例
  • 基于参数的动态抛出类型
  • 不可达的捕获语句
    • 在某个之前的捕获语句中已捕获异常(示例
    • 在同一个捕获语句中两次捕获相同的异常(示例
    • 在相应的 try 块中永远不会抛出检查异常(示例
  • 在全局作用域中报告抛出检查异常(示例

PHPStan 核心提供的特性和规则(我们依赖于此)

  • @throws 注释必须只包含有效的 Throwable 类型
  • 抛出的值必须是 Throwable 的子类

用法

要使用此扩展,请在 Composer 中引入它

composer require --dev pepakriz/phpstan-exception-rules

并在您项目的 PHPStan 配置中包含和配置 extension.neon

includes:
	- vendor/pepakriz/phpstan-exception-rules/extension.neon

parameters:
	exceptionRules:
		reportUnusedCatchesOfUncheckedExceptions: false
		reportUnusedCheckedThrowsInSubtypes: false
		reportCheckedThrowsInGlobalScope: false
		checkedExceptions:
			- RuntimeException

当您更愿意使用未检查的异常列表时,可以使用 uncheckedExceptions。这是一个更安全的变体,但更难适应现有项目。

parameters:
	exceptionRules:
		uncheckedExceptions:
			- LogicException
			- PHPUnit\Framework\Exception

checkedExceptionsuncheckedExceptions 不能同时配置

如果某些第三方代码定义了错误的抛出类型(或者根本不使用 @throws 注释),您可以像这样覆盖定义

parameters:
	exceptionRules:
		methodThrowTypeDeclarations:
			FooProject\SomeService:
				sendMessage:
					- FooProject\ConnectionTimeoutException
				methodWithoutException: []
		functionThrowTypeDeclarations:
			myFooFunction:
				- FooException

在某些情况下,您可能希望根据类的基础忽略与异常相关的错误,这在测试中通常是这种情况

parameters:
	exceptionRules:
		methodWhitelist:
			PHPUnit\Framework\TestCase: '#^(test|(setup|setupbeforeclass|teardown|teardownafterclass)$)#i'

可扩展性

动态抛出类型扩展 - 如果抛出类型不是始终相同,而是依赖于传递给方法的参数。(与 动态返回类型扩展 类似的功能)

有一些接口,您可以实现

  • Pepakriz\PHPStanExceptionRules\DynamicMethodThrowTypeExtension - 服务标签:exceptionRules.dynamicMethodThrowTypeExtension
  • Pepakriz\PHPStanExceptionRules\DynamicStaticMethodThrowTypeExtension - 服务标签:exceptionRules.dynamicStaticMethodThrowTypeExtension
  • Pepakriz\PHPStanExceptionRules\DynamicConstructorThrowTypeExtension - 服务标签:exceptionRules.dynamicConstructorThrowTypeExtension
  • Pepakriz\PHPStanExceptionRules\DynamicFunctionThrowTypeExtension - 服务标签:exceptionRules.dynamicFunctionThrowTypeExtension

并将它们注册为具有正确标签的服务

services:
	-
		class: App\PHPStan\EntityManagerDynamicMethodThrowTypeExtension
		tags:
			- exceptionRules.dynamicMethodThrowTypeExtension

动机

有两种类型的异常

  1. 安全检查,表示某些事情永远不应该发生(例如,你永远不应该在某些情况下调用某些方法等)。我们称这些为 LogicException,如果它们被抛出,则程序员犯了错误。因此,重要的是这个异常永远不要被捕获并终止应用程序。同样,编写良好的描述性消息以说明发生了什么以及如何修复它也很重要——这就是为什么每个 LogicException 都必须有一个消息。因此,继承 LogicException 没有多少意义。LogicException 也永远不应该有 @throws 注解(见下文)。
  2. 业务逻辑中的特殊情况,应由应用程序处理,以及无论如何努力都可能发生错误的情况(例如,HTTP 请求可能会失败)。我们称这些为 RuntimeException 或更好的“已检查异常”。所有这些异常都应该进行检查。因此,它必须要么被捕获,要么在 @throws 注解中编写。此外,如果您调用具有该注解的方法并且没有捕获异常,您必须在您的 @throws 注解中传播它。这当然可能会迅速传播。当此异常被处理(捕获)时,程序员立即知道处理了哪种情况非常重要,因此所有使用的 RuntimeException 都继承自某个父类,并且具有非常描述性的类名(因此您可以在捕获结构中看到它)——例如 CannotCloseAccountWithPositiveBalanceException。消息并不那么重要,因为您应该在某个地方始终捕获这些异常,但在我们的情况下,我们经常在 API 输出中使用该消息并将其显示给最终用户,因此请在这种情况下使用对用户有用的信息(您可以通过构造函数传递自定义参数(例如实体)来提供更好的消息)。有时您会遇到知道某些异常永远不会被抛出的地方——在这种情况下,您可以捕获它并将其包装到 LogicException 中(因为当它被抛出时,这是程序员的错误)。

始终将上一个异常包装起来是一个好主意,这样我们就不至于在日志中丢失真正发生的事情的信息。

// no throws annotation
public function decide(int $arg): void
{
	switch ($arg) {
		case self::ONE:
			$this->decided()
		case self::TWO:
			$this->decidedDifferently()
		default:
			throw new LogicException("Decision cannot be made for argument $arg because of ...");
	}
}

/**
 * @return mixed[]
 *
 * @throws PrintJobFailedException
 */
private function sendRequest(Request $request): array
{
	try {
		$response = $this->httpClient->send($request);
		return Json::decode((string) $response->getBody(), Json::FORCE_ARRAY);

	} catch (GuzzleException | JsonException $e) {
		throw new PrintJobFailedException($e);
	}
}

class PrintJobFailedException extends RuntimeException
{

	public function __construct(Throwable $previous)
	{
		parent::__construct('Printing failed, remote printing service is down. Please try again later', $previous);
	}

}

已知限制

匿名函数在声明的地方进行分析

如果方法没有执行声明的函数,则会产生误报

/**
 * @throws FooRuntimeException false positive
 */
public function createFnFoo(int $arg): callable
{
	return function () {
		throw new FooRuntimeException();
	};
}

但大多数用例都可以正常工作

/**
 * @param string[] $rows
 * @return string[]
 *
 * @throws EmptyLineException
 */
public function normalizeRows(array $rows): array
{
	return array_map(function (string $row): string {
		$row = trim($row);
		if ($row === '') {
			throw new EmptyLineException();
		}

		return $row;
	}, $rows);
}

Catch 语句不了解运行时子类型

此情况由规则检测到,因此您将收到有关潜在风险的警告。

运行时异常被吸收

// @throws phpdoc is not required
public function methodWithoutThrowsPhpDoc(): void
{
	try {
		throw new RuntimeException();
		$this->dangerousCall();

	} catch (Throwable $e) {
		throw $e;
	}
}

作为一种解决方案,您可以使用自定义的 catch 语句

/**
 * @throws RuntimeException
 */
public function methodWithThrowsPhpDoc(): void
{
	try {
		throw new RuntimeException();
		$this->dangerousCall();

	} catch (RuntimeException $e) {
		throw $e;
	} catch (Throwable $e) {
		throw $e;
	}
}