JavaSE系列:定时任务

Timer

它是一种工具,有且只有一个后台线程对多个业务线程进行定时定频率的调度。主要的构件有:Timer(用来执行任务) --> TimerTask(具体的任务)

使用Timer

自定义类继承TimerTask来编写的需要执行的任务:

public class MyTimerTask extends TimerTask{
    @Override
    public void run() {
        /* 任务 */
    }
}

然后在需要执行的地方开启任务:

public class MyMain {
    public static void main(String[] args) {
        MyTimerTask myTimer = new MyTimerTask();
        Timer timer = new Timer();

        timer.schedule(myTimer, 2000);
    }
}

Timer API

这个函数的作用是在时间等于或超过time的时候执行且仅执行一次task

/**
 * task - 需要执行的任务
 * time - 任务第一次执行的时间
 * delay - 执行任务的延迟时间
 * period - 执行一次task的时间间隔,单位毫秒
 */
public void schedule(TimerTask task, Date time, ...) {
    sched(task, time.getTime(), 0);
}

第二个函数跟第一个用法差不多

/**
 * task - 需要执行的任务
 * time - 任务第一次执行的时间
 * delay - 执行任务的延迟时间
 * period - 执行一次task的时间间隔,单位毫秒
 */
public void scheduleAtFixedRate(TimerTask task, long delay, long period) {
    if (delay < 0)
        throw new IllegalArgumentException("Negative delay.");
    if (period <= 0)
        throw new IllegalArgumentException("Non-positive period.");
    sched(task, System.currentTimeMillis()+delay, period);
}

但是两者的具体区别如下:

  • 首次计划执行的时间早于当前时间时,schedule:如果第一次执行的时间被延迟了,随后的执行时间按照上一次实际执行完成的时间点进行计算。例如计划执行时间是14:22,但是当前时间是14:30了,那么最后的执行时间为14:30来算。scheduleAtFixedRate:如果现在是00:00:06秒,但是计划执行时间是00:00:00秒,并且计划是每2秒执行一次,那么scheduleAtFixedRate()会多执行三次。
  • 任务执行所需时间超出任务的执行周期间隔时,schedule:以执行完任务的时间为准,此时的period已经无效了,所以执行时间会不断的延后。scheduleAtFixedRate:会继续按照period的时间执行,存在并发性。

取消方法,如果是Timer实例的,则取消所有任务,如果是TimerTask子类的,则取消当前任务

// Timer
public void cancel() {
    synchronized(queue) {
        thread.newTasksMayBeScheduled = false;
        queue.clear();
        queue.notify();  // In case queue was already empty.
    }
}
// TimerTask
public boolean cancel() {
    synchronized(lock) {
        boolean result = (state == SCHEDULED);
        state = CANCELLED;
        return result;
    }
}

从队列中移除已经取消的任务,返回取消的任务数

public int purge() {
    int result = 0;
    synchronized(queue) {
        for (int i = queue.size(); i > 0; i--) {
            if (queue.get(i).state == TimerTask.CANCELLED) {
                queue.quickRemove(i);
                result++;
            }
        }
        if (result != 0)
            queue.heapify();
    }
    return result;
}

Timer缺陷

  • 管理并发任务的缺陷。它只有一个线程,所有的任务都是串行完成的。
  • 当任务抛出异常时的缺陷。因为是串行化,所以在众多任务中,只要有一个任务发生了问题,后面的任务则都无法完成。

初识Quartz

Quartz 是OpenSymphony提供的强大的开源任务调度框架,纯Java实现。

特点

  • 强大的调度功能:调度运行环境的持久化机制,可以保存并恢复调度现场,不会因为突发情况而导致任务的失败和数据的消失。
  • 灵活的应用方式:允许开发者灵活的定义Trigger的触发时间点,并且可以对触发器和任务进行关联映射
  • 分布式和集群能力

体系结构

  • 调度器:负责定期定频率的调度任务
  • 任务:业务逻辑
  • 触发器

JobDetail就是业务逻辑的实现类和实现类的一些信息。

