解開BIO、NIO、AIO神祕的面紗

本文內容涉及同步與異步, 阻塞與非阻塞, BIO、NIO、AIO等概念, 這塊內容本身比較複雜, 很難用三言兩語說明白. 而書上的定義更不容易理解是什麼意思. 下面跟着我一起解開它們神祕的面紗。

  • BIO 傳統的socket編程,屬於同步阻塞模型
  • NIO 官方(new io) jdk1.4推出 俗稱(non-block io) ,屬於同步非阻塞模式
  • AIO 又稱NIO2.0在jdk1.7推出,屬於異步非阻塞模式

解讀同步異步,阻塞非阻塞。

阻塞和非阻塞

從簡單的開始,我們以經典的讀取文件的模型舉例。(對操作系統而言,所有的輸入輸出設備都被抽象成文件。)

在發起讀取文件的請求時,應用層會調用系統內核的I/O接口。

如果應用層調用的是阻塞型I/O,那麼在調用之後,應用層即刻被掛起,一直出於等待數據返回的狀態,直到系統內核從磁盤讀取完數據並返回給應用層,應用層才用獲得的數據進行接下來的其他操作。

如果應用層調用的是非阻塞I/O,那麼調用後,系統內核會立即返回(雖然還沒有文件內容的數據),應用層並不會被掛起,它可以做其他任意它想做的操作。(至於文件內容數據如何返回給應用層,這已經超出了阻塞和非阻塞的辨別範疇。)

這便是(脫離同步和異步來說之後)阻塞和非阻塞的區別。總結來說,是否是阻塞還是非阻塞,關注的是接口調用(發出請求)後等待數據返回時的狀態。被掛起無法執行其他操作的則是阻塞型的,可以被立即「抽離」去完成其他「任務」的則是非阻塞型的。


同步和異步

阻塞和非阻塞解決了應用層等待數據返回時的狀態問題,那系統內核獲取到的數據到底如何返回給應用層呢?這裏不同類型的操作便體現的是同步和異步的區別。

對於同步型的調用,應用層需要自己去向系統內核問詢,如果數據還未讀取完畢,那此時讀取文件的任務還未完成,應用層根據其阻塞和非阻塞的劃分,或掛起或去做其他事情(所以同步和異步並不決定其等待數據返回時的狀態);如果數據已經讀取完畢,那此時系統內核將數據返回給應用層,應用層即可以用取得的數據做其他相關的事情。

而對於異步型的調用,應用層無需主動向系統內核問詢,在系統內核讀取完文件數據之後,會主動通知應用層數據已經讀取完畢,此時應用層即可以接收系統內核返回過來的數據,再做其他事情。

這便是(脫離阻塞和非阻塞來說之後)同步和異步的區別。也就是說,是否是同步還是異步,關注的是任務完成時消息通知的方式。由調用方盲目主動問詢的方式是同步調用,由被調用方主動通知調用方任務已完成的方式是異步調用。

假設小明需要在網上下載一個軟件:

如果小明點擊下載按鈕之後,就一直幹瞪着進度條不做其他任何事情直到軟件下載完成,這是同步阻塞;
如果小明點擊下載按鈕之後,就一直幹瞪着進度條不做其他任何事情直到軟件下載完成,但是軟件下載完成其實是會「叮」的一聲通知的(但小明依然那樣乾等着),這是異步阻塞;(不常見)
如果小明點擊下載按鈕之後,就去做其他事情了,不過他總需要時不時瞄一眼屏幕看軟件是不是下載完成了,這是同步非阻塞;
如果小明點擊下載按鈕之後,就去做其他事情了,軟件下載完之後「叮」的一聲通知小明,小明再回來繼續處理下載完的軟件,這是異步非阻塞。
相信看完以上這個案例之後,這幾個概念已經能夠分辨得很清楚了。

總的來說,同步和異步關注的是任務完成消息通知的機制,而阻塞和非阻塞關注的是等待任務完成時請求者的狀態。

java網絡編程

