🐘

Readable 正規表現 @PHPerKaigi 2024

2024/03/13に公開

readable regex

About

先日, 中野セントラルパークカンファレンスで行われたPHPerKaigi 2024で「Readable 正規表現」というタイトルでトークさせていただきました. この記事は発表を補足追加したzenn版です.

トップバッターで大変緊張したのですが, 多くの方に参加・感想をいただき大変感謝しております. 少しでも誰かの知識に貢献できていれば幸いです.

GitHub

PHPerKaigi 2024で実際に使用したGitHub. 当日はこちらとスライド両方でコードを読めるようにしていました. スターいただけるとモチベになるのでよければお願いします⭐️
GitHub: Readable Regex

Speech

中野で発表した内容を文書化しつつ, 不足部分を補っています.

導入

  • 正規表現は簡潔な文法で文字列のほぼ全てを表現できる強力なツールである
  • 一方で, 現場では嫌われている (複雑・壊れやすい)
  • なぜだろうか

恐ろしい正規表現の作り方

  • セッションでは電話番号のValidationをする正規表現で行う事例を紹介した
    • 以下がサンプルコード
    • 正規表現/^([\d]{3,4}-?){3}$/が存在している
`Unreadable Regex Example`
class unreadable_regex
{
    /**
     * @param string $phoneNumber
     * @return bool
     */
    public static function isValidPhoneNumber(#[SensitiveParameter] string $phoneNumber): bool
    {
        // この関数は、電話番号のフォーマットが正しいかどうかをチェックする
        // [bad] 関心が大きすぎて管理不可能 (下記詳細)
        //
        // 1. 入力値が信頼できない
        // - stringなので全角か半角どちらにも対応する必要が出る
        // - また、数字以外の文字が含まれている場合もある
        // 2. 複数のフォーマットを一度に判別しようとしている
        // - 000-000-0000, 0000-000-0000, 000-0000-0000, 119など
        // 3. 1, 2により正規表現の複雑度が上がる
        // - フォーマットが複数あるので if, forに当たる演算子で対応
        // - \dは言語ごとに仕様が違うので、非推奨
        //
        // 1, 2, 3によりテストが困難. 信頼できない.
        $regex = '/^([\d]{3,4}-?){3}$/';
        if (preg_match($regex, $phoneNumber) === false) {
            throw new InvalidArgumentException("Invalid phone number format");
        }
        return true;
    }
}

GitHubでコードを読みたい方はこちら

  • 正規表現に関心を詰め込む

    • Validator: 存在しない文字を検知しエラーを返す
    • Normalizer: 表記揺れを吸収する
    • Pattern Checker: 文字列の並び順を判断・どのフォーマットかの確認
  • 神正規表現の誕生

    • 正規表現が複雑化する => ifやforにあたる文法の多用
    • このValidatorを好きなプログラミング言語で実装することを考えてみると...
      • (.*){3}: for
      • .{3,4}: for
      • \d, -?: if
    • 複雑化した正規表現をテストしようと大量のテストケースを作る => 変更を加えるとどこかのテストケースが破綻する => 変更を加えられない
    • 「trueかfalseかは神のみぞ知る」恐ろしいコード、降臨

本質的な問題

  • ソフトウェア開発におけるSOLID原則を守っていない
    • Wiki SOLID 原則
    • 上記のコードは単一の正規表現に複数の役割を持たせてしまっていた

解決方法

