在過去的幾年里,PHP 中的靜態分析,更具體地說是 Laravel,變得越來越流行。 隨著越來越多的人在他們的軟件開發中采用它,我認為現在是編寫一篇關于如何將它添加到 Laravel 項目中的教程的好時機。
早在 2019 年,Nuno Maduro 發布了一個名為 Larastan 的包,這是一組適用于 Laravel 項目的 PHPStan 規則,我非常興奮。 到目前為止,我一直在努力使用 PHPStan 或 Psalm 在 Laravel 中獲得良好的靜態分析覆蓋率。 Larastans 規則允許我開始對我的代碼庫應用更多的靜態分析,進而對我的代碼更有信心。 在使用 PHP 8.1 和 Laravel 9 的現在 - 由于我可以使用大量令人驚嘆的工具,我對自己編寫的代碼感到前所未有的自信。
在本教程中,我會逐步將 Larastan 添加到新的 Laravel 項目中,將級別設置為最高。
先創建一個名為 larastan-test 的新 Laravel 項目:
laravel new larastan-test
新建項目后,安裝 Larastan,通過運行以下 composer 命令:
composer require nunomaduro/larastan --dev
我們希望它作為開發依賴項的原因是因為在生產中我們不應該運行任何靜態分析 - 它僅用于開發目的,以確保您的代碼盡可能安全。 PHPStan 使用一種稱為 neon 的配置格式,在某種程度上類似于 yaml。 因此,我們將在 out 應用程序的根目錄中創建一個名為 ./phpstan.neon 的新文件 - 如果您正在構建一個包,推薦的方法是將 .dist 添加到這些配置文件的末尾。 在這個文件中,我們將開始定義 phpstan 運行所需的配置以及我們可能想要強加的規則,將以下代碼添加到配置文件中,我們可以了解它的含義:
includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: paths: - app level: 9 ignoreErrors: excludePaths:
我們從 includes
開始,這些通常是我們希望包含在我們的基本 phpstan 規則集中的包中的規則。這個配置的參數部分,第一個選項 paths
允許我們定義我們希望 PHPStan 檢查的位置——在案例中,我們只需要聚焦到應用程序代碼所在的 app
目錄。如果你愿意,你可以將其擴展到覆蓋多個目錄,但要小心你所引入的范圍,因為所有的事情即將變得嚴格(嚴謹)!接下來,PHPStan 的 level
參數決定了可以檢查的各種級別,0 是最低的,9 目前是最高的。
如你所見,我們已將級別設置為 9,我建議在現有應用程序上這樣做,因為只有理想情況下你才達到這個級別 - 但由于這是一個全新的項目,我們可以在 9 時感到非常舒服(畢竟技術債務沒有那么多)。
接下來,ignoreErrors
和 excludePaths
這兩個選項允許我們告訴 PHPStan 忽略我們不感興趣的文件或特定的錯誤,比如現階段我們無法控制或修復的錯誤。也許你正在重構一些業務并且遇到了錯誤。你可能正在重構這段代碼,以便稍后進行靜態分析,那你可以通過這個配置,讓 PHPStan 在你結束重構前,忽略相關的錯誤。
includes
包含基本的 phpstan 的規則。parameters
配置參數,第一個選項 paths
配置 phpstan 檢查的目錄——在我的例子中,我只對應用程序代碼所在的 app
目錄進行檢查,當然您也可以配置其他目錄。 level
配置級別,PHPStan 可以配置各種級別,0 是最低的,9 目前是最高的。如您所見,我已將級別設置為 9,我建議將級別設置為 9。接下來有 ignoreErrors
和 excludePaths
這兩個選項告訴 PHPStan 忽略不檢測的文件或特定錯誤,或者現在不需要檢測的文件和錯誤。例如正在重構的代碼,您希望在完成之前忽略錯誤,完成后再進行靜態分析。
因此,讓我們針對默認的 Laravel 應用程序運行 phpstan,看看我們遇到了什么錯誤,如果有的話。在終端中運行以下命令:
./vendor/bin/phpstan analyse
我們從默認 Laravel 應用程序獲得的輸出如下所示:
Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon. 18/18 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100% ------ ---------------------------------------------------------------------------------------------------------------------------- Line Providers/RouteServiceProvider.php ------ ---------------------------------------------------------------------------------------------------------------------------- 49 Parameter #1 $key of method Illuminate\Cache\RateLimiting\Limit::by() expects string, int<min, -1>|int<1, max>|string|null given. ------ ---------------------------------------------------------------------------------------------------------------------------- [ERROR] Found 1 error
正如你所看到的,我們在默認的 Laravel 應用程序中只得到一個錯誤,即使我們將檢查的級別設置到了最嚴格的等級。
這很好,對吧?當然,如果你將其添加到現有項目中,你可能會看到不同的結果,按照本教程,你將學習如何解決這些問題,以便你有一個很好的工作流程可以遵循。
在 Laravel 應用程序運行 phpstan,如果發生錯誤。 在終端中運行以下命令:
./vendor/bin/phpstan analyse
輸出如下所示
Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon. 18/18 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100% ------ ---------------------------------------------------------------------------------------------------------------------------- Line Providers/RouteServiceProvider.php ------ ---------------------------------------------------------------------------------------------------------------------------- 49 Parameter #1 $key of method Illuminate\Cache\RateLimiting\Limit::by() expects string, int<min, -1>|int<1, max>|string|null given. ------ ---------------------------------------------------------------------------------------------------------------------------- [ERROR] Found 1 error
現在,我們在最嚴格的級別下,在默認的 Laravel 應用程序中也只得到一個錯誤。 當然,如果您將其添加到現有項目中,您可能會看到不同的結果,但是按照本教程,您將學習如何解決這些問題。
如果您希望有一種簡便的運行方式,可以將腳本添加到您的composer文件中來運行此命令,那么現在讓我們添加它,以便我們可以更輕松地運行此命令,將以下代碼塊添加到你的 composer.json
文件中:
"scripts": { "phpstan": [ "./vendor/bin/phpstan analyse" ] }, "scripts-descriptions": { "phpstan": "Run PHPStan static analysis against your application." },
你的 composer 文件中有了 scripts
記錄 - 只需將 phpstan
腳本附加到塊的末尾即可。 現在我們可以再次運行 PHPStan ,但這次使用 composer , 更容易輸入:
composer phpstan
所以當我們有 1 個錯誤時,查看對應的行,并且查看它當前的樣子:
protected function configureRateLimiting() { RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); }
本節開始,我們會聊聊靜態分析讓人抱怨的一些具體問題:
$request->user()?->id ?: $request->ip()
當我們想要獲取請求用戶,如果有的話返回ID,或者如果第一部分為空,則返回 IP 地址。在這個例子中,沒有真正的方法來確保這永遠是一個字符串,用戶可能是空的,請求 IP 也可能是空的。
這是你想要消除錯誤的情況,但因為它是來自供應商(第三方包)的代碼,你無法強制執行此操作。在這種特定情況下,你可以做的最好的事情是告訴 PHPStan 忽略該錯誤,但這不是全局性的。我們在這里要做的是添加一個命令塊而不是設置規則,以告訴 PHPStan 在分析此代碼時忽略此特定行。將此方法重構為如下所示:
protected function configureRateLimiting(): void { RateLimiter::for('api', static function (Request $request): Limit { /** @phpstan-ignore-next-line */ return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); }
我們為方法添加了返回類型,使回調成為靜態閉包 - 并提示返回類型。但隨后我們在返回值上方添加命令塊,告訴 PHPStan 我們要忽略下一行。如果我們現在再次在命令行中運行 PHPStan,你將看到以下輸出:
Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon. 18/18 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100% [OK] No errors
所以我們有默認的 Laravel 應用程序在 PHPStan 上運行,現在我們需要開始向我們的應用程序添加一些實際的邏輯,以便我們在添加功能和邏輯時可以確保類型安全。為此,我們將創建一個簡單的應用程序來存儲書簽,這沒什么特別的。
讓我們開始使用 artisan 添加模型,并使用 -mf 參數同時創建遷移任務和工廠模式:
php artisan make:model Bookmark -mf
其中,遷移任務的 up
方法如下所示:
Schema::create('bookmarks', static function (Blueprint $table): void { $table->id(); $table->string('name'); $table->string('url'); $table->boolean('starred')->default(false); $table->foreignId('user_id')->index()->constrained()->cascadeOnDelete(); $table->timestamps(); });
將以下代碼添加到我們的模型中:
class Bookmark extends Model { use HasFactory; protected $fillable = [ 'name', 'url', 'starred', 'user_id', ]; protected $casts = [ 'starred' => 'boolean', ]; /** * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo( related: User::class, foreignKey: 'user_id', ); } }
從上面可以看出,我們在這里唯一關心的是名稱、url,如果用戶想要加星標/收藏書簽并且該書簽屬于用戶。現在我們可以把它留在這里,但我個人喜歡將類型定義添加到我的模型屬性中——因為目前在 Laravel 9 中我無法輸入提示它們。因此,重構你的模型,使其如下所示:
class Bookmark extends Model { use HasFactory; /** * @var array<int,string> */ protected $fillable = [ 'name', 'url', 'starred', 'user_id', ]; /** * @var array<string,string> */ protected $casts = [ 'starred' => 'boolean', ]; /** * @return BelongsTo */ public function user(): BelongsTo { return $this->belongsTo( related: User::class, foreignKey: 'user_id', ); } }
我們在這里所做的只是告訴 PHP 和我們的 IDE,可填充數組是一個沒有鍵的字符串數組——這意味著它將默認為整數。然后我們的 casts 數組是一個帶鍵的字符串數組,其中的鍵也是字符串。現在,即使在沒有類型定義的情況下運行靜態分析,它也不會失敗 - 但這是一個很好的實踐,以便你的 IDE 在你工作時擁有盡可能多的信息。
讓我們繼續處理路由和控制器,以便我們可以繼續運行靜態分析檢查。現在我是可調用控制器的忠實粉絲——我發現它們非常適合我的代碼風格,但是你可能不喜歡它們或有不同的偏好,所以如果你是的話,下一部分可以隨意偏離我的編碼風格,會讓你更舒服。
我們現在將創建一個控制器,運行以下 artisan 命令來為書簽創建索引控制器:
php artisan make:controller Bookmarks/IndexController --invokable
這是我們路由所需的索引控制器,所以我們可以去添加一個新的路由組在 routes/web.php
:
Route::middleware(['auth'])->prefix('bookmarks')->as('bookmarks:')->group(static function (): void { Route::get('/', App\Http\Controllers\Bookmarks\IndexController::class)->name('index'); });
添加在在我們的 auth 中間件中,以便我們控制作者對書簽的訪問,我們還希望在 bookmarks
下為所有路由添加前綴,并將該組的命名策略設置為 bookmarks:*
。 如果我們現在在我們的代碼庫上運行我們的靜態分析,我們會看到一些錯誤,但這主要是因為我們的控制器中沒有內容:
composer phpstan
Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon. 20/20 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100% ------ ------------------------------------------------------------------------------------------------- Line Http/Controllers/Bookmarks/IndexController.php ------ ------------------------------------------------------------------------------------------------- 15 Method App\Http\Controllers\Bookmarks\IndexController::__invoke() has no return type specified. ------ ------------------------------------------------------------------------------------------------- ------ ----------------------------------------------------------------------------------------------------------------------------- Line Models/Bookmark.php ------ ----------------------------------------------------------------------------------------------------------------------------- 33 Method App\Models\Bookmark::user() return type with generic class Illuminate\Database\Eloquent\Relations\BelongsTo does not specify its types: TRelatedModel, TChildModel ? You can turn this off by setting checkGenericClassInNonGenericObjectType: false in your phpstan.neon. ------ ----------------------------------------------------------------------------------------------------------------------------- ------ ---------------------------------------------------------------------------------------------------------------------------- Line Models/User.php ------ ---------------------------------------------------------------------------------------------------------------------------- 49 Method App\Models\User::bookmarks() return type with generic class Illuminate\Database\Eloquent\Relations\HasMany does not specify its types: TRelatedModel ? You can turn this off by setting checkGenericClassInNonGenericObjectType: false in your phpstan.neon. ------ ---------------------------------------------------------------------------------------------------------------------------- [ERROR] Found 3 errors
擺在我面前的第一個錯誤是 Method App\Models\User::bookmarks() return type with generic class
。現在我不想在這個應用中過度依賴通用類型。這一錯誤實際上告訴我們可以做什么,所以讓我們將checkGenericClassInNonGenericObjectType: false
添加到我們的 phpstan.neon
文件中:
includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: paths: - app level: 9 ignoreErrors: excludePaths: checkGenericClassInNonGenericObjectType: false
現在,如果我們再次運行分析,將只有 5 個錯誤,這些錯誤都和控制器相關 - 讓我們從 IndexController
開始,看看我們能做些什么。像這樣重構 IndexController
:
class IndexController extends Controller { public function __invoke(Request $request) { return View::make( view: 'bookmarks.list', data: [ 'bookmarks' => Bookmark::query() ->where('user_id', $request->user()->id) ->paginate(), ] ); } }
如果我們現在對我們的代碼進行靜態分析,并且只關注正在使用的控制器,我們將看到如下問題:
------ ------------------------------------------------------------------------------------------------- Line Http/Controllers/Bookmarks/IndexController.php ------ ------------------------------------------------------------------------------------------------- 15 Method App\Http\Controllers\Bookmarks\IndexController::__invoke() has no return type specified. 21 Cannot access property $id on App\Models\User|null. ------ -------------------------------------------------------------------------------------------------
那么我們對這兩個錯誤能做些什么呢?第一個相對容易修復,我們可以添加返回類型:
public function __invoke(Request $request): \Illuminate\Contracts\View\View
我們可以對此約束起個別名,使之看起來更為美觀:
public function __invoke(Request $request): ViewContract
然而下一個問題,Cannot access property $id on App\Models\User|null.
,類似于我們在默認 Laravel 應用中,在請求的用戶可以為空的情況下去獲取ID時會碰到的問題。因此我用以解決此問題的方法是,使用 Auth 的輔助函數直接從 Auth 守衛中獲取 ID。重構查詢如下:
Bookmark::query() ->where('user_id', auth()->id()) ->paginate()
使用 Auth 的 ID 方法,我們直接從認證守衛中獲取 ID,而不是從可能是 null 的請求(request)中獲取。需要記住的一點是,如果路由沒有使用認證中間件,那么 id 方法會出現“正在嘗試獲取 null 的屬性ID(you are trying to get the property ID of null)”的報錯。因此,請記得為該路由設置對應中間件。
現在,如果我們再次運行靜態分析,我們應該已經消除了這些錯誤:
composer phpstan
Note: Using configuration file /Users/steve/code/sites/larastan-test/phpstan.neon. 20/20 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100% [OK] No errors
既然 IndexController
已經沒有錯誤了。下一步我們要做的是遍歷我們的應用,確保在重要的節點中都運行靜態分析檢查。我們最不想做的事情就是等到 sprint 格式化打印結束,或者在添加新功能來運行它時,才發現我們必須花費無數個小時來修復靜態分析問題。無論如何,到最后 - 你將擁有可信任的代碼了,這也是我通常喜歡使用靜態分析的一個重要原因。如果你可以配合好的測試套件進行靜態分析,那么就沒有理由不信任你的代碼。
你的項目使用了 Larastan 嗎? 你敢把驗證級別提高到最高嗎? 在推特上告訴我們, 或者讓我們知道你的恐怖故事!
原文地址:https://laravel-news.com/running-phpstan-on-max-with-laravel
譯文地址:https://learnku.com/laravel/t/69412