こんにちは、ベトナム人の新入社員のヴー・トゥアン・キエットです。
最近、Markdown パーサーの統一作業を担当することになり、正規表現を本格的に学び始めました。Markdown から HTML への変換処理において、単純な文字列置換では対処できない複雑なパターンマッチングが必要だったからです。
PHP の正規表現を処理する関数を使って複雑な変換ロジックを実装する過程で、正規表現の威力と同時に、その複雑さや可読性の課題も実感しました。この経験を通じて学んだことや、つまづいたポイントをまとめて共有したいです。
今回学んだこと
正規表現(Regular Expression)は、文字列のパターンを記述する方法です。文字列を処理するのに非常に役立つツールであり、現在では、ほぼすべてのプログラミング言語でサポートされています。PHP もこの記法をサポートしています。
正規表現には、注意点もあります。単純な文字列の処理と比べると、正規表現を内部でコンパイルしたり、実行時に文字とのマッチング試行を繰り返すなど、多くの処理を行います。使い方によっては、アプリケーションのパフォーマンスに悪い影響を与える可能性があるため、必要な場合に限って利用するべきです。
単純な手段や文字列処理の関数で解決できる問題なら、正規表現を使うべきではありません。正規表現を使うとコードが複雑になり、わかりにくくなります。
逆に、複数の複雑な文字列を処理する必要があり、単純な手段や文字列処理の関数では解決できないなら、正規表現の採用を考えるべきです。特に、処理の対処となる各文字列が、同じようで異なるパターンを持っているようなケースでは、正規表現が向いています。
正規表現を使うことになった経緯
弁護士ドットコムの開発をする部署に配属され、PHP のプロジェクトを触ることになりました。そして、Markdown パーサーを統一するというタスクを振られました。
現状の問題は、Markdown パーサーがいくつもあって、冗長になっていることです。アップデートなどがある場合、メンテナンスのコスト増加につながる可能性があり、管理も難しくなります。そのため、いくつかのパーサーの中から 1 つを残して、他のパーサーを削除することとしました。
ただ、Markdown には「方言」がいくつかあり、パーサーによって、対応している書式が異なります。そのため、パーサーを変更する場合、新しいパーサーが対応していない書式に対応させるためには、自分でカスタマイズしなければなりません。
このような Markdown の処理は、単純な文字列を処理する関数では対応が困難です。処理の対象となる文字列の内容は、固定ではないためです。例えば、画像を表現する Markdown の例は以下のようになります。
{.class-name #id-name} *text*
この形の Markdown から、変換してほしい HTML はこのようになります。
<p><img src="url" alt="alt" class="class-name" id="id-name"/><em>text</em></p>
このように、Markdown から HTML への変換処理自体は簡単です。しかし実際には、Markdown から HTML へ変換した後、HTML から Markdown へ再変換することもあります。一度 HTML へ変換した後で再変換した Markdown は、元の Markdown とは形式の異なる場合があります。
先ほどの画像の例を一度 HTML に変換し、そこから再変換した Markdown はこのようになります。
{.class-name #id-name} _text_
最初の Markdown とは差分があります。text を括る記号は * から _ になりました。ただ、これだけなら、パーサーは対応でき、自分でカスタマイズする必要はありません。
Markdownパーサーの制限と問題の発生
問題が発生する例を紹介しましょう。_ _(キャプション)の中に、単にテキストが含まれていますが、属性(target=_blank)が指定されているリンクも入ったらどうでしょう。
{.class-name #id-name} _text [link](url){target=_blank} _
今度は問題が発生します。キャプション(_text [link](url){target=_blank} _)の中に、リンクもあります。リンクには属性{target=_blank} が指定されていますが、この _ とキャプションの初めの _ がペアとなって強調と解釈され、em 要素になります。これによって、HTML の表示全体が崩れてしまいました。
なぜこの問題が発生するのか
この問題が発生する理由は、Markdown パーサーが属性値の文脈を適切に処理できないことにあります。
- 文脈の無視: パーサーは
{}の中の内容が属性値であることを理解せず、単純に_記号を強調のマーカーとして認識する - パーサーの処理順序: 強調記号(
*や_)の処理が、属性値の解析よりも優先される - 貪欲マッチング:
{target=_blank}の_とキャプションの初めの_がペアとして認識される
本来であれば、{} 内の内容は属性名と属性値として扱われるべきです。しかし、使用している Markdown から HTML に変換するパーサー(Cebe Markdown)では、この文脈を適切に処理できない制限があります。
Markdown↔HTML変換の複雑さ
この問題は、単一のパーサーだけでなく「Markdown → HTML → Markdown」という複数回の変換プロセスで発生します。
- 最初の Markdown:
{.class-name #id-name} *text [link](url){target=_blank}* - HTML 変換:
html <p><img src="url" alt="alt" class="class-name" id="id-name"/><em>text <a href="url" target="blank">link</a></em></p> - 再 Markdown 化:
{.class-name #id-name} _text [link](url){target=_blank} _(*が_に変換される) - 再パース時:
{target=_blank}の_とキャプションの初めの_がペアとして認識される
このように、複数のパーサーを経由することで、最初は問題なかった記法が後から問題を引き起こす可能性があります。
これは厳密にいうとパーサーのバグではありません。HTML から Markdown へ変換するパーサー(Markdownifyなど)は em タグを強調形式に変換するとき、* ではなく _ を優先する仕様になっているためです。一般的な Markdown パーサーでは、_ のペアや * のペアをどちらでも強調形式として解釈します。
その結果、再変換後の Markdown で _ 記号が意図しない箇所でペアとして認識されてしまいます。
このケースへの対応としては、キャプション全体を括る強調の記号を _ ではなく、* に統一すれば問題ありません。つまり、*text [link](url){target=_blank}* のような形にすることで、属性値内の _ との競合を避けることができます。
以上の問題以外にも、いろいろな問題があります。どんな形式をどのような順番で変換すればいいか、どの程度 Markdown を変換すべきかなど、いろいろ困っていました。
こういった問題を解決し、カスタムの変換ルールを追加するために、正規表現を使うことにしました。
PHPで正規表現を処理するために使った関数
今回実際に使った、正規表現をサポートする関数について紹介します。今回のタスクは Markdown 形式から HTML 形式に変換するというものですので、マッチ系の関数は使わずに、リプレイス系の関数のみを使いました。
preg_replace
今回のタスクではリプレイスの条件や処理が複雑だったため、ほとんど後述する preg_replace_callback 関数を使っていました。しかし、こちらがリプレイス系の関数の基本形になりますので、先に紹介しておきます。
この関数の目的は、パターンにマッチした文字列を置換することです。
preg_replace(
string|array $pattern,
string|array $replacement,
string|array $subject,
int $limit = -1,
int &$count = null
): string|array|null
引数の説明。
$pattern:正規表現パターン(1 つのパターンかパターンの配列でも良い) $replacement:置き換えを行う文字列もしくは文字列の配列 $subject:検索・置き換え対象となる文字列もしくは文字列の配列 - 配列の場合、$subject の各要素において、検索・置き換えが行われ、戻り値が配列になる $limit:各パターンによる置き換えを行う最大回数。デフォルトは
-1(無限) $count:この変数を指定されたら、置き換え回数が代入される
注意: $replacement が配列である場合、 $pattern も配列でなければいけません。
使い方例。
<?php $string = 'Check [our website](https://example.com) for details'; $pattern = '/\[([^\]]+)\]\(([^)]+)\)/'; // [text](url) - リンク用 $replacement = '<a href="$2">$1</a>'; echo preg_replace($pattern, $replacement, $string); ?>
インプット。
Check [our website](https://example.com) for details
アウトプット。
Check <a href="https://example.com">our website</a> for details
preg_replace_callback
この関数は preg_replace と異なり、置換文字列の代わりにコールバック関数(callable タイプ)を指定します。
preg_replace_callback(
string|array $pattern,
callable $callback,
string|array $subject,
int $limit = -1,
int &$count = null,
int $flags = 0
): string|array|null
引数の説明。
$pattern, $subject, $limit, $count は↑の
preg_replaceと同じである $callable:検索対象の文字列でマッチした要素の配列が指定された際に呼び出されるコールバック関数。関数の戻り値が置き換え後の文字列となる。関数のシグネチャはhandler(array $matches) stringのようになっている必要がある。コールバック関数を使いまわさない場合は、無名関数 を利用すると良い $flags: PREG_OFFSET_CAPTURE と PREG_UNMATCHED_AS_NULLを指定できる
実際の例を見てみましょう。
<?php function convertTargetBlankLinkShortToLong($markdown) { return preg_replace_callback( '#<(.+?)>{target=_blank}#', function ($matches) { $url = $matches[1]; return "[$url]($url){target=_blank}"; }, $markdown ); }
処理を説明します。パターン #<(.+?)>{target=_blank}# を使って、短縮形式のリンク <url>{target=_blank} を検索します。マッチした場合、コールバック関数内で URL を取得し、標準的な Markdown 形式 [url](url){target=_blank} に変換します。
インプット。
<https://example.com>{target=_blank}
アウトプット。
[https://example.com](https://example.com){target=_blank}
終わりに
今回の Markdown パーサー統一の経験を通じて、正規表現は確かに強力なツールだと実感しました。複雑なパターンマッチングが必要な場合、他の方法では解決できない問題を解決してくれます。
しかし、同時にその難しさも痛感しました。パターンを書くのも大変ですし、後から読み返すのも困難です。チームメンバーにコードレビューをお願いするときも、正規表現の部分は説明が必要でした。
またパフォーマンスへの影響も気になります。簡単な文字列処理で済む場合は、わざわざ正規表現を使う必要はないと学びました。
私としては、正規表現は「最後の手段」として考えるのが良いでしょう。まずは普通の文字列関数で解決できないかを検討し、どうしても必要なときだけ使います。そして使うときは、必ずコメントを書いて、将来の自分やチームメンバーが理解できるようにする。これが大切だと感じました。
参照資料
https://www.php.net/manual/ja/function.preg-replace.php
https://www.php.net/manual/ja/function.preg-replace-callback.php