銀の弾丸

プログラミングに関して、いろいろ書き残していければと思っております。

今後JavaScriptでArray.forEachはほとんど使わなくなるのかも


いままでJavaScriptの繰り返しは Array#forEachを使いまくっていましたけど、2018年現在「インデックスが必要ないなら for..of 構文が使えますよ」と聞きまして、ミーハーなので突然使い始めています。

聞いたことはありました

いや、for..of については小耳にはさんだことはありましたが「(どうせ)各ブラウザではまだ対応状況がまちまちだったりするんでしょ?」とか思い込んでいて、とりあえず静観してたら忘れちゃっていました(「どうせ」は良くない接頭語w)。

Edge以外はほとんど対応しているみたい

今調べてみると、思った以上に各ブラウザが対応していて、これは無視できないムーヴメントだと認識するに至りました。

MDNによるとEdgeが未対応らしいですね。だから、あまねく世に繰り出すWebサービスでは使えないのかもしれないけれど、個人的にはEdgeはほとんど使わないので、今後for..ofをガンガン使って行こうという所存です。

for ... of の構文は?

MDNでは、以下のように説明されています。

for (variable of iterable) {
  statement
}
variable
それぞれの反復処理において、別々のプロパティの値が variable に代入されます。
iterable
列挙可能なプロパティに対して、反復処理を行うオブジェクトです。

iterableは簡単に言えば配列みたいなオブジェクトで、関数の引数リストである arguments や、DOMの Node.childNodesiterableiterable === Arrayではありませんから、Array#forEachとかは使えなくて、Array.fromArrayに変換しなくてはなりません。 そういう意味では、言語の構文として for..ofがあるので、便利になったと言えるでしょう。 ちなみにStringiterableだそうで、実は本日知りました(内緒)。

一般的な使い方

一般的には以下のように使います。これは配列の場合ですね。他も似たり寄ったりです(多分)。

for(const foo of [0,1,2]) {
    console.log(foo);
}
// output:
// 0
// 1
// 2

速度に関する考察

入力情報によれば「Array.forEachよりも関数呼び出しが伴わない分、for..of のほうが若干速い」とのことでした。

Array.forEachで各ループを別スレッドに振り分けるような実装がされたら、処理速度は比較にならないのでは?と思っていましたが、for..of だって別スレッドに割り振れるはずなので、どっこいどっこいなのかな?と。

ほとんどforEachは使わなくて済みそうですね

見た目に for..ofのほうがスッキリするし若干速そうなので、今後はfor..inを使いましょうと。 インデックスが必要ない場合だけですけどね。

WebアプリからGoogleドライブにファイルを保存する方法

f:id:takamints:20180729003439j:plain

前回記事の読み込みに引き続いて、WEBアプリからGoogleドライブにファイルを保存するサンプルコードを掲載します。

公式ガイドにWEBブラウザJavaScript向けサンプルコードは有りませんでした。 しかし前回懲りて学習しました。 XHRで直接APIを叩けばOK。 今回は、あまり停滞せずに0.5人日でなんとかなった。 いくつになってもお勉強です。

takamints.hatenablog.jp



ここに書いてる機能を、npmとしてまとめましたので是非どうぞー

takamints.hatenablog.jp


目次

シンプルアップロードを使います

Googleドライブへのファイルのアップロードには3つのやり方がありますが、ここで説明しているのは最も単純な「シンプルアップロード」という方法です。 他2つ「マルチパートアップロード」と「リジューマブルアップロード」には言及しません。

シンプルアップロードを使う条件は以下:

  • 通信が切断したりしてアップロードが失敗した場合に、再度アップロードするのが苦にならない程度にファイルが小さい。
  • メタデータを同時に送信しない。アップロードとは別リクエストでメタデータを送信するか、メタデータを利用しないならOK。

新規作成はAPIを二度叩く

シンプルアップロードでファイルを新規作成するのは以下の手順。

  1. ファイル名を指定してリソースを作成(API drive.files.create)し、ファイルIDを入手(この時点で中身が空のファイルが生成されている)
  2. 上で得たファイルIDで、コンテンツを上書きする(API drive.files.update)。

