結果在此: 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();
        }        

首先解析的時候會經過幾個流程,主要是將變數替換掉內容的佔位符:

  1. 載入檔案,將沒有正確設定 from, to 的檔案過濾掉。
  2. 取代環境變數。環境變數可以用 user: '{{ $env.USER }}' 的方式定義。
  3. 取代 $globals 變數。
  4. 收集檔案變數 vars 並且取代。
  5. 添加方法到類別。

上述的 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”.