四、负载均衡Ribbon

上一章讲述了服务注册和发现组件Eureka,同时追踪源码深入讲解了Eureka的机制,并通过案例讲解了如何构建高可用的EurekaServer。本章讲解如何使用 RestTemplate和Ribbon相结合作为服务消费者去消费服务,同时从源码的角度来深入讲解 Ribbon。

一、RestTemplate简介

  RestTemplate是Spring Resources中一个访问第三方RESTful API接口的网络请求框架。RestTemplate的设计原则和其他SpringTemplate (例如 JdbcTemplate、 JmsTemplate)类似,都是为执行复杂任务提供了一个具有默认行为的简单方法。
  RestTemplate是用来消费REST服务的,所以RestTemplate 的主要方法都与REST的Http协议的一些方法紧密相连,例如HEAD、GET、POST、PUT、DELETE和OPTIONS等方法, 这些方法在RestTemplate类对应的方法为headForHeaders()、getForObject()、postForObject()、put()和delete()等。

二、Ribbon简介

  负载均衡是指将负载分摊到多个执行单元上,常见的负载均衡有两种方式。一种是独立进程单元,通过负载均衡策略,将请求转发到不同的执行单元上,例如Ngnix。另一种是将负载均衡逻辑以代码的形式封装到服务消费者的客户端上,服务消费者客户端维护了一份服务提供者的信息列表,有了信息列表,通过负载均衡策略将请求分摊给多个服务提供者,从而达到负载均衡的目的。
  Ribbon是Netflix公司开源的一个负载均衡的组件,它属于上述的第二种方式,是将负载均衡逻辑封装在客户端中,并且运行在客户端的进程里。Ribbon是一个经过了云端测试的IPC库,可以很好地控制HTTP和TCP 客户端的负载均衡行为。
  在SpringCloud构建的微服务系统中, Ribbon作为服务消费者的负载均衡器,有两种使用方式,一种是和RestTemplate相结合,另一种是和Feign相结合。Feign已经默认集成了Ribbon, 关于Feign的内容将会在下一章进行详细讲解。
  Ribbon有很多子模块,但很多模块没有用于生产环境 ,目前Netflix公司用于生产环境的Ribbon子模块如下:

  • ribbon-loadbalancer: 可以独立使用或与其他模块一起使用的负载均衡器API。
  • ribbon-eureka: Ribbon结合Eureka客户端的API,为负载均衡器提供动态服务注册列表信息。
  • ribbon-core: Ribbon的核心API

三、使用RestTemplate和Ribbo和Ribbon来消费服务

  本案例是上一节案例的基础上进行改造的,先回顾一下上一节中的代码结构,它包括一个服务注册中心eureka-server、一个服务提供者eureka-client。eureka-client向eureka-server注册服务,并且eureka-client提供了一个“ /hi"API接口,用于提供服务。
  启动eureka-server, 端口为8671。启动两个eureka-client实例,端口分别为 8762和8763。 启动完成后,在浏览器上访问 http://localhost:8671/,浏览器显示 eureka-client 的两个实例已经成功向服务注册中心注册,它们的端口分别为8672和8673,如下所示。

  创建完成 eureka-ribbon-client 的 Module 工程之后 , 在其pom文件中引入相关的依赖,包括继承了主Maven工程的pom文件,引入了EurekaClient的起步依赖spring-cloud-starter­ eureka、Ribbon的起步依赖spring-cloud-starter-ribbon,以及Web的起步依赖spring-boot-starter-web, 代码如下:

<parent>
        <groupId>com.hand</groupId>
        <artifactId>macro-service</artifactId>
        <version>1.0-SNAPSHOT</version>
        <relativePath/>
</parent>
<dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-eureka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-ribbon</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

macro-service为主模块的配置,内容如下:

