Open5

Reader / Writer を理解したい

nukopynukopy

Reader というのは外部システムからプログラムのメモリ上にデータを読み込むという理解であっていますか?

Reader から変数へ読み込み(Reader からバイトスライスにデータを読み込む)

はい、その理解は基本的に正確です。Readerはデータソース(これは外部システム、ファイル、ネットワーク接続、あるいはメモリ内の他の部分でさえもよい)からデータを読み取り、プログラムがそれを操作できる形(通常はメモリ上のデータ構造)に読み込む機能を提供します。

ただし、一般的には「Reader」は一部のプログラミング言語やライブラリにおける具体的なインターフェイスやクラスを指すものであり、その詳細な動作や機能はその言語やライブラリによって定義されます。そのため、具体的な動作や能力は使用しているシステムによります。

たとえば、Go言語のio.ReaderインターフェイスはReadメソッドを定義しています。このメソッドはバイトスライスを引数にとり、データソースからそのスライスにデータを読み込みます。読み込みが成功すると、読み込んだバイト数とエラー(存在する場合)を返します。

一方、Java言語のjava.io.Reader抽象クラスも同様の目的を果たしますが、そのメソッドや動作は異なります。このクラスは主に文字データの読み込みに使用され、バイトではなく文字を操作します。また、異なる具体的なReaderクラスが異なる種類のデータソースからの読み取りをサポートしています。

なぜ Reader はバイト列でデータを読み込むのでしょうか?文字列や数値として読み込まないのですか?

データをバイト列(バイナリデータ)として読み込む主な理由は、その方がより一般的で汎用的だからです。ReaderやWriterのような抽象的なインターフェースをバイナリデータに対して定義することで、あらゆる種類のデータに対する操作を一貫した方法で行うことができます。

バイト列は、あらゆる種類のデータ(テキスト、画像、音声、動画、シリアライズされたオブジェクトなど)を表現するのに使用できます。したがって、ReaderやWriterがバイト列を基本のデータ単位として扱うことで、これらのインターフェースを任意のデータ形式に対して使用できます。

一方、文字列や数値としてデータを直接扱うと、そのデータ形式に対してのみ有効となります。例えば、文字列を直接扱うReaderはテキストデータに対しては機能しますが、画像データや音声データに対しては使えません。これは、テキストデータとそれ以外のデータ形式では、その内部表現が大きく異なるためです。

しかし、バイト列としてデータを読み込んだ後は、必要に応じてそのデータを任意の形式(文字列や数値など)に変換することができます。この変換処理は通常、特定のデコーダ(例えばTextDecoderやDataView)を使用して行います。

したがって、ReaderやWriterがバイト列を扱うことで、あらゆる種類のデータに対する一般的で汎用的な操作を可能にしています。

Reader から読み込むと消費される

import { StringReader } from "https://deno.land/std@0.195.0/io/mod.ts";
import { iterateReader } from "https://deno.land/std@0.195.0/streams/iterate_reader.ts";

// StringReader
const decoder = new TextDecoder();
const r = new StringReader("Hello, Deno!");
const it = iterateReader(r, { bufSize: 1 });
const results = [];
for await (const x of it) {
  results.push(decoder.decode(x));
}
console.log(results);
/* => ["H", "e", "l", "l", "o", ",", " ", "D", "e", "n", "o", "!"] */

// 1 度目の iterateReader で消費されているので一文字も出力されない
const it2 = iterateReader(r, { bufSize: 2 });
const results2 = [];
for await (const x of it2) {
  results2.push(decoder.decode(x));
}
console.log(results2);

多くの Reader の実装では、一度読み取ったデータは「消費」され、再度読み取ることはできません。具体的には、StringReaderの内部ではカーソル(現在の読み取り位置)が管理されており、readメソッドが呼び出されるたびにカーソルが進みます。そのため、すべてのデータを一度に読み取った後に再度readを呼び出しても、カーソルは既に末尾に達しているため、新たに読み取るデータはありません。

つまり、同じReaderを用いて別の読み取りを試みても、前の読み取りですべてのデータが消費されているため、新たに読み取ることができるデータはありません。

nukopynukopy

読み込んだデータの消費について StringReader と iterateReader の実装に潜る

StringReader

string を受け取って array buffer に変換して Buffer のコンストラクタに渡している。

  • Uint8Arrayuint8 の配列
// ref: https://github.com/denoland/deno_std/blob/2fc511fc422b4e1d147c65b736f2794f9d0df969/io/string_reader.ts
export class StringReader extends Buffer {
  constructor(s: string) {
    const encodedStr: Uint8Array = new TextEncoder().encode(s);
    console.log(encodedStr);
    const arrayBuffer: ArrayBufferLike = encodedStr.buffer;
    console.log(arrayBuffer);
    super(arrayBuffer);
  }
}

// StringReader
const decoder = new TextDecoder();
const r = new StringReader("Hello, Deno!");
const it = iterateReader(r, { bufSize: 1 });
const results = [];
for await (const x of it) {
  results.push(decoder.decode(x));
}
console.log(results);
/* => ["H", "e", "l", "l", "o", ",", " ", "D", "e", "n", "o", "!"] */

const it2 = iterateReader(r, { bufSize: 2 });
const results2 = [];
for await (const x of it2) {
  results2.push(decoder.decode(x));
}
console.log(results2);

