SpringBoot2.x系列教程87--SpringBoot中整合定时任务

SpringBoot2.x系列教程87--SpringBoot中整合定时任务

作者:一一哥

一. Java中的定时任务

1. 前言

在实际项目开发中,定时任务的应用场景可以说是非常广泛的,比如某些视频网站,购买会员后,每天会给会员赠送成长值,每月会给会员送一些电影券;再比如要定时生成报表、邮件;定时清理数据;自动将超过24小时的未付款的单改为取消状态,自动将超过14天客户未签收的订单改为已签收状态等。

2. 定时任务的实现方式

  • 1.Timer: 这是java自带的java.util.Timer类,这个类允许你调度一个java.util.TimerTask任务。使用这种方式可以让你的程序按照某一个频度执行,但不能在指定时间运行,一般用的较少。

  • 2.ScheduledExecutorService: 也jdk自带的一个类,是基于线程池设计的定时任务类。每个调度任务都会分配到线程池中的一个线程中去执行,也就是说,任务是并发执行,互不影响。

  • 3.Spring Task: Spring3.0以后自带的task,可以将它看成一个轻量级的Quartz,而且使用起来比Quartz简单许多。

  • 4.Quartz: 这是一个功能比较强大的的调度器,可以让你的程序在指定时间执行,也可以按照某一个频度执行,配置起来稍显复杂。

二. Cron表达式详解

1. Cron表达式概述

一个cron表达式一般是由7个带空格的分隔的时间元素来组成,按顺序依次为:

  • 秒(0~59)
  • 分钟(0~59)
  • 3 小时(0~23)
  • 4 天(0~31)
  • 5 月(0~11)
  • 6 星期(1~7 1=SUN 或 SUN,MON,TUE,WED,THU,FRI,SAT)
  • 年份(1970-2099)

其中每个元素可以是一个值(如6),一个连续区间(9-12),一个间隔时间(8-18/4)(/表示每隔4小时),一个列表(1,3,5),通配符。由于”月份中的日期”和”星期中的日期”这两个元素互斥的,必须要对其中一个设置。

2. 具体配置实例

每隔5秒执行一次:*/5 ** ?

每隔1分钟执行一次:0 */1 *?

0 0 10,14,16 ? 每天上午10点,下午2点,4点

0 0/30 9-17 ? 朝九晚五工作时间内每半小时

0 0 12 ? * WED 表示每个星期三中午12点

“0 0 12 ?” 每天中午12点触发

“0 15 10 ? “ 每天上午10:15触发

“0 15 10 ?” 每天上午10:15触发

“0 15 10 ? *” 每天上午10:15触发

“0 15 10 ? 2005” 2005年的每天上午10:15触发

“0 *14 ** ?” 在每天下午2点到下午2:59期间的每1分钟触发

“0 0/5 14 ?” 在每天下午2点到下午2:55期间的每5分钟触发

“0 0/5 14,18 ?” 在每天下午2点到2:55期间和下午6点到6:55期间的每5分钟触发

“0 0-5 14 ?” 在每天下午2点到下午2:05期间的每1分钟触发

“0 10,44 14 ? 3 WED” 每年三月的星期三的下午2:10和2:44触发

“0 15 10 ? * MON-FRI” 周一至周五的上午10:15触发

“0 15 10 15 * ?” 每月15日上午10:15触发

“0 15 10 L * ?” 每月最后一日的上午10:15触发

“0 15 10 ? * 6L” 每月的最后一个星期五上午10:15触发

“0 15 10 ? * 6L 2002-2005” 2002年至2005年的每月的最后一个星期五上午10:15触发

“0 15 10 ? * 6#3” 每月的第三个星期五上午10:15触发

3.表达范围的子表达式

有些子表达式能包含一些范围或列表。

例如:

子表达式(天(星期))可以为 “MON-FRI”,“MON,WED,FRI”,“MON-WED,SAT”

4. *与/的含义

“*”字符代表所有可能的值,“/”字符用来指定数值的增量。

例如:在子表达式(分钟)里的“0/15”表示从第0分钟开始,每15分钟
在子表达式(分钟)里的“3/20”表示从第3分钟开始,每20分钟(它和“3,23,43”)的含义一样

5. ?的含义

“?”字符仅被用于天(月)和天(星期)两个子表达式,表示不指定值
当2个子表达式其中之一被指定了值以后,为了避免冲突,需要将另一个子表达式的值设为“?”

6. ”#“的含义