<groupId>com.hand</groupId>
   <artifactId>macro-service</artifactId>
   <version>1.0-SNAPSHOT</version>
   <packaging>pom</packaging>
  <parent>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-parent</artifactId>
       <version>1.5.10.RELEASE</version>
       <relativePath/>
   </parent>
   <properties>
       <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
       <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
       <spring.cloud.vension>Dalston.SR1</spring.cloud.vension>
       <java.version>1.8</java.version>
   </properties>
   <dependencies>
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-test</artifactId>
           <scope>test</scope>
       </dependency>
   </dependencies>
   <dependencyManagement>
       <dependencies>
           <dependency>
               <groupId>org.springframework.cloud</groupId>
               <artifactId>spring-cloud-dependencies</artifactId>
               <version>${spring.cloud.vension}</version>
               <type>pom</type>
               <scope>import</scope>
           </dependency>
       </dependencies>
   </dependencyManagement>

  在工程的配置文件appIication.yml做程序的相关配置,包括指定程序名为 eureka-ribbon­-client,程序的端口号为8674,服务的注册地址http://localhost:8761/eureka/,代码如下:

spring:
  application:
    name: eureka-ribbon-client
server:
  port: 8674
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8671/eureka/

  另外,作为EurekaClient需要在程序的入口类加上注解@EnableEurekaClient开启EurekaClient功能,代码如下:

@SpringBootApplication
@EnableEurekaClient
public class EurekaRibbonClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaRibbonClientApplication.class, args);
    }
}

  写一个RESTful API接口,在该API接口内部需要调用eureka-client的API接口"/hi”, 即服务消费。由于eureka-client为两个实例,它们的端口为8672和8673。在调用eureka-client的API接口“/hi”时希望做到轮流访问这两个实例,这时就需要将RestTemplate和Ribbon相结合,进行负载均衡。
  首先需要在程序的IoC容器中注入一个 restTemplate的Bean,并在这个Bean上加上@LoadBalanced注解,此时RestTemplate就结合了Ribbon开启了负载均衡功能 ,代码如下:

@Configuration
public class RibbonConfig {
    
    @Bean
    @LoadBalanced
    RestTemplate restTemplate(){
        return new RestTemplate();
    }
}

  写一个RibbonService类,在该类的 hi()方法用 restTemplate 调用eureka-client的API接口,此时Uri上不需要使用硬编码(例如IP地址),只需要写服务名eureka-client 即可,代码如下:

@Service
public class RibbonService {

    @Autowired
    private RestTemplate restTemplate;

    public String hi(String name) {
        return restTemplate.getForObject("http://eureka-client/hi?name=" + name, String.class);
    }
}

写一个RibbonController类,为该类加上@RestController注解,开启RestController的功能, 写一个“/hi” Get方法的接口,调用RibbonService类的hi()方法,代码如下:

@RestController
public class RibbonController {

    @Autowired
    private RibbonService ribbonService;

    @GetMapping(value = "/hi")
    public String hi(@RequestParam String name){
        return ribbonService.hi(name);
    }
}

启动eureka-ribbon-client工程,在浏览器上访问http://localhost:8671,显示的EurekaServer 的主界面如下图所示。在主界面上发现有两个服务被注册,分别为eureka-client和eureka-ribbon-client,其中eureka-client有两个实例,端口为8672和8673,而eureka-ribbon-client的端口为8674


在浏览器上多次访问 http://localhost:8674/hi?name=ben,浏览器会轮流显示如下内容:

hi ben, i am from port:8672
hi ben, i am from port:8673

四、LoadBalancerClient简介

  负载均衡器的核心类为LoadBalancerClient, LoadBalancerCiient可以获取负载均衡的服务提供者的实例信息。为了演示,在RibbonController重新写一个接口“/testRibbon”,通过LoadBalancerCIient去选择一个eureka-client的服务实例的信息,并将该信息返回,继续在eureka-ribbon-client工程上修改,代码如下:

@RestController
public class RibbonController {
    ...//省略代码
    @Autowired
    private LoadBalancerClient loadBalancer;

    @GetMapping ("/testRibbon")
    public String testRibbon() {

        ServiceInstance instance = loadBalancer.choose("eureka-client");
        return instance.getHost() + ":" + instance.getPort();
    }
}

  重新启动工程,在浏览器上多次访问http://localhost:8764/testRibbon,浏览器会轮流显示如下内容 :

