Spring Cloud

当我们使用一项新的技术时, 我们通常应该先思考一个问题:
为什么要使用这项技术, 或者说这项技术能解决什么问题, 这项技术的应用场景是什么?
带着这个问题, 我们进入今天的主题: Spring Cloud Eco System

Spring Cloud组件一览

Spring官方网站
Spring Cloud中文站

讲什么

  1. Spring Cloud整体架构
  2. 我们应用到或者正在调研的几个组件及组件间的串联
  3. 顺带提一下OAuth2.0
  4. 问题排查方法

不讲什么

  1. 源码分析
  2. 细致的原理

微服务

看个漫画

image.png

image.png

image.png

image.png

image.png

image.png

image.png

上面漫画中的系统是一个经典的MVC架构的服务, 所有模块集中在一个项目里, 共用一个JVM, 缺点很明显:

缺点一: 项目过于臃肿

所有代码写在一个项目里, 就算修改一个很小功能也要重新部署整个系统. 想想我们最初的dida-booking

缺点二: 资源无法隔离

就像刚刚小灰的经历一样,整个单体系统的各个功能模块都依赖于同样的数据库、内存等资源,一旦某个功能模块对资源使用不当,整个系统都会被拖垮。

缺点三: 无法灵活修改 / 扩展

修改的问题在缺点一里已经有所提现. 那么扩展呢?

功能扩展

必须编译, 部署整个系统, 出错几率大, 系统宕机风险大

服务能力扩展(水平扩展)

如果仅仅是某个模块能力不足, 无法对其进行单独扩展, 必须把整个系统多增加服务节点, 造车服务器性能浪费

什么是微服务

In short, the microservice architectural style is an approach to developing a single application as a suite of small services, each running in its own process and communicating with lightweight mechanisms, often an HTTP resource API. These services are built around business capabilities and independently deployable by fully automated deployment machinery. There is a bare minimum of centralized management of these services, which may be written in different programming languages and use different data storage technologies.

简而言之,微服务架构风格是一种将单个应用程序作为一套小型服务开发的方法,每种应用程序都在自己的进程中运行,并与轻量级机制(通常是HTTP资源API)进行通信。 这些服务是围绕业务功能构建的,可以通过全自动部署机制独立部署。 这些服务的集中管理最少,可以用不同的编程语言编写,并使用不同的数据存储技术。
总结一下微服务的几个特点:

  1. 独立部署, 灵活扩展
  2. 功能单一
  3. 资源的有效隔离

微服务带来的便利

  1. 灵活, 更轻更快的服务架构


    image.png

    左边是传统单体架构集群, 右边是微服务集群.
    传统单体架构中, 服务修改后必须重画整副图, 而微服务架构只需要替换掉一小块拼图即可.

  2. 解耦, 开发人员只需要关注与自己的服务质量, 不需要担心影响别人的模块或被别人影响
    对内: 与1类似, 每个微服务的负责人只需要关注自己的拼图, 而不用看清整幅图像
    对外: 职责明确, 拼车问题找家宇, 出租车找恒进, 有问题想甩锅?


    image.png
  3. 更轻更快, 只使用需要的资源以及轻便的代码集成和系统发布

微服务引入的问题

  1. 服务多且杂, 依赖难治理
    随着业务复杂度的提升, 微服务的数量可能会出现爆炸性增长, 我们目前一共有100多个服务, 管理起来难度
  2. 开发人员分散, 质量难统一
  3. 底层服务影响上层服务, 容易造成雪崩


    image.png

微服务架构的几个关键点

  1. 服务注册 & 发现
  2. 统一入口
  3. 负载均衡
  4. 服务隔离 & 服务降级 & 断路保护

微服务常用开源框架对比

Dubbo Spring Cloud Thrift
服务注册中心 Zookeeper Spring Cloud Netflix Eureka -
服务调用方式 RPC REST API RPC
服务网关 - Spring Cloud Netflix Zuul -
断路器 不完善 Spring Cloud Netflix Hystrix -
分布式配置 - Spring Cloud Config -
服务跟踪 - Spring Cloud Sleuth -
消息总线 - Spring Cloud Bus -
数据流 - Spring Cloud Stream -

组件详解

Service Routing: Zuul

