html5 之 websocket

websocket概述

  • 原理简介
        WebSocket protocol 是HTML5一种新的协议。它与HTTP协议一样是基于应用层的协议。它实现了浏览器与服务器全双工通信(full-duplex)。但它实际上是基于TCP协议,它与HTTP协议的关系 WebSocket 的握手被http 服务器当做 Upgrade request http包处理,服务器响应过后协议升级为TCP连接,连接建立后 client 发送websocket 握手请求.目前 在JavaEE7中也实现了WebSocket协议的支持。
        websocket api在浏览器端的广泛实现似乎只是一个时间问题了, 值得注意的是服务器端没有标准的api, 各个实现都有自己的一套api, 并且jcp也没有类似的提案, 所以使用websocket开发服务器端有一定的风险.可能会被锁定在某个平台上或者将来被迫升级.

  • 使用场景
         现很多网站为了实现即时通讯,所用的技术都是轮询(polling)。这样浏览器需要不断的向服务器发出请求,然而HTTP request 的header是非常长的,里面包含的有用数据可能只是一个很小的值,这样会占用很多的带宽。WebSocket 协议中,在以下场景中会给我们带来很大便利:

  1. 实时通讯
  2. 服务器主动向所有客户端进行消息推送
  3. 以及越来越火的基于H5手机APP的聊天室以及消息推送

  • 与传统socket的区别
      Socket 其实并不是一个协议。它工作在 OSI 模型会话层(第5层),是为了方便大家直接使用更底层协议(一般是 TCP 或 UDP )而存在的一个抽象层。而且而且它大都在java或者C++这类的编程语言中实现,而在浏览器的基于javascript之类的脚本解释型语言中没有实现。而websocket协议大多数浏览器都已经支持,可以让我们在浏览器中像socket通信一样的去使用TCP直接通信。
  • 支持websocket协议的服务器
在服务器端,也出现了一些实现websocket协议的项目:
jetty 7.0.1 包含了一个初步的实现
resin 包含有websocket 实现
pywebsocket, apache http server 扩展
apache tomcat 7.0.27 版本
Nginx 1.3.13 版本
jWebSocket java实现版
nodejs 利用谷歌V8的浏览器引擎也支持websocket
  • 支持websocket协议的浏览器

Chrome
Supported in version 4+
Firefox
Supported in version 4+
Internet Explorer
Supported in version 10+
Opera
Supported in version 10+
Safari
Supported in version 5+

  • 一些基于websocket的框架
  1. 基于 C 的 libwebsocket.org
  2. 基于 Node.js 的 Socket.io  它包含nodeJS 服务器端的实现和浏览器端的实现。
  3. 基于 Python 的 ws4py
  4. 基于 C++ 的 WebSocket++
  5. Apache 对 WebSocket 的支持: Apache Module mod_proxy_wstunnel
  6. Nginx 对 WebSockets 的支持: NGINX as a WebSockets Proxy 、 NGINX Announces Support for WebSocket Protocol 、WebSocket proxying
  7. lighttpd 对 WebSocket 的支持:mod_websocket
  8. 基于java的实现 服务端tomcat7和浏览器端 socketJS

  • websocket实例
  • websocket数据包协议详解

WebSocket协议主要分为两部分,第一部分是连接许可验证和验证后的数据交互.连接许可验证比较简单,由Client发送一个类似于HTTP的请求,服务端获取请求后根据请求的KEY生成对应的值并返回.

连接请求内容:

1
2
3
4
5
6
7
8
GET / HTTP/1.1
Connection:Upgrade
Host:127.0.0.1:8088
Origin: null
Sec-WebSocket-Extensions:x-webkit-deflate-frame
Sec-WebSocket-Key:puVOuWb7rel6z2AVZBKnfw==
Sec-WebSocket-Version:13
Upgrade:websocket

服务端接收请求后主要是成针对Sec-WebSocket-Key生成对就Sec-WebSocket-Accept 的key,生成Sec-WebSocket-Accept 值比较简单就是Sha1(Sec-WebSocket-Key+258EAFA5-E914-47DA-95CA-C5AB0DC85B11)即可,C#代码如下:

1
2
3
4
5
SHA1 sha1 = new  SHA1CryptoServiceProvider();
byte [] bytes_sha1_in = Encoding.UTF8.GetBytes(request.SecWebSocketKey+ "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" );
byte [] bytes_sha1_out = sha1.ComputeHash(bytes_sha1_in);
string  str_sha1_out = Convert.ToBase64String(bytes_sha1_out);
response.SecWebSocketAccept = str_sha1_out;