Trigger就是触发器,决定任务是什么时候被调用,Trigger的两个实现类:SimpleTriggerCronTriggerSimpleTrigger能够执行类似Timer上的一些时间操作,比如说定频率的执行任务,CronTrigger能够实现更复杂的一些业务逻辑,比如每周三执行时间打印。

scheduler则是调度器,将JobDetailTrigger绑定到一起,然后执行任务。

重要的组成

  • Job:它是一个接口,作用如同Timer的TimerTask
  • JobDetail:实例化Job,并且为Job提供了许多设置属性,以及JobDetail的成员变量属性,用来存储特定的Job实例的状态信息,调度器需要借助JobDetail对象类添加Job实例
  • JobBuilder:创建JobDetail
  • JobStore:接口,保存Job数据
  • Trigger:描述Job的执行时间和触发规则
  • TriggerBuilder:创建Trigger
  • Scheduler:用来映射JobDetailTrigger,两者在Scheduler都拥有各自的组级名称,组级名称是Scheduler查找定位容器中某一对象的依据。 JobDetailTrigger的组级名称必须唯一,但是两者的组级名称可以相同。
  • Calendar:一个Trigger可以和多个Calendar关联,用来排除或者包含某些时间点
  • JobListenerTiggerListenerSchedulerListener:分别监听各自的事件,https://www.cnblogs.com/daxin/archive/2013/05/29/3105830.html,http://blog.csdn.net/qwe6112071/article/details/50991539

一个简单的Quartz程序

/**
 * @author: uncle
 * @apdateTime: 2018-01-07 11:04
 */
public class MyJob implements Job {
    /**
     * 业务逻辑
     *
     * @param jobExecutionContext
     * @throws JobExecutionException
     */
    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        // 打印当前时间
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy:MM:dd HH:mm:ss");
        System.out.println(simpleDateFormat.format(new Date()));
        System.out.println("hello quartz");
    }
}
public class MyScheduler {
    public static void main(String[] args) throws SchedulerException {
        // 创建JobDetail(Builder模式)
        JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
                .withIdentity("myJob", "group1")
                .build();

        // 创建触发器(Builder模式)
        Trigger trigger = TriggerBuilder.newTrigger()
                // 描述
                .withIdentity("myTrigger", "group1")
                // 立刻开始执行
                .startNow()
                // 创建执行规则
                .withSchedule(
                        SimpleScheduleBuilder.simpleSchedule()
                                // 每隔两秒执行一次
                                .withIntervalInSeconds(2)
                                // 一直重复执行
                                .repeatForever()
                ).build();

        // 创建Scheduler(工厂模式)
        SchedulerFactory schedulerFactory = new StdSchedulerFactory();
        Scheduler scheduler = schedulerFactory.getScheduler();
        // 绑定Job和Trigger
        scheduler.scheduleJob(jobDetail, trigger);
        // 开始执行任务
        scheduler.start();
    }
}

Job的生命周期

每次调度器在调用Job时,它在调用execute()前会创建一个新的Job实例。当调用完成后,关联的job对象实例会被释放,并且会被垃圾回收器回收。

JobDetail属性

  • name:任务名称
  • group:任务的分组名称
  • jobClass:任务的实现类
  • jobDataMap:传参作用

JobExecutionContext

当Scheduler调用Job时,就会将JobExecutionContext传递给Job的execute(),Job能通过JobExecutionContext对象访问到Quartz运行时的环境以及Job本身的明细数据。

JobDataMap

  • 在进行任务调度时JobDataMap存储在JobExecutionContext中,方便获取。
  • JobDataMap可以用来装载任何可序列化的数据对象,当job实例对象被执行时这些参数对象会传递给它。
  • JobDataMap实现了JDK的Map接口,并且添加了一些方便的方法用来存取基本数据类型

获取JobDataMap

  • 从Map中直接获取
  • Job实现类中添加setter方法对应JobDataMap的键值(Quartz框架默认的JobFactory实现类在初始化job实例对象时会自动调用这些setter方法),这段话意思是假如使用usingJobData()设置了一些键值对,比如:usingJobData("message", "hello"),那么使用setter的话,直接在Job实现类中添加同名的类成员private String message;,添加setter方法,然后程序在运行时会自动把usingJobData()的值放入setter中。

