iOS网络监控方案

目标

  • TCP建立连接时间
  • DNS时间
  • SSL/TLS时间
  • 响应总时间
  • 请求头、请求body、响应头、响应body大小
  • 支持统计原生网络请求、React Native网络请求
  • 代码无侵害

方案对比

方案一:通过 NSURLProtocol 来实现

通过向 NSURLProtocol 注册自定义的 NSURLProtocol 子类,比如是 TESTURLProtocol,然后每个由 NSURLConnectionNSURLSession 发起请求都会访问 TESTURLProtocol

注册方式

[NSURLProtocol registerClass:[TESTURLProtocol class]]

问题一:如果 NSURLSession 使用下面两个方法创建的,只注册是拦截不到的。

+ (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration;
+ (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration 
                                  delegate:(nullable id <NSURLSessionDelegate>)delegate 
                             delegateQueue:(nullable NSOperationQueue *)queue;

需要把TESTURLProtocol 添加到 NSURLSessionConfiguration.protocolClasses 中,可以通过 hook 来实现。

问题二:POST 请求 body 丢失问题。

TESTURLProtocol 中,使用 HTTPBodyStream 获取 body,并赋值到 body 中

- (void)startLoading {
    //缓存下来
    TestModel.request = [self.request cyl_getPostRequestIncludeBody];
}
@interface NSURLRequest (CYLNSURLProtocolExtension)

- (NSURLRequest *)cyl_getPostRequestIncludeBody;

@end

@implementation NSURLRequest (CYLNSURLProtocolExtension)

- (NSURLRequest *)cyl_getPostRequestIncludeBody {
    return [[self cyl_getMutablePostRequestIncludeBody] copy];
}

- (NSMutableURLRequest *)cyl_getMutablePostRequestIncludeBody {
    NSMutableURLRequest * req = [self mutableCopy];
    if ([self.HTTPMethod isEqualToString:@"POST"]) {
        if (!self.HTTPBody) {
            NSInteger maxLength = 1024;
            uint8_t d[maxLength];
            NSInputStream *stream = self.HTTPBodyStream;
            NSMutableData *data = [[NSMutableData alloc] init];
            [stream open];
            BOOL endOfStreamReached = NO;
            //不能用 [stream hasBytesAvailable]) 判断,处理图片文件的时候这里的[stream hasBytesAvailable]会始终返回YES,导致在while里面死循环。
            while (!endOfStreamReached) {
                NSInteger bytesRead = [stream read:d maxLength:maxLength];
                if (bytesRead == 0) { //文件读取到最后
                    endOfStreamReached = YES;
                } else if (bytesRead == -1) { //文件读取错误
                    endOfStreamReached = YES;
                } else if (stream.streamError == nil) {
                    [data appendBytes:(void *)d length:bytesRead];
                }
            }
            req.HTTPBody = [data copy];
            [stream close];
        }
        
    }
    return req;
}
@end

参考方案:NSURLProtocol 拦截 NSURLSession 请求时body丢失问题解决方案探讨

问题三:NSURLProtocol 可以拦截 UIWebView 的请求,无法拦截 WKWebView

可以通过修改WKWebView的私有方法来实现,注意如果提交 AppStore 需要加密处理

//注册scheme
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([cls respondsToSelector:sel]) {
    // 通过http和https的请求,同理可通过其他的Scheme 但是要满足ULR Loading System
    [cls performSelector:sel withObject:@"http"];
    [cls performSelector:sel withObject:@"https"];
}

参考方案:NSURLProtocol对WKWebView的处理

NSURLProtocol 实现方案可参考 NetworkEye 可以监听 NSURLConnectionNSURLSession 请求,处理了问题意一和问题二。

小结

优点:

  1. 可以比较轻松的获取请求头、请求body、响应头、响应body、请求总时长(从开始请求到请求结束时长);
  2. 没有版本限制。

缺点:

  1. 时间方面没法获取各个阶段的时长,比如,DNS时长、TCP时长、SSL时长等;
  2. 流量方面需要自己计算大小,比如响应body很多时候会有压缩,所以在计算的时候需要模拟压缩,得到的结果还是有误差的;
  3. 需要创建新的请求来路由接口,对业务有一定的破坏性(最难接受的)。

方案二:通过监听 URLSession:task:didFinishCollectingMetrics: 中的 NSURLSessionTaskMetrics 来实现

通过 hook NSURLSession 的方法 sessionWithConfiguration:delegate:delegateQueue: <NSURLSessionDelegate>)delegate 使用 NSProxy 来转发,这样就可以监听 URLSession:task:didFinishCollectingMetrics: 方法。

可参考iOS网络性能监控

问题一:需要 iOS10 之后才能使用,流量相关的统计需要 iOS13 之后才能使用

问题二:无法拦截通过 sharedSession 获取 NSURLSession 的实例

问题三:同样也有 POST 请求 body 丢失问题,以及 WKWebView 无法拦截问题, 实现方式跟上面类似

小结

优点:通过 NSURLSessionTaskMetrics 可以获取很方便的获取各个阶段的请求耗时,以及流量的使用请求。

缺点:主要是问题一和问题二带来的。

最终方案

在实际业务开发中,网络请求的方式主要是 NSURLSessionNSURLConnectionAFNetworkingAFNetworking 是基于 NSURLSessionNSURLConnection 实现的;业务如果是 React Native 来开发,底层的网络请求也是基于 NSURLSession 来实现的。

另外,NSURLConnection iOS9之后就被苹果弃用了。

接下来,我们再看一下官方给的当前系统的占有率。

image01.png

数据来源

只有 8% 的设备是低于 iOS13 的。

拦截目标:主要考虑 NSURLSession 的拦截;考虑到实际业务用很使用 AFNetworkingNSURLConnection 的请求,也还是要考虑 NSURLConnection 的拦截。

最终方案:以方案二为主,覆盖不到的地方使用方案一来补充。

方案二拦截不到有两种请求

  • NSURLSession 通过 sharedSession 获取的对象
  • NSURLConnection 发起的请求,虽然 iOS9 之后就已经抛弃了,但是 AFNetworking 有部分是基于 NSURLConnection,而我们很多老代码就是通过 NSURLConnection 发起的请求

所以针对这个两种情况通过方案一的 NSURLProtocol 来实现,内部生成 NSURLSession 实例,路由到方案二。

image02.png
// 方案一,核心代码逻辑,目的是路由接口至方案二上

@interface MTNMURLProtocol ()<NSURLSessionDataDelegate>

@property (nonatomic, strong) NSURLSession *session;

@end

@implementation MTNMURLProtocol

+ (void)install
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [NSURLProtocol registerClass:[MTNMURLProtocol class]];
    });
}

