[转载] Python基础: TCP套接字中出现的粘包现象和解决办法

参考链接: Python中的打包pack和拆包unpack参数

一、什么是粘包现象 

首先我们先来基于TCP制作一个执行远程命令的程序 注意:在服务端使用subprocess执行系统命令返回结果的候 

res=subprocess.Popen(cmd.decode('utf-8'),

shell=True,

stderr=subprocess.PIPE,

stdout=subprocess.PIPE)


上面代码的结果的编码是以当前所在的系统为准的,如果是windows,那么res.stdout.read()读出来的就是GBK编码的执行结果,在接收端要使用GBK来解码,并且只能从管道里读一次结果 TCP服务端 

#_*_coding:utf-8_*_

from socket import *

import subprocess

# 配置信息

ip_port=(127.0.0.1,8080)

BUFSIZE=1024

# 创建服务端的套接字对象

tcp_socket_server=socket(AF_INET,SOCK_STREAM)

tcp_socket_server.bind(ip_port)

tcp_socket_server.listen(5)

# 创建连接循环

while True:

    conn,addr=tcp_socket_server.accept()

    print('客户端',addr)

    #创建通讯循环

    while True:

        # 接受来自客户端的cmd命令

        cmd=conn.recv(BUFSIZE)

        if len(cmd) == 0:break

#将接收到的cmd命令通过subprocess模块交给操作系统去执行

res=subprocess.Popen(cmd.decode('utf-8'),shell=True,

                         stdout=subprocess.PIPE,

                         stdin=subprocess.PIPE,

                         stderr=subprocess.PIPE)

        #读取执行结果

        stderr=act_res.stderr.read()

        stdout=act_res.stdout.read()

        #发送执行结果给客户端

        conn.send(stderr)

        conn.send(stdout)


TCP客户端 

#_*_coding:utf-8_*_

import socket

BUFSIZE=1024

ip_port=(127.0.0.1,8080)

#创建客户端的套接字对象,并连接到服务器

s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)

res=s.connect_ex(ip_port)

#创建通信循环

while True:

msg=input('>>:').strip()

if len(msg)==0:continue

if msg=='quit':break

#将输入的命令发送给服务端

s.send(msg.encode('utf-8'))

#接受服务端返回的数据并打印到终端

act_res=s.recv(BUFSIZE)

print(act_res.decode('gbk'))


上述的程序是基于TCP写的,在运行时会发生粘包,下面给大家看一下具体的粘包现象是什么样的:  第二次输入dir时显示的还是tasklist的返回结果,在执行多次dir之后才会把tasklist的返回结果取完,然后才会取出dir的返回结果,最典型的就是取出来的既有tasklist的结果又有dir的结果,这就是最典型的粘包现象 我们在基于UDP制作一个远程执行命令的程序 UDP的服务端 

#_*_coding:utf-8_*_

from socket import *

import subprocess

#配置信息

ip_port=(127.0.0.1,9003)

bufsize=1024

#创建一个udp的套接字对象,并给服务器绑定ip地址

udp_server=socket(AF_INET,SOCK_DGRAM)

udp_server.bind(ip_port)

# 通信循环

while True:

    #收客户端发来的消息

    cmd,addr=udp_server.recvfrom(bufsize)

    print('用户命令-->',cmd)

    #将消息进行逻辑处理

    res=subprocess.Popen(cmd.decode('utf-8'),shell=True,stderr=subprocess.PIPE,stdin=subprocess.PIPE,stdout=subprocess.PIPE)

    stderr=res.stderr.read()

    stdout=res.stdout.read()

    #将处理后的消息发给客户端

    udp_server.sendto(stderr,addr)

    udp_server.sendto(stdout,addr)

udp_server.close()


UDP客户端 

#_*_coding:utf-8_*_

from socket import *

