CFNetwork框架详细解析(四) —— CFNetwork编程指导之流的处理(三)

版本记录

版本号 时间
V1.0 2018.06.09

前言

CFNetwork框架访问网络服务并处理网络配置的变化。 建立在网络协议抽象的基础上,可以简化诸如使用BSD套接字,管理HTTP和FTP服务器以及管理Bonjour服务等任务。接下来几篇我们就一起看一下这个框架。感兴趣的可以看上面几篇文章。
1. CFNetwork框架详细解析(一) —— 基本概览
2. CFNetwork框架详细解析(二) —— CFNetwork编程指导之简介(一)
3. CFNetwork框架详细解析(三) —— CFNetwork编程指导之CFNetwork概念(二)

Working with Streams - 处理流

本章讨论如何创建,打开并检查读取和写入流上的错误。 它还介绍了如何从读取流中读取,如何写入写入流,如何防止在读取流或写入流时发生阻塞,以及如何通过代理服务器导航流。


Working with Read Streams - 处理读入流

Core Foundation流可用于读取或写入文件或使用网络套接字。 除了创建这些流的过程之外,它们的行为相似。

1. Creating a Read Stream - 创建读入流

首先创建一个读取流。 Listing 2-1为一个文件创建一个读取流。

Listing 2-1  Creating a read stream from a file

CFReadStreamRef myReadStream = CFReadStreamCreateWithFile(kCFAllocatorDefault, fileURL);

在此列表中,kCFAllocatorDefault参数指定使用当前默认系统分配器为流分配内存,fileURL参数指定要为其创建此读取流的文件的名称,例如file:///Users/joeuser/Downloads/MyApp.sit

同样,您可以通过调用CFStreamCreatePairWithSocketToCFHost(在Using a Run Loop to Prevent Blocking中介绍)或CFStreamCreatePairWithSocketToNetService(在NSNetServices and CFNetServices Programming Guide中介绍)来创建基于网络服务的一对流。

现在你已经创建了流,你可以打开它。 打开流会导致流保留所需的任何系统资源,例如打开文件所需的文件描述符。 Listing 2-2是打开读取流的示例。

Listing 2-2  Opening a read stream

if (!CFReadStreamOpen(myReadStream)) {
    CFStreamError myErr = CFReadStreamGetError(myReadStream);
    // An error has occurred.
        if (myErr.domain == kCFStreamErrorDomainPOSIX) {
        // Interpret myErr.error as a UNIX errno.
        } else if (myErr.domain == kCFStreamErrorDomainMacOSStatus) {
        // Interpret myErr.error as a MacOS error code.
            OSStatus macError = (OSStatus)myErr.error;
        // Check other error domains.
    }
}

CFReadStreamOpen函数返回TRUE表示成功,如果打开由于某种原因失败,则返回FALSE。如果CFReadStreamOpen返回FALSE,则该示例调用CFReadStreamGetError函数,该函数返回由两个值组成的CFStreamError类型的结构:域代码和错误代码。域代码指示应如何解释错误代码。例如,如果域代码是kCFStreamErrorDomainPOSIX,则错误代码是一个UNIX errno值。其他错误域是kCFStreamErrorDomainMacOSStatus,它指示错误代码是MacErrors.h中定义的OSStatus值,kCFStreamErrorDomainHTTP指示错误代码是由CFStreamErrorHTTP枚举定义的值之一。

打开一个流可能是一个漫长的过程,所以CFReadStreamOpenCFWriteStreamOpen函数通过返回TRUE来避免阻塞,以指示打开流的过程已经开始。要检查open的状态,调用函数CFReadStreamGetStatusCFWriteStreamGetStatus,如果打开仍在进行中,则返回kCFStreamStatusOpening;如果打开已完成,则返回kCFStreamStatusOpen;如果打开已完成但失败,则返回kCFStreamStatusErrorOccurred。在大多数情况下,打开是否完成并不重要,因为读取和写入的CFStream函数将阻塞,直到流打开。

2. Reading from a Read Stream - 从读取流中读取

要从读取流中读取数据,请调用函数CFReadStreamRead,该函数与UNIX read()系统调用类似。 都采用缓冲区和缓冲区长度参数。 两者都返回读取的字节数,如果在流或文件结束时返回0,如果发生错误则返回-1。 两者都会阻塞,直到至少有一个字节可以被读取,并且只要不阻塞就可以继续读取。 Listing 2-3是读取流的示例。