+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{
    if ([MTNMDataManager.shareManager isWhitelistURL:request.URL])
    {
        return NO;
    }
    
    if (![request.URL.scheme isEqualToString:@"http"] &&
        ![request.URL.scheme isEqualToString:@"https"])
    {
        return NO;
    }
    if ([NSURLProtocol propertyForKey:@"MTNMURLProtocol" inRequest:request])
    {
        return NO;
    }
    return YES;
}

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
{
    NSMutableURLRequest *mutableReqeust = [request mutableCopy];
    [NSURLProtocol setProperty:@YES forKey:@"MTNMURLProtocol" inRequest:mutableReqeust];
    return [mutableReqeust copy];
}

- (NSURLSession *)session
{
    if (!_session)
    {
        _session = [NSURLSession sessionWithConfiguration:NSURLSessionConfiguration.defaultSessionConfiguration delegate:self delegateQueue:nil];
    }
    return _session;
}

- (void)startLoading
{
    [[self.session dataTaskWithRequest:self.request] resume];
}

- (void)stopLoading
{
    [self.session getTasksWithCompletionHandler:^(NSArray<NSURLSessionDataTask *> * _Nonnull dataTasks, NSArray<NSURLSessionUploadTask *> * _Nonnull uploadTasks, NSArray<NSURLSessionDownloadTask *> * _Nonnull downloadTasks) {
        for (NSURLSessionDataTask *task in dataTasks)
        {
            [task cancel];
            
        }
    }];
    
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.session finishTasksAndInvalidate];
    });
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    [self.client URLProtocol:self didLoadData:data];
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler
{
    [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
    if (completionHandler)
    {
        completionHandler(NSURLSessionResponseAllow);
    }
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(nullable NSError *)error
{
    if (error)
    {
        [self.client URLProtocol:self didFailWithError:error];
    }
    else
    {
        [self.client URLProtocolDidFinishLoading:self];
    }
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler
{
    [self.client URLProtocol:self wasRedirectedToRequest:request redirectResponse:response];
    if (completionHandler)
    {
        completionHandler(request);
    }
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler
{
    id<NSURLAuthenticationChallengeSender> sender = [MTNMURLAuthenticationChallengeSender senderWithCompletionHandler:completionHandler];
    NSURLAuthenticationChallenge *wrappedChallenge = [[NSURLAuthenticationChallenge alloc] initWithAuthenticationChallenge:challenge sender:sender];
    [self.client URLProtocol:self didReceiveAuthenticationChallenge:wrappedChallenge];
}

- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session
{
    [self.client URLProtocolDidFinishLoading:self];
}

@end
// 方案二, 核心代码逻辑

@interface MTNMURLSessionMetricsProxy : NSProxy<NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSessionDataDelegate>

@property (nonatomic, strong) id target;

- (instancetype)initWithTarget:(id)target;

@end

@implementation MTNMURLSessionMetricsProxy

- (instancetype)initWithTarget:(id)target
{
    self.target = target;
    return self;
}

- (void)dealloc
{
    if (_target)
    {
        _target = nil;
    }
}

- (BOOL)respondsToSelector:(SEL)aSelector
{
    if ([NSStringFromSelector(aSelector) isEqualToString:@"URLSession:task:didFinishCollectingMetrics:"] ||
        [NSStringFromSelector(aSelector) isEqualToString:@"URLSession:dataTask:didReceiveResponse:completionHandler:"] ||
        [NSStringFromSelector(aSelector) isEqualToString:@"URLSession:dataTask:didReceiveData:"] ||
        [NSStringFromSelector(aSelector) isEqualToString:@"URLSession:task:didCompleteWithError:"])
    {
        return YES;
    }
    return [self.target respondsToSelector:aSelector];
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector
{
    if (!self.target)
    {
        return [NSMethodSignature signatureWithObjCTypes:"v@"];
    }
    return [self.target methodSignatureForSelector:selector];
}

- (void)forwardInvocation:(NSInvocation *)invocation
{
    if (!self.target)
    {
        return;
    }
    if ([self.target respondsToSelector:invocation.selector])
    {
        [invocation invokeWithTarget:self.target];
    }
}

// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler
{
    session.mt_responseBodyMutableData = [NSMutableData data];
    if ([self.target respondsToSelector:@selector(URLSession:dataTask:didReceiveResponse:completionHandler:)])
    {
        [self.target URLSession:session dataTask:dataTask didReceiveResponse:response completionHandler:completionHandler];
    }
    else
    {
        if (completionHandler)
        {
            completionHandler(NSURLSessionResponseAllow);
        }
    }
}

// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    if ([self.target respondsToSelector:@selector(URLSession:dataTask:didReceiveData:)])
    {
        [self.target URLSession:session dataTask:dataTask didReceiveData:data];
    }
    if (!session.mt_didAddData)
    {
        if (!session.mt_requestBodyData)
        {
            session.mt_requestBodyData = [self reqeustBobyForRequest:dataTask.originalRequest];
        }
        if (data)
        {
            [session.mt_responseBodyMutableData appendData:data];
        }
    }
}

// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(nullable NSError *)error
{
    if ([self.target respondsToSelector:@selector(URLSession:task:didCompleteWithError:)])
    {
        [self.target URLSession:session task:task didCompleteWithError:error];
    }
    if (!session.mt_didAddData && error)
    {
        session.mt_error = error;
        [session mt_checkAddRecordData];
    }
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics
{
    if ([self.target respondsToSelector:@selector(URLSession:task:didFinishCollectingMetrics:)])
    {
        [self.target URLSession:session task:task didFinishCollectingMetrics:metrics];
    }
    if (![MTNMDataManager.shareManager isWhitelistURL:task.originalRequest.URL])
    {
        for (NSURLSessionTaskTransactionMetrics *transMetric in metrics.transactionMetrics) {
            if (transMetric.resourceFetchType == NSURLSessionTaskMetricsResourceFetchTypeNetworkLoad)
            {
                if (!session.mt_didAddData)
                {
                    if (!session.mt_requestBodyData)
                    {
                        session.mt_requestBodyData = [self reqeustBobyForRequest:task.originalRequest];
                    }
                    session.mt_metrics = transMetric;
                    [session mt_checkAddRecordData];
                }
            }
        }
    }
}

- (NSData *)reqeustBobyForRequest:(NSURLRequest *)request
{
    NSMutableData *data;
    if ([request.HTTPMethod isEqualToString:@"POST"] && !request.HTTPBody)
    {
        NSInteger maxLength = 1024;
        uint8_t d[maxLength];
        NSInputStream *stream = request.HTTPBodyStream;
        data = [[NSMutableData alloc] init];
        [stream open];
        BOOL endOfStreamReached = NO;
        //不能用 [stream hasBytesAvailable]) 判断,处理图片文件的时候这里的[stream hasBytesAvailable]会始终返回YES,导致在while里面死循环。
        while (!endOfStreamReached)
        {
            NSInteger bytesRead = [stream read:d maxLength:maxLength];
            if (bytesRead == 0)
            { //文件读取到最后
                endOfStreamReached = YES;
            } else if (bytesRead == -1)
            { //文件读取错误
                endOfStreamReached = YES;
            } else if (stream.streamError == nil)
            {
                [data appendBytes:(void *)d length:bytesRead];
            }
        }
        [stream close];
    }
    return request.HTTPBody ?: data;
}

@end

应用时遇到时问题及解决

问题一:在 MTNMURLSessionMetricsProxy 拦截 NSURLSession 请求时,需要获取相应body。

相应body的获取是有必要的,不仅用于 iOS13 以下的接口统计,也可以用于本地网络记录查看,方便排查问题。

NSURLSession 的回调一般有两种方式, delegateblock 的方式。

delegate 只需要在 NSProxy 子类中拦截即可

// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler
{
    session.mt_responseBodyMutableData = [NSMutableData data];
    if ([self.target respondsToSelector:@selector(URLSession:dataTask:didReceiveResponse:completionHandler:)])
    {
        [self.target URLSession:session dataTask:dataTask didReceiveResponse:response completionHandler:completionHandler];
    }
    else
    {
        if (completionHandler)
        {
            completionHandler(NSURLSessionResponseAllow);
        }
    }
}

// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    if ([self.target respondsToSelector:@selector(URLSession:dataTask:didReceiveData:)])
    {
        [self.target URLSession:session dataTask:dataTask didReceiveData:data];
    }
    if (!session.mt_didAddData)
    {
        if (!session.mt_requestBodyData)
        {
            session.mt_requestBodyData = [self reqeustBobyForRequest:dataTask.originalRequest];
        }
        if (data)
        {
            [session.mt_responseBodyMutableData appendData:data];
        }
    }
}

blcok 的方式就复杂一点,需要 hookdataTaskWithRequest:completionHandler:dataTaskWithURL:completionHandler: 来拦截获取。

@implementation NSURLSession (MTNetworkMonitor)

- (NSURLSessionDataTask *)hook_dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler
{
    return [self hook_dataTaskWtihURL:url request:nil fromURL:YES completionHandler:completionHandler];
}

- (NSURLSessionDataTask *)hook_dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler
{
    return [self hook_dataTaskWtihURL:nil request:request fromURL:NO completionHandler:completionHandler];
}

- (NSURLSessionDataTask *)hook_dataTaskWtihURL:(NSURL *)url request:(NSURLRequest *)request fromURL:(BOOL)fromURL completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler
{
    [self addIfNotGuid];
    void(^hookCompletionHandler)(NSData * _Nullable, NSURLResponse * _Nullable, NSError * _Nullable) = ^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error){
        if (![MTNMDataManager.shareManager isWhitelistURL:url])
        {
            if (!self.mt_didAddData)
            {
                self.mt_error = error;
                self.mt_responseBodyMutableData = data.mutableCopy;
                [self mt_checkAddRecordData];
            }
        }
        if (completionHandler) {
            completionHandler(data, response, error);
        }
    };
    
    void(^completion)(NSData * _Nullable, NSURLResponse * _Nullable, NSError * _Nullable) = completionHandler ? hookCompletionHandler : completionHandler;
    if (fromURL)
    {
        return [self hook_dataTaskWithURL:url completionHandler:completion];
    }
    else
    {
        return [self hook_dataTaskWithRequest:request completionHandler:completion];
    }
}

@end

问题二:NSURLSessiondelegate 是强引用,会导致内存泄漏。

需要在合适的时机主动调用 finishTasksAndInvalidate 来释放 delegate 对象。

问题三:在iOS14.0和iOS14.0.1系统上闪退,原因是获取网络类型导致的

网络类型是通过 CTTelephonyNetworkInfo 获取,其中 CTRadioAccessTechnologyNRNSACTRadioAccessTechnologyNR 苹果文档是 iOS14.0 就可以用实际上,在 iOS14.0 和 iOS14.0.1 是没有的。

问题三:在iOS14.0和iOS14.0.1系统上闪退,原因是获取网络类型导致的

网络类型是通过 CTTelephonyNetworkInfo 获取,其中 CTRadioAccessTechnologyNRNSACTRadioAccessTechnologyNR 苹果文档是 iOS14.0 就可以用实际上,在 iOS14.0 和 iOS14.0.1 是没有的。

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

推荐阅读更多精彩内容