当我们把单体架构改为了微服务架构, 面临的第一个问题: 服务数量暴增, 调用端怎么办?

  1. 传统架构


    image.png

Nginx或者LVS来解决服务单点问题以及负载均衡, 但是调用端依然要维护大量服务器地址, 维护的东西越多, 出错的概率就越大. 想要进一步进行服务拆分?很难

  1. 改善后的架构
    所有微服务提供统一入口, 由统一入口进行请求转发, 即Service Routing
    • 我们的现有架构


      image.png

      对传统架构稍有改善, 增加了PHP层的服务路由功能, 使微服务的复杂度仅维持在服务端内部, 不会扩散到调用端. 但是数次线上事故告诉我们这个架构并不科学.


      image.png

      当某一个服务出现故障, 比如有慢sql, PHP Gateway所有连接被占住, 导致的结果和刚开始的漫画一样:
      image.png

      timg.gif

因此我们要换一个更加健壮, 可用性更强的API Gateway: Zuul


image.png

Zuul带来的改变:

  • 路由: 可配置的服务路由, 服务拆分, 扩展更加方便
  • 服务隔离: 每个服务的调用运行在自己的线程池里, 某一个服务崩了, Zuul依然可用, 不会造成整个系统的雪崩
  • 服务降级: 当服务不可用时, 返回预先设置的一些数据, 可以是静态数据或者缓存中的数据
  • 熔断器: 当一个服务长时间不可用时, 可能是当前服务器压力过大响应不过来, Zuul会将其进行断路保护, 调用不再放行, 直接返回Fallback的内容, 并尝试放行少量请求来探测故障服务是否恢复, 如果恢复整条链路即恢复正常.
  1. Zuul的重要组件: Fliter
    移步这里

Service Registry & Discovery: Eureka

几大常用注册中心

Feature Consul zookeeper etcd euerka
服务健康检查 服务状态,内存,硬盘等 (弱)长连接,keepalive 连接心跳 可配支持
多数据中心 支持
kv存储服务 支持 支持 支持
一致性 raft paxos raft
CAP CP CP CP AP
使用接口(多语言能力) 支持http和dns 客户端 http/grpc http(sidecar)
watch支持 全量/支持long polling 支持 支持 long polling 支持 long polling/大部分增量
自身监控 metrics metrics metrics
安全 acl /https acl https支持(弱)
spring cloud集成 已支持 已支持 已支持 已支持

分布式系统的CAP原则

  • Consistent 一致性

Every read receives the most recent write or an error

  • Availability 可用性

Every request receives a (non-error) response – without guarantee that it contains the most recent write

  • Partition-tolerance(Network Partition) 分区容错性

The system continues to operate despite an arbitrary number of messages being dropped (or delayed) by the network between nodes

  • CAP定律: C.A.P只能同时满足两个
    对于服务发现场景来说, 同一个服务,即使注册中心的不同节点保存的服务提供者信息不尽相同,也并不会造成灾难性的后果。因为对于服务消费者来说,能消费才是最重要的——拿到可能不正确的服务实例信息后尝试消费一下,也好过因为无法获取实例信息而不去消费。所以,对于服务发现而言,可用性比数据一致性更加重要——AP胜过CP。这就是Eureka的优势所在。

Eureka使用介绍

Eureka这个词来源于古希腊语,意为“我找到了!我发现了!”,据传,阿基米德在洗澡时发现浮力原理,高兴得来不及穿上裤子,跑到街上大喊:“Eureka(我找到了)!"。

在Eureka的体系中, 有两种角色: Eureka Server和Eureka Client, 而Eureka Client又分成Service Provider和Service Consumer, 如图:


image.png
  • Eureka Server: 服务注册中心, 维护服务列表
  • Eureka Client
    • Service Provider: 服务提供方,作为一个Eureka Client,向Eureka Server做服务注册、续约和下线等操作,注册的主要数据包括服务名、机器ip、端口号、域名等等。
    • Service Consumer: 服务消费方,作为一个Eureka Client,向Eureka Server获取Service Provider的注册信息,并通过远程调用与Service Provider进行通信。
      在复杂的微服务体系架构中, 通常一个服务既是Provider, 同时也是Consumer
Eureka Server