既存ファイルへの上書き保存は簡単です

一方、既にファイルIDが分かっているなら、上記2の「コンテンツの上書き」と同じことを行うだけです。

ということでコードはこちら

以下、少々長くなりますが、関数単位に分割してお送りします。

2つのクラス GdfsUi, Gdfs がありまして、以下の関連になっています。

f:id:takamints:20180729002042p:plain

GdfsUiクラスは、カレントディレクトリとそのファイルリストを管理してます。 GdfsクラスはGoogle Drive APIの低レベルインターフェースクラスです(APIに対して直接的)。

そのほか、OAuth2での認証取得やUIの生成については、以下のGitHubリポジトリを見てください。 API キーを http://localhost:8080/ に対して公開していますので、ローカルのウェブサーバーで動作確認できるはずです。

github.com

Googleドライブにファイルを保存する関数

指定された名称のファイルがファイルリスト中にあるなら上書き保存し、無ければ新規作成します。

/**
 * Create or overwrite a file to current directory.
 * @param {string} filename The file name.
 * @param {any} data The file content.
 * @param {string} contentType The content type.
 * @returns {Promise<object>} The response of update.
 */
GdfsUi.prototype.writeFile = async function (
    filename, data, contentType)
{
    // Find same file on current directory
    let fileIds = this._files.filter( file => {
        return file.name === filename;
    }).map( file => file.id );

    let fileId = (fileIds.length > 0 ? fileIds[0] : null);

    //Create new file
    if(fileId == null) {
        let response = await this._gdfs.createFile(
            this._folderId, filename, contentType);
        let file = JSON.parse(response);
        console.log(JSON.stringify(file, null, "    "));
        fileId = file.id;
    }

    // Write file
    return await this._gdfs.updateFile(
        fileId, data, contentType);
};

ファイルリソースを新規作成する関数

この関数は、ファイルリソースを作成します。 レスポンスに作成されたファイルのファイルIDが返されますので、 これを使ってファイルコンテンツを上書きできます。

/**
 * Create a new file's resource.
 * @param {string} folderId The folder id where the file is created.
 * @param {string} filename The file name.
 * @param {string} mimeType The mime type for the new file.
 * @returns {Promise<object>} The response of the API.
 */
Gdfs.prototype.createFile = function(folderId, filename, mimeType) {
    return this.requestWithAuth("POST",
        "https://www.googleapis.com/drive/v3/files", {},
        { "Content-Type": "application/json", },
        JSON.stringify({
            name: filename,
            mimeType: mimeType,
            parents: [folderId],
        }));
};

既存ファイルへコンテンツを上書きする関数

ファイルIDを指定して既存ファイルに上書きします。 APIガイドでは「既存ファイルの更新にはPUTメソッドを使え」と書いてありましたがPUTはエラーになりました。 drive.files.update APIのリファレンスを参照すると「PATCHメソッドだ」と書いてあった・・・ドナイヤネン。

/**
 * Upload a file content to update a existing file.
 * @param {string} fileId The file id to update.
 * @param {any} data The file content.
 * @param {string} contentType The content type of the file.
 * @returns {Promise<object>} The response of the API.
 */
Gdfs.prototype.updateFile = function(fileId, data, contentType) {
    return this.requestWithAuth("PATCH",
        "https://www.googleapis.com/upload/drive/v3/files/"+fileId,
        { uploadType: "media" },
        { "Content-Type": contentType },
        data);
};

共通関数

以下、上記パブリックメソッドから呼ばれている関数です。

XHRでGoogle Drive APIのリクエス

XHRで指定されたリクエストを行う関数。 認証情報(アクセストークン)をリクエストヘッダに設定します。

/**
 * @param {string} method The request method.
 * @param {string} endpoint The endpoint of API.
 * @param {object} queryParams The query parameters.
 * @param {object} headers The request headers.
 * @param {any} body The request body.
 * @returns {Promise<object>} The response of the request.
 */