服务端返回内容:

1
2
3
4
5
6
7
8
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Server:beetle websocket server
Upgrade:WebSocket
Date:Mon, 26 Nov 2012 23:42:44 GMT
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers:content-type
Sec-WebSocket-Accept:FCKgUr8c7OsDsLFeJTWrJw6WO8Q= 

经过服务器的返回处理后连接握手成功,后面就可以进行TCP通讯.WebSocket在握手后发送数据并象下层TCP协议那样由用户自定义,还是需要遵循对应的应用协议规范...这也是在文章之说没有直接基于Socket tcp方便的原因. 

 数据交互协议:

这图有点难看懂...里面包括几种情况有掩码,数据长度小于126,小于UINT16和小于UINT64等几种情况.后面会慢慢详细说明.整个协议头大概分三部分组成,第一部分是描述消息结束情况和类型,第二部分是描述是否存在掩码长度,第三部分是扩展长度描述和掩码值.

从图中可以看到WebSocket协议数据主要通过头两个字节来描述数据包的情况

第一个字节

最高位用于描述消息是否结束,如果为1则该消息为消息尾部,如果为零则还有后续数据包;后面3位是用于扩展定义的,如果没有扩展约定的情况则必须为0.可以通过以下c#代码方式得到相应值

1
mDataPackage.IsEof = (data[start] >> 7) > 0;

最低4位用于描述消息类型,消息类型暂定有15种,其中有几种是预留设置.c#代码可以这样得到消息类型:

1
2
int  type = data[start] & 0xF;
mDataPackage.Type = (PackageType)type;

第二个字节

消息的第二个字节主要用一描述掩码和消息长度,最高位用0或1来描述是否有掩码处理,可以通过以下c#代码方式得到相应值

1
bool  hasMask = (data[start] >>7) > 0;

剩下的后面7位用来描述消息长度,由于7位最多只能描述127所以这个值会代表三种情况,一种是消息内容少于126存储消息长度,如果消息长度少于UINT16的情况此值为126,当消息长度大于UINT16的情况下此值为127;这两种情况的消息长度存储到紧随后面的byte[],分别是UINT16(2位byte)和UINT64(4位byte).可以通过以下c#代码方式得到相应值

1
2
3
4
5
6
7
8
9
10
11
12
mPackageLength = ( uint )(data[start] & 0x7F);
start++;
if  (mPackageLength == 126)
{
     mPackageLength = BitConverter.ToUInt16(data, start);
     start = start + 2;
}
else  if  (mPackageLength == 127)
{
     mPackageLength = BitConverter.ToUInt64(data, start);
     start = start + 8;
}

如果存在掩码的情况下获取4位掩码值:

1
2
3
4
5
6
7
8
if  (hasMask)
{
     mDataPackage.Masking_key = new  byte [4];
     Buffer.BlockCopy(data, start, mDataPackage.Masking_key, 0, 4);
           
     start = start + 4;
     count = count - 4;
}

获取消息体

当得到消息体长度后就可以获取对应长度的byte[],有些消息类型是没有长度的如%x8 denotes a connection close.对于Text类型的消息对应的byte[]是相应字符的UTF8编码.获取消息体还有一个需要注意的地方就是掩码,如果存在掩码的情况下接收的byte[]要做如下转换处理:

1
2
3
4
5
6
if  (mDataPackage.Masking_key != null )
     {
         int  length = mDataPackage.Data.Count;
         for  ( var  i = 0; i < length; i++)
             mDataPackage.Data.Array[i] = ( byte )(mDataPackage.Data.Array[i] ^ mDataPackage.Masking_key[i % 4]);
     }

  • javaEE7、Websockets实例

1. 介绍

HTML5给Web浏览器带来了全双工TCP连接websocket标准服务器的能力。

换句话说,浏览器能够与服务器建立连接,通过已建立的通信信道来发送和接收数据而不需要由HTTP协议引入额外其他的开销来实现。

在本教程中我们将在Java EE环境下实现一个简单的websockect服务器端来和客户端进行数据交互。

本教程需要以下环境:

  1. Ubuntu 12.04
  2. JDK 1.7.0.21
  3. Glassfish 4.0
: Java EE 7中才引入了WebSocket。


 

2. WebSocket服务器端

让我们定义一个 Java EE websocket服务器端:

WebSocketTest.java

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.byteslounge.websockets;
 
import java.io.IOException;
 
import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
 
@ServerEndpoint ( "/websocket" )
public class WebSocketTest {
 
