09 | 比较:Jetty架构特点之Connector组件

经过专栏前面几期的学习,相信你对Tomcat的整体架构和工作原理有了基本了解。但是Servlet容器并非只有Tomcat一家,还有别的架构设计思路吗?今天我们就来看看Jetty的设计特点。

Jetty是Eclipse基金会的一个开源项目,和Tomcat一样,Jetty也是一个“HTTP服务器 + Servlet容器”,并且Jetty和Tomcat在架构设计上有不少相似的地方。但同时Jetty也有自己的特点,主要是更加小巧,更易于定制化。Jetty作为一名后起之秀,应用范围也越来越广,比如Google App Engine就采用了Jetty来作为Web容器。Jetty和Tomcat各有特点,所以今天我会和你重点聊聊Jetty在哪些地方跟Tomcat不同。通过比较它们的差异,一方面希望可以继续加深你对Web容器架构设计的理解,另一方面也让你更清楚它们的设计区别,并根据它们的特点来选用这两款Web容器。

鸟瞰Jetty整体架构

简单来说,Jetty Server就是由多个Connector(连接器)、多个Handler(处理器),以及一个线程池组成。整体结构请看下面这张图。

跟Tomcat一样,Jetty也有HTTP服务器和Servlet容器的功能,因此Jetty中的Connector组件和Handler组件分别来实现这两个功能,而这两个组件工作时所需要的线程资源都直接从一个全局线程池ThreadPool中获取。

Jetty Server可以有多个Connector在不同的端口上监听客户请求,而对于请求处理的Handler组件,也可以根据具体场景使用不同的Handler。这样的设计提高了Jetty的灵活性,需要支持Servlet,则可以使用ServletHandler;需要支持Session,则再增加一个SessionHandler。也就是说我们可以不使用Servlet或者Session,只要不配置这个Handler就行了。

为了启动和协调上面的核心组件工作,Jetty提供了一个Server类来做这个事情,它负责创建并初始化Connector、Handler、ThreadPool组件,然后调用start方法启动它们。

我们对比一下Tomcat的整体架构图,你会发现Tomcat在整体上跟Jetty很相似,它们的第一个区别是Jetty中没有Service的概念,Tomcat中的Service包装了多个连接器和一个容器组件,一个Tomcat实例可以配置多个Service,不同的Service通过不同的连接器监听不同的端口;而Jetty中Connector是被所有Handler共享的。

它们的第二个区别是,在Tomcat中每个连接器都有自己的线程池,而在Jetty中所有的Connector共享一个全局的线程池。

讲完了Jetty的整体架构,接下来我来详细分析Jetty的Connector组件的设计,下一期我将分析Handler组件的设计。

Connector组件

跟Tomcat一样,Connector的主要功能是对I/O模型和应用层协议的封装。I/O模型方面,最新的Jetty 9版本只支持NIO,因此Jetty的Connector设计有明显的Java NIO通信模型的痕迹。至于应用层协议方面,跟Tomcat的Processor一样,Jetty抽象出了Connection组件来封装应用层协议的差异。

Java NIO早已成为程序员的必备技能,并且也经常出现在面试题中。接下来我们一起来看看Jetty是如何实现NIO模型的,以及它是怎么Java NIO的。

Java NIO回顾

关于Java NIO编程,如果你还不太熟悉,可以先学习这一系列文章。Java NIO的核心组件是Channel、Buffer和Selector。Channel表示一个连接,可以理解为一个Socket,通过它可以读取和写入数据,但是并不能直接操作数据,需要通过Buffer来中转。

Selector可以用来检测Channel上的I/O事件,比如读就绪、写就绪、连接就绪,一个Selector可以同时处理多个Channel,因此单个线程可以监听多个Channel,这样会大量减少线程上下文切换的开销。下面我们通过一个典型的服务端NIO程序来回顾一下如何使用这些组件。

首先,创建服务端Channel,绑定监听端口并把Channel设置为非阻塞方式。

ServerSocketChannel server = ServerSocketChannel.open();
server.socket().bind(new InetSocketAddress(port));
server.configureBlocking(false);

然后,创建Selector,并在Selector中注册Channel感兴趣的事件OP_ACCEPT,告诉Selector如果客户端有新的连接请求到这个端口就通知我。

Selector selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);