ip_port=('127.0.0.1,9003)

bufsize=1024

#创建客户端的udp套接字

udp_client=socket(AF_INET,SOCK_DGRAM)

#通信循环

while True:

msg=input('>>: ').strip()

#向服务端发送cmd命令

    udp_client.sendto(msg.encode('utf-8'),ip_port)

#接受返回结果并打印

    data,addr=udp_client.recvfrom(bufsize)

    print(data.decode('utf-8'),end='')


上述程序是基于udp的socket,在运行时永远不会发生粘包现象 udp不会发生粘包的原因是: udp也叫做数据报协议,它在发送数据的时候,会将数据进行处理,发出的数据其实就是一个完整的数据报,接收端接收的时候按照相应的格式去接收就可以了,**udp在收发数据的时候时一对一进行的,**也就是说一个sendto对应唯一一个recvfrom 

什么是粘包 

首先我们要知道,也通过上面的程序进行了测试,只有TCP有粘包现象,UDP永远不会发生粘包下面我们来剖析一下为什么会有这样的区别: 首先我们需要了解socket收发消息的原理: 首先我们要知道收发数据的时候不是应用程序本身去收发数据的,数据的传输需要依赖于网络,所以数据的传输是要靠网卡然后通过网线将数据发送出去的,然而应用程序不能直接控制硬件,所以应用程序的收发数据其实是在给操作系统发送请求,让操作系统帮忙调用硬件把应用程序产生的数据发送出去,那么应用程序产生的数据存放在哪里呢,应用程序的是在内存中运行的,所以产生的大部分数据都在内存中,这就产生了缓冲区的一个概念,应用程序产生的数据是先存在内存中等待操作系统发送,接受数据的时候也是从操作系统的缓冲区中拿取数据的  首先看一下TCP收发消息的原理: 发送端可以是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据,也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),一条消息有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议,这也是容易出现粘包问题的原因。 UDP收发消息的原理: 而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的 

**怎样定义消息呢?**可以认为对方一次性write/send的数据为一个消息,需要明白的是当对方send一条信息的时候,无论底层怎样分段分片,TCP协议层会把构成整条消息的数据段排序完成后才呈现在内核缓冲区。 

例如基于tcp的套接字客户端往服务端上传文件,发送时文件内容是按照一段一段的字节流发送的,在接收方看了,根本不知道该文件的字节流从何处开始,在何处结束,并且TCP中有一个nagle算法,这个算法会将数据量小并且两次发送时间间隔较短的额多个数据整合成到一次,一次发送出去,这样在接收端收到消息之后就不知道数据包的字节流是从什么位置开始的 所谓的粘包问题主要还是因为接收方不知道消息之间的界限,不知道一次性提取多少字节能够把需要的数据接收完整造成的 此外,发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一个TCP段。若连续几次需要send的数据都很少,通常TCP会根据优化算法(nagle算法)把这些数据合成一个TCP段后一次发送出去,这样接收方就收到了粘包数据 **1.TCP(transport control protocol,传输控制协议)**是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。 即面向流的通信是无消息保护边界的。 **2.UDP(user datagram protocol,用户数据报协议)**是无连接的,面向消息的,提供高效率服务。不会使用块的合并优化算法, 由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了。 即面向消息的通信是有消息保护边界的。我们可以理解为每一个UDP的数据包都被自动分好了消息头消息头里边有消息的描述信息 3.TCP是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),那也不是空消息,udp协议会帮你封装上消息头. 注意:1.udp的recvfrom是阻塞的,一个recvfrom(x)必须对唯一一个sendto(y),收完了x个字节的数据就算完成,比如发送过来的字节数为1324但是这边只recv了1024个字节,剩下的300个字节就会自动被抛弃掉,下次再来数据再进行比对,看是否能够收取完,若是y>x数据就会丢失,这意味着UDP根本不会粘包,但是相应的会丢失数据,不可靠 2.TCP的协议数据不会丢失,没有收完包内的数据,下次接受的时候会继续上次的数据,己端总是在收到ack时才会清楚缓冲区内的内容.数据是可靠的,但是会粘包 两种情况下会发生粘包现象 1.发送端需要等缓冲区满才发送出去,造成粘包(发送数据时间间隔很短,数据还很小,就会合到一起,产生粘包) 例如下面这种情况: 服务端: 

