Posted on

PHPのちょっとしたパッケージを作ろうと思ったら「ちょっとした」規模にならなかった件

# 長すぎる前置き

LaravelなりCakePHPなり、バニラなものにいろいろと「これはどこでも使うやろ」という基本的な機能を追加したのを「アプリケーション側」に置いたような状態のリポジトリを引き出してから開発を始めてたんです。この長年のやり方に疑問を覚えるような出来事がありまして。

🤔

たとえば、Laravelで使っていた基礎ビューテンプレートがありました。したら、誰かがあるプロジェクトで「なんか埋め込んでたjsが思ったように動かん」ってなって、ビュー本体のほうに追記しちゃった。誰もそんなことすぐにわかるもんじゃないから、あちらが立てばこちらが立たぬ状況が訪れてしまい、基本機能を開発した私に「どうにかして」と話が回ってきたんです。

😱

その時は基礎ビューをもとに戻して、アプリケーション側に必要な変更は基礎ビューを継承する形で対処したんですが、構造的な限界を感じたんです。どこをいじったらどこにどんな影響が出るか、誰もはっきりわからないんです。そりゃそうです。基礎機能を置いた継承元ファイルがほぼプロジェクトで追加するファイルと同じ層にずらりですから、境界もあやふやになる。

😇

ここに「パッケージ」という天啓が降りるまで時間はかかりませんでした。境界がはっきりすれば、誰も「こっから先をいじると他に支障が出そう」ってわかるんじゃない?

🤯

最初は基本機能を丸ごと抜き取ってパッケージにできないかと思ってたんですが、これが異様なほど分量多いのと、「基本機能」と一口で言っても、実態として関係性が薄いいくつかの機能の集まりだったので、一度断念しました。とりあえず間に合わせに「このファイルいじるな」リストだけ書いて共有したんですが…

🙁

そんでもあきらめきれなくて、「とりあえずこの都道府県クラスだけ簡単に抜き出してみよう、練習にはちょうどいいよね」という気持ちで始めたのが…険しい道のりの始まりでした。

# 前置きここまで

今まで使ってた都道府県クラス(PrefDef)、やってることはとてもシンプルで、

  • 都道府県Enum的なもの
  • 地域区分Enum的なもの
  • 都道府県リストを地域区分でフィルタリングしたりしなかったりして配列返す

このぐらいのことだったんです。1クラス。
パッケージ化したらどうなったかって…本体10ファイル+単体テスト6ファイル+αにばらすことになりました。

🤣

パッケージ化するにあたってすぐ、単純かつ異様にめんどいことに気づいたんです。

「デフォルトで中部地方というひとまとまり区分を、甲信越と北陸と東海に分けるシステムがあるかもしれない」

アプリケーションに置きっぱなしのPrefDefなら、中の地域区分を勝手に書き換えてもらってよろしかったんです。
でもパッケージにするなら、さすがに都道府県までいじれるようにしておくのはよろしくない。都道府県だけだったらほぼ絶対変わらないし、もし万が一統廃合があったとして、それはもうパッケージ本体が責任持つべきことですから。
しかし地域区分に関してはそうもいかない。都道府県の変更を阻止しつつ、地域区分だけアプリケーションの都合に合わせられるようにするにはどうしたらいいんだろうってなったんです。

「地域区分だけ設定ファイルを公開して、それを読み込めるようにしよう」

というわけで、こんなのをrequireしてもらったら何とかなるかと思ったんです。

use Okushin\PrefDef\Core\PrefDefStatic as Core; //Core::都道府県という定数がずらりという感覚のクラスを使ってます
return [
    'regions' => [
        'Hokkaido' => [
            'name' => '北海道',
            'prefectures' => [
                Core::HOKKAIDO
            ]
        ],
        //云々
    ]
];

甘かった。実に甘かった。設定ファイルのひな形を作っているときにすら2~3か所”prefectures”を”prefecture”と単数形で書いていたことに気づかなかったから、この設定ファイルをシステムの都合に合わせて調整する人が同じ失敗をしないわけがない!

「そんな時に『やばいよやばいよ』と教えてくれる誰かが要る…」

というわけで、設定ファイルのバリデータもご用意。

use InvalidArgumentException;

class ConfigValidator
{
    public static function validate(array $settings):void{

        //regions:配列必須
        if(!isset($settings['regions']) || !is_array($settings['regions'])){
            throw new InvalidArgumentException("'regions' を配列で指定してください");
        }

        foreach($settings['regions'] as $base_key => $region_settings){
            //name:文字列必須
            if(!isset($region_settings['name']) || !is_string($region_settings['name'])){
                throw new InvalidArgumentException("地域{$base_key}:'name' を文字列で指定してください");
            }
            //prefectures:配列必須
            if(!isset($region_settings['prefectures']) || !is_array($region_settings['prefectures'])){
                throw new InvalidArgumentException("地域{$base_key}:'prefectures' を配列で指定してください");
            }
            //prefecturesの中身:都道府県定数、かつ重複不可
            foreach($region_settings['prefectures'] as $pref_code){
                if(!in_array($pref_code,range(1,47))){
                    throw new InvalidArgumentException("地域{$base_key}:{$pref_code}は正しい都道府県コードではありません");
                }
            }
        }
    }
}