我們通過 客戶端像服務端查詢信息作爲一個例子。分別通過三種模型來實現。

1.1、傳統的BIO

在傳統的網絡編程中,服務端監聽端口,客戶端請求服務端的ip跟監聽的端口,跟服務端通信,必須三次握手建立。如果連接成功,通過套接字(socket)進行通信。
在BIO通信模型:採用BIO通信模型的服務端,通常由一個獨立的Acceptor線程負責監聽客戶端的連接,它接收到客戶端連接請求之後爲每個客戶端創建一個新的線程進行鏈路處理沒處理完成後,通過輸出流返回應答給客戶端,線程銷燬。即典型的一請求一應答通信模型。



從圖中可以得知,該模型中每一個請求對應一個線程處理,在線程數量有限的情況下,請求數量多,那麼服務器就會因爲資源不足而掛掉。
服務端代碼

/**
 * @author yukong
 * @date 2018年8月24日18:51:40
 * 服務端
 */
public class Server {

    /**
     * 默認端口
     */
    private static final Integer DEFAULT_PORT = 6789;

    public  void start() throws IOException {
        start(DEFAULT_PORT);
    }

    public  void start(Integer port) throws IOException {
        ServerSocket serverSocket = new ServerSocket(port);
        System.out.println("小yu機器人啓動,監聽端口爲:" + port);
        //通過無線循環監聽客戶端連接
        while (true) {
            // 阻塞方法,直至有客戶端連接成功
            Socket socket = serverSocket.accept();
            // 多線程處理客戶端請求
            new Thread(new ServerHandler(socket)).start();
        }
    }

    public static void main(String[] args) throws IOException {
        new Server().start();
    }


}

服務端處理器代碼

/**
 * @author yukong
 * @date 2018年8月24日18:51:40
 *  服務端業務邏輯處理器
 */
public class ServerHandler implements Runnable{

    private Socket socket;

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

    @Override
    public void run() {
        BufferedReader in = null;
        PrintWriter out = null;

        try {
            // 獲取socket的字符緩存輸入流 也就是獲取客戶端給服務器的字符流
            in = new BufferedReader( new InputStreamReader(this.socket.getInputStream()));
            // 獲取socket的字符輸出流 也就是發送的客戶的字符流 第二個參數自動刷新
            out = new PrintWriter( new OutputStreamWriter(this.socket.getOutputStream()), true);
            String request, response;
            // 讀取輸入流的消息 如果爲空 則退出讀取
            while ((request = in.readLine()) != null) {
                System.out.println("[" + Thread.currentThread().getName()+ "]" + "小yu機器人收到消息:" + request);
                // 具體業務邏輯處理 查詢信息。
                response = ResponseUtil.queryMessage(request);
                out.println(response);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }  finally {
            // 資源釋放
            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                in = null;
            }
            if (out != null) {
                out.close();
                out = null;
            }
            if (socket != null) {
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                socket = null;
            }
        }
    }


}

客戶端代碼

/**
 * @author yukong
 * @date 2018年8月24日18:51:40
 * 客戶端
 */
public class Client {

    /**
     * 默認端口
     */
    private static final Integer DEFAULT_PORT = 6789;

    /**
     * 默認端口
     */
    private static final String DEFAULT_HOST = "localhost";

    public  void send(String key){
        send(DEFAULT_PORT,key);
    }
    public  void send(int port,String key){
        System.out.println("查詢的key爲:" + key);
        Socket socket = null;
        BufferedReader in = null;
        PrintWriter out = null;
        try{
            socket = new Socket(DEFAULT_HOST,port);
            in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            out = new PrintWriter(socket.getOutputStream(),true);
            out.println(key);
            System.out.println("查詢的結果爲:" + in.readLine());
        }catch(Exception e){
            e.printStackTrace();
        }finally{
            if(in != null){
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                in = null;
            }
            if(out != null){
                out.close();
                out = null;
            }
            if(socket != null){
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                socket = null;
            }
        }
    }

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        Client client = new Client();
        while (scanner.hasNext()) {
            String key = scanner.next();
            client.send(key);
        }
    }

}