#_*_coding:utf-8_*_

from socket import *

ip_port=('127.0.0.1',8080)

tcp_socket_server=socket(AF_INET,SOCK_STREAM)

tcp_socket_server.bind(ip_port)

tcp_socket_server.listen(5)

conn,addr=tcp_socket_server.accept()

data1=conn.recv(10)

data2=conn.recv(10)

print('----->',data1.decode('utf-8'))

print('----->',data2.decode('utf-8'))

conn.close()


客户端:


#_*_coding:utf-8_*_

import socket

BUFSIZE=1024

ip_port=('127.0.0.1',8080)

s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)

res=s.connect_ex(ip_port)

s.send('hello'.encode('utf-8'))

s.send('allen'.encode('utf-8'))


服务端接收到的结果为: 

-----> helloallen

-----> 


2.接收方不解释接收缓冲区中的包,造成多个包接收(客户端发送了一段数据,服务端只收了一小部分,服务端下次再收的时候还是从缓冲区拿上次遗留的数据,产生粘包) 例如下面的情况: 服务端: 

#_*_coding:utf-8_*_

from socket import *

ip_port=('127.0.0.1',8080)

tcp_socket_server=socket(AF_INET,SOCK_STREAM)

tcp_socket_server.bind(ip_port)

tcp_socket_server.listen(5)

conn,addr=tcp_socket_server.accept()

data1=conn.recv(2) #一次没有收完整

data2=conn.recv(10)#下次收的时候,会先取旧的数据,然后取新的

print('----->',data1.decode('utf-8'))

print('----->',data2.decode('utf-8'))

conn.close()


客户端 

#_*_coding:utf-8_*_

__author__ = 'Linhaifeng'

import socket

BUFSIZE=1024

ip_port=('127.0.0.1',8080)

s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)

res=s.connect_ex(ip_port)

s.send('hello allen'.encode('utf-8'))


运行结果: 

-----> he

-----> llo allen


拆包的发生情况 当发送端缓冲区的长度大于网卡的MTU(网络上传送的最大数据包)时,tcp会将这次发送的数据拆成几个数据包发送出去 为什么说TCP是可靠传输,UDP是不可靠传输呢 tcp在数据传输时,发送端先把数据发送到自己操作系统的缓存中,然后协议控制将缓存中的数据发往对端,对端返回一个ack=1,发送端则清理缓存中的数据,对端返回ack=0,则重新发送数据,所以tcp是可靠的 

而udp发送数据,对端是不会返回确认信息的,因此不可靠 send(字节流)和recv(1024)及sendall,以及recv和recvfrom的区别 recv里指定的1024意思是从系统缓存里拿出1024个字节的数据 send的字节流是先放入系统缓存,然后由协议控制将缓存内容发往对端,如果待发送的字节流大小大于缓存剩余空间,呢么数据丢失,生sendall就会循环调用send,数据不会丢失 recv和recvfrom的区别是,recv只接受数据,但是recvfrom会接收到发送端的ip地址 

在知道了什么是粘包问题后,下面我们来讨论一下怎么解决年报问题: 比较简单的粘包问题解决办法 首先我们要明白问题的根源在于,接收端不知道发送端要传送的字节流的长度,它不知道从哪里开始读取数据,拿到一堆数据的时候时懵逼的,所以解决粘包的方法就是围绕,如何让发送端在发送数据之前,把自己将要发送的字节流总大小让接收端知道,然后接收端来一个循环来接受慢慢的接受所有的数据 那么就有了对粘包问题的简单解决办法: 在发送数据之前,先把要发送的数据信息长度计算出来,然后先将数据长度发送给对方告诉对方我要发送给你的数据有多长,然后再发送真实的数据: 服务端的改进版本: 

#_*_coding:utf-8_*_

import socket,subprocess

ip_port=('127.0.0.1',8080)

s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)