「このパッケージをLaravelだけで使うのもったいないな…素PHPレベルで動けばうれしい…うれしいね…」

Laravelのときは設定ファイルの内容をconfigに流して、それ以外の時はbootstrap.php的なもので設定ファイルを読み込んで、専用の設定掘り出し用クラスを使う…ような流れを考えることになります。

とあるAIに訊いたら、こういう時に『アダプタ』と『ファサード』を作るのだって言ってました。何が言いたいかというと、”設定用クラスのふりをしたもの(ファサード)”を用意して置いて、その実態には環境に応じて”configへの横流し屋(アダプタ)”か”専用の設定掘り出し用クラス”を埋め込んでもらう、みたいなやり口を使うってことです。

// laravelを使っている場合に使う、configへの横流し屋 ~pref_defを添えて~
class LaravelConfigAdapter
{
    public function __construct(){
        ConfigValidator::validate(\config('pref_def'));
    }

    public function get(string $key,$default=null){
        return \config("pref_def.{$key}",$default);
    }
}
//設定用クラスもどき(ファサード) 
namespace Okushin\PrefDef\Facades //ファサード階層にまとめてツッコむことにした
class Config
{
    protected static $instance;

    public static function set($instance):void
    {
        static::$instance = $instance;
    }

    public static function get(string $key, $default = null){
        if (!static::$instance) {
            throw new \RuntimeException('ConfigFacade instance not set.');
        }

        return static::$instance->get($key, $default);
    }
}

この「もどき」を都道府県パッケージ本体に飲ませて、同じget()ながら異なるルートから設定を使えるようにするわけです。

「で、この『もどき』の設定をどこですればええのん?」

サービスプロバイダでした。このパッケージにLaravel向けのサービスプロバイダ書いてあげたら、(そしてcomposer.jsonによしなに書いてあげたら)、
そこに書いてあることを初期化でやってくれるようなのです。(まだここよくわかってないので今後書きます)

//パッケージ側のcomposer.jsonに書くこと
{
    //云々
    "extra":{
        "laravel": {
            "providers": [
                "Okushin\\PrefDef\\Laravel\\PrefDefServiceProvider" //このサービスプロバイダ使えよォォォ!とアピールする
            ]
        }
    }
}
//肝心のサービスプロバイダ
use Okushin\PrefDef;
class PrefDefServiceProvider extends \Illuminate\Support\ServiceProvider
{
    public function boot()
    {
        //やってること1⃣:「artisan君、うちの設定ファイルここにあるねんけど、vendor:publishでここに持ってってくれん?」
        $this->publishes([
            __DIR__ . '/../Config/default.php' => config_path('pref_def.php'),
        ],'pref_def-config');

        //やってること2⃣:「pref_def.php」の内容をconfig()で読めるよう統合しなくちゃ
        $this->mergeConfigFrom(
            __DIR__.'/../../config/pref_def.php','pref_def'
        );

        //やってること3⃣:Configもどきに横流し屋をあてがっておく
        PrefDef\Facades\Config::set(new PrefDef\Config\LaravelConfigAdapter());
    }

    public function register(){

    }
}

素PHPの時は、プロジェクトごとに初期処理で走らすようなbootstrap.phpみたいなものを用意してもらってと

require 'vendor/autoload.php';

use Okushin\PrefDef\Facades\Config;
use Okushin\PrefDef\Config\Config as CoreConfig; //別名を使えばどってことない

$pref_def_config = require __DIR__."../config/pref_def.php"; //pref_def.phpを実際に置いたパスに合わせるべし
Config::set(new CoreConfig($pref_def_config));//Configもどきに実のConfigクラスを飲ませる

こればっかりは自動的に作るわけにもいかんので、READMEにそういう記述を入れとくことにしました。

他にも単体テスト作って通したり、やっぱりPrefDef本体もファサードあるといいよね、みたいになったりしたけれど、本質的に「プラグインを作った」って話はこんなところです。
あとは、アプリケーション側からこのプラグインを取り込む、というところで composer.jsonにいろいろ書いて、composer updateするし、php artisan vendor:publishも忘れずに。

「長い道のりだった…」

もともと1ファイル1クラスだったものも、プラグインとして本気で組んでみると「ユーザーに公開する部分」と「しまい込んで置く部分」の切り分け、その連携についてより解像度を高くする必要がありました。
あと、composerの使い方も。プラグイン側とアプリケーション側、両方の情報がかみ合っていないとcomposerの力が使えないので、phpでパッケージ作るならしっかり書けるようにしたいですね。
とりあえずこの2点だけでも学んだだけ収穫です。

今後も分割できそうな部分プラグイン化して、知見を書いてみたいですが、今回は他の仕事もあるので、とりあえずここまで。