Trigger

触发器,用来告诉调度程序作业什么时候触发。

Trigger的通用属性

  • JobKey:表示Job实例的标识,触发器被触发时,该指定的Job实例会被执行
  • StartTime:表示触发器的时间表首次被触发的时间,值类型是java.util.Date
  • EndTime:表示触发器的不再被触发的时间,值类型是java.util.Date

CronTrigger

基于日历的作业调度器,而不是像SimpleTrigger那样精确指定间隔时间,比SimpleTrigger更常用

Cron表达式

它是CronTrigger的精髓,用于配置CronTrigger实例,是由7个子表达式组成的字符串,描述了时间表的详细信息。格式:秒时月[年]

Cron表达式特殊字符意义对应表:
image

举例:
image

0/5表示从0分开始,然后每5分钟触发一次,6#3表示每周三的星期五触发,6L L是last,表示最后一个星期五。

通配符说明:
image

Scheduler

创建Scheduler的两种方式:

SchedulerFactory schedulerFactory = new StdSchedulerFactory();
Scheduler scheduler = schedulerFactory.getScheduler();

DirectSchedulerFactory directSchedulerFactory = DirectSchedulerFactory.getInstance();
Scheduler scheduler1 = directSchedulerFactory.getScheduler();

StdSchedulerFactory

  • 使用一组参数(java.util.Properties)来创建和初始化Quartz调度器
  • 配置参数一般存储在quartz.properties中,默认情况下,Quartz在运行时加载的是工程目录下的quartz.properties,如果工程目录下没有这个文件,它就会去读jar包里面的quartz.properties
  • 调用getScheduler方法就能创建和初始化调度器对象

主要函数

  • Date scheduleJob(JobDetail var1, Trigger var2) throws SchedulerException;:绑定Trigger和JobDetail,返回最近一次即将要执行的时间
  • void start():开始执行
  • void standby():让Scheduler暂时挂起
  • void shutdown([boolean flag]):停止任务,并且不能被start(),如果传入了一个布尔参数,为true时表示等待所有正在执行的任务执行完再关闭,为false时直接关闭,不管任务是否执行完
  • boolean isShutdown():判断是否是停止状态

quartz.properties的组成

  • 调度器属性
  • 线程池属性:threadCount - 工作线程数,介意值1-100,threadPriority - 设置线程工作的优先级,值1-10,介意值5,集群模式可以使用,org.quartz.threadPool.class 。
  • 作业存储设置:描述了在调度器实例的生命周期中,Job和Trigger信息是如何被存储的
  • 插件配置:满足特定需求用到的Quartz插件的配置