Gdfs.prototype.requestWithAuth = function(
    method, endpoint, queryParams, headers, body)
{
    let xhr = new XMLHttpRequest();
    xhr.open(method, this.createUrl(endpoint, queryParams), true);
    headers = headers || {};
    Object.keys(headers).forEach( name => {
        xhr.setRequestHeader(name, headers[name]);
    });
    xhr.setRequestHeader("Authorization",
        "Bearer " + this.getAccessToken());
    xhr.timeout = 30000;
    return new Promise( (resolve, reject) => {
        xhr.onload = e => { resolve(xhr.responseText); };
        xhr.onerror = e => { reject(new Error(xhr.statusText)); };
        xhr.ontimeout = () => { reject(new Error("request timeout")); };
        xhr.send(body);
    });
};

アクセストークンを取得する

現セッションのアクセストークンを取得する。 gapiはDirve APIをロードしたときから使えるグローバルオブジェクト。

/**
 * Get access-token on current session.
 * @returns {string} The access token.
 */
Gdfs.prototype.getAccessToken = function() {
    let googleUser = gapi.auth2.getAuthInstance().currentUser.get();
    let authResponse = googleUser.getAuthResponse(true);
    let accessToken = authResponse.access_token;
    return accessToken;
};

APIのURLを作成する

エンドポイントとクエリパラメータからURLを生成。

/**
 * Create URI including query parameters.
 * @param {string} endpoint The endpoint of API.
 * @param {object|null} params The query parameters.
 * @returns {string} The URI.
 */
Gdfs.prototype.createUrl = function(endpoint, params) {
    if(params == null) {
        return endpoint;
    }
    let keys = Object.keys(params).filter(
        key => (key !== ""));
    if(keys.length == 0) {
        return endpoint;
    }
    let queryString = keys.map( key => {
        let value = params[key];
        return (value == null ? null : `${key}=${encodeURI(value)}`);
    }).join("&");
    return `${endpoint}?${queryString}`;
};

WebアプリでGoogleドライブのファイルを読み込む方法

f:id:takamints:20180722212809j:plain FlickrGoogle Self-Driving Car ( Creatorsmoothgroover22, License: CC BY-SA 2.0 )

ここしばらく「できないわけがないじゃないか」と調べていた個人的な大問題、「ブラウザで動作するWebアプリから、ユーザーのGoogleドライブに保存されているファイルを読み込む方法」を解決できたので小躍りしながら書いております。

Googleドライブのファイルを読み込むサンプルコードは、下記APIの説明ページで示されています。 しかしWebページのJavaScript用のがありません。 Node.jsのはありますが、これはWebのJavaScriptでは動きません。fs(ファイルシステム)モジュールを使用しており、パイプでストリームへコンテンツを流しているからです。

先に結論としてまとめてしまうと、Drive REST API Files.get を XHRで直接叩いて読み込みます。 クエリパラメータで alt=mediaを指定して、リクエストヘッダにサインインユーザーのアクセストークンを指定する必要がありますが。 どうして Files.get API の通常の(関数としての)呼び出しで、読み込めないのかわからないのですが、エラーになるので仕方がない。

一方、保存するのはこちらから!
takamints.hatenablog.jp

Node.js のfsモジュール的なメソッドでGoogle Driveを扱うモジュールを、なんとかnpmとしてまとめましたので是非どうぞー

takamints.hatenablog.jp


目次


基礎から学ぶ Vue.js
基礎から学ぶ Vue.js
posted with amazlet at 18.07.22
シーアンドアール研究所 (2018-05-29)
売り上げランキング: 1,625

やりたいことは、それじゃない

ダウンロードではありません

ここで示しているのは、WEBアプリが利用するデータファイルをGoogle Driveから読み込む方法であって、ブラウザの機能を利用してユーザーのローカルファイルシステムへダウンロードするものではありません

Drive REST API Files.get で得られる webContentLink をhrefにセットしたアンカータグ(<a href="...">...</a>)を生成すれば、ユーザーがクリックしてファイルがダウンロードできますが、JavaScriptのコードからは利用できません(CORSのエラーが発生します)。

Google Docのファイルは読み込まない。

