架构设计:系统间通信(1)--概述从“聊天”开始上篇

从这篇博文开始,我们将进入一个新文章系列。这个文章系列专门整理总结了目前系统间通信的主要原理、手段和实现。我们将讲解典型的信息格式、讲解传统的RMI(Java Remote Method Invocation)调用并延伸出来重点讲解RPC(Remote Procedure Call)调用和使用案例;最后我们还会讲到SOA(Service-Oriented Architecture)架构的实现,包括ESB(Enterprise service bus)实现和服务注册/治理的实现,同样包括原理、实现和案例。

系统间通信是架构师需要掌握的又一个关键技术领域如果说理解和掌握负载均衡层技术需要您有一定的linux系统知识和操作系统知识的话,那么理解和掌握系统间通信技术,需要您一定的编程经验(最好是JAVA编程经验,因为我们会主要以JAVA技术作为实例演示)。

1、一个场景

首先我们来看一个显示场景:在现实生活中有两个技术人员A和B,在进行一问一答形式的交流。如下图所示:

image

我们来看这幅图的中的几个要点:

  • 他们俩都使用中文进行交流。如果他们一个使用的是南斯拉夫语,另一人使用的是索马里语,并且相互都不能理解对方的语系,很显然A所表达的内容B是无法理解的。
  • 他们的声音是在空气中进行传播的。空气除了支撑他们的呼吸外,还支撑了他们声音的传播。如果没有空气他们是无法知道对方用中文说了什么。
  • 他们的交流方式是协调一致的,即A问完一个问题后,等待B进行回答。收到B的回答后,A才能问下一个问题。
  • 由于都是人类,所以他们处理信息的方式也是一样的:用嘴说话,用耳朵听话,用大脑处理形成结果。

目前这个交流场景下,只有A和B两个人。但是随时有可能增加N个人进来。第N个人可能不是采用中文进行交流。

2、信息格式

很明显通过中文的交谈,两个人相互明白了对方的意图。为了保证信息传递的高效性,我们一定会将信息做成某种参与者都理解的格式。例如:中文有其特定的语法结构,例如主谓宾,定状补。

在计算机领域为了保证信息能够被处理,信息也会被做成特定的格式,而且要确保目标能够明白这种格式。常用的信息格式包括:

XML:

可扩展标记语言,这个语言由W3C(万维网联盟)进行发布和维护。XML语言应用广泛,扩展之丰富。适合做网络通信的信息描述格式(一般是“应用层”协议了)。例如Google定义的XMPP通信协议就是使用XML进行描述的;不过XML的更广泛使用场景是对系统环境进行描述(因为它会造成较多的不必要的内容传输),例如服务器的配置描述,Spring的配置描述、Maven仓库描述等等。

JSON:

