React frontend 能否提供 callback 并创建 API endpoint?
结论
React frontend 无法像 server 那样创建供 backend 回调的公网 API endpoint。React 代码运行在用户的浏览器中,通常受到 NAT、防火墙和浏览器安全沙箱的限制,backend 不能直接向它发起 HTTP 请求。
这类异步任务可以按处理时间和实时性选择方案:
- 任务能在几秒内完成时,保留普通 HTTP 请求,由 frontend 通过
fetch异步等待结果。 - 任务耗时不确定且更看重可靠性时,由 backend 返回
202 Accepted和jobId,frontend 再根据jobId轮询任务状态。当前的 Kafka 架构适合这种方式。 - 需要及时推送结果时,可以使用 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 ──读取任务状态──▶ 返回结果
处理步骤如下:
- React 提交 query。
- ApiGateway 生成不可预测的
jobId。 - ApiGateway 先保存任务状态
Pending,再向 Kafka 发送消息。 - ApiGateway 立即返回
202 Accepted。 - ML service 处理完成后,将
jobId、状态和结果发送到结果 topic。 - 结果 consumer 根据
jobId更新数据库或共享缓存。 - 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 仍可通过状态接口取回结果。
备注:内容仅供参考。
