怎么給類添加元數(shù)據(jù))
前言先糾兩處事實(shí)Attribute屬性也有人譯作注解是 PHP 8.0 引入的不是 8.1。RFC 的名字是 Attributes v2隨 PHP 8.0 落地。PHP 8.1 增加的是內(nèi)置屬性里的第一個(gè)成員#[\ReturnTypeWillChange]機(jī)制本身在 8.0 就有了。本文按PHP 8.0講機(jī)制末尾單獨(dú)列一張內(nèi)置屬性與版本對(duì)照表。它不是一個(gè)函數(shù)。Attribute 是一套語法#[...]加反射讀取接口沒有任何一個(gè)叫attribute()的函數(shù)。它要做的事以前是靠 docblock 注釋做的。說說以前那種做法為什么會(huì)出問題。老框架里給控制器打路由寫的是注釋?php /** * Route(/users/{id}) * Method(GET) */ public function show(int $id) {}框架啟動(dòng)時(shí)用正則去掃這些注釋。癥狀就很明確了類名寫錯(cuò)了Rout、Route(/x少個(gè)括號(hào)不報(bào)任何錯(cuò)只是這條路由神秘消失注釋里的類名在 IDE 里不能跳轉(zhuǎn)重構(gòu)重命名之后注釋里的引用全部變成死鏈只能靠人工全局搜索想給參數(shù)加類型約束比如Param(id, typeint, min1)解析器要自己寫一套迷你語法注釋是字符串沒有類型也沒有工具能校驗(yàn)。Attribute 把這些藏在字符串里的元數(shù)據(jù)變成了語法結(jié)構(gòu)寫錯(cuò)了是語法錯(cuò)誤參數(shù)有類型IDE 能補(bǔ)全、能跳轉(zhuǎn)反射 API 能直接讀。一、Attribute 的兩段式編譯期掛載、運(yùn)行期讀取Attribute 的語法很簡(jiǎn)單#[開頭]結(jié)尾里面是一個(gè)或多個(gè)可以用在常量表達(dá)式位置的類實(shí)例化寫法。?php #[Route(/users, [GET])] // 位置參數(shù) #[Route(/users/create, [POST])] // 同一個(gè)目標(biāo)上可以疊多個(gè)需要 IS_REPEATABLE #[Middleware(Auth::class, priority: 10)] // 命名參數(shù)8.0 也支持 public function users() {}它是一個(gè)兩段式機(jī)制這一點(diǎn)是理解一切坑點(diǎn)的前提階段發(fā)生了什么會(huì)不會(huì)報(bào)錯(cuò)編譯期編譯器把#[...]里的內(nèi)容記成一個(gè)待實(shí)例化的描述掛到對(duì)應(yīng)的語法節(jié)點(diǎn)上只檢查語法。類名不存在、目標(biāo)類型不匹配都不報(bào)錯(cuò)運(yùn)行期你主動(dòng)調(diào)用ReflectionAttribute::newInstance()時(shí)才真正new出那個(gè)類的實(shí)例這時(shí)才會(huì)報(bào)類不存在不能用在方法上之類的錯(cuò)和注釋解析相比它的收益是對(duì)比項(xiàng)docblock 注釋Attribute語法校驗(yàn)無寫錯(cuò)靜默失效有括號(hào)不配對(duì)直接語法錯(cuò)誤類型全是字符串構(gòu)造函數(shù)有類型聲明重構(gòu)重命名不會(huì)跟著改IDE 能識(shí)別、能跳轉(zhuǎn)參數(shù)校驗(yàn)自己寫解析器構(gòu)造函數(shù)自己校驗(yàn)讀取方式正則匹配getAttributes()生效時(shí)機(jī)解析到就生效框架自定只有newInstance()時(shí)才實(shí)例化二、定義一個(gè) Attribute 類Attribute 的定義就是一個(gè)普通的 PHP 類只是必須在類上再打一個(gè)#[Attribute]?php declare(strict_types1); // 最低版本PHP 8.0 use Attribute; #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class Route { /** * 構(gòu)造函數(shù)的參數(shù)就是使用處 #[Route(...)] 里能寫的東西 * param string[] $methods */ public function __construct( public string $path, public array $methods [GET], public string $name , ) {} }#[Attribute]自己的參數(shù)是兩個(gè)位掩碼常量含義Attribute::TARGET_CLASS只能用在類、接口、trait、枚舉上Attribute::TARGET_FUNCTION只能用在函數(shù)上Attribute::TARGET_METHOD只能用在方法上Attribute::TARGET_PROPERTY只能用在屬性上Attribute::TARGET_CLASS_CONSTANT只能用在類常量上Attribute::TARGET_PARAMETER只能用在參數(shù)上Attribute::TARGET_ALL全部允許這是不寫參數(shù)時(shí)的默認(rèn)值A(chǔ)ttribute::IS_REPEATABLE同一個(gè)目標(biāo)上可以重復(fù)使用多次兩點(diǎn)必須記住TARGET_ALL是默認(rèn)值。不寫參數(shù)不等于嚴(yán)格恰恰相反等于哪兒都能用。要限制用途必須顯式寫。目標(biāo)類型不匹配不是編譯錯(cuò)誤。你把一個(gè)標(biāo)了TARGET_METHOD的屬性用在屬性上PHP 編譯時(shí)不會(huì)攔你只有在newInstance()時(shí)才會(huì)拋Error。所以寫錯(cuò)了卻一直沒發(fā)現(xiàn)是完全可能的——只要那段代碼路徑?jīng)]被反射讀到。三、讀出來反射 API讀取入口分布在各個(gè)反射類上名字統(tǒng)一叫g(shù)etAttributes()目標(biāo)反射類方法類ReflectionClassgetAttributes()方法ReflectionMethodgetAttributes()屬性ReflectionPropertygetAttributes()參數(shù)ReflectionParametergetAttributes()類常量ReflectionClassConstantgetAttributes()函數(shù)ReflectionFunctiongetAttributes()它們返回的是ReflectionAttribute對(duì)象的數(shù)組這個(gè)對(duì)象只有三個(gè)方法方法返回說明getName()string屬性的完整類名getArguments()array使用處寫了的那幾個(gè)參數(shù)不做默認(rèn)值填充、不做類型轉(zhuǎn)換newInstance()object真正實(shí)例化屬性類這一步才會(huì)做類型檢查、套用構(gòu)造函數(shù)的默認(rèn)值getAttributes()的第一個(gè)參數(shù)可以傳一個(gè)類名做過濾第二個(gè)參數(shù)可以傳ReflectionAttribute::IS_INSTANCEOF表示按 instanceof 匹配——這個(gè)常量是PHP 8.0引入的配合屬性的類可以被繼承使用。四、實(shí)戰(zhàn)路由 字段映射下面這份代碼可以在 PHP 8.0 上直接運(yùn)行。它演示三件事用 Attribute 收集路由、用 Attribute 做數(shù)據(jù)庫字段到對(duì)象屬性的映射、以及newInstance()到底在什么時(shí)候才真正執(zhí)行。?php declare(strict_types1); /** * Attribute 實(shí)戰(zhàn)路由收集 字段映射 * 最低版本PHP 8.0 * Attribute 語法本身是 8.0 引入的示例里沒有使用 8.1 的枚舉與只讀屬性 */ #[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] final class Route { /** param string[] $methods */ public function __construct( public string $path, public array $methods [GET], public string $name , ) { foreach ($methods as $m) { if (!in_array($m, [GET, POST, PUT, DELETE], true)) { throw new InvalidArgumentException(不支持的 HTTP 方法: {$m}); } } } } #[Attribute(Attribute::TARGET_PROPERTY)] final class Column { public function __construct( public string $name, public bool $required false, ) {} } /* ---------------- 被標(biāo)記的類 ---------------- */ final class UserController { #[Route(/users, [GET], name: user.index)] #[Route(/users/create, [POST], name: user.create)] public function users(): string { return 用戶列表; } #[Route(/users/{id}, [GET])] public function show(int $id 0): string { return 用戶 . $id; } } final class UserDto { #[Column(user_id)] public int $id 0; #[Column(nickname, required: true)] public string $nickname ; #[Column(email)] public string $email ; } /* ---------------- 讀取 Attribute 的工具 ---------------- */ /** 收集一個(gè)控制器上的全部路由 */ function collectRoutes(string $controllerClass): array { $ref new ReflectionClass($controllerClass); $routes []; foreach ($ref-getMethods(ReflectionMethod::IS_PUBLIC) as $method) { // 第二個(gè)參數(shù)用 IS_INSTANCEOF子類化的屬性也能被匹配到 $attributes $method-getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF); foreach ($attributes as $attribute) { /** var Route $route */ $route $attribute-newInstance(); // 到這里才真正 new Route(...) foreach ($route-methods as $verb) { $routes[] sprintf( %-6s %-16s - %s::%s, $verb, $route-path, $ref-getShortName(), $method-getName() ); } } } return $routes; } /** 把一行數(shù)據(jù)庫數(shù)據(jù)填進(jìn) DTO字段名按 #[Column] 映射 */ function hydrate(string $class, array $row): object { $ref new ReflectionClass($class); $obj $ref-newInstanceWithoutConstructor(); foreach ($ref-getProperties() as $prop) { $attributes $prop-getAttributes(Column::class); if ($attributes []) { continue; } /** var Column $column */ $column $attributes[0]-newInstance(); $value $row[$column-name] ?? null; if ($value null) { if ($column-required) { throw new InvalidArgumentException(字段 {$column-name} 不能為空); } continue; // 非必填字段缺失就跳過保留屬性默認(rèn)值 } $type $prop-getType(); if ($type instanceof ReflectionNamedType $type-getName() int) { $value (int) $value; } $prop-setValue($obj, $value); } return $obj; } /* ---------------- 演示 ---------------- */ foreach (collectRoutes(UserController::class) as $line) { echo $line, PHP_EOL; } echo PHP_EOL; $user hydrate(UserDto::class, [user_id 42, nickname 張三, email ab.c]); printf(id%d nickname%s email%s\n, $user-id, $user-nickname, $user-email); try { hydrate(UserDto::class, [user_id 42]); // 缺 nickname } catch (InvalidArgumentException $e) { echo 捕獲: , $e-getMessage(), PHP_EOL; } // 只讀參數(shù)不實(shí)例化屬性類也不會(huì)報(bào)錯(cuò)拿到的是寫出來的那幾個(gè)參數(shù) $prop new ReflectionProperty(UserDto::class, nickname); var_dump($prop-getAttributes(Column::class)[0]-getArguments());輸出GET /users - UserController::users POST /users/create - UserController::users GET /users/{id} - UserController::show id42 nickname張三 emailab.c 捕獲: 字段 nickname 不能為空 array(2) { [0] string(8) nickname [1] bool(true) }注意最后一段getArguments()拿到的是使用處寫出來的參數(shù)nickname和true而不是實(shí)例化之后的屬性值。要拿到構(gòu)造函數(shù)處理過的對(duì)象必須走newInstance()。五、內(nèi)置屬性與版本對(duì)照內(nèi)置屬性的版本分布很分散這張表經(jīng)常被記混內(nèi)置屬性引入版本用途#[\ReturnTypeWillChange]PHP 8.1給實(shí)現(xiàn)了內(nèi)部接口的方法豁免臨時(shí)返回類型檢查#[\AllowDynamicProperties]PHP 8.2允許類使用動(dòng)態(tài)屬性配合 8.2 的動(dòng)態(tài)屬性棄用#[\SensitiveParameter]PHP 8.2在堆棧跟蹤里隱藏敏感參數(shù)的值#[\Override]PHP 8.3聲明這個(gè)方法覆寫了父類/接口的方法寫錯(cuò)會(huì)報(bào)錯(cuò)#[\Deprecated]PHP 8.4標(biāo)記函數(shù)/方法/常量已棄用所以PHP 8.1 的 attribute這個(gè)說法可以這樣理解機(jī)制是 8.0 的8.1 貢獻(xiàn)的是第一個(gè)內(nèi)置屬性。真正讓 Attribute 變得好用有框架統(tǒng)一注冊(cè)、有 IDE 支持的是各框架自己的實(shí)現(xiàn)而不是 PHP 的版本號(hào)。常見坑點(diǎn)1. 忘了給屬性類加#[Attribute]? 錯(cuò)誤寫法?php final class Route // 少了 #[Attribute] { public function __construct(public string $path) {} } $attr (new ReflectionMethod(Foo::class, bar))-getAttributes(Route::class)[0]; $attr-newInstance(); // Error: Attempting to use non-attribute class Route? 正確寫法?php #[Attribute(Attribute::TARGET_METHOD)] final class Route { public function __construct(public string $path) {} }這個(gè)錯(cuò)誤只在你主動(dòng)讀取的時(shí)候才出現(xiàn)所以屬性類寫完了、代碼也跑得通、就是功能沒生效這種癥狀八成就是漏了這一行。2. 直接取getAttributes()[0]而不判斷為空? 錯(cuò)誤寫法?php $route (new ReflectionMethod($c, $m))-getAttributes(Route::class)[0]-newInstance(); // 沒有這個(gè)屬性時(shí)Warning: Undefined array key 0然后在對(duì) null 調(diào)方法? 正確寫法?php $attributes (new ReflectionMethod($c, $m))-getAttributes(Route::class); if ($attributes []) { continue; // 沒標(biāo)記就跳過 } $route $attributes[0]-newInstance();3. 以為目標(biāo)不匹配會(huì)在編譯期報(bào)錯(cuò)? 錯(cuò)誤認(rèn)知給一個(gè)標(biāo)了TARGET_PROPERTY的屬性寫到方法上以為 PHP 會(huì)立刻報(bào)錯(cuò)。? 事實(shí)編譯期不報(bào)錯(cuò)只有newInstance()時(shí)才拋Error。所以這類錯(cuò)誤可能潛伏很久——直到某天有個(gè)新接口開始反射這個(gè)方法。寫完屬性后第一時(shí)間寫一段反射讀取的冒煙測(cè)試比等框架啟動(dòng)時(shí)才發(fā)現(xiàn)要快得多。4. 以為子類會(huì)繼承父類上的 Attribute? 錯(cuò)誤寫法?php #[Entity] class BaseModel {} final class User extends BaseModel {} $attrs (new ReflectionClass(User::class))-getAttributes(Entity::class); var_dump($attrs); // array(0) {} —— 一個(gè)都沒有? 正確寫法需要繼承語義就自己沿父類鏈往上找?php function findAttribute(ReflectionClass $ref, string $name): ?ReflectionAttribute { do { $found $ref-getAttributes($name); if ($found ! []) { return $found[0]; } $ref $ref-getParentClass(); } while ($ref ! false); return null; }$ref-getParentClass()在沒有父類時(shí)返回false循環(huán)條件寫! false才是對(duì)的寫! null會(huì)死循環(huán)。5. 在#[...]里寫函數(shù)調(diào)用或變量? 錯(cuò)誤寫法?php #[Route(/users/ . $version)] // 變量不行 #[Route(strtoupper(/users))] // 函數(shù)調(diào)用不行 #[Route(null ?? /x)] // 表達(dá)式不行? 正確寫法#[...]里只允許常量表達(dá)式——字面量、常量、類常量、數(shù)組字面量、::class以及 PHP 8.1 起允許的new是的new出現(xiàn)在初始值里是 8.1 的特性不是 8.0。需要?jiǎng)討B(tài)路徑就寫到配置文件里別塞進(jìn)屬性。6. 用getName() Route做匹配? 錯(cuò)誤寫法?php foreach ($method-getAttributes() as $attr) { if ($attr-getName() Route) { // 字符串比較子類化屬性匹配不到 // ... } }? 正確寫法用IS_INSTANCEOF過濾讓框架支持用戶繼承 Route 做擴(kuò)展這種常見需求?php $routes $method-getAttributes(Route::class, ReflectionAttribute::IS_INSTANCEOF);注意getName()返回的是完整類名帶命名空間拿短名去比一定不相等。7. 在循環(huán)里反復(fù)newInstance()? 錯(cuò)誤寫法?php foreach ($methods as $method) { foreach ($method-getAttributes() as $attr) { // 每次都給同一個(gè)屬性創(chuàng)建一個(gè)新對(duì)象 $obj $attr-newInstance(); } }? 正確寫法newInstance()每次調(diào)用都返回一個(gè)新對(duì)象而且會(huì)執(zhí)行構(gòu)造函數(shù)——構(gòu)造函數(shù)里如果有校驗(yàn)邏輯、有 I/O、有緩存寫入成本就上去了。需要復(fù)用就自己建立一次并緩存?php $cache []; $key $declaringClass . :: . $property; $cache[$key] ?? $attributes[0]-newInstance();8. 只讀getArguments()卻依賴構(gòu)造函數(shù)的默認(rèn)值? 錯(cuò)誤寫法?php #[Column(nickname)] // required 參數(shù)沒寫指望它等于默認(rèn)值 true public string $nickname; $args $prop-getAttributes(Column::class)[0]-getArguments(); // 拿到的是 [nickname]一個(gè)元素構(gòu)造函數(shù)的 requiredfalse 沒有被填進(jìn)來? 正確寫法getArguments()給的是寫出來的原始參數(shù)不做默認(rèn)值填充、也不做類型轉(zhuǎn)換。要拿到處理后的結(jié)果必須newInstance()再讀屬性。反過來說如果只是想看看寫了什么用getArguments()更快、也不會(huì)觸發(fā)構(gòu)造函數(shù)的副作用??偨Y(jié)需求做法版本要點(diǎn)給類/方法/屬性加元數(shù)據(jù)#[Foo(...)]Attribute 機(jī)制是PHP 8.0定義可用的屬性類類上再打#[Attribute(...)]漏了就報(bào)Attempting to use non-attribute class限制使用位置Attribute::TARGET_*位掩碼不寫等于TARGET_ALL不寫反而更寬松允許重復(fù)標(biāo)記加Attribute::IS_REPEATABLE否則同一個(gè)目標(biāo)上疊兩個(gè)會(huì)報(bào)錯(cuò)讀取各反射類的getAttributes()類/方法/屬性/參數(shù)/常量/函數(shù)都有實(shí)例化ReflectionAttribute::newInstance()只有這一步才做類型校驗(yàn)與默認(rèn)值填充按是不是某類的子類匹配傳ReflectionAttribute::IS_INSTANCEOF該常量是PHP 8.0引入的內(nèi)置屬性見上面那張表8.1 / 8.2 / 8.3 / 8.4 各有一個(gè)回頭再看標(biāo)題里的兩個(gè)說法Attribute 不是函數(shù)是一套語法 反射接口的機(jī)制它屬于 PHP 8.0不屬于 8.1——8.1 帶來的是#[\ReturnTypeWillChange]這個(gè)內(nèi)置屬性。搞清這兩點(diǎn)之后用起來其實(shí)只有一條核心規(guī)則#[...]只是掛上去真正的語義全在newInstance()那一刻才發(fā)生所以任何 Attribute 都要配一段反射讀取的代碼否則它就真的只是注釋。