   @OnMessage
   public void onMessage(String message, Session session)
     throws IOException, InterruptedException {
   
     // Print the client message for testing purposes
     System.out.println( "Received: " + message);
   
     // Send the first message to the client
     session.getBasicRemote().sendText( "This is the first server message" );
   
     // Send 3 messages to the client every 5 seconds
     int sentMessages =  0 ;
     while (sentMessages <  3 ){
       Thread.sleep( 5000 );
       session.getBasicRemote().
         sendText( "This is an intermediate server message. Count: "
           + sentMessages);
       sentMessages++;
     }
   
     // Send a final message to the client
     session.getBasicRemote().sendText( "This is the last server message" );
   }
   
   @OnOpen
   public void onOpen() {
     System.out.println( "Client connected" );
   }
 
   @OnClose
   public void onClose() {
     System.out.println( "Connection closed" );
   }
}
你可能已经注意到我们从  javax.websocket包中引入了一些类。

@ServerEndpoint 注解是一个类层次的注解,它的功能主要是将目前的类定义成一个websocket服务器端。注解的值将被用于监听用户连接的终端访问URL地址。

onOpen 和 onClose 方法分别被@OnOpen@OnClose 所注解。这两个注解的作用不言自明:他们定义了当一个新用户连接和断开的时候所调用的方法。

onMessage 方法被@OnMessage所注解。这个注解定义了当服务器接收到客户端发送的消息时所调用的方法。注意:这个方法可能包含一个javax.websocket.Session可选参数(在我们的例子里就是session参数)。如果有这个参数,容器将会把当前发送消息客户端的连接Session注入进去。

本例中我们仅仅是将客户端消息内容打印出来,然后首先我们将发送一条开始消息,之后间隔5秒向客户端发送1条测试消息,共发送3次,最后向客户端发送最后一条结束消息。

 

3. 客户端

现在我们要来写websocket测试应用的客户端:

page.html

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!DOCTYPE html>
<html>
<head>
<title>Testing websockets</title>
</head>
<body>
   <div>
     <input type= "submit" value= "Start" onclick= "start()" />
   </div>
   <div id= "messages" ></div>
   <script type= "text/javascript" >
     var webSocket =
       new WebSocket( 'ws://localhost:8080/byteslounge/websocket' );
 
     webSocket.onerror =  function (event) {
       onError(event)
     };
 
     webSocket.onopen =  function (event) {
       onOpen(event)
     };
 
     webSocket.onmessage =  function (event) {
       onMessage(event)
     };
 
     function onMessage(event) {
       document.getElementById( 'messages' ).innerHTML
         +=  '<br />' + event.data;
     }
 
     function onOpen(event) {
       document.getElementById( 'messages' ).innerHTML
         'Connection established' ;
     }
 
     function onError(event) {
       alert(event.data);
     }
 
     function start() {
       webSocket.send( 'hello' );
       return false ;
     }
   </script>
</body>
</html>

这是一个简单的页面,包含有JavaScript代码,这些代码创建了一个websocket连接到websocket服务器端。

onOpen 我们创建一个连接到服务器的连接时将会调用此方法。

onError 当客户端-服务器通信发生错误时将会调用此方法。

onMessage 当从服务器接收到一个消息时将会调用此方法。在我们的例子中,我们只是将从服务器获得的消息添加到DOM。

我们连接到websocket 服务器端,使用构造函数 new WebSocket() 而且传之以端点URL:

ws://localhost:8080/byteslounge/websocket

 

4. 测试

现在我们可以访问测试页面对我们的应用进行测试:

http://localhost:8080/byteslounge/page.html

正如所期望的,我们将看到 Connection established 消息:

http://localhost:8080/byteslounge/page.html

现在只要我们一按按钮,就会通过此websocket发送初始化报文给服务器,而且接下来会收到发自服务器的测试消息:

服务器发送、客户端接收的消息


5. WebSockets 握手

客户端和服务器端TCP连接建立在HTTP协议握手发生之后。通过HTTP流量调试,很容易观察到握手。客户端一创建一个 WebSocket实例,就会出现如下请求和服务器端响应: 

注意: 我们只录入了WebSocket握手所用到的HTTP头。

请求:

GET /byteslounge/websocket HTTP/1.1 
Connection: Upgrade 
Upgrade: websocket 
Sec-WebSocket-Key: wVlUJ/tu9g6EBZEh51iDvQ==

响应:

HTTP/1.1 101 Web Socket Protocol Handshake 
Upgrade: websocket 
Sec-WebSocket-Accept: 2TNh+0h5gTX019lci6mnvS66PSY=

