分布式技术一:RPC通信与服务治理框架——Dubbo

一、什么是Dubbo?

1、Dubbo简介

  • Dubbo是一款开源的,高性能、轻量级的Java RPC框架
  • Dubbo是SOA时代的产物

2、Dubbo的产生

在我们思考为什么要生产一个工具的时候,我们的前置原因是要解决一个问题。回顾上文,在分布式系统中,我们将不同的服务置放于不同的服务器上,来满足系统的一些需求时,出现了一个问题:不同服务器上的服务,要如何通信?

在先前的单机系统中(以Spring Boot开发的单机应用为例),我们通常会为一个服务提供至少三段代码:Controller、Service和ServiceImpl。在单机系统中,应用间的通信,通常利用Spring的@Autowired注解,注入一个被调用的Bean,以达到调用的目的。但是在分布式系统中,我们也想如此调用,但是发现,服务间隔着网络的通信。在一般情况下,我们会想到,能否建立一个链接来实现这种调用,Dubbo就应运而生。


项目结构演变.jpg

作为SOA时代的产物,Dubbo一定程度上满足了SOA所需的一些内容:粗粒度、松耦合。服务之间,可以通过简单、精确定义接口进行通讯,并且不涉及底层编程接口和通讯模型。

Dubbo的底层封装了Netty,利用Netty完成RPC(Remote Procedure Call,远程过程调用)过程,在操作上简易快捷,让开发者仅仅关心服务的提供过程,不必直面服务间到底进行了怎么样的调用,极大地减少了分布式应用开发工作量。

3、RPC

RPC.jpg

I、首先需要解除的问题是:既然已经能通过HTTP协议(内置了TCP)发起请求,为什么还要自定义封装TCP构建RPC?

①、针对效率问题

起初在http1.1的协议时代,一个完整的http报文是繁琐而复杂的,即使编码协议也就是body是使用二进制编码协议,报文元数据也就是header头的键值对却用了文本编码,无用的字节占据了六到七成,这严重影响了通信的效率。服务间调用的过程,不该有那么多冗余字段的出现,也需要一定的时效性,因此RPC被提出和应用

②、针对封装性问题

现在都http2.0了,效率上做了极大改进,那么为什么还要用rpc或者grpc?首先,需要了解,grpc这种rpc库使用的就是http2.0协议。但是http容器的性能测试单位通常是kqps,自定义tpc协议则高出其一到两个两级。其次,完整的rpc封装,其实内置了更多的功能,比如负载均衡、服务降级等,相较于http有着更高的可用性。

II、那到底什么是RPC?

实现不同服务器上的服务之间通信的工具。因为服务不在同一个服务器,不在一个内存当中,因此需要远程调用,即RPC。本质上,是一个封装了服务请求调用的建立TCP,并且能完成有效的服务器间通信的工具。

一个完整的RPC框架要对使用者和用户做到低层透明。

一次完整的RPC调用流程如下(此处先不关心异步还是同步):
1)服务消费方(client)调用以本地调用方式调用服务;
2)client stub接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体;
3)client stub找到服务地址,并将消息发送到服务端;
4)server stub收到消息后进行解码;
5)server stub根据解码结果调用本地的服务;
6)本地服务执行并将结果返回给server stub;
7)server stub将返回结果打包成消息并发送至消费方;
8)client stub接收到消息,并进行解码;
9)服务消费方得到最终结果。
RPC框架的目标就是要2~8这些步骤都封装起来,这些细节对用户来说是透明的,不可见的。