Eureka是一个独立的服务, 通过RESTful API提供服务注册, 查询以及一些管理能力. 同时Eureka Server也为我们提供了一个WEB页面, 可以直观的看到集群情况, 服务注册情况及健康状况


image.png
  • Eureka Server Cluster
    Eureka Server可以通过部署多个节点来组成一个集群, 从而保证HA, 解决单点问题. 但它不同于其他的注册中心, 没有Master和Slave的概念, 所有的节点是Peer to Peer的, 即集群中所有节点是对等的. 多个节点组成集群需要配置serviceUrl来指向其他节点, 以便节点间可以同步注册信息. 但是需要注意, Eureka Server的注册信息同步是不保证强一致性的.
  • Peer to Peer Communication

Once the server starts receiving traffic, all of the operations that is performed on the server is replicated to all of the peer nodes that the server knows about. If an operation fails for some reason, the information is reconciled on the next heartbeat that also gets replicated between servers.

When the Eureka server comes up, it tries to get all of the instance registry information from a neighboring node. If there is a problem getting the information from a node, the server tries all of the peers before it gives up.
以上是官方对节点间通信的解释. 可以看到节点间数据同步发生在两个阶段:

  1. 注册信息有变化
    当某一个节点的注册信息发生变化, 比如有服务上线或下线, 那么该节点会发送Replication请求到其他节点
  2. 节点启动
    Eureka Server节点启动时会自动从别的节点拉取全部注册信息, 但是并不是拉所有节点, 先拉到谁就是谁

这两点就可以解释为什么Eureka不是强一致性的了. 需要额外说明的一点是, Eureka不会进行二次Replication, 即Replication请求不具备传递性, 这会对我们的集群配置有影响. 假如有三台Eureka Server: A, B, C, 组成集群的方式为A->B, B->C, C->A, 那么当A收到一个新的注册请求, 会同步给B, 然而B并不会同步给C. 具体的源码分析参见:Eureka Server不进行二次Replication的原因, 因此我们的集群配置不是这种环状, 而是互相依赖所有节点

eureka:
  client:
    serviceUrl:
      defaultZone: http://xiangyun:8761/eureka/,http://xifeng:8761/eureka/
  • self-preservation mode
    另一个对我们影响比较大的特性是self-preservation mode: 自我保护模式.


    image.png
    • 什么时候进入自我保护模式
      Eureka Client会定时向Server发送renew请求(默认30s一次), 当一分钟内收到的renew请求少于期望的renew总数(默认注册的实例数2)阈值(默认85%)就会进入自我保护模式.

    • 什么是自我保护模式
      自我保护模式是Eureka为了提高分区容错性而采取的一种手段, 当网络情况不佳服务与注册中心间的心跳不正常, 有些注册中心会认为这些服务已经不可用了, 直接将其剔除. 再有watch机制支持的注册中心里这样问题不大, 当网络恢复正常, watcher可以重新把服务注册回来. 但Eureka Server并不支持watch机制, 它会认为这是网络原因造成的服务暂时表现的不健康, 但是Consumer可能是可以正常消费的. 所以不会把这些服务剔除, 这就是自我保护模式.

    • 进入自我保护模式会发生什么

      1. Eureka Server不再从注册列表中移除因为长时间没收到心跳而应该过期的服务。
      2. Eureka Server仍然能够接受新服务的注册和查询请求,但是不会被同步到其它节点上,保证当前节点依然可用。
      3. 当网络稳定时,当前Eureka Server新的注册信息会被同步到其它节点中。

保护模式会给我们带来一些干扰, 比如Windows机器上启动了一个服务, 注册到Eureka, 当停止的时候Windows是直接杀进程的, 所以不会发生Deregistry, 这种残留的服务就会导致Eureka进入自我保护模式, 导致服务一会通一会不通. 我们可以通过配置

eureka.server.enable-self-preservation = false

来禁用自我保护模式, 不过官方不建议这么做.
我们目前的配置:

# server端
eureka:
  server:
    eviction-interval-timer-in-ms: 3000 # 剔除无心跳服务的时间间隔
  instance:
    lease-expiration-duration-in-seconds: 90    # 续约到期时间, 即下次心跳间隔(默认90秒)
