PAYATHON 2026

React frontend 能否提供 callback 并创建 API endpoint?

技术老齐

结论

React frontend 无法像 server 那样创建供 backend 回调的公网 API endpoint。React 代码运行在用户的浏览器中,通常受到 NAT、防火墙和浏览器安全沙箱的限制,backend 不能直接向它发起 HTTP 请求。

这类异步任务可以按处理时间和实时性选择方案:

  1. 任务能在几秒内完成时,保留普通 HTTP 请求,由 frontend 通过 fetch 异步等待结果。
  2. 任务耗时不确定且更看重可靠性时,由 backend 返回 202 Accepted 和 jobId,frontend 再根据 jobId 轮询任务状态。当前的 Kafka 架构适合这种方式。
  3. 需要及时推送结果时,可以使用 WebSocket、SignalR 或 SSE。这些连接不会独占整个 ApiGateway,但会持续消耗一定的连接和内存资源。

项目刚开始搭建时,可以先实现提交任务和查询状态。业务流程稳定后,再根据实时性要求增加 SignalR 或 SSE。

等待 HTTP 响应不会阻塞 React 页面

下面的调用会等待响应,但浏览器 UI 不会因此冻结:

async function submitQuery(query: string) {
  const response = await fetch("/api/queries", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query }),
  });

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  return response.json();
}

await 暂停的只是当前异步函数。浏览器仍可渲染页面、处理点击事件和发起其他网络请求。

ASP.NET Core 同样可以异步处理多个请求。一个请求等待 I/O 时,其他 client 仍然可以连接。需要避免的是同步阻塞线程,例如:

// 不推荐
var result = SomeAsyncMethod().Result;
Thread.Sleep(5000);

应改用异步 API:

var result = await SomeAsyncMethod(cancellationToken);

如果 ML 通常只需几秒,而且 ApiGateway、反向代理和负载均衡器的超时配置允许,维持普通 HTTP 请求通常就够了。引入 Kafka 后,任务生命周期和结果关联会变得更复杂,当前架构更适合采用明确的异步任务模型。

推荐的异步任务流程

流程可以这样设计:

React
  │ POST /api/queries
  ▼
ApiGateway ──发送包含 jobId 的消息──▶ Kafka ──▶ ML worker
  │
  └──立即返回 202 Accepted、jobId 和 statusUrl

ML worker ──处理完成──▶ Kafka ──▶ Result consumer
                                      │
                                      ▼
                                保存任务结果

React ──GET statusUrl──▶ ApiGateway ──读取任务状态──▶ 返回结果

处理步骤如下:

  1. React 提交 query。
  2. ApiGateway 生成不可预测的 jobId。
  3. ApiGateway 先保存任务状态 Pending,再向 Kafka 发送消息。
  4. ApiGateway 立即返回 202 Accepted。
  5. ML service 处理完成后,将 jobId、状态和结果发送到结果 topic。
  6. 结果 consumer 根据 jobId 更新数据库或共享缓存。
  7. React 轮询状态接口,直到任务完成或失败。

提交接口可以返回:

HTTP/1.1 202 Accepted
Location: /api/queries/7f0e5d22-6a5d-48dd-a22f-583916cef877
Content-Type: application/json
{
  "jobId": "7f0e5d22-6a5d-48dd-a22f-583916cef877",
  "status": "pending",
  "statusUrl": "/api/queries/7f0e5d22-6a5d-48dd-a22f-583916cef877"
}

statusUrl 是 backend 提供的查询地址,并非 React 创建的回调地址。

ASP.NET Minimal API 示例

以下是简化后的接口,省略了 Kafka 和数据库的具体实现:

app.MapPost("/api/queries", async (
    CreateQueryRequest request,
    IQueryRepository repository,
    IQueryPublisher publisher,
    CancellationToken cancellationToken) =>
{
    var jobId = Guid.NewGuid();

    var job = new QueryJob
    {
        Id = jobId,
        Status = "pending",
        Query = request.Query
    };

    await repository.CreateAsync(job, cancellationToken);

    await publisher.PublishAsync(new QueryRequested
    {
        JobId = jobId,
        Query = request.Query
    }, cancellationToken);

    var statusUrl = $"/api/queries/{jobId}";

    return Results.Accepted(statusUrl, new
    {
        jobId,
        status = "pending",
        statusUrl
    });
});

状态查询接口:

app.MapGet("/api/queries/{jobId:guid}", async (
    Guid jobId,
    IQueryRepository repository,
    CancellationToken cancellationToken) =>
{
    var job = await repository.FindAsync(jobId, cancellationToken);

    if (job is null)
    {
        return Results.NotFound();
    }

    return job.Status switch
    {
        "pending" => Results.Ok(new
        {
            jobId,
            status = "pending"
        }),

        "completed" => Results.Ok(new
        {
            jobId,
            status = "completed",
            result = job.Result
        }),

        "failed" => Results.Ok(new
        {
            jobId,
            status = "failed",
            error = job.Error
        }),

        _ => Results.StatusCode(StatusCodes.Status500InternalServerError)
    };
});

