Deno响应流数据

109 阅读2分钟

现有这样一个需求,要提供一个接口,响应流式数据,其实就是日志信息,让客户端(调用方)能够实时展示服务器的日志。

如果是用Node.js的Express框架,可以这样轻松搞定:

const express = require("express");
const app = express();
app.get("/", (req, res) => {
  res.write("hello\n");
  setTimeout(() => {
    res.write("world");
    res.end();
  }, 5000);
});

app.listen(3000);

但在Deno中,我们使用的oak框架的官方文档中找不到响应流数据的例子,但看代码(deno.land/x/oak@v11.1…)是支持的。

export async function convertBodyToBodyInit(
  body: ResponseBody | ResponseBodyFunction,
  type?: string,
  jsonBodyReplacer?: (key: string, value: unknown) => unknown,
): Promise<[globalThis.BodyInit | undefined, string | undefined]> {
  let result: globalThis.BodyInit | undefined;
  if (BODY_TYPES.includes(typeof body)) {
    result = String(body);
    type = type ?? (isHtml(result) ? "html" : "text/plain");
  } else if (isReader(body)) {
    result = readableStreamFromReader(body);
  } else if (
    ArrayBuffer.isView(body) || body instanceof ArrayBuffer ||
    body instanceof Blob || body instanceof URLSearchParams
  ) {
    // deno-lint-ignore no-explicit-any
    result = body as any;
  } else if (body instanceof ReadableStream) {
    result = body.pipeThrough(new Uint8ArrayTransformStream());
  } else if (body instanceof FormData) {
    result = body;
    type = "multipart/form-data";
  } else if (isAsyncIterable(body)) {
    result = readableStreamFromAsyncIterable(body);
  } else if (body && typeof body === "object") {
    result = JSON.stringify(body, jsonBodyReplacer);
    type = type ?? "json";
  } else if (typeof body === "function") {
    const result = body.call(null);
    return convertBodyToBodyInit(await result, type, jsonBodyReplacer);
  } else if (body) {
    throw new TypeError("Response body was set but could not be converted.");
  }
  return [result, type];
}

如果body传递了ReadableStream,则支持流式响应。

怎么构建ReadableStream呢?在Deno官方样例中有这样一个例子

import { serve } from "https://deno.land/std@0.114.0/http/server.ts";

function handler(_req: Request): Response {
  let timer: number | undefined = undefined;
  const body = new ReadableStream({
    start(controller) {
      timer = setInterval(() => {
        const message = `It is ${new Date().toISOString()}\n`;
        controller.enqueue(new TextEncoder().encode(message));
      }, 1000);
    },
    cancel() {
      if (timer !== undefined) {
        clearInterval(timer);
      }
    },
  });
  return new Response(body, {
    headers: {
      "content-type": "text/plain",
      "x-content-type-options": "nosniff",
    },
  });
}
console.log("Listening on http://localhost:8000");
serve(handler);

看得出来,ReadableStream的start方法内置了controller参数,它的enqueue可以控制写入。再细看它的TypeScript定义:

/** @category Streams API */
interface ReadableStreamDefaultController<R = any> {
  readonly desiredSize: number | null;
  close(): void;
  enqueue(chunk: R): void;
  error(error?: any): void;
}

显而易见,close就是关闭或停止流的方法。

为方便起见,我们这样封装一段函数:

export interface ReadableStreamResult {
  body: ReadableStream;
  write(message: string): void;
  end(message?: string): void;
}

export function getReadableStream(): ReadableStreamResult {
  let controller: ReadableStreamDefaultController;
  const body = new ReadableStream({
    start(_controller) {
      controller = _controller;
    },
  });
  const te = new TextEncoder();
  return {
    body,
    write(message: string) {
      controller.enqueue(te.encode(message));
    },
    end(message?: string) {
      if (message) {
        controller.enqueue(te.encode(message));
      }
      controller.close();
    },
  };
}

使用:

const rs = getReadableStream();
response.body = rs.body;
rs.write("haha");
rs.end(globals.end_msg);

以下是个完整的样例:

import { Application } from "https://deno.land/x/oak@v11.1.0/mod.ts";

const app = new Application();

app.use((ctx) => {
  const res = getReadableStream();
  let { body, write, end } = res;
  let timer: number | undefined = undefined;
  let num = 0;
  timer = setInterval(() => {
    if (num === 5) {
      clearInterval(timer);
      return end();
    }
    num++;
    const message = `It is ${new Date().toISOString()}\n`;
    console.log(message);
    write(message);
  }, 1000);
  ctx.response.body = body;
});

app.listen({ port: 3000 });

在控制台输入:curl http://localhost:3000,效果如下: image.png 这样就达到了流式效果,即客户端在一开始就能接收到响应,一直到服务器主动调用end结束。 但在具体业务中,发现并不是这么简单,因为我们的业务通常是异步的,把代码这样改下:

import { delay } from "https://deno.land/std@0.114.0/async/delay.ts";

app.use(async (ctx) => {
  const res = getReadableStream();
  const { body, write } = res;
  ctx.response.body = body;
  write("hello\n");
  await test(res);
});

async function test(res: ReadableStreamResult) {
  await delay(5000);
  res.write("world\n");
  res.end();
}

以上代码我们预期的是先响应hello,再过5秒响应world结束,但事实并非如此,而是过了5秒,hello world一起响应。如果你是用浏览器打开的http://localhost:3000,在网络里可以看到: image.png

这是oak框架的中间件处理逻辑,因为test方法调用前加了await,这让框架以为你是要等待它结束后这个接口才算完成。

所以,要想达到预期的效果,只需要将这个await去掉即可。但这要求你的test方法必须做好容错处理,也就是说需要保障它不能失败,或者失败后处理好相应逻辑,主要是调用res.end()来结束流。