Listing 2-3  Reading from a read stream (blocking)

CFIndex numBytesRead;
do {
    UInt8 buf[myReadBufferSize]; // define myReadBufferSize as desired
    numBytesRead = CFReadStreamRead(myReadStream, buf, sizeof(buf));
    if( numBytesRead > 0 ) {
        handleBytes(buf, numBytesRead);
    } else if( numBytesRead < 0 ) {
        CFStreamError error = CFReadStreamGetError(myReadStream);
        reportError(error);
    }
} while(numBytesRead > 0);

3. Tearing Down a Read Stream - 关闭读取流

当所有数据都被读取后,您应该调用CFReadStreamClose函数来关闭流,从而释放与之关联的系统资源。 然后通过调用函数CFRelease来释放流引用。 您可能还想通过将引用设置为NULL来使引用无效。 有关示例,请参阅Listing 2-4。

Listing 2-4  Releasing a read stream

CFReadStreamClose(myReadStream);
CFRelease(myReadStream);
myReadStream = NULL;

Working with Write Streams - 处理写入流

使用写入流类似于使用读取流。 一个主要区别是函数CFWriteStreamWrite不保证接受所有传递它的字节。 相反,CFWriteStreamWrite返回它接受的字节数。 在代码Listing 2-5所示的示例代码中,您会注意到,如果写入的字节数与要写入的总字节数不相同,则会调整缓冲区以适应此情况。

Listing 2-5  Creating, opening, writing to, and releasing a write stream

CFWriteStreamRef myWriteStream =
        CFWriteStreamCreateWithFile(kCFAllocatorDefault, fileURL);
if (!CFWriteStreamOpen(myWriteStream)) {
    CFStreamError myErr = CFWriteStreamGetError(myWriteStream);
    // An error has occurred.
    if (myErr.domain == kCFStreamErrorDomainPOSIX) {
    // Interpret myErr.error as a UNIX errno.
    } else if (myErr.domain == kCFStreamErrorDomainMacOSStatus) {
        // Interpret myErr.error as a MacOS error code.
        OSStatus macError = (OSStatus)myErr.error;
        // Check other error domains.
    }
}
UInt8 buf[] = “Hello, world”;
CFIndex bufLen = (CFIndex)strlen(buf);
 
while (!done) {
    CFIndex bytesWritten = CFWriteStreamWrite(myWriteStream, buf, (CFIndex)bufLen);
    if (bytesWritten < 0) {
        CFStreamError error = CFWriteStreamGetError(myWriteStream);
        reportError(error);
    } else if (bytesWritten == 0) {
        if (CFWriteStreamGetStatus(myWriteStream) == kCFStreamStatusAtEnd) {
            done = TRUE;
        }
    } else if (bytesWritten != bufLen) {
        // Determine how much has been written and adjust the buffer
        bufLen = bufLen - bytesWritten;
        memmove(buf, buf + bytesWritten, bufLen);
 
        // Figure out what went wrong with the write stream
        CFStreamError error = CFWriteStreamGetError(myWriteStream);
        reportError(error);
 
    }
}
CFWriteStreamClose(myWriteStream);
CFRelease(myWriteStream);
myWriteStream = NULL;

Preventing Blocking When Working with Streams - 使用流时防止阻塞

在使用流进行通信时,数据传输可能需要很长时间才会出现,尤其是在基于套接字的流中。 如果你正在同步实现你的流,你的整个应用程序将被迫等待数据传输。 因此,强烈建议您的代码使用备用方法来防止阻塞。

在读取或写入CFStream对象时,有两种方法可以防止阻塞:

  • 使用运行循环 - 注册以接收与流相关的事件并在运行循环中调度流。 当发生与流相关的事件时,会调用您的回调函数(由注册调用指定)。
  • 轮询 - 对于读取流,在从流中读取数据之前查明是否有要读取的字节。 对于写入流,请在写入流之前确定是否可以在不阻塞的情况下写入流。

以下各节将介绍每种方法。

1. Using a Run Loop to Prevent Blocking - 使用运行循环来防止阻塞

使用流的首选方式是使用运行循环。 运行循环在主程序线程上执行。 它等待事件发生,然后调用与给定事件相关的任何函数。

