广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何在TypeScript中为WebTransport Datagram定义不可靠数据报的重传类型?

WebTransport 提供基于 HTTP/3 的数据报接口,允许在客户端与服务器之间交换长度受限的不可靠消息。与基于 QUIC 流的可靠传输不同,Datagram 的发送结果不会携带确认信息,也没有自动重传。如果某个控制包丢失,上层业务要么容忍,要么自行设计重传。在 TypeScript 项目中,如果没有清晰定义重传类型和接口,很容易出现魔法数字与状态混乱。本文会从类型系统层面给出一个可落地的设计。

如何在TypeScript中为WebTransport Datagram定义不可靠数据报的重传类型?

先厘清不可靠数据报的重传边界

WebTransport 规范把 Datagram 定义为一种无确认、无顺序保证的传输方式。浏览器端通常通过 WebTransport.datagrams 拿到一个双工流对象,其中 readable 是 ReadableStream<Uint8Array>,writable 是 WritableStream<Uint8Array>。与可靠流不同,datagram 流没有接收确认,也不会在丢包后自动重发。规范中出现的 outgoingMaxAge 和 incomingMaxAge 只是控制数据报在发送队列或接收缓冲中的最长存活时间,超过该时间的数据报会被丢弃,它们并不实现重传。

因此,重传只能放在应用层。常见做法是给每个数据报分配唯一标识,维护一个未确认条目表,并按照一定的超时策略重复发送。这样做的好处是业务可以按需选择可靠级别:关键信令使用带重传的队列,实时位置更新则直接发送一次即可。为了在 TypeScript 中安全地实现这一层,必须先定义好核心数据结构,避免给 any 留出空间。

interface DatagramDuplexStream {
  readable: ReadableStream<Uint8Array>;
  writable: WritableStream<Uint8Array>;
  maxDatagramSize: number;
  incomingMaxAge: number;
  outgoingMaxAge: number;
}
interface DatagramTransport {
  datagrams: DatagramDuplexStream;
  ready: Promise<void>;
  closed: Promise<WebTransportCloseInfo>;
  close: (info?: WebTransportCloseInfo) => void;
}
interface WebTransportCloseInfo {
  closeCode: number;
  reason: string;
}

定义重传策略与待确认条目类型

重传策略的类型应该独立于业务载荷,这样同一个策略可以复用到不同消息通道。上面的 RetransmissionPolicy 包含最大尝试次数、基础超时、最大超时和退避系数。把超时拆成基础值和最大值,是因为指数退避很容易在弱网下迅速膨胀,导致重传间隔过长;用 maxTimeoutMs 可以限制最坏情况。

待确认条目 DatagramEntry<T> 使用了泛型参数,允许载荷类型从默认的 Uint8Array 切换成自定义序列化结构。状态字段 status 没有随意写成字符串,而是限制了五个可判别状态:待处理、发送中、已确认和已过期。这样在写判断逻辑时,TypeScript 可以根据状态缩小类型范围,减少错误处理分支。

type RetryStatus = 'idle' | 'pending' | 'sent' | 'acked' | 'expired';
interface RetransmissionPolicy {
  maxAttempts: number;
  baseTimeoutMs: number;
  maxTimeoutMs: number;
  backoffMultiplier: number;
}
interface DatagramEntry<T = Uint8Array> {
  readonly id: string;
  readonly payload: T;
  readonly createdAt: number;
  attempts: number;
  nextSendAt: number;
  status: RetryStatus;
}

实现带指数退避的重传队列管理器

有了类型之后,下一步是把散落的定时器和条目查询收敛到一个类中。DatagramRetransmitQueue 接收策略对象、发送回调与确认回调,内部维护两个 Map:一个保存条目,另一个保存定时器句柄。泛型约束 T extends Uint8Array 是为了让发送回调明确拿到已经编码好的字节,而不是在运行时才猜测数据形态。

重传调度器的核心是 scheduleRetry。每个条目在首次发送后进入待确认状态,并根据当前尝试次数计算下一次延迟。代码中使用 Math.pow 实现指数退避,同时用 Math.min 截断到最大超时。定时器触发后先检查状态,已经确认或过期的条目直接返回,避免无效重发。这种判断必须在定时器回调内部进行,因为外部调用的 acknowledge 已经清理了定时器和条目,但 JavaScript 定时器回调仍然可能已经进入任务队列。

需要特别注意的是,enqueue 方法在入队时立即发送一次并增加尝试计数,然后才安排第一次重传。这样做可以保持发送时延最低,同时把重传逻辑集中在调度器中。最大尝试次数达到后,条目会被标记为过期并从表中移除,业务层可以通过回调或状态查询感知失败。