JSON(JavaScript Object Notation)是一种轻量级的数据交换格式。它和XML的设计思路是一致的:和语言无关(流行的语言都支持JSON格式描述:Go、Python、C、C++、C#、JAVA、Erlang、JavaScript等等);但是和XML不同,JSON的设计目标就是为了进行通信。要描述同样的数据,JSON格式的容量会更小。

protocol buffer(PB):

protocolbuffer(以下简称PB)是Google的一种数据交换的格式,它独立于语言,独立于平台。Google提供了三种语言的实现:java、c++和python,每一种实现都包含了相应语言的编译器及库文件。

TLV(三元组编码):

T(标记/类型域)L(长度/大小域)V(值/内容域),通常这种信息格式用于金融、军事领域。它通过字节的位运算来进行信息的序列化/反序列化(据说微信的信息格式也采用的是TLV,但实际情况我不清楚):

image

这里有一篇介绍TLV的文章:《通信协议之序列化TLV》,TLV格式所携带的内容是最有效的,它就连JSON中用于分割层次的“{}”符号都没有。

自定义的格式

当然,如果您的两个内部系统已经约定好了一种信息格式,您当然可以使用自己定制的格式进行描述。您可以使用C++描述一个结构体,然后序列化/反序列它,或者使用一个纯文本,以“|”号分割这些字符串,然后序列化/反序列它。

在这个系列的博文中,我们不会把信息格式作为一个重点,但是会花一些篇幅去比较各种信息格式在网络上传输的速度、性能,并为大家介绍几种典型的信息格式选型场景。

3、网络协议

如文中第一张图描述的场景,有一个我们看不到但是却很重要的元素:空气。声音在空气中完成传播,真空无法传播声音。同样信息是在网络中完成传播的,没有网络就没法传播信息。网络协议就是计算机领域的“空气”,下图中我们OSI(Open System Interconnection)模型作为参考:

image

物理层:物理层就是我们的网络设备层,例如我们的网卡、交换机等设备,在他们之间我们一般传递的是电信号或者光信号。

数据链路层:数据链路又分为物理链路和逻辑链路。物理链路负责组合一组电信号,称之为“帧”;逻辑链路层通过一些规则和协议保证帧传输的正确性,并且可以使用来自于多个源/目标的帧在同一个物理链路上进行传输,实现“链路复用”。

网络层:网络层使用最广泛的协议就是IP协议(又分为IPV4协议和IPV6协议),IPX协议。这些协议解决的是源和目标的定位问题,以及从源如何到达目标的问题。

传输层:TCP、UDP是传输层最常使用的协议,传输层的最重要工作就是携带内容信息了,并且通过他们的协议规范提供某种通信机制。举例来说,TCP协议中的通信机制是:首先进行三次通信握手,然后再进行正式数据的传送,并且通过校验机制保证每个数据报文的正确性,如果数据报文错误了,则重新发送。

应用层:HTTP协议、FTP协议,TELNET协议这些都是应用层协议。应用层协议是最灵活的协议,甚至可以由程序员自行定义应用层协议。下图我们表示了HTTP协议的工作方式:

image

在这个系列的博文中,我们不会把网络协议协议作为一个重点。这是因为网络协议的知识是一个相对独立的知识领域,十几篇文章都不一定讲的清楚。如果您对网络协议有兴趣,这里推荐两本书:《TCP/IP详解.卷1-协议》和《TCP/IP详解.卷2-实现》。

4、通信方式/框架

在文章最前面我们看到其中一个人规定了一种沟通方式:“你必须把我说的话听完,然后给我反馈后,我才会问第二个问题”。这种沟通方式虽然沟通效率不高,但是很有效:一个问题一个问题的处理。

但是如果参与沟通的人处理信息的能力比较强,那么他们还可以采用另一种沟通方式:我给我提的问题编了一个号,在问完第X个问题后,我不会等待你返回,就会问第X+1个问题,同样你在听完我第X个问题后,一边处理我的问题,一边听我第X+1个问题。

实际上以上两种现实中的沟通方式,在计算机领域是可以找到对应的通信方式的,这就是我们这个系列的博文会着重讲的BIO(Blocking IO)(阻塞模式)通信和NIO(Non-Blocking IO)(非阻塞模式)。

4-1、BIO通信方式

以前大多数网络通信方式都是阻塞模式的,即:

  • 客户端向服务器发出请求后,客户端会一直等待(不会再做其他事情),直到服务器返回结果或者网络出现问题。
  • 服务端同样的,当在处理某个客户端A发来的请求时,另一个客户端B发来的请求会等待,直到服务器端的这个处理线程完成上一个处理。

如下图所示:

image

传统的BIO通信方式存在几个问题:

  • 同一时间,服务器只能接受来自于客户端A的请求信息;虽然客户端A和客户端B的请求是同时进行的,但客户端B发送的请求信息只能等到服务器接受完A的请求数据后,才能被接受。
  • 由于服务器一次只能处理一个客户端请求,当处理完成并返回后(或者异常)时,才能进行第二次请求的处理。很显然,这样的处理方式在高并发的情况下,是不能采用的。

上面说的情况是服务器只有一个线程的情况,那么读者会直接提出我们可以使用多线程技术来解决这个问题:

  • 当服务器收到客户端X的请求后,(读取到所有请求数据后)将这个请求送入一个独立线程进行处理,然后主线程继续接受客户端Y的请求。
  • 客户端一侧,也可以使用一个子线程和服务端进行通信。这样客户端主线程的其他工作就不受影响了,当服务器端有响应信息的时候再由这个子线程通过监听模式/观察模式(等其他设计模式)通知主线程。

如下图所示:

image

但是使用线程来解决这个问题实际上是有局限性的:

  • 虽然在服务器端,请求的处理交给了一个独立线程进行,但是操作系统通知accept()的方式还是单个的。也就是,实际上是服务器接收到数据报文后“业务处理过程”可以多线程,但是数据报文的接受还是需要一个一个的来(下文的示例代码和debug过程我们可以明确看到这一点)
  • 在linux系统中,可以创建的线程是有限的。我们可以通过cat/proc/sys/kernel/threads-max命令查看可以创建的最大线程数。当然这个值是可以更改的但是线程越多,CPU切换所需时间也就越长,用来处理真正业务的需求也就越少。
  • 创建一个线程是有较大的资源消耗的。JVM创建一个线程的时候,即使这个线程不做任何的工作,JVM都会分配一个堆栈空间。这个空间的大小默认为128K,您可以通过-Xss参数进行调整。
  • 当然您还可以使用ThreadPoolExecutor线程池来缓解线程的创建问题,但是又会造成BlockingQueue积压任务的持续增加,同样消耗了大量资源。
  • 另外,如果您的应用程序大量使用长连接的话线程是不会关闭的。这样系统资源的消耗更容易失控。
  • 那么,如果你真想单纯使用线程解决阻塞的问题,那么您自己都可以算出来您一个服务器节点可以一次接受多大的并发了。看来,单纯使用线程解决这个问题不是最好的方法。

4-2、BIO通信方式深入分析

在这个系列的博文中,通信方式/框架将作为一个重点进行讲解。包括NIO的原理,并通过讲解Netty的使用、JAVA原生NIO框架的使用,去熟悉这些核心原理。

实际上从上文中我们可以看出,BIO的问题关键不在于是否使用了多线程(包括线程池)处理这次请求,而在于accept()、read()的操作点都是被阻塞。要测试这个问题,也很简单。我们模拟了20个客户端(用20根线程模拟),利用JAVA的同步计数器CountDownLatch,保证这20个客户都初始化完成后然后同时向服务器发送请求,然后我们来观察下Server这边接受信息的情况。

4-2-1、模拟20个客户端并发请求,服务器端使用单线程:

  • 客户端代码(SocketClientDaemon)
package com.example.demo.testBSocket;
import java.util.concurrent.CountDownLatch;

public class SocketClientDaemon {
    public static void main(String[] args) throws Exception {
        Integer clientNumber = 20;
        CountDownLatch countDownLatch = new CountDownLatch(clientNumber);
        //分别开始启动这20个客户端
        for (int index = 0; index < clientNumber; index++, countDownLatch.countDown()) {
            SocketClientRequestThread client = new SocketClientRequestThread(countDownLatch, index);
            new Thread(client).start();
        }

        //这个wait不涉及到具体的实现逻辑,只是为了保证守护线程在启动所有线程后,进入等待状态
        synchronized (SocketClientDaemon.class) {
            SocketClientDaemon.class.wait();
        }
    }
}
  • 客户端代码(SocketClientRequestThread模拟请求)
package com.example.demo.testBSocket;


import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.concurrent.CountDownLatch;
import org.apache.log4j.BasicConfigurator;

/**
 * 一个SocketClientRequestThread线程模拟一个客户端请求。
 */
public class SocketClientRequestThread implements Runnable {

    static {
        BasicConfigurator.configure();//自动快速地使用缺省 Log4j 环境。
    }
    /**
     * 日志
     */
    private static final Log LOGGER = LogFactory.getLog(SocketClientRequestThread.class);
    private CountDownLatch countDownLatch;
    /**
     * 这个线层的编号
     */
    private Integer clientIndex;

    /**
     * countDownLatch是java提供的同步计数器
     * 当计数器数值减为0时,所有受其影响而等待的线程将会被激活。这样保证模拟并发请求的真实性。
     *
     * @param countDownLatch
     * @param clientIndex
     */
    public SocketClientRequestThread(CountDownLatch countDownLatch, Integer clientIndex) {
        this.countDownLatch = countDownLatch;
        this.clientIndex = clientIndex;
    }

    @Override
    public void run() {
        Socket socket = null;
        OutputStream clientRequest = null;
        InputStream clientResponse = null;

        try {
            socket = new Socket("localhost", 83);
            clientRequest = socket.getOutputStream();
            clientResponse = socket.getInputStream();
            //等待,直到SocketClientDaemon完成所有线程的启动,然后所有线程一起发送请求。
            this.countDownLatch.await();

            //发送请求信息
            clientRequest.write(("这是第" + this.clientIndex + "个客户端的请求。").getBytes());
            clientRequest.flush();

            //在这里等待,直到服务器返回信息
            SocketClientRequestThread.LOGGER.info("第" + this.clientIndex + "个客户端的请求发送完成,等待服务器返回信息");
            int maxLen = 1024;
            byte[] contextBytes = new byte[maxLen];
            int realLen;
            String message = "";
            //程序执行到这里,会一直等待服务器返回信息(注意,前提是in和out都不能close,如果close了就收不到服务器的反馈了)
            while ((realLen = clientResponse.read(contextBytes, 0, maxLen)) != -1) {
                message += new String(contextBytes, 0, realLen);
            }
            SocketClientRequestThread.LOGGER.info("接收到来自服务器的信息" + message);


        } catch (Exception e) {
            SocketClientRequestThread.LOGGER.error(e.getMessage(), e);
        } finally {
            try {
                if (clientRequest != null) {
                    clientRequest.close();
                }
                if (clientResponse != null) {
                    clientResponse.close();
                }
            } catch (IOException e) {
                SocketClientRequestThread.LOGGER.error(e.getMessage(), e);
            }
        }
    }
}
  • 服务器端(SocketServer1)单个线程
package com.example.demo.testBSocket;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import org.apache.log4j.BasicConfigurator;

public class SocketServer1 {
    static {
        BasicConfigurator.configure();//自动快速地使用缺省 Log4j 环境。
    }

    /**
     * 日志
     */
    private static final Log LOGGER = LogFactory.getLog(SocketServer1.class);

    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket(83);

        try {
            while (true) {
                Socket socket = serverSocket.accept();

                //下面我们收取信息
                InputStream in = socket.getInputStream();
                OutputStream out = socket.getOutputStream();
                int sourcePort = socket.getPort();
                int maxLen = 2048;
                byte[] contextBytes = new byte[maxLen];
                //这里也会被阻塞,直到有数据准备好
                int realLen = in.read(contextBytes, 0, maxLen);
                //读取信息
                String message = new String(contextBytes, 0, realLen);

                //下面打印信息
                SocketServer1.LOGGER.info("服务器收到来自于端口:" + sourcePort + "的信息" + message);

                //下面开始发送信息
                out.write("回发响应信息!".getBytes());

                //关闭
                out.close();
                in.close();
                socket.close();
            }
        } catch (Exception e) {
            SocketServer1.LOGGER.error(e.getMessage(), e);
        } finally {
            if (serverSocket != null) {
                serverSocket.close();
            }
        }

    }
}

