手把手讲解 OkHttp硬核知识点(2)

前言

手把手讲解系列文章,是我写给各位看官,也是写给我自己的。
文章可能过分详细,但是这是为了帮助到尽量多的人,毕竟工作5,6年,不能老吸血,也到了回馈开源的时候.
这个系列的文章:
1、用通俗易懂的讲解方式,讲解一门技术的实用价值
2、详细书写源码的追踪,源码截图,绘制类的结构图,尽量详细地解释原理的探索过程
3、提供Github 的 可运行的Demo工程,但是我所提供代码,更多是提供思路,抛砖引玉,请酌情cv
4、集合整理原理探索过程中的一些坑,或者demo的运行过程中的注意事项
5、用gif图,最直观地展示demo运行效果

如果觉得细节太细,直接跳过看结论即可。
本人能力有限,如若发现描述不当之处,欢迎留言批评指正。

学到老活到老,路漫漫其修远兮。与众君共勉 !


引子

OkHttp 知名第三方网络框架SDK,使用简单,性能优秀,但是内核并不简单,此系列文章,专挑硬核知识点详细讲解. 何为硬核,就是要想深入研究,你绝对绕不过去的知识点.

TIPS:声明:拦截器种细节太多,要一一讲解不太现实,所以我挑了其中最实用的一些要点加以总结。

本文接上一篇文章: //www.greatytc.com/p/dc06a54e920a
详细讲解 OKHttp的核心内容,拦截器。不过拦截器众多,有系统自带的,也有我们可以自己去自定义的。

image.png

这是网络请求执行的核心方法的起点,这里涉及了众多拦截器.

正文大纲

系统自带拦截器

1 重试与重定向拦截器 RetryAndFollowUpInterceptor
2 桥接拦截器 BridgeInterceptor
3 缓存拦截器 CacheInterceptor
4 连接拦截器 ConnectInterceptor
5 服务调用拦截器 CallServerInterceptor

正文

在详解拦截器之前,有必要先将 RealCallgetResponseWithInterceptorChain() 方法最后两行展开说明:

Interceptor.Chain chain = new RealInterceptorChain( interceptors, null, null, null, 0, originalRequest);
return chain.proceed(originalRequest);

这里最终返回 一个Response,进入chain.proceed方法,最终索引到 RealInterceptorChainproceed方法:

image.png

之后,我们追踪这个interceptor.intercept(next); ,发现是一个接口,找到实现类,有多个,进入其中的RetryAndFollowUpInterceptor,发现:
image.png

它这里又执行了 chain.proceed,于是又回到了RealInterceptorChain.proceed()方法,但是此时,刚才链条中的拦截器已经不再是原来的拦截器了,而是变成了第二个,因为每一次都index+1了(这里比较绕,类似递归,需要反复仔细体会),依次类推,直到所有拦截器的intercept方法都执行完毕,直到链条中没有拦截器。就返回最后的Response

这一段是okhttp责任链模式的核心,应该好理解。

系统自带拦截器

1. 重试与重定向拦截器 RetryAndFollowUpInterceptor

先说结论吧:

