Node.jsのPathモジュールで定義されているjoin
メソッドが10年以上の開発期間を経てどのように変化し、成長してきたかをコミット履歴をたどりながらコードリーディングを行って調べてみました。この前編では、まず現在のjoin
メソッドのソースコードを読んで解説します。後編では全34回のコミットを3つの時期に分けて、その成長の記録を追いかけていきます。
Node.js Pathモジュールのjoinメソッドとは?
Pathモジュールのjoin
メソッドは、引数を配列要素として受け取り、それらを文字列として連結してくれるメソッドです。1
const path = require('node:path');
path.join('/foo', 'bar', 'baz')
=> '/foo/bar/baz'
引数に文字列以外の値を入れるとTypeError
を吐き出します。Node.js
を動作させるOSによって出力するパスを変えられるよう、ソースコード(node/lib/path.js
)にはWindows
用とPOSIX
用の2つのjoin
メソッドが実装されています。
ちなみにPOSIX
(Portable Operating System Interface
)とは、主にUNIX系OSに共通する機能についてインターフェース等を定めた規格であり、ここではWindows
以外という意味でLinux
やmacOS
用のメソッドであることを指しています。23
POSIX用のjoinメソッドのソースコードリーディング
2022/12/7現在の最新のソースコードのうち、比較的短いコードで書かれているPOSIX
用のjoin
メソッドの内部処理から説明していきます。
const posix = {
/**
* @param {...string} args
* @returns {string}
*/
join(...args) {
if (args.length === 0)
return '.';
let joined;
for (let i = 0; i < args.length; ++i) {
const arg = args[i];
validateString(arg, 'path');
if (arg.length > 0) {
if (joined === undefined)
joined = arg;
else
joined += `/${arg}`;
}
}
if (joined === undefined)
return '.';
return posix.normalize(joined);
},
}
まずposix
という定数に複数のメソッドが定義されており、join
メソッドもその1つになっています。これが前述したPOSIX
用のメソッドという意味です。
メソッド定義1行目にある引数...args
は、引数の数を指定せず拡張できる残余引数(可変長引数)4です。...
を使うと、path.join('foo')
のように1つだけ引数を渡すこともできますし、path.join('foo', 'bar', 'baz')
のように3つの引数を渡すこともできます。
次のif文は、join
メソッドに引数が1つも渡されていなかった場合の処理です。args.length
は配列要素の数を返す定番表現であり、条件を作る時によく使われます。配列要素が0、つまり引数がないため、パスの連結処理が不要となります。.
という文字列を返して処理を終了させます。(join
メソッドはパスを扱うメソッドですので、この戻り値.
はカレントディレクトリに相当するパスです。)
if文の処理のあとは、joined
という変数を宣言し、for文による繰り返し処理が行われています。args.length
は繰り返し処理の回数上限を設定するために利用されています。このfor文では、join
メソッドに渡された引数の数だけ処理を繰り返すという条件指定を行っています。
for文の中の処理に入っていきます。arg
という定数を宣言し、join
メソッドが受け取った引数の配列args
からi
で要素を指定してarg
に代入しています。これをvalidateString
メソッドに渡して引数が文字列であることを確認しています。validateString
メソッドは、第1引数の値が文字列であるかどうかを確認し、文字列以外の場合はTypeError
を返すメソッドです。5 TypeError
となった場合は、この段階でエラーを吐き出してjoin
メソッドの処理も終了となります。
少し脱線しますが、このvalidateString
はNode.js
の各モジュールで共通に使うために定義されたメソッドです。
/** @type {validateString} */
function validateString(value, name) {
if (typeof value !== 'string')
throw new ERR_INVALID_ARG_TYPE(name, 'string', value);
}
join
メソッド内では第2引数としてpath
という文字列を受け取っています(validateString(arg, 'path')
)が、このpath
という文字列はさらにERR_INVALID_ARG_TYPE
へ第1引数として受け渡しされます。最終的にERR_INVALID_ARG_TYPE
が生成するエラーメッセージの一部としてpath
が使われます。
再びfor文の中の処理に戻りましょう。validateString
で引数が文字列であることをバリデーションできたら、if文にて定数arg
に入っている文字列が空の文字列("")かどうかを判別します。空の文字列なら何もせず、ここで1回分のfor文内の処理が完了となります。arg
に何か文字列が入っていれば、それがパスを生成するための最初の文字列かどうかを判別します。
if (joined === undefined)
の行から始まる処理が、join
メソッドのメインの処理になっています。join
メソッドは、foo
, bar
, baz
という文字列をfoo/bar/baz
のように/
をつけて連結したいだけなので、1つ目の引数は何もせず、2つ目以降の引数には先頭に/
をつけて、すでに連結した文字列の後ろに連結するという処理を行います。1つ目の引数であるかどうかを判別するために、joined
に何か値が入っているかどうか(=undefined
かどうか)を条件として利用しています。/
の追加と前の文字列への連結は、バッククォートを使ったテンプレートリテラルとプレースホルダー${}
を利用して実装してあります。
for文の処理を繰り返すと、たとえばpath.join('foo', 'bar', 'baz')
の場合には、joined
にfoo/bar/baz
という文字列が入ります。仮にpath.join('', '', '')
のような空の文字列を3つ渡したとすると、joined
には何も代入されずundefined
のままになります。こういったケースを次のif文で検出して.
を返すようにしています。
最後のnormalize
メソッド6も同じくnode/lib/path.js
にて定義されており、パスに含まれている.
や..
を解決します。例えば/foo/bar/..
となっている場合は、/foo
を返します。こうしてパス生成の最後の仕上げまで行って、連結したパスを返すことがjoin
の処理になります。
以上がPOSIX
用のjoin
メソッドの内部処理です。
Windows用のjoinメソッドのソースコードリーディング
Windows
用のソースコードは以下の通りです。
/**
* @param {...string} args
* @returns {string}
*/
join(...args) {
if (args.length === 0)
return '.';
let joined;
let firstPart;
for (let i = 0; i < args.length; ++i) {
const arg = args[i];
validateString(arg, 'path');
if (arg.length > 0) {
if (joined === undefined)
joined = firstPart = arg;
else
joined += `\\${arg}`;
}
}
if (joined === undefined)
return '.';
// Make sure that the joined path doesn't start with two slashes, because
// normalize() will mistake it for a UNC path then.
//
// This step is skipped when it is very clear that the user actually
// intended to point at a UNC path. This is assumed when the first
// non-empty string arguments starts with exactly two slashes followed by
// at least one more non-slash character.
//
// Note that for normalize() to treat a path as a UNC path it needs to
// have at least 2 components, so we don't filter for that here.
// This means that the user can use join to construct UNC paths from
// a server name and a share name; for example:
// path.join('//server', 'share') -> '\\\\server\\share\\')
let needsReplace = true;
let slashCount = 0;
if (isPathSeparator(StringPrototypeCharCodeAt(firstPart, 0))) {
++slashCount;
const firstLen = firstPart.length;
if (firstLen > 1 &&
isPathSeparator(StringPrototypeCharCodeAt(firstPart, 1))) {
++slashCount;
if (firstLen > 2) {
if (isPathSeparator(StringPrototypeCharCodeAt(firstPart, 2)))
++slashCount;
else {
// We matched a UNC path in the first part
needsReplace = false;
}
}
}
}
if (needsReplace) {
// Find any more consecutive slashes we need to replace
while (slashCount < joined.length &&
isPathSeparator(StringPrototypeCharCodeAt(joined, slashCount))) {
slashCount++;
}
// Replace the slashes if needed
if (slashCount >= 2)
joined = `\\${StringPrototypeSlice(joined, slashCount)}`;
}
return win32.normalize(joined);
},
中央に長々とコメントがありますが、そこまでの内容はPOSIX
用とほぼ同じです。違いは、firstPart
という変数を追加して第1引数の値を別途保持しているところ(joined = firstPart = arg
)とパスの連結のために/
ではなく\\
を付加しているところです。\\
は1つ目の\
がエスケープ用、2つ目の\
が実際のパス連結用です。
以降の処理をざっと眺めて最後まで見ると、最終行ではPOSIX
用と同じnormalize
メソッドへjoined
に入れたパスの文字列を渡しています。
POSIX
用と異なる部分は、コメントにも説明がありますが、Windows
で使われているUNCパスのための\\
(バックスラッシュ)に対応した処理になっています。コメントによると、最終行で呼び出すnormalize
メソッドは、UNCパスの特徴である「パスの先頭に2つのバックスラッシュが続く」パスの処理に対応していないため、normalize
メソッドへ渡す前にjoin
メソッド内で処理する必要があるとのことです。
長いコメントのあとに続くif文は、ネストが深くなっていてややこしいですが、firstPart
に格納した第1引数の文字列の中にバックスラッシュがいくつあるかを数えてslashCount
の値へ反映しています。この処理によってUNCパスであるかいなかを判別し、\
の置き換え処理が必要かどうかを選別しています。
StringPrototypeCharCodeAt
は、firstPart
に格納された文字列の位置を指定して、その文字コードを返します。第1引数はthis
であり、StringPrototypeCharCodeAt(firstPart, 0)
はfirstPart.charCodeAt(0)
と同じ意味になります。7 isPathSeparator
はその文字コードが/
または\
に該当するかどうかを識別します。\\server
という文字列がfirstPart
に入っていたとすると、slashCount
が2となってUNCパスであることが確定し、needsReplace
がfalseに設定され、以降のif文の処理がスキップされます。\\\foo
のように3つのバックスラッシュがある場合は、slashCount
が3となって、次のif文で置き換え処理が実行されます。
次に文字列を連結してパスを生成した状態で「パスの先頭に2つのバックスラッシュが続く」パスになっているかどうかを調べて、slashCount
に結果を反映しています。path.join('\', '\server')
のようなケースを想定した処理です。
最後にslashCount
が2以上の場合に先頭のバックスラッシュの数を調整する処理を行っています。StringPrototypeSlice(joined, slashCount)
は、joined.slice(slashCount)
と同じ意味であり、slashCount
の値だけ先頭のバックスラッシュを削った文字列を返しています。そこに2つのバックスラッシュを加えてjoined
に代入してパスを完成させています。
長くなりましたが、以上がNode.js
のPath
モジュールで定義されているjoin
メソッドのソースコードの中身です。
joinメソッドのコードリーディングを振り返って
Windows
用、POSIX
用の2つの定義をあわせて90行ほどのソースコードでしたが、いろんな知識を動員しなければきちんと読み解くことができないことがおわかりいただけたのではないでしょうか?今回のコードリーディングで必要となった知識について主なものを以下に挙げておきます。
-
POSIX
とは - 残余引数(可変長引数とも呼ばれる)
...args
- 現在のディレクトリへのパスが
.
で表記されること - 繰り返し処理の回数上限として
args.length
のような配列要素の数を使う定番表現 -
Node.js
で定義されているメソッドの処理内容(validateString
、normalize
、isPathSeparator
、StringPrototypeCharCodeAt
、StringPrototypeSlice
) -
let joined
と変数宣言して何も値を代入していないときはundefined
という値になるというJavaScript
の基礎的な文法 - プレースホルダー
${}
を使った文字列への変数代入 - UNCパスとは
これらの知識や文法への理解が1つでも欠けていれば、その時点でコードリーディングは行き詰まります。頭の中にある知識だけですべてを理解することがそもそも難しい作業であることがわかります。実際のコードリーディングは、調べて、ソースコードに戻って、また調べて、の繰り返しです。
後編では、このソースコードが10年以上の開発期間を経てどのように変化してきたかを28回分のコミット履歴をたどりながら見ていきます。
-
https://nodejs.org/dist/latest-v18.x/docs/api/path.html#pathjoinpaths ↩
-
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Functions/rest_parameters ↩
-
https://github.com/nodejs/node/blob/22c39b1ddd2f5f2ed13397b351b05836f7abea54/lib/internal/validators.js#L161 ↩
-
https://nodejs.org/dist/latest-v18.x/docs/api/path.html#pathnormalizepath ↩
-
https://github.com/nodejs/node/blob/main/doc/contributing/primordials.md ↩