JavaScript Tips | 文字列ユーティリティ:業務用 - パス正規化

JavaScript JavaScript
スポンサーリンク

何をしたいユーティリティか:「パス正規化」

ここでの「パス正規化」は、
"//api//v1/../v2//users/./123/" のような「ぐちゃっとしたパス文字列」を、
"/api/v2/users/123" のような「きれいで一貫した形」に整える処理です。

ファイルパスでも URL パスでも、
「余計なスラッシュ」「...」「末尾スラッシュの有無」がバラバラだと、
比較やログ出力、ルーティング判定がやりづらくなります。

だからこそ、
「パスを使う前に必ず正規化関数を通す」というルールを作るのが、業務ではかなり効きます。


パス正規化でやりたいことを整理する

どんな“ゆがみ”を直したいか

パス文字列には、よくこんな「ゆがみ」が混ざります。

// のような連続スラッシュ
/./ のような「カレントディレクトリ」
/a/b/../c のような「親ディレクトリへの戻り」
末尾の余計な /

これらを、次のようなルールで整えます。

連続スラッシュは 1 個にまとめる。
. は無視する。
.. は一つ前のセグメントを削る。
先頭の / は維持し、末尾の / は基本的に削る(必要ならオプション)。

この「ルールセット」をコードに落としたものが、パス正規化ユーティリティです。


シンプルなパス正規化の実装

normalizePath のコード

まずは、UNIX 風のパス(/ 区切り)を対象にしたシンプル版を書きます。

function normalizePath(path) {
  if (!path) {
    return "/";
  }

  const isAbsolute = path.startsWith("/");

  const rawSegments = path.split("/");

  const segments = [];

  for (const seg of rawSegments) {
    if (!seg || seg === ".") {
      continue;
    }

    if (seg === "..") {
      if (segments.length > 0 && segments[segments.length - 1] !== "..") {
        segments.pop();
      } else if (!isAbsolute) {
        segments.push("..");
      }
      continue;
    }

    segments.push(seg);
  }

  let normalized = segments.join("/");

  if (isAbsolute) {
    normalized = "/" + normalized;
  }

  if (normalized === "") {
    normalized = isAbsolute ? "/" : ".";
  }

  return normalized;
}
JavaScript

重要なポイントを一つずつかみ砕く

絶対パスか相対パスかを最初に判定する

const isAbsolute = path.startsWith("/");
JavaScript

"/api/users" のように先頭が / なら「絶対パス」、
"api/users" のように先頭に / がなければ「相対パス」とみなします。

この情報は、最後に「先頭に / を付けるかどうか」を決めるために使います。

スラッシュで分割して「セグメント」に分ける

const rawSegments = path.split("/");
JavaScript

"/api//v1/../users/./123/" は、分割するとこういう配列になります。

["", "api", "", "v1", "..", "users", ".", "123", ""]

空文字が混ざっているのは、
先頭の / や連続スラッシュ // の影響です。

この「生のセグメント配列」を、ルールに従って整えていきます。

. と空文字は無視する

if (!seg || seg === ".") {
  continue;
}
JavaScript

"." は「今のディレクトリ」を意味しますが、
パスの正規化では「無視してよいもの」として扱います。

空文字("")は、先頭の / や連続スラッシュから生まれたものなので、
これも無視します。

.. は「一つ前のセグメントを削る」

if (seg === "..") {
  if (segments.length > 0 && segments[segments.length - 1] !== "..") {
    segments.pop();
  } else if (!isAbsolute) {
    segments.push("..");
  }
  continue;
}
JavaScript

ここが一番大事な部分です。

.. は「親ディレクトリ」を意味するので、
すでに積み上げたセグメントがあれば、それを 1 つ削ります。

例えば、

["api", "v1"].. が来たら → ["api"] に戻す。

ただし、絶対パスの先頭より外には出られないので、
絶対パスの場合は「もう削るものがない」状態では何もしません。

相対パスの場合は、"../../foo" のような形を保ちたいこともあるので、
削るものがなければ .. 自体を残します。

残りは普通のセグメントとして積む

segments.push(seg);
JavaScript

. でも .. でもない普通の文字列は、
そのまま「パスの一部」として積み上げます。

こうしてループを回し終わると、
segments には「余計なものを取り除いたパスの部品」が並びます。