出力

$ deno run main.ts
Hello, Deno!
Uint8Array(12) [
   72, 101, 108, 108, 111,
   44,  32,  68, 101, 110,
  111,  33
] 12
ArrayBuffer {
  [Uint8Contents]: <48 65 6c 6c 6f 2c 20 44 65 6e 6f 21>,
  byteLength: 12
} 12
[
  "H", "e", "l", "l",
  "o", ",", " ", "D",
  "e", "n", "o", "!"
]
[]

https://github.com/denoland/deno_std/blob/2fc511fc422b4e1d147c65b736f2794f9d0df969/io/string_reader.ts#L1-L39

ArrayBuffer について

ArrayBufferはJavaScriptで使われる型の一つで、一連のバイトデータを表す汎用的な固定長のバイナリデータバッファを示します。

ArrayBufferはそのままでは直接データを操作することはできません。代わりに、それをビューとして見るための「TypedArray」(Int8Array, Uint8Array, Float32Array など)や DataView を使用します。

これらのビューはArrayBuffer上のバイト列を特定の数値型として扱います。例えば、Int8Arrayは8ビット整数としてデータを扱い、Float32Arrayは32ビット浮動小数点数としてデータを扱います。このようにして、JavaScriptでバイナリデータを効率的に扱うことができます。

このようなバイナリデータの操作は、例えばWebSocketやXHR、File API、Canvas、WebGLなど、JavaScriptでバイナリデータを扱う必要がある多くのAPIで使用されます。また、WebAssemblyと連携して高速な数値計算を行う際にも重要となります。

なお、上記のnew TextEncoder().encode(s).bufferで得られるのがArrayBufferです。encodeメソッドによって得られるのはUint8Arrayですが、そのbufferプロパティを参照することで元のArrayBufferにアクセスすることができます。

nukopynukopy

iterateReader

使われ方

const decoder = new TextDecoder();
const r = new StringReader("Hello, Deno!");
const it = iterateReader(r, { bufSize: 1 });
const results = [];
for await (const x of it) {
  results.push(decoder.decode(x));
}

iterateReader の実装

https://github.com/denoland/deno_std/blob/main/streams/iterate_reader.ts#L35-L51

Buffer

  • 全体

https://github.com/denoland/deno_std/blob/main/io/buffer.ts#L30-L249

  • Buffer.constructor
    • ArrayBuffer 型の引数を byte array に変換して保持している
    • 基本的に Reader はバイト列で読み込んだデータを保持することが多い(Deno や Go で実装されている Reader はそう)

https://github.com/denoland/deno_std/blob/main/io/buffer.ts#L30-L36

  • Buffer.read の実装
    • 受け取ったバッファに対して Reader が読み込んだデータをコピーする
    • コピーして消費する実態は Buffer.readSync

https://github.com/denoland/deno_std/blob/main/io/buffer.ts#L119-L129

  • Buffer.readSync の実装
    • L114-L116 で this.#off して Reader.#buf を読み始める場所(offset)を進めている部分が「消費」の正体。一度 Reader.read でソースデータを読み込んでしまうと、2 度目は読み込めない(もちろん Reader.read で読み込んだデータを変数に格納すればそっちでデータにアクセスできるようになる)。
    • nread は実際にコピーできたバイト数なので、Reader.read(buffer) で渡した buffer のサイズに依存する。今回のサンプルコードでは、buffer のサイズを 1 にしてあるので、Reader.#buf からは 1 バイトずつしかコピーされない。

https://github.com/denoland/deno_std/blob/main/io/buffer.ts#L104-L117

処理の流れ

import { StringReader } from "./reader.ts";
// import { iterateReader } from "https://deno.land/std@0.195.0/streams/iterate_reader.ts";
import { iterateReader } from "./iterate_reader.ts";

const decoder = new TextDecoder();

// load data to Reader
const r = new StringReader("Hello, Deno!");

// Read data from Reader
const it = iterateReader(r, { bufSize: 1 });
const results = [];
for await (const x of it) {
  results.push(decoder.decode(x));
}
console.log(results);
/* => ["H", "e", "l", "l", "o", ",", " ", "D", "e", "n", "o", "!"] */

// 1 度目の iterateReader で消費されているので一文字も出力されない
const it2 = iterateReader(r, { bufSize: 2 });
const results2 = [];
for await (const x of it2) {
  results2.push(decoder.decode(x));
}
console.log(results2);
  • iterateReader
export async function* iterateReader(
  r: Reader,
  options?: {
    bufSize?: number;
  }
): AsyncIterableIterator<Uint8Array> {
  const bufSize = options?.bufSize ?? DEFAULT_BUFFER_SIZE;
  const b = new Uint8Array(bufSize);
  while (true) {
    const nread = await r.read(b);
    if (nread === null) {
      break;
    }
    console.log(`b: ${b.slice(0, nread)}`);

    yield b.slice(0, nread);
  }
}
  1. Reader によるデータの読み込み(データはバイト列として保持される)
  2. データソースを読み込んだ Reader を iterateReader に渡しイテレータの初期化
  3. for await で iterateReader から yield されるデータを変数 results へ格納していく。
    • iterateReader の中身:Reader.read(buffer) を実行し、Reader に保持されているデータを固定長の buffer に読み込む。
  4. イテレータで取り出したデータを results に格納
  5. results を出力