4-2-2、就像上文所述我们可以使用多线程来优化服务器端的处理过程:

客户端代码和上文一样,最主要的是更改服务器端的代码:

package com.example.demo.testBSocket;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.log4j.BasicConfigurator;

public class SocketServer2 {

    static {
        BasicConfigurator.configure();
    }

    private static final Log LOGGER = LogFactory.getLog(SocketServer2.class);

    public static void main(String[] args) throws Exception{
        ServerSocket serverSocket = new ServerSocket(83);

        try {
            while(true) {
                Socket socket = serverSocket.accept();
                //当然业务处理过程可以交给一个线程(这里可以使用线程池),并且线程的创建是很耗资源的。
                //最终改变不了.accept()只能一个一个接受socket的情况,并且被阻塞的情况
                SocketServerThread socketServerThread = new SocketServerThread(socket);
                new Thread(socketServerThread).start();
            }
        } catch(Exception e) {
            SocketServer2.LOGGER.error(e.getMessage(), e);
        } finally {
            if(serverSocket != null) {
                serverSocket.close();
            }
        }
    }
}

/**
 * 当然,接收到客户端的socket后,业务的处理过程可以交给一个线程来做。
 * 但还是改变不了socket被一个一个的做accept()的情况。
 * @author yinwenjie
 */
