対象読者
- JavaScript初心者
- JavaScriptの非同期処理について学んだけど実際には使ってみたことはない人
- Node.jsで対話式のCLIツールを作ろうとしている人
完成したツールを教えてほしい
という人は、ここまでジャンプ。(ほぼ最後まで飛びます)
途中はこの記事を書く上での経緯や、日記的な記録を織り交ぜている。
背景も知りたいなあという方は続けてどうぞ。
前提とおことわり
まず、この記事ではJavaScriptの非同期処理のすべては解説していない。(は?)
JavaScript初心者が、初心者なりに、実践の中で学んだ内容を解説しているので、
非同期処理の詳細が知りたい方は専門書を読んでください。
あくまで 「非同期処理をより実践で使うにはどうするのか?」 という点にフォーカスした記事ですのでご了承を。
この記事で書くこと
- CLIツール制作において非同期処理を扱い、組み込む流れ
この記事で書かないこと
- JavaScript非同期処理についての詳しい解説
- slackアプリトークンの取得方法、設定方法
- シェルスクリプトのパスの通し方
この記事を書くに至った経緯
JavaScriptの非同期処理について解説した記事は、
とにかくsetTimeout
を例にした記事が出てくる。
しかし、setTimeout
は実際のプログラムでの使い所は限られていると感じる。
(私がJavaScript初心者だから使い所を知らないだけかも)
(setTimeout
最高!という方はごめんなさい)
後述するCLIツール作成においては、
より実践的な非同期処理の扱いが必要となる。
setTimeout
を用いない非同期処理についての
解説記事は少ないのではないかと思い、この記事を書いてみることにした。
これを書いている人
- JavaScript初心者(業務未経験)
- C言語の業務経験が4年ちょい(Javaも少し)
- VBAやbashのツール開発とかもやってた
- 経験上、プログラムは基本的に上から下へ順番に動くと思っていた
とある日のこと
私は組み込み系のエンジニアを4年近く経験した後、
フロントエンドエンジニアとしてジョブチェンジした。
研修も終わり、とある部署に配属された。
そこでは退勤時にslackでミニ日報を報告する文化があった。
と思う人は少なくないのではないだろうか。
じゃあ、やってみよう。
先人の知恵に感謝する
どうやって作ろうか考えていたところ、
やはり先人たちも同じ道を通ってきたのだろう。
先輩がこのような記事を書いていた。
これだ!!!!
私好みのCLIツールで、中身もNode.jsだ。
JavaScript初心者の自分には
ちょうど良い勉強になると思い、
さっそく記事を参考にツール制作に取り掛かった。
※実は先輩が書いた記事通りにコードを書いても、(いい感じに省略されているので)非同期処理以前の問題でうまくいかないのですが、それはまた別のお話ということで一部改変しながら進めます。
開発環境
- macOS Monterey バージョン12.5.1
- Node.js 16.19.0
試作ツールの概要
今回作る試作ツールで使うパッケージなどを簡単に紹介すると以下の通り。
- 対話型で入力した内容を反映する
- データ一覧から取得した選択肢から入力する
- 入力が終了したらslackチャンネルに投稿する
各ステップの詳細は以下の通り
1. 対話型で入力した内容を反映する : Node.js readlineモジュールのquestionメソッドを使用
import readline from "readline";
// インターフェースを設定
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
function askFood () {
rl.question('好きな食べ物はなんですか?', (answer) => {
console.log(`なるほど、${answer}が好きなんですね。`)
rl.close(); // closeメソッドでインターフェイスを閉じます。
});
};
export {askFood};
2. データ一覧から取得した選択肢から入力する : Node.js enquirerモジュールのMultiSelect Promptを使用
import pkg from 'enquirer';
const {MultiSelect} = pkg;
const selectFood = (question) => {
const prompt = new MultiSelect({
message: question,
choices: [
{ name: '寿司' },
{ name: '焼肉' },
{ name: 'カレー' },
{ name: '唐揚げ' },
{ name: 'ハンバーグ' },
{ name: 'ピザ' }
]
});
return prompt
.run()
.then((answer) => {
return `${answer.join(",")}`;
})
.catch(console.error);
};
export {selectFood};
// ['寿司', 'カレー', 'ピザ']
3.Node.js @slack/boltモジュールのclient.chat.postMessageメソッドを使用
※Slack APIとの連携の詳細やアプリの設置手順等は割愛するため、他の記事を参考にしていただきたい。
import slack from "../bin/slack.json" assert { type: "json" };
import pkg from "@slack/bolt";
const { App } = pkg;
const app = new App({
token: 'ワークスペースに設置したアプリのトークン',
signingSecret: 'ワークスペースに設置したアプリのシークレットID',
});
const channelId = slack.postChannelId;
const postTextToSlack = (param) => {
try {
app.client.chat.postMessage({
channel: channelId,
text: param,
});
} catch (error) {
console.error(error);
}
console.log('slackに投稿しました!');
};
export {postTextToSlack};
各パーツは完成。
(へぇ〜jsonモジュールのインポートは実験的な機能なんだ・・・と思いつつ)
うまくいってそう。
次に上記3つのソースを呼び出すmain関数を追加。
import {askFood} from "./js/readline.js";
import {selectFood} from "./js/selectFood.js";
import {postTextToSlack} from "./js/postTextToSlack.js";
const main = () => {
console.log("★ ★ ★ tool start ★ ★ ★");
// 好きな食べ物を入力する
const input = askFood("");
// 好きな食べ物を選択肢から選ぶ
const select = selectFood("好きな食べ物はなんですか?");
// slackへ投稿
const text = "```" + input + "\n" + select + "```";
postTextToSlack(text);
console.log("★ ★ ★ tool end ★ ★ ★");
};
main();
現時点のディレクトリ構造
├── bin
│ ├── report ★「report」コマンドで起動するためのbashシェルスクリプト
│ └── slack.json ★slack APIとの連携情報(トークン、チャンネルIDなど)
├── js
│ ├── postTextToSlack.js
│ ├── readline.js
│ └── selectFood.js
└── main.js
よしできた!動かしてみよう
※「report」というコマンドでCLIツールを起動するためにはパスを通す必要があるがここでは割愛する
・・・あれ?
何も入力、選択していないのにslackに投稿したという出力が・・・
なんかうまくいってないっぽいのはわかる。
そのままenterを押して進むと、slackには以下の投稿が。
うーん・・・やっぱりダメみたい。
では何がダメなのか?
どう動いて欲しいか
経験上、プログラムは基本的に上から下へ順番に動くと思っていた
これに尽きる。
つまり
main関数実行
↓
1. askFood
で入力待ち→入力値を変数に格納
2. selectFood
で入力待ち→選択値を変数に格納
3. postTextToSlack
でslackに投稿
この順番で動いて欲しいのだ。というかこう動くと思っていた。
もう少し詳しく書くと、
「上から順番通り、入力待ちなどが発生したらその処理が完了してから次の処理が動く」
と思っていた。
どうやら、入力待ちをすっ飛ばして次の処理が動いた結果、未入力状態のままslackに送信されているようだ。
実際に、それぞれの関数を個別で動かした時は、うまくいったのに、なぜ・・・
非同期処理との出会い
やっと本題です。お待たせしました。
入力待ちを飛ばして次の処理が動く現象は、
調べるとどうやらJavaScriptが親切に非同期処理をしてくれていたみたいだ。
※「非同期」とはJavaScript特有のものではなく、コンピューターサイエンス全般における概念である
同期処理
「上から順番通りに動いて欲しい」「入力待ちなどは、飛ばさずに待って欲しい」 というような、
コードに記載されている処理を上から下に順番に実行すること。
非同期処理
「入力待ちなどを飛ばして次の処理が動く」 というような、
コードに記載されている処理を上から下に順番に実行していく中で、ある処理が終了するのを待たずに別の処理を実行すること。
非同期処理の挙動
つまりどういうことかというと、先ほどのmain関数を例に見ると以下の通り。
1.
askFood
で入力待ち→入力値を変数に格納
→したいが、入力完了まで待たずに次の処理を実行
2.
selectFood
で入力待ち→選択値を変数に格納
→したいが、入力完了まで待たずに次の処理を実行
3.
postTextToSlack
でslackに投稿
→処理が終了したので飛ばした処理に戻る
ということが裏で行われていたのだ。
なるほど。
時間がかかるとわかっているから、処理を後回しにしてくれていたんですね。
親切だなぁ・・・(親切でない時もある)
今回は同期的に動かしたいんだ!
そう、今回は処理を飛ばさずに待ってほしい。
同期的に動かす方法は・・・ ありました!
- コールバック地獄
Promise
async
/await
これらは、JavaScriptの非同期処理について調べると必ず出てくるワードなので、すでに知っている人も多いだろう。
冒頭にも記載したが、詳しい内容は専門書などを参考にしてほしい。
今回は、async
/ await
を使って、ツールを完成させたいと思う。
async / await
こちらの記事を参考にしてasync
/ await
を入れ込んでいく。
まず、1. askFood
と2. selectFood
を入力が完了するまで実行待ちさせたいので、await
を追加する。
// 好きな食べ物を入力する
const input = await askFood("");
// 好きな食べ物を選択肢から選ぶ
const select = await selectFood("好きな食べ物はなんですか?");
次に、await
はasync
関数の中でしか使えないので、main
関数を書き換える。
今回はアロー関数で書いているので、引数の()の前に追加する。
import {askFood} from "./js/readline.js";
import {selectFood} from "./js/selectFood.js";
import {postTextToSlack} from "./js/postTextToSlack.js";
const main = async () => {
console.log("★ ★ ★ tool start ★ ★ ★");
// 好きな食べ物を入力する
const input = await askFood("");
// 好きな食べ物を選択肢から選ぶ
const select = await selectFood("好きな食べ物はなんですか?");
// slackへ投稿
const text = "```" + input + "\n" + select + "```";
postTextToSlack(text);
console.log("★ ★ ★ tool end ★ ★ ★");
};
main();
もちろんこれだけではダメで、呼び出し先のaskFood
とselectFood
でPromise
オブジェクトが返ってくるようにしないといけない。
ここで思い出したいのが、最初にslackに投稿されてしまったテキストだ。
askFood
はundefinedであったが、selectFood
の方はPromise
オブジェクトが返ってきていることがわかる。
なので、askFood
の方でPromise
オブジェクトが返ってくるように書き換える。
import readline from "readline";
// インターフェースを設定
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
function askFood () {
return new Promise(resolve => {
rl.question('好きな食べ物はなんですか?', (answer) => {
console.log(`なるほど、${answer}が好きなんですね。`)
rl.close(); // closeメソッドでインターフェイスを閉じます。
resolve(`${answer}が好き`);
});
});
};
export {askFood};
再度動かしてみる
お!処理が止まった!
良さそうなので続けて入力してみる。
await
をつけたことにより、selectFood
のところで処理が止まっていることがわかる。
うまくいっている!
これでやりたかったことは最低限できるようになった。
実際に使えるツールに改造する
私が作りたいのは食べ物を投稿するツールではない。
日報報告ツールを作りたいのだ。
と、いうことで改造してみたのがこちら↓
ざっくり解説すると以下の通り
.
├── README.md
├── bin
│ ├── report
│ └── slack.json
├── config
│ └── workList.js ★タスクの選択肢をオブジェクトにまとめたファイル
├── js
│ ├── addOtherItems.js ★その他共有事項を自由入力できるようにするための関数
│ ├── addOtherText.js ★登録済みのタスク以外に自由入力できるようにするための関数
│ ├── calcWorkingHours.js ★ツールを実行した時間から勤務時間を計算する関数群
│ ├── checkPosting.js ★slackに投稿してよいかチェックする関数
│ ├── inputRestTime.js ★休憩時間を入力する関数
│ ├── inputStartTime.js ★勤務開始時間を入力する関数
│ ├── makePostingText.js ★slackに投稿する文章を整形する関数
│ ├── postTextToSlack.js
│ └── selectWork.js ★config/workList.jsに記載されているタスクを選択する関数
├── main.js
├── package-lock.json
└── package.json
実際に使ってみたのがこんな感じ
※一部は業務内容が含まれているので隠しています
興味ある方はクローン等して自分でも作ってみてください。
まとめ
途中かなり解説を省略してしまったが、無事ツールを完成させることができた。
まだまだ改善できそうなところはあるので、隙を見て直していきたい。
※タスク一覧をローカルを持っているのではなく、外部のプロジェクト管理ツールなどから取得する、など
また、JavaScriptの非同期処理という落とし穴に早いうちに気づけてよかったと思っている。
この記事が、JavaScript初心者のみなさんの非同期処理についての悩みの手助けになれば幸いだ。