Google Docのファイル(Googleスプレッド、ドキュメント、スライドなど)は、以下のコードでは読み込めません。

これらは別途 Files.export API で取り出せるらしいのですが、あくまでもエクスポートで、独自のWebアプリで開く方法ではありません。 各ファイルの専用アプリで表示したり編集するには、ファイルリソースの webViewLink をたどれば可能。

WEBページのJavaScriptGoogle Driveのファイルを読み込むコード

ということで、前置きが長くなりましたが、以下にコードを示します(ざっくり説明は下のほうに)。 このコードは、現在作成中のモジュール gdrive-fs からの抜粋です。

使用しているGoogle Drive APIはV3。 WebアプリケーションがOAuth2の認証を得ている前提で動作します。 ChromeFirefox、Edgeで検証済みです。

/**
 * Get a file content as text from Google Drive.
 * Even if the file is not a text actually, it could be converted
 * to ArrayBuffer, Blob or JSON to use by Web App.
 * @async
 * @param {string} fileId The file id to download.
 * @param {boolean|null} acknowledgeAbuse A user acknowledgment
 * status for the potential to abuse. This parameter is optional.
 * default value is false.
 * @returns {Promise<string>} A downloaded content as text.
 */
GdfsUi.prototype.downloadFile = async function(
    fileId, acknowledgeAbuse)
{
    return await new Promise( (resolve, reject) => {

        // Create download URL
        let apiUrl = "https://www.googleapis.com/drive/v3/files";

        // Query parameter
        let queryParams = { "alt":"media" }; //【1】
        if(acknowledgeAbuse) {
            queryParams.acknowledgeAbuse = acknowledgeAbuse; //【2】
        }
        let urlParam = Object.keys(queryParams).map(
                key => `${key}=${encodeURI(queryParams[key])}` ).join("&");

        let downloadUrl = `${apiUrl}/${fileId}?${urlParam}`; //【3】

        // Get access-token on current session【4】
        let googleUser = gapi.auth2.getAuthInstance().currentUser.get();
        let authResponse = googleUser.getAuthResponse(true);
        let accessToken = authResponse.access_token;

        // Send a request with XHR
        let xhr = new XMLHttpRequest();
        xhr.open("GET", downloadUrl, true);
        xhr.setRequestHeader("Authorization", "Bearer " + accessToken); //【5】
        xhr.onload = e => { resolve(xhr.responseText); };
        xhr.onerror = e => { reject(new Error(xhr.statusText)); };
        xhr.ontimeout = () => { reject(new Error("request timeout")); };
        xhr.timeout = 30000;
        xhr.send(null);

    });
};

ざっくり説明

※ 以下のリストの項番がコード中の【 】内の数字と合致します。

  1. ファイルの中身を取り出したいときは、クエリパラメータ alt"media"としなくてはなりません。でないとリソース情報が返ってきます。
  2. acknowledgeAbuseはよくわかっていませんが、妙なことをするかもしれないけどユーザーがそれを認識しているかどうかってことでしょうか。少なくとも自分が試した範囲では、指定しなくてもよかったのですけど・・・。Abuseフラグみたいなのがファイルについているみたいで、そのフラグが立っていると、このリクエストパラメータをtrueにしなくてはならないようです。逆に、フラグがfalseの場合に、trueを指定すると怒られます。残念。
  3. ダウンロードURLには、fileId と 上記 alt パラメータが必須です。
  4. ユーザーがサインインしたときに発行されたアクセストークンを取得しています。gapiAPIを読み込み以降利用できるグローバルオブジェクトです。gapi.auth2.getAuthInstance().currentUser.get() で、ユーザーがサインインしたときの情報が得られ、その中にアクセストークンがあります。
  5. このアクセストークンを、リクエストヘッダに設定します。本当ならこの前にアクセストークンの有効期限をチェックして無効になっているならリフレッシュトークンで再発行する必要があるかもしれません。

モジュール全体はコチラ

上記コードを含むモジュール全体は、GitHubのgdrive-fsです。 まだ作成途中ですが、UIのスケルトンやサインインシーケンスなどのコードがあります。 UIをカスタマイズできてWebアプリからGoogle Drive のファイルを扱うモジュールを目指しています。おそらくそのうち npm でPublishすると思います。