type AckHandler<T> = (id: string, entry: DatagramEntry<T>) => void;
type SendHandler<T> = (payload: T) => void;
class DatagramRetransmitQueue<T extends Uint8Array> {
  private entries = new Map<string, DatagramEntry<T>>();
  private timers = new Map<string, ReturnType<typeof setTimeout>>();
  constructor(
    private readonly policy: RetransmissionPolicy,
    private readonly sendHandler: SendHandler<T>,
    private readonly ackHandler: AckHandler<T>
  ) {}
  enqueue(id: string, payload: T): void {
    const now = Date.now();
    const entry: DatagramEntry<T> = {
      id,
      payload,
      createdAt: now,
      attempts: 0,
      nextSendAt: now,
      status: 'pending'
    };
    this.entries.set(id, entry);
    this.sendHandler(payload);
    entry.attempts += 1;
    entry.status = 'sent';
    entry.nextSendAt = now + this.policy.baseTimeoutMs;
    this.scheduleRetry(id, entry);
  }
  acknowledge(id: string): void {
    const entry = this.entries.get(id);
    if (!entry) return;
    entry.status = 'acked';
    const timer = this.timers.get(id);
    if (timer) clearTimeout(timer);
    this.entries.delete(id);
    this.timers.delete(id);
    this.ackHandler(id, entry);
  }
  private scheduleRetry(id: string, entry: DatagramEntry<T>): void {
    const delay = Math.min(
      this.policy.baseTimeoutMs * Math.pow(this.policy.backoffMultiplier, entry.attempts),
      this.policy.maxTimeoutMs
    );
    const timer = setTimeout(() => {
      if (entry.status === 'acked' || entry.status === 'expired') return;
      if (entry.attempts >= this.policy.maxAttempts) {
        this.expire(id);
        return;
      }
      entry.attempts += 1;
      entry.status = 'sent';
      entry.nextSendAt = Date.now() + delay;
      this.sendHandler(entry.payload);
      this.scheduleRetry(id, entry);
    }, delay);
    this.timers.set(id, timer);
  }
  private expire(id: string): void {
    const entry = this.entries.get(id);
    if (!entry) return;
    entry.status = 'expired';
    this.timers.delete(id);
    this.entries.delete(id);
  }
}

与 WebTransport 事件流集成并处理确认

重传队列本身不负责网络收发,它只通过回调与外部交互。这样设计让 WebTransport 的适配层可以独立变化。在实际项目中,发送方可以把 queue.enqueue 包装在 transport.datagrams.writable.getWriter().write 的后面,接收方则读取 transport.datagrams.readable 中的字节,解析出控制报文。

为了区分数据报文和确认报文,线路上可以使用可判别联合 WireMessage,通过 kind 字段进行判别。isAckMessage 的作用是通过类型守卫把普通 WireMessage 缩小到确认消息类型,从而安全地访问 id。数据报文则交给业务处理函数。这样即使消息格式日后扩展,TypeScript 也会提示所有需要处理的分支。

集成时还要注意背压。数据报写入虽然是不可靠的,但如果发送速度长时间超过网络容量,writable 队列会积压。重传队列的发送回调应优先于业务主动发送,或者在检测到 writable.getWriter().desiredSize 过低时暂停新数据入队。可靠性与实时性之间需要根据场景刻意取舍。

type WireMessage =
  | { kind: 'data'; id: string; payload: Uint8Array }
  | { kind: 'ack'; id: string };
function isAckMessage(msg: WireMessage): msg is Extract<WireMessage, { kind: 'ack' }> {
  return msg.kind === 'ack';
}
async function handleIncomingDatagrams(
  transport: DatagramTransport,
  queue: DatagramRetransmitQueue<Uint8Array>
): Promise<void> {
  const reader = transport.datagrams.readable.getReader();
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      let message: WireMessage;
      try {
        message = JSON.parse(new TextDecoder().decode(value)) as WireMessage;
      } catch {
        continue;
      }
      if (isAckMessage(message)) {
        queue.acknowledge(message.id);
      } else if (message.kind === 'data') {
        console.log('received data', message.id);
      }
    }
  } finally {
    reader.releaseLock();
  }
}

这套类型定义和队列实现并不与 WebTransport 的直接 API 绑定,你可以方便地替换为 WebSocket 的二进制帧或任意传输通道。关键是明确不可靠数据报的重传策略属于应用层协议的一部分,而 TypeScript 负责把这种协议表达得足够精确。

广告位 · 文章底部(后台-模块管理替换此区域)