接下来,Selector会在一个死循环里不断地调用select去查询I/O状态,select会返回一个SelectionKey列表,Selector会遍历这个列表,看看是否有“客户”感兴趣的事件,如果有,就采取相应的动作。

比如下面这个例子,如果有新的连接请求,就会建立一个新的连接。连接建立后,再注册Channel的可读事件到Selector中,告诉Selector我对这个Channel上是否有新的数据到达感兴趣。

 while (true) {
        selector.select();//查询I/O事件
        for (Iterator<SelectionKey> i = selector.selectedKeys().iterator(); i.hasNext();) { 
            SelectionKey key = i.next(); 
            i.remove(); 

            if (key.isAcceptable()) { 
                // 建立一个新连接 
                SocketChannel client = server.accept(); 
                client.configureBlocking(false); 
                
                //连接建立后,告诉Selector,我现在对I/O可读事件感兴趣
                client.register(selector, SelectionKey.OP_READ);
            } 
        }
    } 

简单回顾完服务端NIO编程之后,你会发现服务端在I/O通信上主要完成了三件事情:监听连接、I/O事件查询以及数据读写。因此Jetty设计了Acceptor、SelectorManager和Connection来分别做这三件事情,下面我分别来说说这三个组件。

Acceptor

顾名思义,Acceptor用于接受请求,跟Tomcat一样,Jetty也有独立的Acceptor线程组用于处理连接请求。在Connector的实现类ServerConnector中,有一个_acceptors的数组,在Connector启动的时候, 会根据_acceptors数组的长度创建对应数量的Acceptor,而Acceptor的个数可以配置。

for (int i = 0; i < _acceptors.length; i++)
{
  Acceptor a = new Acceptor(i);
  getExecutor().execute(a);
}

Acceptor是ServerConnector中的一个内部类,同时也是一个Runnable,Acceptor线程是通过getExecutor得到的线程池来执行的,前面提到这是一个全局的线程池。

Acceptor通过阻塞的方式来接受连接,这一点跟Tomcat也是一样的。

public void accept(int acceptorID) throws IOException
{
  ServerSocketChannel serverChannel = _acceptChannel;
  if (serverChannel != null && serverChannel.isOpen())
  {
    // 这里是阻塞的
    SocketChannel channel = serverChannel.accept();
    // 执行到这里时说明有请求进来了
    accepted(channel);
  }
}

接受连接成功后会调用accepted函数,accepted函数中会将SocketChannel设置为非阻塞模式,然后交给Selector去处理,因此这也就到了Selector的地界了。

private void accepted(SocketChannel channel) throws IOException
{
    channel.configureBlocking(false);
    Socket socket = channel.socket();
    configure(socket);
    // _manager是SelectorManager实例,里面管理了所有的Selector实例
    _manager.accept(channel);
}

SelectorManager

Jetty的Selector由SelectorManager类管理,而被管理的Selector叫作ManagedSelector。SelectorManager内部有一个ManagedSelector数组,真正干活的是ManagedSelector。咱们接着上面分析,看看在SelectorManager在accept方法里做了什么。

public void accept(SelectableChannel channel, Object attachment)
{
  //选择一个ManagedSelector来处理Channel
  final ManagedSelector selector = chooseSelector();
  //提交一个任务Accept给ManagedSelector
  selector.submit(selector.new Accept(channel, attachment));
}

SelectorManager从本身的Selector数组中选择一个Selector来处理这个Channel,并创建一个任务Accept交给ManagedSelector,ManagedSelector在处理这个任务主要做了两步:

第一步,调用Selector的register方法把Channel注册到Selector上,拿到一个SelectionKey。

 _key = _channel.register(selector, SelectionKey.OP_ACCEPT, this);

第二步,创建一个EndPoint和Connection,并跟这个SelectionKey(Channel)绑在一起:

private void createEndPoint(SelectableChannel channel, SelectionKey selectionKey) throws IOException
{
    //1. 创建EndPoint
    EndPoint endPoint = _selectorManager.newEndPoint(channel, this, selectionKey);
    
    //2. 创建Connection
    Connection connection = _selectorManager.newConnection(channel, endPoint, selectionKey.attachment());
    
    //3. 把EndPoint、Connection和SelectionKey绑在一起
    endPoint.setConnection(connection);
    selectionKey.attach(endPoint);
    
}