class SocketServerThread implements Runnable {

    /**
     * 日志
     */
    private static final Log LOGGER = LogFactory.getLog(SocketServerThread.class);

    private Socket socket;

    public SocketServerThread (Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        InputStream in = null;
        OutputStream out = null;
        try {
            //下面我们收取信息
            in = socket.getInputStream();
            out = socket.getOutputStream();
            Integer sourcePort = socket.getPort();
            int maxLen = 1024;
            byte[] contextBytes = new byte[maxLen];
            //使用线程,同样无法解决read方法的阻塞问题,
            //也就是说read方法处同样会被阻塞,直到操作系统有数据准备好
            int realLen = in.read(contextBytes, 0, maxLen);
            //读取信息
            String message = new String(contextBytes , 0 , realLen);

            //下面打印信息
            SocketServerThread.LOGGER.info("服务器收到来自于端口:" + sourcePort + "的信息:" + message);

            //下面开始发送信息
            out.write("回发响应信息!".getBytes());
        } catch(Exception e) {
            SocketServerThread.LOGGER.error(e.getMessage(), e);
        } finally {
            //试图关闭
            try {
                if(in != null) {
                    in.close();
                }
                if(out != null) {
                    out.close();
                }
                if(this.socket != null) {
                    this.socket.close();
                }
            } catch (IOException e) {
                SocketServerThread.LOGGER.error(e.getMessage(), e);
            }
        }
    }
}