# Default Properties file for use by StdSchedulerFactory
# to create a Quartz Scheduler Instance, if a different
# properties file is not explicitly specified.
#
# ===========================================================================
# Configure Main Scheduler Properties 调度器属性
# ===========================================================================
# 用来区分特性的调度器实例
org.quartz.scheduler.instanceName: DefaultQuartzScheduler
# 和前者一样,允许任何字符串,但这个值必须是在所有调度器实例中唯一的。如果是集群模式,可以设置为AUTO,意思是自动生成
org.quartz.scheduler.instanceid:AUTO
org.quartz.scheduler.rmi.export: false
org.quartz.scheduler.rmi.proxy: false
org.quartz.scheduler.wrapJobExecutionInUserTransaction: false
# ===========================================================================  
# Configure ThreadPool 线程池属性  
# ===========================================================================
#线程池的实现类(一般使用SimpleThreadPool即可满足几乎所有用户的需求)
org.quartz.threadPool.class: org.quartz.simpl.SimpleThreadPool
#指定线程数,至少为1(无默认值)(一般设置为1-100直接的整数合适)
org.quartz.threadPool.threadCount: 10
#设置线程的优先级(最大为java.lang.Thread.MAX_PRIORITY 10,最小为Thread.MIN_PRIORITY 1,默认为5)
org.quartz.threadPool.threadPriority: 5
#设置SimpleThreadPool的一些属性
#设置是否为守护线程
#org.quartz.threadpool.makethreadsdaemons = false
#org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread: true
#org.quartz.threadpool.threadsinheritgroupofinitializingthread=false
#线程前缀默认值是:[Scheduler Name]_Worker
#org.quartz.threadpool.threadnameprefix=swhJobThead;
# 配置全局监听(TriggerListener,JobListener) 则应用程序可以接收和执行 预定的事件通知
# ===========================================================================
# Configuring a Global TriggerListener 配置全局的Trigger监听器
# MyTriggerListenerClass 类必须有一个无参数的构造函数,和 属性的set方法,目前2.2.x只支持原始数据类型的值(包括字符串)
# ===========================================================================
#org.quartz.triggerListener.NAME.class = com.swh.MyTriggerListenerClass
#org.quartz.triggerListener.NAME.propName = propValue
#org.quartz.triggerListener.NAME.prop2Name = prop2Value
# ===========================================================================
# Configuring a Global JobListener 配置全局的Job监听器
# MyJobListenerClass 类必须有一个无参数的构造函数,和 属性的set方法,目前2.2.x只支持原始数据类型的值(包括字符串)
# ===========================================================================
#org.quartz.jobListener.NAME.class = com.swh.MyJobListenerClass
#org.quartz.jobListener.NAME.propName = propValue
#org.quartz.jobListener.NAME.prop2Name = prop2Value
# ===========================================================================  
# Configure JobStore 存储调度信息(工作,触发器和日历等)
# ===========================================================================
# 信息保存时间 默认值60秒
org.quartz.jobStore.misfireThreshold: 60000
#保存job和Trigger的状态信息到内存中的类
org.quartz.jobStore.class: org.quartz.simpl.RAMJobStore
# ===========================================================================  
# Configure SchedulerPlugins 插件属性 配置
# ===========================================================================
# 自定义插件  
#org.quartz.plugin.NAME.class = com.swh.MyPluginClass
#org.quartz.plugin.NAME.propName = propValue
#org.quartz.plugin.NAME.prop2Name = prop2Value
#配置trigger执行历史日志(可以看到类的文档和参数列表)
org.quartz.plugin.triggHistory.class = org.quartz.plugins.history.LoggingTriggerHistoryPlugin  
org.quartz.plugin.triggHistory.triggerFiredMessage = Trigger {1}.{0} fired job {6}.{5} at: {4, date, HH:mm:ss MM/dd/yyyy}  
org.quartz.plugin.triggHistory.triggerCompleteMessage = Trigger {1}.{0} completed firing job {6}.{5} at {4, date, HH:mm:ss MM/dd/yyyy} with resulting trigger instruction code: {9}  
#配置job调度插件  quartz_jobs(jobs and triggers内容)的XML文档  
#加载 Job 和 Trigger 信息的类   (1.8之前用:org.quartz.plugins.xml.JobInitializationPlugin)
org.quartz.plugin.jobInitializer.class = org.quartz.plugins.xml.XMLSchedulingDataProcessorPlugin
#指定存放调度器(Job 和 Trigger)信息的xml文件,默认是classpath下quartz_jobs.xml
org.quartz.plugin.jobInitializer.fileNames = my_quartz_job2.xml  
#org.quartz.plugin.jobInitializer.overWriteExistingJobs = false  
org.quartz.plugin.jobInitializer.failOnFileNotFound = true  
#自动扫描任务单并发现改动的时间间隔,单位为秒
org.quartz.plugin.jobInitializer.scanInterval = 10
#覆盖任务调度器中同名的jobDetail,避免只修改了CronExpression所造成的不能重新生效情况
org.quartz.plugin.jobInitializer.wrapInUserTransaction = false
# ===========================================================================  
# Sample configuration of ShutdownHookPlugin  ShutdownHookPlugin插件的配置样例
# ===========================================================================
#org.quartz.plugin.shutdownhook.class = \org.quartz.plugins.management.ShutdownHookPlugin
#org.quartz.plugin.shutdownhook.cleanShutdown = true
#
# Configure RMI Settings 远程服务调用配置
#
#如果你想quartz-scheduler出口本身通过RMI作为服务器,然后设置“出口”标志true(默认值为false)。
#org.quartz.scheduler.rmi.export = false
#主机上rmi注册表(默认值localhost)
#org.quartz.scheduler.rmi.registryhost = localhost
#注册监听端口号(默认值1099)
#org.quartz.scheduler.rmi.registryport = 1099
#创建rmi注册,false/never:如果你已经有一个在运行或不想进行创建注册
# true/as_needed:第一次尝试使用现有的注册,然后再回来进行创建
# always:先进行创建一个注册,然后再使用回来使用注册
#org.quartz.scheduler.rmi.createregistry = never
#Quartz Scheduler服务端端口,默认是随机分配RMI注册表
#org.quartz.scheduler.rmi.serverport = 1098
#true:链接远程服务调度(客户端),这个也要指定registryhost和registryport,默认为false
# 如果export和proxy同时指定为true,则export的设置将被忽略
#org.quartz.scheduler.rmi.proxy = false