從代碼中可以得知,我們每次請求都是new Thread去處理,意味着線程消耗巨大,可能會有朋友說道,那就用線程池,同樣的如果使用線程池,當達到線程最大數量,也會達到瓶頸。該模式不適合高併發的訪問。

1.2 NIO模型

NIO提供了與傳統BIO模型中的Socket和ServerSocket相對應的SocketChannel和ServerSocketChannel兩種不同的套接字通道實現。

新增的着兩種通道都支持阻塞和非阻塞兩種模式。
阻塞模式使用就像傳統中的支持一樣,比較簡單,但是性能和可靠性都不好;非阻塞模式正好與之相反。
對於低負載、低併發的應用程序,可以使用同步阻塞I/O來提升開發速率和更好的維護性;對於高負載、高併發的(網絡)應用,應使用NIO的非阻塞模式來開發。
下面會先對基礎知識進行介紹。

1.2.1、緩衝區 Buffer

Buffer是一個對象,包含一些要寫入或者讀出的數據。

在NIO庫中,所有數據都是用緩衝區處理的。在讀取數據時,它是直接讀到緩衝區中的;在寫入數據時,也是寫入到緩衝區中。任何時候訪問NIO中的數據,都是通過緩衝區進行操作。
緩衝區實際上是一個數組,並提供了對數據結構化訪問以及維護讀寫位置等信息。
具體的緩存區有這些:ByteBuffe、CharBuffer、 ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。他們實現了相同的接口:Buffer。

1.2.2、通道 Channel

我們對數據的讀取和寫入要通過Channel,它就像水管一樣,是一個通道。通道不同於流的地方就是通道是雙向的,可以用於讀、寫和同時讀寫操作。
底層的操作系統的通道一般都是全雙工的,所以全雙工的Channel比流能更好的映射底層操作系統的API。
Channel主要分兩大類:

  • SelectableChannel:用戶網絡讀寫

  • FileChannel:用於文件操作

    後面代碼會涉及的ServerSocketChannel和SocketChannel都是SelectableChannel的子類。

1. 2.3、多路複用器 Selector

Selector是Java NIO 編程的基礎。
Selector提供選擇已經就緒的任務的能力:Selector會不斷輪詢註冊在其上的Channel,如果某個Channel上面發生讀或者寫事件,這個Channel就處於就緒狀態,會被Selector輪詢出來,然後通過SelectionKey可以獲取就緒Channel的集合,進行後續的I/O操作。
一個Selector可以同時輪詢多個Channel,因爲JDK使用了epoll()代替傳統的select實現,所以沒有最大連接句柄1024/2048的限制。所以,只需要一個線程負責Selector的輪詢,就可以接入成千上萬的客戶端。
服務端代碼

/**
 * 服務端
 */
public class Server {


    /**
     * 默認端口
     */
    private static final Integer DEFAULT_PORT = 6780;

    public  void start() throws IOException {
        start(DEFAULT_PORT);
    }

    public  void start(Integer port) throws IOException {
        // 打開多路複用選擇器
        Selector selector = Selector.open();
        //  打開服務端監聽通道
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        // 設置爲非阻塞模式
        serverSocketChannel.configureBlocking(false);
        // 綁定監聽的端口
        serverSocketChannel.bind(new InetSocketAddress(port));
        // 將選擇器綁定到監聽信道,只有非阻塞信道纔可以註冊選擇器.並在註冊過程中指出該信道可以進行Accept操作
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("小yu機器人啓動,監聽端口爲:" + port);
        new Thread(new ServerHandler(selector)).start();
    }

    public static void main(String[] args) throws IOException {
        new Server().start();
    }

}

服務端處理器

public class ServerHandler implements Runnable{

    private Selector selector;

    public ServerHandler(Selector selector) {
        this.selector = selector;
    }

