首页 最新 热门 推荐

  • 首页
  • 最新
  • 热门
  • 推荐

Gateway集成WebSocket 实现前后端通信(全)

  • 25-02-22 03:01
  • 3212
  • 14045
blog.csdn.net

前言:最近项目上需要用到这个技术,但是真正集成到SpringCloud项目运行时,遇到各种问题。查了很多博客也没有一篇相对完整的,大多数是demo代码。下面将完整地分享从 Client-->Nginx-->gateway-->server 到返回的整个功能实现。

一、基本概念

1.websocket基础概念

WebSocket是一种通信协议,可在单个TCP连接上进行全双工通信。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就可以建立持久性的连接,并进行双向数据传输。

简单易懂:WebSocket可以实现客户端与服务端的双向通讯,最大也是最明显的区别就是可以做到服务端主动将消息推送给客户端

 

2.websocket的特点

  • 握手阶段采用 HTTP 协议。
  • 数据格式轻量,性能开销小。客户端与服务端进行数据交换时,服务端到客户端的数据包头只有2到10字节,客户端到服务端需要加上另外4字节的掩码。HTTP每次都需要携带完整头部。
  • 更好的二进制支持,可以发送文本,和二进制数据
  • 没有同源限制,客户端可以与任意服务器通信
  • 协议标识符是ws(如果加密,则是wss),请求的地址就是后端支持websocket的API。

 

3.什么场景下使用

在项目没有使用websocket时,如果客户端(前端)想要实时获取后端的数据变化,需要定一个定时器,一直轮询地调用后端接口。这样开销太大,也不是真的实时,而且是很被动的。

  • 定时任务时间间隔,多久调用一次。太长达不到效果,太短又请求太频繁
  • 假设并发很高的话,这对服务端也是个考验

WebSocket一次握手,持久连接,以及主动推送的特点可以解决上边的问题,又不至于损耗性能。

真实使用场景:日志刷新、监控调度平台、以及一些也和业务相关的需要服务端主动发消息的场景

 

二、版本信息和配置

SpringCloud: Hoxton.SR6

Gateway: 2.2.3.RELEASE

  1. <dependencyManagement>
  2. <dependencies>
  3. <dependency>
  4. <groupId>org.springframework.cloudgroupId>
  5. <artifactId>spring-cloud-dependenciesartifactId>
  6. <version>Hoxton.SR6version>
  7. <type>pomtype>
  8. <scope>importscope>
  9. dependency>
  10. dependencies>
  11. dependencyManagement>
  12. <dependencies>
  13. <dependency>
  14. <groupId>org.springframework.cloudgroupId>
  15. <artifactId>spring-cloud-starter-gatewayartifactId>
  16. dependency>
  17. dependencies>

 

SpringBoot: 2.3.0.RELEASE

Spring-boot-starter-websocket: 2.3.0.RELEASE

  1. <parent>
  2. <groupId>org.springframework.bootgroupId>
  3. <artifactId>spring-boot-starter-parentartifactId>
  4. <version>2.3.0.RELEASEversion>
  5. parent>
  6. <dependencies>
  7. <dependency>
  8. <groupId>org.springframework.bootgroupId>
  9. <artifactId>spring-boot-starter-websocketartifactId>
  10. dependency>
  11. dependencies>

Nginx: 1.19.0

 

三、功能实现

1.编写Websocket服务端