顾名思义,retry 重试, FollowUp 重定向 。这个拦截器处在所有拦截器的第一个,它是用来判定要不要对当前请求进行重试和重定向的,那么我们应该关心的是:什么时候重试什么时候重定向
并且,它会判断用户有没有取消请求,因为RealCall中有一个cancel方法,可以支持用户取消请求
(不过这里有两种情况,在请求发出之取消,和 在之取消。如果是在请求之取消,那就直接不执行之后的过程,如果是在请求发出去之取消,那么客户端就会丢弃这一次的response

重试

RetryAndFollowUpInterceptor的核心方法 interceptor() :

   @Override public Response intercept(Chain chain) throws IOException {
    ...省略
    while (true) {
      ...省略
      try {
        response = ((RealInterceptorChain) chain).proceed(request, streamAllocation, null, null);
        releaseConnection = false;
      } catch (RouteException e) {
        // The attempt to connect via a route failed. The request will not have been sent.
        if (!recover(e.getLastConnectException(), false, request)) {
          throw e.getLastConnectException();
        }
        releaseConnection = false;
        continue;
      } catch (IOException e) {
        // An attempt to communicate with a server failed. The request may have been sent.
        boolean requestSendStarted = !(e instanceof ConnectionShutdownException);
        if (!recover(e, requestSendStarted, request)) throw e;
        releaseConnection = false;
        continue;
      }
      ...省略
      if (followUp == null) {
        if (!forWebSocket) {
          streamAllocation.release();
        }
        return response;
      }
      ...省略

    }
  }

上面的代码中,我只保留了关键部分。其中有两个continue,一个return.

当请求到达了这个拦截器,它会进入一个while(true)循环,

  • 当发生了RouteException 异常(这是由于请求尚未发出去,路由异常,连接未成功),就会去判断recover方法的返回值,根据返回值决定要不要continue.
  • 当发生IOException(请求已经发出去,但是和服务器通信失败了)之后,同样去判断recover方法的返回值,根据返回值决定要不要continue.

如果这两个continue都没有执行,就有可能走到最后的return response结束本次请求.
那么 是不是要重试,其判断逻辑就在recover()方法内部:

private boolean recover(IOException e, StreamAllocation streamAllocation,
                            boolean requestSendStarted, Request userRequest) {
        streamAllocation.streamFailed(e);

        //todo 1、在配置OkhttpClient是设置了不允许重试(默认允许),则一旦发生请求失败就不再重试
        //The application layer has forbidden retries.
        if (!client.retryOnConnectionFailure()) return false;

        //todo 2、由于requestSendStarted只在http2的io异常中为false,http1则是 true,
        //在http1的情况下,需要判定 body有没有实现UnrepeatableRequestBody接口,而body默认是没有实现,所以后续instanceOf不成立,不会走return false.
        //We can't send the request body again.
        if (requestSendStarted && userRequest.body() instanceof UnrepeatableRequestBody)
            return false;

        //todo 3、判断是不是属于重试的异常
        //This exception is fatal.
        if (!isRecoverable(e, requestSendStarted)) return false;

        //todo 4、有没有可以用来连接的路由路线
        //No more routes to attempt.
        if (!streamAllocation.hasMoreRoutes()) return false;

        // For failure recovery, use the same route selector with a new connection.
        return true;
    }

简单解读一下这个方法:

  1. 如果okhttpClient已经set了不允许重试,那么这里就返回false,不再重试。
  2. 如果requestSendStarted 只在http2.0的IO异常中是true,不过HTTP2.0还没普及,先不管他,这里默认通过。
  3. 判断是否是重试的异常,也就是说,是不是之前重试之后发生了异常。
    这里解读一下,之前重试发生过异常,抛出了Exception,这个isRecoverable方法会根据这个异常去判定,是否还有必要去重试。
  • 协议异常,如果发生了协议异常,那么没必要重试了,你的请求或者服务器本身可能就存在问题,再重试也是白瞎。
  • 超时异常,只是超时而已,直接判定重试(这里requestSendStarted是http2才会为true,所以这里默认就是false)
  • SSL异常,HTTPS证书出现问题,没必要重试。
  • SSL握手未授权异常,也不必重试
private boolean isRecoverable(IOException e, boolean requestSendStarted) {
    // 出现协议异常,不能重试
    if (e instanceof ProtocolException) {
      return false;
    }

    // requestSendStarted认为它一直为false(不管http2),异常属于socket超时异常,直接判定可以重试
    if (e instanceof InterruptedIOException) {
      return e instanceof SocketTimeoutException && !requestSendStarted;
    }

    // SSL握手异常中,证书出现问题,不能重试
    if (e instanceof SSLHandshakeException) {
      if (e.getCause() instanceof CertificateException) {
        return false;
      }
    }
    // SSL握手未授权异常 不能重试
    if (e instanceof SSLPeerUnverifiedException) {
      return false;
    }
    return true;
}
  1. 有没有可以用来连接的路由路线,也就是说,如果当DNS解析域名的时候,返回了多个IP,那么这里可能一个一个去尝试重试,直到没有更多ip可用。

重定向

依然是RetryAndFollowUpInterceptor的核心方法 interceptor() 方法,这次我截取后半段:

public Response intercept(Chain chain) throws IOException {
    while (true) {
            ...省略前面的重试判定
            //todo 处理3和4xx的一些状态码,如301 302重定向
            Request followUp = followUpRequest(response, streamAllocation.route());
            if (followUp == null) {
                if (!forWebSocket) {
                    streamAllocation.release();
                }
                return response;
            }

            closeQuietly(response.body());

            //todo 限制最大 followup 次数为20次
            if (++followUpCount > MAX_FOLLOW_UPS) {
                streamAllocation.release();
                throw new ProtocolException("Too many follow-up requests: " + followUpCount);
            }

            if (followUp.body() instanceof UnrepeatableRequestBody) {
                streamAllocation.release();
                throw new HttpRetryException("Cannot retry streamed HTTP body", response.code());
            }
            //todo 判断是不是可以复用同一份连接
            if (!sameConnection(response, followUp.url())) {
                streamAllocation.release();
                streamAllocation = new StreamAllocation(client.connectionPool(),
                        createAddress(followUp.url()), call, eventListener, callStackTrace);
                this.streamAllocation = streamAllocation;
            } else if (streamAllocation.codec() != null) {
                throw new IllegalStateException("Closing the body of " + response
                        + " didn't close its backing stream. Bad interceptor?");
            }
  }
}

上面源码中, followUpRequest() 方法中规定了哪些响应码可以重定向:

private Request followUpRequest(Response userResponse) throws IOException {
    if (userResponse == null) throw new IllegalStateException();
    Connection connection = streamAllocation.connection();
    Route route = connection != null
        ? connection.route()
        : null;
    int responseCode = userResponse.code();

    final String method = userResponse.request().method();
    switch (responseCode) {
      // 407 客户端使用了HTTP代理服务器,在请求头中添加 “Proxy-Authorization”,让代理服务器授权
      case HTTP_PROXY_AUTH:
        Proxy selectedProxy = route != null
            ? route.proxy()
            : client.proxy();
        if (selectedProxy.type() != Proxy.Type.HTTP) {
          throw new ProtocolException("Received HTTP_PROXY_AUTH (407) code while not using proxy");
        }
        return client.proxyAuthenticator().authenticate(route, userResponse);
      // 401 需要身份验证 有些服务器接口需要验证使用者身份 在请求头中添加 “Authorization” 
      case HTTP_UNAUTHORIZED:
        return client.authenticator().authenticate(route, userResponse);
      // 308 永久重定向 
      // 307 临时重定向
      case HTTP_PERM_REDIRECT:
      case HTTP_TEMP_REDIRECT:
        // 如果请求方式不是GET或者HEAD,框架不会自动重定向请求
        if (!method.equals("GET") && !method.equals("HEAD")) {
          return null;
        }
      // 300 301 302 303 
      case HTTP_MULT_CHOICE:
      case HTTP_MOVED_PERM:
      case HTTP_MOVED_TEMP:
      case HTTP_SEE_OTHER:
        // 如果用户不允许重定向,那就返回null
        if (!client.followRedirects()) return null;
        // 从响应头取出location 
        String location = userResponse.header("Location");
        if (location == null) return null;
        // 根据location 配置新的请求 url
        HttpUrl url = userResponse.request().url().resolve(location);
        // 如果为null,说明协议有问题,取不出来HttpUrl,那就返回null,不进行重定向
        if (url == null) return null;
        // 如果重定向在http到https之间切换,需要检查用户是不是允许(默认允许)
        boolean sameScheme = url.scheme().equals(userResponse.request().url().scheme());
        if (!sameScheme && !client.followSslRedirects()) return null;

        Request.Builder requestBuilder = userResponse.request().newBuilder();
        /**
         *  重定向请求中 只要不是 PROPFIND 请求,无论是POST还是其他的方法都要改为GET请求方式,
         *  即只有 PROPFIND 请求才能有请求体
         */
        //请求不是get与head
        if (HttpMethod.permitsRequestBody(method)) {
          final boolean maintainBody = HttpMethod.redirectsWithBody(method);
           // 除了 PROPFIND 请求之外都改成GET请求
          if (HttpMethod.redirectsToGet(method)) {
            requestBuilder.method("GET", null);
          } else {
            RequestBody requestBody = maintainBody ? userResponse.request().body() : null;
            requestBuilder.method(method, requestBody);
          }
          // 不是 PROPFIND 的请求,把请求头中关于请求体的数据删掉
          if (!maintainBody) {
            requestBuilder.removeHeader("Transfer-Encoding");
            requestBuilder.removeHeader("Content-Length");
            requestBuilder.removeHeader("Content-Type");
          }
        }

        // 在跨主机重定向时,删除身份验证请求头
        if (!sameConnection(userResponse, url)) {
          requestBuilder.removeHeader("Authorization");
        }

        return requestBuilder.url(url).build();

      // 408 客户端请求超时 
      case HTTP_CLIENT_TIMEOUT:
        // 408 算是连接失败了,所以判断用户是不是允许重试
        if (!client.retryOnConnectionFailure()) {
            return null;
        }
        // UnrepeatableRequestBody实际并没发现有其他地方用到
        if (userResponse.request().body() instanceof UnrepeatableRequestBody) {
            return null;
        }
        // 如果是本身这次的响应就是重新请求的产物同时上一次之所以重请求还是因为408,那我们这次不再重请求了
        if (userResponse.priorResponse() != null
                        && userResponse.priorResponse().code() == HTTP_CLIENT_TIMEOUT) {
            return null;
        }
        // 如果服务器告诉我们了 Retry-After 多久后重试,那框架不管了。
        if (retryAfter(userResponse, 0) > 0) {
            return null;
        }
        return userResponse.request();
       // 503 服务不可用 和408差不多,但是只在服务器告诉你 Retry-After:0(意思就是立即重试) 才重请求
       case HTTP_UNAVAILABLE:
        if (userResponse.priorResponse() != null
                        && userResponse.priorResponse().code() == HTTP_UNAVAILABLE) {
            return null;
         }

         if (retryAfter(userResponse, Integer.MAX_VALUE) == 0) {
            return userResponse.request();
         }

         return null;
      default:
        return null;
    }
}

解读一下这个方法,它根据拿到的response的内容,判断他的响应码,决定要不要返回一个新的request,如果返回了新的request,那么外围(看RetryAndFollowUpInterceptor的intercept方法)的while(true)无限循环就会 使用新的request再次请求,完成重定向。细节上请查看上面代码的注释,来自一位高手,写的很详细。
大概做个结论:

  • 响应码 3XX 一般都会返回一个 新的Request,而另外的 return null就是不允许重定向。
  • followup最大发生20次

不过还是那句话,我们不是专门做网络架构或者优化,了解到 这一个拦截器的基本作用,重要节点即可,真要抠细节,谁也记不了那么清楚。


2. 桥接拦截器 BridgeInterceptor

这个可能是这5个当中最简单的一个拦截器了,它从上一层RetryAndFollowUpInterceptor拿到request之后,只做了一件事:补全请求头
我们使用OkHttp发送网络请求,一般只会 addHeader中写上我们业务相关的一些参数,而 真正的请求头远远没有那么简单。
服务器不只是要识别 业务参数,还要识别 请求类型,请求体的解析方式等,具体列举如下:

image.png

它在补全了请求头之后,交给下一个拦截器处理。在它得到响应之后,还会干两件事:

1、保存cookie,下一次同样域名的请求就会带上cookie到请求头中,但是这个要求我们自己在okHttpClient的CookieJar中实现具体过程。


image.png
  1. 如果使用gzip返回的数据,则使用GzipSource包装便于解析。

3. 缓存拦截器 CacheInterceptor

本文只介绍他的作用,因为内部逻辑太过复杂,必须单独成文讲解。

作用:判定缓存命中,如果命中,就使用缓存response给上一层拦截器,不必请求服务器,如果没有命中,就 发送网络请求。


4. 连接拦截器 ConnectInterceptor

顾名思义:它自然是管理网络连接的。如何管理?
我们都知道,HTTP协议的底层,还是要走TCP,他们分属于 TCP/IP四层架构的应用层和传输层。传输层会三次握手,Socket建立成功之后,就会开启IO流传输数据。那么是不是每一次我们的网络请求都需要重新取三次握手建立新的Socket连接?显然不用。如果已经就有了现成的闲置连接,没有必要去建立新的Socket连接. 而这些连接的管理逻辑,就在这个ConnectionInterceptor中.

这个类相当简洁:

image.png

上面的关键点:
就是这个叫做 StreamAllocation (顾名思义,流的分配者,是用来管理数据流通道的)的类,如果进入它的源码,就会发现,它管理着一个连接池,

image.png

那么,它是如何管理所有连接的呢?核心逻辑在哪里?

我们跟踪,RealConnection connection = streamAllocation.connection(); 这一句代码:

image.png

这一个connection变量的赋值,在这里
image.png

从而找到 ConnectionPool 的 get方法:
image.png

此处有一个isEligible方法,它的作用是 遍历连接池的双端队列(观察connections的类型,它是一个Deque<RealConnection>)中是否有合格的 连接可用。

/**
   * Returns true if this connection can carry a stream allocation to {@code address}. If non-null
   * {@code route} is the resolved route for a connection.
   */
  public boolean isEligible(Address address, @Nullable Route route) {
    // If this connection is not accepting new streams, we're done.
    if (allocations.size() >= allocationLimit || noNewStreams) return false;

    // If the non-host fields of the address don't overlap, we're done.
    if (!Internal.instance.equalsNonHost(this.route.address(), address)) return false;

    // If the host exactly matches, we're done: this connection can carry the address.
    if (address.url().host().equals(this.route().address().url().host())) {
      return true; // This connection is a perfect match.
    }

    // At this point we don't have a hostname match. But we still be able to carry the request if
    // our connection coalescing requirements are met. See also:
    // https://hpbn.co/optimizing-application-delivery/#eliminate-domain-sharding
    // https://daniel.haxx.se/blog/2016/08/18/http2-connection-coalescing/

    // 1. This connection must be HTTP/2.
    if (http2Connection == null) return false;

    // 2. The routes must share an IP address. This requires us to have a DNS address for both
    // hosts, which only happens after route planning. We can't coalesce connections that use a
    // proxy, since proxies don't tell us the origin server's IP address.
    if (route == null) return false;
    if (route.proxy().type() != Proxy.Type.DIRECT) return false;
    if (this.route.proxy().type() != Proxy.Type.DIRECT) return false;
    if (!this.route.socketAddress().equals(route.socketAddress())) return false;

    // 3. This connection's server certificate's must cover the new host.
    if (route.address().hostnameVerifier() != OkHostnameVerifier.INSTANCE) return false;
    if (!supportsUrl(address.url())) return false;

    // 4. Certificate pinning must match the host.
    try {
      address.certificatePinner().check(address.url().host(), handshake().peerCertificates());
    } catch (SSLPeerUnverifiedException e) {
      return false;
    }

    return true; // The caller's address can be carried by this connection.
  }

解读一下这段代码:

  • 返回值: 如果这个连接可以给这个访问地址一个流,那么返回true。(可见,判断连接是否可用,会考虑当前访问的ip地址是否匹配,很好理解,如果访问的ip都不一样,我凭什么把现成的连接给你复用。)

然后是众多if判断(这里存在一个当前连接目标地址的参数值对比),总结起来:

  • 连接到达最大并发流或者连接不允许建立新的流,那就不允许复用;
  • 如果地址的非host字段没有完全相同,也不允许复用。(解释一下,一个Address对象包含了host字段和其他字段,这里的非host字段,就是指的这个类中的其他字段)
  • 如果到了这里,那就判断host是不是相同,如果相同,那就允许复用。
  • 如果上面的3步判断都没有命中,那么,我们依然有机会去复用,只要不命中下面的判断。
    1)HTTP2连接为空,不允许复用
    2)使用了代理,并且代理的类型不是直接代理 或者 ,不允许复用
    3)此连接的服务器证书必须覆盖新主机,否则也不能复用
    4)证书固定必须与主机匹配,否则,也不能复用
    上面的4个if都没有命中,那么还是判定为可以复用。

