Phead, A PHP code generator
結果在此: https://github.com/absszero/phead/
開發 API 的節奏大致底定。開立路由、建立控制器、建立 Model、表現層以及撰寫相關測試。由於過程諸多類似,想必一定有可以簡化的方法。目前覺得最煩心的部分是開立檔案的同時也要開立相對的空目錄。雖然可以透過 ./arstian make:model MyDir/MyModel
的方式產生相對應的目錄跟檔案,但檔案一多這個操作就不是那麼俐落。於是就有了代碼產生器的概念。
初期訂定的目標希望能達到幾個功能:
- 可以從一個 yaml 設定檔設定需要多個檔案。
- 可以添加新的方法到既有的類別。
- 可以沿用原本的 stub file。
- 可以在程式裡頭參照其他這次產生的檔案。
- 可以帶入變數。
既然要建立一個 PHP 指令列工具, 首選當然是 Symfony Console,然後搭配 Symfony Yaml 作為 Yaml 解析。 接下來我找到 Laminas\Code\Generator 用於程式碼產生。會選擇該套件的主要原因是因為它支援添加新方法到既有類別。
// https://docs.laminas.dev/laminas-code/generator/examples/#add-code-to-existing-php-files-and-classes
$generator = ClassGenerator::fromReflection(
new ClassReflection($class)
);
$generator->addMethod(
'setBaz',
['baz'],
MethodGenerator::FLAG_PUBLIC,
'$this->baz = $baz;' . "\n" . 'return $this;',
(new DocBlockGenerator())
->setShortDescription('Set the baz property')
->setTags([
new Tag\ParamTag('baz', ['string']),
new Tag\ReturnTag('string'),
])
);
Use 的引用問題
一開始認為這個方案很完美,有一個現成產生器作為基礎,但後續應用上遇到兩個問題不得不棄坑。首先所謂添加新方法需要先解析當前的類別。背後當然是透過 Reflection 實現。然而在開發指令的同時,用來參考的類別檔案多半是假的,或者從原本程式裡頭拔出來使用的類別檔案。裡面諸多類別引用並不存在我當前開發專案。最簡單控制器的例子:
namespace App\Http\Controllers;
use Illuminate\View\View;
class UserController extends Controller
{
public function show(string $id): View
{
return view('user.profile');
}
}
Reflection 需要載入這個類別才能進行解析。 Illuminate\View\View
不存在於當前專案會報錯。雖然真實專案上這些類別會存在,解析不會有問題。但目前即便只是最小幅度開發,為了引入這些類別可能需要添加整個 Laravel 類別也說不定。
接著另外一個問題是對於 use
關鍵字的引用。 Laminas\Code\Generator 無法正確產生使用 use 關鍵字的類別檔案。它的方式是載入該類別,而輸出的時候則是 重新產生整個檔案內容
。原本 use 的部分會被移除變成絕對參照。假設上面的例子會變成底下:
namespace App\Http\Controllers;
class UserController extends Controller
{
public function show(string $id): \Illuminate\View\View
{
return view('user.profile');
}
}
應該說這是 Reflection 的緣故,似乎在 use 方面沒有完整的支援。我嘗試另外找了一個對於 Reflection 的強化套件 roave/better-reflection。它標榜提供 Reflection 更多功能與改進。其中提到的 Loading a class from a string 很符合我的期待。但對於 use 的使用是否如我預期,後續沒有繼續嘗試下去。畢竟初期目標是一個簡單的產生器,不希望有太多其他套件的引用。
既然無法精準頗析並添加新的方法,後續先用暴力添加的方式,直接把新的方法加到類別的底部。
Layout 結構
關於這個 Yaml 格式的設定檔我將它稱呼為 Layout
。主要的結構包含 $globals
跟 $files
。 $globals 定義整個過程中會用到的變數;$files 則是要產生的檔案來源(from) 跟目的地(to),以及它所屬的變數(vars)。簡單的結構如下:
$globals:
dir: Hello
$files:
my_model:
vars:
foo: bar
from: |
<?php
namespace {{ namespace }};
class {{ class }}
{
public $foo = '{{ foo }}';
}
to: "{{ $globals.dir }}/MyModel.php"
methods:
- |
public function getAll()
{
return self::all();
}
首先解析的時候會經過幾個流程,主要是將變數替換掉內容的佔位符:
- 載入檔案,將沒有正確設定
from
,to
的檔案過濾掉。 - 取代環境變數。環境變數可以用
user: '{{ $env.USER }}'
的方式定義。 - 取代 $globals 變數。
- 收集檔案變數 vars 並且取代。
- 添加方法到類別。
上述的 yaml 內容最後變成如下:
$globals:
dir: Hello
$files:
model:
vars:
foo: bar
namespace: Hello
class: MyHotel
from: |
<?php
namespace Hello;
class MyModel
{
public $foo = 'bar';
}
to: "Hello/MyModel.php"
methods:
- |
public function getAll()
{
return self::all();
}
檔案的 vars 會自動添加兩個變數 namespace
, class
根據檔案的 to
設定來推導。當然可以自己設定進去取代它。
from
除了可以是字串也可以是一個路徑,指定一個 stub 檔案。所以可以搭配 Laravel 所提供的指令 php artisan stub:publish
匯出 stub 檔案來使用。佔位符的格式跟 Laravel 一致,使用 {{ YOUR_VAR }}
。不過這個工具不限於使用在 Laravel,所有 PHP 開發都是適用的。
關於命名
產生器的任務是為開發工作起個頭,在英文裡頭可能會用 beginning
, start
。為了跟中文 起頭
更為接近,我查了一下 head
的確也有類似的意思。所以一開始真的就決定以 head
作為指令名稱。
head
the top part or beginning of something.
但開發到了中期,發現 Unix 已經有相同名稱的指令。隨後想到這是一個 PHP 的工具。就把 P
加到命名裡頭,於是有了 phead
。
從 Google 翻譯了解到這個字是愛爾蘭蓋爾語,起源追朔到愛爾蘭姓氏 McPhadden。裡頭說明了這個字的意思。(突然高大上起來了 😮…..)
The name is derived from the Gaelic word phead, which translates as “lord” or “master”.