在网络传输的情况下,当您注册的事件发生时,您的回调函数由运行循环执行。 这允许你不必轮询你的套接字流,轮询会减慢线程。

要了解有关运行循环的更多信息,请阅读Threading Programming Guide

此示例从创建套接字读取流开始:

CFStreamCreatePairWithSocketToCFHost(kCFAllocatorDefault, host, port,
                                   &myReadStream, NULL);

其中CFHost对象引用host指定要用来读取流的远程主机,而port参数指定主机使用的端口号。 CFStreamCreatePairWithSocketToCFHost函数返回myReadStream中的新读取流引用。 最后一个参数NULL表示调用者不想创建写入流。 如果你想创建一个写入蒸汽,最后一个参数是例如&myWriteStream

在打开套接字读取流之前,请创建一个在注册以接收与流相关的事件时将使用的上下文:

CFStreamClientContext myContext = {0, myPtr, myRetain, myRelease, myCopyDesc};

第一个参数是0来指定版本号。info参数myPtr是一个指向要传递给回调函数的数据的指针。通常,myPtr是指向您定义的结构的指针,其中包含与流相关的信息。 retain参数是指向保留info参数的函数的指针。因此,如果将其设置为函数myRetain,如上面的代码所示,CFStream将调用myRetain(myPtr)来保留info指针。同样,release参数myRelease是指向释放info参数的函数的指针。当流与上下文分离时,CFStream会调用myRelease(myPtr)。最后,copyDescription是一个函数的参数,用于提供流的描述。例如,如果您要使用上面显示的流客户端上下文来调用CFCopyDesc(myReadStream),则CFStream会调用myCopyDesc(myPtr)

客户端上下文还允许您选择将retainreleasecopyDescription参数设置为NULL。如果将retain和release参数设置为NULL,那么系统会希望您保持info指针指向的内存处于活动状态,直到流本身被销毁。如果您将copyDescription参数设置为NULL,则系统将根据请求提供信息指针指向的内存内容的基本描述。

设置客户端上下文后,调用函数CFReadStreamSetClient注册以接收与流相关的事件。 CFReadStreamSetClient要求您指定回调函数和您想要接收的事件。Listing 2-6中的以下示例指定回调函数想要接收kCFStreamEventHasBytesAvailablekCFStreamEventErrorOccurredkCFStreamEventEndEncountered事件。然后使用CFReadStreamScheduleWithRunLoop函数将流安排在运行循环中。有关如何执行此操作的示例,请参见Listing 2-6

Listing 2-6  Scheduling a stream on a run loop

CFOptionFlags registeredEvents = kCFStreamEventHasBytesAvailable |
        kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered;
if (CFReadStreamSetClient(myReadStream, registeredEvents, myCallBack, &myContext))
{
    CFReadStreamScheduleWithRunLoop(myReadStream, CFRunLoopGetCurrent(),
                                    kCFRunLoopCommonModes);
}

通过运行循环中的流调度,您可以打开流,如Listing 2-7所示。

Listing 2-7  Opening a nonblocking read stream

if (!CFReadStreamOpen(myReadStream)) {
    CFStreamError myErr = CFReadStreamGetError(myReadStream);
    if (myErr.error != 0) {
    // An error has occurred.
        if (myErr.domain == kCFStreamErrorDomainPOSIX) {
        // Interpret myErr.error as a UNIX errno.
            strerror(myErr.error);
        } else if (myErr.domain == kCFStreamErrorDomainMacOSStatus) {
            OSStatus macError = (OSStatus)myErr.error;
            }
        // Check other domains.
    } else
        // start the run loop
        CFRunLoopRun();
}

现在,等待你的回调函数被执行。 在您的回调函数中,检查事件代码并采取适当的措施。 参见代码Listing 2-8

Listing 2-8  Network events callback function

void myCallBack (CFReadStreamRef stream, CFStreamEventType event, void *myPtr) {
    switch(event) {
        case kCFStreamEventHasBytesAvailable:
            // It is safe to call CFReadStreamRead; it won’t block because bytes
            // are available.
            UInt8 buf[BUFSIZE];
            CFIndex bytesRead = CFReadStreamRead(stream, buf, BUFSIZE);
            if (bytesRead > 0) {
                handleBytes(buf, bytesRead);
            }
            // It is safe to ignore a value of bytesRead that is less than or
            // equal to zero because these cases will generate other events.
            break;
        case kCFStreamEventErrorOccurred:
            CFStreamError error = CFReadStreamGetError(stream);
            reportError(error);
            CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(),
                                              kCFRunLoopCommonModes);
            CFReadStreamClose(stream);
            CFRelease(stream);
            break;
        case kCFStreamEventEndEncountered:
            reportCompletion();
            CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(),
                                              kCFRunLoopCommonModes);
            CFReadStreamClose(stream);
            CFRelease(stream);
            break;
    }
}

