JavaScript
Node
Socket.io
promise
AsyncAwait

timeout付き非同期処理をPromise or async/awaitで

timeout付き非同期処理

動機

ここら辺、すぐ忘れるのでメモ。あと、あんまりPromise や async/awaitの実践的な説明少ないような感じするので。

やりたいこと

  1. server起動
  2. localhost:5000にアクセス
  3. input boxにfilePath入れる1
  4. 送信ボタン押す
  5. inputの値をserverにemitする。(request)
  6. file sizeを計算(なんらかの時間かかりそうな非同期処理)
  7. 結果をclientに送る(response)
  8. input boxの下にメッセージを表示させる。以下の挙動を想定:
    • file sizeの計算時間長い時、timeoutの表示
    • file sizeが時間内に(10ms)計算できたら、file sizeを表示2
    • そもそもファイルがない場合は、errorメッセージを表示

もちろん、リロードや他のconnectionに対してもフリーズさせないように(ノンブロッキングで)対応する。

みたいなことをする。

./public
├── index.html
└── javascripts
    └── client.js
./src
└── test.js
./store
├── large.pdf // 大きいファイルを想定
└── mini.txt // 小さいファイルを想定

ac5f84b72ba5418b8e868004548de7f7.gif

今回はどのように実装できるかのみを知りたいので、フレームワーク使わず、http, fs, socket.ioを使用する。

環境

$ node -v
v10.6.0

準備

client側の準備。

public/index.html
<!doctype html>
<html>
    <head><title>Test</title></head>
    <body>
        <p>OK</p>
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js" type="text/javascript"></script>
        <script src="/socket.io/socket.io.js" type="text/javascript"></script>
        <script src="/javascripts/client.js" type="text/javascript"></script>

        <form id="send-form">
            <input id="send-message" />
            <input id="send-button" type="submit" value="Send" />
        </form>
        <p id="notification"></p>
    </body>
</html>
public/javascripts/client.js
const socket = io("http://localhost:" + 5000);

const initialize = () => {
  $("#send-message").focus();
  $("#send-message").val("");
}

const processUserInput = socket => {
  const msg = $("#send-message").val();
  socket.emit("request", { "data": msg });
  $("#notification").text("wait...");
};


$(document).ready(() => {
  initialize();

  $("#send-form").submit(() => {
    processUserInput(socket);
    initialize();
    return false;
  });

  socket.on("response", response => {
    console.log(response);
    let msg;
    if (response.error) {
      msg = "error: " + response.error;
    } else {
      msg = "file: " + response.file + ", " + "message: " + response.data;
    }
    $("#notification").text(msg);
  });
});

雑にhttp server用意:

src/app.js
module.exports = require('http').createServer(handler)
const fs = require('fs');

// http handler
function handler (req, res) {
    console.log(req.url);
    url = req.url[req.url.length-1] === "/" ? req.url + "index.html" : req.url;
    absPath = "public" + url;
    fs.exists(absPath, exists => {
      if(!exists) {
        send404(res);
        return;
      }
      mimetype = req.url.endsWith(".js") ? "text/javascript" : "text/html";
      fs.readFile(absPath, (err, data) => err ? send404(res) : render(res, mimetype, data));
    });
}

function render(res, mimetype, fileContents) {
    res.writeHead(200, {"content-type": mimetype});
    res.end(fileContents);
}

function send404(res) {
    res.writeHead(404, {'Content-Type': 'text/plain'});
    res.end();
}
/src/test.js
const app = require("./app");
const io = require('socket.io')(app);
const fs = require('fs');
app.listen(5000);

// TODO implement below

サーバー起動は、node ./src/test.jsで。本文中のコードは./src/test.jsに書き足していくことを想定している:

最初の勘違い

最初、setTimeout使えば良いんだな、と思って、

/src/test.js
// 想定の挙動と違う
function execOrTimeout(ms, input, resolve, reject) {
    // 一定時間後に処理を返す関数(もし、callbackの処理が一定時間(ms)内に終わらなければ、一定時間後に処理返さない)
    setTimeout(() =>{
        fs.readFile(input, (err, data) => {
            if (err) return reject(err.message);
            resolve(data.byteLength);
        });
    }, ms);
}


