ほんじゃーねっと

おっさんがやせたがったり食べたがったりする日常エッセイ

Node.jsで非同期処理を不特定回数繰り返す方法をYouTubeのお気に入りタイトル一覧を取得するスクリプトを作りながら考える

f:id:piro_suke:20161212004436j:plain

YouTubeでお気に入りに入れた動画のタイトルを一覧化して見たくなったので、

YouTube Data API経由でデータを取得して表示するNode.jsスクリプトを作ってみた。

APIへのアクセス自体はサクッとできたのだけど、

複数ページデータを非同期で取得するフローのコントロールで苦労した。

Node.jsスクリプトを書く時は毎回このフローコントロールでつまづくのだけど、

慣れの問題なのだろうか。

今回はcaolan/asyncライブラリのおかげで何とか解決できた感じ。

async - Documentation

作成したスクリプトの内容を紹介しつつ、

非同期処理を不特定回数実行する際のパターンについて考えてみよう。

YouTubeのAPIにアクセスする準備

今回のスクリプトはYouTubeのAPIを利用するため、

Googleのデベロッパーサイトでアプリケーション登録が必要となる。

YouTube Data API の概要  |  YouTube Data API (v3)  |  Google Developers

今回はローカルのスクリプトでアクセスするため、

OAuth認証ではなく、APIキーを取得してアクセスする形にした。

お気に入りは再生リストに含まれるので、PlaylistItemsを取得するAPIを使用する。

PlaylistItems: list  |  YouTube Data API (v3)  |  Google Developers

まずはAPIにアクセスするスクリプトを書いてみる

必要な処理内容は「お気に入りのリストを取得する」だけなのだけど、

上記のPlaylistItemsの仕様を確認したところ、

1リクエストで最大50件までしか取得できないため、

お気に入りの件数が多い場合はページ数分のリクエストが必要となる。

ページの取得は、前のページのレスポンスに含まれるnextPageTokenの値を

pageTokenパラメータとして指定してリクエストを送れば良いようだ。

APIへのリクエストにはNode.js標準ライブラリのhttpsライブラリを使う。

リクエストパラメータマップ、pageToken文字列、および非同期で返ってきた

レスポンスを処理するためのコールバック関数を引数として受け取って

APIリクエストを行うfetchFavList関数を作成した:

const https = require('https');
const util = require('util');
const querystring = require('querystring');

const fetchFavList = (queryString, nextPageToken, callback) => {
    const apiFavListUrl = 'https://www.googleapis.com/youtube/v3/playlistItems';
    let baseQueryString = { 
        'part': 'snippet,contentDetails',
        'maxResults': 50 // 0-50
    };  
    if (nextPageToken !== null) {
        baseQueryString.pageToken = nextPageToken;
    }   
    const qs = querystring.stringify(Object.assign(baseQueryString, queryString));

    const requestUrl = util.format('%s?%s', apiFavListUrl, qs);
    const req = https.get(requestUrl, (res) => {

        let rawData = ''; 
        res.on('data', (chunk) => {
            rawData += chunk;
        }); 
        res.on('end', () => {
            const resData = JSON.parse(rawData);
            callback(resData);
        }); 
    }); 
};

下記のようなコードでfetchFavList関数を呼び出すと、

お気に入りの最初の50件が取得できる:

const query = {
    'key': '<アプリケーション登録で取得したAPIキー>',
    'playlistId': '<自分のお気に入りのプレイリストID>'
};

fetchFavList(query, null, (res) => {
    console.log(res);
});

ページング処理を追加する

こちらが今回の本題。

pageTokenを使ってページ数分のデータを連続で取得するページング処理を追加する。

非同期で返ってきたレスポンスにnextPageTokenが含まれていたら

それをfetchFavListのパラメータに再設定してリクエストを行う、

という処理をnextPageTokenが含まれなくなるまで繰り返す必要がある。

これだけなら再帰的にfetchFavListを呼び出すだけで実現できるのだけど、

