从grpc源码讲起(Client端的消息发送)

铺垫

grpc有三个核心的抽象层:

Stub

绝大数开发者会直接使用的一部分,proto文件编译所生成的Stub就是在此层的基础之上生成的。它提供了一种调用方和服务之间类型安全的绑定关系,对比http服务:http服务就不提供类型安全的绑定关系,调用方和服务方需要自行处理类型转化的相关工作。

Channel

channel层是对数据传输的抽象,便于用户更方便的进行 拦截器/装饰者 等类似的处理。它旨在使应用程序框架易于使用此层来解决诸如日志记录,监视,身份验证等交叉问题。流控制也暴露在此层,以允许更复杂的应用程序直接与其交互。

Transport

这一层在绝大多数情况下是我们不需要关心的,它的作用就是传输数据,底层基于netty,或者ok http的实现

grpc请求创建

分析一个典型的grpc请求创建的代码

请求从调用 grpcClient 的getFutureStub开始

CrmConsumeServiceGrpc.CrmConsumeServiceFutureStub rechargeService =
        (CrmConsumeServiceGrpc.CrmConsumeServiceFutureStub) grpcClient.getFutureStub(CrmConsumeServiceGrpc.class);

getFutureStub 方法实际就是调用了 getStub

public Object getFutureStub(Class grpcClass) throws Exception {
  return getStub(grpcClass, "newFutureStub");
}

实际上就是通过反射的方式调用了了 CrmConsumeServiceGrpc 的newFutureStub 方法

private Object getStub(Class grpcClass, String stubMethodName) throws Exception {
  Method stubMethod = grpcClass.getMethod(stubMethodName, Channel.class);
  return stubMethod.invoke(null, channel);
}

继续跟到具体方法

public static CrmConsumeServiceFutureStub newFutureStub(
    io.grpc.Channel channel) {
  return new CrmConsumeServiceFutureStub(channel);
}

类的私有构造方法

public static final class CrmConsumeServiceFutureStub extends io.grpc.stub.AbstractStub<CrmConsumeServiceFutureStub> {
  private CrmConsumeServiceFutureStub(io.grpc.Channel channel) {
    super(channel);
  }

最终跟到了AbstractStub 类,这个类是stub层的核心类之一,实际上proto文件生成的Stub类,都是该类的子类。

protected AbstractStub(Channel channel) {
  this(channel, CallOptions.DEFAULT);
}

简单的说,获取stub的过程,就是把GrpcClient 的channel属性传递给Stub的过程。那由此可以推断出,channel是在GrpcClient创建的时候,一起创建的。 下面看一下GrpcClient的源码。

 private void init(String target, int maxMessageSize, Tracing tracing, LoadBalancer.Factory loadBalancerFactory, ClientInterceptor... interceptors) {
    LoggingClientInterceptor loggingClientInterceptor = new LoggingClientInterceptor();
    this.allClientInterceptors.add(loggingClientInterceptor);
    if (tracing != null) {
      GrpcTracing grpcTracing = GrpcTracing.create(tracing);
      allClientInterceptors.add(grpcTracing.newClientInterceptor());
    }
    if (interceptors != null) {
      allClientInterceptors.addAll(Arrays.asList(interceptors));
    }

    NettyChannelBuilder builder = NettyChannelBuilder
        .forTarget(target)
        .keepAliveTime(20, TimeUnit.SECONDS)
        .keepAliveTimeout(2, TimeUnit.SECONDS)
        .keepAliveWithoutCalls(true)
        .idleTimeout(24, TimeUnit.HOURS)
        .usePlaintext(true)
        .intercept(allClientInterceptors)
        .nameResolverFactory(NameResolverProvider.asFactory())
        .loadBalancerFactory(RoundRobinLoadBalancerFactory.getInstance());
    if (loadBalancerFactory != null) {
      builder.loadBalancerFactory(loadBalancerFactory);
    }

    if (maxMessageSize > 0) {
      builder.maxInboundMessageSize(maxMessageSize);
    }
    channel = builder.build();
  }
  

调用 NettyChannelBuilder的build方法,创建了channel。下面的注释也验证了这点,NettyChannelBuilder 创建了一个以Netty 作为传输层的 channel。其中除了必要的target,也就是服务地址以外,传递给channel的参数还包含了三个比较重要的属性 intercept,namemResolverFactory,loadBalancerFactory。 。

/**
 * A builder to help simplify construction of channels using the Netty transport.
 */
@ExperimentalApi("https://github.com/grpc/grpc-java/issues/1784")
@CanIgnoreReturnValue
public final class NettyChannelBuilder

先看一下channel能够做什么

Channel的注释:

A virtual connection to a conceptual endpoint, to perform RPCs. A channel is free to have zero or many actual connections to the endpoint based on configuration, load, etc. A channel is also free to determine which actual endpoints to use and may change it every RPC, permitting client-side load balancing. Applications are generally expected to use stubs instead of calling this class directly.
Applications can add common cross-cutting behaviors to stubs by decorating Channel implementations using ClientInterceptor. It is expected that most application code will not use this class directly but rather work with stubs that have been bound to a Channel that was decorated during application initialization.

Channel是一个虚拟的链接,它可以维护任意数量的真实链接,也可以自主选择具体使用的链接。也就是说,在channel上,我们可以实现客户端的 load balancing。另外,注释中建议应用使用stubs,而不是直接调用channel。其实也就说,我们可以避开Stub的创建,直接调用Channel。另外通过实现ClientInterceptor 可以实现对Channel的横切(cross-cutting),这个也是在Channel层做到的。

方法的调用

在看方法调用的源码前,先思考一个问题。grpc 实现远程调用,需要哪些参数。
首先远程调用首先是一个方法调用,所以需要定位一个方法,以及对应的参数。
另外因为是远端请求,所以还需要一个远端服务地址(Channel),远端的请求,必然涉及到序列化和反序列化的过程,另外grpc提供了类型安全的方法调用,所以序列化和反序列化的时候,也需要获取到 request 和response 的具体类型。