API

  • getkey():获取唯一标识
  • usingJobData(String key, T value):传入自定义参数,键值对形式
  • getJobDataMap():获取JobDataMap
  • getMergedJobDataMap():JobExecutionContext方法,获取合并的Trigger和JobDetail的JobDataMap,如果key相同,则优先使用Trigger的,JobDetail重名key会被覆盖
  • startAt(Date date):设置第一次触发的时间
  • endAt():设置结束时间
  • withRepeatCount(int count):重复执行的次数
JobDataMap jobDataMap = jobExecutionContext.getJobDetail().getJobDataMap();
jobDataMap.getString("");
jobDataMap.getDouble();
jobDataMap.getFloat();

JobDataMap dataMap = jobExecutionContext.getTrigger().getJobDataMap();

Quartz与Spring

添加依赖:

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-support</artifactId>
  <version>4.3.13.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-tx</artifactId>
  <version>4.3.13.RELEASE</version>
</dependency>
<dependency>
  <groupId>org.opensymphony.quartz</groupId>
  <artifactId>quartz</artifactId>
  <version>1.6.1</version>
</dependency>

使用Quartz作业的两种方式:

  • MethodInvokingJobDetailFactoryBean
<bean id = "simpleJobDetail" class = "org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean">
    <!-- myBean是一个简单的持久化类 -->
    <property name = "targetObject" ref = "myBean"/>
    <!-- printMessage 是myBean里的一个方法 -->
    <property name = "targetMethod" ref = "printMessage"/>
</bean>

// ------------------------------------------------------------
@Component("myBean")
public class MyBean {
    public void printMessage() {
        System.out.println("MyBean ...");
    }
}
  • JobDetailFactoryBean

需要给作业传递数据,或者想更加灵活的话,就可以使用:

<bean id = "firstComplexJobDetail" class = "org.springframework.scheduling.quartz.JobDetailFactoryBean">
    <!-- ref 的类是继承自QuartzJobBean的 -->
    <property name = "jobClass" ref = "com.zengxiaochen.MyQuartzBean"/>
    <!-- 自定义参数 -->
    <property name = "jobDataMap">
        <map>
            <entry key = "anotherBean" value-ref = "anotherBean">
        </map>
    </property>
    <!-- 指明任务可以不绑定Trigger,durability / Durability -->
    <property name = "durability" ref = "true"/>
</bean>

// -----------------------------------------------
public class MyQuartzBean extends QuartzJobBean {
    // 这是对应xml中传入的参数
    private AnotherBean anotherBean;
    // 业务逻辑代码
    @Override
    prodected void executeInternal(JobExecutionContext context) {
        System.out.println("MyQuartzBean ...");
        this.anotherBean.printAnotherMessage();
    }
    
    public void setAnotherBean(AnotherBean anotherBean) {
        this.anotherBean = anotherBean;
    }
}

public class AnotherBean {
    public void printAnotherMessage() {
        System.out.println("AnotherBean ...");
    }
}

