OKHttp源码浅析与最佳实践

1、概述

OKHttp 是 Square 开源的一款高效的处理网络请求的工具。不仅限于处理 Http 请求。

功能特点:

  • 链接复用
  • Response 缓存和 Cookie
  • 默认 GZIP
  • 请求失败自动重连
  • DNS 扩展
  • Http2/SPDY/WebSocket 协议支持

OKHttp 类似于 HttpUrlConnection, 是基于传输层实现应用层协议的网络框架。 而不止是一个 Http 请求应用的库。

2、请求处理流程

  1. execute()/enqueue() 提交请求开始执行
  2. RealCall.getResponse()
  3. HttpEngine 处理 sendRequest, readResponse
  4. sendRequest 之前,会先从 Cache 中判断当前请求是否可以从缓存中返回
  5. connect 发起连接, 先从 ConnectPool 中找到缓存的连接,如果没有会建立一个新的 RealConnection
  6. RealConnection 本身是基于 Socket 的, 在 Socket 之上建立各种协议. buildConnection(), establishProtocol()
  7. Platform 处理真实的 socket 连接。 通过反射适配 Android 与 Java, 以及 ALPN。

3、特性分析

缓存处理

public void sendRequest() throws RequestException, RouteException, IOException { 
 if (cacheStrategy != null) return; // Already sent. 
 if (httpStream != null) throw new IllegalStateException();  
 Request request = networkRequest(userRequest);  
 InternalCache responseCache =
      Internal.instance.internalCache(client); 
 Response cacheCandidate = responseCache != null  ?  responseCache.get(request) : null; 
 long now = System.currentTimeMillis();  
 cacheStrategy = new CacheStrategy.Factory(now, request, cacheCandidate).get();  
 networkRequest = cacheStrategy.networkRequest; 
 cacheResponse = cacheStrategy.cacheResponse;
...
}

CacheStrategy 中会根据 Http Headers 从 Cache 中加载缓存。

// If we don't need the network, we're done.
if (networkRequest == null) { 
  userResponse = cacheResponse.newBuilder()     
   .request(userRequest)      
   .priorResponse(stripBody(priorResponse))      
   .cacheResponse(stripBody(cacheResponse))      
   .build();  
  userResponse = unzip(userResponse);  
  return;
}

缓存判断通过, 直接返回 Response。 不会再发起请求。

链路复用

StreamAllocation 负责创建 Stream 和 Connection.

Stream 有各种协议版本的实现, 负责网络读写数据

private RealConnection findConnection(int connectTimeout, int readTimeout, int writeTimeout,
    boolean connectionRetryEnabled) throws IOException, RouteException {
  Route selectedRoute;
  synchronized (connectionPool) {
    if (released) throw new IllegalStateException("released");
    if (stream != null) throw new IllegalStateException("stream != null");
    if (canceled) throw new IOException("Canceled");

    RealConnection allocatedConnection = this.connection;
    if (allocatedConnection != null && !allocatedConnection.noNewStreams) {
      return allocatedConnection;
    }

    // Attempt to get a connection from the pool. 
    //先从 ConnectPool 中取出可用的 Connection
    RealConnection pooledConnection = Internal.instance.get(connectionPool, address, this);
    if (pooledConnection != null) {
      this.connection = pooledConnection;
      return pooledConnection;
    }

    selectedRoute = route;
  }

  if (selectedRoute == null) {
    selectedRoute = routeSelector.next();
    synchronized (connectionPool) {
      route = selectedRoute;
      refusedStreamCount = 0;
    }
  }
  RealConnection newConnection = new RealConnection(selectedRoute);
  acquire(newConnection);

  // 创建完后, 将 Connection 存入 ConnectionPool
  synchronized (connectionPool) {
    Internal.instance.put(connectionPool, newConnection);
    this.connection = newConnection;
    if (canceled) throw new IOException("Canceled");
  }

// 发起连接
  newConnection.connect(connectTimeout, readTimeout, writeTimeout, address.connectionSpecs(),
      connectionRetryEnabled);
  routeDatabase().connected(newConnection.route());

  return newConnection;
}

链接何时释放

ConnectionPool 中有一个 Runnable, 负责清除不再使用的链接

  private final Runnable cleanupRunnable = new Runnable() {
    @Override public void run() {
      while (true) {
        long waitNanos = cleanup(System.nanoTime());
        if (waitNanos == -1) return;
        if (waitNanos > 0) {
          long waitMillis = waitNanos / 1000000L;
          waitNanos -= (waitMillis * 1000000L);
          synchronized (ConnectionPool.this) {
            try {
              ConnectionPool.this.wait(waitMillis, (int) waitNanos);
            } catch (InterruptedException ignored) {
            }
          }
        }
      }
    }
  };

