🐘
Readable 正規表現 @PHPerKaigi 2024
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
さて, 当日はあえて隠しましたが, こちらも参考にしています. 気付きましたか?
どこで参考にしたって...?
どこかで見覚えありますね!
Discussion