s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

s.bind(ip_port)

s.listen(5)

while True:

    conn,addr=s.accept()

    print('客户端',addr)

    while True:

        msg=conn.recv(1024)

        if not msg:break

        res=subprocess.Popen(msg.decode('utf-8'),shell=True,

                         stdin=subprocess.PIPE,

                         stderr=subprocess.PIPE,

                         stdout=subprocess.PIPE)

        err=res.stderr.read()

        if err:

            ret=err

        else:

            ret=res.stdout.read()

        # 在发送真实的数据之前首先进行数据的长度计算

        data_length=len(ret)

        #然后将数据长度发送给客户端

        conn.send(str(data_length).encode('utf-8'))

        # 等待接收客户端的接受确认信息

        data=conn.recv(1024).decode('utf-8')

        if data == 'recv_ready':

            # 确认成功后,将真实数据发送给客户端

            conn.sendall(ret)

    conn.close()


客户端的改进版本: 

#_*_coding:utf-8_*_

import socket,time

s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)

res=s.connect_ex(('127.0.0.1',8080))

while True:

    msg=input('>>: ').strip()

    if len(msg) == 0:continue

    if msg == 'quit':break

    s.send(msg.encode('utf-8'))

    # 在接收真实数据之前先接收服务端发过来的数据长度

    length=int(s.recv(1024).decode('utf-8'))

    # 然后发送给服务端一个回馈信息,表示客户端已经收到了长度信息,并且准备好接收真实数据了

    s.send('recv_ready'.encode('utf-8'))

    # 循环接收真实数据

    send_size=0

    recv_size=0

    data=b''

    while recv_size < length:

        data+=s.recv(1024)

        recv_size+=len(data)

    print(data.decode('utf-8'))


这种解决办法为什么说它是比较简单的解决办法呢: 首先我们知道程序的运行速度是远远快于网络传输速度的,所以在发送一段字节钱,先用send去发送该字节流长度,这种方式会放大网络延迟带来的性能消耗. 

自定义报头解决粘包问题 

下面我们需要找到一种比较完善的解决粘包问题的办法: 首先我们想一下,服务端每次发送给客户端的数据长度是不固定的,我们每次需要把这个长度转换成bytes类型发送给客户端转换后的bytes长度也是不一样的,这样客户端读取起来也比较麻烦,并且如果发报头,我们不仅仅只是要发数据的长度,在实际的数据传送过程中,还需要传输数据的MD5值来验证数据的完整性,还要传输文件的名字信息等对文件有描述性作用的信息,这样我们之前的解决办法就不能满足我们的需求 首先我们需要了解一个新的模块Struct 该模块可以把一个类型,如数字,装成定长度的bytes struct.pack(‘i’,111111111111111) 表示把11111111111111转换成固定长度的bytes struct.pack(fmt, *args) 第一个参数需要传入要使用的格式,第二个是要格式化的内容 注意:打包后的返回值是一个元组类型,元组中索引值为0的数据为数据打包后的长度值 struct.error: ‘i’ format requires -2147483648 <= number <= 2147483647 #这个是范围 因为模块是C写的所以在使用的时候需要使用C支持的数据类型,下面我们看一下struct中有哪些格式可以使用  下面给大家介绍一下关于struct的详细用法: 

import json,struct

#假设通过客户端上传1T:1073741824000的文件a.txt

#为避免粘包,必须自定制报头

header={'file_size':1073741824000,'file_name':'/a/b/c/d/e/a.txt','md5':'8f6fbf8347faa4924a76856701edb0f3'} #1T数据,文件路径和md5值

#为了该报头能传送,需要序列化并且转为bytes

head_bytes=bytes(json.dumps(header),encoding='utf-8') #序列化并转成bytes,用于传输

#为了让客户端知道报头的长度,用struck将报头长度这个数字转成固定长度:4个字节

head_len_bytes=struct.pack('i',len(head_bytes)) #这4个字节里只包含了一个数字,该数字是报头的长度

#客户端开始发送