该字符只能在星期字段中使用,表示当月某个工作日。如6#3表示当月的第三个星期五(6表示星期五,#3表示当前的第三个),而4#5表示当月的第五个星期三,假设当月没有第五个星期三,忽略不触发。

7. L字符的含义

L字符只在日期和星期字段中使用,代表“Last”的意思,但它在两个字段中意思不同。L在日期字段中,表示这个月份的最后一天,如一月的31号,非闰年二月的28号;如果L用在星期中,则表示星期六,等同于7。但是,如果L出现在星期字段里,而且在前面有一个数值X,则表示“这个月的最后X天”,例如,6L表示该月的最后星期五;
如果在“L”前有具体的内容,它就具有其他的含义了。

例如:

“6L”表示这个月的倒数第6天
注意:在使用“L”参数时,不要指定列表或范围,因为这会导致问题。

8. W字符的含义

该字符只能出现在日期字段里,是对前导日期的修饰,表示离该日期最近的工作日。例如15W表示离该月15号最近的工作日,如果该月15号是星期六,则匹配14号星期五;如果15日是星期日,则匹配16号星期一;如果15号是星期二,那结果就是15号星期二。但必须注意关联的匹配日期不能够跨月,如你指定1W,如果1号是星期六,结果匹配的是3号星期一,而非上个月最后的那天。W字符串只能指定单一日期,而不能指定日期范围;

大部分的商业处理都是基于工作周的,所以 W 字符可能是非常重要的。

LW组合:在日期字段可以组合使用LW,它的意思是当月的最后一个工作日;

例如:

日域中的 15W 意味着 “离该月15号的最近一个平日。” 假如15号是星期六,那么 trigger 会在14号(星期五)触发,因为星期四比星期一离15号更近。

9. C字符的含义

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

例如:

5C在日期字段中就相当于日历5日以后的第一天,1C在星期字段中相当于星期日后的第一天。

10. Cron规则表

Cron表达式对特殊字符的大小写不敏感,对代表星期的缩写英文大小写也不敏感。

  • 星号():可用在所有字段中,表示对应时间域的每一个时刻,例如,在分钟字段时,表示“每分钟”;

  • 问号(?):该字符只在日期和星期字段中使用,它通常指定为“无意义的值”,相当于占位符;

  • 减号(-):表达一个范围,如在小时字段中使用“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。

在线cron表达式生成:http://qqe2.com/cron/index

三. 实现方式一:Timer与TimerTask

1. 概述

Timer定时任务调度:基于给定的时间点、给定的时间间隔、给定的执行次数自动执行的任务。

Timer位于java.util包下,其内部包含且仅包含一个后台线程(TimeThread)对多个业务任务(TimeTask)进行定时定频率的调度。

参数说明:

task:所要执行的任务,需要extends TimeTask override run()

time/firstTime:首次执行任务的时间

period:周期性执行Task的时间间隔,单位是毫秒

delay:执行task任务前的延时时间,单位是毫秒

很显然,通过上述的描述,我们可以实现:

延迟多久后执行一次任务;指定时间执行一次任务;延迟一段时间,并周期性执行任务;指定时间,并周期性执行任务。

Timer定时任务一般需要由Timer、TimerTask两个类来配合完成,Java API中是这样定义它们的,

  • Timer:一种工具,线程用其安排以后在后台线程中执行的任务,可安排任务执行一次,或者定期重复执行。

  • TimerTask:Timer 安排的一次执行或重复执行的任务。

我们可以这样理解Timer是一种定时器工具,用来在一个后台线程计划执行指定任务;而TimerTask一个抽象类,它的子类代表一个可以被Timer计划的任务。

2. Timer类

在工具类Timer中,提供了四个构造方法,每个构造方法都启动了计时器线程,同时Timer类可以保证多个线程可以共享单个Timer对象而无需进行外部同步,所以Timer类是线程安全的。但是由于每一个Timer对象对应的是单个后台线程,用于顺序执行所有的计时器任务,一般情况下我们的线程任务执行所消耗的时间应该非常短,但是由于特殊情况导致某个定时器任务执行的时间太长,那么他就会“独占”计时器的任务执行线程,其后的所有线程都必须等待它执行完,这就会延迟后续任务的执行,使这些任务堆积在一起,具体情况我们后面分析。