    public com.google.common.util.concurrent.ListenableFuture<com.hualala.app.shop.pos.grpc.UpdateAgentInfoInterfaceData.UpdateAgentResponse> updateAgentInfo(
        com.hualala.app.shop.pos.grpc.UpdateAgentInfoInterfaceData.UpdateAgentRequest request) {
      return futureUnaryCall(
          getChannel().newCall(METHOD_UPDATE_AGENT_INFO, getCallOptions()), request);
    }
  }

grpc 方法的调用就是调用proto自动生成的Stub上的方法,所有的的方法都是调用了 形如futureUnaryCall的方法,只是 每个方法传入的 METHOD_UPDATE_AGENT_INFO参数不一样。

  public static final io.grpc.MethodDescriptor<com.hualala.app.shop.pos.grpc.UpdateAgentInfoInterfaceData.UpdateAgentRequest,
      com.hualala.app.shop.pos.grpc.UpdateAgentInfoInterfaceData.UpdateAgentResponse> METHOD_UPDATE_AGENT_INFO =
      io.grpc.MethodDescriptor.create(
          io.grpc.MethodDescriptor.MethodType.UNARY,
          generateFullMethodName(
              "UpdateAgentInfoInterface", "updateAgentInfo"),
          io.grpc.protobuf.ProtoUtils.marshaller(com.hualala.app.shop.pos.grpc.UpdateAgentInfoInterfaceData.UpdateAgentRequest.getDefaultInstance()),
          io.grpc.protobuf.ProtoUtils.marshaller(com.hualala.app.shop.pos.grpc.UpdateAgentInfoInterfaceData.UpdateAgentResponse.getDefaultInstance()));

METHOD_UPDATE_AGENT_INFO 保存了 FullMethodName,还有两个marshaller,marshaller保存着request和response的类信息,用于序列化和反序列化。

到此为止,grpc发起的一个远端请求需要的信息怎么处理的我们都已经了解了。Channel通过GrpcClient创建,调用的方法信息和序列化的信息在Stub中保存,request 是调用方创建的。

之后其实就是这些信息的加工和组合。

首先第一步获取Channel,然后创建一个Call。Channel毫无疑问就是刚刚创建的ManagedChannelImpl。

    public com.google.common.util.concurrent.ListenableFuture<com.hualala.app.shop.pos.grpc.UpdateAgentInfoInterfaceData.UpdateAgentResponse> updateAgentInfo(
        com.hualala.app.shop.pos.grpc.UpdateAgentInfoInterfaceData.UpdateAgentRequest request) {
      return futureUnaryCall(
          getChannel().newCall(METHOD_UPDATE_AGENT_INFO, getCallOptions()), request);
    }
  }
  

newCall创建了一个 ClientCall,在看newCall的源码前,我们先看一下ClientCall文档