localhost:8672
localhost:8673

  可见,LoadBalancerClient 的 choose(”eureka-client'’)方法可以轮流得到 eureka-client 的两个 服务实例的信息。
  负载均衡器LoadBalancerClient是从EurekaClient获取服务注册列表信息的,并将服务注册列表信息缓存了一份。在LoadBalancerCJient 调用choose()方法时,根据负载均衡策略选择一个服务实例的信息,从而进行了负载均衡。 LoadBalancerClient也可以不从EurekaClient获取注册列表信息, 这时需要自己维护一份服务注册列表信息。需要修改application.xml的配置信息,通过stores.ribbon.listOfServers来配置这些服务实例的Uri。

#禁止Ribbon从Eureka获取注册列表信息
ribbon:
  eureka:
    enabled: false

#手动配置服务列表
stores:
  ribbon:
    listOfServers: example1.com,example2.com
@RestController
public class RibbonController {
    @Autowired
    private LoadBalancerClient loadBalancer;

    @GetMapping ("/testSelfConfigRibbon")
    public String testSelfConfigRibbon() {
        ServiceInstance instance = loadBalancer.choose("stores");
        return instance.getHost() + ":" + instance.getPort();
    }

启动工程 ,在浏览器上多次访问内容:http://localhost:8769/testRibbon,浏览器会交替出现以下内容:

example1.com:80
example2.com:80

  由此,我们知道在Ribbon中的负载均衡客户端为LoadBalancerClient。在SpringCloud项目中,负载均衡器Ribbon会默认从EurekaClient的服务注册列表中获取服务的信息,并缓存一份。根据缓存的服务注册列表信息,可以通过LoadBalancerClient来选择不同的服务实例, 从而实现负载均衡。如果禁止Ribbon从Eureka获取注册列表信息,则需要自己去维护一份服务注册列表信息。根据自己维护服务注册列表的信息,Ribbon也可以实现负载均衡。

五、源码解析Ribbon

  为了深入理解Ribbon,通过查看源码来分析Ribbon如何和RestTemplate相结合来做负载均衡。开启负载均衡的关键在@LoadBalanced这个注解上,首先从这个注解入手。

@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Qualifier
public @interface LoadBalanced {
}

  上面是@LoadBalanced的定义,这就是一个普通的标记注解,作用就是修饰RestTemplate让其拥有负载均衡的能力,全局搜索发现在LoadBalancerAutoConfiguration.java这个类里用到了。

@Configuration
@ConditionalOnClass(RestTemplate.class)
@ConditionalOnBean(LoadBalancerClient.class)
@EnableConfigurationProperties(LoadBalancerRetryProperties.class)
public class LoadBalancerAutoConfiguration {

    @LoadBalanced
    @Autowired(required = false)
    private List<RestTemplate> restTemplates = Collections.emptyList();

    @Bean
    public SmartInitializingSingleton loadBalancedRestTemplateInitializer(
            final List<RestTemplateCustomizer> customizers) {
        return new SmartInitializingSingleton() {
            @Override
            public void afterSingletonsInstantiated() {
                for (RestTemplate restTemplate : LoadBalancerAutoConfiguration.this.restTemplates) {
                    for (RestTemplateCustomizer customizer : customizers) {
                        customizer.customize(restTemplate);
                    }
                }
            }
        };
    }
    //这里的restTemplates是所有的被@LoadBalanced注解的集合,这就是标记注解的作用(Autowired是可以集合注入的)
    @Autowired(required = false)
    private List<LoadBalancerRequestTransformer> transformers = Collections.emptyList();

    @Bean
    @ConditionalOnMissingBean
    public LoadBalancerRequestFactory loadBalancerRequestFactory(
            LoadBalancerClient loadBalancerClient) {
        return new LoadBalancerRequestFactory(loadBalancerClient, transformers);
    }
    //生成一个LoadBalancerInterceptor的Bean
    @Configuration
    @ConditionalOnMissingClass("org.springframework.retry.support.RetryTemplate")
    static class LoadBalancerInterceptorConfig {
        @Bean
        public LoadBalancerInterceptor ribbonInterceptor(
                LoadBalancerClient loadBalancerClient,
                LoadBalancerRequestFactory requestFactory) {
            return new LoadBalancerInterceptor(loadBalancerClient, requestFactory);
        }
        //给注解了@LoadBalanced的RestTemplate加上拦截器
        @Bean
        @ConditionalOnMissingBean
        public RestTemplateCustomizer restTemplateCustomizer(
                final LoadBalancerInterceptor loadBalancerInterceptor) {
            return new RestTemplateCustomizer() {
                @Override
                public void customize(RestTemplate restTemplate) {
                    List<ClientHttpRequestInterceptor> list = new ArrayList<>(
                            restTemplate.getInterceptors());
                    list.add(loadBalancerInterceptor);
                    restTemplate.setInterceptors(list);
                }
            };
        }
    }
        ...//省略后面代码
}

  看到这里,我们应该大致知道@loadBalanced的作用了,就是起到一个标记RestTemplate的作用,当服务启动时,标记了的RestTemplate对象里面就会被自动加入LoadBalancerInterceptor拦截器,这样当RestTemplate像外面发起http请求时,会被LoadBalancerInterceptor的intercept函数拦截,而intercept里面又调用了LoadBalancerClient接口实现类execute方法,我们接着往下看;

public class LoadBalancerInterceptor implements ClientHttpRequestInterceptor {

    ...//省略部分代码
    @Override
    public ClientHttpResponse intercept(final HttpRequest request, final byte[] body,
            final ClientHttpRequestExecution execution) throws IOException {
        //这是以服务名为地址的原始请求:例:http://HI-SERVICE/hi
        final URI originalUri = request.getURI();
        String serviceName = originalUri.getHost();
        Assert.state(serviceName != null, "Request URI does not contain a valid hostname: " + originalUri);
        return this.loadBalancer.execute(serviceName, requestFactory.createRequest(request, body, execution));
    }
}

这里的LoadBalancerClient的实现是RibbonLoadBalancerClient,调用的是RibbonLoadBalancerClient.execute()方法。在execute内首先执行getLoadBalancer(serviceId)获取ILoadBalancer的实现者,然后调用getServer(loadBalancer)方法通过负载均衡策略获取服务。

@Override
public <T> T execute(String serviceId, LoadBalancerRequest<T> request) throws IOException {
    //通过serviceId找到ILoadBalancer的实现者
    ILoadBalancer loadBalancer = getLoadBalancer(serviceId);
    Server server = getServer(loadBalancer);
    if (server == null) {
        throw new IllegalStateException("No instances available for " + serviceId);
    }
    RibbonServer ribbonServer = new RibbonServer(serviceId, server, isSecure(server,
            serviceId), serverIntrospector(serviceId).getMetadata(server));

    return execute(serviceId, ribbonServer, request);
}

继续看getServer(loadBalancer)方法,发现是调用ILoadBalancer实现类对象的chooseServer()方法。

protected Server getServer(ILoadBalancer loadBalancer) {
        if (loadBalancer == null) {
            return null;
        }
        return loadBalancer.chooseServer("default"); // TODO: better handling of key
    }

这里ILoadBalancer接口有三个实现类,通过查看源码发现,BaseLoadBalancer和ZoneAwareLoadBalancer类里都有具体的实现方法,到底调用的是哪个类的方法呢?


查看RibbonClientConfiguration.java类发现如下代码:

@Bean
@ConditionalOnMissingBean
public ILoadBalancer ribbonLoadBalancer(IClientConfig config,
       ServerList<Server> serverList, ServerListFilter<Server> serverListFilter,
           IRule rule, IPing ping) {
       if (this.propertiesFactory.isSet(ILoadBalancer.class, name)) {
           return this.propertiesFactory.get(ILoadBalancer.class, config, name);
       }
       ZoneAwareLoadBalancer<Server> balancer = LoadBalancerBuilder.newBuilder()
                .withClientConfig(config).withRule(rule).withPing(ping)
                .withServerListFilter(serverListFilter).withDynamicServerList(serverList)
                .buildDynamicServerListLoadBalancer();
       return balancer;
}

由此可知,拦截器里默认调用的是ZoneAwareLoadBalancer.chooseServer()方法。

    public Server chooseServer(Object key) {
        //ENABLED默认值为true
        if (!ENABLED.get() || getLoadBalancerStats().getAvailableZones().size() <= 1) {
            logger.debug("Zone aware logic disabled or there is only one zone");
            return super.chooseServer(key);
        }
        Server server = null;
        try {
            LoadBalancerStats lbStats = getLoadBalancerStats();
            Map<String, ZoneSnapshot> zoneSnapshot = ZoneAvoidanceRule.createSnapshot(lbStats);
            logger.debug("Zone snapshots: {}", zoneSnapshot);
            if (triggeringLoad == null) {
                triggeringLoad = DynamicPropertyFactory.getInstance().getDoubleProperty(
                        "ZoneAwareNIWSDiscoveryLoadBalancer." + this.getName() + ".triggeringLoadPerServerThreshold", 0.2d);
            }

            if (triggeringBlackoutPercentage == null) {
                triggeringBlackoutPercentage = DynamicPropertyFactory.getInstance().getDoubleProperty(
                        "ZoneAwareNIWSDiscoveryLoadBalancer." + this.getName() + ".avoidZoneWithBlackoutPercetage", 0.99999d);
            }
            Set<String> availableZones = ZoneAvoidanceRule.getAvailableZones(zoneSnapshot, triggeringLoad.get(), triggeringBlackoutPercentage.get());
            logger.debug("Available zones: {}", availableZones);
            if (availableZones != null &&  availableZones.size() < zoneSnapshot.keySet().size()) {
                String zone = ZoneAvoidanceRule.randomChooseZone(zoneSnapshot, availableZones);
                logger.debug("Zone chosen: {}", zone);
                if (zone != null) {
                    BaseLoadBalancer zoneLoadBalancer = getLoadBalancer(zone);
                    server = zoneLoadBalancer.chooseServer(key);
                }
            }
        } catch (Exception e) {
            logger.error("Error choosing server using zone aware logic for load balancer={}", name, e);
        }
        if (server != null) {
            return server;
        } else {
            logger.debug("Zone avoidance logic is not invoked.");
            return super.chooseServer(key);
        }
    }

但是由于我是模拟单服务器测试的,所以是单区域,通过调试可以看到空间数为1,如下图。所以这里会去调用ZoneAwareLoadBalancer父类的chooseServer()方法,也就是BaseLoadBalancer的chooseServer()方法。


//BaseLoadBalancer.chooseServer()
public Server chooseServer(Object key) {
        if (counter == null) {
            counter = createCounter();
        }
        counter.increment();
        if (rule == null) {
            return null;
        } else {
            try {
                return rule.choose(key);
            } catch (Exception e) {
                logger.warn("LoadBalancer [{}]:  Error choosing server for key {}", name, key, e);
                return null;
            }
        }
    }

接下来就是调用rule.choose(key);这里是选择负载均衡策略。IRule的实现类如下:


IRule的默认实现类有以下7种。在大多数情况下,这些默认的实现类是可以满足需求的,如果有特殊的谛求,可以自己实现。

  • BestAvailableRule: 选择最小请求数。
  • ClientConfigEnabledRoundRobinRule:轮询。
  • RandornRule: 随机选择一个server。
  • RoundRobinRule: 轮询选择server。
  • RetryRule: 根据轮询的方式重试。
  • ZoneAvoidanceRule:根据server的zone区域和可用性来轮询选择。
  • WeightedResponseTirneRule: 根据响应时间去分配一个weight,weight越低,被选择的可能性就越低。

  综上所述,Ribbon的负载均衡,主要通过LoadBalancerClient来实现的,而LoadBalancerClient具体交给了ILoadBalancer来处理,ILoadBalancer通过配置IRule等信息,并向EurekaClient获取注册列表的信息,得到注册列表后,ILoadBalancer根据IRule的策略进行负载均衡。
  RestTemplate 被@LoadBalance注解后,能使用负载均衡,主要是维护了一个被@LoadBalance注解的RestTemplate列表,并给列表中的RestTemplate添加拦截器,进而交给负载均衡器去处理。

总结:本章节学习了Ribbon负载均衡搭配RestTemplate实现的方式,通过学习Ribbon的源码,深入了解了Ribbon的实现原理和方式,使自己受益匪浅。下一章学习声明式调用Feign的有关内容。

源代码:https://github.com/Cheerman/macro-service.git

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

推荐阅读更多精彩内容