当程序初始化完成Timer后,定时任务就会按照我们设定的时间去执行,Timer提供了schedule方法,该方法有多中重载方式来适应不同的情况,如下:

  • schedule(TimerTask task, Date time):在指定的时间内执行指定的任务。

  • schedule(TimerTask task, Date firstTime, long period) :指定的任务在指定的时间开始进行重复的固定延迟执行。

  • schedule(TimerTask task, long delay) :在指定的延迟时间后执行指定的任务。

  • schedule(TimerTask task, long delay, long period) :指定的任务从指定的延迟时间后开始进行重复固定的执行。

  • scheduleAtFixedRate(TimerTask task, Date firstTime, long period):指定的任务在指定的时间内开始进行重复固定速率的执行。

  • scheduleAtFixedRate(TimerTask task, long delay, long period):指定的任务在指定的延迟时间后开始进行重复固定速率的执行。

3. TimerTask

TimerTask类是一个抽象类,由Timer 安排为一次执行或重复执行的任务。它有一个抽象方法run()方法,该方法用于执行相应计时器任务要执行的操作。因此每一个具体的任务类都必须继承TimerTask,然后重写run()方法。

另外它还有两个非抽象的方法:

  • boolean cancel():取消此计时器任务。

  • long scheduledExecutionTime():返回此任务最近实际执行的安排执行时间。

4. Timer的缺陷

Timer计时器可以定时(指定时间执行任务)、延迟(延迟5秒执行任务)、周期性地执行任务(每隔个1秒执行任务),但是,Timer存在一些缺陷。

  • 首先Timer对调度的支持是基于绝对时间的,而不是相对时间,所以它对系统时间的改变非常敏感。
  • 其次Timer线程是不会捕获异常的,如果TimerTask抛出的了未检查异常则会导致Timer线程终止。
  • 第三Timer也不会重新恢复线程的执行,它会错误的认为整个Timer线程都会取消。
  • 同时,已经被安排单尚未执行的TimerTask也不会再执行了,新的任务也不能被调度。

所以如果TimerTask抛出未检查的异常,Timer将会产生无法预料的行为。

5. Timer实现代码

TimerTask timerTask = new TimerTask() {
    @Override
    public void run() {
        System.out.println("task  run:"+ new Date());
    }
};

Timer timer = new Timer();
//安排指定的任务在指定的时间开始进行重复的固定延迟执行。这里是每3秒执行一次
timer.schedule(timerTask,10,3000);

测试效果:

四.实现方式二:ScheduledExecutorService

1. 概述

由于Timer存在的问题,JDK5之后便提供了基于线程池的定时任务调度:ScheduledExecutorService。

设计理念:每一个被调度的任务都会被线程池中的一个线程去执行,因此任务可以并发执行,而且相互之间不受影响。

2. 线程与线程池

2.1 New Thread的弊端:

  • a、每次New Thread新建对象性能差。

  • b、线程缺乏统一的管理,可能无限制的新建线程,相互之间竞争,极可能占用过多的系统资源导致死机 或者 OOM。

  • c、缺乏更多功能,如定时执行、定期执行、线程中断。

2.2 线程池的好处:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

  • 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

2.3 Java提供的四种线程池的好处:

  • a、重用存在的线程,减少对象创建、消亡的开销,性能佳。

  • b、可有效控制最大并发线程数、提供系统资源的使用率,同时避免过多资源竞争,避免堵塞。

  • c、提供定时执行、定期执行、单线程、并发数控制等功能。

2.4 Executors的四种线程池构建方式:

  • newCachedThreadPool 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

  • newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待,表示同一时刻只能有这么大的并发数。

  • newScheduledThreadPool 创建一个定时线程池,支持定时及周期性任务执行。

  • newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

3. ThreadPoolExecutor 创建线程池

Java线程池不建议使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 

3.1 Executors各个方法的弊端:

  • newFixedThreadPool 和 newSingleThreadExecutor
    主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。

  • newCachedThreadPool 和 newScheduledThreadPool
    主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
      

    3.2 三种创建线程池的推荐方式:

方式一:

//org.apache.commons.lang3.concurrent.BasicThreadFactory
ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1,new BasicThreadFactory.Builder().namingPattern("example-schedule-pool-%d").daemon(true).build());

方式二:

ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build();

//Common Thread Pool
ExecutorService pool = new ThreadPoolExecutor(5, 200,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());

pool.execute(()-> System.out.println(Thread.currentThread().getName()));

pool.shutdown();//gracefully shutdown

方式三:

<bean id="userThreadPool" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
        <property name="corePoolSize" value="10" />
        <property name="maxPoolSize" value="100" />
        <property name="queueCapacity" value="2000" />
        <property name="threadFactory" value= threadFactory />
        <property name="rejectedExecutionHandler">
            <ref local="rejectedExecutionHandler" />
        </property>
</bean>

//in code
userThreadPool.execute(thread);

4.示例代码

ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1,
                new BasicThreadFactory.Builder()
                        .namingPattern("schedule-pool-%d")
                        .daemon(true)
                        .build());
        // 参数:1.任务体;2.首次执行的延时时间;3.任务执行间隔;4.间隔时间单位.
        executorService.scheduleAtFixedRate(()->System.out.println("定时任务:"+new Date()), 0, 3, TimeUnit.SECONDS);

测试效果:

五. 实现方式三:Spring Task

1. 添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

2.创建任务类

使同一个线程中串行执行,如果只有一个定时任务,这样做肯定没问题,当定时任务增多,如果一个任务卡死,会导致其他任务也无法执行。

package com.yyg.boot.task;

import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * @Author 一一哥Sun
 * @Date Created in 2020/5/17
 * @Description Description
 * 如果不添加@Async注解,默认是串行执行.
 * 如果只有一个定时任务,这样做肯定没问题,当定时任务增多,如果一个任务卡死,会导致其他任务也无法执行。
 * 当有多个任务的时候,可以使用@Async注解进行异步执行.
 */
@Component
@EnableScheduling
@Async
public class AlarmTask {

    /**默认是fixedDelay,上一次执行完毕时间后执行下一轮*/
    @Scheduled(cron = "0/3 * * * * *")
    public void run1() throws InterruptedException {
        Thread.sleep(3000);
        System.out.println(Thread.currentThread().getName()+"=====>>>>>使用cron  {}"+(System.currentTimeMillis()/1000));
    }

    /**fixedRate:上一次开始执行时间点之后5秒再执行*/
    @Scheduled(fixedRate = 5000)
    public void run2() throws InterruptedException {
        Thread.sleep(6000);
        System.out.println(Thread.currentThread().getName()+"=====>>>>>使用fixedRate  {}"+(System.currentTimeMillis()/1000));
    }

    /**fixedDelay:上一次执行完毕时间点之后5秒再执行*/
    @Scheduled(fixedDelay = 5000)
    public void run3() throws InterruptedException {
        Thread.sleep(7000);
        System.out.println(Thread.currentThread().getName()+"=====>>>>>使用fixedDelay  {}"+(System.currentTimeMillis()/1000));
    }

    /**第一次延迟2秒后执行,之后按fixedDelay的规则每5秒执行一次*/
    @Scheduled(initialDelay = 2000, fixedDelay = 5000)
    public void run4(){
        System.out.println(Thread.currentThread().getName()+"=====>>>>>使用initialDelay  {}"+(System.currentTimeMillis()/1000));
    }

}

3. 支持多线程的配置

在传统的Spring项目中,我们可以在xml配置文件添加task的配置,而在SpringBoot项目中一般使用config配置类的方式添加配置,所以新建一个AsyncConfig类。

package com.yyg.boot.task;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

/**
 * @Author 一一哥Sun
 * @Date Created in 2020/5/17
 * @Description Description
 * 注解@Configuration:表明该类是一个配置类;
 * 注解@EnableAsync:开启异步事件的支持,然后在定时任务的类或者方法上添加@Async,最后重启项目,每一个任务都是在不同的线程中.
 */
@Configuration
@EnableAsync
public class AsyncConfig {

    /*
    * 此处成员变量应该使用@Value从配置中读取
    */
    private int corePoolSize = 10;
    private int maxPoolSize = 200;
    private int queueCapacity = 10;

    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(corePoolSize);
        executor.setMaxPoolSize(maxPoolSize);
        executor.setQueueCapacity(queueCapacity);
        executor.initialize();
        return executor;
    }

}

执行时间的配置

在上面的定时任务中,我们在方法上使用@Scheduled注解来设置任务的执行时间,并且使用三种属性配置方式:

  • fixedRate:定义一个按一定频率执行的定时任务。

  • fixedDelay:定义一个按一定频率执行的定时任务,与上面不同的是,改属性可以配合。

  • initialDelay,定义该任务延迟执行时间。

  • cron:通过表达式来配置任务执行时间。

4. 启动测试

配置完上面两个类之后,定时任务就会自动执行,效果如下:

六. 实现方式四:Quartz

1. Quartz简介

