【ElasticJob源码解析】任务调度器

作业需要执行,必然需要一个调度器,去统筹作业执行的逻辑,这也是ElasticJob的核心内容;ElasticJob依赖注册中心实现分片,所以调度器主要需要做的的事就是在任务启动的时候,将任务的信息写到注册中心,其次就是启动任务,具体的执行逻辑需要慢慢分析;

1,作业调度器-JobScheduler

首先来看看JobScheduler的构造器;

1.1,构造器

private JobScheduler(final CoordinatorRegistryCenter regCenter, 
                    final LiteJobConfiguration liteJobConfig, 
                    final JobEventBus jobEventBus, 
                    final ElasticJobListener... elasticJobListeners) {
    JobRegistry.getInstance().addJobInstance(liteJobConfig.getJobName(), new JobInstance());
    this.liteJobConfig = liteJobConfig;
    this.regCenter = regCenter;
    List<ElasticJobListener> elasticJobListenerList = Arrays.asList(elasticJobListeners);
    setGuaranteeServiceForElasticJobListeners(regCenter, elasticJobListenerList);
    schedulerFacade = new SchedulerFacade(regCenter, liteJobConfig.getJobName(), elasticJobListenerList);
    jobFacade = new LiteJobFacade(regCenter, liteJobConfig.getJobName(), Arrays.asList(elasticJobListeners), jobEventBus);
}
  • 首先使用作业注册表JobRegistry注册当前的作业信息,标注唯一作业的方式是,使用ip地址+虚拟机进程+作业名称,来唯一标识某台机器上运行的某个项目的某个作业实例;
  • 其实是为监听器设置值,这个以后再讲;
  • 最后就是两个门面类的初始化赋值;

1.2,启动调度器-init

启动调度器使用的是init方法,这个方法可以理解为启动一个定时任务,只不过这个定时任务功能比较强大,不仅仅是简单的定时执行;

首先来看init方法:

public void init() {
    LiteJobConfiguration liteJobConfigFromRegCenter = schedulerFacade.updateJobConfiguration(liteJobConfig);
    JobRegistry.getInstance().setCurrentShardingTotalCount(liteJobConfigFromRegCenter.getJobName(), liteJobConfigFromRegCenter.getTypeConfig().getCoreConfig().getShardingTotalCount());
    JobScheduleController jobScheduleController = new JobScheduleController(
            createScheduler(), createJobDetail(liteJobConfigFromRegCenter.getTypeConfig().getJobClass()), liteJobConfigFromRegCenter.getJobName());
    JobRegistry.getInstance().registerJob(liteJobConfigFromRegCenter.getJobName(), jobScheduleController, regCenter);
    schedulerFacade.registerStartUpInfo(!liteJobConfigFromRegCenter.isDisabled());
    jobScheduleController.scheduleJob(liteJobConfigFromRegCenter.getTypeConfig().getCoreConfig().getCron());
}
  • 第一行首先更新该作业的配置信息到注册中心,更新的条件是注册中心中,名为jobName+"config"的节点不存在,或者LiteJobConfiguration的overWrite字段设置为true时,会强制更新;
  • 第二行代码是将本次作业的分片总数注册到注册表JobRegistry中;
  • 然后构建一个作业调度器的控制器JobScheduleController,这个控制器,可以实现调度,暂停,恢复,关闭等操作,使用的是quartz的Scheduler来实现的,所以构造是需要一个Scheduler对象,同时还需要一个JobDetail对象,这两个对象后面讲;
  • 控制器构造完成后,将控制器和注册中心注册到作业注册表(JobRegistry)中,也就是说这个注册表中包含了所有作业的所有信息,当后期需要时,可以从注册表中获取;
  • 再然后使用门面装饰类schedulerFacade,注册初始化信息到注册中心,这里使用了装饰器模式,将大量的逻辑分在在里面,需要另开一篇讲解,总之他的任务就是讲作业的各项信息写到注册中心;
  • 最后,使用作业调度器的控制器,启动调度操作,也就是启动定时任务,使用quartz来实现的;

再来看看作业调度器的控制器需要的JobDetail是如何构造的:

private JobDetail createJobDetail(final String jobClass) {
    JobDetail result = JobBuilder.newJob(LiteJob.class).withIdentity(liteJobConfig.getJobName()).build();
    result.getJobDataMap().put(JOB_FACADE_DATA_MAP_KEY, jobFacade);
    Optional<ElasticJob> elasticJobInstance = createElasticJobInstance();
    if (elasticJobInstance.isPresent()) {
        result.getJobDataMap().put(ELASTIC_JOB_DATA_MAP_KEY, elasticJobInstance.get());
    } else if (!jobClass.equals(ScriptJob.class.getCanonicalName())) {
        try {
            result.getJobDataMap().put(ELASTIC_JOB_DATA_MAP_KEY, Class.forName(jobClass).newInstance());
        } catch (final ReflectiveOperationException ex) {
            throw new JobConfigurationException("Elastic-Job: Job class '%s' can not initialize.", jobClass);
        }
    }
    return result;
}
  • 构造方式很简单,使用一个builder对象,然后将elasticJob对象和jobFacade对象放置进去;
  • 但是这里面还有一点很有意思的东西,newJob的对象LiteJob,他是quartz的job接口的实现类,他内部拥有两个属性elasticJob和jobFacade,而后面的代码向jobDataModel中(ELASTIC_JOB_DATA_MAP_KEY->elasticJob;JOB_FACADE_DATA_MAP_KEY->jobFacade)放置的也是这两个属性,quartz框架会将jobDataModel中的键值对赋值给newJob对象中对应的属性;
  • 还需要注意的是,当jobClass为ScriptJob时,elasticJob是没有放置到jobDataModel中的,但是没关系,在LiteJob中调用的JobExecutorFactory在调用时,如果elasticJob为null,那么就默认执行ScriptJobExecutor;

再来看看Scheduler对象是如何构建:

 private Scheduler createScheduler() {
    Scheduler result;
    try {
        StdSchedulerFactory factory = new StdSchedulerFactory();
        factory.initialize(getBaseQuartzProperties());
        result = factory.getScheduler();
        result.getListenerManager().addTriggerListener(schedulerFacade.newJobTriggerListener());
    } catch (final SchedulerException ex) {
        throw new JobSystemException(ex);
    }
    return result;
}

private Properties getBaseQuartzProperties() {
    Properties result = new Properties();
    result.put("org.quartz.threadPool.class", org.quartz.simpl.SimpleThreadPool.class.getName());
    result.put("org.quartz.threadPool.threadCount", "1");
    result.put("org.quartz.scheduler.instanceName", liteJobConfig.getJobName());
    result.put("org.quartz.jobStore.misfireThreshold", "1");
    result.put("org.quartz.plugin.shutdownhook.class", JobShutdownHookPlugin.class.getName());
    result.put("org.quartz.plugin.shutdownhook.cleanShutdown", Boolean.TRUE.toString());
    return result;
}
  • 这里注册了一个JobTriggerListener,用来设置任务被错过执行的标记;
  • JobShutdownHookPlugin是用来在Scheduler关闭的时候做扫尾工作的;

总结:

  • 调度器的启动逻辑较为复杂,大量逻辑包含在内部尚未解析,但是他主要做的事:
    • 向注册中心写入各种节点信息;
    • 向作业注册表(JobRegistry)中,写入作业的各种信息;
    • 构建并启动quartz的调度器,也就是启动定时任务,执行本次作业;

2,作业的具体执行

从上面代码也可以看出,每次定时任务出发的时候,quratz会调用,实现了Job接口的LiteJob类的execute方法,那么我们就从这儿开始看起;

public final class LiteJob implements Job {
    
    @Setter
    private ElasticJob elasticJob;
    
    @Setter
    private JobFacade jobFacade;
    
    @Override
    public void execute(final JobExecutionContext context) throws JobExecutionException {
        JobExecutorFactory.getJobExecutor(elasticJob, jobFacade).execute();
    }
}

  • 上面也分析到了,这个类的构造器只有空构造函数,有quartz框架new出来,并且会设置elasticJob和jobFacade的值,值的来源是,构建JobDetail对象时,设置到jobDataMap中的同名属性;
  • 然后使用JobExecutorFactory根据elasticJob的类型的不同挑选对应的作业执行器,代码如下;
