9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Javascript 再入門

Posted at

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.comRelease からのダウンロードは、最初に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 での書き換え

ついでにプロンプトも若干リッチにしておいた。readlinequestionの他にも、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
9
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?