Java socket詳解,看這一篇就夠了續(xù)

前一段時間裸燎,博主利用忙里偷閑的時間茅逮,對Java Socket通信進行了一個簡單的描述,由淺入深嚼蚀,循序漸進的將Java socket基本核心通過完善的小示例講解了一下,根據(jù)部分網(wǎng)友的反映管挟,雖然沒到達到立竿見影的效果轿曙,但對需要進一步了解socket通信的網(wǎng)友來說,至少可以說是雪中送炭僻孝、錦上添花吧导帝。

其次,根據(jù)部分網(wǎng)上的反映穿铆,對代碼的可讀性比較差您单,不夠美觀的問題,在這里解釋一下荞雏,代碼是由idea直接拷貝過來的虐秦,為了方便網(wǎng)友可以直接在本地運行,然后觀察具體的運行結果凤优,有助于進一步加深理解悦陋,在后續(xù)的章節(jié)中,我會將代碼的可讀性進一步完善筑辨,提高代碼的可讀性俺驶,在這里同時多謝網(wǎng)友提出的寶貴意見。
接下來我們將進一步討論Java socket 異步通信棍辕,Java socket異步通信包主要位于是在Java.nio框架下暮现,在講解Java socket異步通信前还绘,我們先來回顧一下傳統(tǒng)socket通信的演進。
Blocking I/O 模式

image

BlockIng I/O模式下栖袋,主要缺點如下:

  1. 只能用于小規(guī)模下多個socket通信蚕甥,因為客戶端socket每次連接請求后,服務端ServerSocket都會創(chuàng)建一個線程來處理當前客戶端的連接請求栋荸,如果連接數(shù)非常大,以千萬級為單位凭舶,那么服務端的CPU資源開銷會是一個非常龐大的數(shù)據(jù)晌块。

  2. Read、Write讀寫資源問題帅霜,由于是阻塞的讀寫模式匆背,如果大量線程處于空閑狀態(tài)沒有數(shù)據(jù)可讀寫,則會造成空閑socket的Read 身冀、Write操作大量阻塞钝尸,對系統(tǒng)資源線程的開銷也會造成非常大的浪費。

接下來我們看看NIO(not Blocking I/O 搂根,也有人叫他new IO)的工作原理珍促,NIO主要實現(xiàn)機制于IO最大的區(qū)別在他通過選擇器與采用觀察者模式將之前大量連接采用一個線程即可搞定,同時通過通道的方式剩愧,可對流進行重復選擇的讀取猪叙,下面我們通過圖形來描述一下NIO的工作原理。

image

NIO模式下下的優(yōu)點:

1. NIO采用channel 與selector結合方式仁卷,可以多次從通過讀寫或者寫入通道數(shù)據(jù)穴翩,并且可以讀取指定位置的數(shù)據(jù),而傳統(tǒng)io方式锦积,采用流的方式對數(shù)據(jù)進行讀取芒帕,一但打開流,那么只能讀取到流的結尾丰介,無法從流指定的位置進行讀取背蟆。我們把數(shù)據(jù)流比作打開的自來水管一樣,你沒法只獲取水流中的一部分數(shù)據(jù)基矮。

2. socketor選擇器淆储,在通過socketchannl將socket注冊到選擇器中,那么就可以通過一個線程處理注冊進來的所有socket家浇。socketor說的通俗一點就像飯店的點菜系統(tǒng)本砰,比如說在傳統(tǒng)上,我們點菜的流程是這樣的钢悲,拿著菜單点额,把服務員叫過來舔株,你在點菜,服務員在旁邊候著还棱,形成的方式是客戶和服務員一對一的方式载慈,如果飯店只有10個服務員,那么我只能服務10個用戶珍手,這樣是效率及其底下的办铡。而使用socketor后,在你點菜的時候琳要,服務員給你一個電子菜單(或者像海底撈的紙質可以選擇的菜單)寡具,你自己將需要點的菜在菜單上勾選,點好了直接給服務員就可以了稚补,這樣加入飯店來了100個客人童叠,那個10個服務員就只需要將菜單發(fā)給客戶,客戶自己選擇菜名后课幕,交給服務員即可厦坛。

3. 我們知道流的數(shù)據(jù)是單向的,而socketChannel則是雙向的乍惊,我們繼可以向通道中寫數(shù)據(jù)杜秸,也可以從通道中讀取數(shù)據(jù),并且通道中的數(shù)據(jù)讀寫都是通過buffer實現(xiàn)的污桦。

