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 ↩