任务调度和异步执行器

1.任务调度概述

各种企业应用都会遇到任务调度的需求,比如每天凌晨统计论坛用户的积分排名等等,在特定的时间做特定的事情。如果将任务调度的范围稍微扩大一点,则还应该包括资源上的调度。如Web Server在接收到请求时,会立即创建一个新的线程服务该请求。但是资源是有限的,无限制的使用必然会耗尽亏空,大多数系统都要对资源使用进行控制。首先必须限制服务线程的最大数目;其次可以考虑使用线程池以便共享服务的线程资源,降低频繁创建、销毁线程的消耗。

任务调度本身设计多线程并发,运行时间规则制定及解析、运行线程保持与恢复、线程池维护等诸多方面的工作。如果直接使用自定义线程这种最原始的办法,则开发任务调度程序是一项颇具挑战性的工作。

2.Quartz快速进阶

Quartz允许开发人员灵活地定义触发器的调度时间表,并可对触发器和任务进行关联映射。此外,Quartz提供了调度运行环境的持久化机制,可以保存并恢复调度现场,即使系统因故障关闭,任务调度现场数据也不会丢失。

2.1.Quartz基础结构

Quartz对任务调度的领域问题进行了高度的抽象,提出了调度器、任务和触发器这3个核心概念,并且在org.quartz中通过接口和类对核心概念进行了描述

  • Job:是一个接口,只有一个方法void execute(JobExecutionContext context),开发者通
    过实现该接口来定义需要执行的任务,JobExecutionContext类提供了调度上下文的各种信息。Job运行时的信息都保存在JobDataMap实例中
  • JobDetail:Quartz在每次执行Job时,都重新创建一个Job实例,所以它不是直接接收一个Job实例,而是接收一个Job实现类,以便运行时通过newInstance()的反射调用机制来实例化Job。因此需要通过一个类来描述Job的实现类及其他相关的静态信息,如Job名称、描述、关联监听器等信息,而JobDetail承担了这一角色
  • Triger:是一个类,描述触发Job执行的时间触发规则。主要有SimpleTrigger和CronTrigger这两个类。当仅需要触发一次后者以固定间隔周期性执行时,SimpleTigger是最适合的选择;而CronTrigger则可以通过Cron表达式定义出各种复杂的调度方法,
  • Calendar:org.quartz.Calendar和java.util.Calendar不同,它是一些日历特定时间点的集合
  • Scheduler:代表一个Quartz的独立运行容器,Trigger和JobDetail可以注册到Scheduler中,二者在Scheduler中拥有各自的组及名称。组及名称是Scheduler查找定位容器中某一对象的依据,Trigger的组及名称的组合必须唯一,JobDetail的组合名称的组合也必须唯一。
  • ThreadPool:Scheduler使用一个线程池作为任务运行的基础设施,任务通过共享线程池中的线程来提高运行效率
2.2.使用SimpleTrigger

SimpleTrigger拥有多个重载的构造函数,用于在不同场合下构造出对应的实例

  • SimpleTrigger(String name,String group):通过该构造函数指定Trigger所属组合名称
  • SimpleTrigger(String name,String group,Date startTime):除指定Trigger所属组和名
    称外,还可以指定触发的开始时间
  • SimpleTrigger(String name,String group,Date startTime,Date endTime,int repeatCount,long repeatInterval):除指定以上信息外,还可以指定结束时间、重复执行次数、时间间隔等参数
  • SimpleTrigger(String name,String group,String jobName,String jobGroup,Date startTime,Date endTime,int repeatCount,long repeatInterval):这是最复杂的一个构造
    函数,在指定触发参数的同时,通过jobGroup和jobName,使该Trigger和Schedule中的某个任务关联起来
public class SimpleJob implements Job {
    public void execute(JobExecutionContext jobCtx) throws JobExecutionException {//实现Job接口方法
        System.out.println(jobCtx.getTrigger().getName()+" triggered. time is:" + (new Date()));
    }
}

以下是通过SimpleTrigger对SimpleJob进行调度

