nodejsのhttp(s)モジュールでリダイレクトを処理する

nodejshttpモジュールで、リダイレクトをフォローするhttpリクエストを行う。

リダイレクトでリクエストし直してくれるモジュールを追加すれば済む話だが、追加しない場合は自分で必要な分だけ実装する。

http.get()の概要

nodejsといえばサーバーの構築だが、httpリクエストも行うことができる。 よく使われるGETリクエストならhttp.get()でメソッドの指定やリクエストの手順を簡略化することができる。

httpsへのリクエストは、httpモジュールではなくhttpsモジュールを使う。httpのままだと次のようなエラーが表示される。

TypeError [ERR_INVALID_PROTOCOL]: Protocol "https:" not supported. Expected "http:"

なので、https://~~のURLに対してはhttpsモジュールを使おう。 なお、実装ではhttpsを使ったので、以降のサンプルではhttpsが多く出現する。

http.get()やhttps.get()の仕様

httphttpsget()自体はあまり変わりはない。コールバック関数内でレスポンスの読み取りを行う。

https.get('https://blog.ikappio.com', (res) => {
    const { statusCode, headers } = res;
    console.log("code:", statusCode);
    console.log("headers:", headers);

    let data = "";
    res.setEncoding("utf8")
    // データが大きければ、dataイベントは複数回起きるはず
    res.on("data", (chunk) => {
        data += chunk;
    });

    // endイベントでデータの読み込みは終了
    res.on("end", () => {
        console.log("received data:", data);
    });
});

基本的にはこのように行う。コールバックの中でデータの読み取りを行うので、リダイレクトに沿ってリクエストを再度行うのは、少し工夫が必要になると思う。(もっと簡単にできるのかもしれないが)

単純に実装するとコールバックの中でもう一度get()することになり少し見にくいように思う。

http.get自体もhttp.ClientRequestインスタンスを返すが、これは個人的にはデータの読み取りには使いにくい。(というか取得できるのか?)

また、get()は非同期に実行されるので、コールバックが実行されるのは、get()http.ClientRequestを返したしばらく後になっている。

今回の実装は、特定URLにgetリクエストすると200の前に302が0回か1回返ってくることがある場合で行った。

実装の方針

get()のコールバック関数の中でレスポンスのステータスコードをチェックして、その内容で次のリクエストを行うか行わないかを決定する。

ループと非同期処理を使う。Promiseasyncawaitを利用した。まずは、ループの方から。

リダイレクトはループで処理

2回目以降のリクエストは1回目と同じコードで行う。このため、無限ループ内でget()を行うことにする。一定回数失敗したら終了。ステータスコード302のリダイレクトでももう1回実行する。

ループに関するサンプルコードはこんな感じ。

// 一応関数の形にする。
const sampleLoop = () => {
    let retryCount = 1;  // リトライを数える
    const MAX_RETRY = 5;  // リトライ回数の上限

    let response = null;  // リクエストの結果の格納用変数
    let url = "<URL to request>"  // リダイレクトで書き換える可能性があるのでletで定義
    while(true) {
        if(retryCount > MAX_RETRY) {
            // 5回やってダメなら諦める
            // ..本当はちょっと処理があるけど省略
            break;
        }
        // 以降はhttps.get()を使ってリクエストを行うが、これは後述する。
        // ...

        // カウントの更新
        retryCount++;
        continue;
    }
    // responseを使って処理を行う。
    // ...
    return response;
}

同期的処理に変換

https.get()の処理は非同期なので、そのまま書くとコードの実行が進み、関数を抜けてしまう。 なので、レスポンスが来るまで待機するようにする必要がある。

そして、https.get()の返り値はPromiseではないので、https.get()Promiseでラップすることにした。(これは正しいのだろうか)

ラップして、これを実行する関数をasyncにすれば同期的に実行できる。

そんなわけで、https.getの同期化したサンプルコードは次のようになる。

// awaitを使うので、asyncを関数に指定する
const syncGet = async (url) => {
    const reqPromise = new Promise((resolve, reject) => {
        https.get(url, (res) => {
            // 色々処理。
            // ...

            res.on("end", () => {
                // ここでresolveしておけばいい
                resolve(someResult)
            });
        });
    });
    // awaitでresolveかrejectを待つ。
    const result = await reqPromise.catch((reason) => {
        // rejectされてたらここで返り値を設定できる。(上でチェーンしておいてもOK)
        return "失敗"
    });
    return result;
}

この関数を使えば、同期的にhttpリクエストの結果を取得できる:

const result = syncGet("some url");
// リクエストが完了して結果を取得した後に下の行は実行される。
doSomething(result);

これらの2つを組み合わせればリダイレクトは処理できる。

また、実際のsyncGet相当の関数のresolverejectを行うときは一定のルールが必要になる。typescriptなどなら型を定義すればいいが、今回はオブジェクトを返すようにした。 このオブジェクトにstatusCodeとステータスコードごとの追加プロパティを持たせる。

実装

あとはステータスコードをみて分岐したりを加えればいい。

サンプルコードは次のようになる。

const retrieveResourse = async () => {
    let retryCount = 1;
    const MAX_RETRY = 5;

    let response = null;
    let url = "<URL to request>"
    while(true) {
        if(retryCount > MAX_RETRY) {
            // 5回やってダメなら諦める
            response = { success: false, reason: "Retry Count Exceeded"};
            break;
        }

        // カウントの更新
        retryCount++;

        const promise = new Promise((resolve, reject) => {
            https.get(url, (res) => {
                const { statusCode } = res;
                if(statusCode === 302) {
                    // リダイレクトするのでもう一度ループする。
                    resolve({
                        statusCode,
                        url: res.headers.location,  // リダイレクト先のURL
                    });
                    return;
                }
                if(statusCode === 200) {
                    // データを受け取り終了
                    let data = "";
                    res.setEncoding("utf8");
                    res.on("data", (chunk) => {
                        data += chunk;
                    });
                    res.on("end", () => {
                        resolve({
                            statusCode,
                            data,
                        });
                        return;
                    });
                    return;
                }
                // 他のステータスコードは想定外
                resolve({
                    statusCode,
                });
            });
        });

        // 上のpromiseの終了を待つ。
        const result = await promise.catch((reason) => {
            // レスポンス以外のエラー(get自体に失敗など)
            return { statusCode: 0, reason }
        });

        if(result.statusCode === 302) {
            // もう1回ループする必要あり
            url = result.url;
            continue;
        }
        if(result.statusCode === 200) {
            // 取得できた
            response = {
                success: true,
                data: result.data,
            };
            break;
        }
        // エラーハンドリングする
        console.log("不明なエラー?");
        // 一応リトライ
        continue;
        // もしくはresponseを生成して終了させる
        // response = {
        //     success: false,
        //     reason: result.reason,
        // };
        // break;
    }
    return response;
}


// あとはどこからか呼び出せばnullか取得できたデータが返ってくる
const res = retrieveResource();
if (res !== null && res.success) {
    doSomething(res.data)
} else {
    console.log("取得失敗:", res.reason)
}

少しネストが深いような気もするが、これでリダイレクトはフォローできる。

おわりに

あまり調べていないが、Pythonrequestsでのallow_redirectsオプションのようにリダイレクトでリクエストし直してくれるモジュールはあると思うので、そちらを使ったほうが安心だとは思う。

イベント中でresolveしてもいいものなのかよくわからないが、無事に動いているのでよしとする。

外部のモジュールをやたらと入れたくないのであれば上のような実装も仕方ないかなと思う。

長くなってしまった。以上。

タイトルとURLをコピーしました