# client端
eureka:
  instance:
    lease-renewal-interval-in-seconds: 30     # 续约更新时间间隔(默认30秒)
Eureka Client
  • Service Provider
    作为服务的提供者, 需要知道注册中心的集群地址, 并把自己的IP/Hostname:port:serviceName注册到Eureka Server, 在代码中只需要增加@EnableDiscoveryClient就会拥有注册能力, 通过配置eureka.client.register-with-eureka=true/false决定要不要注册
  • Service Consumer
    单独作为消费者的情况只需要从Eureka Server获取提供者的注册信息进行调用即可, 这里就涉及到了LB, HA的问题, 下文详解.

LB: Ribbon

Ribbon的作用: Spring Cloud的负载均衡器
  1. 优先选择在同一个Zone且负载较少的Eureka Server;
  2. 定期从Eureka更新并过滤服务实例列表;
  3. 根据用户指定的策略,在从Server取到的服务注册列表中选择一个实例的地址;
  4. 通过RestClient进行服务调用。
故障处理

定期从Eureka更新并过滤服务实例列表, 当Eureka不可用了怎么办?
Ribbon会把Eureka取过来的ServerList进行缓存, 在一定间隔后再去刷新, 所以当Eureka宕机, Ribbon依然可以调用到Server Provider, 但是我们知道, 有缓存就会有latency, 所以有时候我们部署了一个服务, 过了好久才能被调用到. 默认配置的lantency太大(30s), 所以一个服务从部署到被调用会有最大1分钟的延时
调整:

ribbon:
  ServerListRefreshInterval: 5000

Curcuit Breaker & Fallback: Hystrix

  1. 服务隔离(策略, 服务能力)
  2. 断路
  3. 配置

移步这里

Service Deps Http Client: Feign

  1. 使用
    看列子
  2. 配置
    FeignClient默认是不会启用Hystrix的, 需要配置
    feign:
      hystrix:
        enabled: true
    

Config Server

Bus

Turbin

其他

OAuth2.0

三种角色
  • Client
    客户端, 可以是第三方的调用, 也可以是我们的App, 浏览器
  • Resource Owner
    资源的所有者, 一般是用户
  • Resource Server
    资源服务器, 即微服务们
  • Authorization Server
    授权服务器
四种授权模式
  • password
    密码模式, 移步这里
  • client credentials
    客户端模式, 不同于password模式的地方在于, 它使用的是client_id和client_secret来换取access_token, 而不是username+password, 这种模式一般适用于一些第三方的服务调用授权, 针对一个组织机构提供一对client_id和client_secret, 相对password的access_token来说, client模式的token泄漏后影响更大, 所以一般会有一个更小的过期时间.
  • implicit
    简化模式,
  • authorization code
    授权码模式, 参考微信登录


    image.png

JWT

为什么会有JWT
  • 传统认证方式:
    基于Session, 所有的用户信息都要存放在session里, 当用户量巨大? 而且在微服务架构下, 传统session显然不好使.
  • 简单Token认证
    我们的现行方案, 每个用户生成token, 存储token, 请求带着token
    缺陷: 要存储
  • OAuth2
    太重了

有了以上对比, 就搞出了JWT这么个东西

什么是JWT

定义: Json Web Token, 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。简单说就是一种认证方式
简单说, JWT是基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。
划重点: 无状态, 不需要服务端保留

JWT长什么样

JWT是由三段信息构成的,将这三段信息文本用.链接一起就构成了Jwt字符串。就像这样:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
JWT构成
  • 头部: header
    头部由两部分组成

    1. 声明类型,jwt
    2. 声明加密的算法,一定是对称加密, 因为要解密, 我们用AES

    完整的头部就像下面这样的JSON:

    {
      'typ': 'JWT',
      'alg': 'AES128'
    }
    

    然后对称加密

  • 载荷: payload
    载荷就是存放有效信息的地方。可以存放一下非敏感的数据, 包含三部分

    1. 标准的声明
    2. 公共声明
    3. 私有声明

    这些信息我们不太关心, 也不是必须设置的, 不赘述, 我们只放私有声明, 即自定义数据, 比如:

    {
        "cid": "bb705986-b47c-454a-986e-e4556ef1e937",
        "clientId": "minipg",
        "gt": 1,
        "ts": 1516778588270
    }
    

    然后对称加密

  • 签名: signature
    签名包含三部分数据

    1. header(加密后的)
    2. payload(加密后的)
    3. secret

    header payload拼起来, 配合header中声明的加密方式和secret加密, 组成最后的token