有一个Sprinboot服务:端口8086,在项目里引入websocket依赖

  1. package com.yonjar.demo.service;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.springframework.stereotype.Component;
  4. import javax.websocket.*;
  5. import javax.websocket.server.ServerEndpoint;
  6. import java.util.Map;
  7. import java.util.concurrent.ConcurrentHashMap;
  8. import java.util.concurrent.atomic.AtomicInteger;
  9. /**
  10. * @author luoyj
  11. * @date 2021/5/17.
  12. * @description @ServerEndpoint(value = "/test/websocket") 这个地址要和前端调用保持一致
  13. */
  14. @Slf4j
  15. @ServerEndpoint(value = "/test/websocket")
  16. @Component
  17. public class WebSocketServer {
  18. /** 记录当前在线连接数 */
  19. private static final AtomicInteger onlineCount = new AtomicInteger(0);
  20. /** 存放所有在线的客户端 */
  21. private static final Map clients = new ConcurrentHashMap<>();
  22. /**
  23. * 连接建立成功调用的方法
  24. */
  25. @OnOpen
  26. public void onOpen(Session session) {
  27. onlineCount.incrementAndGet(); // 在线数加1
  28. clients.put(session.getId(), session);
  29. log.info("有新连接加入:{},当前在线人数为:{}", session.getId(), onlineCount.get());
  30. }
  31. /**
  32. * 连接关闭调用的方法
  33. */
  34. @OnClose
  35. public void onClose(Session session) {
  36. onlineCount.decrementAndGet(); // 在线数减1
  37. clients.remove(session.getId());
  38. log.info("有一连接关闭:{},当前在线人数为:{}", session.getId(), onlineCount.get());
  39. }
  40. /**
  41. * 收到客户端消息后调用的方法
  42. * @param message
  43. * 客户端发送过来的消息
  44. * 当业务改动数据时,可以主动发消息(不需要客户端主动请求)
  45. */
  46. @OnMessage
  47. public void onMessage(String message, Session session) {
  48. log.info("服务端收到客户端[{}]的消息:{}", session.getId(), message);
  49. this.sendMessage(message);
  50. }
  51. @OnError
  52. public void onError(Session session, Throwable error) {
  53. log.error("发生错误");
  54. error.printStackTrace();
  55. }
  56. /**
  57. * 群发消息
  58. * @param message
  59. * 消息内容
  60. */
  61. public void sendMessage(String message) {
  62. for (Map.Entry sessionEntry : clients.entrySet()) {
  63. Session toSession = sessionEntry.getValue();
  64. /* 排除掉自己
  65. if (!fromSession.getId().equals(toSession.getId())) {
  66. log.info("服务端给客户端[{}]发送消息{}", toSession.getId(), message);
  67. toSession.getAsyncRemote().sendText(message);
  68. }*/
  69. toSession.getAsyncRemote().sendText(message);
  70. }
  71. }
  72. }

2.添加配置类

  1. package com.yonjar.demo.config;
  2. import org.springframework.context.annotation.Bean;
  3. import org.springframework.context.annotation.Configuration;
  4. import org.springframework.web.socket.server.standard.ServerEndpointExporter;
  5. /**
  6. * @author luoyj
  7. * @date 2021/5/17.
  8. * @description 因为使用的是原生API,不需要另外实现接口或集成类
  9. */
  10. @Configuration
  11. public class WebSocketConfig {
  12. /**
  13. * 注入一个ServerEndpointExporter,该Bean会自动注册使用@ServerEndpoint注解申明的websocket endpoint
  14. */
  15. @Bean
  16. public ServerEndpointExporter serverEndpointExporter() {
  17. return new ServerEndpointExporter();
  18. }
  19. }

3.编写客户端index.html(前端请求)

demo测试时,可以放在Springboot项目的resources/static/index.html

  1. html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>My WebSockettitle>
  6. head>
  7. <body>
  8. <input id="text" type="text" />
  9. <button onclick="send()">Sendbutton>
  10. <button onclick="closeWebSocket()">Closebutton>
  11. <div id="message">div>
  12. body>
  13. <script type="text/javascript">
  14. var websocket = null;
  15. //判断当前浏览器是否支持WebSocket, 主要此处要更换为自己的地址
  16. if ('WebSocket' in window) {
  17. websocket = new WebSocket("ws://localhost:8086/test/websocket");
  18. } else {
  19. alert('Not support websocket')
  20. }
  21. //连接发生错误的回调方法
  22. websocket.onerror = function() {
  23. setMessageInnerHTML("error");
  24. };
  25. //连接成功建立的回调方法
  26. websocket.onopen = function(event) {
  27. setMessageInnerHTML("open");
  28. }
  29. //接收到消息的回调方法
  30. websocket.onmessage = function(event) {
  31. setMessageInnerHTML(event.data);
  32. }
  33. //连接关闭的回调方法
  34. websocket.onclose = function() {
  35. setMessageInnerHTML("close");
  36. }
  37. //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
  38. window.onbeforeunload = function() {
  39. websocket.close();
  40. }
  41. //将消息显示在网页上
  42. function setMessageInnerHTML(innerHTML) {
  43. document.getElementById('message').innerHTML += innerHTML + '
    '
    ;
  44. }
  45. //关闭连接
  46. function closeWebSocket() {
  47. websocket.close();
  48. }
  49. //发送消息
  50. function send() {
  51. var message = document.getElementById('text').value;
  52. websocket.send(message);
  53. }
  54. script>
  55. html>