总结:如果在连接池中找到个连接参数一致并且未被关闭没被占用的连接,则可以复用旧连接,无需新建连接


5. 服务调用拦截器 CallServerInterceptor

经历了 上面4个拦截器,我们最终拿到了 requestconnection,有了请求,有了连接,那么就可以向服务器发起网络请求了。这一步,就包含执行网络请求的具体逻辑。

注意:我们这里的request,它是一个java对象,但是底层用的TCP协议,发送的是报文(在http1.X下,报文全都是明文字符串格式拼接,那么你一个request对象中包含的内容如何去拼接成一个 报文字符串呢?)
反过来想,我们TCP底层从服务器取得的也最先是 响应报文,他也是一大串字符串,那么如何封装成java需要的Response对象呢?逻辑也在这里。具体在哪个类?

HttpCodeC.java
在这里我关心两个问题:
1)真正与底层发生socket网络通信是如何进行的?
2)httpCodeC是如何解析request成请求报文,又是如何将相应报文变成Response的?

来探索,首先进入到CallServerInterceptorInterceptor方法,这是核心入口:

image.png

首先回顾一下TCP socket通信的过程:
1)建立Socket连接
2)打开IO流通道(socket是双工通讯协议,可以同时调用输入和输出流)
3)在这个案例中,移动端作为客户端需要往IO流通道中去写数据,然而我们知道,OutputStream,当我们写了数据之后,必须调用flush,刷新缓存区,才能将数据发送过去。
这里,我们就能得出上面一个问题的答案:

Q:真正与底层发生socket网络通信是如何进行的?
A:OkHttp使用了 OkIo来发送数据给远端,在这个Intercept 方法中,我们可以找到两句代码:httpCodec.flushRequest();httpCodec.finishRequest(); 如果你进去看实现,会发现,

他们执行的是同一个flush过程,将缓存去数据发送给远端。(至于更细节的,比如 sink.flush是如何实现的,我就不去探索了,有兴趣的可以继续深入)

下面来探究问题2:
Q: httpCodeC是如何解析request成请求报文,又是如何将相应报文变成Response的?
继续在Intercept方法中寻找,发现:

httpCodec.writeRequestHeaders(request);  //写入请求头
httpCodec.createRequestBody(request, contentLength)  //构建请求体

深入可以发现,这两个方法其实都是接口方法,他们的具体实现分为了http1 和http2 ,两者实现并不相同,我们只看http1的:

image.png

image.png

看到了吧,这里其实就是在利用 io通道 sink来写入字符串而已。至此,request变成字符串报文已经有答案了。

那么,报文如何变成response对象呢?
响应,分为响应头和响应体,先看响应头:

image.png

image.png

可以看出,响应头,是利用通道中读取出来的响应行,解析成StatusLine对象,然后将StatusLine对象的属性逐一读取,构建一个Response对象。
那么响应体呢?

