深入理解OkHttp源码及设计思想

用OkHttp很久了,也看了很多人写的源码分析,在这里结合自己的感悟,记录一下对OkHttp源码理解的几点心得。

整体结构

网络请求框架虽然都要做请求任务的封装和管理,但是最大的难点在于网络请求任务的多样性,因为网络层情况复杂,不仅要考虑功能性的建立Socket连接、文件流传输、TLS安全、多平台等,还要考虑性能上的Cache复用、Cache过期、连接池复用等,这些功能如果交错在一起,实现和维护都会有很大的问题。

为了解决这个问题,OkHttp采用了分层设计的思想,使用多层拦截器,每个拦截器解决一个问题,多层拦截器套在一起,就像设计模式中的装饰者模式一样,可以在保证每层功能高内聚的情况下,解决多样性的问题。

OkHttp使用了外观模式,开发者直接操作的主要就是OkHttpClient,其实如果粗略划分的话,整个OkHttp框架从功能上可以分为三部分:
1.请求和回调:具体的类就是Call、RealCall(及其内部类AsyncCall)、Callback等。
2.分发器及线程池:具体的类就是Dispatcher、ThreadPoolExecutor等。
3.拦截器:实现了分层设计+链式调用,具体的类就是Interceptor+RealInterceptorChain。

至于更具体的操作,均由拦截器实现,包括应用层拦截器、网络层拦截器等,开发者也可以自己扩展新的拦截器。

请求

网络请求其实可以分为数据和行为两部分,数据即我们的请求数据和返回数据,行为则是发起网络请求,以及得到处理结果。
数据(Request和Response)
在OkHttp中,用Request定义请求数据,用Response定义返回数据,这两个类都使用了建造者模式,把对象的创建和使用分离开,但这两个类更接近于数据模型,主要用来读写数据,不做请求动作。
行为(Call/RealCall/AsyncCall和Callback)
在OkHttp中,用Call和Callback定义网络请求,用Call去发起网络请求,用Callback去接收异步返回,(如果是同步请求,就直接返回Response数据)。
其中,Call是个接口,真正的实现类是RealCall,RealCall如果需要异步处理,还会先包装为RealCall的内部类AsyncCall,然后再把AsyncCall交给线程池。

在具体执行过程中,把数据对象交给行为对象去操作:
在RealCall行为中调用enqueue去发起异步网络请求,此时需要传参Request数据对象;返回的Callback会传递Response数据对象。
如果RealCall行为中调用的是execute同步网络请求,就直接返回Response数据对象。

RealCall只是对请求做了封装,真正处理请求的是分发器Dispatcher。

分发器及线程池

对于网络请求RealCall来说,需要可并行、可回调、可取消,因为OkHttp统一使用Dispatcher分发器来分发所有的Call请求,分发给多个线程进行执行(所以Dispatcher也叫反向代理),所以,这几个问题就需要交给Dispatcher来处理,对于Dispatcher来说,可并行、可回调、可取消的问题可以进一步被分解为以下几个问题,并分别处理:

1.有没有必要管理所有的请求

不论是同步请求还是异步请求,都是耗时操作,所以是个需要观测的行为,比如请求结束需要处理,请求本身可能取消等,都需要管理起来。
而且,不论是正在运行的,还是等待运行的,都需要管理。

2.如何管理所有的请求

为了管理所有的请求,Dispatcher采用了队列+生产+消费的模式。
为同步执行提供了runningSyncCalls来管理所有的同步请求;
为异步执行提供了runningAsyncCalls和readyAsyncCalls来管理所有的异步请求。
其中readyAsyncCalls是在当前可用资源不足时,用于缓存请求的。

由于这三个队列的使用场景类似于栈,偶尔需要删除功能,所以OkHttp使用了ArrayDeque双端队列来管理,ArrayDeque的设计和实现非常精妙,感兴趣的可以深入了解一下。

3.如何确保多个队列之间能顺畅地调度

对于多线程情况下的队列调度,其实就是数据移动和失败阻塞的这两个问题。
对于数据移动来说,就是要考虑多线程下队列数据移动的问题。
对于同步请求来说,只有1个队列,不存在数据移动,数据移动的场景在两个异步队列,每当有一个异步请求finish了,就需要从待处理readyAsyncCalls队列移动到runningAsyncCalls队列,这在多线程场景下并不安全,需要加锁:

    synchronized (this) {//加锁操作
      if (!calls.remove(call)) throw new AssertionError("Call wasn't in-flight!");
      if (promoteCalls) promoteCalls();
      runningCallsCount = runningCallsCount();
      idleCallback = this.idleCallback;
    }