4.在线请求测试

  1. 启动server项目,浏览器访问:http://localhost:8086/index.html
     
  2. 也可以使用在线连接工具进行测试

    http://www.jsons.cn/websocket/
  3. 以上就是demo的测试使用。但是真正集成到企业项目时,就会遇到各种问题
     

四、集成到真实项目

首先考虑Nginx转发websocket是否支持,其次是gateway进行路由转发ws请求到具体的服务,然后是请求到服务连接成功进行业务处理,最后还要考虑鉴权以及并发问题。

1.Nginx配置:升级,让它支持websocket转发

注意配置正确的位置(看图)

  1. map $http_upgrade $connection_upgrade {
  2. default upgrade;
  3. '' close;
  4. }
  5. #升级http1.1到 websocket协议
  6. proxy_http_version 1.1;
  7. proxy_set_header Upgrade $http_upgrade;
  8. proxy_set_header Connection $connection_upgrade;

2.gateway配置:配置路由,支持转发ws请求

  1. server:
  2. port: 8080
  3. spring:
  4. application:
  5. name: app-gateway
  6. cloud:
  7. gateway:
  8. discovery:
  9. locator:
  10. enabled: true
  11. enabled: true
  12. routes:
  13. #表示websocket的转发
  14. - id: app-metadata-websocket
  15. uri: lb:ws://app-metadata
  16. predicates: Path=/api/web/**
  17. filters: StripPrefix=2
  18. #正常接口转发
  19. - id: app-metadata
  20. uri: lb://app-metadata
  21. predicates: Path=/api/**
  22. filters: StripPrefix=1

3.websocket基于协议头传递token

建议前端使用原生websocket API请求

  • 使用封装过的api,例如 SocketJS 会有跨域,后端也需另外配置。
  • websocket连接成功后,如果没有进行通信,过一段时间后会断开连接。所以还需要前端隔5或10秒发送一个心跳请求后台。
  • 页面初始化没有成功后,还需要处理重试连接。

websocket请求头中可以包含Sec-WebSocket-Protocol这个属性,该属性是一个自定义的子协议。它从客户端发送到服务器并返回从服务器到客户端确认子协议。我们可以利用这个属性添加token。

  1. var token='jlllwei68jj776'
  2. var ws = new WebSocket("ws://" + url+ "/webSocketServer",[token]);

4.WebsocketServer服务

01.编写过滤器获取token

拿到token可以解析判断,set 到response里面,否则Gateway源码WebSocketClientHandshaker会报异常,因为response没拿到子协议

  1. import lombok.extern.slf4j.Slf4j;
  2. import org.apache.commons.lang3.StringUtils;
  3. import org.springframework.core.annotation.Order;
  4. import org.springframework.stereotype.Component;
  5. import javax.servlet.*;
  6. import javax.servlet.annotation.WebFilter;
  7. import javax.servlet.http.HttpServletRequest;
  8. import javax.servlet.http.HttpServletResponse;
  9. import java.io.IOException;
  10. /**
  11. * @Author luoyj
  12. * @Date 2021/6/7.
  13. */
  14. @Slf4j
  15. @Order(1)
  16. @Component
  17. @WebFilter(filterName = "WebSocketFilter", urlPatterns = "/websocket/app/edit")
  18. public class WebSocketFilter implements Filter {
  19. @Override
  20. public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
  21. HttpServletResponse response = (HttpServletResponse) servletResponse;
  22. String token = ((HttpServletRequest) servletRequest).getHeader("Sec-WebSocket-Protocol");
  23. log.info("【WebSocketFilter】response.setHeader = key:{},value:{}","Sec-WebSocket-Protocol",token);
  24. /*if (StringUtils.isNotBlank(token)) {
  25. response.setHeader("Sec-WebSocket-Protocol",token);
  26. filterChain.doFilter(servletRequest, servletResponse);
  27. }else {
  28. throw new BizException(ErrorCode.TEAMWORK_WS_NOT_TOKEN,"websocket请求没有携带token,无法请求!");
  29. }*/
  30. if (StringUtils.isNotBlank(token)) response.setHeader("Sec-WebSocket-Protocol",token);
  31. filterChain.doFilter(servletRequest, servletResponse);
  32. }
  33. }

