PHP Tips | 文字列処理:URL・パス系 – URL 正規化

PHP PHP
スポンサーリンク

まず「URL 正規化」で何をしたいのかイメージする

「URL 正規化」と聞くと難しく感じますが、やりたいことは意外とシンプルです。

"https://Example.com//path/../path2/?a=1"
"http://example.com/path2"
"https://example.com/path2/"

これらが「全部同じ場所を指している」のに、文字列としてはバラバラです。

業務で困るのは、例えばこんなときです。

  • 同じページなのに「別の URL」としてログに記録されてしまう
  • キャッシュキーや DB のユニークキーとして URL を使うときに、重複判定がうまくいかない
  • 「ホワイトリストに登録した URL と一致するか」を判定したいのに、表記ゆれで判定がズレる

そこで、

「同じ場所を指す URL は、できるだけ同じ文字列になるように整える」

これが「URL 正規化」です。


URL 正規化でよくやる「整え方」のパーツ

ホスト名を小文字にそろえる

URL のホスト名(Example.com の部分)は、大文字小文字を区別しません。

https://Example.com/path

Example Domain

これらは同じ場所を指します。

なので、正規化では「ホスト名を小文字にする」のが定番です。

余計なスラッシュや . .. を整理する

パス部分には、こんなゆらぎがあります。

https://example.com//a//b///c

Example Domain
Example Domain

これらは、全部「/a/b/c」と同じ場所を指します。

正規化では、

  • 連続する / を 1 個にする
  • . を削除する
  • .. を解決して一つ上の階層に戻す

といった処理で、「最短のパス」に整えます。

デフォルトポートを消す

HTTP/HTTPS には「デフォルトポート」があります。

  • http のデフォルトポートは 80
  • https のデフォルトポートは 443

なので、

https://example.com:443/path

Example Domain

は同じ場所です。

正規化では、「スキームに対するデフォルトポートが明示されていたら消す」というのもよくやります。

末尾のスラッシュをどう扱うかを決める

https://example.com/path

Example Domain

これを「同じ」とみなすか、「別」とみなすかは、システム設計次第です。

正規化のルールとして、

  • 「ディレクトリ扱いの URL は末尾 / を付ける」
  • 「API のエンドポイントは末尾 / を付けない」

など、プロジェクトごとに方針を決めておくとよいです。

ここは「正解が一つではない」ので、ユーティリティ側で勝手に変えすぎない方が安全なことも多いです。


PHP で URL を分解・再構築する基本:parse_url と http_build_query

parse_url で URL を分解する

PHP には、URL を構造的に分解してくれる parse_url があります。

$url = "https://Example.com:443/a/../b//c/?q=php#top";

$parts = parse_url($url);

var_dump($parts);
PHP

$parts には、だいたいこんな配列が入ります。

[
  'scheme' => 'https',
  'host'   => 'Example.com',
  'port'   => 443,
  'path'   => '/a/../b//c/',
  'query'  => 'q=php',
  'fragment' => 'top',
]
PHP

これを一つ一つ「整えて」から、また文字列に戻す、というのが正規化の基本パターンです。

クエリ部分は「とりあえずそのまま」でいいことが多い

クエリ(?q=php&sort=desc の部分)も、本気でやるなら

  • パラメータの順序をソートする
  • 重複パラメータをまとめる

などの正規化もありえますが、
業務アプリでは「そこまでやらない」ことも多いです。

初心者向けの段階では、

「クエリ文字列は、今回はいじらずそのまま」

くらいにしておく方が、安全で分かりやすいです。


シンプルな「URL 正規化」ユーティリティを作ってみる

パスの . .. と連続スラッシュを整理する関数

まずは、パス部分だけをきれいにする小さな関数から。

/**
 * URL のパス部分を正規化する
 * - 連続する / を 1 個に
 * - . を削除
 * - .. を解決
 */
function normalizePath(string $path): string
{
    // 連続する / を 1 個に
    $path = preg_replace('#/+#', '/', $path);

    $segments = explode('/', $path);
    $stack    = [];

    foreach ($segments as $seg) {
        if ($seg === '' || $seg === '.') {
            // 何もしない(スキップ)
            continue;
        }
        if ($seg === '..') {
            // 一つ上の階層に戻る
            array_pop($stack);
            continue;
        }
        $stack[] = $seg;
    }

    return '/' . implode('/', $stack);
}
PHP