public static AbstractElasticJobExecutor getJobExecutor(final ElasticJob elasticJob, final JobFacade jobFacade) {
        if (null == elasticJob) {
            return new ScriptJobExecutor(jobFacade);
        }
        if (elasticJob instanceof SimpleJob) {
            return new SimpleJobExecutor((SimpleJob) elasticJob, jobFacade);
        }
        if (elasticJob instanceof DataflowJob) {
            return new DataflowJobExecutor((DataflowJob) elasticJob, jobFacade);
        }
        throw new JobConfigurationException("Cannot support job type '%s'", elasticJob.getClass().getCanonicalName());
    }
  • 在构建JobDetail时说过,如果作业类型为ScriptJob,那么elasticJob是没有值的,在此处,如果没有值,就返回ScriptJobExecutor,与之前相对应;
  • 其他的,根据作业类型,获取相应的执行器,如果没有,直接报错;

2.1,简单作业执行器-SimpleJobExecutor

public final class SimpleJobExecutor extends AbstractElasticJobExecutor {
    
    private final SimpleJob simpleJob;
    
    public SimpleJobExecutor(final SimpleJob simpleJob, final JobFacade jobFacade) {
        super(jobFacade);
        this.simpleJob = simpleJob;
    }
    
    @Override
    protected void process(final ShardingContext shardingContext) {
        simpleJob.execute(shardingContext);
    }
}

这个类没什么讲的,只是向父类传递了一个参数,然后重写了一个方法,但是这个方法调用的是我们实现SimpleJob时复写的方法,也就是我们自己写的业务逻辑在此处执行;主要还是看看他的父类中干了什么,先来看看父类的构造器;

2.1.1,执行器的构造器

protected AbstractElasticJobExecutor(final JobFacade jobFacade) {
    this.jobFacade = jobFacade;
    jobRootConfig = jobFacade.loadJobRootConfiguration(true);
    jobName = jobRootConfig.getTypeConfig().getCoreConfig().getJobName();
    executorService = ExecutorServiceHandlerRegistry.getExecutorServiceHandler(jobName, (ExecutorServiceHandler) getHandler(JobProperties.JobPropertiesEnum.EXECUTOR_SERVICE_HANDLER));
    jobExceptionHandler = (JobExceptionHandler) getHandler(JobProperties.JobPropertiesEnum.JOB_EXCEPTION_HANDLER);
    itemErrorMessages = new ConcurrentHashMap<>(jobRootConfig.getTypeConfig().getCoreConfig().getShardingTotalCount(), 1);
}
  • 根据子类传过来的JobFacade对象,获取JobRootConfig,JobName;
  • 以及执行作业的线程池ExecutorService,和作业的异常处理器jobExceptionHandler,这两个不同类型的字段是通过同一方法(getHandler)获取的,值得借鉴;
private Object getHandler(final JobProperties.JobPropertiesEnum jobPropertiesEnum) {
    String handlerClassName = jobRootConfig.getTypeConfig().getCoreConfig().getJobProperties().get(jobPropertiesEnum);
    try {
        Class<?> handlerClass = Class.forName(handlerClassName);
        if (jobPropertiesEnum.getClassType().isAssignableFrom(handlerClass)) {
            return handlerClass.newInstance();
        }
        return getDefaultHandler(jobPropertiesEnum, handlerClassName);
    } catch (final ReflectiveOperationException ex) {
        return getDefaultHandler(jobPropertiesEnum, handlerClassName);
    }
}

实现逻辑是这样的:

  • 方法参数给定一个 JobProperties.JobPropertiesEnum 类型的参数作为默认值;
  • 如果能从核心配置类(JobCoreConfiguration)中获取,定义作业配置的值,那么就使用配置的值,如果没有,或者配置的不是方法参数中期望的类型,那么就使用参数中的默认值;
  • 获取默认值的方法如下;
private Object getDefaultHandler(final JobProperties.JobPropertiesEnum jobPropertiesEnum, final String handlerClassName) {
    log.warn("Cannot instantiation class '{}', use default '{}' class.", handlerClassName, jobPropertiesEnum.getKey());
    try {
        return Class.forName(jobPropertiesEnum.getDefaultValue()).newInstance();
    } catch (final ClassNotFoundException | InstantiationException | IllegalAccessException e) {
        throw new JobSystemException(e);
    }
}