02.编写前后端请求数据结构实体Message,Encoder编码器,Decoder解码器。这样发送和接收信息可以更好的处理 。

  1. import com.authine.mvp.app.metadata.domain.enums.TeamworkEditEvent;
  2. import com.authine.mvp.app.metadata.domain.enums.TeamworkEditType;
  3. import lombok.AllArgsConstructor;
  4. import lombok.Builder;
  5. import lombok.Data;
  6. import lombok.NoArgsConstructor;
  7. /**
  8. * @Author luoyj
  9. * @Date 2021/5/19.
  10. * 业务数据结构,根据实际业务场景定义
  11. * 空参构造、有参构造不可少
  12. */
  13. @Builder
  14. @Data
  15. @NoArgsConstructor
  16. @AllArgsConstructor
  17. public class TeamworkEditMessage {
  18. private String appCode;
  19. private TeamworkEditType teamworkEditType;
  20. private String code;
  21. /**
  22. * 必传
  23. * HEART_BEAT(0,"心跳"),
  24. * COMPETING_LOCK(1,"抢锁"),
  25. * CLEARING_LOCK(2,"释放锁"),
  26. * REFRESH_EXPIRE_TIME(3,"刷新锁的有效时间"),
  27. * LOCK_STATUS(4,"查看锁状态"),
  28. * SAVE(5,"保存数据,群发通知"),
  29. * EDIT(6,"编辑操作,群发通知"),
  30. * DELETE(7,"删除操作,群发通知"),
  31. * 例子参数传:COMPETING_LOCK
  32. */
  33. private TeamworkEditEvent teamworkEditEvent;
  34. private Boolean haveLock;
  35. private int expireTime;
  36. private String editUserId;
  37. private String editUserName;
  38. private String remark;
  39. /**
  40. * 标识当前code是锁定 还是未锁定
  41. */
  42. private Boolean codeLock;
  43. }
  1. import com.alibaba.fastjson.JSON;
  2. import lombok.extern.slf4j.Slf4j;
  3. import javax.websocket.EncodeException;
  4. import javax.websocket.Encoder;
  5. import javax.websocket.EndpointConfig;
  6. /**
  7. * @Author luoyj
  8. * @Date 2021/5/18.
  9. */
  10. @Slf4j
  11. public class TeamworkEditEncoder implements Encoder.Text {
  12. @Override
  13. public String encode(TeamworkEditMessage teamworkEditMessage) throws EncodeException {
  14. try {
  15. return JSON.toJSONString(teamworkEditMessage);
  16. } catch (Exception e) {
  17. e.printStackTrace();
  18. log.info("服务端数据转换json结构失败!");
  19. return "";
  20. }
  21. }
  22. @Override
  23. public void init(EndpointConfig endpointConfig) {
  24. }
  25. @Override
  26. public void destroy() {
  27. }
  28. }
  1. import com.alibaba.fastjson.JSONObject;
  2. import lombok.extern.slf4j.Slf4j;
  3. import javax.websocket.DecodeException;
  4. import javax.websocket.Decoder;
  5. import javax.websocket.EndpointConfig;
  6. /**
  7. * @Author luoyj
  8. * @Date 2021/5/19.
  9. */
  10. @Slf4j
  11. public class TeamworkEditDecoder implements Decoder.Text {
  12. @Override
  13. public TeamworkEditMessage decode(String jsonMessage) throws DecodeException {
  14. return JSONObject.parseObject(jsonMessage, TeamworkEditMessage.class);
  15. }
  16. @Override
  17. public boolean willDecode(String jsonMessage) {
  18. try {
  19. JSONObject.parseObject(jsonMessage, TeamworkEditMessage.class);
  20. return true;
  21. } catch (Exception e) {
  22. e.printStackTrace();
  23. log.info("客户端发送消息到服务端,数据解析失败!");
  24. return false;
  25. }
  26. }
  27. @Override
  28. public void init(EndpointConfig endpointConfig) {
  29. }
  30. @Override
  31. public void destroy() {
  32. }
  33. }
  1. import com.authine.mvp.app.metadata.domain.enums.TeamworkEditEvent;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.springframework.stereotype.Component;
  4. import javax.websocket.*;
  5. import javax.websocket.server.ServerEndpoint;
  6. import java.io.IOException;
  7. import java.util.concurrent.CopyOnWriteArraySet;
  8. import java.util.concurrent.atomic.AtomicInteger;
  9. /**
  10. * @Author luoyj
  11. * @Date 2021/5/18.
  12. *
  13. * protocol
  14. */
  15. @Slf4j
  16. @ServerEndpoint(value = "/websocket/app/edit", encoders = TeamworkEditEncoder.class, decoders = TeamworkEditDecoder.class, subprotocols = {"sec-webSocket-protocol"})
  17. @Component
  18. public class WebSocketServer {
  19. /** 记录当前在线连接数 */
  20. private static final AtomicInteger onlineCount = new AtomicInteger(0);
  21. /** 存放所有在线的客户端 */
  22. // private static final Map clients = new ConcurrentHashMap<>();
  23. private static CopyOnWriteArraySet webSocketSet = new CopyOnWriteArraySet<>();
  24. private Session session;
  25. @OnOpen
  26. public void onOpen(Session session) throws IOException, EncodeException {
  27. onlineCount.incrementAndGet();
  28. // clients.put(session.getId(),session);
  29. this.session = session;
  30. webSocketSet.add(this);
  31. log.info("有新连接加入:{},当前在线人数为:{}", session.getId(), onlineCount.get());
  32. }
  33. @OnClose
  34. public void onClose(Session session) {
  35. onlineCount.decrementAndGet();
  36. webSocketSet.remove(this);
  37. // clients.remove(session.getId());
  38. log.info("有一连接关闭:{},当前在线人数为:{}", session.getId(), onlineCount.get());
  39. }
  40. @OnMessage
  41. public void onMessage(TeamworkEditMessage message, Session session) throws IOException, EncodeException {
  42. //TODO 参数校验
  43. log.info("服务端收到客户端【{}】的消息:{}", session.getId(), message.toString());
  44. TeamworkEditEvent teamworkEditEvent = message.getTeamworkEditEvent();
  45. if (teamworkEditEvent == null) {
  46. log.info("teamworkEditEvent 为空!");
  47. return;
  48. }
  49. log.info("处理客户端的请求:{}",teamworkEditEvent.getName());
  50. this.handleEvent(message, session);
  51. }
  52. @OnError
  53. public void onError(Session session, Throwable error) {
  54. log.error("发生错误");
  55. error.printStackTrace();
  56. }
  57. /**
  58. * 群发消息
  59. * @param message
  60. * 消息内容
  61. */
  62. public void sendMessage(TeamworkEditMessage message) throws IOException, EncodeException {
  63. for (WebSocketServer webSocketServer : webSocketSet) {
  64. Session toSession = webSocketServer.session;
  65. synchronized (toSession){
  66. toSession.getBasicRemote().sendObject(message);
  67. }
  68. }
  69. /*for (Map.Entry sessionEntry : clients.entrySet()) {
  70. Session toSession = sessionEntry.getValue();
  71. synchronized(toSession){
  72. toSession.getBasicRemote().sendObject(message);
  73. }
  74. }*/
  75. log.info("服务端给客户端群发发送消息{}",message.toString());
  76. }
  77. /**
  78. * 对特定客户端发送消息
  79. * @param message
  80. * @param toSession
  81. */
  82. public void sendMessageToOne(TeamworkEditMessage message, Session toSession) throws IOException, EncodeException {
  83. log.info("服务端给指定客户端【{}】 发送消息{}",toSession.getId(),message.toString());
  84. synchronized(toSession){
  85. toSession.getBasicRemote().sendObject(message);
  86. }
  87. }
  88. /**
  89. * 处理客户请求
  90. * @param message
  91. */
  92. private void handleEvent(TeamworkEditMessage message, Session session) throws IOException, EncodeException {
  93. TeamworkEditEvent teamworkEditEvent = message.getTeamworkEditEvent();
  94. String editUserId = message.getEditUserId();
  95. String editUserName = message.getEditUserName();
  96. log.info("【websocket】LoginId:{},LoginName:{}",editUserId,editUserName);
  97. if (teamworkEditEvent.getIndex() != 0){
  98. if (editUserId == null || editUserName == null) {
  99. message.setRemark("editUserId 和 editUserName 不能为空");
  100. this.sendMessageToOne(message,session);
  101. return;
  102. }
  103. }else {
  104. editUserId = "001";
  105. editUserName = "heartbeat";
  106. }
  107. switch (teamworkEditEvent.getIndex()){
  108. case 0:
  109. log.info("WebSocket 心跳检测");
  110. this.sendMessageToOne(message,session);
  111. break;
  112. default:
  113. log.info("TeamworkEditEvent事件类型:{} 不存在!",teamworkEditEvent.getName());
  114. }
  115. }
  116. }