  • 詰め込まれた関心を分離する
    • Validator: 存在しない文字を検知しエラーを返す
    • Normalizer: 表記揺れを吸収する
    • ([A-Z][a-z]+)+Number: 文字列の並び順を判断
    • PhoneNumberFactory: どのフォーマットかの確認
`Readable Regex Example`
readonly class ValidatedInputPhoneNumber
{
    public string $value;

    public function __construct(#[SensitiveParameter] string $phoneNumber)
    {
        $validCharacterOfPhoneNumber = '/[^0-90-9\-ー]+/u';
        if (preg_match($validCharacterOfPhoneNumber, $phoneNumber)) {
            throw new InvalidArgumentException("Invalid phone number format");
        }

        $this->value = $phoneNumber;
    }
}

readonly class NormalizedInputPhoneNumber
{
    public string $value;

    public function __construct(#[SensitiveParameter] ValidatedInputPhoneNumber $phoneNumber)
    {
        // 入力には全角数字とハイフン, 半角数字のハイフンのみが許可されている
        // 全角数字を半角数字に変換 (例: 0 -> 0)
        // mb_convert_kanaは機能が多いため, 今回は半角数字に変換するnだけを使う
        $phoneNumberWithoutFullWidthDigit = mb_convert_kana($phoneNumber->value, 'n');

        $phoneNumberWithoutFullwidthHyphen = str_replace('ー', '-', $phoneNumberWithoutFullWidthDigit);

        $this->value = $phoneNumberWithoutFullwidthHyphen;
    }
}

// Interfaceには I{Name} とつける流派もあるが今回の例では使わない
// 理由は I 接頭辞をつけると IPhoneNumber という名前になるが、iPhoneと混同される可能性があるため
// 参照: https://qiita.com/suin/items/00656d9dbd2f26dd8b91
interface PhoneNumberInterface {
    public static function matchFormat(NormalizedInputPhoneNumber $phoneNumber): bool;
}


readonly class MobilePhoneNumber implements PhoneNumberInterface
{
    public string $phoneNumber;

    public function __construct(#[SensitiveParameter] NormalizedInputPhoneNumber $phoneNumber)
    {
        if (self::matchFormat($phoneNumber) === false) {
            throw new InvalidArgumentException("Invalid phone number format");
        }
        $this->phoneNumber = $phoneNumber->value;
    }

    /**
     * @param NormalizedInputPhoneNumber $phoneNumber
     * @return bool
     */
    public static function matchFormat(#[SensitiveParameter] NormalizedInputPhoneNumber $phoneNumber): bool
    {
        // [good] NormalizedInputPhoneNumberのvalueは半角数字とハイフンのみ
        // [good] この関数は半角数字とハイフンの並び順だけを判断する
        $mobilePhoneNumberFormat = '/^[0-9]{3}-[0-9]{4}-[0-9]{4}$/';
        $is_valid = preg_match($mobilePhoneNumberFormat, $phoneNumber->value);
        return $is_valid === 1;
    }
}

readonly class ServiceProviderNumber implements PhoneNumberInterface
{
    public string $phoneNumber;

    public function __construct(#[SensitiveParameter] NormalizedInputPhoneNumber $phoneNumber)
    {
        if (self::matchFormat($phoneNumber) === false) {
            throw new InvalidArgumentException("Invalid phone number format");
        }
        $this->phoneNumber = $phoneNumber->value;
    }

    /**
     * @param NormalizedInputPhoneNumber $phoneNumber
     * @return bool
     */
    public static function matchFormat(#[SensitiveParameter] NormalizedInputPhoneNumber $phoneNumber): bool
    {
        // [good] NormalizedInputPhoneNumberのvalueは半角数字とハイフンのみ
        // [good] この関数は半角数字とハイフンの並び順だけを判断する
        $serviceProviderNumberFormat = '/^[0-9]{4}-[0-9]{3}-[0-9]{3}$/';
        $is_valid = preg_match($serviceProviderNumberFormat, $phoneNumber->value);
        return $is_valid === 1;
    }
}

readonly class PhoneNumberFactory
{
    /**
     * @param NormalizedInputPhoneNumber $phoneNumber
     * @return MobilePhoneNumber|ServiceProviderNumber
     */
    public static function create(#[SensitiveParameter] NormalizedInputPhoneNumber $phoneNumber): MobilePhoneNumber|ServiceProviderNumber
    {
        // [good] regexを確認するUnitな関数を並べておくとテストしやすい
        // (補足) function(function(function(string text)))のようにネストするとテストが難しい
        return match (true) {
            MobilePhoneNumber::matchFormat($phoneNumber) => new MobilePhoneNumber($phoneNumber),
            ServiceProviderNumber::matchFormat($phoneNumber) => new ServiceProviderNumber($phoneNumber),
            default => throw new InvalidArgumentException("error (check your input): ". $phoneNumber->value),
        };
    }
}

GitHubでコードを読みたい方はこちら

  • 関心を分離した事例
    • Validator => Normalizer => Pattern Match (Factory)というような構成
    • Factoryが『「Validateした文字列をvalueとして持つClass」を引数にとるNomalizeするClass』を引数にとるという実装
    • Validatorは異常な文字がないかのみ確認する (入力はstring全て)
    • Normalizerは全角数字とハイフンを半角にすることのみ (入力はValidator Classで保証)
    • Factoryは半角数字とハイフンの並び順だけを確認する (入力はNormalizer Classで保証)
readonly class PhoneNumberFactory
{
    /**
     * @param NormalizedInputPhoneNumber $phoneNumber
     * @return MobilePhoneNumber|ServiceProviderNumber
     */
    public static function create(#[SensitiveParameter] NormalizedInputPhoneNumber $phoneNumber): MobilePhoneNumber|ServiceProviderNumber
    {
        // [good] regexを確認するUnitな関数を並べておくとテストしやすい
        // (補足) function(function(function(string text)))のようにネストするとテストが難しい
        return match (true) {
            MobilePhoneNumber::matchFormat($phoneNumber) => new MobilePhoneNumber($phoneNumber),
            ServiceProviderNumber::matchFormat($phoneNumber) => new ServiceProviderNumber($phoneNumber),
            default => throw new InvalidArgumentException("error (check your input): ". $phoneNumber->value),
        };
    }
}
  • 関心を分離することでプログラマは分離した概念に名前をつける
    • 名前がつくことでそのコードが読みやすくなる
    • 間違っていることに気付くことができる
// 携帯電話の番号
mobilePhoneFormat="[0-9]{3}-[0-9]{4}-[0-9]{4}"
// フリーダイヤルやナビダイヤルなどのサービス専用番号
serviceHotlineFormat="[0-9]{4}-[0-9]{3}-[0-9]{3}"
// 119、110、117などの公共の緊急サービス番号
publicServiceFormat="1[0-9]{2}"
  • 例えば, 上記のmobilePhoneFormat"[0-9]{2}-[0-9]{4}-[0-9]{4}”であった場合, debugの時に「携帯電話なのに始まりが二文字はおかしくない?」とバグに気付くことができる

  • テストを書く

    • 正常系・異常系のテストを行う
    • 特に, 入力値の保証がされていない場合, 異常系の入力に対するテストの重要度が高い

結論

  • 正規表現のような一行のコードでも関心を詰め込めば保守・管理が不可能なコードになりうる
  • 行数ではなく, 関心をどれだけ含んでいるかを確認することが重要
  • 神正規表現もいわゆる神Classと同様に, 関心ごとに分離・解体するとReadableに変化する

Comments

本編ではできないコメ返コーナー

  • w
  • 激しく同意
    • 改善した後のだとフォーマット増えてもリーダブルなままでいられるのがミソ
  • 撮ってくれてありがとう
  • メールとかもそうですよね
    • これ本当に正しいので電話番号やメールなどはライブラリに頼るのが良いです
    • ただ, RFC違反してライブラリにつっかえされ...おっと誰かが来たようだ

Thoghts

トップバッターで緊張しましたが, 有意義な時間になってよかったです. お陰様でFeedbackをたくさんいただいていると運営の方から聞いています. まだ手元には来ていないですが, とても楽しみです.

Reference

さて, 当日はあえて隠しましたが, こちらも参考にしています. 気付きましたか?

どこで参考にしたって...?

どこかで見覚えありますね!

readable regex

Discussion