一次从同步到异步的华丽转身 ✨
📋 目录
- 背景问题
- 解决方案
- 技术实现
- 踩坑记录
- 经验总结
🎬 背景问题
问题场景 😱
长耗时接口(30-60秒)遇到网关超时(30秒)的问题:
- 接口调用需要30-60秒才能返回
- 网关设置了30秒超时重试
- 结果:接口超时 → 网关重试 → 重复调用 → 用户体验差 😭
解决方案 💡
异步处理 + Redis缓存= 完美解决!
- 用户发起请求 → 立即返回"处理中" → 后台异步处理
- 处理完成后 → Redis存储结果 → 发送通知
- 用户点击通知链接 → 自动打开页面 → 显示处理结果
🎯 核心改动
后端改动
1️⃣ 新增 Redis Key
// constant/redis.jsOP_TASK_AI:'task_ai'// 用于存储异步任务状态2️⃣ 新增查询路由
// router/xxx.jsrouter.get('/api/task/result_by_key',controller.task.getTaskResult,)3️⃣ Controller层:两个接口的职责分离
| 接口 | 用途 | 参数 | 返回 |
|---|---|---|---|
create_task | 🚀创建任务 | input,type,related_id | 处理结果 or{status: 'processing'} |
get_task_result | 🔍查询结果 | taskKey | Redis中的完整数据 ornull |
关键代码:
// 创建任务接口(主动)asynccreateTask(ctx){const{input,type,related_id}=ctx.queryconstresult=awaitthis.ctx.service.task.createTask(input,type,related_id)// ...}// 查询结果接口(被动)asyncgetTaskResult(ctx){const{taskKey}=ctx.queryconstresult=awaitthis.ctx.service.task.getTaskResult(taskKey)// ...}4️⃣ Service层:核心方法
createTask- 创建异步任务
asynccreateTask(input,type,related_id){// 1. 检查Redis是否有结果// 2. 如果有 → 直接返回// 3. 如果没有 → 创建任务,异步处理// 4. 返回 { status: 'processing' }}processTaskAsync- 异步处理任务
asyncprocessTaskAsync(input,type,redisKey,related_id){// 1. 调用长耗时接口// 2. 处理成功 → 更新Redis状态为 'completed'// 3. 发送通知(包含跳转链接)}getTaskResult- 查询任务结果
asyncgetTaskResult(taskKey){// 直接从Redis查询,不创建任务constredisKey=`${REDIS_KEYS.OP_TASK_AI}:${taskKey}`constredisValue=awaitthis.ctx.getRedisValue(redisKey)returnredisValue?JSON.parse(redisValue):null}Redis数据结构:
// processing 状态{status:'processing',type:'task_type',input:'input_data',related_id:123,createdAt:1234567890}// completed 状态{status:'completed',result:'处理结果',type:'task_type',input:'input_data',related_id:123,completedAt:1234567890}前端改动
1️⃣ Service层:新增查询接口
// service/xxx/index.tsasyncfunctiongetTaskResult(params:{taskKey:string}){const{data}=awaithttpGet('/api/task/result_by_key',params)returndata}2️⃣ 列表页:URL参数处理
// 解析URL参数const{relatedId}=querystring.parse(window.location.search)// 通过URL打开详情constopenDetailByUrl=async()=>{if(relatedId){constdetail=data.list.find((item)=>`${item.id}`===relatedId)if(detail){dispatchUpdate(detail)}}}// 监听数据加载完成,自动打开useEffect(()=>{if(data.list.length>0){openDetailByUrl()}},[relatedId,data])关闭时清除URL参数:
consthandleClose=()=>{dispatchClose()onReset()// 重置搜索参数,URL会自动更新}3️⃣ 详情页:从URL查询结果
// 解析URL参数const{taskKey}=querystring.parse(window.location.search)as{taskKey:string}// 查询Redis并显示结果constloadTaskResult=async()=>{if(taskKey){consttaskResult=awaitgetTaskResult({taskKey})if(taskResult){const{status,result,type}=taskResult// 设置类型if(type){setTaskType(type)}// 根据状态显示结果if(status==='completed'&&result){setShowResult(true)setResultValue(result)}elseif(status==='processing'){message.info('处理中,请稍候...')}elseif(status==='failed'){message.warning('处理失败,请重试')}}}}// 页面打开时自动查询useEffect(()=>{if(taskKey&&visible){loadTaskResult()}},[taskKey,visible])重要:Form和State的区别
// ❌ 错误:组件不在Form.Item中,不能用form管理form.setFieldsValue({field:value})// 没用!// ✅ 正确:直接用state管理const[field,setField]=useState<string>()<Select value={field}// 绑定stateonChange={setField}// 更新state>🛠️ 技术实现
Redis Key设计
规则:${REDIS_KEYS.OP_TASK_AI}:${taskKey}
为什么只用单个唯一标识?
- ✅ 简单:不需要组合多个参数
- ✅ 唯一:taskKey本身就是唯一的
- ✅ 易查:前端只需要提取唯一标识
示例:
input: https://example.com/file/1765423642667_169.jpeg taskKey: 1765423642667_169.jpeg redisKey: task_ai:1765423642667_169.jpeg异步处理流程
用户点击识别 ↓ 检查Redis是否有结果 ↓ 有结果 → 直接返回 ↓ 无结果 → 设置Redis状态为 'processing' ↓ 异步调用 processRecognitionTask ↓ 调用AI识别接口(30-60秒) ↓ 识别成功 → 更新Redis状态为 'completed' ↓ 发送通知(包含跳转链接)通知链接格式
https://example.com/page/detail?relatedId=${related_id}&taskKey=${taskKey}为什么这样设计?
relatedId:用于获取关联数据详情,打开页面taskKey:用于查询Redis任务结果
URL参数处理
前端解析:
importquerystringfrom'query-string'// 解析URL参数const{relatedId,taskKey}=querystring.parse(window.location.search)// ⚠️ 注意:querystring.parse 返回的类型可能是 string | string[] | null// 需要类型断言或类型守卫const{taskKey}=querystring.parse(window.location.search)as{taskKey:string}清除URL参数:
// 方法1:重置defaultParams(推荐)onReset()// 会设置所有参数为默认值,空值会被过滤// 方法2:手动清除特定参数constclearUrlExtraParams=()=>{constcurrentParams=querystring.parse(location.search)const{relatedId,taskKey,...restParams}=currentParams// 只保留 restParams}🐛 踩坑记录
坑1:Select组件不在Form.Item中
问题:
// Select不在Form.Item中<Select onChange={onVendorTypeChange}>{/* ... */}</Select>// 但是代码中用了form.setFieldsValueform.setFieldsValue({vendorType:value})// ❌ 没用!原因:Form只能管理Form.Item中的字段
解决:
// ✅ 直接用state管理const[vendorType,setVendorType]=useState<string>()constonVendorTypeChange=(value:string)=>{setVendorType(value)// 只更新state}<Select value={vendorType}onChange={onVendorTypeChange}>坑2:URL参数被useSyncParams重置
问题:
- URL中有额外参数(如
relatedId,taskKey) - 但是
useSyncParamshook 会重写URL,只保留defaultParams中的参数 - 结果:额外参数被清除了 😱
原因:
// useSyncParams 的 onParamSync 会重写URLconstonParamSync=()=>{history.push(`${pathname}?${querystring.stringify(params,{sort:false})}`)// params 只包含 defaultParams 中的字段}解决:
// 方案1:在defaultParams中添加这些字段(推荐)constdefaultParams={// ...其他字段taskKey:'',relatedId:'',}// 方案2:修改useSyncParams hook,保留额外参数// (但可能影响其他页面,需谨慎)坑3:后端获取域名的方式
问题:前端用window.location.host,后端用什么?
答案:
// 方式1:配置文件(当前使用)constbaseUrl=this.app.config.baseUrl||''// 方式2:从请求获取(更灵活)constbaseUrl=`${this.ctx.request.protocol}://${this.ctx.request.host}`// 或constbaseUrl=this.ctx.request.origin坑4:异步处理中的this上下文
问题:异步函数中this可能为undefined
解决:
// ✅ 项目风格:直接调用,不捕获thisthis.processRecognitionTask(imageUrl,vendorType,redisKey,maintain_id)// 如果出错,会在processRecognitionTask内部用this.ctx.throwValidateWarn处理💡 经验总结
后端经验
Redis Key设计要简单
- ✅ 只用单个唯一标识(如
taskKey),不要组合多个参数 - ✅ 便于前端查询,不需要复杂的key生成逻辑
- ✅ 从输入中提取唯一标识(如文件名、ID等)
- ✅ 只用单个唯一标识(如
异步处理要"fire and forget"
- ✅ 不等待异步任务完成
- ✅ 错误处理在异步函数内部完成
- ✅ 符合项目现有代码风格
两个接口职责要清晰
create_task:创建任务(主动,会创建新任务)get_task_result:查询结果(被动,只查询不创建)
Redis数据结构要完整
- 存储任务相关的完整信息(type、input、related_id等)
- 便于前端直接使用,不需要额外查询
- 包含状态、结果、时间戳等元数据
前端经验
URL参数处理要统一
- ✅ 使用
querystring.parse(window.location.search) - ✅ 注意类型处理(可能是
string | string[] | null) - ✅ 使用类型断言或类型守卫
- ✅ 使用
Form和State要分清
- ✅ Form只能管理Form.Item中的字段
- ✅ 不在Form.Item中的组件用state管理
- ✅ 不要混用,避免无效操作
useSyncParams要小心
- ✅ 会重写URL,只保留defaultParams中的参数
- ✅ 额外参数需要在defaultParams中声明(即使为空)
- ✅ 避免URL参数被意外清除
页面打开时机要准确
- ✅ 等数据加载完成后再打开
- ✅ 通过useEffect监听数据变化
- ✅ 避免在数据未加载时操作
代码清理很重要
- ✅ 删除无意义的form操作
- ✅ 删除console.log和调试代码
- ✅ 保持代码整洁和可维护
通用经验
异步改造的核心
- 立即返回 → 后台处理 → 结果存储 → 通知用户
Redis的妙用
- 临时存储任务状态
- 跨请求共享数据
- 设置TTL自动清理
URL参数传递
- 简单直接,便于分享
- 刷新页面不丢失状态
- 但要处理好清除逻辑
代码风格一致性
- 参考项目现有代码
- 保持风格统一
- 便于团队协作
🎉 最终效果
用户体验提升
- 发起请求→ 立即返回"处理中",可以关闭页面
- 处理完成→ 收到通知,点击链接
- 自动打开→ 页面自动打开,处理结果自动填充
- 完美!✨
技术指标
- ✅ 接口响应时间:从30-60秒 →<1秒
- ✅ 用户体验:从等待 →异步处理
- ✅ 代码质量:逻辑清晰,职责分明
- ✅ 可维护性:高,符合项目风格
🚀 适用场景
什么时候用这个模式?
✅适合:
- 长耗时接口(>10秒)
- 网关有超时限制
- 用户不需要立即看到结果
- 可以通过通知告知用户
❌不适合:
- 短耗时接口(<5秒)
- 需要实时反馈
- 用户必须等待结果
📝 核心要点总结
- 两个接口分离:创建任务 vs 查询结果
- Redis存储状态:processing → completed/failed
- 异步处理:fire and forget模式
- URL参数传递:通过链接跳转并自动加载结果
- 前端状态管理:Form vs State要分清
Happy Coding! 🎊