動きのイメージです。

echo normalizePath('/a/../b//c/');   // /b/c
echo normalizePath('//x///y/./z');   // /x/y/z
PHP

ここでのポイントは、

  • 「パスだけ」を対象にしている
  • ... をちゃんと解決している

というところです。

URL 全体を正規化する関数

これを使って、URL 全体を整える関数を作ります。

/**
 * URL をシンプルに正規化する
 * - ホスト名を小文字に
 * - デフォルトポートを削除(http:80, https:443)
 * - パスを normalizePath で整える
 * - クエリ・フラグメントはそのまま
 */
function normalizeUrl(string $url): ?string
{
    $parts = parse_url($url);
    if ($parts === false || !isset($parts['scheme'], $parts['host'])) {
        // URL として解釈できない場合は null を返す
        return null;
    }

    $scheme = strtolower($parts['scheme']);
    $host   = strtolower($parts['host']);
    $port   = $parts['port'] ?? null;
    $path   = $parts['path'] ?? '/';
    $query  = $parts['query'] ?? null;
    $frag   = $parts['fragment'] ?? null;

    // デフォルトポートなら削除
    if (($scheme === 'http'  && $port === 80) ||
        ($scheme === 'https' && $port === 443)) {
        $port = null;
    }

    // パスを正規化
    $path = normalizePath($path);

    // URL を再構築
    $normalized = $scheme . '://' . $host;

    if ($port !== null) {
        $normalized .= ':' . $port;
    }

    $normalized .= $path;

    if ($query !== null && $query !== '') {
        $normalized .= '?' . $query;
    }

    if ($frag !== null && $frag !== '') {
        $normalized .= '#' . $frag;
    }

    return $normalized;
}
PHP

使い方の例です。

echo normalizeUrl("https://Example.com:443/a/../b//c/?q=php#top");
// https://example.com/b/c?q=php#top

echo normalizeUrl("HTTP://EXAMPLE.COM//path");
// http://example.com/path
PHP

ここまでできれば、かなり「実務で使える」レベルです。


実務での使いどころのイメージ

ログやキャッシュキーの「URL をそろえたい」とき

アクセスログやキャッシュキーに URL を使うとき、
同じページなのに URL の表記ゆれで別物扱いされると、分析やキャッシュ効率が悪くなります。

$rawUrl1 = "https://Example.com:443/a/../b//c/";
$rawUrl2 = "https://example.com/b/c";

$norm1 = normalizeUrl($rawUrl1);
$norm2 = normalizeUrl($rawUrl2);

var_dump($norm1 === $norm2); // true
PHP

こうしておけば、「同じ場所」は同じ文字列として扱えるようになります。

ホワイトリストチェックなどの前処理として

「この URL が、許可されたドメイン配下かどうか」をチェックしたいときも、
まず正規化してから判定すると安全です。

$allowedHost = 'example.com';

$url   = $_GET['url'] ?? '';
$norm  = normalizeUrl($url);

if ($norm === null) {
    // そもそも URL としておかしい
}

$host = parse_url($norm, PHP_URL_HOST);

if ($host !== $allowedHost) {
    // 許可されていないドメイン
}
PHP

ホスト名を小文字にそろえておけば、
Example.comexample.com の違いで判定がズレることもありません。


まとめ:今日からの「URL 正規化」ユーティリティ

押さえておきたいポイントをコンパクトにまとめると、こうです。

  • URL 正規化は、「同じ場所を指す URL を、できるだけ同じ文字列にそろえる」ための処理。
  • よくやるのは「ホスト名を小文字に」「デフォルトポートを消す」「パスの . .. や連続 / を整理する」。
  • PHP では parse_url で分解し、パスは自前で整えてから文字列に戻す、という形が扱いやすい。
  • クエリやフラグメントは、最初のうちは「いじらずそのまま」にしておく方が安全。

まずは、あなたのコードの中で「URL をそのまま文字列として比較しているところ」を一箇所思い出してみてください。
そこを normalizeUrl() 前提に書き換えると、どれくらい判定が安定するか—その感覚を一度つかむと、「URL は正規化してから扱う」が自然な習慣になっていきます。

タイトルとURLをコピーしました