注意:进行连接需要将通过Upgrade and Upgrade将协议升级到支持websocket HTTP头的Websocket协议。服务器响应表明请求被接受,协议将转换到WebSocket协议(HTTP状态码101):

HTTP/1.1 101 Web Socket Protocol Handshake

6. 下载源码

在本页的末尾有范例源代码下载链接,源码在Glassfish 4(需要兼容Java EE 7的服务器)上通过测试。

  • Spring+Websocket实现消息的推送

Websocet服务端实现

WebSocketConfig.java

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
@EnableWebMvc
@EnableWebSocket
public class WebSocketConfig extends WebMvcConfigurerAdapter implements WebSocketConfigurer {
     @Override
     public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
         registry.addHandler(systemWebSocketHandler(), "/webSocketServer" ).addInterceptors( new WebSocketHandshakeInterceptor());
 
         registry.addHandler(systemWebSocketHandler(), "/sockjs/webSocketServer" ).addInterceptors( new WebSocketHandshakeInterceptor())
                 .withSockJS();
     }
 
     @Bean
     public WebSocketHandler systemWebSocketHandler(){
         return new SystemWebSocketHandler();
     }
 
}
不要忘记在springmvc的配置文件中配置对此类的自动扫描

?
1
< context:component-scan base-package = "com.ldl.origami.websocket" />

@Configuration

@EnableWebMvc
@EnableWebSocket
这三个大致意思是使这个类支持以@Bean的方式加载bean,并且支持springmvc和websocket,不是很准确大致这样,试了一下@EnableWebMvc不加也没什么影响,@Configuration本来就支持springmvc的自动扫描


?
1
registry.addHandler(systemWebSocketHandler(), "/webSocketServer" ).addInterceptors( new WebSocketHandshakeInterceptor())
用来注册websocket server实现类,第二个参数是访问websocket的地址



?
1
2
3
registry.addHandler(systemWebSocketHandler(), "/sockjs/webSocketServer" ).addInterceptors( new WebSocketHandshakeInterceptor())
                 .withSockJS();
     }
这个是使用Sockjs的注册方法


首先SystemWebSocketHandler.java


?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
public class SystemWebSocketHandler implements WebSocketHandler {
 
     private static final Logger logger;
 
     private static final ArrayList<WebSocketSession> users;
 
     static {
         users = new ArrayList<>();
         logger = LoggerFactory.getLogger(SystemWebSocketHandler. class );
     }
 
     @Autowired
     private WebSocketService webSocketService;
 
     @Override
     public void afterConnectionEstablished(WebSocketSession session) throws Exception {
         logger.debug( "connect to the websocket success......" );
         users.add(session);
         String userName = (String) session.getAttributes().get(Constants.WEBSOCKET_USERNAME);
         if (userName!= null ){
             //查询未读消息
             int count = webSocketService.getUnReadNews((String) session.getAttributes().get(Constants.WEBSOCKET_USERNAME));
 
             session.sendMessage( new TextMessage(count + "" ));
         }
     }
 
     @Override
     public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
 
         //sendMessageToUsers();
     }
 
     @Override
     public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
         if (session.isOpen()){
             session.close();
         }
         logger.debug( "websocket connection closed......" );
         users.remove(session);
     }
 
     @Override
     public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
         logger.debug( "websocket connection closed......" );
         users.remove(session);
     }
 
     @Override
     public boolean supportsPartialMessages() {
         return false ;
     }
 
     /**
      * 给所有在线用户发送消息
      *
      * @param message
      */
     public void sendMessageToUsers(TextMessage message) {
         for (WebSocketSession user : users) {
             try {
                 if (user.isOpen()) {
                     user.sendMessage(message);
                 }
             } catch (IOException e) {
                 e.printStackTrace();
             }
         }
     }
 
     /**
      * 给某个用户发送消息
      *
      * @param userName
      * @param message
      */
     public void sendMessageToUser(String userName, TextMessage message) {
         for (WebSocketSession user : users) {
             if (user.getAttributes().get(Constants.WEBSOCKET_USERNAME).equals(userName)) {
                 try {
                     if (user.isOpen()) {
                         user.sendMessage(message);
                     }
                 } catch (IOException e) {
                     e.printStackTrace();
                 }
                 break ;
             }
         }
     }
}



相关内容大家一看就能明白,就不多解释了


然后WebSocketHandshakeInterceptor.java