github.com

今のところhttp://localhost:8080/ホワイトリストに入れたAPIキーをリポジトリで晒しておりますので、そのままでもご確認いただけます。 セキュリティリスク大丈夫か?って警告を受けていますが、利用者のDriveのファイルが見えるだけなので問題はありません。 利用者側でAPIKeyを取っていただいても大丈夫(というかむしろ推奨)。

所感・あとがき

こんなの、今の時代すぐにでもできると思うじゃないですか。 しかし、情報を探っても辿っても途中でぶつ切れになる感じで、同じところを逡巡して「やりたいのはそれじゃない」って情報がわんさか出てきて自分のググラビリティっていったい・・・。

しかしまあ、自分の Google Drive API に関する知識はチュートリアルをやっと乗り越えられたレベルで、実装経験はほとんどゼロです。 Google Drive APIに関して、根本的な設計思想やドキュメントの行間を読む力が足りていなかったからでしょう。 そういう意味では今回いろんなドキュメントを手繰ってどうにか解決したので少しはレベルアップできたかもしれません。

日々精進。いくつになってもお勉強です。

非同期Lambdaのコンテキストはどこ行った?

f:id:takamints:20180630111519j:plain
Flickrdetective ( Creatorolarte.ollie, License: CC BY-SA 2.0 )


結局どこへも行ってなかったって結論ですがっ!

AWSによるサーバーレスアーキテクチャ
Peter Sbarski
翔泳社
売り上げランキング: 4,634


この4月からAWS Lambda で Node.js 8.10 が使えるようになっており、AWSのコンソールから新規作成するとひな型がasync関数になっています。 しかし引数リストが event ひとつだけになっていて、従来第2引数で渡されていた context はどこに行ったの?と調べてみました。

目次

はじめての Lambda with Node.js 8.10 は非同期関数

現在、Node.js 8.10 のLambda を新規作成すると以下のひな型が作られます。async関数になってますね。

そして、引数が event だけ。えらいスッキリしましたね。context は何処へ?

exports.handler = async (event) => {
    // TODO implement
    return 'Hello from Lambda!'
};

ところで Lambda のコンテキストって何でしょう

AWS Lambdaに渡される context は以下のキーを持つオブジェクト。 Lambda関数自体の情報を保持しています。 結構重要な情報があるはずなので、これが渡されないとはいぶかしい。

  • callbackWaitsForEmptyEventLoop
  • functionName
  • functionVersion
  • invokedFunctionArn
  • memoryLimitInMB
  • awsRequestId
  • logGroupName
  • logStreamName
  • identity
  • clientContext

参考: Context オブジェクト (Node.js) - AWS Lambda

this === context?」って仮説は否定されました

ジャバスクリプターが「コンテキスト」と聞いて最初に連想するのは this ですよね(本当か?)

そこで「Node.js 8.10 のLambda関数内では、this === context なのではないか?」と仮説を立てて、以下のLambdaを書いてみた。

exports.handler = async (event) => {
    return JSON.stringify({
        callbackWaitsForEmptyEventLoop:
            this.callbackWaitsForEmptyEventLoop,
        functionName: this.functionName,
        functionVersion: this.functionVersion,
        invokedFunctionArn: this.invokedFunctionArn,
        memoryLimitInMB: this.memoryLimitInMB,
        awsRequestId: this.awsRequestId,
        logGroupName: this.logGroupName,
        logStreamName: this.logStreamName,
        identity: this.identity,
        clientContext: this.clientContext,
    });
};

で、コンソールから動かしてみたのですが、全てがnullで、仮説は完全否定されました。 まあ、そんなヤヤコシイことしないか。

じゃあコンテキストはどこ行った?

てことでAWSコンソールでしばらく探ってみたのですが、「もしやどこにも行っていないのでは?」と思い、

