JavaScript (node.js) に再入門する必要があったので、Copilot Chat 先生に教えていただいた内容を忘れないように整理しておきたい。
多分普通にやっている人には何の必要性も無い情報だと思うが、自分の知識の整理のためにやっている。
経緯としては、GitHub Actions のカスタム Action を書くにあたって、JavaScript/TypeScript が必要になったので、ひっさびさに触ってみたが、いろいろ進化しているようなので、そのあたりを整理する。
元々の目的が、Custom GitHub Action の作成だから、Creating a JavaScript actionにしたがってNode.js 20を使用してコードを書いている。書きたいコードは単純で、ファイルをダウンロードして、Unzipするという極めて簡単なもの。Copilot Chat も生成してくれたが動かない。
http.get vs fetch
最初に躓いたのが、http.get
で、ファイルのダウロードなど簡単と思っていたが、Copilot Chat が作ってくれた次のようなプログラムは失敗した。
const https = require('https');
const fs = require('fs');
const AdmZip = require('adm-zip');
const url = 'https://github.com/TsuyoshiUshio/BlogBabel/releases/download/v1.0.0-preview.5/BlogBabel-win-x64.zip'; // 'https://github.com/TsuyoshiUshio/BlogBabel/releases/download/v1.0.0-preview.5/BlogBabel-win-x64.zip'; // Replace with your ZIP file's URL
const outputPath = 'downloaded.zip'; // Temporary output path for the downloaded ZIP file
const extractPath = 'extracted/'; // Directory where the contents will be extracted
https.get(url, (response) => {
if (response.statusCode !== 200) {
console.error(`Failed to download file: ${response.statusCode}`);
response.resume();
return;
}
:
}).on('error', (err) => {
console.error(`Error: ${err.message}`);
});
上記のプログラムが動作しない理由は、github.com
の Release
からのダウンロードは、最初に302つまりリダイレクトが帰ってくるのだが、http.get
はそれに対応していないらしい。2024年にそれは衝撃的過ぎるのだが、http.get
はリダイレクトをサポートしていないっぽい。だからやるとしたら、302 のレスポンスをハンドリングして、自分でリクエストする必要がある。
代わりにfetchは思いっきりサポートしている。使い味もそんなに変わらないので、書き換えてみよう。
const fs = require('fs');
const AdmZip = require('adm-zip');
const { pipeline } = require('stream');
const { promisify } = require('util');
const url = 'https://github.com/TsuyoshiUshio/BlogBabel/releases/download/v1.0.0-preview.5/BlogBabel-win-x64.zip';
const outputPath = 'downloaded.zip';
const extractPath = 'extracted/';
const download = async (url, outputPath) => {
const response = await fetch(url);
if (!response.ok) throw new Error(`unexpected response ${response.statusText}`);
await promisify(pipeline)(response.body, fs.createWriteStream(outputPath));
console.log('Download completed, extracting...');
};
download(url, outputPath)
.then(() => {
const zip = new AdmZip(outputPath);
zip.extractAllTo(extractPath, true);
console.log(`Extraction completed to "${extractPath}"`);
fs.unlinkSync(outputPath);
console.log('Downloaded ZIP file removed.');
})
.catch((err) => console.error(`Error: ${err.message}`));
素晴らしい。簡単だ。第一部完!という感じだが、JavaScript が久々過ぎてちゃんと理解できていない。
自分でスパイクソリューションを書いて理解していこう。今は Copilot Chat 先生がいるので質問が出来て便利だ。
fetch と async/await
まずは次のセクションを理解していこう。download 関数の定義で、async
が定義されている。C#と同様のイメージで使える感じのようだ。これで、非同期のプログラミングが楽になりそう。C#の場合は内部での戻り値は、Task
とか ValueTask
だったが、こちらはPromise
になっている。
const download = async () => {
const response = await fetch("https://developer.mozilla.org/ja/docs/Web/API/fetch#redirect");
const writable = fs.createWriteStream("redirect.html");
await promisify(pipeline)(response.body, writable);
promisifyはコールバックをPromiseの形式にしてくれるようだ。これは素晴らしい。つまりJavaScript はコールバックの塊だが、これで、promise 化ができるので、async/await と使うことが出来るようになる。
pipelineはその名の通り、ストリームを別のストリームにつないでくれるもの。試していないが第三引数にコールバックを渡せる様子。ファイルを読んで書くとか、そういう時にとてもすっきりかけて良い。だから上記のプログラムは、fetch
でファイルを取得して、ファイルに書き出しているというコードだ。
Promise と then/catch
次のセクションは上記の download() を実行すると、async
メソッドで定義されているので、実態としては、Promise
が帰ってくる。then
は Continuation で、ダウンロードの実行後に継続して実行するコールバックを書ける。catch
は例外が発生したときのコールバックだ。
download()
.then(() => console.log("Download completed"))
.catch((err) => console.error(`Error: ${err.message}`));
ところが、これにはちょっと問題がある。download のあとに、入力プロンプトを表示して、ファイルを消したいとする。すると、問題が発生する。
const fs = require('fs');
const { pipeline } = require('stream');
const { promisify } = require('util');
const download = async () => {
const response = await fetch("https://developer.mozilla.org/ja/docs/Web/API/fetch#redirect");
const writable = fs.createWriteStream("redirect.html");
await promisify(pipeline)(response.body, writable);
}
download()
.then(() => console.log("Download completed"))
.catch((err) => console.error(`Error: ${err.message}`));
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('Clear redirect.html:', (answer) => {
fs.unlinkSync("redirect.html");
rl.close();
});
このコードは思った通り動かない。
> node fetchspike.js
Clear redirect.html:Download completed
これは、ダウンロードが完了する前に、入力プロンプトが実行されてしまっている。これを防ぐためには、then
の中に、入力プロンプトのコードを入れ込む必要がある。
こんなサンプルプログラム程度ならよいが、このコードのあとにこれを実行みたいなケースですべて、then
の中のコールバックに書いていたらネストがすごいことになるだろう。
これがいわゆるコールバックヘルという状態だ。そうならない方法を聞くと async/await
だとCopilot Chat から帰ってきた。早速書き換えてみよう。
const fs = require('fs');
const { pipeline } = require('stream');
const { promisify } = require('util');
const download = async () => {
const response = await fetch("https://developer.mozilla.org/ja/docs/Web/API/fetch#redirect");
const writable = fs.createWriteStream("redirect.html");
await promisify(pipeline)(response.body, writable);
}
const cleanup = (message) => {
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.setPrompt('Clear redirect.html? [y/n]> ');
rl.prompt();
return new Promise(resolve => rl.on('line', (line) => {
switch (line.trim()) {
case 'y':
fs.unlinkSync("redirect.html");
rl.close();
break;
case 'n':
rl.close();
break;
default:
console.log(`Unknown: ${line.trim()}`);
break;
}
resolve();
}));
}
const main = async() => {
try {
await download();
console.log("Download completed");
await cleanup();
} catch (err) {
console.error(`Error: ${err.message}`);
}
}
main();
promisify での書き換え
ついでにプロンプトも若干リッチにしておいた。readline
はquestion
の他にも、setPrompt/prompt
や、on
のメソッドがあるようだ。cleanup
関数がPromise()を返すようにしているのは、await
できるようにするためだが、もしかすると、promisify
でできるんちゃう?やってみよう。
:
const cleanup = (message) => {
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.setPrompt(message);
rl.prompt();
rl.on('line', (line) => {
switch (line.trim()) {
case 'y':
fs.unlinkSync("redirect.html");
rl.close();
break;
case 'n':
rl.close();
break;
default:
console.log(`Unknown: ${line.trim()}`);
break;
}
});
}
const main = async() => {
try {
await download();
console.log("Download completed");
await promisify(cleanup)(`Clear redirect.html? [y/n]> `);
} catch (err) {
console.error(`Error: ${err.message}`);
}
}
main();
とってもクリアになりました。
> node fetchspikeasync.js
Download completed
Clear redirect.html? [y/n]> y