io.on('connection', (socket) => {
    console.log("connection start");
    socket.on("request", request => {
        // 10ms or timeout
        execOrTimeout(10, request["data"],
        msg => socket.emit("response", {"data": msg, "file": request["data"]}),
        msg => socket.emit("response", {"error": msg})
    );
    });
});

と書いていた。しかし、今回は、一定時間が経過したら、timeoutである旨をユーザーに通知したい。
setTimeoutはどうやら、一定時間後でも終わらないコールバックに関しては、signalなど通知が飛ぶわけでなく、そのまま完了するまで実行されるようだ。

Note) readFile自体は非同期処理なので、リロードや新規connectionに対して、ブラウザがフリーズするわけではない。また、ボタンの再押下に関してはジョブがstackに積まれて実行される(ただし、一回目に押したときの処理はstopされずに処理が完了してからボタン再押下時の処理が走る感じになる)。

多分、Promise または、async/await使わないと書けなさそう。。3

Promise

https://italonascimento.github.io/applying-a-timeout-to-your-promises/ もさんこうにした4

一見ファイルを読み取るだけの非同期処理に見えるが、実は2つの非同期処理が走っている:

  • readFile非同期処理
  • timeout非同期処理

今回は、どちらかの非同期処理が先に終わったら、その結果を返すようにしたいという枠組みに帰着される。そのための関数として、Promise.race([promise1, promise2])が用意されている:

/src/test.js
const promiseOrTimeout = function(ms, promise){
    const timeout = new Promise((_resolve, reject) => {
      setTimeout(() => reject('Timed out in '+ ms + 'ms.'), ms);
    });

    // Returns a race between our timeout and the passed in promise
    return Promise.race([
      promise,
      timeout
    ]);
};

const promiseExec = filePath => new Promise((resolve, reject) => {
    fs.readFile(filePath, (err, data) => {
        if (err) return reject(err.message);
        resolve(data.byteLength);
    });
});


function execOrTimeout(ms, input, callback) {
    // The statement then(fulfill, reject) is also permitted
    return promiseOrTimeout(ms, promiseExec(input)).then(
        res => {
            console.log("res: " + res);
            return {"data": res, "file": input};
        }
    ).catch(
        err => {
            console.log("err: " + err);
            return {"error": err}
        }
    ).then(callback);
}

io.on('connection', (socket) => {
    console.log("connection start");
    socket.on("request", request => {
        execOrTimeout(10, request["data"], msg => socket.emit("response", msg));
    });
});

これで、gifのような感じで正常に動作した!

async/await

async/await使えば、execOrTimeoutは以下のように書き換えられる:(promiseOrTimeout, promiseExec自体はそのまま)

/src/test.js
async function execOrTimeout(ms, input, callback) {
    let msg;
    try {
        // if reject, jump catch statement.
        const res = await promiseOrTimeout(ms, promiseExec(input));
        msg = {"data": res, "file": input};
    }
    catch (err) {
        msg = {"error": err};
    } finally {
        console.log(msg);
        callback(msg);
    }
}

asyncは、関数の中身(この中身はpromiseのthenやcatchのコールバックの中身を書けば良い)、PromiseにPromiseという箱をつける役割を果たしていて、awaitは、promiseOrTimeout(ms, promiseExec(input))というPromise箱の中身を取り出すことをしているにすぎない。
awaitはrustでいうtry!(もしくは?演算子)に相当する。Haskellでいうと、mainの中身がjavascriptのasyncの中身に相当する。


async/await使っても結局はPromiseの存在を意識する必要がありそうなので、どっちが良いかは個人的には微妙な感じがする。ただし、
https://qiita.com/suin/items/97041d3e0691c12f4974#promise%E3%81%A7%E3%82%82%E3%81%9D%E3%82%93%E3%81%AA%E3%81%AB%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AE%E4%BF%9D%E5%AE%88%E6%80%A7%E6%82%AA%E3%81%8F%E3%81%AA%E3%81%84%E3%81%AE%E3%81%A7%E3%81%AF
のPromise入れ子がasync/awaitで改善されることを見るとasync/awaitの方がいいぞ、というのは多少の理があると思う。


  1. 今回は簡単のため、localfileのみ対応 

  2. 今回は検証用なので、かなりtimeoutを短くした 

  3. 簡潔にかける方法あったら教えていただければありがたいです 

  4. clearTimeout(id);必要なさそうな感じする。