?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class WebSocketHandshakeInterceptor implements HandshakeInterceptor {
 
     private static Logger logger = LoggerFactory.getLogger(HandshakeInterceptor. class );
     @Override
     public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object
                 > attributes) throws Exception {
         if (request instanceof ServletServerHttpRequest) {
             ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
             HttpSession session = servletRequest.getServletRequest().getSession( false );
             if (session != null ) {
                 //使用userName区分WebSocketHandler,以便定向发送消息
                 String userName = (String) session.getAttribute(Constants.SESSION_USERNAME);
                 attributes.put(Constants.WEBSOCKET_USERNAME,userName);
             }
         }
         return true ;
     }
 
     @Override
     public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
 
     }
}

这个的主要作用是取得当前请求中的用户名,并且保存到当前的WebSocketHandler中,以便确定WebSocketHandler所对应的用户,具体可参考HttpSessionHandshakeInterceptor

用户登录建立websocket连接

index.jsp

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script type= "text/javascript" src= "http://localhost:8080/Origami/websocket/sockjs-0.3.min.js" ></script>
         <script>
             var websocket;
             if ( 'WebSocket' in window) {
                 websocket = new WebSocket( "ws://localhost:8080/Origami/webSocketServer" );
             } else if ( 'MozWebSocket' in window) {
                 websocket = new MozWebSocket( "ws://localhost:8080/Origami/webSocketServer" );
             } else {
                 websocket = new SockJS( "http://localhost:8080/Origami/sockjs/webSocketServer" );
             }
             websocket.onopen = function (evnt) {
             };
             websocket.onmessage = function (evnt) {
                 $( "#msgcount" ).html( "(<font color='red'>" +evnt.data+ "</font>)" )
             };
             websocket.onerror = function (evnt) {
             };
             websocket.onclose = function (evnt) {
             }
 
         </script>


使用sockjs时要注意

1、这两个的写法

?
1
<script type= "text/javascript" src= "http://localhost:8080/Origami/websocket/sockjs-0.3.min.js" ></script>
?
1
websocket = new SockJS( "http://localhost:8080/Origami/sockjs/webSocketServer" );
2、web.xml中
?
1
2
3
< web-app version = "3.0" xmlns = "http://java.sun.com/xml/ns/javaee"
     xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
     xsi:schemaLocation = "http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_1.xsd" >
?
1
version
?
1
web-app_3_1.xsd
这两个的版本都要是3.0+


然后在这个servlet中加入

?
1
< async-supported >true</ async-supported >


?
1
2
3
4
5
6
7
8
9
10
< servlet >
         < servlet-name >appServlet</ servlet-name >
         < servlet-class >org.springframework.web.servlet.DispatcherServlet</ servlet-class >
         < init-param >
             < param-name >contextConfigLocation</ param-name >
             < param-value >classpath*:servlet-context.xml</ param-value >
         </ init-param >
         < load-on-startup >1</ load-on-startup >
         < async-supported >true</ async-supported >
     </ servlet >

然后所有的filter中也加入

?
1
< async-supported >true</ async-supported >


3、添加相关依赖

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
< dependency >
             < groupId >com.fasterxml.jackson.core</ groupId >
             < artifactId >jackson-annotations</ artifactId >
             < version >2.3.0</ version >
         </ dependency >
         < dependency >
             < groupId >com.fasterxml.jackson.core</ groupId >
             < artifactId >jackson-core</ artifactId >
             < version >2.3.1</ version >
         </ dependency >
         < dependency >
             < groupId >com.fasterxml.jackson.core</ groupId >
             < artifactId >jackson-databind</ artifactId >
             < version >2.3.3</ version >
         </ dependency >
好了,现在websocket可以正常建立起来了



返回用户未读的消息

当连接建立后,会进入SystemWebSocketHandler的afterConnectionEstablished方法,代码看上边,取出WebSocketHandshakeInterceptor中保存的用户名

查询信息后使用session.sendMessage(new TextMessage(count + ""));返回给用户,从哪来回哪去


服务端推送消息给用户

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Controller
public class AdminController {
 
     static Logger logger = LoggerFactory.getLogger(AdminController. class );
 
     @Autowired (required = false )
     private AdminService adminService;
 
     @Bean
     public SystemWebSocketHandler systemWebSocketHandler() {
         return new SystemWebSocketHandler();
     }
 
 
     @RequestMapping ( "/auditing" )
     @ResponseBody
     public String auditing(HttpServletRequest request){
         //无关代码都省略了
         int unReadNewsCount = adminService.getUnReadNews(username);
         systemWebSocketHandler().sendMessageToUser(username, new TextMessage(unReadNewsCount + "" ));
         return result;
     }
}

在这里可以使用sendMessageToUser给某个用户推送信息,也可以使用sendMessageToUsers给所有用户推送信息


  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值