上面这两个过程是什么意思呢?打个比方,你到餐厅吃饭,先点菜(注册I/O事件),服务员(ManagedSelector)给你一个单子(SelectionKey),等菜做好了(I/O事件到了),服务员根据单子就知道是哪桌点了这个菜,于是喊一嗓子某某桌的菜做好了(调用了绑定在SelectionKey上的EndPoint的方法)。

这里需要你特别注意的是,ManagedSelector并没有调用直接EndPoint的方法去处理数据,而是通过调用EndPoint的方法返回一个Runnable,然后把这个Runnable扔给线程池执行,所以你能猜到,这个Runnable才会去真正读数据和处理请求。

Connection

这个Runnable是EndPoint的一个内部类,它会调用Connection的回调方法来处理请求。Jetty的Connection组件类比就是Tomcat的Processor,负责具体协议的解析,得到Request对象,并调用Handler容器进行处理。下面我简单介绍一下它的具体实现类HttpConnection对请求和响应的处理过程。

请求处理:HttpConnection并不会主动向EndPoint读取数据,而是向在EndPoint中注册一堆回调方法:

getEndPoint().fillInterested(_readCallback);

这段代码就是告诉EndPoint,数据到了你就调我这些回调方法_readCallback吧,有点异步I/O的感觉,也就是说Jetty在应用层面模拟了异步I/O模型。

而在回调方法_readCallback里,会调用EndPoint的接口去读数据,读完后让HTTP解析器去解析字节流,HTTP解析器会将解析后的数据,包括请求行、请求头相关信息存到Request对象里。

响应处理:Connection调用Handler进行业务处理,Handler会通过Response对象来操作响应流,向流里面写入数据,HttpConnection再通过EndPoint把数据写到Channel,这样一次响应就完成了。

到此你应该了解了Connector的工作原理,下面我画张图再来回顾一下Connector的工作流程。

1.Acceptor监听连接请求,当有连接请求到达时就接受连接,一个连接对应一个Channel,Acceptor将Channel交给ManagedSelector来处理。

2.ManagedSelector把Channel注册到Selector上,并创建一个EndPoint和Connection跟这个Channel绑定,接着就不断地检测I/O事件。

3.I/O事件到了就调用EndPoint的方法拿到一个Runnable,并扔给线程池执行。

4.线程池中调度某个线程执行Runnable。

5.Runnable执行时,调用回调函数,这个回调函数是Connection注册到EndPoint中的。

6.回调函数内部实现,其实就是调用EndPoint的接口方法来读数据。

7.Connection解析读到的数据,生成请求对象并交给Handler组件去处理。

本期精华

Jetty Server就是由多个Connector、多个Handler,以及一个线程池组成,在设计上简洁明了。

Jetty的Connector只支持NIO模型,跟Tomcat的NioEndpoint组件一样,它也是通过Java的NIO API实现的。我们知道,Java NIO编程有三个关键组件:Channel、Buffer和Selector,而核心是Selector。为了方便使用,Jetty在原生Selector组件的基础上做了一些封装,实现了ManagedSelector组件。

在线程模型设计上Tomcat的NioEndpoint跟Jetty的Connector是相似的,都是用一个Acceptor数组监听连接,用一个Selector数组侦测I/O事件,用一个线程池执行请求。它们的不同点在于,Jetty使用了一个全局的线程池,所有的线程资源都是从线程池来分配。

Jetty Connector设计中的一大特点是,使用了回调函数来模拟异步I/O,比如Connection向EndPoint注册了一堆回调函数。它的本质将函数当作一个参数来传递,告诉对方,你准备好了就调这个回调函数。

课后思考

Jetty的Connector主要完成了三件事件:接收连接、I/O事件查询以及数据读写。因此Jetty设计了Acceptor、SelectorManager和Connection来做这三件事情。今天的思考题是,为什么要把这些组件跑在不同的线程里呢?

不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,也欢迎你把你的课后思考和心得记录下来,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