exports.handler = async (event, context) => {
    return JSON.stringify({
        callbackWaitsForEmptyEventLoop:
            context.callbackWaitsForEmptyEventLoop,
        functionName: context.functionName,
        functionVersion: context.functionVersion,
        invokedFunctionArn: context.invokedFunctionArn,
        memoryLimitInMB: context.memoryLimitInMB,
        awsRequestId: context.awsRequestId,
        logGroupName: context.logGroupName,
        logStreamName: context.logStreamName,
        identity: context.identity,
        clientContext: context.clientContext,
    });
};

上のLambdaで確認すると、、、お見事大当たり。なんだちゃんと渡って来てるじゃないかと。

コンソールの実行結果: f:id:takamints:20180630102131p:plain

非同期Lambdaのcallbackの扱いは?

じゃ、「コールバックはどこ行った?」と確認してみると、しっかり第3引数でもらってました。何も変わっちゃいなかったのね。

そして、Async なLambdaで、従来通り callback を呼び出した場合は、優先的に戻り値として採用されるようになっています。

以下のLambdaを呼び出すと "OK!" が返されます。

exports.handler = async (event, context, callback) => {
    callback(null, "OK!");
    return JSON.stringify({
        callbackWaitsForEmptyEventLoop:
            context.callbackWaitsForEmptyEventLoop,
        functionName: context.functionName,
        functionVersion: context.functionVersion,
        invokedFunctionArn: context.invokedFunctionArn,
        memoryLimitInMB: context.memoryLimitInMB,
        awsRequestId: context.awsRequestId,
        logGroupName: context.logGroupName,
        logStreamName: context.logStreamName,
        identity: context.identity,
        clientContext: context.clientContext,
    });
};

結論と所感など

この調査で分かったこと:

  • Node.js 8.10 の Async な Lambda にも、contextやcallbackコールバックは渡されている(単にひな型の引数リストからなくなってるだけ)。
  • コールバックで解決した値が、return よりも優先的にLambdaの戻り値として採用される。

contextについて、あえて渡さないことにメリットがないので当たり前と言えば当たり前か。 自分の妙な思い込みが邪魔をしました(笑)。

コールバックの件については、古いLambdaを改造するときに、どうしても await を使いたくなったら、Node.js のランタイムバージョンを8.10に変更して、async を付け加えれるだけで済むのですね。レアケースかも知れんけど。

しかし、なんで初期状態の引数リストからcontextを削除しちゃったのだろうかね?

callbackを、あえて示さないのは理解できるが・・・。

async / await の基本事項 ― やっぱりPromiseは無視できない

f:id:takamints:20180527125137j:plain
photo credit: simmons.kevin4208 Promise via photopin (license)

JavaScriptのES2017で使えるようになった async/await 。 従来Promiseで書いていた非同期処理が、ずいぶん簡潔に書けるようになりました。

AWS Lambdaでも、2018年4月から Node.js v8.10(LTS) が使えるようになっており、新規作成したハンドラーは async 関数になっています。

しかし「async/await を使えば、Promiseについて知らなくてもよい」とは言えません。 むしろしっかり理解しておく必要がありますよと。

つまり、async / await は Promiseを置き換えるものではなく「Promiseによる非同期処理を同期処理的に記述するための記法」なのです。

ということで、ここには async / await の基本事項と気づいた点やら困った点などを、まとめておきたいと思います(Promiseを理解している人向けの内容です)。

関連記事:
takamints.hatenablog.jp takamints.hatenablog.jp

目次:

これからWebをはじめる人のHTML&CSS、JavaScriptのきほんのきほん
たにぐちまこと
マイナビ出版 (2017-03-27)
売り上げランキング: 6,710

async関数とはどういうものか?

async関数は、その記述内容に関わらず、必ずPromiseオブジェクトを返す関数です。 必ずしも非同期動作するわけではないことには軽く注意が必要です(これ後述します)。 また、構文的には関数内で後述の await を使えることも示しています。

以降、これらをもう少し細かく書いてます。

見かけによらずPromiseオブジェクトを返します

async関数は、どのように記述されていようと、Promiseオブジェクトを返します。 以下の何もしなさそうに見える関数 foo も、しっかりPromiseを返します。

async function foo() {   }