4-2-3、看看服务器端的执行效果:

image

4-2-4、问题根源:

那么重点的问题并不是“是否使用了多线程”,而是为什么accept()、read()方法会被阻塞。即:异步IO模式就是为了解决这样的并发性存在的。但是为了说清楚异步IO模式,在介绍IO模式的时候,我们就要首先了解清楚,什么是阻塞式同步、非阻塞式同步、多路复用同步模式。

这里我要特别说明一下,在一篇网文《Java NIO与IO的详细区别(通俗篇)》中,作者主要讲到了自己对非阻塞方式下硬盘操作的理解。按照我的看法,只要有IO存在,就会有阻塞和非阻塞的问题,无论这个IO是网络的,还是硬盘的。这就是为什么基本的JAVA NIO框架中会有FileChannel(而且FileChannel在操作系统级别是不支持非阻塞模式的)、DatagramChannel和SocketChannel的原因。NIO并不是为了解决磁盘读写的性能而存在的,它的出现原因、要解决的问题更为广阔;但是另外一个方面,文章作者只是表达了自己的思想,没有必要争论得“咬文嚼字”。

API文档中对于serverSocket.accept()方法的使用描述:

Listens for a connection to be made to this socket an accepts it.The method blocks until a connnection is made.

那么我们首先来看看为什么serverSocket.accept()会被阻塞。这里涉及到阻塞式同步IO的工作原理:

  • 服务器线程发起一个accept动作,询问操作系统是否有新的socket套接字信息从端口X发送过来。
image
  • 注意,式询问操作系统。也就是说socket套接字的IO模式支持是基于操作系统的,那么自然同步IO/异步IO的支持就是需要操作系统级别的了。如下图:
image
  • 如果操作系统没有发现有套接字从指定的端口X来,那么操作系统就会等待。这样serverSocket.accept()方法就会一直等待。这就是为什么accept()方法为什么会阻塞:它内部的实现是使用的操作系统级别的同步IO。

阻塞IO和非阻塞IO 这两个概念是程序级别的。主要描述的是程序级别的。主要描述的是程序请求操作系统IO操作后,如果IO资源没有准备好,那么程序该如何处理的问题;前者等待;后者继续执行(并且使用线程一直轮询,直到有IO资源准备好了)

同步IO和非同步IO,这两个概念是操作系统级别的。主要描述的是操作系统在收到程序请求IO操作后,如果IO资源没有准备好,该如何响应程序的问题;前者是不响应,直到IO资源准备好以后;后者是返回一个标记(好让程序和自己知道以后的数据往哪里通知),当IO资源准备好以后,再用事件机制返回给程序。

原文地址:https://blog.csdn.net/yinwenjie/article/details/48274255

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

推荐阅读更多精彩内容