当回调函数收到kCFStreamEventHasBytesAvailable事件代码时,它会调用CFReadStreamRead读取数据。

当回调函数收到kCFStreamEventErrorOccurred事件代码时,它会调用CFReadStreamGetError来获取错误和它自己的错误函数(reportError)来处理错误。

当回调函数接收到kCFStreamEventEndEncountered事件代码时,它会调用自己的函数(reportCompletion)来处理数据结束,然后调用CFReadStreamUnscheduleFromRunLoop函数从指定的运行循环中删除流。 然后运行CFReadStreamClose函数关闭流和CFRelease以释放流引用。

2. Polling a Network Stream - 轮询网络流

一般来说,轮询网络流是不可取的。 但是,在某些罕见情况下,这样做可能很有用。 要轮询流,首先检查流是否准备好读取或写入,然后对流执行读取或写入操作。

写入写入流时,可以通过调用CFWriteStreamCanAcceptBytes来确定流是否准备好接受数据。 如果它返回TRUE,那么你可以确定随后调用CFWriteStreamWrite函数将立即发送数据而不会阻塞。

同样,对于读取流,在调用CFReadStreamRead之前,调用函数CFReadStreamHasBytesAvailable

Listing 2-9是读取流的轮询示例。

Listing 2-9  Polling a read stream

while (!done) {
    if (CFReadStreamHasBytesAvailable(myReadStream)) {
        UInt8 buf[BUFSIZE];
        CFIndex bytesRead = CFReadStreamRead(myReadStream, buf, BUFSIZE);
        if (bytesRead < 0) {
            CFStreamError error = CFReadStreamGetError(myReadStream);
            reportError(error);
        } else if (bytesRead == 0) {
            if (CFReadStreamGetStatus(myReadStream) == kCFStreamStatusAtEnd) {
                done = TRUE;
            }
        } else {
            handleBytes(buf, bytesRead);
        }
    } else {
        // ...do something else while you wait...
    }
}

Listing 2-10是写入流的轮询示例。

Listing 2-10  Polling a write stream

UInt8 buf[] = “Hello, world”;
UInt32 bufLen = strlen(buf);
 
while (!done) {
    if (CFWriteStreamCanAcceptBytes(myWriteStream)) {
        int bytesWritten = CFWriteStreamWrite(myWriteStream, buf, strlen(buf));
        if (bytesWritten < 0) {
            CFStreamError error = CFWriteStreamGetError(myWriteStream);
            reportError(error);
        } else if (bytesWritten == 0) {
            if (CFWriteStreamGetStatus(myWriteStream) == kCFStreamStatusAtEnd)
            {
                done = TRUE;
            }
        } else if (bytesWritten != strlen(buf)) {
            // Determine how much has been written and adjust the buffer
            bufLen = bufLen - bytesWritten;
            memmove(buf, buf + bytesWritten, bufLen);
 
            // Figure out what went wrong with the write stream
            CFStreamError error = CFWriteStreamGetError(myWriteStream);
            reportError(error);
        }
    } else {
        // ...do something else while you wait...
    }
}

Navigating Firewalls - 浏览防火墙

将防火墙设置应用于流有两种方法。 对于大多数流,您可以使用SCDynamicStoreCopyProxies函数检索代理设置,然后通过设置kCFStreamHTTPProxy(或kCFStreamFTPProxy)属性将结果应用于流。 SCDynamicStoreCopyProxies函数是System Configuration框架的一部分,因此您需要在项目中包含<SystemConfiguration / SystemConfiguration.h>以使用该函数。 然后,只需在完成后发布代理字典参考。 该过程将如Listing 2-11所示

Listing 2-11  Navigating a stream through a proxy server

CFDictionaryRef proxyDict = SCDynamicStoreCopyProxies(NULL);
CFReadStreamSetProperty(readStream, kCFStreamPropertyHTTPProxy, proxyDict);