WebSocketConfig配置类同上 

 03.分析调用链路

首先启动 nginx:80,启动gateway:8080,启动app-metadata服务(websocketServer)

请求的调用链路:由于网关做了请求前缀限制,必须已 /api 开头,所以在配置nginx升级时,是在 location /api/ { } 进行配置。然后在gateway配置路由时
          uri: lb:ws://app-metadata
          predicates: Path=/api/web/**
          filters: StripPrefix=2

app-metadata是websocketServer服务,lb表示负载均衡,ws表示websocket请求。StripPrefix=2表示请求到app-metadata服务时,过滤掉/api/web/前缀。

实际前端请求地址为(80可省略):ws://localhost/api/web/websocket/app/edit

 

五、注意事项

1.检查网关请求是否有前缀配置

  1. spring:
  2. webflux:
  3. base-path: /api

2.检查网关是否做了黑白名单控制

3.在WebsocketServer类里,无法直接注入Bean。可通过编写ApplicationContextUtil来获取Ioc容器里的Bean

  1. import org.springframework.beans.BeansException;
  2. import org.springframework.context.ApplicationContext;
  3. import org.springframework.context.ApplicationContextAware;
  4. import org.springframework.stereotype.Component;
  5. /**
  6. * @author luoyj
  7. * @date 2021/3/17.
  8. * @description
  9. */
  10. @Component
  11. public class ApplicationContextUtil implements ApplicationContextAware {
  12. private static ApplicationContext applicationContext;
  13. @Override
  14. public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
  15. ApplicationContextUtil.applicationContext = applicationContext;
  16. }
  17. public static Object getBean(String name) throws BeansException {
  18. return applicationContext.getBean(name);
  19. }
  20. public static T getBean(Class clazz) throws BeansException {
  21. return applicationContext.getBean(clazz);
  22. }
  23. public static ApplicationContext getApplicationContext(){
  24. return applicationContext;
  25. }
  26. }

 