消费 ML 结果时,通过消息中的 jobId 更新对应任务:

public async Task HandleAsync(
    QueryCompleted message,
    CancellationToken cancellationToken)
{
    await repository.CompleteAsync(
        message.JobId,
        message.Result,
        cancellationToken);
}

jobId 是贯穿整个流程的 correlation ID,Kafka 的请求消息和结果消息都必须携带它。

React 轮询示例

type QueryState =
  | { status: "pending" }
  | { status: "completed"; result: string }
  | { status: "failed"; error: string };

const delay = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function waitForResult(
  statusUrl: string,
  signal: AbortSignal
): Promise<string> {
  while (!signal.aborted) {
    const response = await fetch(statusUrl, { signal });

    if (!response.ok) {
      throw new Error(`Status request failed: ${response.status}`);
    }

    const data: QueryState = await response.json();

    if (data.status === "completed") {
      return data.result;
    }

    if (data.status === "failed") {
      throw new Error(data.error);
    }

    await delay(1500);
  }

  throw new DOMException("Request aborted", "AbortError");
}

组件卸载时,可以用 AbortController 停止轮询:

useEffect(() => {
  const controller = new AbortController();

  waitForResult(statusUrl, controller.signal)
    .then(setResult)
    .catch((error) => {
      if (error.name !== "AbortError") {
        setError(error.message);
      }
    });

  return () => controller.abort();
}, [statusUrl]);

实际项目还要设置总超时时间,并在发生网络错误时进行有限次数的退避重试,以免无限轮询。

什么时候使用 WebSocket、SignalR 或 SSE

如果浏览器需要在任务完成后立即收到通知,提交任务时仍可返回 jobId,完成事件则通过推送通道发送。

在 ASP.NET Core 项目中,SignalR 可以管理 WebSocket 连接,并在 WebSocket 不可用时改用其他传输方式。推送消息中仍需包含 jobId:

await hubContext.Clients
    .User(userId)
    .SendAsync("QueryCompleted", new
    {
        jobId = message.JobId,
        result = message.Result
    }, cancellationToken);

React 收到消息后,根据 jobId 更新对应任务:

connection.on("QueryCompleted", (message) => {
  if (message.jobId === currentJobId) {
    setResult(message.result);
  }
});

单个 WebSocket 连接不会妨碍其他 client 连接。现代异步 server 可以维持许多并发连接,不过每条连接都会消耗文件描述符、内存和网关资源。实际容量取决于部署环境、代理配置、心跳设置和消息量,不能只根据所用框架判断。

如果 server 只需单向推送,也可以使用 SSE。它比完整的双向 WebSocket 简单,但仍需处理断线重连和多实例路由。

多实例部署时不能只保存内存状态

ApiGateway 有多个实例时,下面这种状态保存方式会出问题:

var jobs = new ConcurrentDictionary<Guid, QueryJob>();

提交请求可能到达实例 A,状态查询可能到达实例 B,Kafka 结果则可能由实例 C 消费,而这些实例的内存并不共享。

任务状态和结果需要保存到所有实例都能访问的存储中,例如关系数据库或共享缓存。使用 SignalR 时,多实例之间还要共享消息分发机制,也可以交给专门的实时通信服务管理连接。

还要处理这些情况:

  • Kafka 消息可能重复投递,因此结果写入要具备幂等性。
  • 任务可能失败或超时,需要设置 failed、timed_out 等明确状态。
  • 结果要设置保留期限,以免数据无限增长。
  • 先保存任务再发布消息时,要考虑发布失败后的重试。可靠性要求较高时,可以采用 outbox pattern。

不要信任 frontend 提供的 callback URL

frontend 提供的任意 callback URL 无法解决浏览器监听问题,还可能带来 SSRF 风险。攻击者可以借此要求 server 请求内部地址或其他受保护的网络资源。

也不建议把 userId 和连续的 queryId 直接作为访问凭证:

/query/{userId}/{queryId}

可以采取以下措施:

  • 由 server 生成随机 jobId。
  • 查询任务时,验证当前登录用户是否拥有该任务。
  • URL 中不要包含敏感信息。
  • 为提交接口和状态接口设置认证、授权和限流。
  • 不能把知道 jobId 作为访问结果的唯一权限依据。

当前架构可以继续使用 Kafka。ApiGateway 提交任务后立即返回 202 Accepted,由独立 consumer 保存结果,React 使用 jobId 查询状态。需要更强的实时性时,再在任务查询机制上增加 SignalR、WebSocket 或 SSE。即使推送消息丢失,client 仍可通过状态接口取回结果。

备注:内容仅供参考。