間にデータベース登録処理等、他の処理を挟みたくなった時に使える構成に

したかったので、ループで処理できる方法を探すことにした。

色々調べてみた結果、フロー制御ライブラリとして有名なcaolan/asyncの

forever関数が不特定回数の繰り返しに使えそうなので試してみる。

async - Documentation

caolan/asyncをnpm installする:

npm install async --save-dev

下記の設定がpackage.jsonに追加される:

  "devDependencies": {
    "async": "^2.1.4"
  }

forever関数は名前からして、条件が満たされている限り

無限にループを続けるwhileループ的なもののようだ。

下記のような形で利用する:

const async = require('async');

async.forever(
    (callback) => { /* 処理1 */
        // 繰り返したい処理
        // 引数nullのcallbackを呼び出すと、再度「処理1」が呼ばれる
        callback(null);
        // callbackにnull以外の値を渡すと「処理2」に移り、ループが終了する
        callback('error');
    },
    (err) => { /* 処理2 */
        // ループ終了時に実行したい処理
    }
);

forever関数を使ってnextPageTokenが存在する限り

fetchFavListを繰り返すようにしたのがこちら:

const async = require('async');

let nextPageToken = null;
async.forever(
    (callback) => {
        
        fetchFavList(query, nextPageToken, (res) => {
            if (res.items.length === 0) {
                callback('success');
            }

            const items = res.items.map(item => {
                return {
                    'videoId': item.snippet.resourceId.videoId,
                    'title': item.snippet.title
                };
            });

            console.log(items);

            if (res.hasOwnProperty('nextPageToken')) {
                nextPageToken = res.nextPageToken;
                callback(null);
            } else {
                callback('success');
            }
        });
    },
    (res) => {
        if (res !== 'success') {
            console.error('Error!');
            console.error(res);
        }
    }
);

うまく全ページ分のvideoIdとタイトルを取得できた。

応用方法

お気に入りタイトル一覧の取得についてはここまでで満足なのだけど、

ページを順に処理していくようなコードを書く場合は

今回のようにasyncのforever関数を使う方法でパターン化できそうだ。

例えばAPI経由で取得したデータを一時保存したりしたくなった場合は、

コールバック前に保存処理を挟むことで実現できる。

DB処理にknexライブラリを使うサンプルを書くとしたら、こんな感じだろうか:

const dbSetting = {
    client: 'pg',
    connection: {
        // DB接続設定
    }
};

const knex = require('knex')(dbSetting);

let nextPageToken = null;
async.forever(
    (callback) => {
        fetchFavList(query, nextPageToken, (res) => {
            if (res.items.length === 0) {
                callback('success');
            }

            const items = res.items.map(item => {
                return {
                    'videoId': item.snippet.resourceId.videoId,
                    'title': item.snippet.title
                };
            });

            const columns = items.map(item => {
                return knex.raw('sp_upsert_youtube_favs(?, ?)', [item.videoId, item.title]);
            });

            knex.column(columns).select()
            .then(queryRes => {
                if (res.hasOwnProperty('nextPageToken')) {
                    nextPageToken = res.nextPageToken;
                    callback(null);
                } else {
                    callback('success');
                }
            })
            .catch(error => {
                callback(error);
            });
        });
    },
    (res) => {
        if (res !== 'success') {
            console.error('Error!');
            console.error(res);
        }

        knex.destroy();
    }
);

Knex.js - A SQL Query Builder for Javascript

callbackの呼び方で繰り返しを制御できるので、

間にDB更新処理のような別の非同期処理が入っても制御しやすい。

DB切断処理のような最後に実行したいがある処理も、

forever関数の第二引数で繰り返し後処理で指定できる。

おわり

forever関数は良い発見だった。

caolan/asyncにはwaterfallやeachSeriesの他、

いろんな関数が定義されているので、一通りチェックしておけば

非同期処理のフロー制御を大分カバーできそうだ。

実践Node.js プログラミング (Programmer's SELECTION)

実践Node.js プログラミング (Programmer's SELECTION)