conn.send(head_len_bytes) #先发报头的长度,4个bytes

conn.send(head_bytes) #再发报头的字节格式

conn.sendall(文件内容) #然后发真实内容的字节格式

#服务端开始接收

head_len_bytes=s.recv(4) #先收报头4个bytes,得到报头长度的字节格式

x=struct.unpack('i',head_len_bytes)[0] #提取报头的长度

head_bytes=s.recv(x) #按照报头长度x,收取报头的bytes格式

header=json.loads(json.dumps(header)) #提取报头

#最后根据报头的内容提取真实的数据,比如

real_data_len=s.recv(header['file_size'])

s.recv(real_data_len)


自定义报头 服务端(自定义报头): 

from socket import *

import subprocess

import struct

import json

#创建服务端的套接字对象

server=socket(AF_INET,SOCK_STREAM)

server.bind(('127.0.0.1',8080))

server.listen(5)

#创建连接循环

while True:

   conn,client_addr=server.accept() #(连接对象,客户端的ip和端口)

   print(client_addr)

   #创建通信循环

   while True:

       try:

           cmd=conn.recv(1024)

           obj=subprocess.Popen(cmd.decode('utf-8'),

                                shell=True,

                                stdout=subprocess.PIPE,

                                stderr=subprocess.PIPE

                                )

           stdout=obj.stdout.read()

           stderr=obj.stderr.read()

           # 1、制作报头

           header_dic={

               'total_size':len(stdout) + len(stderr),

               'md5':'123svsaef123sdfasdf',

               'filename':'a.txt'

           }

           #将报头字典序列化成字符串

           header_json = json.dumps(header_dic)

           #将字符串的报头转成bytes类型等待发送

           header_bytes = header_json.encode('utf-8')

           # 2、先发送报头的长度

           header_size=len(header_bytes)

           conn.send(struct.pack('i',header_size))

           # 3、发送报头

           conn.send(header_bytes)

           # 4、发送真实的数据

           conn.send(stdout)

           conn.send(stderr)

       except ConnectionResetError:

           break

   conn.close()

server.close()


客户端(自定义报头) 

from socket import *

import struct

import json

#创建客户端套接字对象并绑定服务端的IP地址

client=socket(AF_INET,SOCK_STREAM)

client.connect(('127.0.0.1',8080))

#创建通讯循环

while True:

   cmd=input('>>>: ').strip()

   if not cmd:continue

   #发送cmd指令

   client.send(cmd.encode('utf-8'))

   #1、先收报头的长度

   header_size=struct.unpack('i',client.recv(4))[0]

   #2、接收报头的bytes类型的数据

   header_bytes=client.recv(header_size)

   #3、解析报头

   #先将bytes类型的报头信息进行解码

   header_json=header_bytes.decode('utf-8')

   #解码完成后序列化出报头字典信息

   header_dic=json.loads(header_json)

   print(header_dic)


   #从字典中获取到真实数据的长度

   total_size=header_dic[ 'total_size']

   #4、根据报头内的信息,循环收取真实的数据

   recv_size=0

   res=b''

   while recv_size < total_size:

       recv_data=client.recv(1024)

       res+=recv_data

       recv_size+=len(recv_data)

   print(res.decode('gbk'))

client.close()


使用TCP的时候对空数据的处理 空数据在windows和mac上的处理方式是不一样的,我们在这里分开来讨论 在windows上: 在windows上当客户端发送一个空数据时,如果没有做空数据处理,客户端会制造一个空的数据放到缓冲区,让操作系统将这个空数据发送出去,这一步也是客户端执行了send操作调用了操作系统之后执行的,但是操作系统根本不会发这个空数据,这就导致了服务端收不到数据阻塞在recv,客户端看似发了一个空数据实则什么也没发从而阻塞在recv的现象 在Mac上: 在mac上当客户端输入一个空数据的时候,客户端会将数据发送过去,但是,并且服务端会收空,如果不做收空处理,服务端会一直阻塞在recv处

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容