能够被清除,前提是这个链接已经被标记为 Idle 了。处理代码在 **RealCall getResponse() ** 请求结束之后。

失败重连

RealCall getResponse 时, 如果中间出现异常或者需要重定向请求, 会再 new HttpEngine 继续发起请求。

 */
  Response getResponse(Request request, boolean forWebSocket) throws IOException {
    // Copy body metadata to the appropriate request headers.
    
    …   

    int followUpCount = 0;
    while (true) {
      if (canceled) {
        engine.releaseStreamAllocation();
        throw new IOException("Canceled");
      }

      boolean releaseConnection = true;
      try {
        engine.sendRequest();
        engine.readResponse();
        releaseConnection = false;
      } catch (RequestException e) {
        // The attempt to interpret the request failed. Give up.
        throw e.getCause();
      } catch (RouteException e) {
        // The attempt to connect via a route failed. The request will not have been sent.
        HttpEngine retryEngine = engine.recover(e.getLastConnectException(), true, null);
        if (retryEngine != null) {
          releaseConnection = false;
          engine = retryEngine;
          continue;
        }
        // Give up; recovery is not possible.
        throw e.getLastConnectException();
      } catch (IOException e) {
        // An attempt to communicate with a server failed. The request may have been sent.
        HttpEngine retryEngine = engine.recover(e, false, null);
        if (retryEngine != null) {
          releaseConnection = false;
          engine = retryEngine;
          continue;
        }

        // Give up; recovery is not possible.
        throw e;
      } finally {
        // We're throwing an unchecked exception. Release any resources.
        if (releaseConnection) {
          StreamAllocation streamAllocation = engine.close();
          streamAllocation.release();
        }
      }

      Response response = engine.getResponse();
      Request followUp = engine.followUpRequest();

      if (followUp == null) {
        if (!forWebSocket) {
          engine.releaseStreamAllocation();
        }
        return response;
      }

      StreamAllocation streamAllocation = engine.close();

      if (++followUpCount > MAX_FOLLOW_UPS) {
        streamAllocation.release();
        throw new ProtocolException("Too many follow-up requests: " + followUpCount);
      }

      if (!engine.sameConnection(followUp.url())) {
        streamAllocation.release();
        streamAllocation = null;
      } else if (streamAllocation.stream() != null) {
        throw new IllegalStateException("Closing the body of " + response
            + " didn't close its backing stream. Bad interceptor?");
      }

      request = followUp;
      engine = new HttpEngine(client, request, false, false, forWebSocket, streamAllocation, null,
          response);
    }
  }
}

DNS 扩展

在 findConnection() 的过程中,会通过 RouterSelector 组件找到需要链接的地址。 在如果未找到, 会通过 DNS 接口根据 hostname 查询对应的 IP 地址列表,并且在内存缓存这个列表。

 /** Prepares the socket addresses to attempt for the current proxy or host. */
  private void resetNextInetSocketAddress(Proxy proxy) throws IOException {
    // Clear the addresses. Necessary if getAllByName() below throws!
    inetSocketAddresses = new ArrayList<>();

    String socketHost;
    int socketPort;
    if (proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.SOCKS) {
      socketHost = address.url().host();
      socketPort = address.url().port();
    } else {
      SocketAddress proxyAddress = proxy.address();
      if (!(proxyAddress instanceof InetSocketAddress)) {
        throw new IllegalArgumentException(
            "Proxy.address() is not an " + "InetSocketAddress: " + proxyAddress.getClass());
      }
      InetSocketAddress proxySocketAddress = (InetSocketAddress) proxyAddress;
      socketHost = getHostString(proxySocketAddress);
      socketPort = proxySocketAddress.getPort();
    }

    if (socketPort < 1 || socketPort > 65535) {
      throw new SocketException("No route to " + socketHost + ":" + socketPort
          + "; port is out of range");
    }

    if (proxy.type() == Proxy.Type.SOCKS) {
      inetSocketAddresses.add(InetSocketAddress.createUnresolved(socketHost, socketPort));
    } else {
      // Try each address for best behavior in mixed IPv4/IPv6 environments.
      List<InetAddress> addresses = address.dns().lookup(socketHost);
      for (int i = 0, size = addresses.size(); i < size; i++) {
        InetAddress inetAddress = addresses.get(i);
        inetSocketAddresses.add(new InetSocketAddress(inetAddress, socketPort));
      }
    }

    nextInetSocketAddressIndex = 0;
  }

