ReadableStream(Webストリーム)でFizz Buzzする
ReadableStreamは細切れのデータを送受信できるAPIです。
今回はこのAPIを使ってFizz Buzzしていきたいと思います。
また、Denoを使うとストリーミングするサーバーを簡単に書くことができます。今回はDenoを使って無限FizzBuzzストリーミングサーバーを書いてみます。
(何を言っているかわからない方は https://dash.deno.com/playground/fizzbuzz にアクセスして実例を見てください。)
実装方法
WebストリームAPIには以下の3種類が存在します。
- ReadableStream - 細切れのデータを読み込み
- TransformStream - 細切れのデータを変換
- WritableStream - 細切れのデータを書き込み
これらは、以下のコードのようにメソッドチェーンで組み合わせて使います。
new ReadableStream()
.pipeThrough(new TransformStream())
// ...(中略)
.pipeThrough(new TransformStream())
.pipeTo(new WritableStream())
このようにメソッドチェーンできる理由は、.pipeThrough()
の返り値がReadableStreamになっているからです。
上のコードが細切れのデータを読み書きする様子を図で表すと、下の画像のようになります。
図に書いた通り、Webストリームはpullストリームであり、ジェネレーターのように遅延評価されます。
WritableStreamの書き込み準備が整うとバケツリレーのように遡ってReadableStreamにデータが要求され、次にReadableStreamがデータを書き込むとまたバケツリレーされてWritableStreamへデータが届きます。
このことを利用すると、無限のデータを生成するReadableStreamを作ることができます。
無限ストリームの作り方
上記のように、「データを要求された時にデータを書き込む」というやり方で実装します。これをするにはReadableStreamの作成時にpull
メソッドを定義します。
new ReadableStream({
pull(controller) { // データが要求された時の処理
// ここにデータを送信する処理を書く
}
})
pull
メソッドにはcontroller
という引数が渡されます。このcontroller
を使ってデータを書き込みます。
new ReadableStream({
pull(controller) { // データが要求された時に
controller.enqueue(data); // データを書き込み
}
})
今回はFizzBuzzを送信したいので、FizzBuzzをcontroller.enqueue
します。
let i = 0;
const readable = new ReadableStream<string>({
pull(controller) {
const val = i % 15 === 0
? "FizzBuzz"
: i % 3 === 0
? "Fizz"
: i % 5 === 0
? "Buzz"
: i;
controller.enqueue(`${val}`);
i++;
},
});
これで「無限にFizzBuzzが流れてくるストリーム」の完成です。
試しにストリームを読んでみましょう。実行にはDeno1.22を使います。
ストリームを読むにはfor-await-of
文を使うことができます。
for await (const data of readable) {
console.log(data);
}
これをdeno run <ファイル名>
コマンドで動かすと、無限にFizzBuzzが表示されます。
for-await-of
文を使ったやり方は、DenoとNode.jsのみで使えます。ブラウザでは絶賛実装中です。
ブラウザの場合は.getReader()
を使います。
const reader = readable.getReader();
while (true) {
console.log(await reader.read());
}
なお無限に表示されるので、気が済んだらctrl-cで止めましょう。
無限Fizz Buzzサーバー
DenoはNode.jsの後継の新しいサーバーサイドJavaScriptランタイムです。
Denoを使うと、サーバーとブラウザの間でReadableStreamをやり取りすることができます。(DenoとブラウザはどちらもWeb標準APIを使えるため)
実装方法
まずは無限にFizz Buzzが流れてくるサーバーを作ります。
http://localhost:8000/fizzbuzz
にアクセスしたときにfizzbuzzをストリーミングするサーバーを書いていきます。
import {
serve,
Status,
STATUS_TEXT,
} from "https://deno.land/std@0.142.0/http/mod.ts";
serve((request) => {
const { pathname } = new URL(request.url);
if (pathname === "/fizzbuzz") { // "/fizzbuzz"にアクセスしたら
// fizzbuzzストリームを作成
let i = 0;
const readable = new ReadableStream<string>({
pull(controller) {
const val = i % 15 === 0
? "FizzBuzz"
: i % 3 === 0
? "Fizz"
: i % 5 === 0
? "Buzz"
: i;
controller.enqueue(`${val}`);
i++;
},
});
// 文字列のストリームをUint8Arrayのストリームに変換
const uint8ArrayStream = readable.pipeThrough(new TextEncoderStream());
// Response作成時に1番目の引数にストリームを渡す
return new Response(uint8ArrayStream);
}
// それ以外の場合は404 Not Foundを表示
return new Response(STATUS_TEXT.get(Status.NotFound), {
status: Status.NotFound,
});
});
基本的にはif (pathname === "/fizzbuzz") { return new Reaponse(fizzbuzzストリーム) }
という処理の流れになります。
一つ注意点として、レスポンスに渡す前に文字列をUint8Arrayに変換するというポイントがあります。
文字列のストリームをUint8Arrayのストリームに変換するには組み込みで用意されている変換ストリームであるTextEncoderStream
を利用します。
TextEncoderStream
はTransformStreamの一種なので、readable.pipeThrough(new TextEncoderStream())
と書くことでパイプすることができます。
この状態でサーバーを起動し、http://localhost:8000/fizzbuzz
にアクセスすると、fizzbuzzのデータが(無限に)どんどん流れてくるのがわかると思います。
deno run --allow-net ./serve.ts
クライアント側でReadableStreamを受け取る
サーバーで送信したReadableStreamを、ブラウザで受け取る処理を書いていきます。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script type="module">
// body はUint8Arrayのストリーム
const { body } = await fetch("/fizzbuzz");
// Uint8Arrayのストリームを文字列のストリームに変換
const fizzbuzzStream = body.pipeThrough(new TextDecoderStream());
// 読み出してconsole.logで表示
const reader = fizzbuzzStream.getReader();
while (true) {
console.log(await reader.read());
}
</script>
</head>
<body></body>
</html>
ブラウザでもReadableStreamの基本的な操作は一緒です。
fetch()
で/fizzbuzz
というURLにリクエストすると、Responseオブジェクトが返ってきます。このResponseオブジェクトには先ほどサーバーで作成したResponseオブジェクトと同じ情報が入っています。
その中のbody
というプロパティを使うと、ReadableStreamでデータを読み出すことができます。(const { body } = await fetch("/fizzbuzz");
の行。)
サーバー側では「文字列のストリームをUint8Arrayのストリームに変換する」という処理を書きましたが、ブラウザ側では「Uint8Arrayを文字列のストリームのストリームに変換する」という処理を書きます。これにはTextDecoderStream
を使います。(TextEncoderStream
の逆ですね。)
これらの処理が書かれたindex.html
をサーバーから配信すれば、FizzBuzzストリームをブラウザで受け取ることができます。
import {
serve,
Status,
STATUS_TEXT,
} from "https://deno.land/std@0.142.0/http/mod.ts";
import { contentType } from "https://deno.land/std@0.142.0/media_types/mod.ts";
import { delay } from "https://deno.land/std@0.142.0/async/mod.ts";
serve(async (request) => {
const { pathname } = new URL(request.url);
if (pathname === "/") { // "/"というパスにアクセスされた場合は
// index.htmlを読み込み
const { body } = await fetch(new URL("./index.html", import.meta.url));
// index.htmlのデータをレスポンスで返す
return new Response(body, {
headers: { "Content-Type": contentType("html")! },
});
}
if (pathname === "/fizzbuzz") {
let i = 0;
const readable = new ReadableStream<string>({
async pull(controller) {
// 送信スピードが速すぎてブラウザコンソールが埋まってしまうので、1秒間待機してから送信する
await delay(1000);
const val = i % 15 === 0
? "FizzBuzz"
: i % 3 === 0
? "Fizz"
: i % 5 === 0
? "Buzz"
: i;
controller.enqueue(`${val}`);
i++;
},
});
return new Response(readable.pipeThrough(new TextEncoderStream()));
}
return new Response(STATUS_TEXT.get(Status.NotFound), {
status: Status.NotFound,
});
});
ここまで実装すると、
FizzBuzzストリーム
↓
TextEncoderStream(文字列→Uint8Array変換)
↓
(サーバーからブラウザへ送信)
↓
TextDecoderStream(Uint8Array→文字列変換)
↓
while文で読み出し
という処理の流れが完成します。
このサーバーを起動した状態で http://localhost:8000/ にアクセスすれば、ブラウザコンソールにfizzbuzzが流れてきます。
無事FizzBuzzがブラウザ側で読めていることが確認できました。
※なお、送信スピードが速すぎてブラウザコンソールが埋まってしまうので、サーバー側からデータを送信する時に、delay関数を使って1秒待機するようにしています。
まとめ
- ReadableStreamは遅延評価なので無限にデータを流すことができる
- これを利用して無限FizzBuzzストリームを作ることができる
- Denoを使うとサーバー側で作成したReadableStreamをブラウザ側に渡すことができる
以上、無限Fizz Buzzストリームを配信するサーバーの作り方でした。
外部ライブラリを一切使わずにここまでできるので、Denoはすごいぞという感じです。
なお本記事で作成したサーバーはdeno deployというサービスを使って公開しています。急にFizzBuzzストリームが必要になったときはhttps://fizzbuzz.deno.dev にアクセスしてお使いください。