2.1.2,执行器的execute方法

该方法逻辑比较长,Elasticjob也分成了几段来实现,我们就一段一段的看;

  1. 第一段
public final void execute() {
    try {
        jobFacade.checkJobExecutionEnvironment();
    } catch (final JobExecutionEnvironmentException cause) {
        jobExceptionHandler.handleException(jobName, cause);
    }
    ShardingContexts shardingContexts = jobFacade.getShardingContexts();
    if (shardingContexts.isAllowSendJobEvent()) {
        jobFacade.postJobStatusTraceEvent(shardingContexts.getTaskId(), State.TASK_STAGING, String.format("Job '%s' execute begin.", jobName));
    }
    if (jobFacade.misfireIfRunning(shardingContexts.getShardingItemParameters().keySet())) {
        if (shardingContexts.isAllowSendJobEvent()) {
            jobFacade.postJobStatusTraceEvent(shardingContexts.getTaskId(), State.TASK_FINISHED, String.format(
                    "Previous job '%s' - shardingItems '%s' is still running, misfired job will start after previous job completed.", jobName, 
                    shardingContexts.getShardingItemParameters().keySet()));
        }
        return;
    }
    try {
        jobFacade.beforeJobExecuted(shardingContexts);
        //CHECKSTYLE:OFF
    } catch (final Throwable cause) {
        //CHECKSTYLE:ON
        jobExceptionHandler.handleException(jobName, cause);
    }
    execute(shardingContexts, JobExecutionEvent.ExecutionSource.NORMAL_TRIGGER);
    while (jobFacade.isExecuteMisfired(shardingContexts.getShardingItemParameters().keySet())) {
        jobFacade.clearMisfire(shardingContexts.getShardingItemParameters().keySet());
        execute(shardingContexts, JobExecutionEvent.ExecutionSource.MISFIRE);
    }
    jobFacade.failoverIfNecessary();
    try {
        jobFacade.afterJobExecuted(shardingContexts);
        //CHECKSTYLE:OFF
    } catch (final Throwable cause) {
        //CHECKSTYLE:ON
        jobExceptionHandler.handleException(jobName, cause);
    }
}
  • 首先检查本机与注册中心的时间误差秒数是否在允许范围;
    • 如果maxTimeDiffSeconds配置为-1,表示不检查;
    • 需要注意一点,即使误差过大,抛出异常,如果使用的是默认的异常处理器,那么也只是会打印error日志,而并不会阻碍程序;
  • 然后获取分片上下文;
    • 这里的获取到的分片逻辑有点复杂,在2.2中在来具体解释一下,因为涉及到了失效转移的问题;
    • 如果没有失效转移,那么获取的分片就是当前实例在执行分片时获取的分片,去除标记了disable的分片;
  • 然后判断,如果当前分片项仍在运行,是否需要设置任务被错过执行的标记;
    • 如果需要设置,那么当前任务将被跳过,并设置任务被错过执行的标记;
    • 如果不需要设置,那么接着执行后面的逻辑;
    • 需要注意的是分片项是否正在运行的判断逻辑;
      • 首先根据LiteJobConfiguration中的monitorExecution字段判断是否监控执行,默认为true,如果为false,根本就不会设置 错过执行,即使上一次的定时任务还在执行,这一次的定时任务也将启动执行;
      • 其次,本项目实例拿到的分片中,有任意一个分片还在运行中,那么所持有的所有分片,都将错过本次定时任务的执行;
  • 然后执行监听器(ElasticJobListener)的beforeJobExecuted方法;
  • 然后执行第二段逻辑,下面再讲;
  • 然后判断作业是否需要执行错过的任务,如果需要,那么还是执行第二段逻辑;
  • 被错过执行有两种情况:
    • 开启了monitorExecution,发现上一次的任务还在执行中,那么本实例拿到的所有分片的这一次的定时任务都将被错过;
    • 如果定时任务时间间距过小,如10秒,而任务执行了12秒,那么quartz会触发监听器JobTriggerListener,监听器会设置本作业实例的所有分片为错过执行;
  • 然后判断如果需要失效转移, 则执行作业失效转移;
  • 最后执行监听器的afterJobExecuted方法;
  1. 第二段