在promoteCalls时,会把call从ready队列转移到running队列:

 private void promoteCalls() {
    if (runningAsyncCalls.size() >= maxRequests) return; // Already running max capacity.
    ...
    for (Iterator<AsyncCall> i = readyAsyncCalls.iterator(); i.hasNext(); ) {
      AsyncCall call = i.next();

      if (runningCallsForHost(call) < maxRequestsPerHost) {
        i.remove();
        runningAsyncCalls.add(call);//添加队列
        executorService().execute(call);//交给线程池
      }

      if (runningAsyncCalls.size() >= maxRequests) return; // Reached max capacity.
    }
  }

另外这个移动的操作放在finish函数里,会存在另一个问题,就是如何确保会执行这个finish函数,避免造成失败阻塞

对于失败阻塞来说,因为网络请求失败是很常见的场景,必须能在失败时避免阻塞队列。
OkHttp的处理是为Call对象的execute函数写try finally,在RealCall的execute函数里,在finally中调用client.dispatcher.finish(call),确保队列不阻塞。
这其实类似AsyncTask的处理方式,AsyncTask也是使用了try finally,在finally中scheduleNext,确保队列不阻塞。

4.如何实现多线程

io是个耗时但是不耗CPU的操作,是典型的需要并行处理的场景。
OkHttp不出意外地采用了线程池实现并行,这一点类似于AsyncTask,但不像AsyncTask使用了全局唯一的线程池,每个OkHttpClient都有自己的线程池。
不过,与AsyncTask不同的是,OkHttp的同步执行不进线程池,在RealCall执行同步execute任务时,只是在Dispatcher的runningSyncCalls中记录这个call,然后直接在当前线程执行了拦截器的操作。
至于异步执行,就是在RealCall中enqueue时调用Dispatcher的enqueue,然后调用线程池executeService().execute(call),这里面的call是RealCall的内部类AsyncCall,实现异步调用。

5.在这个过程中,用哪些方式提升效率

OkHttp主要针对队列和线程池做了优化:
循环数组
因为Dispatcher中的三个队列需要频繁出栈和入栈,所以采用了性能良好的循环数组ArrayDeque管理队列。

阻塞队列
因为Dispatcher自己用队列管理了排队的请求,所以Dispatcher中的线程池其实不需要缓存队列,那么这个线程池的任务其实是尽快地把元素转交给线程池中的io线程,所以采用了容量为0的阻塞队列SynchronousQueue,SynchronousQueue与普通队列不同,不是数据等线程,而是线程等数据,这样每次向SynchronousQueue里传入数据时,都会立即交给一个线程执行,这样可以提高数据得到处理的速度。

控制线程数量
因为线程本身也会消耗资源,所以每个线程池都需要控制线程数量,OkHttp的线程池更进一步,会针对每个Host主机的请求(避免全都卡死在某个Host上),分别控制线程数上限(5个),具体方法就是遍历所有runningAsyncCall队列中的每个Call,查询每个Call的Host,并做计数。

拦截器原理

在前面的步骤中,不管是同步请求还是异步请求,最终都会调用拦截器来处理网络请求。

//RealCall源码
Response result = getResponseWithInterceptorChain();

这就是OkHttp的核心,Interceptor拦截器。
在OkHttp中,Call、Callback和Dispatcher虽然很有用,但对于解决复杂的网络请求没有太多作用,使用了分层设计的拦截器Interceptor才是解决复杂网络请求的核心,这也是OkHttp的核心设计。

分层设计

我们都知道,真实情况中的网络行为其实非常复杂,纵跨软件、协议、数据包、电信号、硬件等,所以网络层的第一个基础知识就是IOS七层模型,明确了各层的功能范围,每一层各司其职,层与层依次依赖,实际上降低了开发和维护的难度与成本。

OkHttp也采用了分层设计思想,每层Interceptor的输入都是Request,输出都是Response,所以可以一层层地加工Request,再一层层地加工Response。

