在web开发时经常会遇到实时请求数据的需求,比如聊天功能、投票功能、抢购功能等,通过哪些技术可以实现这些功能呢,这里给出三种常用的技术,分别是轮询,长轮询和基于WebSock协议来实现,本文以基于Flask框架的开发的一个简单的投票功能来演示这三种技术是如何实现投票和实时更新投票信息的。
轮询
轮询其实就是客户端定时去请求服务端, 是客户端主动请求来促使数据更新,如何实现的,例子如下;
服务端:
from flask import Flask,render_template,request,jsonify app = Flask(__name__) USERS = { '1':{'name':'刘能','count':1}, '2':{'name':'赵四','count':0}, '3':{'name':'广坤','count':0}, } @app.route('/user/list') def user_list(): import time return render_template('user_list.html',users=USERS) @app.route('/vote',methods=['POST']) def vote(): uid = request.form.get('uid') USERS[uid]['count'] += 1 return "投票成功" @app.route('/get/vote',methods=['GET']) def get_vote(): return jsonify(USERS) if __name__ == '__main__': # app.run(host='192.168.13.253',threaded=True) app.run(threaded=True)
客户端:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Title</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <style> li{ cursor: pointer; } </style> </head> <body> <ul id="userList"> {% for key,val in users.items() %} <li uid="{{key}}">{{val.name}} ({{val.count}})</li> {% endfor %} </ul> <script src="https://cdn.bootcss.com/jquery/3.3.0/jquery.min.js"></script> <script> $(function () { $('#userList').on('dblclick','li',function () { var uid = $(this).attr('uid'); $.ajax({ url:'/vote', type:'POST', data:{uid:uid}, success:function (arg) { console.log(arg); } }) }); });
/* 获取投票信息 */ function get_vote() { $.ajax({ url:'/get/vote', type:"GET", dataType:'JSON', success:function (arg) { $('#userList').empty(); $.each(arg,function (k,v) { var li = document.createElement('li'); li.setAttribute('uid',k); li.innerText = v.name + "(" + v.count + ')' ; $('#userList').append(li); }) } }) } setInterval(get_vote,3000); </script> </body> </html>
从上面可以看出来轮询主要有两个缺点:(1)大量耗费服务器内存和宽带资源,因为不停的请求服务器,很多时候 并没有新的数据更新,因此绝大部分请求都是无效请求;(2)数据不一定是实时更新,要看设定的请求间隔,基本会有延迟,如何解决这两个问题呢,长轮询就能够解决这两个问题。
长轮询
长轮询其实 也是客户端请求服务端,但是服务端并不是即时返回,而是当有内容更新的时候才返回内容给客户端,从流程上讲,可以理解为服务器向客户端推送内容;
服务端:
from flask import Flask,render_template,request,jsonify,session import uuid import queue app = Flask(__name__) app.secret_key = 'asdfasdfasd' USERS = { '1':{'name':'刘能','count':1}, '2':{'name':'赵四','count':0}, '3':{'name':'广坤','count':0}, } QUEQUE_DICT = { # 'asdfasdfasdfasdf':Queue() } @app.route('/user/list') def user_list(): user_uuid = str(uuid.uuid4()) QUEQUE_DICT[user_uuid] = queue.Queue() session['current_user_uuid'] = user_uuid return render_template('user_list.html',users=USERS) @app.route('/vote',methods=['POST']) def vote(): uid = request.form.get('uid') USERS[uid]['count'] += 1 for q in QUEQUE_DICT.values(): q.put(USERS) return "投票成功" @app.route('/get/vote',methods=['GET']) def get_vote(): user_uuid = session['current_user_uuid'] q = QUEQUE_DICT[user_uuid] ret = {'status':True,'data':None} try: users = q.get(timeout=5) # 没有新的数据进来时,最多夯住请求5秒时间,时间可以自己定 ret['data'] = users except queue.Empty: ret['status'] = False return jsonify(ret) if __name__ == '__main__': app.run(host='192.168.13.253',threaded=True) # app.run(threaded=True)
客户端:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Title</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <style> li{ cursor: pointer; } </style> </head> <body> <ul id="userList"> {% for key,val in users.items() %} <li uid="{{key}}">{{val.name}} ({{val.count}})</li> {% endfor %} </ul> <script src="https://cdn.bootcss.com/jquery/3.3.0/jquery.min.js"></script> <script> $(function () { $('#userList').on('click','li',function () { var uid = $(this).attr('uid'); $.ajax({ url:'/vote', type:'POST', data:{uid:uid}, success:function (arg) { console.log(arg); } }) }); get_vote(); }); /* 获取投票信息 */ function get_vote() { $.ajax({ url:'/get/vote', type:"GET", dataType:'JSON', success:function (arg) { if(arg.status){ // 获取到了新的数据,改变网页展示的数据 $('#userList').empty(); $.each(arg.data,function (k,v) { var li = document.createElement('li'); li.setAttribute('uid',k); li.innerText = v.name + "(" + v.count + ')' ; $('#userList').append(li); }) } get_vote(); // 不管获取到了还是没有获取到新的数据,都会再次发一个请求 } }) } </script> </body> </html>
这里的长轮询解决了轮询中的两个问题,使实时请求数据的技术前进了一大步;但是本身在hold住请求的时候也会消耗一些服务端的资源,并且不能做到真正的前后端连接不断开,只是断开的时间非常短,肉眼无法看到而已,那么如何做到比较完美一点的连接呢,WebSocket就能实现不断开连接。
WebSocket
WebSocket协议是基于TCP的一种新的协议,同时也是在http请求的基础上发展的。WebSocket最初在HTML5规范中被引用为TCP连接,作为基于TCP的套接字API的占位符。它实现了浏览器与服务器全双工(full-duplex)通信。其本质是保持TCP连接,在浏览器和服务端通过Socket进行通信。
下面将使用Python编写Socket服务端,一步一步分析请求过程!!!
websocket 其实就是 web socket,它也是一种协议
http 的问题:
1. http 是一个协议 - 数据格式 - 一次请求和响应之后断开连接(无状态、短连接) 2. 服务端不可以向客户端主动推送消息(因为不知道客户端的IP端口) 3. 服务端只能做出响应 4. 为了伪造服务端向客户端主动推送消息的效果,我们使用:轮询和长轮询 websocket 是一种新的协议: 1. 连接时需要握手;2.发送数据进行加密;3.连接之后不断开; websocket 解决了 服务端能够真正向客户端推送消息;缺点:兼容性 - 数据格式: - 连接请求: http协议 - 收发请求: websocket协议 - 不断开连接 基于 flask的websocket示例: # 安装: pip install gevent-websocket
支持 websocket 的框架:
所有框架都支持,但是,
flask : gevent-websocket
django:channel
tornado 框架自带 websocket
应用场景:实时响应
服务端:
from flask import Flask,render_template,request
import json # 使用 websocket 时 需要导入下面的两个模块 from geventwebsocket.handler import WebSocketHandler from gevent.pywsgi import WSGIServer # 发送 websocket 请求得通过 js 来实现 app = Flask(__name__) USERS = { '1':{'name':'刘能','count':0}, '2':{'name':'赵四','count':0}, '3':{'name':'广坤','count':100}, } # http://127.0.0.1:5000/index # 浏览器在渲染 index.html 时,在执行 var ws = new WebSocket('ws://192.168.13.253:5000/message') 这句js代码时,客户端就会和服务端建立一个 websocket 的连接 @app.route('/index') def index(): return render_template('index.html',users=USERS) # http://127.0.0.1:5000/message WEBSOCKET_LIST = [] # 用于保存所有投票人员的 websocket 连接;可理解为所有的客户端 @app.route('/message') def message(): ws = request.environ.get('wsgi.websocket') # 如果不是 websocket 协议的请求,request.environ.get('wsgi.websocket') 的值是 None;如果是 websocket 请求, ws 将是一个 websocket 对象 if not ws: print('http') return '您使用的是Http协议' WEBSOCKET_LIST.append(ws) # 把当前的 ws 连接添加到 WEBSOCKET_LIST 中 while True: cid = ws.receive() # 接收投票的 cid; ws.receive() :接收客户端发过来的 websocket协议的 数据(有数据就往下走,没有请求数据时就在这里夯住) if not cid: # 如果客户端关闭连接 ws.receive() 接收到的将会是 None WEBSOCKET_LIST.remove(ws) ws.close() # 后台的 ws 也关闭 break old = USERS[cid]['count'] new = old + 1 USERS[cid]['count'] = new for client in WEBSOCKET_LIST: client.send(json.dumps({'cid':cid,'count':new})) # 给前端返回一个字典(数据会发给还在列表中的每一个投票人员) # 服务端通过 websocket 协议就可以向客户端主动推送消息;这是由于 客户端与服务端的连接没有断开; 需要事先在客户端的浏览器上 new 一个 WebSocket 的对象 (WebSocket 支持H5等,但低版本的浏览器不支持) if __name__ == '__main__': http_server = WSGIServer(('0.0.0.0', 5000), app, handler_class=WebSocketHandler) # 如果是 http 协议的请求,就交给 app 去处理;如果是 websocket 协议的请求,就交给 WebSocketHandler 去处理。 # app 的 werkzeug 只能处理 http 请求 http_server.serve_forever()
客户端:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Title</title> <meta name="viewport" content="width=device-width, initial-scale=1"> </head> <body> <h1>丑男投票系统</h1> <ul> {% for k,v in users.items() %} <li onclick="vote({{k}})" id="id_{{k}}">{{v.name}}<span>{{v.count}}</span></li> {% endfor %} </ul> <script src="https://cdn.bootcss.com/jquery/3.3.0/jquery.min.js"></script>
<script> var ws = new WebSocket('ws://192.168.13.253:5000/message') // 实例化一个WebSocket 对象: 用于向 192.168.13.253:5000/message 这个url 发送 websocket 协议的请求;该连接不会断开 // 前端通过 ws.send("你好") 来发送 websocket协议的数据 ws.onmessage = function (event) { // 服务端返回 ws 数据的时候, ws.onmessage() 自动触发 /* 服务器端向客户端发送数据时,自动执行 */ // {'cid':cid,'count':new} var response = JSON.parse(event.data); // event.data 就是 服务端返回回来的数据 $('#id_'+response.cid).find('span').text(response.count); }; function vote(cid) { ws.send(cid) // 发送 websocket 数据 } </script> </body> </html>
可以看出WebSocket可以真正的实现不断开连接,实时的请求数据,使用起来也比较简单,仅限于使用的话参考上面示例足够使用了。
探索WebSocket实现原理(选看)
但是websocket的实现原理比较复杂,感兴趣的话继续往下探索大致步骤如下:首先建立连接时,前端通过js代码创建websocket对象时,向后端发请求,请求头中有段秘钥,后端通过使用秘钥,并通过一定规则加密的响应返回给前端,前端判断正确后才真正的建立了连接;然后就是彼此收发数据了,所有发的数据也都是加密了的,具体的如何实现这些过程的请往下看:
1. 启动服务端
1
2
3
4
5
6
7
8
9
10
|
import
socket
sock
=
socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR,
1
)
sock.bind((
'127.0.0.1'
,
8002
))
sock.listen(
5
)
# 等待用户连接
conn, address
=
sock.accept()
...
...
...
|
启动Socket服务器后,等待用户【连接】,然后进行收发数据。
2. 客户端连接
1
2
3
4
|
<script
type
=
"text/javascript"
>
var socket
=
new WebSocket(
"ws://127.0.0.1:8002/xxoo"
);
...
<
/
script>
|
当客户端向服务端发送连接请求时,不仅连接还会发送【握手】信息,并等待服务端响应,至此连接才创建成功!
3. 建立连接【握手】
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
import
socket
sock
=
socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR,
1
)
sock.bind((
'127.0.0.1'
,
8002
))
sock.listen(
5
)
# 获取客户端socket对象
conn, address
=
sock.accept()
# 获取客户端的【握手】信息
data
=
conn.recv(
1024
)
...
...
...
conn.send(
'响应【握手】信息'
)
|
请求和响应的【握手】信息需要遵循规则:
- 从请求【握手】信息中提取 Sec-WebSocket-Key
- 利用magic_string 和 Sec-WebSocket-Key 进行hmac1加密,再进行base64加密
- 将加密结果响应给客户端
注:magic string为:258EAFA5-E914-47DA-95CA-C5AB0DC85B11
请求【握手】信息为:
1
2
3
4
5
6
7
8
9
10
11
12
|
GET
/
chatsocket HTTP
/
1.1
Host:
127.0
.
0.1
:
8002
Connection: Upgrade
Pragma: no
-
cache
Cache
-
Control: no
-
cache
Upgrade: websocket
Origin: http:
/
/
localhost:
63342
Sec
-
WebSocket
-
Version:
13
Sec
-
WebSocket
-
Key: mnwFxiOlctXFN
/
DeMt1Amg
=
=
Sec
-
WebSocket
-
Extensions: permessage
-
deflate; client_max_window_bits
...
...
|
提取Sec-WebSocket-Key值并加密:
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
|
import
socket
import
base64
import
hashlib
def
get_headers(data):
"""
将请求头格式化成字典
:param data:
:return:
"""
header_dict
=
{}
data
=
str
(data, encoding
=
'utf-8'
)
for
i
in
data.split(
'\r\n'
):
print
(i)
header, body
=
data.split(
'\r\n\r\n'
,
1
)
header_list
=
header.split(
'\r\n'
)
for
i
in
range
(
0
,
len
(header_list)):
if
i
=
=
0
:
if
len
(header_list[i].split(
' '
))
=
=
3
:
header_dict[
'method'
], header_dict[
'url'
], header_dict[
'protocol'
]
=
header_list[i].split(
' '
)
else
:
k, v
=
header_list[i].split(
':'
,
1
)
header_dict[k]
=
v.strip()
return
header_dict
sock
=
socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR,
1
)
sock.bind((
'127.0.0.1'
,
8002
))
sock.listen(
5
)
conn, address
=
sock.accept()
data
=
conn.recv(
1024
)
headers
=
get_headers(data)
# 提取请求头信息
# 对请求头中的sec-websocket-key进行加密
response_tpl
=
"HTTP/1.1 101 Switching Protocols\r\n"
\
"Upgrade:websocket\r\n"
\
"Connection: Upgrade\r\n"
\
"Sec-WebSocket-Accept: %s\r\n"
\
"WebSocket-Location: ws://%s%s\r\n\r\n"
magic_string
=
'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
value
=
headers[
'Sec-WebSocket-Key'
]
+
magic_string
ac
=
base64.b64encode(hashlib.sha1(value.encode(
'utf-8'
)).digest())
response_str
=
response_tpl
%
(ac.decode(
'utf-8'
), headers[
'Host'
], headers[
'url'
])
# 响应【握手】信息
conn.send(bytes(response_str, encoding
=
'utf-8'
))
...
...
...
|
4.客户端和服务端收发数据
客户端和服务端传输数据时,需要对数据进行【封包】和【解包】。客户端的JavaScript类库已经封装【封包】和【解包】过程,但Socket服务端需要手动实现。
第一步:获取客户端发送的数据【解包】

解包详细过程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
0
1
2
3
0
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
6
7
8
9
0
1
+
-
+
-
+
-
+
-
+
-
-
-
-
-
-
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
|F|R|R|R| opcode|M| Payload
len
| Extended payload length |
|I|S|S|S| (
4
) |A| (
7
) | (
16
/
64
) |
|N|V|V|V| |S| | (
if
payload
len
=
=
126
/
127
) |
| |
1
|
2
|
3
| |K| | |
+
-
+
-
+
-
+
-
+
-
-
-
-
-
-
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
| Extended payload length continued,
if
payload
len
=
=
127
|
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
| |Masking
-
key,
if
MASK
set
to
1
|
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
| Masking
-
key (continued) | Payload Data |
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
: Payload Data continued ... :
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
| Payload Data continued ... |
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
|
第二步:向客户端发送数据【封包】

5. 基于Python实现WebSocket连接示例(汇总了上面4步)
a. 基于Python socket实现的WebSocket服务端:
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
92
93
94
95
96
97
98
99
100
101
102
103
|
#!/usr/bin/env python
# -*- coding:utf-8 -*-
import
socket
import
base64
import
hashlib
def
get_headers(data):
"""
将请求头格式化成字典
:param data:
:return:
"""
header_dict
=
{}
data
=
str
(data, encoding
=
'utf-8'
)
header, body
=
data.split(
'\r\n\r\n'
,
1
)
header_list
=
header.split(
'\r\n'
)
for
i
in
range
(
0
,
len
(header_list)):
if
i
=
=
0
:
if
len
(header_list[i].split(
' '
))
=
=
3
:
header_dict[
'method'
], header_dict[
'url'
], header_dict[
'protocol'
]
=
header_list[i].split(
' '
)
else
:
k, v
=
header_list[i].split(
':'
,
1
)
header_dict[k]
=
v.strip()
return
header_dict
def
send_msg(conn, msg_bytes):
"""
WebSocket服务端向客户端发送消息
:param conn: 客户端连接到服务器端的socket对象,即: conn,address = socket.accept()
:param msg_bytes: 向客户端发送的字节
:return:
"""
import
struct
token
=
b
"\x81"
length
=
len
(msg_bytes)
if
length <
126
:
token
+
=
struct.pack(
"B"
, length)
elif
length <
=
0xFFFF
:
token
+
=
struct.pack(
"!BH"
,
126
, length)
else
:
token
+
=
struct.pack(
"!BQ"
,
127
, length)
msg
=
token
+
msg_bytes
conn.send(msg)
return
True
def
run():
sock
=
socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR,
1
)
sock.bind((
'127.0.0.1'
,
8003
))
sock.listen(
5
)
conn, address
=
sock.accept()
data
=
conn.recv(
1024
)
headers
=
get_headers(data)
response_tpl
=
"HTTP/1.1 101 Switching Protocols\r\n"
\
"Upgrade:websocket\r\n"
\
"Connection:Upgrade\r\n"
\
"Sec-WebSocket-Accept:%s\r\n"
\
"WebSocket-Location:ws://%s%s\r\n\r\n"
value
=
headers[
'Sec-WebSocket-Key'
]
+
'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
ac
=
base64.b64encode(hashlib.sha1(value.encode(
'utf-8'
)).digest())
response_str
=
response_tpl
%
(ac.decode(
'utf-8'
), headers[
'Host'
], headers[
'url'
])
conn.send(bytes(response_str, encoding
=
'utf-8'
))
while
True
:
try
:
info
=
conn.recv(
8096
)
except
Exception as e:
info
=
None
if
not
info:
break
payload_len
=
info[
1
] &
127
if
payload_len
=
=
126
:
extend_payload_len
=
info[
2
:
4
]
mask
=
info[
4
:
8
]
decoded
=
info[
8
:]
elif
payload_len
=
=
127
:
extend_payload_len
=
info[
2
:
10
]
mask
=
info[
10
:
14
]
decoded
=
info[
14
:]
else
:
extend_payload_len
=
None
mask
=
info[
2
:
6
]
decoded
=
info[
6
:]
bytes_list
=
bytearray()
for
i
in
range
(
len
(decoded)):
chunk
=
decoded[i] ^ mask[i
%
4
]
bytes_list.append(chunk)
body
=
str
(bytes_list, encoding
=
'utf-8'
)
send_msg(conn,body.encode(
'utf-8'
))
sock.close()
if
__name__
=
=
'__main__'
:
run()
|
b. 利用JavaScript类库实现客户端
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
|
<!
DOCTYPE
html>
<
html
>
<
head
lang="en">
<
meta
charset="UTF-8">
<
title
></
title
>
</
head
>
<
body
>
<
div
>
<
input
type="text" id="txt"/>
<
input
type="button" id="btn" value="提交" onclick="sendMsg();"/>
<
input
type="button" id="close" value="关闭连接" onclick="closeConn();"/>
</
div
>
<
div
id="content"></
div
>
<
script
type="text/javascript">
var socket = new WebSocket("ws://127.0.0.1:8003/chatsocket");
socket.onopen = function () {
/* 与服务器端连接成功后,自动执行 */
var newTag = document.createElement('div');
newTag.innerHTML = "【连接成功】";
document.getElementById('content').appendChild(newTag);
};
socket.onmessage = function (event) {
/* 服务器端向客户端发送数据时,自动执行 */
var response = event.data;
var newTag = document.createElement('div');
newTag.innerHTML = response;
document.getElementById('content').appendChild(newTag);
};
socket.onclose = function (event) {
/* 服务器端主动断开连接时,自动执行 */
var newTag = document.createElement('div');
newTag.innerHTML = "【关闭连接】";
document.getElementById('content').appendChild(newTag);
};
function sendMsg() {
var txt = document.getElementById('txt');
socket.send(txt.value);
txt.value = "";
}
function closeConn() {
socket.close();
var newTag = document.createElement('div');
newTag.innerHTML = "【关闭连接】";
document.getElementById('content').appendChild(newTag);
}
</
script
>
</
body
>
</
html
>
|

出处: https://www.cnblogs.com/liujiajia_me/
本文版权归作者和博客园共有,不得转载,未经作者同意参考时必须保留此段声明,且在文章页面明显位置给出原文连接。