二、快速使用(详情:请参考官方文档的《快速开始》

1、注册中心安装

Dubbo所推荐的注册中心为Zookeeper(此处参考Zookeeper的官方文档和其他博主的安装说明,不做赘述),也可以用Nacos(在后期Spring Cloud Alibaba的使用中会有所提及,该处不做说明)

2、Dubbo的使用流程

① 创建服务提供者

创建服务,并将服务暴露给注册中心(如果没有注册中心,也可以采用Dubbo的直连)

② 创建服务消费者

创建一个消费者,调用(消费)服务

③ 在Impl内提供服务

在服务方,实现服务,可以利用Stub完成调用前准入操作

④ 在Controller远程调用,消费Impl内的服务

在消费方,给外界提供服务Api的层级,利用Dubbo的Reference调用已暴露的服务,完成功能服务的对外提供。

三、Dubbo的基本原理

1、Dubbo架构与运行(未来补充图文)

I、Dubbo的架构

Duubo架构图.jpg

①、 容器启动

将应用归置在容器内启动服务,作为生产者,是一个初始化活动

② 注册服务

需要将可被调用的服务注册到一个注册中心内,告知系统,服务可被调用。该步骤也是一个生产者服务的初始化。

③ 发现服务

消费者想要调取服务,需要去注册中心中获取。该步骤分为两个步骤,消费者向注册中心发起调用的请求,是一个初始化调用的过程。注册中心通知消费者其所请求的服务的状况,是一个异步的动作。

④ 代理调用

Dubbo内部实现了代理,将被成功发现的服务反馈给消费者进行调用,如果被消费者所需的服务可以被调用,该步骤是一个同步的调用过程。

⑤ 监控服务

Dubbo内部实现了一个监控器,可以监控Dubbo的运行状况,监听采用的是异步过程。

II、Dubbo的层级结构

Dubbo运行过程.jpg
  • config 配置层:对外配置接口,以 ServiceConfig, ReferenceConfig 为中心,可以直接初始化配置类,也可以通过 spring 解析配置生成配置类
  • proxy 服务代理层:服务接口透明代理,生成服务的客户端 Stub 和服务器端 Skeleton, 以 ServiceProxy 为中心,扩展接口为 ProxyFactory
  • registry 注册中心层:封装服务地址的注册与发现,以服务 URL 为中心,扩展接口为 RegistryFactory, Registry, RegistryService
  • cluster 路由层:封装多个提供者的路由及负载均衡,并桥接注册中心,以 Invoker 为中心,扩展接口为 Cluster, Directory, Router, LoadBalance
  • monitor 监控层:RPC 调用次数和调用时间监控,以 Statistics 为中心,扩展接口为 MonitorFactory, Monitor, MonitorService
  • protocol 远程调用层:封装 RPC 调用,以 Invocation, Result 为中心,扩展接口为 Protocol, Invoker, Exporter
  • exchange 信息交换层:封装请求响应模式,同步转异步,以 Request, Response 为中心,扩展接口为 Exchanger, ExchangeChannel, ExchangeClient, ExchangeServer
  • transport 网络传输层:抽象 mina 和 netty 为统一接口,以 Message 为中心,扩展接口为 Channel, Transporter, Client, Server, Codec
  • serialize 数据序列化层:可复用的一些工具,扩展接口为 Serialization, ObjectInput, ObjectOutput, ThreadPool
    (该处引用尚硅谷的文档)

III、Dubbo的运行过程

①、Dubbo的启动与配置解析

Dubbo的启动时,需要的是解析和加载配置,通过DubboNamespaceHandler,通过registerBeanDefinitionParse()解析器从Bean容器内提取对应配置标签的配置对象及配置信息,生成对应的DubboBeanDefinitionParse对象。在DubboBeanDefinitionParse中,会有一处BeanDefinition类型的parse()方法,该处会根据传入的不同的lConfig配置类型,对不同的标签所配置的文件进行匹配和注册(set方法),完成对配置内容的解析和保存。(此处需要注意,解析过程中,根据标签的解析顺序,产生的对同一属性配置值的覆盖问题,该处会在Dubbo高级特性中说明)

Dubbo配置加载.JPG

②、Dubbo的暴露服务过程

在关注启动加载时,我们关注到在DubboNamespaceHandler中有两个特殊的加载,就是ServiceBean,一个是ReferenceBean。其中服务的暴露过程与ServiceBean相关。在ServiceBean中,其实现了两个重要的接口:

  • 一个是Spring原生的Bean初始化InitializingBean,在容器创建完对象后,会回调的一个void afterPropertiesSet()方法。该方法,在会对provider的配置信息做保存的工作。
  • 另一个是ApplicationListener<ContextRefreshedEvent>,在整个IOC容器初始化启动完成后,会回调一个void onApplicationEvent()方法。该方法会进行一次判断,如果一个服务没有暴露且需要暴露,那么调用export()方法,进行服务的暴露。

export()中,先会获取加载服务的信息,然后调用doExport()方法。

doExport()方法是一个加设Synchronized的同步方法。在doExport()中,对服务对象进行一系列的检查后,调用doExportUrls()doExportUrl()中会首先加载注册中心LoadRegisteries(),然后循环读取ProtocolConfig对象,获取到需暴露服务的协议以及对应的端口,注意,该处为循环读取,也说明一个服务在提供时,可以构建多份(集群部署)。然后调用doExportUrlsForProtocol()方法。

doExportUrlsForProtocol()方法中,最关键的几行代码为

Invoke<?> invoker = proxyFactory.getInvoker(ref,(Class),interface...);
DelegateProviderMetaDataInvoker wrapperInvoker = new DelegateProviderMetaDataInvoker(invoker,this);
Exporter<?> exporter = protocol.export(wrapperInvoker);
exporters.add(exporter);

该处首先,利用proxyFactory.getInvoker获取执行器,即经过包装的、被调用的服务的实现类以及调用的url等信息的组合,即invoker。然后将invoker封装进DelegateProviderMetaDataInvoker对象,然后提供给exporter

然后注意protocol.export(wrapperInvoker),该处所能调用的协议,是基于protocol内的方法ExtensionLoader,getExtensionLoader(),而具体该调用什么协议进行暴露服务,是基于Java SPI协议进行的。这里由于是Dubbo的服务,因此调用DubboProtocolRegistryProtocol内的export()方法。其中RegistryProtocol内的export()方法首先初始化了服务的信息(如:url、接口名等),会调用DubboProtocol内的export()方法,首先获取到Url地址,然后逐渐的将执行器invoker封装成一个DubboExporter暴露器,然后调用openServer(),初次调用会对其调用创建服务器方法,并绑定url,之后会进入Netty的底层方法,完成建立服务并监听暴露服务的地址即端口。之后,会将服务进行注册ProviderConsumerRegTable.registerProvider(),该处存在两个CurrentHashMap,CurrentHashMap<String,Set<providerInvokers>> providerInvokersCurrentHashMap<String,Set<consumerInvokers>> consumerInvokers,该处向CurrentHashMap<String,Set<providerInvokers>> providerInvokers添加新的服务对象,String为注册的调用地址,Set<>为调用服务的信息。最后调用注册器registry(),将服务暴露的地址,注册到注册中心,完成服务暴露。

简要来说就是:首先是ServiceBean的解析,然后ApplicationListener<ContextRefreshedEvent>出发一个事件,然后通过建立服务监听,并在注册中心注册,完成服务的暴露。

③、Dubbo的引用过程

在Dubbo进行引用时,会用到@Reference注解。实际上与@Autowired一样,引用也意味着需要从容器内部获取到服务的Bean。Reference进行引用时,会建立一个ReferenceBean(这是一个FactoryBean),因此最后会调用FactoryBeangetObject(),返回标签配置注入的对象。在getObject()的返回:get()中,会对第一次调用进行一个init()(初始化)。该初始化会操作一个creatProxy(),创建一个代理对象,带代理器会保存一些信息,如:调用地址、调用属性、方法等。

在creatProxy方法内部,通过调用refProtocol.refer(interface,urls.get(0)),该refProtocol依旧是基于Java SPI协议进行的。这里由于是Dubbo的服务,因此调用DubboProtocolRegistryProtocol内的refer()方法。

  • RegistryProtocol内的refer()方法内,调用doRefer(),传入调用所需的信息,其方法内部dictionary.subscribe(),去注册中心订阅服务,获取引用信息。
  • 在订阅中,会进入DubboProtocol内的refer()方法,在执行refer()过程中会构建DubboInvoker对象时传入一个getClient()来获取客户端(消费端),getClient()内会初始化客户端,并构建连接,此时进入了Netty的底层,利用Netty构建出客户端,最后返回DubboInvoker对象。之后,将DubboInvoker对象通过ProviderConsumerRegTable.registerProvider()CurrentHashMap<String,Set<consumerInvokers>> consumerInvokers内注册消费对象,最后返回ref = creatProxy()这个代理对象。

④、Dubbo的调用过程

Dubbo调用链.jpg

在获取到Proxy对象后,Dubbo会代理执行。首先在InvokerInvocationHandler中执行invoker.invoke,其内部依旧是层层代理以及Filter会获取到可用的执行器列表,即invoker.invoke执行时会先去注册中心列表(AbstractClusterInvokerList<Invoker<T>>)内获取到所有代理执行器,并利用Java SPI协议获取Dubbo协议。之后加载负载均衡配置load.ExtensionLoader.get.ExtensionLoader(LoadBalance.class),然后利用RpcUtils构建异步请求,并执行doInvokedoInvoke在逐层的代理中,调动负载均衡,然后匹配到一个服务器上的invoker。在该处,其实是图中Proxy→Filter→Invoker→LoadBalance的过程,在Filter中可以根据不同配置的属性进行分滤,加载各类配置,然后才开始进入负载均衡。

在加载完负载均衡,会进入下一层Filter实现统计、监听、备份等功能和,最终会在DubboInvoker中执行currentClient = clients[0]获取到调用的客户端。然后currentClient.request向客户端发起请求并获取请求结果并封装成Result对象。该处请求过程会先进行序列号,返回反序列化解码获得请求结果。最后根据请求为异步还是同步,分别调用asyncCallBack或者syncCallBack返回代理请求的结果,完成调用。此处对应图内的Remoting。

2、Dubbo高级特性

I、负载均衡

在集群负载均衡时,Dubbo 提供了多种均衡策略,默认为 random 随机调用。
其主要由以下几种负载均衡策略

  • Random LoadBalance
    随记调用,按权重设置的权重进行随机概率。
    在一个截面上碰撞的概率高,随着调用量的增加,调用某台机器的频率就越趋于分配的权重比率。将权重置换为概率后,有利于动态调整提供者权重。
  • RoundRobin LoadBalance
    请求时对提供服务的列表进行轮循,按RoundRobin哈希取模后的权重设置轮循比率。
    存在慢的提供者累积请求的问题,比如:某机器没挂但是执行异常卡顿,当请求调到这台机器时就会阻塞在此,长时间调用后的结果就是所有请求都卡在调到这台机器上。
  • LeastActive LoadBalance
    最少活跃调用数,相同活跃数的随机,活跃数指调用前后计数差。
    使慢的提供者收到更少请求,因为越慢的提供者的调用前后计数差会越大。
  • ConsistentHash LoadBalance
    一致性 Hash,相同参数的请求总是发到同一提供者。
    当某一台提供者挂时,原本发往该提供者的请求,基于虚拟节点,平摊到其它提供者,不会引起剧烈变动。
    默认只对第一个参数 Hash,如果要修改,请配置 <dubbo:parameter key="hash.arguments" value="0,1" />
    默认用 160 份虚拟节点,如果要修改,请配置 <dubbo:parameter key="hash.nodes" value="320" />

II、服务降级

①、当服务器压力剧增的情况下,根据实际业务情况及流量,对一些服务和页面有策略的不处理或换种简单的方式处理,从而释放服务器资源以保证核心交易正常运作或高效运作。

可以通过服务降级功能临时屏蔽某个出错的非关键服务,并定义降级后的返回策略。
其中,主要由两种配置:

  • mock=force:return+null 表示消费方对该服务的方法调用都直接返回 null 值,不发起远程调用。用来屏蔽不重要服务不可用时对调用方的影响。
  • mock=fail:return+null 表示消费方对该服务的方法调用在失败后,再返回 null 值,不抛异常。用来容忍不重要服务不稳定时对调用方的影响。

②、还可以进行集群容错

主要有以下配置,默认为 failover 重试。

  • Failover Cluster
    失败自动切换,当出现失败,重试其它服务器。通常用于读操作,但重试会带来更长延迟。可通过 retries="n" 来设置重试n次(不含第一次)。
    重试次数配置如下:<dubbo:service retries="n" />或<dubbo:reference retries="n" />或<dubbo:reference><dubbo:method name="findFoo" retries="n" /></dubbo:reference>
  • Failfast Cluster
    快速失败,只发起一次调用,失败立即报错。可用于非幂等性的操作。
  • Failsafe Cluster
    失败安全:出现异常时,直接忽略。可用于日志处理中。
  • Failback Cluster
    失败自动恢复:根据后台记录失败请求,定时重发。例如:消息通知操作。
  • Forking Cluster
    并行联调:并行调用多个服务器,只要一个成功即返回。用于实时性要求较高的读操作,但需要浪费更多服务资源。可通过 forks="n" 来设置最大并行数。
  • Broadcast Cluster
    广播调配:广播调用所有提供者,逐个调用,任意一台报错则报错 。可在通知所有提供者更新缓存或日志等本地资源信息中配置。

集群模式配置
在服务提供方:<dubbo:service cluster="failsafe" />或消费方配置集群模式:<dubbo:reference cluster="failsafe" />

\color{violet}{推荐:与Hystrix整合}

III、服务熔断

\color{violet}{推荐:与Hystrix整合}

IV、注册中心宕机与Dubbo的直连

zookeeper注册中心宕机,还可以消费dubbo暴露的服务。
该处设计,保证了服务调用的健壮性,其保证了高可用,减少系统不能提供服务的时间。而其能保持可用的原因在于:

  • 监控中心宕掉不影响使用,只是丢失部分采样数据
  • 数据库宕掉后,注册中心仍能通过缓存提供服务列表查询,但不能注册新服务
  • 注册中心对等集群,任意一台宕掉后,将自动切换到另一台
  • 服务提供者无状态,任意一台宕掉后,不影响使用
  • 服务提供者全部宕掉后,服务消费者应用将无法使用,并无限次重连等待服务提供者恢复

V、超时与重试

设置timeout属性来配置最大超时时间,超时即返回调用失败。配置retries标签来进行重试配置,失败调用时自动重试。当进行重试时,会对其它服务器进行重试,但重试会带来更长延迟。可通过 retries="n" 来设置重试次数(不含第一次)。

VI、本地缓存

结果缓存,用于加速热门数据的访问速度,Dubbo 提供声明式缓存,以减少用户加缓存的工作量。

缓存类型

  • lru 基于最近最少使用原则删除多余缓存,保持最热的数据被缓存。
  • threadlocal 当前线程缓存,比如一个页面渲染,用到很多 portal,每个 portal 都要去查用户信息,通过线程缓存,可以减少这种多余访问。
  • jcacheJSR107 集成,可以桥接各种缓存实现。

<dubbo:reference interface="com.foo.BarService" cache="lru" />

VII、配置覆盖与引用优先级

参考两张图片以及官方文档

配置覆盖规则:

  • JVM 启动 -D 参数优先。
  • XML 次之。
  • Properties 最后,相当于默认值,只有 XML 没有配置时,dubbo.properties 的相应配置项才会生效。
Dubbo配置属性覆盖.jpg

配置的引用规则:

  • 方法级配置别优于接口级别,小Scope优先
  • Consumer端配置 优于 Provider配置 优于 全局配置
  • 最后是Dubbo Hard Code的配置值
Dubbo配置引用优先级.jpg

VIII、本地存根

Dubbo本地存根.jpg

远程服务后,客户端通常只剩下接口,而实现全在服务器端,但提供方有些时候想在客户端也执行部分逻辑,比如:做 ThreadLocal 缓存,提前验证参数,调用失败后伪造容错数据等等,此时就需要在 API 中带上 Stub,客户端生成 Proxy 实例,会把 Proxy 通过构造函数传给 Stub 1,然后把 Stub 暴露给用户,Stub 可以决定要不要去调 Proxy。
<dubbo:service interface="com.foo.BarService" stub="true" />或<dubbo:service interface="com.foo.BarService" stub="com.foo.BarServiceStub" />

IX、多版本

利用version标签配置新老版本的调用,先配置一个新版本的服务,让一部分请求落在新版的服务器上,逐渐的将老版本的请求转接给新版本的服务器,实现服务的无停机式升级。

结束语

Dubbo的介绍到此结束,但是这仅仅是分布式技术一个一个开端,我们离真实的分布式还很远,Dubbo为我们完成远程服务的调用封装,利用成熟的RPC技术,方便我们将部署在不同服务器上的应进行调取。Dubbo也仅仅能为我们做到这些,因此他也足以被称之为一个轻量级RPC通信框架。但是作为分布式应用,仅仅完成通信和一些简单的配置是完全不够的,还有很多事情没有做完,如何更好的完成一致性?如何更好的维护可用性?在CAP原则上,我们要么追求CP,要么会追求AP,根据项目的不同需求,定制不同的组件拼装结构(即应用层面的架构)。接下来,我们将介绍一套基于微服务时代的产物,它提供了一套完整的微服务治理方案,即SpringCloud,而由于SpringCloud为维护问题且为了能更好的与Dubbo结合,我们将采取SpringCloudAlibaba进行介绍,逐渐了解,如何构建微服务以及如何进行治理。

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

推荐阅读更多精彩内容