由于各个Interceptor之间不是组合关系,不能像ViewTree那样递归调用,所以需要一个链把这些拦截器全部串起来,为此,入口RealCall会执行网络请求的getResponseWithInterceptorChain函数,主要就是一层层地组织Interceptor,组成一个链,然后用chain.proceed去调用它。

  Response getResponseWithInterceptorChain() throws IOException {
    // Build a full stack of interceptors.
    List<Interceptor> interceptors = new ArrayList<>();
    interceptors.addAll(client.interceptors());//自定义应用拦截器
    interceptors.add(retryAndFollowUpInterceptor);//重试/重定向
    interceptors.add(new BridgeInterceptor(client.cookieJar()));//应用请求转网络请求
    interceptors.add(new CacheInterceptor(client.internalCache()));//缓存
    interceptors.add(new ConnectInterceptor(client));//连接
    if (!forWebSocket) {
      interceptors.addAll(client.networkInterceptors());//自定义网络拦截器
    }
    interceptors.add(new CallServerInterceptor(forWebSocket));//服务端连接

    Interceptor.Chain chain = new RealInterceptorChain(//组成链
        interceptors, null, null, null, 0, originalRequest);
    return chain.proceed(originalRequest);//从RealCall的Request开始链式处理
  }

如何实现链式处理

我们看到,链式处理的入口是RealInterceptorChain的proceed函数:

  public Response proceed(Request request, StreamAllocation streamAllocation, HttpCodec httpCodec,
      RealConnection connection) throws IOException {
    ...
    RealInterceptorChain next = new RealInterceptorChain(//在chain中前进一步
        interceptors, streamAllocation, httpCodec, connection, index + 1, request);
    Interceptor interceptor = interceptors.get(index);
    Response response = interceptor.intercept(next);//调用拦截器
    ...
    return response;
  }