image.png

上图中,利用了OkIo来缓存source通道中的数据,构建一个RequestBody对象。
现在有了ResponseHeaderResponseBody,就能组成一个完整的Response对象。

最后的结论:

CallServerInterceptor拦截器 就是真正向远端socket发送数据以及 接收远端socket数据的一层,利用OkIO作为数据通道。 HTTP1中以字符串拼接的方式,解析Request数据然后发送 / 解析响应报文并封装成Response对象.


结语

OkHttp作为知名第三方开源框架的佼佼者,其中值得探索学习的细节非常至多。但是我们学习框架,探索源码,一定要有自己的明确的目标。网上介绍okhttp的文章很多,每个人的侧重点都不一样,但是 okhttp的大思想都是一样,比如:分发器/拦截器,分发器的三个双端队列的作用,5大系统拦截器的职责,责任链模式设计思路。
一定有人会说,上面文章的这么多细节,我是可以根据你的文章看一遍,走一遍,能够在脑子里留个印象,但是过两天又忘了,怎么办?
大可不必担忧,自己走过一遍的路,想要走第二遍,手到擒来,但是如果是完全没有走过的路,就是完全的陌生区域,会发生什么事,你完全把控不了。

程序员修炼路遥遥,深入学习过源码,和完全没看过源码,是截然不同的两个境界。

(PS:缓存拦截器的具体细节,后面有时间会出文章补上!)


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