    @Override
    public void run() {
        try {
        while (true) {
            // 等待某信道就緒(或超時)
            if(selector.select(1000)==0){
//                System.out.print("獨自等待.");
                continue;
            }
            // 取得迭代器.selectedKeys()中包含了每個準備好某一I/O操作的信道的SelectionKey
            Iterator<SelectionKey> keyIterator=selector.selectedKeys().iterator();
           while (keyIterator.hasNext()){
               SelectionKey sk = keyIterator.next();
               // 刪除已選的key 以防重負處理
               keyIterator.remove();
               // 處理key
               handlerSelect(sk);

            }

        }


       } catch (IOException e) {

       }
    }

    private void handlerSelect(SelectionKey sk) throws IOException {
        // 處理新接入的請求
        if (sk.isAcceptable()) {
            ServerSocketChannel ssc = (ServerSocketChannel) sk.channel();
            //通過ServerSocketChannel的accept創建SocketChannel實例
            //完成該操作意味着完成TCP三次握手,TCP物理鏈路正式建立
            SocketChannel sc = ssc.accept();
            //設置爲非阻塞的
            sc.configureBlocking(false);
            //註冊爲讀
            sc.register(selector, SelectionKey.OP_READ);
        }
        // 讀操作
        if (sk.isReadable()) {
            String request, response;
            SocketChannel sc = (SocketChannel) sk.channel();
            // 創建一個ByteBuffer 並設置大小爲1m
            ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
            // 獲取到讀取的字節長度
            int readBytes = sc.read(byteBuffer);
            // 判斷是否有數據
            if (readBytes > 0) {
                //將緩衝區當前的limit設置爲position=0,用於後續對緩衝區的讀取操作
                byteBuffer.flip();
                //根據緩衝區可讀字節數創建字節數組
                byte[] bytes = new byte[byteBuffer.remaining()];
                // 複製至新的緩衝字節流
                byteBuffer.get(bytes);
                request = new String(bytes, "UTF-8");
                System.out.println("[" + Thread.currentThread().getName()+ "]" + "小yu機器人收到消息:" + request);
                // 具體業務邏輯處理 查詢信息。
                response = ResponseUtil.queryMessage(request);
                //將消息編碼爲字節數組
                byte[] responseBytes = response.getBytes();
                //根據數組容量創建ByteBuffer
                ByteBuffer writeBuffer = ByteBuffer.allocate(responseBytes.length);
                //將字節數組複製到緩衝區
                writeBuffer.put(responseBytes);
                //flip操作
                writeBuffer.flip();
                //發送緩衝區的字節數組
                sc.write(writeBuffer);
            }
        }
    }


}

客戶端

/**
 * 客戶端
 */
public class Client {


    // 通道選擇器
    private Selector selector;

    // 與服務器通信的通道
    SocketChannel socketChannel;

    /**
     * 默認端口
     */
    private static final Integer DEFAULT_PORT = 6780;

    /**
     * 默認端口
     */
    private static final String DEFAULT_HOST = "127.0.0.1";

    public  void send(String key) throws IOException {
        send(DEFAULT_PORT, DEFAULT_HOST, key);
    }
    public  void send(int port,String host, String key) throws IOException {
        init(port, host);
        System.out.println("查詢的key爲:" + key);
        //將消息編碼爲字節數組
        byte[] bytes = key.getBytes();
        //根據數組容量創建ByteBuffer
        ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
        //將字節數組複製到緩衝區
        writeBuffer.put(bytes);
        //flip操作
        writeBuffer.flip();
        //發送緩衝區的字節數組
        socketChannel.write(writeBuffer);
        //****此處不含處理“寫半包”的代碼
    }

    public void init(int port,String host) throws IOException {
        // 創建選擇器
        selector = Selector.open();

        // 設置鏈接的服務端地址
        InetSocketAddress socketAddress = new InetSocketAddress(host, port);
        // 打開通道
        socketChannel = SocketChannel.open(socketAddress);// 非阻塞
        socketChannel.configureBlocking(false);
        socketChannel.register(selector, SelectionKey.OP_READ);
        new Thread(new ClientHandler(selector)).start();
    }