六、参考链接

http://iyenn.com/rec/1676482.html
https://www.jianshu.com/p/cfe3dbda9023
http://iyenn.com/rec/1676483.html
https://www.cnblogs.com/kiwifly/p/11729304.html
https://www.cnblogs.com/zhongjidoushi/p/13367144.html
https://www.cnblogs.com/zhangXingSheng/p/11969633.html
https://www.cnblogs.com/xuwenjin/p/12664650.html

 

 

文章知识点与官方知识档案匹配,可进一步学习相关知识
网络技能树首页概览45702 人正在系统学习中
注:本文转载自blog.csdn.net的新西雪的文章"https://blog.csdn.net/YonJarLuo/article/details/117957845"。版权归原作者所有,此博客不拥有其著作权,亦不承担相应法律责任。如有侵权,请联系我们删除。
复制链接
复制链接
相关推荐
发表评论
登录后才能发表评论和回复 注册

/ 登录

评论记录:

未查询到任何数据!
回复评论:

分类栏目

后端 (14832) 前端 (14280) 移动开发 (3760) 编程语言 (3851) Java (3904) Python (3298) 人工智能 (10119) AIGC (2810) 大数据 (3499) 数据库 (3945) 数据结构与算法 (3757) 音视频 (2669) 云原生 (3145) 云平台 (2965) 前沿技术 (2993) 开源 (2160) 小程序 (2860) 运维 (2533) 服务器 (2698) 操作系统 (2325) 硬件开发 (2492) 嵌入式 (2955) 微软技术 (2769) 软件工程 (2056) 测试 (2865) 网络空间安全 (2948) 网络与通信 (2797) 用户体验设计 (2592) 学习和成长 (2593) 搜索 (2744) 开发工具 (7108) 游戏 (2829) HarmonyOS (2935) 区块链 (2782) 数学 (3112) 3C硬件 (2759) 资讯 (2909) Android (4709) iOS (1850) 代码人生 (3043) 阅读 (2841)

热门文章

101
推荐
关于我们 隐私政策 免责声明 联系我们
Copyright © 2020-2024 蚁人论坛 (iYenn.com) All Rights Reserved.
Scroll to Top