public class SimpleTriggerRunner {
    public static void main(String args[]) {
        try {

            //创建一个JobDetail实例,指定SimpleJob
            JobDetail jobDetail = new JobDetail("job1_1", "jgroup1",
                    SimpleJob.class);
            //通过SimpleTrigger定义调度规则,马上启动,每2秒运行一次,共运行100次
            SimpleTrigger simpleTrigger = new SimpleTrigger("trigger1_1",
                    "tgroup1");
            simpleTrigger.setStartTime(new Date());
            simpleTrigger.setRepeatInterval(2000);
            simpleTrigger.setRepeatCount(100);
            
            //通过SchedulerFactory获取一个调度器实例
            SchedulerFactory schedulerFactory = new StdSchedulerFactory();
            //注册并进行调度
            Scheduler scheduler = schedulerFactory.getScheduler();
            scheduler.scheduleJob(jobDetail, simpleTrigger);
            //调度启动
            scheduler.start();
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

}
2.3.使用CronTrigger

CronTrigger能够提供比SimpleTrigger更具有实际意义的调度方案,调度规则基于Cron表达式。CronTrigger支持日历相关的周期时间间隔(比如每月第一个周一执行),而不是简单的周期时间间隔。一次相对于SimpleTrigger而言,CronTrigger在使用上也要复杂一些。

2.3.1.Cron表达式

Quartz使用类似Linux下的Cron表达式定义时间规则。Cron表达式由6或7个空格分隔的时间间隔字段组成

位置 时间域名 允许值 允许的特殊字符
1 0-59 ,-*/
2 分钟 0-59 ,-*/
3 小时 0-23 ,-*/
4 日期 1-31 ,-?/L W C
5 月份 1-12 ,-*/
6 星期 1-7 ,-*?/L C #
7 年(可选) 空值 1970-2099 ,-*./
  • 星号(*):可用在所有字段中,表示对应时间域的每一个时刻。例如,*在分钟字段时,表示“每分钟”
  • 问号(?):该字符只在日期和星期字段中使用,它通常指定为“无意义的值”,相当于占位符
  • 减号(-):表达一个范围,如在小时字段中使用“10-12”,则表示从10点到12点,即10,11,12
  • 逗号(,):表示一个列表值。如在星期字段中使用“MON,WED,FRI”,则表示星期一、星期三和星期五
  • 斜杠(/):x/y表达一个等步长序列,x为起始值,y为增量步长值。如在分钟字段中使用0/15,则表示为0,15,30和45秒;而5/15在分钟字段中表示5,20,35,50。用户也可以使用*/y,它等同于0/y
  • L:该字符只在日期和星期字段中使用,代表“Last”的意思,但它在两个字段中的意思不同。如果L用在日期字段中,则表示这个月份的最后一天,用在星期中,则表示星期六。但是,如果L出现在星期字段里,而且前面有一个数字N,则表示“这个月的最后N天”。例如,6L表示该月的最后一个星期五
  • W:该字符只能出现在在日期字段里,是对前导日期的修饰,表示离该日期最近的工作日。例如,15W表示离该月15日最近的工作日
  • LW组合:在日期字段中可以组合使用LW,它的意思是当月的最后一个工作日
  • :该字符只能在星期字段中使用,表示当月的某个工作日,如4#5表示当月的第五个星期三。假设当月没有第五个星期三,则忽略不触发

  • C:该字符只在日期和星期字段中使用,代表“Calendar”的意思。它的意思是计划所关联的日期,如果日期没有被关联,则相当于日历中的所有日期。例如,5C在日期字段中相当于5日以后的一天,1C在星期字段中相当于星期日后的第一天
2.3.2.CronTrigger实例

下面使用CronTrigger对SimpleJob进行调度,通过Cron表达式制定调度规则

public class CronTriggerRunner {

    public static void main(String args[]) {
        try {           
            JobDetail jobDetail = new JobDetail("job1_2", "jgroup1",
                    SimpleJob.class);
            CronTrigger cronTrigger = new CronTrigger("trigger1_2", "tgroup1");

            CronExpression cexp = new CronExpression("0/5 * * * * ?");
            cronTrigger.setCronExpression(cexp);
            

            SchedulerFactory schedulerFactory = new StdSchedulerFactory();
            Scheduler scheduler = schedulerFactory.getScheduler();
            scheduler.scheduleJob(jobDetail, cronTrigger);
            scheduler.start();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

2.4.使用Calendar

在实际任务调度中,不可能一成不变地按照某个特定周期调度任务,必须考虑到实现生活中日历上的特殊日期

public class CalendarExample {

    public static void main(String[] args) throws Exception {
        SchedulerFactory sf = new StdSchedulerFactory();
        Scheduler scheduler = sf.getScheduler();

        AnnualCalendar holidays = new AnnualCalendar();
        //五一劳动节
        Calendar laborDay = new GregorianCalendar();
        laborDay.add(Calendar.MONTH,5);
        laborDay.add(Calendar.DATE,1);
        holidays.setDayExcluded(laborDay, true);       
        //国庆节
        Calendar nationalDay = new GregorianCalendar();
        nationalDay.add(Calendar.MONTH,10);
        nationalDay.add(Calendar.DATE,1);
        holidays.setDayExcluded(nationalDay, true);


        scheduler.addCalendar("holidays", holidays, false, false);
        
        //从5月1号10am开始
        Date runDate = TriggerUtils.getDateOf(0,0, 10, 1, 5);
        JobDetail job = new JobDetail("job1", "group1", SimpleJob.class);
        SimpleTrigger trigger = new SimpleTrigger("trigger1", "group1", 
                runDate, 
                null, 
                SimpleTrigger.REPEAT_INDEFINITELY, 
                60L * 60L * 1000L);
        //让Trigger遵守节日的规则(排除节日)
        trigger.setCalendarName("holidays");
        scheduler.scheduleJob(job, trigger);
        scheduler.start();
        try {
            // wait 30 seconds to show jobs
            Thread.sleep(30L * 1000L); 
            // executing...
        } catch (Exception e) {
        }            
        scheduler.shutdown(true);
    }
}
2.5.任务调度信息存储

在默认情况下,Quartz将任务调度的运行信息保存在内存中。这种方法提供了最佳性能,因为在内存中数据访问速度最快;不足之处是缺乏数据的持久性,当程序中途停止或者系统崩溃时,所有运行信息都会丢失。

如果确实需要持久化任务调度信息,则Quzrtz允许用户通过调整其属性文件,将这些信息保存到数据库中。在使用数据库保存了任务调度信息后,即使系统崩溃后重新启动,任务调度信息仍将得到恢复。如前面所说的例子,执行50次系统崩溃后重新运行,计数器将从51开始计数。使用数据库保存信息的任务称为持久化任务。

####### 2.5.1.通过配置文件调整任务调度信息的保存策略

其实Quartz JAR文件的org.quartz包下就包含了一个quartz.properties属性配置文件,并提供了默认的配置。如果需要调整配置,则可以在类路径下建立一个新的quartz.properties属性,它将自动被Quartz加载并覆盖默认的配置

//集群的配置,这里不使用集群
org.quartz.scheduler.instanceName = DefaultQuartzScheduler
org.quartz.scheduler.rmi.export = false
org.quartz.scheduler.rmi.proxy = false
org.quartz.scheduler.wrapJobExecutionInUserTransaction = false

//配置调度器的线程池
org.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount = 10
org.quartz.threadPool.threadPriority = 5
org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread = true

org.quartz.jobStore.misfireThreshold = 60000
//配置任务调度现场保存机制
org.quartz.jobStore.class = org.quartz.simpl.RAMJobStore

Quartz的属性配置文件主要包括三方面的信息:

  • 集群信息
  • 调度器线程池
  • 任务调度现场数据的保存

可以通过下面的设置将任务调度现场的数据保存到数据库中

org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.tablePrefix = QRTZ_ //1、数据库表前缀
org.quartz.jobStore.dataSource = qzDS //2、数据源名称

//3、定义数据源的具体属性
org.quartz.dataSource.qzDS.driver = com.mysql.jdbc.Driver
org.quartz.dataSource.qzDS.URL = jdbc:mysql://localhost:3306/sampledb
org.quartz.dataSource.qzDS.user = root
org.quartz.dataSource.qzDS.password = 123456
org.quartz.dataSource.qzDS.maxConnections = 30

要将任务调度数据保存到数据库中,就必须使用org.quartz.impl.jdbcjobstore.JobStoreTX代替原来的org.quartz.simpl.RAMJobStore,并提供相应的数据库配置信息。首先在1处指定了Quartz数据库表的前缀,然后在2处定义了一个数据源,然后在3处定义这个数据源的连接信息

用户必须事先在相应的数据库中建立Quartz的数据表(共8张),在Quartz的完整发布包的docs/dbTables目录下拥有对应不同数据库的SQL脚本

2.5.2.查询数据库中的运行信息
org.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool
org.quartz.threadPool.threadCount = 10
org.quartz.threadPool.threadPriority = 5
org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread = true


org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX

org.quartz.jobStore.tablePrefix = QRTZ_<!--配置数据库表前缀-->

org.quartz.jobStore.dataSource = qzDS<!--定义数据源名称-->
<!--配置持久化的数据库-->
org.quartz.dataSource.qzDS.driver = com.mysql.jdbc.Driver
org.quartz.dataSource.qzDS.URL = jdbc:mysql://localhost:3306/sampledb
org.quartz.dataSource.qzDS.user = root
org.quartz.dataSource.qzDS.password = 281926
org.quartz.dataSource.qzDS.maxConnections = 30

3.在Spring中使用Quartz

Spring为创建Quartz的Scheduler、Trigger和JobDetail提供了便利的FactoryBean类,以便能够在Spring容器中享受注入的好处。此外,Spring还提供了一些便利工具类,用于直接将Spring中的Bean包装成合法的任务。Spring进一步降低了使用Quartz的难度,能以更具Spring风格的方式使用Quartz。

3.1.创建JobDetail

用户可以直接使用Quartz的JobDetail在Spring中配置一个JobDetail Bean,但是JobDetail使用带参的构造函数,对于习惯通过属性配置的Spring用户来说存在使用上的不便。为此,Spring通过扩展JobDetail提供了一个更具有Bean风格的JobDetailFactoryBean。此外,Spring还提供了一个MethodInvokingJobDetailFactoryBean,通过这个FactoryBean可以将Spring容器中Bean的方法包装成Quartz任务,这样开发者就不必为Job创建对应的类。

3.1.1.JobDetailFactoryBean

JobDetailFactoryBean扩展于Quartz的JobDetail。使用该Bean声明JobDetail时,Bean的名字即任务的名字,如果没有指定所属组,就是用默认组。除了JobDetail中的属性外,还定义了以下属性

  • jobClass:类型为Class,实现Job接口的任务类
  • beanName:默认为Bean的id名,通过该属性显示指定Bean名称,它对应任务的名称
  • jobDataAsMap:类型为Map,为任务所对应的JobDataMap提供值。之所以需要提供这个属性,是因为用户无法在Spring的配置文件中为JobDataMap类型的属性提供信息,所以Spring通过jobDataAsMap设置JobDataMap的值
  • applicationContextJobDataKey:用户可以将Spring ApplicationContext的引用保存
    JobDataMap中,以便在Job的代码中访问ApplicationContext.为了达到这个目的,用户需要指定一个键,用于在jobDataAsMap中保存ApplicationContext。如果不设置此键,JobDetailBean就不会将ApplicationContext放入JobDataMap中
  • jobListenerNames:类型为String[],指定注册在Scheduler中的JobListener名称,以便让这些监听器对本任务的时间进行监听

配置JobDetail

<bean name="jobDetail" class="org.springframework.scheduling.quartz.JobDetailBean"
        p:jobClass="com.smart.quartz.MyJob"
        p:applicationContextJobDataKey="applicationContext">
        <property name="jobDataAsMap">
            <map>
                <entry key="size" value="10" />
            </map>
        </property>
</bean>

public class MyJob implements StatefulJob {
    public void execute(JobExecutionContext jctx) throws JobExecutionException {
//      Map dataMap = jctx.getJobDetail().getJobDataMap();
        Map dataMap = jctx.getTrigger().getJobDataMap();//获取JobDetail关联的JobDataMap
        String size =(String)dataMap.get("size");
        ApplicationContext ctx = (ApplicationContext)dataMap.get("applicationContext");
        System.out.println("size:"+size);
        dataMap.put("size",size+"0");//对JobDataMap所做的更改是否会被持久化取决于任务的类型
        
        String count =(String)dataMap.get("count");
        System.out.println("count:"+count);
    }
}


3.1.2.MethodInvokingJobDetailFactoryBean

通常情况下,任务都定义在一个业务类方法中,这时,为了满足Quartz Job接口的规
定,还需要定义一个引用业务类方法的实现类。为了避免创建这个只包含一行调用代码的Job实现类,Spring提供了MethodInvokingJobDetailFactoryBean,借由该FactoryBean,可以将一个Bean的某个方法封装成满足Quartz要求的Job

<!-- 通过封装服务类方法实现 -->
    <bean id="jobDetail_1"
        class="org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean"
        p:targetObject-ref="myService" p:targetMethod="doJob" p:concurrent="false" />

    <bean id="myService" class="com.smart.service.MyService" />
    
    
public class MyService {
      public void doJob(){
       System.out.println("in MyService.dojob().");
    }
}

doJob()方法既可以是static的,也可以非static的,但不能拥有方法入参。通过
MethodInvokingJobDetailFactoryBean产生的JobDeatail不能序列化,所以不能被持久化到数据库中。如果希望使用持久化任务,则只能创建正规的Quartz的Job实现类

3.2.创建Trigger
3.2.1.SimpleTriggerFactoryBean

在默认情况下,通过SimpleTriggerFactoryBean配置的Trigger名称即为Bean的名称,属于默认数组。SimpleTriggerFactoryBean在SimpleTrigger的基础上新增了以下属性

  • jobDetail:对应的JobDetail
  • beanName:默认为Bean的id名,通过该属性显示指定Bean名称,它对应Trigger的名称
  • jobDataAsMap:以Map类型为Trigger关联的JobDataMap提供值
  • startDelay:延迟多少时间开始触发,单位为毫秒,默认值为0
  • triggerListenerNames:类型为String[],指定注册在Scheduler中的TriggerListener名称,以便让这些监听器对本触发器的事件进行监听
<bean id="simpleTrigger" class="org.springframework.scheduling.quartz.SimpleTriggerBean"
        p:jobDetail-ref="jobDetail" p:startDelay="1000" p:repeatInterval="2000"
        p:repeatCount="100">
        <property name="jobDataAsMap">
            <map>
                <entry key="count" value="10" />
            </map>
        </property>
    </bean>
3.2.2.CronTriggerFactoryBean

CronTriggerFactoryBean扩展于CronTrigger,触发器的名称即为Bean的名称,保存在默认组中。在CronTrigger的基础上,新增的属性和SimpleTriggerFactoryBean大致相同,配置的方法也和SimpleTriggerFactoryBean相似

<bean id="checkImagesTrigger" 
          class="org.springframework.scheduling.quartz.CronTriggerBean"
          p:jobDetail-ref="jobDetail"
          p:cronExpression="0/5 * * * * ?"/>

3.3.创建Scheduler

Quartz的SchedulerFactory是标准的工厂类,不太适合在Spring环境下使用。此外,为了保证Scheduler能够感知Spring容器的生命周期,在Spring容器启动后,Scheduler自动开始工作,而在Spring容器关闭前,自动关闭Scheduler。为此,Spring提供了SchedulerFactoryBean,这个FactoryBean大致拥有以下功能

  • 以更具有Bean风格的方式为Scheduler提供配置信息
  • 让Scheduler和Spring容器的生命周期建立关联,相生相息
  • 通过属性配置的方式代替Quartz自身的配置文件
<bean id="scheduler"
        class="org.springframework.scheduling.quartz.SchedulerFactoryBean">
        <property name="triggers">
            <list>
                <ref bean="simpleTrigger" />
            </list>
        </property>
        <property name="schedulerContextAsMap">
            <map>
                <entry key="timeout" value="30" />
            </map>
        </property>
        <property name="quartzProperties">
            <props>
                <prop key="org.quartz.threadPool.class">
                    org.quartz.simpl.SimpleThreadPool
                </prop>
                <prop key="org.quartz.threadPool.threadCount">10</prop>
            </props>
        </property>
    </bean>

在实际应用中,我们并不总是在程序部署的时候就确定需要哪些任务,往往需要在运行期根据业务数据动态产生触发器和任务。用户完全可以在运行时通过代码调用SchedulerFactoryBean获取Scheduler实例,然后动态注册触发器和任务。

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

推荐阅读更多精彩内容