    public static void main(String[] args) throws IOException {
        Scanner scanner = new Scanner(System.in);
        Client client = new Client();
        while (scanner.hasNext()) {
            String key = scanner.next();
            client.send(key);
        }
    }

}

客戶端處理器

public class ClientHandler implements Runnable {

    private Selector selector;

    public ClientHandler(Selector selector) {
        this.selector = selector;
    }

    @Override
    public void run() {
        try {
            while (true) {
                if(selector.select(1000) < 0) {
                 continue;
                }
                Set<SelectionKey> keys = selector.selectedKeys();
                Iterator<SelectionKey> selectionKeyIterator = keys.iterator();
                while (selectionKeyIterator.hasNext()) {
                    SelectionKey sc = selectionKeyIterator.next();
                    selectionKeyIterator.remove();
                    //讀消息
                    if (sc.isReadable()) {
                        SocketChannel socketChannel = (SocketChannel) sc.channel();
                        //創建ByteBuffer,並開闢一個1M的緩衝區

                        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                        //讀取請求碼流,返回讀取到的字節數
                        int byteSize = socketChannel.read(byteBuffer);
                        if (byteSize > 0) {
                            //將緩衝區當前的limit設置爲position=0,用於後續對緩衝區的讀取操作
                            byteBuffer.flip();
                            //根據緩衝區可讀字節數創建字節數組 總長度減去空餘的
                            byte[] bytes = new byte[byteBuffer.remaining()];
                            // 複製至新的緩衝字節流
                            byteBuffer.get(bytes);
                            String message = new String(bytes, "UTF-8");
                            System.out.println(message);
                        }
                    }
                }
            }
        } catch (IOException e) {

        }
    }
}

從代碼中 我們也能看出來,nio解決的是阻塞與非阻塞的,通過selector輪詢上註冊的channel的狀態,來獲取對應準備就緒channel的 那麼請求者就不用一直去accpet阻塞,等待了。那爲什麼是同步呢,因爲還是我們請求者不停的輪詢selector是否有完全就緒的channel。

3、AIO編程

NIO 2.0引入了新的異步通道的概念,並提供了異步文件通道和異步套接字通道的實現。
異步的套接字通道時真正的異步非阻塞I/O,對應於UNIX網絡編程中的事件驅動I/O(AIO)。他不需要過多的Selector對註冊的通道進行輪詢即可實現異步讀寫,從而簡化了NIO的編程模型。
服務端代碼

/**
 *  異步非阻塞服務端
 */
public class Sever {

    /**
     * 默認端口
     */
    private static final Integer DEFAULT_PORT = 6780;

    private AsynchronousServerSocketChannel serverChannel;


    //作爲handler接收客戶端連接
    class ServerCompletionHandler implements CompletionHandler<AsynchronousSocketChannel, Void> {

        private AsynchronousServerSocketChannel serverChannel;
        private ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
        private CharBuffer charBuffer;
        private CharsetDecoder decoder = Charset.defaultCharset().newDecoder();

        public ServerCompletionHandler(AsynchronousServerSocketChannel serverChannel) {
            this.serverChannel = serverChannel;
        }