An instance of a call to a remote method. A call will send zero or more request messages to the server and receive zero or more response messages back.
Instances are created by a Channel and used by stubs to invoke their remote behavior.

clientCall是一个调用远程方法的实例,由Channel创建,被stubs调用。所以clientCasll是真正的发送请求的对象。另外在clientCall在创建的时候,也会创建对应的拦截器。

思考一个问题,拦截器的功能实现,需要考虑哪些东西。拦截器的效果就是在请求真正执行前,获取到相关的信息,可以进行鉴权,日志等等。那么就是说,拦截器主要功能又两点,一个就是获取到请求信息。另外一个就是对请求的转发(包括转发到另外的拦截器和真实请求)

了解到这之后,我们看一下newCall方法。

  private static class InterceptorChannel extends Channel {
    private final Channel channel;
    private final ClientInterceptor interceptor;

    private InterceptorChannel(Channel channel, ClientInterceptor interceptor) {
      this.channel = channel;
      this.interceptor = Preconditions.checkNotNull(interceptor, "interceptor");
    }

    @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> newCall(
        MethodDescriptor<ReqT, RespT> method, CallOptions callOptions) {
      return interceptor.interceptCall(method, callOptions, channel);
    }

newCall方法设计的很巧妙,首先它本身继承自Channel,另外还有还有一个Channel的属性。在调用newCall方法的时候,Channel属性被当作参数传入了interceptCall中。如果InterceptorChannel中的channel,就同样是一个InterceptorChannel,那么如果在interceptCall的实现中,继续调用channel的newCall,那就顺序的创建了一批call

   this.interceptorChannel = ClientInterceptors.intercept(new RealChannel(), interceptors);

下面看一下interceptorChannel到底是怎么初始化的,首先interceptorChannel是在ManagedChannelImpl出事的时候,在构造方法中初始化好的。传入了两个参数,一个是RealChannel,一个是build的时候传入的interceptorts(List<ClientInterceptor>)参数

该方法的注释,大概意思就是,这段逻辑是为了拦截器用的

Create a new Channel that will call interceptors before starting a call on the given channel. The last interceptor will have its ClientInterceptor.interceptCall called first.
  public static Channel intercept(Channel channel, List<? extends ClientInterceptor> interceptors) {
    Preconditions.checkNotNull(channel, "channel");
    for (ClientInterceptor interceptor : interceptors) {
      channel = new InterceptorChannel(channel, interceptor);
    }
    return channel;
  }

下面继续看 newCall 方法本身,实际上就是inteceptor的inteceptorCall方法。

       @Override
    public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> methodDescriptor, CallOptions callOptions, Channel channel) {
        
            return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(channel.newCall(methodDescriptor, callOptions)) {

                @Override
                public void start(Listener<RespT> responseListener, Metadata headers) {

                    super.start(new ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>(responseListener) {
                        @Override

可以看到在这个方法内部确实调用了channel的newCall方法,新创建的一个匿名类SimpleForwardingClientCall,创建这个类的过程中调用了 channel的newCall。注意一个细节,在重写start方法后,还调用了super的start的方法

public abstract class ForwardingClientCall<ReqT, RespT> extends ClientCall<ReqT, RespT> {
  /**
   * Returns the delegated {@code ClientCall}.
   */
  protected abstract ClientCall<ReqT, RespT> delegate();

  @Override
  public void start(Listener<RespT> responseListener, Metadata headers) {
    delegate().start(responseListener, headers);
  }

ForwardingClientCall 就是典型的代理模式,在调用A方法的时候,实际上调用的都是代理类的方法。delegate 就是nextChannel创建的call, 注意到刚刚在拦截器中重写start的时候,最后调用了父类的start,实际上就是通过ForwardingClientCall 转发到了代理类的start方法上

接下来就缕一下整个client的创建过程。

假设:
项目里有两个拦截器,logging   auth
然后创建了两个InterceptorChannel 
loggingChannel:channel realChannel inteceptor logging
authChannel:channel loggingChannel  inteceprot auth
返回authChannel
调用authChannel 的newCall方法。
先调用 auth 的interceptCall 方法创建call (authCall)
在authCall创建的时候
    调用authChannel的interceptCall 方法
    然后继续调用loggingChanne的newCall方法(loggingCall)
    在loggingCall的创建过程中
        调用的realCall的new Call方法 
        创建一个ClientCallImpl
最后方法执行结束,返回authCall

authCall的执行(以start方法为例)

 先调用authCall的start方法
 然后调用super.start(),super.start()方法中的delegated 是logginCall
 执行 loggingCall的start方法
 loggingCall也同样调用 super的start,最终开始执行realCall的start方法

创建

0B808183-B2A1-45B1-95D1-FBDE8B02B00D.png

调用

4C776402-3342-4C52-B487-A69D03039196.png

拦截器实际就是创建了一个clientCall,也就是说,clientCall的所有方法都可以被拦截,到底是在方法执行前拦截,还是方法执行后拦截,可以通过调用super方法的时机来确定。ClientCall在创建的时候还可以创建对应的Listener。下面会介绍Listener的作用

86140735-3D02-4727-B9D3-DEB8F1E5F100.png

刚刚说了这么多,其实方法还在在说Stub里方法。下面继续看futureUnaryCall的调用过程。futureUnaryCall方法是ClientCalls的一个方法。

  public static <ReqT, RespT> ListenableFuture<RespT> futureUnaryCall(
      ClientCall<ReqT, RespT> call,
      ReqT param) {
    GrpcFuture<RespT> responseFuture = new GrpcFuture<RespT>(call);
    asyncUnaryRequestCall(call, param, new UnaryStreamToFuture<RespT>(responseFuture), false);
    return responseFuture;
  }

futureUnaryCall方法中,创建了UnaryStreamToFuture。UnaryStreamToFuture是一个Listener,这就涉及到了设计模式里的观察者模式,观察者模式主要有两个要点,一个是事件和观察者的绑定,一个是事件触发的时候,会调用观察者。

  private static final class UnaryStreamToFuture<RespT> extends ClientCall.Listener<RespT> {
    private final GrpcFuture<RespT> responseFuture;
    private RespT value;

开始实际发送请求
startCall,处理了call 和callListener的绑定。
call.sendMessage(param) 方法开始发送请求
call.halfClose() 是只关闭了这个call 发送 request message,但是不影响response的接收
实际上到此为止,请求的发送就已经结束了。
但是除了发送请求以外,还要接收请求。拦截器有一个onMessage方法,就是收到消息的时候,会调用的接口。我们通过这个切入点来看一下client端是怎么接收消息的。

  private static <ReqT, RespT> void asyncUnaryRequestCall(
      ClientCall<ReqT, RespT> call,
      ReqT param,
      ClientCall.Listener<RespT> responseListener,
      boolean streamingResponse) {
    startCall(call, responseListener, streamingResponse);
    try {
      call.sendMessage(param);
      call.halfClose();
    } catch (RuntimeException e) {
      throw cancelThrow(call, e);
    } catch (Error e) {
      throw cancelThrow(call, e);
    }
  }

startCall方法中,调用了call的start方法。所以拦截器中也可以自定义的添加具体的listener。

     private static <ReqT, RespT> void startCall(ClientCall<ReqT, RespT> call,
      ClientCall.Listener<RespT> responseListener, boolean streamingResponse) {
    call.start(responseListener, new Metadata());
    if (streamingResponse) {
      call.request(1);
    } else {
      // Initially ask for two responses from flow-control so that if a misbehaving server sends
      // more than one responses, we can catch it and fail it in the listener.
      call.request(2);
    }
  }

所以拦截器在重写start方法的时候,也可以传入对应的listener,看一下listener的创建

    super.start(new ForwardingClientCallListener.SimpleForwardingClientCallListener<RespT>(responseListener) {
        Override
        public void onMessage(RespT message) {
            //如果当前在线,则onMessage是从服务端返回,这就相当于一次服务可用性的检测
            healthCheck.setProxyStatus(true);
            super.onMessage(message);
        }
    }, headers)

这个是项目里面accessTokenInterceptor里创建的Listener,可以看到也是创建里一个SimpleForwarding***,在onMessage的时候,也是调用了super的onMessage方法,所以listenter和clientCall类似,也是通过代理模式实现的转发。

看一下创建的真正实际工作的Call

      return new ClientCallImpl<ReqT, RespT>(
              method,
              executor,
              callOptions,
              transportProvider,
              terminated ? null : transportFactory.getScheduledExecutorService())
          .setFullStreamDecompression(fullStreamDecompression)
          .setDecompressorRegistry(decompressorRegistry)
          .setCompressorRegistry(compressorRegistry);

传入的参数有

method :指定执行的方法

transportProvider: 提供链路信息

真正执行的start方法

 if (!deadlineExceeded) {
      updateTimeoutHeaders(effectiveDeadline, callOptions.getDeadline(),
          context.getDeadline(), headers);
      ClientTransport transport = clientTransportProvider.get(
          new PickSubchannelArgsImpl(method, headers, callOptions));
      Context origContext = context.attach();
      try {
        stream = transport.newStream(method, headers, callOptions);
      } finally {
        context.detach(origContext);
      }
    } else {
      stream = new FailingClientStream(DEADLINE_EXCEEDED);
    }

    if (callOptions.getAuthority() != null) {
      stream.setAuthority(callOptions.getAuthority());
    }
    if (callOptions.getMaxInboundMessageSize() != null) {
      stream.setMaxInboundMessageSize(callOptions.getMaxInboundMessageSize());
    }
    if (callOptions.getMaxOutboundMessageSize() != null) {
      stream.setMaxOutboundMessageSize(callOptions.getMaxOutboundMessageSize());
    }
    stream.setCompressor(compressor);
    stream.setFullStreamDecompression(fullStreamDecompression);
    stream.setDecompressorRegistry(decompressorRegistry);
    stream.start(new ClientStreamListenerImpl(observer));

创建了一个stream ,并且执行了stream 的start方法。
在创建的时候,传入了method信息,header信息。并且执行了stream 的start方法。

看一下stream的作用

A single stream of communication between two end-points within a transport.

在transport基础上,用作两端通信的流

看一下这个stream的具体作用

  public final void start(ClientStreamListener listener) {
    transportState().setListener(listener);
    if (!useGet) {
      abstractClientStreamSink().writeHeaders(headers, null);
      headers = null;
    }
  }

在Sink 里写入header

@Override
    public void writeHeaders(Metadata headers, byte[] requestPayload) {
      // Convert the headers into Netty HTTP/2 headers.
      AsciiString defaultPath = (AsciiString) methodDescriptorAccessor.geRawMethodName(method);
      if (defaultPath == null) {
        defaultPath = new AsciiString("/" + method.getFullMethodName());
        methodDescriptorAccessor.setRawMethodName(method, defaultPath);
      }
      boolean get = (requestPayload != null);
      AsciiString httpMethod;
      if (get) {
        // Forge the query string
        // TODO(ericgribkoff) Add the key back to the query string
        defaultPath =
            new AsciiString(defaultPath + "?" + BaseEncoding.base64().encode(requestPayload));
        httpMethod = Utils.HTTP_GET_METHOD;
      } else {
        httpMethod = Utils.HTTP_METHOD;
      }
      Http2Headers http2Headers = Utils.convertClientHeaders(headers, scheme, defaultPath,
          authority, httpMethod, userAgent);

      ChannelFutureListener failureListener = new ChannelFutureListener() {
        @Override
        public void operationComplete(ChannelFuture future) throws Exception {
          if (!future.isSuccess()) {
            // Stream creation failed. Close the stream if not already closed.
            // When the channel is shutdown, the lifecycle manager has a better view of the failure,
            // especially before negotiation completes (because the negotiator commonly doesn't
            // receive the execeptionCaught because NettyClientHandler does not propagate it).
            Status s = transportState().handler.getLifecycleManager().getShutdownStatus();
            if (s == null) {
              s = transportState().statusFromFailedFuture(future);
            }
            transportState().transportReportStatus(s, true, new Metadata());
          }
        }
      };

      // Write the command requesting the creation of the stream.
      writeQueue.enqueue(new CreateStreamCommand(http2Headers, transportState(), get),
          !method.getType().clientSendsOneMessage() || get).addListener(failureListener);
    }

主要做了两件事

1:在header前添加上method,构建一个http2.0 的header

2: 把这个header 写入写队列。

后面再详细的调用就是netty框架的写入执行过程。

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

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,598评论 18 139
  • (目前有点乱,先贴上来,等以后有时间在整理吧。这个问题一直想拿出来分享,还有两个博客,都是相关的,一点点发出来) ...
    kamiSDY阅读 4,331评论 0 2
  • 两个人在一起吵吵闹闹走过8年,要不是8月发生那个事情,我不会停下来思考,思绪万千地把点点滴滴都想着,或许经历一些事...
    伊志如此阅读 332评论 0 0
  • 昨天晚上半夜还在谈工作室的事情,虽然说今天8点才起来,还是没有能成功早起,但是起来后买了早餐吃了早餐就去将昨天平面...
    Jennifer_怡阅读 58评论 0 0