但是,如果您需要将代理设置经常用于多个流,则会变得更加复杂。 在这种情况下,检索用户机器的防火墙设置需要五个步骤:

  • 为动态存储会话创建一个持久句柄SCDynamicStoreRef
  • 将句柄放入动态存储会话中,以便通知代理更改。
  • 使用SCDynamicStoreCopyProxies检索最新的代理设置。
  • 当被告知更改时更新您的代理副本。
  • 清理SCDynamicStoreRef时,请仔细阅读。

要创建动态存储会话的句柄,请使用函数SCDynamicStoreCreate并传递一个分配器,一个名称来描述您的进程,一个回调函数和一个动态存储上下文SCDynamicStoreContext。 这在初始化应用程序时运行。 代码与Listing 2-12中的类似。

Listing 2-12  Creating a handle to a dynamic store session

SCDynamicStoreContext context = {0, self, NULL, NULL, NULL};
systemDynamicStore = SCDynamicStoreCreate(NULL,
                                          CFSTR("SampleApp"),
                                          proxyHasChanged,
                                          &context);

创建对动态存储的引用后,需要将其添加到运行循环中。 首先,获取动态存储引用并设置它以监视代理的任何更改。 这是通过函数SCDynamicStoreKeyCreateProxiesSCDynamicStoreSetNotificationKeys完成的。 然后,您可以使用函数SCDynamicStoreCreateRunLoopSourceCFRunLoopAddSource将动态存储引用添加到运行循环中。 你的代码应该如Listing 2-13所示。

Listing 2-13  Adding a dynamic store reference to the run loop

// Set up the store to monitor any changes to the proxies
CFStringRef proxiesKey = SCDynamicStoreKeyCreateProxies(NULL);
CFArrayRef keyArray = CFArrayCreate(NULL,
                                    (const void **)(&proxiesKey),
                                    1,
                                    &kCFTypeArrayCallBacks);
SCDynamicStoreSetNotificationKeys(systemDynamicStore, keyArray, NULL);
CFRelease(keyArray);
CFRelease(proxiesKey);
 
// Add the dynamic store to the run loop
CFRunLoopSourceRef storeRLSource =
    SCDynamicStoreCreateRunLoopSource(NULL, systemDynamicStore, 0);
CFRunLoopAddSource(CFRunLoopGetCurrent(), storeRLSource, kCFRunLoopCommonModes);
CFRelease(storeRLSource);

一旦将动态存储引用添加到运行循环中,可以使用它通过调用SCDynamicStoreCopyProxies将代理字典预加载当前代理设置。 有关如何执行此操作,请参见Listing 2-14。

Listing 2-14  Loading the proxy dictionary

gProxyDict = SCDynamicStoreCopyProxies(systemDynamicStore);

由于将动态存储引用添加到运行循环中,每次更改代理时,都会运行回调函数。 释放当前的代理字典并使用新的代理设置重新加载它。 示例回调函数看起来像Listing 2-15中的那个。

Listing 2-15  Proxy callback function

void proxyHasChanged() {
    CFRelease(gProxyDict);
    gProxyDict = SCDynamicStoreCopyProxies(systemDynamicStore);
}

由于所有代理信息都是最新的,因此应用代理。 创建读取或写入流后,通过调用函数CFReadStreamSetPropertyCFWriteStreamSetProperty来设置kCFStreamPropertyHTTPProxy代理。 如果您的流是一个名为readStream的读取流,那么您的函数调用将如Listing 2-16所示

Listing 2-16  Adding proxy information to a stream

CFReadStreamSetProperty(readStream, kCFStreamPropertyHTTPProxy, gProxyDict);

当您完成使用代理设置时,请确保释放字典和动态存储引用,并从运行循环中删除动态存储引用。 参见Listing 2-17

Listing 2-17  Cleaning up proxy information

if (gProxyDict) {
    CFRelease(gProxyDict);
}
 
// Invalidate the dynamic store's run loop source
// to get the store out of the run loop
CFRunLoopSourceRef rls = SCDynamicStoreCreateRunLoopSource(NULL, systemDynamicStore, 0);
CFRunLoopSourceInvalidate(rls);
CFRelease(rls);
CFRelease(systemDynamicStore);

后记

本篇主要介绍了处理流的相关逻辑,感兴趣的给个赞或者关注~~~~

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

推荐阅读更多精彩内容