        @Override
        public void completed(AsynchronousSocketChannel result, Void attachment) {

            //立即接收下一個請求,不停頓
            serverChannel.accept(null, this);
            try {
                while (result.read(buffer).get() != -1) {
                    buffer.flip();
                    charBuffer = decoder.decode(buffer);
                    String request = charBuffer.toString().trim();
                    System.out.println("[" + Thread.currentThread().getName()+ "]" + "小yu機器人收到消息:" + request);
                    // 具體業務邏輯處理 查詢信息。
                    String response = ResponseUtil.queryMessage(request);
                    //將消息編碼爲字節數組
                    byte[] responseBytes = response.getBytes();
                    //根據數組容量創建ByteBuffer
                    ByteBuffer outBuffer = ByteBuffer.allocate(responseBytes.length);
                    //將字節數組複製到緩衝區
                    outBuffer.put(responseBytes);
                    //flip操作
                    outBuffer.flip();
                    //發送緩衝區的字節數組
                    result.write(outBuffer).get();
                    if (buffer.hasRemaining()) {
                        buffer.compact();
                    } else {
                        buffer.clear();
                    }
                }
            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
            } catch (CharacterCodingException e) {
                e.printStackTrace();
            } finally {
                try {
                    result.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

        }

        @Override
        public void failed(Throwable exc, Void attachment) {
            //立即接收下一個請求,不停頓
            serverChannel.accept(null, this);
            throw new RuntimeException("connection failed!");
        }

    }


    public void init() throws IOException, InterruptedException {
        init(DEFAULT_PORT);
    }

    public void init(Integer port) throws IOException, InterruptedException {
        // 打開異步通道
        this.serverChannel = AsynchronousServerSocketChannel.open();
        // 判斷通道是否打開
        if (serverChannel.isOpen()) {
            serverChannel.setOption(StandardSocketOptions.SO_RCVBUF, 4 * 1024);
            serverChannel.setOption(StandardSocketOptions.SO_REUSEADDR, true);
            serverChannel.bind(new InetSocketAddress(port));
        } else {
            throw new RuntimeException("Channel not opened!");
        }
        start(port);
    }

    public void start(Integer port) throws InterruptedException {
        System.out.println("小yu機器人啓動,監聽端口爲:" + port);
        this.serverChannel.accept(null, new ServerCompletionHandler(serverChannel));
        // 保證線程不會掛了
        while (true) {
            Thread.sleep(5000);
        }
    }


    public static void main(String[] args) throws IOException, InterruptedException {
        Sever server = new Sever();
        server.init();
    }
}

客戶端

public class Client {

    class ClientCompletionHandler implements CompletionHandler<Void, Void> {

        private AsynchronousSocketChannel channel;
        private CharBuffer charBufferr = null;
        private CharsetDecoder decoder = Charset.defaultCharset().newDecoder();
        private BufferedReader clientInput = new BufferedReader(new InputStreamReader(System.in));

        public ClientCompletionHandler(AsynchronousSocketChannel channel) {
            this.channel = channel;
        }


        @Override
        public void completed(Void result, Void attachment) {

            System.out.println("Input Client Reuest:");
            String request;
            try {
                request = clientInput.readLine();
                channel.write(ByteBuffer.wrap(request.getBytes()));
                ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
                while(channel.read(buffer).get() != -1){
                    buffer.flip();
                    charBufferr = decoder.decode(buffer);
                    System.out.println(charBufferr.toString());
                    if(buffer.hasRemaining()){
                        buffer.compact();
                    }
                    else{
                        buffer.clear();
                    }
                    request = clientInput.readLine();
                    channel.write(ByteBuffer.wrap(request.getBytes())).get();
                }
            } catch (IOException e) {
                e.printStackTrace();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
            finally {
                try {
                    channel.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }


        }

        @Override
        public void failed(Throwable exc, Void attachment) {
            throw  new  RuntimeException("channel not opened!");
        }

    }

    public void start() throws IOException, InterruptedException{
        AsynchronousSocketChannel channel = AsynchronousSocketChannel.open();
        if(channel.isOpen()){
            channel.setOption(StandardSocketOptions.SO_RCVBUF, 128*1024);
            channel.setOption(StandardSocketOptions.SO_SNDBUF, 128*1024);
            channel.setOption(StandardSocketOptions.SO_KEEPALIVE,true);
            channel.connect(new InetSocketAddress("127.0.0.1",6780),null,new ClientCompletionHandler(channel));
            while(true){
                Thread.sleep(5000);
            }
        }
        else{
            throw new RuntimeException("Channel not opened!");
        }
    }

    public static void main(String[] args) throws IOException, InterruptedException{
        Client client = new Client();
        client.start();
    }


}

4、各種I/O的對比

先以一張表來直觀的對比一下:


5

References
完全理解同步/異步與阻塞/非阻塞
Java 網絡IO編程總結
下面是配套的完整代碼地址
完整代碼地址

最後大家關注一下我的個人公衆號把。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章