而拦截器在执行过程中,会再调用chain

  @Override 
  public Response intercept(Chain chain) throws IOException {
  ...
  Response networkResponse = chain.proceed(requestBuilder.build());
  ...

这样,就形成一个chain.process(intreceptor)-->interceptor.intercept(chain)-->chainprocess(intreceptor)-->interceptor.intercept(chain)的循环,这个过程中,chain不断消费,直至最后一个拦截器,最后这个拦截器一定是CallServerInterceptor,CallServerInterceptor不再调用chain.process,链式调用结束。

拦截器的层次设计

了解过拦截器和链式反应的基本原理,我们再来看看各拦截器的层次设计和具体实现,有很多可以借鉴的地方。
我们先回到RealCall中,看看拦截器的层次和分类:

  Response getResponseWithInterceptorChain() throws IOException {
    // Build a full stack of interceptors.
    List<Interceptor> interceptors = new ArrayList<>();
    interceptors.addAll(client.interceptors());//自定义应用拦截器
    interceptors.add(retryAndFollowUpInterceptor);//重试/重定向
    interceptors.add(new BridgeInterceptor(client.cookieJar()));//应用请求转网络请求
    interceptors.add(new CacheInterceptor(client.internalCache()));//缓存
    interceptors.add(new ConnectInterceptor(client));//连接
    if (!forWebSocket) {
      interceptors.addAll(client.networkInterceptors());//自定义网络拦截器
    }
    interceptors.add(new CallServerInterceptor(forWebSocket));//实现在线网络连接

    Interceptor.Chain chain = new RealInterceptorChain(//组成链
        interceptors, null, null, null, 0, originalRequest);
    return chain.proceed(originalRequest);//从RealCall的Request开始链式处理
  }

我们可以看到,OkHttp中拦截器的层次是这样的:
1.自定义应用拦截器
2.重试、重定向拦截器
3.应用/网络桥接拦截器
4.缓存拦截器
5.连接拦截器
6.自定义网络拦截器
7.在线网络请求拦截器

我们看到,我们开发者可以添加两种自定义Interceptor,一种是client.interceptors()应用层拦截器,一种是client.networkInterceptors()网络层拦截器
但其实这两种都是Interceptor,为什么可以分成是应用层和网络层呢?
因为在网络层拦截器上方,是ConnectionInterceptor连接拦截器,这个拦截器里会提供Address、ConnectionPool等资源,可以用于处理网络连接,networkInterceptors是添加在这之后的,可以参与真正的网络层数据的处理。
接下来,我们自顶向下,依次看看每层拦截器的实现

拦截器——自定义应用拦截器

OkHttp在最外围允许添加自定义的应用拦截器,我们可以拦截Request和Response,分别进行加工,例如在Request时统一添加Header和Url参数:

Request.Builder builder = chain.request().newBuilder();
builder.addHeader("Accept-Charset", "UTF-8");
builder.addHeader("Accept", " application/json");
builder.addHeader("Content-type", "application/json");

HttpUrl url=builder.build().url().newBuilder()
                  .addQueryParameter("mac", EquipmentUtils.getMac())
                  .build();
Request request = builder.url(url).build();

还可以拦截Response内容,打印返回数据的日志:

long t1 = System.nanoTime();
Request request = chain.request();
Response response = chain.proceed(request);
long t2 = System.nanoTime();

//直接复制字节流,获取response的数据内容
BufferedSource sr = response.body().source();
sr.request(Long.MAX_VALUE);
Buffer buf = sr.buffer().clone();//copy副本读取,不能读取原文
String content = buf.readString(Charset.forName("UTF-8"));
buf.clear();

Log.i(TAG, "net layer received response of url: " + request.url().url().toString()
          + "\nresponse: " + content
          + "\nspent time: " + (t2 - t1) / 1e6d);

开发者可以扩展针对请求数据和返回数据,自由开发功能。

拦截器——重试/重定向

虽然前面有开发者自定义的应用拦截器,但是真正准备处理网络连接,是从OkHttp自己定义的RetryAndFollowUpInterceptor开始的,因为OkHttp正是把这个拦截器作为真正的入口,创建StreamAllocation对象,在StreamAllocation对象中准备了网络连接的Address、连接池等资源,后续的拦截器,使用的都是这个StreamAllocation对象。

StreanAllocation

StreamAllocation是OkHttp中用来定义和传递网络资源,并建立网络连接的对象,内部包含:
Address:规定如何连接服务器,包括DNS、协议、URL等。
Route:存储建立连接的目标IP和端口InetSocketAddress,以及代理服务器。
ConnectionPool:存储和复用已存在的连接,复用时根据Address查找对应的连接。
StreamAllocation会通过findConnection创建连接,或复用已存在的连接,期间会调用RealConnection,根据设置建立TLS连接、处理握手协议等,最底层是根据当前运行的平台,直接操作Socket。
每个Host不超过5个连接,每个连接不超过5分钟。

重试/重定向

网络环境本质上是不稳定的,已建立的连接可能突然不可用,或者连接可用但是服务器报错,这就需要重试/重定向功能,这也是RetryAndFollowUpInterceptor拦截器的分层功能。
重试
如果整个链式调用出现了RouteException或IOException,就会调用recover函数重新建立连接;
重定向
如果服务器返回错误码如301,要求重定向,就会调用followUpRequest函数,新建一个Request,然后重定向,再走一遍整个调用链。
while
intercept函数中的这些主要逻辑都在while(true)循环中,最大循环上限是20。

拦截器——应用转网络的桥接功能

BridgeInterceptor是个桥梁,这主要是指他会自动处理一些网络层特有的Header信息,例如Host属性,是HTTP1.1必须的,但应用层并不关心这个属性,这就是由BridgeInterceptor自动处理的。
BridgeInterceptor中处理的Header属性包括Host、Connection的Keep-Alive、gzip透明压缩、User-Agent描述、Cookie策略等。
当然,因为OkHttp采用了外观模式,所以很多属性需要通过client设置和获取。

拦截器——缓存功能

在网络请求中使用缓存是非常必要提速手段,OkHttp专门用了CacheInterceptor拦截器来处理这个功能。
缓存的使用注意包括存储、查询和有效性检查,在OkHttp中:
存储,使用client外观模式来设置存储Cache数据的InternalCache实现类,在走请求链获取Response时记录cache。
查询,在存储Cache数据的InternalCache实现类中,根据Request过滤,来查找Cache。
有效性检查,利用工具类CacheStrategy的getCandidate函数,来判断Cache数据的各项指标是否达到条件。

拦截器——连接功能

在RetryAndFollowUpInterceptor入口处,我们已经分析过,在OkHttp中,连接功能由StreamAlloc实现,提供Address地址、Route路由、RealConnection连接、ConnectionPool线程池复用、身份验证、协议、握手、平台、安全等功能。

在ConnectionInterceptor这一层,其实还没有真正连接网络,它的具体功能很简单,就是准备好request请求、streamAllocation连接资源、httpCodec传输工具、connection连接,为最底层的网络连接服务。

其中,httpCodec通过sink提供了OKio封装过的基于socket的OutputStream,通过source提供了OKio封装的基于socket的InputStream,最终就是通过这个sink提交Request,用这个source获取Response。

拦截器——自定义网络拦截器

主要区别

自定义的网络层拦截器相比应用层拦截器,能直接监测到在线网络请求的数据交换过程。
例如,Http有url重定向机制,如果Http返回码为301,就需要根据Header中Location字段的新url,重新发起一次请求,这样的话,总共会有两次请求。

在应用层的拦截器看来,第一次请求并没有返回有效数据,它只会抓到一次请求,也就是第二次的请求。
但是在网络层的拦截器看来,两次都是网络请求,所以它会抓到两次请求。

用途扩展

根据网络层拦截器的特点,我们可以扩展如下功能:
1.模拟各种网络情况
网络接口不只是可用不可用的问题,还存在速度波动的问题,一个稳健的App应该能hold住波动的甚至是断断续续的网络,但是这样的网络非常不好模拟,我们可以在网络拦截器层自由设定网络返回值和返回时间,辅助我们检查App在处理网络数据时的健壮性。
2.模拟多个备用地址切换
无论是为了灾备,还是为了节省DNS解析时间,App都会有多个备用地址,有些就是ip地址,当网络出现问题时,要自动切换到备用地址,就可以在网络层模拟出301返回,直接重定向到备用地址。
3.模拟数据辅助开发/测试
在开发过程中,我们可以用gradle多环境的方法,增加一个mock的productFlavor,在这个环境下添加一个mockInterceptor,把指向官网的地址重定向为指向开发测试网址,甚至直接mock返回数据,换掉在线数据,这样可以检测整个网络层的全部功能(编码、缓存、切换、报错等),把mock数据的内容和App的反馈结合的话,还可以做到针对网络数据的半自动/自动化的测试验证。

拦截器——在线网络请求功能

前面所有的拦截器,都是在准备或处理网络连接前后的数据,只有CallServerInterceptor这个拦截器,是真正连接在线服务的。
它使用ConnectionInterceptor提供的HttpCodec传输工具来发出Request,获取Response,然后用ResponseBuilder生成最终的Response,再层层传递给外层的拦截器。
HttpCodec本身是一个接口,实例是StreamAllocation利用RealConnection生产的,RealConnection根据连接池中的可用连接,利用Okio生产source和sink:

  private void connectSocket(int connectTimeout, int readTimeout) throws IOException {
    Proxy proxy = route.proxy();
    Address address = route.address();

    rawSocket = proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.HTTP
        ? address.socketFactory().createSocket()
        : new Socket(proxy);

    rawSocket.setSoTimeout(readTimeout);
    ...
      //用Okio生产
      source = Okio.buffer(Okio.source(rawSocket));
      sink = Okio.buffer(Okio.sink(rawSocket));
    ...
  }

Okio的source是socket.inputStream,sink是socket.outputStream。
所以,真正在传输数据时,就是用Okio的sink去传socket,用source去取socket,底层其实也是socket操作。

其他特性

以上是OkHttp的主要内容,此外,OkHttp还有一些很有意思的特性。

1.返回数据阅后即焚

在OkHttp中,如果要拦截ResponseBody的数据内容(比如写日志),会发现该数据读过一次就会被情况,相当于是“阅后即焚”:

  //ResponseBody源码
  public final String string() throws IOException { //底层不能自己消化异常,应该向上层抛出异常
    BufferedSource source = source();
    try {
      Charset charset = Util.bomAwareCharset(source, charset());
      return source.readString(charset);
    //不做catch,异常全部抛出给上层
    } finally { //确保原始字节数据得到处理
      Util.closeQuietly(source); //阅后即焚,这样可以迅速腾出内存空间来
    }
  }

如果一定要拦截出数据内容,我们就不能直接读ResponseBody中的source,需要copy一个副本才行:

BufferedSource sr = response.body().source();
sr.request(Long.MAX_VALUE);
Buffer buf = sr.buffer().clone();//copy副本读取,不能读取原文
String content = buf.readString(Charset.forName("UTF-8"));
buf.clear();

Response也提供了专门获取ResponsBody数据的函数peekBody,实现原理也是copy“:

  //Response源码
  public ResponseBody peekBody(long byteCount) throws IOException {
    BufferedSource source = body.source();
    source.request(byteCount);
    Buffer copy = source.buffer().clone();
    ...
    return ResponseBody.create(body.contentType(), result.size(), result);
  }

参考

深入解析OkHttp3
OkHttp3源码分析[综述]
Okhttp-wiki 之 Interceptors 拦截器

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

推荐阅读更多精彩内容