private void execute(final ShardingContexts shardingContexts, final JobExecutionEvent.ExecutionSource executionSource) {
    if (shardingContexts.getShardingItemParameters().isEmpty()) {
        if (shardingContexts.isAllowSendJobEvent()) {
            jobFacade.postJobStatusTraceEvent(shardingContexts.getTaskId(), State.TASK_FINISHED, String.format("Sharding item for job '%s' is empty.", jobName));
        }
        return;
    }
    jobFacade.registerJobBegin(shardingContexts);
    String taskId = shardingContexts.getTaskId();
    if (shardingContexts.isAllowSendJobEvent()) {
        jobFacade.postJobStatusTraceEvent(taskId, State.TASK_RUNNING, "");
    }
    try {
        process(shardingContexts, executionSource);
    } finally {
        // TODO 考虑增加作业失败的状态,并且考虑如何处理作业失败的整体回路
        jobFacade.registerJobCompleted(shardingContexts);
        if (itemErrorMessages.isEmpty()) {
            if (shardingContexts.isAllowSendJobEvent()) {
                jobFacade.postJobStatusTraceEvent(taskId, State.TASK_FINISHED, "");
            }
        } else {
            if (shardingContexts.isAllowSendJobEvent()) {
                jobFacade.postJobStatusTraceEvent(taskId, State.TASK_ERROR, itemErrorMessages.toString());
            }
        }
    }
}
  • 该段代码如果monitorExecution属性没有开启,将不会有什么意义;
    • 首先需要在注册中心中增加running节点,表示任务正在运行;
    • 当处理完后,在finally中再将running移除;
  1. 第三段
private void process(final ShardingContexts shardingContexts, final JobExecutionEvent.ExecutionSource executionSource) {
    Collection<Integer> items = shardingContexts.getShardingItemParameters().keySet();
    if (1 == items.size()) {
        int item = shardingContexts.getShardingItemParameters().keySet().iterator().next();
        JobExecutionEvent jobExecutionEvent =  new JobExecutionEvent(shardingContexts.getTaskId(), jobName, executionSource, item);
        process(shardingContexts, item, jobExecutionEvent);
        return;
    }
    final CountDownLatch latch = new CountDownLatch(items.size());
    for (final int each : items) {
        final JobExecutionEvent jobExecutionEvent = new JobExecutionEvent(shardingContexts.getTaskId(), jobName, executionSource, each);
        if (executorService.isShutdown()) {
            return;
        }
        executorService.submit(new Runnable() {
            
            @Override
            public void run() {
                try {
                    process(shardingContexts, each, jobExecutionEvent);
                } finally {
                    latch.countDown();
                }
            }
        });
    }
    try {
        latch.await();
    } catch (final InterruptedException ex) {
        Thread.currentThread().interrupt();
    }
}
  • 这里对ShardingContexts中是否只包含了一个分片做了区分处理
    • 如果只包含一个分片,比较好处理,直接进入第三段的处理即可;
    • 如果包含多个分片,那么因为第二段代码是夹在第一段代码的监听器的两个方法中间执行的,必须要处理完所有的分片任务,才能执行监听器的afterJobExecuted方法;
    • 这里使用了CountDownLatch对象,该对象在初始化的时候需要设置一个数量值,每调用一次countDown方法,则数量会减少1,如果值不为0,那么线程会一直阻塞在await方法处,此处是为了实现线程等待而使用的,等待所有分片任务执行完,再接着向下执行;
  1. 第四段
private void process(final ShardingContexts shardingContexts, final int item, final JobExecutionEvent startEvent) {
    if (shardingContexts.isAllowSendJobEvent()) {
        jobFacade.postJobExecutionEvent(startEvent);
    }
    log.trace("Job '{}' executing, item is: '{}'.", jobName, item);
    JobExecutionEvent completeEvent;
    try {
        process(new ShardingContext(shardingContexts, item));
        completeEvent = startEvent.executionSuccess();
        log.trace("Job '{}' executed, item is: '{}'.", jobName, item);
        if (shardingContexts.isAllowSendJobEvent()) {
            jobFacade.postJobExecutionEvent(completeEvent);
        }
        // CHECKSTYLE:OFF
    } catch (final Throwable cause) {
        // CHECKSTYLE:ON
        completeEvent = startEvent.executionFailure(cause);
        jobFacade.postJobExecutionEvent(completeEvent);
        itemErrorMessages.put(item, ExceptionUtil.transform(cause));
        jobExceptionHandler.handleException(jobName, cause);
    }
}