精选留言

  • Li Shunduo

    2019-06-01 15:22:47

    有两个问题请教老师:
    问题一:根据文章看,Jetty中有多个Acceptor组件,请问这些Acceptor背后是共享同一个ServerSocketChannel?还是每个Acceptor有自己的ServerSocketChannel? 如果有多个ServerSocketChannel的话,这些ServerSocketChannel如何做到监听同一个端口?连接到来时如何决定分配到哪一个ServerSocketChannel?
    问题二:Acceptor组件是直接使用ServerSocketChannel.accept()方法来接受连接的,为什么不使用向Selector注册OP_ACCEPT事件的方式来接受连接?直接调用.accept()方法有什么考虑?
    问题三:Jetty中有多个ManagedSelector,这些ManagedSelector背后是共享同一个Selector吗?还是每个ManagedSelector有自己的Selector?如果是多个Selector有什么好处,注册IO事件时如何选择合适的Selector?
    作者回复

    👍不错的问题。
    1)多个Acceptor共享同一个ServerSocketChannel。多个Acceptor线程调用同一个ServerSocketChannel的accept方法,由操作系统保证线程安全
    2)直接调用accept方法,编程上简单一些,否则每个Acceptor又要自己维护一个Selector。
    3)每个ManagedSelector都有自己的Selector,多个Selector可以并行管理大量的channel,提高并发,连接请求到达时采用Round Robin的方式选择ManagedSelector。

    2019-06-01 17:07:19

  • why

    2019-06-02 20:20:11

    - Jetty 也是 Http 服务器 + Servlet 容器, 更小巧, 更易于定制
    - Jetty 架构: 多个 Connector + 多个 Handler + 一个全局线程池(Connector 和 Handler 共享)
    - 多个 Connector 在不同端口监听请求, 可以根据应用场景选择 Handler : ServletHandler 和 SessionHandler
    - Jetty 用 Server 启动和协调上述组件
    - Jetty 与 Tomcat 的区别
    - Jetty 没有 Service 的概念, Jetty 的 Connector 被 Handler 共享
    - Tomcat 连接器有自己的线程池, Jetty Connector 使用全局线程池
    - Connector 组件, 完成 I/O 模型 + 协议封装
    - 只支持 NIO 模型, 通过 Connection 组件封装协议
    - Java NIO 核心组件为: Channel, Buffer, Selector
    - Channel 即一个 socket 连接
    - Channel 通过 Buffer 间接读写数据
    - Selector 检测 Channel 的 I/O 事件, 可以处理多个 Channel, 减少线程切换开销
    - NIO 完成三个功能: 监听连接, I/O 事件查询, 数据读写, 对应的 Jetty 封装为 Acceptor, SelectorManager, Connection
    - Acceptor 接受请求
    - Jetty 有独立 Acceptor 线程组处理连接请求
    - Connector 的实现类 ServerConnector 中有 _acceptors 数组, 保存固定数目的 Acceptor.
    - Acceptor 是 Connector 内部类, 是 Runnable 的. 通过 getExecutor 得到线程以执行
    - Acceptor 通过阻塞接受连接, 接受连接后, 调用 accepted, 其将 SocketChannel 设为非阻塞, 交给 Selector 处理
    - SelectorManager 管理 Selector
    - 被管理的 Selector 叫 ManagedSelector, 保存于 SelectorManager 的一个数组中
    - SelectorManager 选择一个 Selector, 并创建一个任务 Accept 给 ManagedSelector, ManagerSelector 实现:
    - 调用 register 将 Channel 注册到 Selector, 拿到 SelectionKey
    - 创建 EndPoint 和 Connection, 并与 SelectionKey(Channel) 绑定
    - 当有 I/O 事件时, ManagedSelector 调用 EndPoint 返回一个 Runnable 对象, 并扔给线程池执行
    - Connection
    - 上述 Runnable 对象会调用 Connection 处理请求, 得到 Request 并调用 Handler 容器处理
    - 具体实现类 HttpConnection
    - 请求处理: 在 EndPoint 中注册一系列回调函数, 数据到达时调用. ( 用回调函数模拟异步 I/O ). 在回调方法中读数据, 解析请求并存到 Request
    - 相应处理: Handler 返回 Response, HttpConnection 通过 EndPoint 写到 Channel
    - 留言
    - 每次请求跟一个 Hanlder 线程是一对一的关系, 下一次再来请求,会分配一个新的 Hanlder 线程。
    - 多个 Acceptor 共享同一个 ServerSocketChannel 。多个 Acceptor 线程调用同一个 ServerSocketChannel 的 accept 方法,由操作系统保证线程安全
    作者回复

    👍

    2019-06-03 20:02:30

  • 2019-05-30 10:05:28

    使用不同的线程是为了合理的使用全局线程池。
    我有两个问题请教老师:
    问题一:负责读写的socket与handle线程是什么对应关系呢?多对1,还是1对1?
    问题二:如果有很多tcp建立连接后迟迟没有写入数据导致连接请求堵塞,或者如果有很多handle在处理耗时io操作时,
    同样可能拖慢整个线程池,进而影响到accepters和selectors,那么可能会拖慢整个线程池,jetty是如何考虑的呢?
    作者回复

    1)一个Socket上可以接收多个HTTP请求,每次请求跟一个Hanlder线程是一对一的关系,因为keepalive,一次请求处理完成后Socket不会立即关闭,下一次再来请求,会分配一个新的Hanlder线程。

    2)很好的问题,这就是为什么Servlet3.0中引入了异步Servlet的概念,就是说遇到耗时的IO操作,Tomcat的线程会立即返回,当业务线程处理完后,再调用Tomcat的线程将响应发回给浏览器,异步Servlet的原理后面有专门的一篇来介绍。

    2019-05-30 20:20:16

  • focus

    2019-05-30 09:38:41

    源码都是怎么导入,怎么编译,怎么看呢
    作者回复

    下载这里的源码,直接IDE打开,设断点就行。
    https://github.com/jetty-project/embedded-jetty-jsp

    2019-05-30 20:22:07

  • -W.LI-

    2019-06-02 21:07:17

    老师好,跑在不同的线程里是为了解耦么?实在想不出,告诉答案吧
    作者回复

    反过来想,如果等待连接到达,接收连接、等待数据到达、数据读取和请求处理(等待应用处理完)都在一个线程里,这中间线程可能大部分时间都在”等待“,没有干活,而线程资源是很宝贵的。并且线程阻塞会发生线程上下文切换,浪费CPU资源。

    2019-06-03 20:10:25

  • -W.LI-

    2019-06-01 09:43:30

    老师好!在线程模型设计上 Tomcat 的 NioEndpoint 跟 Jetty 的 Connector 是相似的,都是用一个 Acceptor 数组监听连接,用一个 Selector 数组侦测 I/O 事件。这句话怎么理解啊?
    问题1:Acceptor 数组监听连接,监听的是一次TCP链接么?

    问题2:Selector 数组侦测 I/O 事件,具体监听的是啥?

    问题3:长链接下,每次http请求会新打开一个channel的,还是复用原有的channel,channel是阻塞还是非阻塞的。

    说的有点乱不晓得老师看的懂不😂。我就是想知道,一次TCP链接,多次http具体是和connector怎么绑定的。
    作者回复

    简单可以这样理解:
    1. Acceptor就是不停的调accept函数,接收新的连接

    2. Selector不停的调select函数,查询某个Channel上是否有数据可读

    3. 同一个浏览器发过来的请求会重用TCP连接,也就是用同一个Channel

    Channel是非阻塞的,连接器里维护了这些Channel实例,过了一段时间超时到了channel还没数据到来,表面用户长时间没有操作浏览器,这时Tomcat才关闭这个channel。

    2019-06-01 16:26:33

  • gameboy120

    2019-07-09 10:07:06

    请问注册一堆回调函数的用意是什么?
    作者回复

    赶时髦,在应用层面模拟异步编程风格

    2019-07-12 00:04:04

  • 强哥

    2019-05-30 08:50:30

    说了各自的特点。但是感觉缺少关键性的对比,以及背后设计的理念,建议再深入探讨各自的主要差异及场景
    作者回复

    随着讲解的深入,会涉及这部分内容。Jetty和Tomcat没有本质区别,一般来说Jetty比较小巧,又可以高度裁剪和定制,因此适合放在嵌入式设备等对内存资源比较紧张的场合。而Tomcat比较成熟稳定,对企业级应用支持比较好。

    2019-05-30 20:26:34

  • kxkq2000

    2019-06-01 09:54:09

    分在不同的线程里我认为是这样分工明确好比工厂流水线最大化提升处理能力。
    我有个疑问是用全局线程池真的好吗,不是应该根据任务类型分配线程池的吗?用全局的不会互相干扰吗?
    作者回复

    全局线程池和多个隔离的线程池各有优缺点。全局的线程池方便控制线程总数,防止过多的线程导致大量线程切换。隔离的线程池可以控制任务优先级,确保低优先级的任务不会去抢高优先级任务的线程。

    2019-06-01 17:42:51

  • Lrwin

    2019-05-30 09:47:39

    感觉jetty就是一个netty模型
    作者回复

    说的很对,Tomcat和Jetty相比,Jetty的I/O线程模型更像Netty,后面会讲到Jetty的EatWhatYouKill线程策略,其实就是Netty 4.0中的线程模型。

    2019-05-30 20:21:26

  • Lc

    2019-05-30 08:14:09

    Jetty作为后起之秀,跟tomcat相比,它的优势在哪儿?他们的设计思路不同,我们自己在设计的时候应该依据什么来确定使用哪种呢?
    作者回复

    Jetty的优势是小巧,代码量小,比如它只支持非阻塞IO,这意味着把它加载到内存后占用内存空间也小,另外还可以把它裁剪的更小,比如不需要Session支持,可以方便的去掉相应的Hanlder。

    2019-05-30 20:31:10

  • Geek_28b75e

    2019-06-04 20:25:24

    老师,麻烦您给说说bio和nio的区别,表面上的差别我看过了,能不能从操作系统角度给讲解一下呢?麻烦您了,实在有点难理解
    作者回复

    第14篇会详细介绍,图文并茂。

    2019-06-04 23:29:23

  • 802.11

    2019-06-02 21:04:25

    老师,在一个类里再写接口或者类,一般是基于什么样的设计思想呢
    作者回复

    内部类,作为一个类的实现细节,外部不需要知道,通过内部类的方式实现显得整洁干净。

    2019-06-03 20:03:34

  • 毕延文

    2019-06-22 16:25:00

    前面几章还好,到这里就有些看不懂了,我还是太菜了。
    作者回复

    补一补相关的基础再来看会好点,22答疑篇希望能帮到你

    2019-06-24 20:14:18

  • 天天向上

    2019-05-30 22:26:09

    为了分工协作,IO工作,业务处理工作分成两大流程环节,互不干扰,分工明确,
    高效复用IO线程
  • Monday

    2019-05-30 08:43:09

    tomcat还没学透,就学jetty,两者还相互比较,生怕自己混淆了
    作者回复

    这个模块还只是架构层面的学习,下一个模块会深入到细节。

    2019-05-30 20:27:12

  • Wiggle Wiggle

    2019-08-03 14:27:31

    请教一下,在 SelectorManager 在 accept 方法里,向selector中注册channel的事件为什么还是on_accept呢?在上一步的serverchannel不是已经建立连接了吗?
    作者回复

    连接建立之后还要做些处理,这里onaccept应该建立连接的动作

    2019-08-03 22:34:26

  • 易儿易

    2019-07-01 20:33:50

    Jetty这个共享线程池的设计,在另一篇专讲并发的专栏里是明确提出严禁使用的……比如A调用B,线程池容量2,2个A同时开始执行,这个时候B无法创建,A无法释放……Jetty有特别设计可以避免这种情况吗?
    作者回复

    Acceptor和Poller的个数都是有限的,因此它们消耗的线程数是一定的,而剩下的Proccessor线程都做同一件事情,在Web容器看来优先级没有高低之分。

    2019-07-03 14:28:52

  • 梁中华

    2019-06-21 12:53:20

    jetty内部是用epoll的吗?还是只是传统的select?
    作者回复

    jetty也是通过java nio实现的,java nio底层调用了操作传统的epoll api。

    2019-06-21 19:31:31

  • 田程杯166

    2019-06-11 15:34:11

    java处理网络请求的底层都是socket,传统bio是将每个网络连接的socket封装完成后,交给单独的线程去处理,那nio是怎么处理?将每个网络连接封装成的socket,用一个线程去接受 ,然后放到一个数组里面,再用一个线程去轮询这个socket数组?那么实际处理业务的代码是在什么线程呢?业务线程是在哪里创建的呢?
    作者回复


    感觉这些问题在14篇都会得到解答...

    2019-06-11 20:55:04