これをPromiseを明示的に使って書くとこうなります。

function foo() {
    return new Promise(
        (resolve,reject) => {
            resolve();
        }
    );
}

上に示したどちらのfooも、Promiseを返しますので、以下のコードは正しく動作します。

foo().then(()=>{
    console.log("ほらね!");// =>ほらね!
});
asyncだからって非同期だとは限らない

関数定義でasyncを指定しただけで非同期関数にはなりません。 同期的に解決するPromiseはあり得ます。 上の foo も、同期的に解決しています。

Promiseのコンストラクタ内でresolveハンドラが呼ばれており生成されたPromiseを返しているので、明らかに同期的に動作してます。

もうひとつ。以下のスクリプトを実行してコンソールに先に表示されるのは "foo" でしょうか、"bar"でしょうか。

async function foo() {
    //await new Promise(r=>setTimeout(()=>r(),0));
    console.log("foo");
}
function bar() {
    foo();
    console.log("bar");
}
bar();

これ実は、”foo” なんですよ。

Async関数だから「"foo"は後から表示される」と思っていましたが、手元のNode.jsで検証した結果、そうなりました。 つまり同期的に実行されているのですね。

ちなみに foo のコメントアウトを外せば"foo" はあとから表示されます(タイマー値が0でも)。

この動き、地味ですけれど覚えておいたほうがよさそうですね。

そのPromiseは見かけ上の戻り値で解決(resolve)される

上のfooが返すPromiseオブジェクトは、undefinedで解決(resolve)されます。 それは見かけ上この関数が何も返していない(≒undefinedを返している)からです。

async function foo() {  return  "ちゃんと動いた!"; }

foo().then( ( result )=>{
    console.log( result ); // => ちゃんと動いた!
});

エラー投入でrejectされる。

Promiseを明示的に生成するときには、エラーの発生はrejectコールバックで伝えましたが、async関数ではErrorをthrowすればよろしい。 そうすると、実質的な戻り値のPromiseオブジェクトはrejectされて、呼び元の catchハンドラが呼ばれます。

async function foo() {
    throw new Error("エラーだよ!");
}
foo().then( () => {} ).catch( err => {
    console.log( err.message ); // => エラーだよ!
});

asyncがコールバック地獄から救ってくれるわけじゃない

妙な言い方になっていますが、つまり、コールバック関数による旧式非同期処理を最新式のPromise化するには直接Promiseを使うしか方法はなく、asyncは直接的に関わらないということです。

例えば、コールバックによる非同期処理の fs.readFile をPromise化するラッパーを作るには、以下のようになります。