上面我們簡單的介紹了一下NIO中socket的應用原理亩歹,接下來我們詳細介紹一下NIO中socket相關的知識點,由于NIO框架下涉及的類和接口非常多凡橱,在這里我們主要講解的是nio下的socket通信小作,所有我把nio下的關于socket相關的主要的幾個類和接口進行整理和分類一下,方便大家有個脈絡稼钩,其實顾稀,我們分析一下,nio下和socket通信相關的我們可以把大分為三大類(其實應該是倆類坝撑,channel 與buffer静秆,selector相關的也是在channel下,在這里是主要是為了給大家講解的清楚巡李,我把selector拿出來了抚笔,進行單獨的分類),channel侨拦、buffer與selector三大類殊橙,每一種類型下面涉及到常用的類和接口我在大家整理一下,請看下面的這個思維導圖:

image

首先我們來看一下buffer、channel與selector這三者之間的區(qū)別和聯(lián)系膨蛮,channel通道叠纹,這里我們可以把它理解為傳統(tǒng)io的流,而buffer就是針對channel 的一個緩沖區(qū)敞葛,他就是一個連續(xù)的內存塊誉察,是NIO數(shù)據(jù)的一個中轉站。我們可以將channel中的數(shù)據(jù)讀取到buffer中惹谐,也可以將buffer中的數(shù)據(jù)寫入到channel中持偏,所以channel是雙向的,可以進行讀寫操作氨肌,而傳統(tǒng)IO基于字節(jié)流的操作综液,讀和寫都是分開的,我們必須打開對應Input才可以操作IO儒飒。

接下來,我們首先看channel包中的這幾個核心的類檩奠。在這里我主要介紹一下服務端的socketChannel 與客戶端的socketchannel桩了。其他的類大家可以自行閱讀API,結合源碼我詳細有更深入的了解埠戳。

ServerSocketChannel 類是有常用的幾個方法分別是:

  1. abstract SocketChannel accept() 井誉。接受來之Channel通道socket的連接。

  2. ServerSocketChannel bind(SocketAddress local)整胃。將通道的socket綁定到本地地址颗圣。

  3. abstract ServerSocketChannel bind(SocketAddresslocal, int backlog)。是上一個方法的重載屁使,也是剛通道的socket綁定到本地地址在岂,第一個參數(shù)是本地地址,第二個表示掛起連接數(shù)的最大值蛮寂。

  4. abstract SocketAddress getLocalAddress()蔽午。返回當前通道socket綁定的本地地址

  5. static ServerSocketChannel open()。 打開一個ServersocketChannel酬蹋。

6. abstract ServerSocket socket()及老。檢索通道相關聯(lián)的socket

由于ServerSocketChannel 繼承了ServerSocketChannel 并且實現(xiàn)了NetworkChannel 的接口,所有他換有一些其他的方法可用范抓,比如:

7. void close()骄恶。 關閉通道的方法。

8. abstract SelectableChannel configureBlocking(boolean block)匕垫。調整通道的阻塞模式僧鲁。

9.SelectionKey register(Selectorsel, int ops)。將通道注冊到制定的選擇器上,

  1. SelectorProvider provider()悔捶。返回創(chuàng)建通道的提供程序

SocketChannel 類是有常用的幾個方法分別是:

  1. abstract SocketChannel bind(SocketAddresslocal)铃慷。 將通道的socket綁定到本地地址。

  2. abstract boolean connect(SocketAddress remote)蜕该。

3. abstract SocketAddress getLocalAddress()犁柜。

4. abstract SocketAddress getRemoteAddress()。

5. abstract boolean isConnected()堂淡。

6. static SocketChannel open()馋缅。 打開一個socketChannel

  1. static SocketChannel open(SocketAddress remote)。

8.abstract Socket socket()绢淀。 檢索與通道相關聯(lián)的socket

  1. abstract SocketChannels hutdownInput()萤悴。 在不關閉通道的情況下,關閉連接已方便獲取數(shù)據(jù)皆的。

Selector 類是有常用的幾個方法分別是:

1. abstract void close() 覆履。 關閉當前選擇器

2. abstract boolean isOpen()。 當前選擇器是否打開

3. abstract Set<SelectionKey> keys()费薄。返回當前選擇器中的key硝全,是一個set集合

4. static Selector open()。 打開一個選擇器楞抡。

5. abstract int select()伟众。當對的通道io準備好時選擇一組鍵,

6. abstract Set<SelectionKey> selectedKeys()召廷。返回當前選擇器的selected-key set.集合

SelectionKey類有四個屬性凳厢,分別是:

static int OP_ACCEPT 接受socket

static int OP_CONNECT 開始連接

static int OP_READ 開始讀數(shù)據(jù)