总的xml:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context"
    xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:p="http://www.springframework.org/schema/p"
    xsi:schemaLocation="http://www.springframework.org/schema/beans  
            http://www.springframework.org/schema/beans/spring-beans.xsd  
            http://www.springframework.org/schema/mvc  
            http://www.springframework.org/schema/mvc/spring-mvc.xsd  
            http://www.springframework.org/schema/context  
            http://www.springframework.org/schema/context/spring-context.xsd"
    default-lazy-init="true">

    <!-- 通过mvc:resources设置静态资源,这样servlet就会处理这些静态资源,而不通过控制器 -->
    <!-- 设置不过滤内容,比如:css,jquery,img 等资源文件 -->
    <mvc:resources location="/*.html" mapping="/**.html" />
    <mvc:resources location="/css/*" mapping="/css/**" />
    <mvc:resources location="/js/*" mapping="/js/**" />
    <mvc:resources location="/images/*" mapping="/images/**" />
    <!-- 设定消息转换的编码为utf-8防止controller返回中文乱码 -->
    <bean
        class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
        <property name="messageConverters">
            <list>
                <bean
                    class="org.springframework.http.converter.StringHttpMessageConverter">
                    <property name="supportedMediaTypes">
                        <list>
                            <value>text/html;charset=UTF-8</value>
                        </list>
                    </property>
                </bean>
            </list>
        </property>
    </bean>
    <!-- 添加注解驱动 -->
    <mvc:annotation-driven />
    <!-- 默认扫描的包路径 -->
    <context:component-scan base-package="com.imooc.springquartz" />

    <!-- mvc:view-controller可以在不需要Controller处理request的情况,转向到设置的View -->
    <!-- 像下面这样设置,如果请求为/,则不通过controller,而直接解析为/index.jsp -->
    <mvc:view-controller path="/" view-name="index" />
    <bean class="org.springframework.web.servlet.view.UrlBasedViewResolver">
        <property name="viewClass"
            value="org.springframework.web.servlet.view.JstlView"></property>
        <!-- 配置jsp路径前缀 -->
        <property name="prefix" value="/"></property>
        <!-- 配置URl后缀 -->
        <property name="suffix" value=".jsp"></property>
    </bean>

    <bean id="simpleJobDetail"
        class="org.springframework.scheduling.quartz.MethodInvokingJobDetailFactoryBean">
        <property name="targetObject" ref="myBean" />
        <property name="targetMethod" value="printMessage" />
    </bean>

    <bean id="firstComplexJobDetail"
        class="org.springframework.scheduling.quartz.JobDetailFactoryBean">
        <property name="jobClass"
            value="com.imooc.springquartz.quartz.FirstScheduledJob" />
        <property name="jobDataMap">
            <map>
                <entry key="anotherBean" value-ref="anotherBean" />
            </map>
        </property>
        <property name="Durability" value="true"/>                
    </bean>
    <!-- 距离当前时间1秒之后执行,之后每隔两秒钟执行一次 -->
    <bean id="mySimpleTrigger" class="org.springframework.scheduling.quartz.SimpleTriggerFactoryBean">
        <property name="jobDetail"  ref="simpleJobDetail"/>
        <property name="startDelay"  value="1000"/>
        <property name="repeatInterval"  value="2000"/>
    </bean>
    
    <!-- 每隔5秒钟执行一次 -->
    <bean id="myCronTrigger" class="org.springframework.scheduling.quartz.CronTriggerFactoryBean">
        <property name="jobDetail"  ref="firstComplexJobDetail"/>
        <property name="cronExpression"  value="0/5 * * ? * *"/>
    </bean>
    
    <bean class="org.springframework.scheduling.quartz.SchedulerFactoryBean">
        <property name="jobDetails">
            <list>
                <ref bean="simpleJobDetail"/>
                <ref bean="firstComplexJobDetail"/>
            </list>
        </property>
        <property name="triggers">
            <list>
                <ref bean="mySimpleTrigger"/>
                <ref bean="myCronTrigger"/>
            </list>
        </property>
    </bean>
</beans>  

Timer和Quartz 的区别

  • 出生不同:Timer是JDK自带的工具类,Quartz是Open..的开源项目
  • 能力区别:Timer只能完成一些简单的串行定时任务,相反Quartz的时间控制功能比Timer要完善很多
  • 底层机制:Timer只能有一个后台线程去执行定时任务,而Quartz拥有线程池
Last modification:January 28th, 2018 at 11:28 am
If you think my article is useful to you, please feel free to appreciate

Leave a Comment