WordPress 是一個歷史悠久的內容管理系統,但也是使用最多的一種。由於支援過時的 PHP 版本和遺留程式碼,WordPress 在實施現代編碼實踐方面仍然存在不足,WordPress 抽象就是一個例子。
例如,如果能將 WordPress 核心程式碼庫拆分成由 Composer 管理的軟體包,效果會好得多。又或者,從檔案路徑自動載入 WordPress 類。
本文將詳細介紹程式碼抽象相關的資訊,WordPress 程式碼抽象的最佳實踐及相關外掛。
WordPress 與 PHP 工具的整合問題
由於其古老的架構,我們在將 WordPress 與 PHP 程式碼庫工具(如靜態分析器 PHPStan、單元測試庫 PHPUnit 和名稱空間範圍庫 PHP-Scoper)整合時偶爾會遇到問題。例如,請考慮以下情況:
- 在 WordPress 5.6 支援 PHP 8.0 之前,Yoast 的一份報告描述了在 WordPress 核心上執行 PHPStan 會產生數千個問題。
- 由於WordPress仍支援PHP 5.6,因此 WordPress 測試套件目前只支援 PHPUnit 到 7.5 版本,而 7.5 版本的生命週期已經結束。
- 通過 PHP-Scoper 擴充套件 WordPress 外掛非常具有挑戰性。
在我們的專案中,WordPress 程式碼只佔總程式碼的一小部分;專案還將包含與底層 CMS 無關的業務程式碼。然而,僅僅因為有一些 WordPress 程式碼,專案就可能無法與工具正常整合。
有鑑於此,將專案拆分成若干個軟體包,其中一些包含 WordPress 程式碼,另一些則只包含使用 “vanilla” PHP 的業務程式碼,而不包含 WordPress 程式碼。這樣,後面這些軟體包就不會受到上述問題的影響,而是可以與工具完美整合。
什麼是程式碼抽象?
程式碼抽象可以消除程式碼中的固定依賴關係,生成通過契約相互影響的軟體包。這些軟體包可以通過不同的堆疊新增到不同的應用程式中,從而最大限度地提高其可用性。程式碼抽象的結果是基於以下支柱的簡潔的解耦程式碼庫:
- 針對介面而非實現進行編碼。
- 建立包並通過 Composer 釋出。
- 通過依賴注入將所有部分粘合在一起。
針對介面而非實現進行編碼
針對介面編碼是指使用合約讓程式碼片段相互互動的做法。合約是一個簡單的 PHP 介面(或任何其他語言),它定義了哪些函式可用及其簽名,即它們接收哪些輸入及其輸出。
介面宣告瞭功能的意圖,但不解釋功能將如何實現。通過介面訪問功能,我們的應用程式可以依賴自主的程式碼片段來實現特定目標,而無需知道或關心它們是如何實現的。這樣,應用程式就不需要進行調整,就能切換到另一段程式碼來實現相同的目標,例如,從不同的提供商那裡獲取程式碼。
合約示例
下面的程式碼使用了 Symfony 的合約 CacheInterface
和 PHP 標準建議(PSR)合約 CacheItemInterface
來實現快取功能:
use Psr\Cache\CacheItemInterface; use Symfony\Contracts\Cache\CacheInterface; $value = $cache->get('my_cache_key', function (CacheItemInterface $item) { $item->expiresAfter(3600); return 'foobar'; });
$cache
實現了 CacheInterface
,它定義了從快取中獲取物件的 get
方法。通過合約訪問該功能,應用程式可以忽略快取的位置。無論它是在記憶體、磁碟、資料庫、網路還是其他任何地方。但它仍然必須執行該功能。CacheItemInterface
定義了 expiresAfter
方法,用於宣告專案必須在快取中保留多長時間。應用程式可以呼叫該方法,而不必關心快取的物件是什麼;它只關心該物件必須被快取多長時間。
針對 WordPress 中的介面編碼
由於我們對 WordPress 程式碼進行了抽象,因此應用程式不會直接引用 WordPress 程式碼,而總是通過介面來引用。例如,WordPress的函式 get_posts
就有這樣的簽名:
/** * @param array $args * @return WP_Post[]|int[] Array of post objects or post IDs. */ function get_posts( $args = null )
我們可以通過 Owner\MyApp\Contracts\PostsAPIInterface
合同來訪問它,而不是直接呼叫這個方法:
namespace Owner\MyApp\Contracts; interface PostAPIInterface { public function get_posts(array $args = null): PostInterface[]|int[]; }
請注意,WordPress 函式 get_posts
可以返回類 WP_Post
的物件,這是 WordPress 特有的。在抽象程式碼時,我們需要刪除這種固定的依賴關係。合約中的 get_posts
方法會返回 PostInterface
型別的物件,這樣就可以不明確地引用 WP_Post
類。PostInterface
類需要提供對 WP_Post
所有方法和屬性的訪問許可權:
namespace Owner\MyApp\Contracts; interface PostInterface { public function get_ID(): int; public function get_post_author(): string; public function get_post_date(): string; // ... }
執行這一策略可以改變我們對 WordPress 在堆疊中位置的理解。我們不再把 WordPress 視為應用程式本身(我們在其上安裝主題和外掛),而是將其視為應用程式中的另一個依賴項,與其他元件一樣可以替換。(儘管我們在實踐中不會替換 WordPress,但從概念上講,它是可以替換的)。
建立和分發軟體包
Composer 是 PHP 的軟體包管理器。它允許 PHP 應用程式從資源庫中獲取軟體包(即程式碼片段),並將其作為依賴項安裝。為了將應用程式與 WordPress 解耦,我們必須將其程式碼分成兩種不同型別的包:包含 WordPress 程式碼的包和包含業務邏輯(即不包含 WordPress 程式碼)的包。
最後,我們將所有軟體包作為依賴項新增到應用程式中,並通過 Composer 進行安裝。由於工具將應用於業務程式碼包,因此這些包必須包含應用程式的大部分程式碼;百分比越高越好。讓它們管理整個程式碼的 90% 左右是個不錯的目標。
將 WordPress 程式碼提取到程式碼包中
按照前面的例子,PostAPIInterface
和 PostInterface
合同將被新增到包含業務程式碼的包中,而另一個包將包含這些合同的 WordPress 實現。為了滿足 PostInterface
的要求,我們建立了一個 PostWrapper
類,它將從 WP_Post
物件中獲取所有屬性:
namespace Owner\MyAppForWP\ContractImplementations; use Owner\MyApp\Contracts\PostInterface; use WP_Post; class PostWrapper implements PostInterface { private WP_Post $post; public function __construct(WP_Post $post) { $this->post = $post; } public function get_ID(): int { return $this->post->ID; } public function get_post_author(): string { return $this->post->post_author; } public function get_post_date(): string { return $this->post->post_date; } // ... }
在實現 PostAPI
時,由於 get_posts
方法返回 PostInterface[]
,我們必須將 WP_Post
物件轉換為 PostWrapper
物件:
namespace Owner\MyAppForWP\ContractImplementations; use Owner\MyApp\Contracts\PostAPIInterface; use WP_Post; class PostAPI implements PostAPIInterface { public function get_posts(array $args = null): PostInterface[]|int[] { // This var will contain WP_Post[] or int[] $wpPosts = \get_posts($args); // Convert WP_Post[] to PostWrapper[] return array_map( function (WP_Post|int $post) { if ($post instanceof WP_Post) { return new PostWrapper($post); } return $post }, $wpPosts ); } }
使用依賴注入
依賴注入是一種設計模式,可讓您以鬆散耦合的方式將所有應用程式部分粘合在一起。通過依賴注入,應用程式通過合約訪問服務,而合約實現則通過配置 “注入 “到應用程式中。
只需更改配置,我們就能輕鬆地從一個合約提供者切換到另一個合約提供者。我們可以選擇多種依賴注入庫。我們建議選擇一個符合 PHP 標準建議(通常稱為 “PSR”)的庫,這樣我們就可以在需要時輕鬆地用另一個庫代替。關於依賴注入,庫必須符合 PSR-11,它提供了 “容器介面” 的規範。以下庫符合 PSR-11 標準:
- Symfony’s DependencyInjection
- PHP-DI
- Aura.Di
- Container (Dependency Injection)
- Yii Dependency Injection
通過服務容器訪問服務
依賴注入庫將提供一個 “服務容器”,它將合同解析為相應的實現類。應用程式必須依賴服務容器來訪問所有功能。例如,我們通常會直接呼叫 WordPress 函式:
$posts = get_posts();
……有了服務容器,我們必須首先獲取滿足 PostAPIInterface
的服務,並通過它執行功能:
use Owner\MyApp\Contracts\PostAPIInterface; // Obtain the service container, as specified by the library we use $serviceContainer = ContainerBuilderFactory::getInstance(); // The obtained service will be of class Owner\MyAppForWP\ContractImplementations\PostAPI $postAPI = $serviceContainer->get(PostAPIInterface::class); // Now we can invoke the WordPress functionality $posts = $postAPI->get_posts();
使用 Symfony 的依賴注入
Symfony 的 DependencyInjection 元件是目前最流行的依賴注入庫。它允許你通過 PHP、YAML 或 XML 程式碼來配置服務容器。例如,要通過類 PostAPI
來定義 PostAPIInterface
合同,可以這樣在 YAML 中配置:
services: Owner\MyApp\Contracts\PostAPIInterface: class: \Owner\MyAppForWP\ContractImplementations\PostAPI
Symfony 的依賴注入(DependencyInjection)還允許將一個服務的例項自動注入(或 “autowired”)到依賴於它的任何其他服務中。此外,它還可以輕鬆定義一個類是其自身服務的實現。例如,請看下面的 YAML 配置:
services: _defaults: public: true autowire: true GraphQLAPI\GraphQLAPI\Registries\UserAuthorizationSchemeRegistryInterface: class: '\GraphQLAPI\GraphQLAPI\Registries\UserAuthorizationSchemeRegistry' GraphQLAPI\GraphQLAPI\Security\UserAuthorizationInterface: class: '\GraphQLAPI\GraphQLAPI\Security\UserAuthorization' GraphQLAPI\GraphQLAPI\Security\UserAuthorizationSchemes\: resource: '../src/Security/UserAuthorizationSchemes/*'
該配置定義了以下內容:
- 通過類
UserAuthorizationSchemeRegistry
滿足合約UserAuthorizationSchemeRegistryInterface
- 通過類
UserAuthorization
滿足合約UserAuthorizationInterface
UserAuthorizationSchemes/
資料夾下的所有類都是其自身的實現- 服務之間必須自動注入(
autowire: true
)
讓我們看看自動連線是如何工作的。UserAuthorization
類依賴於帶有 UserAuthorizationSchemeRegistryInterface
合約的服務:
class UserAuthorization implements UserAuthorizationInterface { public function __construct( protected UserAuthorizationSchemeRegistryInterface $userAuthorizationSchemeRegistry ) { } // ... }
由於使用了 autowire: true
,DependencyInjection 元件將自動讓 UserAuthorization
服務接收其所需的依賴關係,即 UserAuthorizationSchemeRegistry
的例項。
何時抽象
抽象程式碼會耗費大量的時間和精力,因此我們只有在利大於弊的情況下才進行抽象。以下是值得抽象程式碼的建議。您可以使用本文中的程式碼片段或下面建議的抽象 WordPress 外掛來實現這一點。
獲取工具
如前所述,在 WordPress 上執行 PHP-Scoper 是很困難的。通過將 WordPress 程式碼解耦為不同的軟體包,就可以直接使用 WordPress 外掛。
減少工具開發時間和成本
執行 PHPUnit 測試套件時,需要初始化和執行 WordPress 的時間比不需要初始化和執行 WordPress 的時間長。時間越少,執行測試所需的費用也就越少 – 例如,GitHub Actions 會根據使用時間對 GitHub 託管的執行程式收費。
不需要大量重構
現有專案可能需要進行大量重構才能引入所需的架構(依賴注入、將程式碼拆分成包等),因此很難抽離。在從頭開始建立專案時對程式碼進行抽象,則更易於管理。
為多個平臺生成程式碼
通過將 90% 的程式碼提取到與 CMS 無關的軟體包中,我們只需替換整個程式碼庫中的 10%,就能生成適用於不同 CMS 或框架的庫版本。
遷移到不同的平臺
如果我們需要將一個專案從 Drupal 遷移到 WordPress、從 WordPress 遷移到 Laravel 或其他任何組合,那麼只需重寫 10%的程式碼,這將大大節省成本。
最佳實踐
在設計合同以抽象我們的程式碼時,我們可以對程式碼庫進行一些改進。
遵守 PSR-12
在定義訪問 WordPress 方法的介面時,我們應遵守 PSR-12。這一最新規範旨在減少掃描不同作者程式碼時的認知摩擦。遵守 PSR-12 意味著重新命名 WordPress 函式。
WordPress 使用 snake_case 寫為函式命名,而 PSR-12 則使用 camelCase。因此,函式 get_posts
將變為 getPosts
:
interface PostAPIInterface { public function getPosts(array $args = null): PostInterface[]|int[]; }
……還有:
class PostAPI implements PostAPIInterface { public function getPosts(array $args = null): PostInterface[]|int[] { // This var will contain WP_Post[] or int[] $wpPosts = \get_posts($args); // Rest of the code // ... } }
拆分方法
介面中的方法不必完全照搬 WordPress 中的方法。只要合理,我們就可以對它們進行轉換。例如,WordPress 函式 get_user_by($field,$value)
知道如何通過引數 $field 從資料庫中檢索使用者,該引數可接受的值有 "id"
, "ID"
, "slug"
, "email"
或 "login"
。這種設計存在一些問題:
- 如果我們傳遞了一個錯誤的字串,它不會在編譯時失敗
- 引數
$value
需要接受所有選項的所有不同型別,即使在傳遞"ID"
時,它期望的是一個int
,但在傳遞"email"
時,它只能接收一個string
。
我們可以通過將函式拆分成幾個函式來改善這種情況:
namespace Owner\MyApp\Contracts; interface UserAPIInterface { public function getUserById(int $id): ?UserInterface; public function getUserByEmail(string $email): ?UserInterface; public function getUserBySlug(string $slug): ?UserInterface; public function getUserByLogin(string $login): ?UserInterface; }
對於 WordPress 來說,合同是這樣解決的(假設我們已經建立了 UserWrapper
和 UserInterface
,如前所述):
namespace Owner\MyAppForWP\ContractImplementations; use Owner\MyApp\Contracts\UserAPIInterface; class UserAPI implements UserAPIInterface { public function getUserById(int $id): ?UserInterface { return $this->getUserByProp('id', $id); } public function getUserByEmail(string $email): ?UserInterface { return $this->getUserByProp('email', $email); } public function getUserBySlug(string $slug): ?UserInterface { return $this->getUserByProp('slug', $slug); } public function getUserByLogin(string $login): ?UserInterface { return $this->getUserByProp('login', $login); } private function getUserByProp(string $prop, int|string $value): ?UserInterface { if ($user = \get_user_by($prop, $value)) { return new UserWrapper($user); } return null; } }
刪除函式簽名中的實現細節
WordPress 中的函式可能會在自己的簽名中提供如何實現的資訊。在從抽象的角度評估函式時,可以刪除這些資訊。例如,WordPress 中獲取使用者姓氏的方法是呼叫 get_the_author_meta
,明確說明使用者姓氏是作為 “meta” 值儲存的(在 wp_usermeta
表中):
$userLastname = get_the_author_meta("user_lastname", $user_id);
您不必將這些資訊傳達給合同。介面只關心 “是什麼”,而不關心 “怎麼做”。因此,合約中可以有一個 getUserLastname
方法,但不提供任何關於如何實現的資訊:
interface UserAPIInterface { public function getUserLastname(UserWrapper $userWrapper): string; ... }
新增更嚴格的型別
WordPress 的某些函式可以以不同的方式接收引數,從而導致歧義。例如,函式 add_query_arg
可以接收單個鍵和值:
$url = add_query_arg('id', 5, $url);
… 或 key => value
的陣列:
$url = add_query_arg(['id' => 5], $url);
我們的介面可以將這些函式拆分成幾個獨立的函式,每個函式接受一個獨特的輸入組合,從而定義一個更易於理解的意圖:
public function addQueryArg(string $key, string $value, string $url); public function addQueryArgs(array $keyValues, string $url);
消除技術債務
WordPress 函式 get_posts
不僅返回 “帖子”,也返回 “頁面 “或任何 “自定義帖子” 型別的實體,而這些實體是不能互換的。帖子和頁面都是自定義帖子,但頁面既不是帖子,也不是頁面。因此,執行 get_posts
可以返回頁面。這種行為是概念上的差異。
為使其正確,get_posts
應被稱為 get_customposts
,但 WordPress 核心從未對其進行重新命名。這是大多數長期軟體的常見問題,被稱為 “技術債務”-程式碼存在問題,但由於引入了破壞性更改而從未得到修復。
不過,在建立合同時,我們有機會避免這種技術債務。在這種情況下,我們可以建立一個新的介面 ModelAPIInterface
,它可以處理不同型別的實體,然後我們再建立幾個方法,每個方法處理不同型別的實體:
interface ModelAPIInterface { public function getPosts(array $args): array; public function getPages(array $args): array; public function getCustomPosts(array $args): array; }
這樣,就不會再出現差異,您將看到這些結果:
getPosts
只返回帖子getPages
只返回頁面getCustomPosts
返回帖子和頁面
抽象程式碼的好處
抽象應用程式程式碼的主要優點有:
- 在僅包含業務程式碼的軟體包上執行的工具更易於設定,執行所需的時間(和資金)也更少。
- 我們可以使用與 WordPress 不相容的工具,例如使用 PHP-Scoper 對外掛進行掃描。
- 我們製作的軟體包可以很容易地自主用於其他應用程式。
- 將應用程式遷移到其他平臺變得更加容易。
- 我們可以將思維方式從 WordPress 轉變為業務邏輯思維。
- 合約描述了應用程式的意圖,使其更易於理解。
- 應用程式通過軟體包進行組織,建立一個包含最基本內容的精簡應用程式,並根據需要逐步增強。
- 我們可以清除技術債務。
抽象程式碼的問題
抽象應用程式程式碼的缺點是:
- 一開始需要做大量工作。
- 程式碼變得更加冗長;為了實現同樣的結果,需要增加額外的程式碼層。
- 最終可能會產生幾十個軟體包,而這些軟體包又必須進行管理和維護。
- 您可能需要一個 monorepo 來統一管理所有軟體包。
- 依賴注入對於簡單的應用程式來說可能是矯枉過正(收益遞減)。
- 抽象程式碼永遠無法完全實現,因為 CMS 的架構中通常隱含著一種普遍的偏好。
抽象 WordPress 外掛選項
雖然通常最明智的做法是先將程式碼提取到本地環境,然後再對其進行處理,但一些 WordPress 外掛可以幫助你實現抽象目標。這些是我們的首選。
1. WPide
由 WebFactory Ltd 製作的 WPide 外掛很受歡迎,它極大地擴充套件了 WordPress 預設程式碼編輯器的功能。作為一款抽象的 WordPress 外掛,它允許您在原處檢視程式碼,以便更直觀地瞭解需要注意的地方。
WPide 外掛
WPide 還具有搜尋和替換功能,可快速查詢過時或過期的程式碼,並用重構後的程式碼進行替換。
除此之外,WPide 還提供了大量額外功能,包括:
- 語法和程式碼塊高亮
- 自動備份
- 建立檔案和資料夾
- 全面的檔案樹瀏覽器
- 訪問 WordPress 檔案系統 API
2. Ultimate DB Manager
來自 WPHobby 的 Ultimate WP DB Manager 外掛為您提供了一種快速方法,可完整下載您的資料庫,以便提取和重構。
Ultimate DB Manager 外掛
3. 自己定製的抽象 WordPress 外掛
最後,抽象的最佳選擇永遠是建立自己的外掛。這看似是一項大工程,但如果你直接管理 WordPress 核心檔案的能力有限,這就提供了一種便於抽象的變通辦法。
這樣做的好處顯而易見:
- 從主題檔案中抽取功能
- 在主題更改和資料庫更新時保留程式碼
您可以通過 WordPress 的《外掛開發者手冊》瞭解如何建立抽象 WordPress 外掛。
小結
我們是否應該對應用程式中的程式碼進行抽象?就像任何事情一樣,沒有預定義的 “正確答案”,因為這取決於每個專案的具體情況。那些需要花費大量時間使用 PHPUnit 或 PHPStan 進行分析的專案可以從中獲益最多,但付出的努力並不總是值得的。
您已經瞭解了開始抽象 WordPress 程式碼所需的一切知識。
你打算在你的專案中實施這一策略嗎?如果是,您會使用抽象 WordPress 外掛嗎?請在評論區告訴我們!
評論留言