总结
  • JWT就是一种轻量的Token鉴权机制, 服务器不需要存储token, 只需要生成token并把它送到客户端绕一圈, 以后请求都带上, 服务端接收到的token能解密, 解密后payload里的数据符合预期就是一个合法的token.
  • 然鹅这样就有一个问题: token无法主动过期, 只有等到到了失效时间才能过期, 所以有主动过期的需要时, JWT是肯定不适用的
  • 我们在OAuth2的Client模式中使用了JWT, 因为一般认为第三方是可信的, token不会泄露, 而且token过期时间不长

问题排查方法

接口返回400: Badrequest

基本可以判断为接口定义有问题, 有参数没传时会返回500: Internal Server Error, 在Gateway对其进行了包装, 返回400, 修改接口定义
gateway-rs-service中会有如下WARN日志:

 WARN  c.d.r.g.filter.MetricsPostFilter - Request /passengertaxi/queryTaxiOrderStatus failed! origin:500 gateway decorate:400

接口返回401: Unauthorized

鉴权不通过, 大部分情况是Token没传或者被别人登录顶掉, 查看日志:
在authorization-rs-service中

WARN  VerifyStrategies - Token mismatch! token:UYaedI6VEFmj25dIleVlSXSqDh4itmUyvf9lTQFvqUfjiRLeRv73iTLDwl5SwsJVWz2sIQCKKcPlROFdz8ra4KI0VOjesq8nk6E/8jk89aTqqy1wSt5XynrY8imufKfovpswP3yLEzrv9P1eI87OCg== expected token:UYaedI6VEFmj25dIleVlSXSqDh4itmUyvf9lTQFvqUfjiRLeRv73iTLDwl5SwsJVWz2sIQCKKcPlROFdz8ra4KI0VOjesq8nk6E/8jk89aQngYGo6Sb74Uh1bzQUoY78uZJ9xbx+VfUvrPdDBs9itw== expire:Tue Jan 10 11:38:34 CST 2023

被人顶掉, Token失效, Token非法都会有如上类似的WARN日志
如果没有调用到authorization-rs-service, 就是token没传: 在gateway-rs-service中

WARN  c.d.rs.gateway.filter.OAuthFilter - Auth param wrong! cid: clientId:null token:

接口返回405: Method Not Allowed

极大可能是客户端调用方式错误, 应该核对Request Method, 自己可以用Postman或者其他工具发一个正确的请求试试.

接口返回502: Badgateway

  • 所有调用全都返回502
    肯定是服务没启动, 去Eureka中查看服务列表有没有自己的服务
  • 时好时坏
    首先想到是不是有本地服务注册到了Eureka, 由于开发机防火墙或者服务器不认识开发机机器名会出现服务不可用, 去Eureka查看服务列表, 如果确认是开发机注册到Eureka, 应该停止本地服务, Windows会强杀进程, 导致Deregistry不会发生, 而这基本上会导致Eureka进入保护模式, 需要自己从Eureka剔除节点, 记住这个命令:
curl -X DELETE "http://xiangyun:8761/eureka/apps/${serviceName}/${instanceId}"
curl -X DELETE "http://xifeng:8761/eureka/apps/${serviceName}/${instanceId}"

比如:

curl -X DELETE "http://xiangyun:8761/eureka/apps/user-rs-service/localhost:user-rs-service:8100"
curl -X DELETE "http://xifeng:8761/eureka/apps/user-rs-service/localhost:user-rs-service:8100"

也有可能是服务性能不好, 调用超时.
以上两种情况都可以查看日志:
gateway-rs-service中会有如下ERROR日志:

ERROR c.d.r.g.controller.VndErrorHandler - Error during routing for /xxx 500
Caused by: java.lang.IllegalStateException: getWriter() has already been called for this response
Caused by: Read Timeout

参考资料

漫画: 什么是微服务
CAP_theorem
几大注册中心比较
Spring Cloud Service Discovery
什么是JWT

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

推荐阅读更多精彩内容