protected abstract void process(ShardingContext shardingContext);
  • 这一段没什么逻辑,除了调用真实的业务逻辑外,就是发布一些事件;
  • 真正执行的业务代码,由子类提供;

2.2,失效转移

首先来解释一下失效转移的含义:

  • 就是在某个定时任务,从这一次开始运行,到下一次开始运行之前,这之间的时间段内,某一个或多个作业实例出现了问题(或者作业实例的部分分片出了问题),那么这些出问题的作业实例,这一次的定时任务就相当于没有执行,那么失效转移功能就会将这些没有执行的分片,转移到其他正常机器,马上触发执行;
  • 当下一次任务的时间点到来时,开始重新分片,然后继续正常执行;

再来看看代码层面的实现:

  • 首先是JobCrashedJobListener监听到了需要失效转移的分片项;
  • 然后逐一设置失效的分片项标记,具体位置在注册中心的:{jobName}/leader/failover/items/{item};
  • 然后开始逐一执行作业失效转移,先执行主节点选举争抢当前分片,然后执行回调;
public void executeInLeader(final String latchNode, final LeaderExecutionCallback callback) {
    try (LeaderLatch latch = new LeaderLatch(getClient(), jobNodePath.getFullPath(latchNode))) {
        latch.start();
        latch.await();
        callback.execute();
    //CHECKSTYLE:OFF
    } catch (final Exception ex) {
    //CHECKSTYLE:ON
        handleException(ex);
    }
}
  • 回调使用的是FailoverLeaderExecutionCallback:
    • 首先向{jobName}/sharding/{item}/failover中填充临时数据;
    • 然后移除先前的{jobName}/leader/failover/items/{item}中的数据;
    • 最后构建一个jobScheduleController对象,调用triggerJob方法,马上进行一次任务启动操作;

再来看看AbstractElasticJobExecutor中获取分片的逻辑:

public ShardingContexts getShardingContexts() {
    boolean isFailover = configService.load(true).isFailover();
    if (isFailover) {
        List<Integer> failoverShardingItems = failoverService.getLocalFailoverItems();
        if (!failoverShardingItems.isEmpty()) {
            return executionContextService.getJobShardingContext(failoverShardingItems);
        }
    }
    shardingService.shardingIfNecessary();
    List<Integer> shardingItems = shardingService.getLocalShardingItems();
    if (isFailover) {
        shardingItems.removeAll(failoverService.getLocalTakeOffItems());
    }
    shardingItems.removeAll(executionService.getDisabledItems(shardingItems));
    return executionContextService.getJobShardingContext(shardingItems);
}
  • 如果开启了失效转移,那么就去获取{jobName}/sharding下所有item中的failover节点,并筛选出当前作业实例的failover,将这些failover对应的item构造成ShardingContexts直接返回;这里的执行逻辑,对应的是上面jobScheduleController对象调用triggerJob方法;
  • 失效转移执行完成后,下一次再来获取分片的时候,会执行shardingIfNecessary方法,重新进行分片;
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,594评论 18 139
  • 今天早上还像以前一样,6点起床煮稀饭再简单研究点菜,还有给儿子烧好白开水,凉半个小时装水杯上学喝。每天我和他爸这样...
    文皓文文妈妈阅读 247评论 0 5
  • 失业十天,这让我的心情极其郁闷。浪费一分一秒都是不可取的。 于是我整装出发,跑了一天去面试,最后无果而终。心里有说...
    司才林阅读 154评论 5 4
  • A1: 回顾过往经历,我的阅读高峰期主要集中在初高中、大学时期和入职后的这些年里。无论哪个阶段,我的选书读书方式都...
    Oreao阅读 161评论 1 1
  • 哲学人生 背后国文 僧者 人生的最高境界 安于现状 嘲弄了 互相追逐的你我 路 实际 没有尽头 不要怨恨老天 他厚...
    背后国文阅读 180评论 2 1