虽然ScheduledExecutorService对Timer进行了线程池的改进,但是依然无法满足复杂的定时任务调度场景。因此OpenSymphony提供了强大的开源任务调度框架:Quartz。Quartz是纯Java实现,而且作为Spring的默认调度框架,由于Quartz的强大的调度功能、灵活的使用方式、还具有分布式集群能力,可以说Quartz出马,可以搞定一切定时任务调度!

2. Quartz的体系结构

  • Quartz有3个核心概念:调度器(Scheduler)、任务(Job&JobDetail)、触发器(Trigger)。一个任务可以被多个触发器触发,一个触发器只能触发一个任务。

  • 注意当Scheduler调度Job时,实际上会通过反射newInstance一个新的Job实例(待调度完毕后销毁掉),同时会把JobExecutionContext传递给Job的execute方法,Job实例通过JobExecutionContext访问到Quartz运行时的环境以及Job本身的明细数据。

  • JobDataMap可以装载任何可以序列化的数据,存取很方便。需要注意的是JobDetail和Trigger都可以各自关联上JobDataMap。JobDataMap除了可以通过上述代码获取外,还可以在YourJob实现类中,添加相应setter方法获取。

  • Trigger用来告诉Quartz调度程序什么时候执行,常用的触发器有2种:SimpleTrigger(类似于Timer)、CronTrigger(类似于Linux的Crontab)。

  • 实际上,Quartz在进行调度器初始化的时候,会加载quartz.properties文件进行一些属性的设置,比如Quartz后台线程池的属性(threadCount)、作业存储设置等。它会先从工程中找,如果找不到那么就是用quartz.jar中的默认的quartz.properties文件。

  • Quartz存在监听器的概念,比如任务执行前后、任务的添加等,可以方便实现任务的监控。

2.添加依赖包

如果SpringBoot版本是2.0.0以后的,则在spring-boot-starter中已经包含了quart的依赖,则可以直接使用spring-boot-starter-quartz依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

如果是1.5.x则要使用以下添加依赖:

<dependency>
  <groupId>org.quartz-scheduler</groupId>
  <artifactId>quartz</artifactId>
  <version>2.3.0</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-support</artifactId>
</dependency>

2. 创建任务类

该类主要是继承了QuartzJobBean。

package com.yyg.boot.quartz;

import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.scheduling.quartz.QuartzJobBean;

import java.util.Date;

/**
 * @Author 一一哥Sun
 * @Date Created in 2020/5/17
 * @Description Description
 */
public class TaskQuartz extends QuartzJobBean {

    /**
     * 执行定时任务
     * @param jobExecutionContext
     * @throws JobExecutionException
     */
    @Override
    protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        System.out.println("quartz task "+new Date());
    }

}

3. 创建配置类QuartzConfig

package com.yyg.boot.quartz;

import org.quartz.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @Author 一一哥Sun
 * @Date Created in 2020/5/17
 * @Description Description
 */
@Configuration
public class QuartzConfig {

    @Bean
    public JobDetail teatQuartzDetail(){
        return JobBuilder.newJob(TaskQuartz.class)
                .withIdentity("taskQuartz")
                .storeDurably()
                .build();
    }

    @Bean
    public Trigger testQuartzTrigger(){
        SimpleScheduleBuilder scheduleBuilder = SimpleScheduleBuilder.simpleSchedule()
                //设置时间周期单位秒
                .withIntervalInSeconds(10)
                .repeatForever();

        return TriggerBuilder.newTrigger().forJob(teatQuartzDetail())
                .withIdentity("taskQuartz")
                .withSchedule(scheduleBuilder)
                .build();
    }

}

4. 启动测试

配置完上面两个类之后,定时任务就会自动执行,效果如下:




 

一一哥Sun CSDN认证博客专家 Elasticsearch Java 架构
十年软件开发授课经验,专注大学生毕业设计及面试求职私塾式指导!对Android/JavaWeb/微服务/H5等技术领域有深入研究!曾任国内最大的智能物联网平台架构师,参与国内最大的智能物流系统平台天眼/冷链/订单/车次系统研发;曾主导国内首款国学教育App及后台架构;负责大庆油田采油管理系统,铝业云计算系统,湖南广电地面波机顶盒等项目的研发。目前担任某著名培训机构教学主管,培养了大批入职阿里,IBM,百度,国家电网等名企的高薪学生.本人坚持只有一心做教育,才能做一流教育的理念,被学生爱称为"一一哥"!QQ:2312119590,今日头条号:一一哥Sun;知乎ID:一一哥
已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页