最後に文字列に戻す

let normalized = segments.join("/");

if (isAbsolute) {
  normalized = "/" + normalized;
}

if (normalized === "") {
  normalized = isAbsolute ? "/" : ".";
}
JavaScript

セグメントを / でつなぎ直し、
絶対パスなら先頭に / を付けます。

もしセグメントが空([])なら、
絶対パスなら /、相対パスなら "."(カレントディレクトリ)を返すようにしています。


実際の動きを例で確認する

normalizePath("/api//v1/../users/./123/");
// "/api/users/123"

normalizePath("api/./v1//users/../");
// "api/v1"

normalizePath("/a/b/../../c");
// "/c"

normalizePath("a/../../b");
// "../b"

normalizePath("/");
// "/"

normalizePath("");
// "/"
JavaScript

特に注目してほしいのは、.. の扱いです。

"/a/b/../../c""/c"
"a/../../b""../b"

絶対パスでは「ルートより上には行かない」、
相対パスでは「.. を残すこともある」という違いが出ています。


URL パス用に少しだけルールを変える

末尾スラッシュを残したい場合

API の設計やルーティングによっては、
"/api/users""/api/users/" を区別したいこともあります。

その場合は、「末尾スラッシュを残すかどうか」をオプションにできます。

function normalizePathUrl(path, options = {}) {
  const { keepTrailingSlash = false } = options;

  const originalHasTrailingSlash = path?.endsWith("/");

  let normalized = normalizePath(path);

  if (keepTrailingSlash && originalHasTrailingSlash && !normalized.endsWith("/")) {
    normalized += "/";
  }

  return normalized;
}
JavaScript

これで、

normalizePathUrl("/api/users/");
// "/api/users"

normalizePathUrl("/api/users/", { keepTrailingSlash: true });
// "/api/users/"
JavaScript

のように、用途に応じて挙動を変えられます。


実務で意識してほしい設計のポイント

「パスを比較する前に必ず正規化する」

ルーティングや権限チェックで、

if (path === "/api/users") { ... }

のような比較をするとき、
入力側のパスが "/api//users/" のように微妙に違っていると、
本来同じ意味なのに一致しません。

そこで、

const normalized = normalizePath(inputPath);
if (normalized === "/api/users") {
  ...
}
JavaScript

という形にしておけば、
「見た目が違うだけで意味は同じ」パスを、同一視できます。

ログやメトリクスでも「正規化済み」を使う

ログやメトリクスでパスをキーにするときも、
正規化しておかないと、似たようなパスがバラバラに集計されてしまいます。

"/api/users""/api/users/""/api//users" が別々にカウントされる、
というのはよくある「もったいないパターン」です。

「ログに出す前に normalizePath を通す」というルールを作るだけで、
集計の精度がかなり上がります。

OS のパスと URL のパスを混同しない

ここで書いたのは「UNIX 風の / 区切りパス」の正規化です。

Windows のファイルパス(C:\path\to\file)や、
URL 全体(https://example.com/api/users)を扱う場合は、
別途ルールを決める必要があります。

ただ、「セグメントに分けて ... を処理する」という考え方自体は同じなので、
このユーティリティをベースに、用途ごとのバリエーションを作るのが現実的です。


少し手を動かして感覚をつかむ

コンソールで、次のようなコードを実際に打ってみてください。

normalizePath("/api//v1/../users/./123/");
normalizePath("api/./v1//users/../");
normalizePath("/a/b/../../c");
normalizePath("a/../../b");

normalizePathUrl("/api/users/");
normalizePathUrl("/api/users/", { keepTrailingSlash: true });
JavaScript

返ってきた文字列を見て、

「自分が頭の中でイメージした“きれいなパス”と一致しているか」
.. の挙動が直感と合っているか」

を確認してみてください。

そのうえで、自分のプロジェクトに

export function normalizePath(...) { ... }
export function normalizePathUrl(...) { ... }
JavaScript

を置き、

「パスを扱いたくなったら、まず“パス正規化ユーティリティ”を通す」

というルールを作ってみてください。
それだけで、あなたのシステムのパス処理は、場当たり的な文字列操作から、
意図と一貫性を備えた「業務レベルのパス設計」に変わっていきます。

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