function readFile(filename) {
    return new Promise((resolve, reject)=>{
        fs.readFile(filename, (err, data) => {
            if(err) {
                 reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

これを、async関数にもできますが下のようになる程度。 上の実装で既にawaitできますから「確実にPromiseを返すことを宣言する」以外に意味はありません。

async function readFile(filename) {
    return await new Promise((resolve, reject)=>{
        fs.readFile(filename, (err, data) => {
            if(err) {
                 reject(err);
            } else {
                resolve(data);
            }
        });
    });
}

awaitはPromiseの解決を待つ記法

await は、async関数の中だけで使えて、Promiseオブジェクトの解決した値を同期的な記述で得る記法です。 あくまでも記法なので、Promiseと本質的に動作の違いはありません。だからawaitは処理をブロックしたりしません。

だから async関数でなくても await できます。そもそも関数でなくてもかまいません。

async 関数はPromiseを返す関数だからawait可能なだけなんですね。 Promiseを明示的に返す関数でも await できるし、Promiseオブジェクトを直接 await したってかまいません。

これらは結構最初に勘違いしやすいところだと思います。

以下のconsole.log はすべてOK!を表示します。

async function foo() { return "OK!"; }
function bar() {
    return new Promise( resolve => { resolve("OK!"); });
}
async function main() {
    let result = await foo();
    console.log(result);
    let p = foo();
    console.log(await p);
    console.log(await bar());
    console.log(await (new Promise( resolve => {
            resolve("OK!");})));
    // => OK!
    // => OK!
    // => OK!
    // => OK!
}

main();

※ awaitはasync関数でしか使えないので、async mainを定義しています。無名関数の即時実行でも構いません。

上のmainをPromiseで書き直すと以下のようになりますね。 長いだけじゃなくて、処理のまとまりがわかりにくいですね。 thenを分割すれば、さらにコードが長くなります。

function main() {
    foo().then( result => {
        console.log( result );
        let p = foo();
        return p;
    }). then( result => { 
        console.log( result );
        return bar();
    }). then( result => { 
        console.log( result );
        return new Promise( resolve => {
            resolve("OK!");});
    }). then( result => { 
        console.log( result );
    });
}

awaitしているPromiseがrejectされたらエラー投入

async関数と対になっている感じですけど、awaitしているPromiseがrejectされたら、エラーがthrowされます。

エラーを捕捉したければ、普通に try~catch で括ればよろしいです。

await は async関数の中だけで使えますから、try~catchがないなら、前述の通り async関数が暗黙的に返すPromiseオブジェクトはrejectされます。

その他いろいろ

複数のPromiseを全部待つ

複数の非同期処理(Promise)すべてが解決するまで待つのは await だけではできなくて、Promise.all を await します。 Promise.all には Promise配列を渡します。そして、全てが解決すれば解決するPromiseを返します。 なので await できるのですね。 この Promise.all が返したPromiseの解決値(=await結果)は、パラメータで与えたPromise配列の各要素の解決値の配列です。

これらをきちんと理解すれば、かなり楽に書けますね。

function delayedSquare(n) {
    return new Promise(
        (resolve, reject) => {
            setTimeout( ()=> {
                resolve(n * n);
            }, 3000);
        });
}

(async () => {
    let result = await Promise.all(
        [0,1,2,3].map( async n => {
            return await delayedSquare(n);
        }));
    console.log(JSON.stringify(result));
})();

順次実行(非同期処理の直列化)

ちなみに、非同期処理を順次実行するには「Array.reduce で出来ます」てなことが検索結果によくでてきますが、「できる=そうすべき」ってことでもないので、その辺のことを以下に書きました。

takamints.hatenablog.jp

await書き忘れ問題(未解決)

awaitを書き忘れることがたまにありますが、これ厄介です。 eslintなどでもエラーにならないんですよね。 実行時にも単に処理を待っているだけの(値をawaitしていない)場合はエラーになりません。

async function foo() {
    await bar();
    await baz();
}

上のように順次処理を書いてるつもりが、下のようにawaitをすっかり忘れてもエラーにならず、単に2つのPromiseが宙に浮いて非同期実行されるだけになってしまう。

async function foo() {
    bar();
    baz();
}

リファクタリングしている場合に何度かハマりました。処理順がごちゃごちゃになってしまうんですよね。 どうにかならんかなと思っていますが、未解決です。

async 関数内にしか await が書けない理由?

(これは独り言)

awaitが、async宣言された関数内でしか使えないというのは、必ずしも設けなくてもよい制限のように思えますが、非同期処理の間違いを防ぐための仕様なのだろうと思っています。

awaitを使っているということは明示されていないPromiseオブジェクトのthenのハンドラーで処理されています。 async関数以外では、この暗黙のPromiseを返さないように記述できてしまいますが、そうするとawaitの結果が宙に浮いてしまうことになります。 暗黙的なPromiseの結果を待つ await を使用するのは、暗黙的にPromiseを返す関数、つまり async 関数の中だけに限定しているのではないかなと。

まとめ

上に書いた以外にも、「async取り除き忘れ問題」とか、「asyncいちいちつけるのメンドクセー問題」とか言いがかりに近いものも含めるといろいろありますが、「async / awaitで非同期処理を簡潔で分かりやすく書けるようになった」のは事実。 しかしPromiseのことが分かっていないとコードを追いかけられないというのもまた事実なんですね(←これが言いたかった)。

AWS Lambda でも、2018年4月から ES2017に対応していて、デフォルトのハンドラーがAsync関数になりました。以下関連記事ですー。

takamints.hatenablog.jp