static int OP_WRITE 開始寫數(shù)據(jù)

同時也有對應的幾個方法。分別是isAcceptable()竞慢、isConnectable()先紫、isReadable() 和isWritable()。

上面我們對常用的幾個接口和方法進行進行了詳細的介紹筹煮,接下來我們就通過詳細的例子抽絲剝繭了解他們的原理泡孩,

先來一個簡單的例子:
ServerSocketChannel 服務端.


package SocketChannel;

import java.io.IOException;

import java.net.InetSocketAddress;

import java.nio.ByteBuffer;

import java.nio.channels.ServerSocketChannel;

import java.nio.channels.SocketChannel;

import java.nio.charset.Charset;

public class ServerSocketChnnel1 {

public static void main(String[] args) {

try {

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

            serverSocketChannel.bind(new InetSocketAddress(9000));

            serverSocketChannel.configureBlocking(false);

            while (true){

SocketChannel socketChannel = serverSocketChannel.accept();

                while (socketChannel!=null){

ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

                    int i = socketChannel.read(byteBuffer);

                    byteBuffer.flip();

                    System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer)

.toString());

                }

}

}catch (IOException e) {

e.printStackTrace();

        }

}

}

客戶端:

package socket;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Date;

public class ClientSocket {

    public static void main(String[] args) {
        Socket socket;

        {
            try {
                socket = new Socket("127.0.0.1",9000);
                OutputStream outputStream = socket.getOutputStream();
                outputStream.write("你好".getBytes());
                outputStream.flush();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

}

運行后我們可以看到控制臺收到了客戶端的信息。


image.png

錯誤寺谤,我們先不需要關注仑鸥,后面我們一步一步帶大家講解。
首先我們分析服務端程序:
第一步:通過ServerSocketCHannel.open()打開這個Channel通道,我們看一下他這個源碼:

   public static ServerSocketChannel open() throws IOException {
        return SelectorProvider.provider().openServerSocketChannel();
    }

他是調用SelectorProvider類的provider()方法,獲取SelectorProvider腹泌,然后在調用SelectorProvider的openServerSocketChannel()方法。其中provider()方法是一個線程安全的疮胖。

 public static SelectorProvider provider() {
        synchronized (lock) {
            if (provider != null)
                return provider;
            return AccessController.doPrivileged(
                new PrivilegedAction<SelectorProvider>() {
                    public SelectorProvider run() {
                            if (loadProviderFromProperty())
                                return provider;
                            if (loadProviderAsService())
                                return provider;
                            provider = sun.nio.ch.DefaultSelectorProvider.create();
                            return provider;
                        }
                    });
        }
    }

通過上面我們可以看到,open()方法打開一個線程安全的ServerSocketChannel。
第二步:我們通過bind()方法綁定對應的端口澎灸。這個和我們普通的ServerSocket類似院塞。
第三步:通過configureBlocking()方法設置阻塞方式。
第四步:就可以通過accept方法接受對應的請求了性昭。
通過上面的小例子拦止,我們簡單的描述了一下ServerSocketChannel最基本的概念和應用,讓大家有一個初步的認識糜颠,那么在接下來的示例中汹族,我會引入Selector 選擇器、ByteBuffer緩存其兴、已經IO多路復用的幾種模式顶瞒。
上面只是一個簡單的SocketChannel示例,一次接受一個一個socket請求元旬,接下來我們對上面的是示例進一步細化榴徐。
服務端ServerSocketChannel 示例:

package SocketChannel;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;

/**
 * @創(chuàng)建人:zhangzhiqiang
 * @創(chuàng)建時間:2019/09/03
 * @描述:
 * @聯(lián)系方式:QQ:125717901
 **/

public class ServerSocketChannel2 {
    public static void main(String[] args) {

        try {
            // 第一步
            ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
            serverSocketChannel.bind(new InetSocketAddress(9000));
            serverSocketChannel.configureBlocking(false);

            while (true){
                SocketChannel socketChannel = serverSocketChannel.accept();
                if(socketChannel!=null){
                    System.out.println("有新的客戶端進來連接");
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                    while (socketChannel.read(byteBuffer)!=-1){
                        byteBuffer.flip();
                            System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer).toString());
                            byteBuffer.clear();
                    }

                }


            }

        } catch (IOException e) {
            e.printStackTrace();
        }

    }
}

客戶端SocketChannel示例:

package SocketChannel;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

/**
 * @創(chuàng)建人:zhangzhiqiang
 * @創(chuàng)建時間:2019/09/03
 * @描述:
 * @聯(lián)系方式:QQ:125717901
 **/

public class SocketChannel2 {
    public static void main(String[] args) {

        try {
            SocketChannel socketChannel = SocketChannel.open();
            socketChannel.connect(new InetSocketAddress("127.0.0.1",9000));
            socketChannel.configureBlocking(false);
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
            String str = null;
            while ((str=bufferedReader.readLine())!=null){
                ByteBuffer byteBuffer = ByteBuffer.allocate(str.length());
               byteBuffer.put(str.getBytes());
               byteBuffer.flip();
               socketChannel.write(byteBuffer);
               byteBuffer.clear();

            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

在這里,我們服務端和客戶端都是采用Channel的方式創(chuàng)建的匀归,和傳統(tǒng)的Socket不一樣箕速,服務端不通過的輪詢是否有信息進來,如果有信息進來我們就創(chuàng)建一個ByteBuffer用來接收信息朋譬,然后在打印出來。服務端我們設置的非阻塞的方式兴垦,所以在下面接收到客戶端socket的時候徙赢,需要進行一個判斷,當前socket是否為null探越。
知識點:

  1. serverSocketChannel.configureBlocking(false); 設置通道的阻塞方式狡赐。
  2. if(socketChannel!=null) ;判斷當前socket是否為空钦幔,如果不為空我們才能進行后續(xù)的業(yè)務操作枕屉。
  3. ByteBuffer.allocate(1024); 分配指定大小的ByteBuffer,ByteBuffer.allocateDirect(1024)分配一個直接的ByteBuffer效率更高鲤氢,對于將一些文件讀取到內存中處理來說可以使用該方法搀擂。
  4. byteBuffer.flip();用于將緩存區(qū)進行翻轉,通過我們將數(shù)據(jù)寫入Buffer中卷玉,如果想讀取的話哨颂,一般我們采用byteBuffer.flip()方法將緩存區(qū)進行翻轉,然后在讀取緩存區(qū)的數(shù)據(jù)相种,其實這個byteBuffer.flip()方法并不是真的把緩存區(qū)翻了一個威恼,他只是將buffer中的mark、position、limit進行重新標記了一下箫措,方便數(shù)據(jù)讀取腹备。我們來看一下flip()方法的源碼:
    public final Buffer flip() {
        limit = position;
        position = 0;
        mark = -1;
        return this;
    }

在這里我們可以看到,重置了position 和limit 斤蔓。

  1. ByteBuffer數(shù)據(jù)打印問題植酥,由于采用的Unicode編碼,如果我們直接通過array方法打印可能出現(xiàn)亂碼的問題附迷。
  2. byteBuffer.clear()惧互;清除Buffer,在數(shù)據(jù)讀取完畢后,我們一般會調用byteBuffer.clear()喇伯;方便下次數(shù)據(jù)的寫入喊儡,如果不調用的話,每次讀數(shù)的數(shù)據(jù)都是在上次數(shù)據(jù)后的追加稻据。其實byteBuffer.clear()方法也不是真的把buffer中的數(shù)據(jù)清除掉艾猜,他也是將buffer中的mark、position捻悯、limit進行重新標記了一下匆赃,方便下次數(shù)寫入,我們來看一下源碼:
   public final Buffer clear() {
        position = 0;
        limit = capacity;
        mark = -1;
        return this;
    }

他和flip()的區(qū)別就在與limit 的值今缚,下面我們舉例說明:
比如說我們在Buffer中有一個“helloword"字符串算柳。并且我們Buffer的 capacity設置的為20,則:
當前buffer的各項值:
position = 9
limit = 20
capacity = 20
執(zhí)行flip后:
position = 0
limit = 9
capacity = 20
執(zhí)行clear 后:
position = 0
limit = 20
capacity = 20

package Buffer;

        import java.nio.ByteBuffer;
        import java.nio.CharBuffer;
        import java.nio.charset.CharacterCodingException;
        import java.nio.charset.Charset;

/**
 * @創(chuàng)建人:zhangzhiqiang
 * @創(chuàng)建時間:2019/09/03
 * @描述:
 * @聯(lián)系方式:QQ:125717901
 **/

public class Buffer1 {
    public static void main(String[] args) {
        //1 將數(shù)據(jù)寫到buff
        CharBuffer charBuffer = CharBuffer.allocate(20);
        charBuffer.put("helloword");
        System.out.println("capacity = "+charBuffer.capacity());
        System.out.println("limit = " +charBuffer.limit());
        System.out.println("position = "+charBuffer.position());

        charBuffer.flip();
        System.out.println("執(zhí)行flip后:");
        System.out.println("capacity = "+charBuffer.capacity());
        System.out.println("limit = " +charBuffer.limit());
        System.out.println("position = "+charBuffer.position());

        System.out.println("執(zhí)行clear后:");
        charBuffer.clear();
        System.out.println("capacity = "+charBuffer.capacity());
        System.out.println("limit = " +charBuffer.limit());
        System.out.println("position = "+charBuffer.position());
        System.out.println("打印buffer中的數(shù)據(jù) = "+ charBuffer.clear());
    }
}

執(zhí)行后的結果我們可以沖控制臺看到和我們上面分析的結果一致姓言,執(zhí)行clear后瞬项,我們換是可以將buffer的數(shù)據(jù)打印出來的。


image.png

下面是position 和limit的一個圖例:


image

通過上面的例子我們了解的CHannel何荚、和Buffer的基本一些用法和原理囱淋,寫了一個簡單的例子,展示了一下客戶端socket通過System的in方式獲取數(shù)餐塘,然后發(fā)送到服務端妥衣,服務端是如何接受的,在這里的基礎上戒傻,我們下一步引入selector選擇器税手,如果是沒有selector,我感覺Channel和傳統(tǒng)的socket沒有什么大的區(qū)別需纳,并且意義不大冈止。接下來我們引入selector,還是通過詳細的示例一步一步給大家講解候齿。
熙暴。闺属。。周霉。掂器。。俱箱。国瓮。。狞谱。乃摹。。跟衅。
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末孵睬,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子伶跷,更是在濱河造成了極大的恐慌掰读,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,183評論 6 516
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件叭莫,死亡現(xiàn)場離奇詭異蹈集,居然都是意外死亡,警方通過查閱死者的電腦和手機雇初,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,850評論 3 399
  • 文/潘曉璐 我一進店門拢肆,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人靖诗,你說我怎么就攤上這事郭怪。” “怎么了呻畸?”我有些...
    開封第一講書人閱讀 168,766評論 0 361
  • 文/不壞的土叔 我叫張陵,是天一觀的道長悼院。 經常有香客問我伤为,道長,這世上最難降的妖魔是什么据途? 我笑而不...
    開封第一講書人閱讀 59,854評論 1 299
  • 正文 為了忘掉前任绞愚,我火速辦了婚禮,結果婚禮上颖医,老公的妹妹穿的比我還像新娘位衩。我一直安慰自己,他們只是感情好熔萧,可當我...
    茶點故事閱讀 68,871評論 6 398
  • 文/花漫 我一把揭開白布糖驴。 她就那樣靜靜地躺著僚祷,像睡著了一般。 火紅的嫁衣襯著肌膚如雪贮缕。 梳的紋絲不亂的頭發(fā)上辙谜,一...
    開封第一講書人閱讀 52,457評論 1 311
  • 那天,我揣著相機與錄音感昼,去河邊找鬼装哆。 笑死,一個胖子當著我的面吹牛定嗓,可吹牛的內容都是我干的蜕琴。 我是一名探鬼主播,決...
    沈念sama閱讀 40,999評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼宵溅,長吁一口氣:“原來是場噩夢啊……” “哼凌简!你這毒婦竟也來了?” 一聲冷哼從身側響起层玲,我...
    開封第一講書人閱讀 39,914評論 0 277
  • 序言:老撾萬榮一對情侶失蹤号醉,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后辛块,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體畔派,經...
    沈念sama閱讀 46,465評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,543評論 3 342
  • 正文 我和宋清朗相戀三年润绵,在試婚紗的時候發(fā)現(xiàn)自己被綠了线椰。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,675評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡尘盼,死狀恐怖憨愉,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情卿捎,我是刑警寧澤配紫,帶...
    沈念sama閱讀 36,354評論 5 351
  • 正文 年R本政府宣布,位于F島的核電站午阵,受9級特大地震影響躺孝,放射性物質發(fā)生泄漏。R本人自食惡果不足惜底桂,卻給世界環(huán)境...
    茶點故事閱讀 42,029評論 3 335
  • 文/蒙蒙 一植袍、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧籽懦,春花似錦于个、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,514評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽秀存。三九已至,卻和暖如春贷笛,著一層夾襖步出監(jiān)牢的瞬間应又,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,616評論 1 274
  • 我被黑心中介騙來泰國打工乏苦, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留株扛,地道東北人。 一個月前我還...
    沈念sama閱讀 49,091評論 3 378
  • 正文 我出身青樓汇荐,卻偏偏與公主長得像洞就,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子掀淘,可洞房花燭夜當晚...
    茶點故事閱讀 45,685評論 2 360