尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Spring Boot SSE vs WebSocket:实时推送选型与SseEmitter实战

Spring Boot SSE vs WebSocket:实时推送选型与SseEmitter实战 1. 为什么我最终选了SSE而不是WebSocket一次实时推送选型的完整复盘做实时推送很多团队的第一反应是上WebSocket。这个直觉没有错但如果你只是需要服务端往客户端单向推数据WebSocket的复杂度很多时候是被浪费掉的。我在一个设备监控项目里重新纠结过这个选型最终定了Server-Sent EventsSSE要我把当时的思考过程摊开来说可能对正在纠结的你更有参考价值。先说项目背景后端是Spring Boot 2.7前端有PC端管理页面Vue和移动端H5需要实时推送设备状态变化、告警消息和指标曲线。设备量不大单机千级连接撑死消息频率是每秒几条到每几分钟一条不等。最初我准备直接上WebSocket但列了一下需求点之后发现SSE其实完全覆盖而且省掉一大半接入成本。三者的本质区别可以这样理解轮询你每隔几秒去问一次有没有新数据有就返回没有也返回。WebSocket全双工、长连接、双向实时通道你需要处理握手、二进制帧、心跳、重连状态机。SSE一个普通的HTTP长连接服务端可以持续往这个连接里写文本数据客户端断线后可以自动重连并带上上次的偏移量Last-Event-ID。对应到Spring BootSSE的落地比很多人想象中简单得多——核心就是一个SseEmitter它是ResponseBodyEmitter的一个子类专门用于Server-Sent Events。你不需要引入额外的依赖不需要配置消息代理不需要处理复杂的握手协议开箱即用。用一张表把选型逻辑说清楚对比项SSEWebSocket轮询通信方向单向服务端→客户端双向双向轮询模拟协议实现HTTP/1.1 text/event-stream独立协议WSHTTP自动重连内置浏览器自动需手写重连逻辑需手写定时器穿透代理/防火墙容易需额外配置容易二进制数据不支持文本支持支持Spring Boot接入成本低SseEmitter中WebSocketHandler/STOMP低服务端推送能力强强弱我最后选SSE还有一个很现实的原因团队前端对WebSocket的状态机没有太多经验而EventSource API是浏览器内置的几行代码就能用起来。如果哪天需要双向通信再上WebSocket也不迟SSE项目不需要推翻重来。如果你要做的功能是服务端生成事件客户端被动接收那么SSE大概率是最短路径。这个结论值得你在动手前多想一会儿因为它直接决定了后面几周开发体验的轻松程度。2. 从零搭起服务端SseEmitter的核心用法与容易被忽略的细节在Spring Boot里实现SSE核心就两个类SseEmitter和WebMvcConfigurer用于CORS配置。先看我最终版本的一个完整可运行的服务端控制器后面再逐步拆解每个细节。RestController RequestMapping(/api/sse) public class SseNotificationController { private final MapString, SseEmitter emitterRegistry new ConcurrentHashMap(); /** * 客户端建立连接返回SseEmitter对象 */ GetMapping(value /subscribe/{clientId}, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(PathVariable String clientId) { SseEmitter emitter new SseEmitter(60_000L); // 超时时间60秒 emitterRegistry.put(clientId, emitter); emitter.onCompletion(() - { emitterRegistry.remove(clientId); System.out.println(连接完成回调已移除: clientId); }); emitter.onTimeout(() - { emitterRegistry.remove(clientId); System.out.println(连接超时回调已移除: clientId); }); emitter.onError(e - { emitterRegistry.remove(clientId); System.err.println(连接异常: e.getMessage()); }); return emitter; } /** * 向指定客户端推送消息 */ PostMapping(/send/{clientId}) public ResponseEntityString send(PathVariable String clientId, RequestBody String message) throws IOException { SseEmitter emitter emitterRegistry.get(clientId); if (emitter null) { return ResponseEntity.notFound().build(); } emitter.send(SseEmitter.event() .name(message) .id(UUID.randomUUID().toString()) .data(message)); return ResponseEntity.ok(sent); } }这段代码看着简单但每个参数背后都有讲究。逐个拆开讲。2.1 超时时间不是随便填的new SseEmitter(60_000L)里这个60秒是服务端认为连接没有活动后自动断开的阈值。它和HTTP长连接的空闲超时是两回事但坑起来是一起坑的后面我会单独讲生产环境的stream disconnected before completion: idle timeout waiting for sse问题。如果在这个超时时间内没有调用emitter.send()连接会被容器自动回收。所以如果你要推送的数据是低频的比如每5分钟才推一次就必须设置一个大于推送间隔的超时时间或者持续发送心跳来维持连接活性。SseEmitter的构造器还支持无参版本默认超时时间由容器决定但在Tomcat下默认只有20秒左右做实时推送几乎必断。我建议的做法是明确传入一个业务上合理的时间。如果推送间隔可能在几分钟甚至更长就传0L表示永不超时但这时更依赖心跳机制保活或者传一个足够大的数。我最常用的是30秒到5分钟之间配合心跳线程。2.2 用ConcurrentHashMap管理连接是入门写法生产环境要想清楚上面代码里的emitterRegistry用了ConcurrentHashMap这只是单机版的起步方案。一旦你有多个实例客户端连到A实例消息由B实例产生就会发不出去。这种场景下你有两个思路引入Redis Pub/Sub或消息队列A实例收到推送事件后转发给自己本地的emitter集合。如果非要跨实例直推可以用Spring的ApplicationEventPublisher结合分布式锁来做通知路由。我个人实际开发中最常用的方案是ConcurrentHashMap Redis订阅。服务端产生消息时先发到Redis频道各实例订阅同一个频道收到消息后检查本地是否有所需的emitter有则推送。这个方案保留了ConcurrentHashMap的极低延迟又解决了多实例问题。这块代码不复杂但很多教程没告诉你。2.3 MediaType.TEXT_EVENT_STREAM_VALUE这个注解属性有多重要produces MediaType.TEXT_EVENT_STREAM_VALUE是让Spring MVC以SSE协议输出响应的关键。如果你不指定这个值Spring可能按application/json来处理客户端EventSource会解析失败。有个容易踩的坑是在Spring Boot 3.x里直接返回SseEmitter类型同时加了ResponseBody或在类上加了RestController此时produces仍然建议显式声明。我用过Spring Boot 2.3.x和2.6.x行为保持一致但显式声明永远比隐式推断可靠。2.4 连接生命周期回调onCompletion、onTimeout、onErrorSseEmitter提供三个重要的回调很多人写完忘了注册回调实际生产中是会有内存泄漏的onCompletion连接正常完成客户端关闭或服务端主动complete时触发。onTimeout达到超时时间自动断开时触发。onError连接出错时触发。回调里必须做同一件事从emitterRegistry中移除该clientId。否则这个SseEmitter对象虽然已经死了但还留在Map里随着连接增多内存会被这些僵尸对象占满GC还清不掉因为Map强引用着它们。这是我见过最多的SSE生产事故一定不要省。3. 消息格式与服务端主动断开让客户端拿到结构化事件而非裸字符串很多SSE教程只演示emitter.send(Hello)这在真实项目中是不够的。客户端解析你要的设备状态、告警级别、指标数值不能靠字符串切割你需要利用SseEmitter.event()构建标准事件格式。看一下SSE协议的标准事件流长什么样id: 6f0a1b2c-3a5e-4d8f-9b1a-123456789abc event: deviceStatus data: {deviceId:dev-001,status:online,battery:78,ts:1700000000000} retry: 3000对应到服务端代码emitter.send(SseEmitter.event() .name(deviceStatus) // 对应event: 字段 .id(String.valueOf(counter)) // 对应id: 字段 .data(payload) // 对应data: 字段 .reconnectTime(3000L)); // 对应retry: 字段.name()字段就是事件类型客户端可以用addEventListener(deviceStatus, cb)去监听特定类型的事件不用在所有消息里手动做类型判断。这是SSE比普通轮询优雅得多的地方。.id()字段是断线重连的命根子。浏览器EventSource在意外断开后会自动重连并把上次收到的id放到Last-Event-ID请求头里发送给服务端。这恰恰是词典里那个热搜词的核心场景基于deerflow智能体进行二次开发封装sse流式接口调用逻辑完成流式消息解析。你仔细想智能体流式输出AI聊天场景、监控告警、订单状态推送本质上都是同一件事服务端持续产生消息客户端按序消费。如果有断点和续传需求比如分页推送历史消息Last-Event-ID就是天然的游标。这里我补充一个服务端主动断开的细节当你推送完一批数据不想再保持连接时调用emitter.complete()。注意complete()之后这个emitter就不能再send了。而emitter.completeWithError(new RuntimeException(...))则会触发客户端的error事件浏览器会尝试重连。所以如果你的业务是想正常结束一定用complete()而不是completeWithError()。这两个方法的语义差别我见过不止一个人用反导致前端vconsole里疯狂报错重连。再聊一个必须设计的点连接标识。用clientId作为Map的key这个key的生成方式决定了路由能力。如果你的客户端是浏览器网页一个用户可能开两个标签页那clientId至少得是userId 随机数的组合否则两个标签页共享一个emitter一个页面断开另一个就被误删了。我给客户端设计连接标识时用的格式是token设备的base64这样既保证唯一又方便在多设备多标签下路由。下面是消息格式设计时我常用的一份参考模板推送内容event名称data结构示例说明设备状态deviceStatus{deviceId:dev-001,status:online}状态变化低频实时指标metric{deviceId:dev-001,metric:cpu,value:23.5,unit:%}高频指标告警事件alarm{level:P1,message:温度超限,deviceId:dev-001}告警优先推送业务数据business{orderId:NO123,state:PAID}常规业务事件data里建议统一使用JSON字符串并且不要包含换行符以外的裸数据。因为SSE协议里空行\n\n是消息终止符data内容若有裸换行会导致客户端解析错乱。所以这里我强烈建议所有通过.data()传递的内容统一用ObjectMapper序列化成JSON字符串绝不手写拼接。4. 生产环境排雷实录stream disconnected idle timeout、心跳保活与Spring Boot/AstApi互通这部分是实战中最有价值的我们逐一聊。4.1 为什么会出现idle timeout waiting for sse你搜过那条报错stream disconnected before completion: idle timeout waiting for sse。我实际踩过还花了不少时间查。最终定位到两个根因。第一个是Tomcat连接器层面的空闲超时。Spring Boot内嵌Tomcat的server.connection-timeout默认值是-1或者很短当连接空闲超过一定时间Tomcat的连接器server.tomcat.keep-alive-timeout默认20秒连接会被认为是死连接而回收。SSE连接虽然一直挂着HTTP响应流但没有数据传输时它就是空闲的正好撞上这个回收机制。第二个是反向代理或网关的空闲超时。如果你们前面有Nginx或Spring Cloud Gateway它们也会有自己的idle timeout。Nginx默认proxy_read_timeout是60秒超过60秒没有从后端读到数据就会主动断开跟客户端的连接。解决思路有三条我建议组合使用调大Tomcat的连接idle超时在application.yml里设置server.tomcat.keep-alive-timeout180s或更大。针对SSE接口单独绕过代理超时Nginx配置proxy_read_timeout 3600s; proxy_buffering off;。最稳妥的兜底方案服务端定时发送心跳消息注释事件让连接永远不空闲。第三点是最通用的因为很多时候你控制不了网关的配置比如云上负载均衡。我的做法是单独起一个ScheduledExecutorService每15秒遍历一次emitterRegistry对每个emitter发送一个注释事件Scheduled(fixedRate 15_000) public void heartbeat() { emitterRegistry.forEach((clientId, emitter) - { try { emitter.send(SseEmitter.event().comment(hb)); } catch (IOException e) { // 连接已经断了交给回调清理这里记录日志即可 System.err.println(心跳发送失败: clientId); } }); }注释事件comment(hb)在SSE协议里是以:开头的行严格来说不算事件数据浏览器会自动忽略不会触发客户端的onmessage和onerror但又能让连接产生实际的数据传输流量从而让代理的idle计时器重新计时。这个方案我实测在Spring Boot 2.6.x和Spring Boot 3.x上都有效也不会引发客户端业务事件回调。注意Scheduled得在启动类上开启EnableScheduling很多新手会漏。4.2 为什么IDEA社区版用户特别容易遇到Spring Boot项目起不来的情况热搜词里那个问题intellij idea 社区版怎么用spring boot。我解释一下。IDEA社区版本身不带Spring Boot的专门运行配置面板但这不是阻碍。正确做法是用Maven的spring-boot-maven-plugin插件通过mvn spring-boot:run启动项目。或者安装Spring Boot Helper社区插件。更习惯调试的可以直接运行main方法所在的类Spring Boot应用本质上就是一个普通Java程序不依赖IDEA高级功能。如果项目用的是Spring Boot 3注意需要JDK 17社区版也完全支持。这个跟Spring Initializr无关Spring Initializr是网页服务社区版可以通过File - New - Project - Spring Initializr创建没任何限制。4.3 Spring Boot和FastAPI/AstAPI之间怎么互通SSE现在不少团队会用Python FastAPI做AI/数据服务用Spring Boot做业务层两者之间也可能需要SSE流式转发。这里补充一个桥接思路。FastAPI端生成SSE流响应text/event-streamSpring Boot端通过WebClient非阻塞拉取这个流再通过本地的SseEmitter推给最终浏览器客户端。注意这里Spring Boot扮演的是代理/转发角色。WebClient.create().get() .uri(http://python-service:8000/api/stream) .retrieve() .bodyToFlux(String.class) .doOnNext(data - { emitterRegistry.forEach((clientId, emitter) - { try { emitter.send(SseEmitter.event().data(data)); } catch (IOException e) { // ignore } }); }) .subscribe();这个模式在AI流式输出场景里特别常见。原理上就是上游是SSE源下游是SSE目标中间Spring Boot只做协议转换和连接管理。WebClient的bodyToFlux天然支持流式不会一次性把整个Stream读进内存这也是我推荐用它而不是RestTemplate的原因。5. 客户端消费的全家桶原声EventSource、Vue封装与主动断开的坑服务端写好了客户端怎么接同样有讲究。不同前端技术栈的接入方式虽然有差别但底层都靠EventSource。这里把浏览器原生、Vue、以及其他场景比如React的接法都讲一遍。5.1 浏览器原生EventSource最简单也最容易踩“事件类型”的坑const source new EventSource(/api/sse/subscribe/user001); source.addEventListener(deviceStatus, (event) { const data JSON.parse(event.data); console.log(收到设备状态, data); }); source.addEventListener(alarm, (event) { const data JSON.parse(event.data); // 弹出告警 }); source.onerror (err) { // 这里注意浏览器会自动重连不要在这里手动new EventSource console.error(SSE连接异常或服务端断开, err); }; source.onopen () { console.log(SSE连接建立); };三个坑挨个说addEventListener(deviceStatus)只能匹配服务端.name(deviceStatus)的事件。如果没有.name(...)则默认事件名是message此时你只能用onmessage或addEventListener(message, ...)。onerror触发不代表连接彻底挂了。可能是服务端主动断开的complete()或者网络抖动此时浏览器会自动重连。如果你在这个回调里也去new EventSource()就会产生重复连接风暴服务端Map里会出现大量重复的emitter。EventSource原生只支持GET请求无法携带Authorization头。如果你们的登录态靠JWT放在请求头里SSE的握手请求会带不上。这种情况下的替代方案有三个把token放在cookie里让SSE请求自动携带。在URL参数或路径上附带token以满足业务需要为前提注意日志清洗。服务端改用CrossOrigin支持跨域然后把token放query参数里传给服务端校验。我一般推荐方案1或2因为SSE本来面向的就是服务端到浏览器的单向推送安全性上要额外注意token泄漏风险。5.2 Vue项目里的封装连接状态管理与自动重连Vue里直接用原生EventSource没问题但真实项目里建议封成一个模块统一处理全局的推送消息、连接状态、以及页面销毁时的资源清理。// sse-manager.js let source null; export function connectSSE(url, handlers {}) { if (source) { source.close(); } source new EventSource(url); source.onopen () { console.log(SSE已连接); handlers.onOpen handlers.onOpen(); }; source.onerror (err) { console.warn(SSE连接异常浏览器将自动重连, err); }; Object.entries(handlers).forEach(([eventName, cb]) { if (eventName.startsWith(on)) { source[on eventName.slice(2)] cb; } else { source.addEventListener(eventName, cb); } }); return source; } export function closeSSE() { if (source) { source.close(); source null; } }这个封装解决了一个我在实际项目里很痛的问题Vue Router切换到别的页面时SSE连接如果还挂在旧组件上不仅浪费服务端连接还可能收到本不该处理的业务消息。所以在路由的onBeforeUnmount或Vue 3的onUnmounted里一定要调用closeSSE()。关于React类似的场景核心是一样的SSE连接应该绑定在某个单例管理对象里Context或全局模块而不是在组件内部反复创建。React生态里如果你用了useState存EventSource实例然后忘清理会看到控制台疯狂报错“EventSources connection has been closed”。5.3 关于“主动断开”的语义再说一次服务端complete()之后浏览器EventSource会触发onerror然后尝试重连。但有些业务场景是我主动告知客户端推送结束不要再重连了。正确的做法是服务端在断开前发一条event: done的业务事件客户端收到done事件后手动source.close()。这样就不会陷入服务端已经断开客户端还在无限重连的资源浪费循环。emitter.send(SseEmitter.event().name(done).data(stream-finished)); emitter.complete();客户端侧source.addEventListener(done, (event) { source.close(); console.log(服务端推送结束连接已关闭); });这个模式在AI流式问答、大数据导出推送场景里几乎是必须的。如果没有这条done事件用户看完最后一条消息应用还傻傻地挂着连接直到超时被回收。6. 把SSE服务推向企业级鉴权、跨域、监控与多实例扩展基础功能跑通只是开始真要做到能上线、能扛住生产环境下面几个点绕不过去。6.1 鉴权方案握手时校验身份推送时校验权限SSE连接的入口就是GET /subscribe/{clientId}所以鉴权可以在这个接口上做。Spring Boot集成Spring Security时直接在SecurityFilterChain里把这个路径加入认证范围即可。如果不想引入Spring Security也可以用一个HandlerInterceptorComponent public class SseAuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getParameter(token); if (!isValidToken(token)) { response.setStatus(401); return false; } return true; } }这个方案最大的问题是SSE连接建立后后续服务端推送不需要客户端再发请求那你无法在推送的每一条数据上做权限校验。所以生产上我建议至少做到握手时校验身份 订阅时按clientId绑定归属这两步。比如A用户只能订阅clientIduser-100服务端在连接时校验路径上的clientId是否属于当前登录用户。再进一步可以把需要推送的业务数据本身按用户维度做过滤防止不该你看到的数据通过被猜测的clientId订阅到了。6.2 跨域配置前后端分离项目最容易被忽略的一环Vue项目跑在localhost:5173Spring Boot跑在localhost:8080SSE请求天然会被浏览器跨域拦截。必须在Spring Boot里配置CORS。一个最简单的全局CORS配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/sse/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST) .allowCredentials(true); } }有几个坑如果你的SSE请求原始模式是EventSource它不会自己带Origin之外的预检OPTIONS请求因为EventSource发的是简单GET请求所以服务端主要关注Access-Control-Allow-Origin响应头是否回显了正确的来源。用了allowCredentials(true)之后allowedOrigins不能是*必须是明确的前端地址。否则后端会直接拒绝。前端如果用axios/fetch封装SSE是做不到的EventSource只能用系统原生的基本请求没有浏览器自定义头的余量。6.3 监控与告警利用Spring Boot Actuator Spring Boot AdminSSE服务一旦上线最怕的是连接全断了我不知道。如果你接入了spring-boot-starter-actuator可以在actuator/metrics里自定义一个指标sse.active.connections每次连接创建和销毁时更新这个值。最简单的自定义指标方式Component public class SseMetrics { private final AtomicInteger activeConnections new AtomicInteger(); public void increment() { activeConnections.incrementAndGet(); } public void decrement() { activeConnections.decrementAndGet(); } Bean public MeterRegistry meterRegistry() { return new SimpleMeterRegistry(); } }然后在连接建立时调用increment()在onCompletion/onTimeout/onError回调里调用decrement()。Spring Boot Admin后台就能看到这个指标的曲线低于阈值发告警。另外一个常见的生产检查项是SSE的线程模型。SseEmitter.send()不是异步的内心在调用时是同步写的。如果你的推送线程在高并发下同时向上千个emitter发送数据而某个客户端网络很慢写操作会阻塞住这条线程。解决方案是用一个线程池隔离推送动作给每个emitter一个独立的写入机会ExecutorService executor Executors.newFixedThreadPool(32); executor.submit(() - { try { emitter.send(event); } catch (IOException e) { // log } });当然这个线程池要按项目的QPS和连接数做压测不能拍脑袋定。我在项目中一般先设成最大连接数的10%~20%比如预期1000连接就开100~200线程避免线程浪费。6.4 多实例部署与Redis Pub/Sub桥接前面提到过多实例问题这里把完整方案给出来。SSE连接是有状态的每个客户端只会连到某一个实例。业务服务在任意实例上产生消息后要把消息广播出去由目标实例做本地推送。借助Redis Pub/Sub// Spring Boot中通过RedisTemplate发布 stringRedisTemplate.convertAndSend(sse:notify, payloadJson); // 订阅端 Bean public RedisMessageListenerContainer redisContainer( RedisConnectionFactory connectionFactory, MessageListenerAdapter sseListenerAdapter) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener(sseListenerAdapter, new ChannelTopic(sse:notify)); return container; }订阅端收到消息后解析出clientId从本地emitterRegistry找emitter推送。这样每个实例只推在本实例上挂着的客户端消息不重复、不丢失Redis Pub/Sub不持久化如果必须可靠投递可以换Stream或MQ。这个架构下SSE服务从单机玩具升级到了可横向扩容。实测中两台4C8G的实例每台保持1500个长连接完全没问题瓶颈往往在文件描述符和内存而不是CPU所以部署时记得调大ulimit nofile。嫌麻烦的直接用Spring Boot Actuator监控文件描述符占用一旦逼近max file descriptors就扩容这个方法最直接。7. 从能跑到好用我最后补充的几个实操经验和建议最后说几个完全来自实操的体会。第一个是SseEmitter一定要在Controller方法里直接返回不要在Service层返回再层层传递。我早期写过把SseEmitter从Controller传到Service层保存的代码遇到过一次序列化问题Spring在返回前对SseEmitter做了特殊处理如果你在Service层把它存在某种DTO里有时候会被当成普通POJO序列化直接报错。保持Controller - SseEmitter的直通关系最稳。第二个是客户端探测到断线自动重连是特性不是bug。很多人看到vconsole红红的error就慌实际上EventSource自动重连本身就是设计的一部分。你真正要关心的是重连次数是否过多、重连是否携带了正确的Last-Event-ID。如果要控制重连间隔服务端可以在事件流里输出retry: 5000告诉浏览器断开后5秒再重连。第三个是消息体积和频率决定了你需不需要背压。如果客户端消费速度跟不上服务端生产速度SseEmitter.send()会积压到TCP缓冲区、再积压到内存。如果你有高吞吐推送需求建议加一个业务层的降频合并逻辑同类消息30秒内只推送最新一条。反向代理的proxy_buffering off配合这套降频线上效果相当明显。第四个是关于单元测试。Spring Boot为SseEmitter写了SseEmitter可以直接通过MockMvc测试在测试里你可以用MvcResult拿到流内容判断是否返回了Content-Type: text/event-stream。更简单的做法是写一个临时客户端用Java的HttpURLConnection连上后读几行流数据验证事件格式。如果现在让我重新做一个实时推送项目我依然会选SSE而不是WebSocket因为大部分业务场景就是单向的、文本的、即时的。SseEmitter让服务端代码几乎不需要学习新的消息协议客户端一套EventSource走天下。唯一需要认真设计的就是连接管理、心跳保活和消息格式这老三样把它们想清楚了SSE的上线之路会非常顺。这篇文章里的代码和方案都是我在实际项目中验证过的你可以直接拿去做基线版本再按自己的业务改。如果你准备用SSE做AI流式输出、监控大屏或办公用品管理系统里的实时通知会遇到的问题基本都覆盖在这篇里了。碰到具体报错欢迎回来对号入座。
返回列表