在 Dns 类中, 有一个默认的 SYSTEM 实现,如果没单独配置 Dns, 会使用默认的实现。

public interface Dns {
  /**
   * A DNS that uses {@link InetAddress#getAllByName} to ask the underlying operating system to
   * lookup IP addresses. Most custom {@link Dns} implementations should delegate to this instance.
   */
  Dns SYSTEM = new Dns() {
    @Override public List<InetAddress> lookup(String hostname) throws UnknownHostException {
      if (hostname == null) throw new UnknownHostException("hostname == null");
      return Arrays.asList(InetAddress.getAllByName(hostname));
    }
  };

  /**
   * Returns the IP addresses of {@code hostname}, in the order they will be attempted by OkHttp. If
   * a connection to an address fails, OkHttp will retry the connection with the next address until
   * either a connection is made, the set of IP addresses is exhausted, or a limit is exceeded.
   */
  List<InetAddress> lookup(String hostname) throws UnknownHostException;
}

Http2/SPDY 协议支持

使用 Http2/SPDY 协议需要用到 ALPN 组件,并且服务器支持。 在 Android 5.0 以后系统包含了 ALPN。
协议兼容代码在 AndroidPlatform 类中

 public static Platform buildIfSupported() {
    // Attempt to find Android 2.3+ APIs.
    try {
      Class<?> sslParametersClass;
      try {
        sslParametersClass = Class.forName("com.android.org.conscrypt.SSLParametersImpl");
      } catch (ClassNotFoundException e) {
        // Older platform before being unbundled.
        sslParametersClass = Class.forName(
            "org.apache.harmony.xnet.provider.jsse.SSLParametersImpl");
      }

      OptionalMethod<Socket> setUseSessionTickets = new OptionalMethod<>(
          null, "setUseSessionTickets", boolean.class);
      OptionalMethod<Socket> setHostname = new OptionalMethod<>(
          null, "setHostname", String.class);
      OptionalMethod<Socket> getAlpnSelectedProtocol = null;
      OptionalMethod<Socket> setAlpnProtocols = null;

      // Attempt to find Android 5.0+ APIs.
      try {
        Class.forName("android.net.Network"); // Arbitrary class added in Android 5.0.
        getAlpnSelectedProtocol = new OptionalMethod<>(byte[].class, "getAlpnSelectedProtocol");
        setAlpnProtocols = new OptionalMethod<>(null, "setAlpnProtocols", byte[].class);
      } catch (ClassNotFoundException ignored) {
      }

      return new AndroidPlatform(sslParametersClass, setUseSessionTickets, setHostname,
          getAlpnSelectedProtocol, setAlpnProtocols);
    } catch (ClassNotFoundException ignored) {
      // This isn't an Android runtime.
    }

    return null;
  }

WebSocket 协议支持

加入 okhttp-ws 扩展包,可以使用 WebSocket 协议连接服务器。

compile 'com.squareup.okhttp3:okhttp-ws:3.3.1'

最佳实践

OKHttp 是一个更倾向于协议的网络框架。 通常在使用上,我们会再包装一层,以简化调用。

封装的 BJNetwork 库主要实现了:

  • 缓存的自动配置(cache + cookie)
  • 请求日志开关
  • 基于腾讯的 DnsPod 实现的 DNS 扩展(可选)
  • Get & Post 请求参数的简化
  • 上传&下载进度
  • 根据 tag 关闭请求; 或者 tag 被销毁后,自动关闭请求
  • Http2/SPDY 配置
  • 支持 RxJava 调用
  • WebSocket 的调用封装以及自动重连处理

示例代码

集成

compile 'io.github.yangxlei:bjnetwork:1.5.1'

创建 BJNetworkClient :

 BJNetworkClient client  = new BJNetworkClient.Builder()
                // 设置缓存文件存储路径. 会自动将请求的缓存和 cookie 存储在该路径下
                .setCacheDir(context.getCacheDir())
                // 开启开关后, 会在支持协议列表内加入 Http2 & SPDY. 如果服务器不支持该协议, 建议关闭
                .setEnableHttp2x(false)
                // 开启日志. 建议 Debug 环境开启, Release 环境关闭.
                .setEnableLog(true)
                .setConnectTimeoutAtSeconds(30)
                .setReadTimeoutAtSeconds(30)
                .setWriteTimeoutAtSeconds(30)
                // DnsPod 的实现. 不设置默认使用 SYSTEM
                .setDns(new DnsPodImpl(context.getCacheDir()))
                .build();

请求管理类

 // 建议由调用者维护一份实例
  mNetRequestManager = new BJNetRequestManager(client);

发起请求

       //创建请求
        BJNetCall call = mNetRequestManager.newGetCall("http://xxx.com");
//        call = mNetRequestManager.newPostCall("http://xxx.com/xx", requestBody);

        Object tag = new Object();

        //同步请求
        try {
            BJResponse response = call.executeSync(tag);
        } catch (IOException e) {
            e.printStackTrace();
        }

        //异步请求
        call.executeAsync(tag, new BJNetCallback() {
            @Override
            public void onFailure(Exception e) {
            }

            @Override
            public void onResponse(BJResponse bjResponse) {
            }
        });

        // tag 会被 JVM 回收. 响应的请求也会被自动关闭
        tag = null;

执行请求后, RequestManager 内会建立一个 tag 和对应的 calls 的虚引用 list。 当 tag 被回收后, 自动关闭 list 中的 call。
需要注意的是, 如果这个 tag 传入的是 this, 因为 callback 会一直被持有直到请求结束, callback 中会有一个 this 的引用。 所以 this 不会销毁也无法自动关闭请求。可以调用 cancelCalls 主动关闭

  mNetRequestManager.cancelCalls(tag);

下载进度

       BJNetCall call = requestManager.newDownloadCall("http://d.gsxservice.com/app/genshuixue.apk", getCacheDir());
        call.executeAsync(tag, new BJDownloadCallback() {
            @Override
            public void onProgress(long progress, long total) {
                System.out.println("download  onProgress " + progress +"," + total +" " + Thread.currentThread());
            }

            @Override
            public void onDownloadFinish(BJResponse response, File file) {
                System.out.println("download  onDownloadFinish " + Thread.currentThread());
            }

            @Override
            public void onFailure(Exception e) {
                e.printStackTrace();
                System.out.println("download  onFailure " + Thread.currentThread());
            }
        });

上传进度

 BJRequestBody requestBody = BJRequestBody.createWithMultiForm(params, "Filedata",
                new File("aaaa.jpg"),BJRequestBody.MEDIA_TYPE_IMAGE);

        requestManager.newPostCall("http://xxx.com/doc/upload",requestBody)
                .executeAsync(this, new BJProgressCallback(){
                    @Override
                    public void onFailure(Exception e) {
                        System.out.println("1upload  onFailure " + Thread.currentThread());
                    }

                    @Override
                    public void onResponse(BJResponse response) {
                        System.out.println("1upload  onResponse "+ " "+ Thread.currentThread());
                    }

                    @Override
                    public void onProgress(long progress, long total) {
                        System.out.println("1upload  onProgress " + progress*100/total+" " + Thread.currentThread());
                    }
                });

提供了 BJProgressCallback 和 BJDownloadCallback 两种回调实现。 在异步执行时传入 ProgressCallback 会自动提供进度的回调。

RxJava 扩展

compile 'io.github.yangxlei:rx-bjnetwork:1.5.1'

示例:


        mRxNetRequestManager = new BJRxNetRequestManager(client);
        mRxNetRequestManager.rx_newGetCall("http://xxx.com")
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Action1<BJResponse>() {
                    @Override
                    public void call(BJResponse response) {
                        try {
                            String result = response.getResponseString();
                        } catch (IOException e) {
                            e.printStackTrace();
                            throw new HttpException(e);
                        }
                    }
                }, new Action1<Throwable>() {
                    @Override
                    public void call(Throwable throwable) {
                       HttpException exception = (HttpException) throwable;
                    }
                });

WebSocket 扩展

compile 'io.github.yangxlei:bjnetwork-ws:1.5.1'

示例:


        mWebsocketClient = new BJWebsocketClient("TestWS");
        mWebsocketClient.setAddress("ws://xxx.com");
        // LogLevel.Body 会打印出收发的数据以及行为. Info 只打印行为不打印详细数据
        mWebsocketClient.setLogLevel(BJWebsocketClient.LogLevel.Body);
        mWebsocketClient.setListener(new BJWebsocketListener() {

            @Override
            public void onReconnect(BJWebsocketClient client) {

            }

            @Override
            public void onClose(BJWebsocketClient client) {

            }

            @Override
            public void onSentMessageFailure(BJWebsocketClient client, BJMessageBody messageBody) {

            }

            @Override
            public void onMessage(BJWebsocketClient client, String message) {
                System.out.println("WS receive:" + message);
            }

            @Override
            public void onMessage(BJWebsocketClient client, InputStream inputStream) {

            }

            @Override
            public void onStateChanged(BJWebsocketClient client, BJWebsocketClient.State state) {

            }
        });
        
        // 启动链接
        mWebsocketClient.connect();
        // 消息会进入发送队列. 链接建立成功后, 逐个发送
        mWebsocketClient.sendMessage("message");
        // 关闭链接
        mWebsocketClient.disconnect();

1.WebSocketClient 内维护一个消息发送线程和消息队列。 链接成功后自动发送消息。 不排除消息可能会在本地发送失败, 内部处理默认情况会自动重试五次, 如果都失败会将 message 通过 onSentMessageFailure 返回上层。
2.WebSocketClient 维护三种状态: Offline, Connecting, Connected.
3.自动重连,无法避免某些情况下链接会被断开。 内部处理如果非主动断开链接, 都会自动重新建立链接。

踩过的几个坑

1、gzip 问题

OKHttp 响应会自动处理 gzip 解压缩. 在 HttpEngine 类中收到响应后会调用:

  private Response unzip(final Response response) throws IOException {
    if (!transparentGzip || !"gzip".equalsIgnoreCase(userResponse.header("Content-Encoding"))) {
      return response;
    }

    if (response.body() == null) {
      return response;
    }

    GzipSource responseBody = new GzipSource(response.body().source());
    Headers strippedHeaders = response.headers().newBuilder()
        .removeAll("Content-Encoding")
        .removeAll("Content-Length")
        .build();
    return response.newBuilder()
        .headers(strippedHeaders)
        .body(new RealResponseBody(strippedHeaders, Okio.buffer(responseBody)))
        .build();
  }

发现里面有个 transparentGzip 的判断,再看来这个值是怎么回事:

  private Request networkRequest(Request request) throws IOException {
    Request.Builder result = request.newBuilder();

    if (request.header("Host") == null) {
      result.header("Host", hostHeader(request.url(), false));
    }

    if (request.header("Connection") == null) {
      result.header("Connection", "Keep-Alive");
    }

    if (request.header("Accept-Encoding") == null) {
      transparentGzip = true;
      result.header("Accept-Encoding", "gzip");
    }

    List<Cookie> cookies = client.cookieJar().loadForRequest(request.url());
    if (!cookies.isEmpty()) {
      result.header("Cookie", cookieHeader(cookies));
    }

    if (request.header("User-Agent") == null) {
      result.header("User-Agent", Version.userAgent());
    }

    return result.build();
  }

sendRequest 的时候会先补全常用的 Http Headers。 如果不存在 “Accept-Encoding” 头会 transparentGzip=true。
也就是说,OKHttp 的处理规则是:如果你的请求自己主动加了 gzip 支持, 那么响应也自己处理 gzip 解压缩; 否则我给你做。

2、WebSocket SocketTimeOutException

WebSocket 在链接成功之后,可能过一会就会抛个 timeout 异常然后断开链接。出问题的地方是:

 private void createWebSocket(Response response, WebSocketListener listener) throws IOException {
    if (response.code() != 101) {
      throw new ProtocolException("Expected HTTP 101 response but was '"
          + response.code()
          + " "
          + response.message()
          + "'");
    }

    String headerConnection = response.header("Connection");
    if (!"Upgrade".equalsIgnoreCase(headerConnection)) {
      throw new ProtocolException(
          "Expected 'Connection' header value 'Upgrade' but was '" + headerConnection + "'");
    }
    String headerUpgrade = response.header("Upgrade");
    if (!"websocket".equalsIgnoreCase(headerUpgrade)) {
      throw new ProtocolException(
          "Expected 'Upgrade' header value 'websocket' but was '" + headerUpgrade + "'");
    }
    String headerAccept = response.header("Sec-WebSocket-Accept");
    String acceptExpected = Util.shaBase64(key + WebSocketProtocol.ACCEPT_MAGIC);
    if (!acceptExpected.equals(headerAccept)) {
      throw new ProtocolException("Expected 'Sec-WebSocket-Accept' header value '"
          + acceptExpected
          + "' but was '"
          + headerAccept
          + "'");
    }

    StreamAllocation streamAllocation = Internal.instance.callEngineGetStreamAllocation(call);
    RealWebSocket webSocket = StreamWebSocket.create(
        streamAllocation, response, random, listener);

    listener.onOpen(webSocket, response);

    while (webSocket.readMessage()) {
    }
  }

最后一行会进入循环不断的读取数据。 默认 OkHttpClient 的 readTimeOut 时间是 10s。所以如果服务器 10s 内没有数据返回,客户端就会自动断开。 所以配置的时候可以把 readTimeout 值设置长一点。 BJWebsocketClient 默认是 10 分钟。

Refference

HTTP DNS的实现
OkHttp3之Cookies管理及持久化
Https 详解
Http2
WebSocket

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

推荐阅读更多精彩内容