本文項目Github地址:https://github.com/zhouhuanghua/z-job
什麼是任務調度平臺呢?暫時不做解釋,先來看一下定時器的發展歷史吧!
首先,new Thread + while (true) + Thread.sleep的方式,雖然很low但是起碼能夠實現對吧——這種方式的問題是過於佔用資源,定時任務一多就暴露出來了。
然後,就是利用一些框架,比如JDK提供的定時器工具Timer和線程池ScheduledThreadPoolExecutor,Netty中基於時間輪實現的HashedWheelTimer,還有大名鼎鼎的Quartz等,在代碼裏面寫死了執行計劃——顯然,這種方式的問題是管理困難,有哪些定時任務在跑,執行計劃是什麼,要想知道得去刨代碼。
接着,基於第二種方式,將執行計劃和任務開關寫到配置文件裏面,達到了統一維護的目的——但是,修改執行計劃、任務啓用關閉,需要修改配置文件然後重新啓動應用,相當麻煩(不過,對於一些小型項目這種方式已經夠用,畢竟容易上手且成本低)。
最後, 就是基於第二種方式,將定時任務以及相關的執行計劃、開關都保存到數據庫,然後提供一個可視化的界面,給用戶在線修改。實現方式就是,以某種標準在代碼裏面寫好業務代碼,當用戶操作時纔將它們加入到定時任務裏面,或者從定時任務裏移除,應用啓動時也會自動將數據庫中啓用狀態的定時任務註冊——這種方式已經很靈性了。
只是,伴隨着系統的流量快速增長,大型網站對高併發高可用的要求逐漸提高,於是衍生出了集羣這麼一個東西。何謂"集羣",比如一個Tomcat的最大併發是500,但是網站的QPS是1000,這誰頂得住啊?於是需要再加一個Tomcat,通過負載均衡將流量分擔,這兩個Tomcat就形成了集羣。注意與系統拆分的區別:一組集羣裏面的每個機器跑的應用是一樣的。
那麼,問題來了:假如我的應用裏有定時任務,那豈不是集羣裏面每臺機器都在跑,這肯定不行的!怎麼解決呢?通過單獨設置機器上面應用的配置,只讓其中一臺跑。這,當然也可以,不過相當麻煩,而且負載均衡下管理界面怎麼解決(聰明的你們也許有辦法)。還有另外一個,微服務:總不能每個子應用設置一個管理界面吧?
扯了一大推,好像有點廢話了。那,什麼是任務調度平臺呢?
任務——調度——平臺:通過一個平臺,對所有機器上面的定時任務進行統一管理。不懂的話看圖
(暫不提風靡全國、功能強大的輕量級分佈式任務調度平臺xxl-job了哈,只說我發明的輪子。手動滑稽)
其實,還是有必要說明一下滴:首先,需要在具體應用上面配置任務調度平臺的地址信息和自己的信息,當應用啓動時,將自己的應用名稱、IP及端口發送給任務調度平臺,後者保存到數據庫。然後在任務調度平臺手動添加任務信息,需要選擇所屬的應用並填寫任務名稱(具體應用裏面實現了IJobHandler的類並注入到Spring容器後的beanName)、執行計劃的CRON、告警郵件等信息。接着任務調度平臺會根據這個任務的名稱、所屬應用名稱、執行計劃創建一個定時作業(Quartz的Job),並將任務信息以及它的應用信息傳遞進去。最後任務調度平臺的定時作業執行的時候,會拿出傳遞進來的任務信息和它的應用信息,根據應用信息的機器地址發送HTTP請求,請求參數爲任務名稱以及運行參數,具體的應用收到請求後執行對應的JobHandler並將結果返回,任務調度平臺收到結果(如果返回執行失敗或者異常,並且任務信息配了嘗試次數、應用信息裏還有其它機器地址的話,就會進行重試,始終不成功的話發送告警郵件)後,將這一個過程寫入任務調度記錄裏面。
要不"鎮樓圖"先來一波!?
---首頁
---應用列表
---任務列表。還有更多信息沒展示,後期考慮做個詳情頁面
---任務調度日誌。調度結果或者任務執行結果爲失敗時,鼠標放到提示的位置會展示失敗原因
---發送的告警郵件。內容是任務調度的記錄詳情
下面我們就結合代碼看一下開發步驟吧(基於Spring Boot的哦)
一、項目結構
一共分爲三個模塊(這裏是爲了方便開發,實際應用時需要分成不同的項目)。
- z-job-core:核心模塊。
- z-job-admin:任務調度平臺,依賴z-job-core模塊。
- z-job-example:使用z-job的一個示例應用,依賴z-job-core模塊即可。
下面我們就逐一模塊進行講解。
二、z-job-core模塊
這個模塊是做什麼的呢?你要使用一個框架,需要引入它的一些依賴,按照它的一些標準開發代碼吧?z-job-core就是充當這麼一個角色。它的作用有:
- 提供一個註解,達到Spring Boot自動配置、信息(當前應用的名稱和描述、z-job-admin平臺的IP和端口)寫在屬性上面的目的。
- 提供開發定時任務時需要遵守的標準,即實現給定的接口。
- 統一維護當前應用的所有定時任務。
- 與z-job-admin平臺的交互(重點):即應用啓動時將地址信息註冊到z-job-admin平臺、接收來自z-job-admin平臺的任務調用請求並返回任務執行結果。
1、約定每個任務需要遵守的標準
我們定義標準的是接口,因爲這樣可以確定方法的簽名信息。此外,還需要這個類使用@Component註解將自己注入Spring容器,方便後續收集定時任務實例,並以beanName作爲任務名稱(自帶唯一標識功能)。
- 入參:z-job-admin平臺發送過來的任務執行參數。
- 返回:JobInvokeRsp,包含int類型的執行是否成功(code)和String類型的說明(msg)。
/**
* 任務處理器接口
*
* @author z_hh
*/
public interface IJobHandler {
JobInvokeRsp execute(String params) throws Exception;
}
2、加載配置信息
爲了方便和避免用戶忘記,我們定義一個所有屬性都非空的註解,用在Spring Boot啓動類上(而且,僅當該註解存在時,纔會開啓z-job並加載相關數據)。
- adminIp:z-job-admin平臺的IP地址
- adminPort:z-job-admin平臺的端口
- appName:當前應用的名稱,在z-job-admin平臺裏面表示此應用的唯一標識
- appDesc:當前應用的描述信息
爲了達到應用的啓動類存在EnableJobAutoConfiguration註解時才加載z-job的目的, 所以用Import註解的方式將兩個類註冊到Spring容器。
/**
* 開啓任務自動配置
*
* @author z_hh
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
@Documented
@Import({JobAutoConfigurationRegistrar.class, JobConfiguration.class})
public @interface EnableJobAutoConfiguration {
String adminIp();
int adminPort();
String appName();
String appDesc();
}
1)@EnableJobAutoConfiguration第一個Import的是JobAutoConfigurationRegistrar:該類用於讀取註解上面的配置信息,並手動註冊兩個bean到Spring容器。這兩個bean是
- JobExecutor:這個類的作用是,應用啓動時將地址信息註冊到z-job-admin平臺、統一維護應用的所有定時任務實例、運行指定定時任務。註冊的同時將註解類的配置信息設置進去,並且執行init方法初始化。
- JobInvokeServletRegistrar:這個類實際上是一個Servlet,用於接收來自z-job-admin平臺的調用任務請求,並且返回任務執行結果。註冊時使用newInstance工廠方法,並將jobExecutor這個bean注入。(爲啥不直接定義一個Controller?因爲控制不了當@EnableJobAutoConfiguration存在時才註冊到Spring容器的目的,條件註解都沒有用)。
/**
* ImportBeanDefinitionRegistrar
*
* @author z_hh
*/
@Slf4j
public class JobAutoConfigurationRegistrar implements ImportBeanDefinitionRegistrar, EnvironmentAware {
@Setter
private Environment environment;
@Override
public void registerBeanDefinitions(AnnotationMetadata annotationMetadata, BeanDefinitionRegistry beanDefinitionRegistry) {
AnnotationAttributes annotationAttributes = AnnotationAttributes
.fromMap(annotationMetadata.getAnnotationAttributes(EnableJobAutoConfiguration.class.getName()));
if (Objects.isNull(annotationAttributes)) {
log.error("【任務調度平臺】EnableJobAutoConfiguration annotationAttributes is null!");
return;
}
// 註冊JobExecutor
registerJobExecutor(annotationAttributes, beanDefinitionRegistry);
// 註冊Servlet
registerJobInvokeServletRegistrationBean(beanDefinitionRegistry);
}
private void registerJobExecutor(AnnotationAttributes annotationAttributes, BeanDefinitionRegistry beanDefinitionRegistry) {
// 創建配置實例
JobProperties jobProperties = new JobProperties();
jobProperties.setAdminIp(annotationAttributes.getString("adminIp"));
jobProperties.setAdminPort(annotationAttributes.getNumber("adminPort"));
jobProperties.setAppName(annotationAttributes.getString("appName"));
jobProperties.setAppDesc(annotationAttributes.getString("appDesc"));
jobProperties.setIp(NetUtil.getIp());
jobProperties.setPort(environment.getProperty("server.port", Integer.class, 8080));
AbstractBeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(JobExecutor.class)
.setInitMethodName("init")
.setDestroyMethodName("destroy")
.addPropertyValue("jobProperties", jobProperties)
.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE)
.getBeanDefinition();
beanDefinitionRegistry.registerBeanDefinition("jobExecutor", beanDefinition);
log.info("【任務調度平臺】JobExecutor register success!");
}
private void registerJobInvokeServletRegistrationBean(BeanDefinitionRegistry beanDefinitionRegistry) {
AbstractBeanDefinition beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(JobInvokeServletRegistrar.class)
.setFactoryMethod("newInstance")
.addPropertyReference("jobExecutor", "jobExecutor")
.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_NO)
.getBeanDefinition();
beanDefinitionRegistry.registerBeanDefinition("JobInvokeServlet", beanDefinition);
log.info("【任務調度平臺】JobInvokeServletRegistrar register success!");
}
}
JobExecutor類的代碼如下,init方法會做兩件事
- 初始化所有JobHandler:將Spring容器裏面所有實現了IJobHandler接口的bean取出來,然後放到一個Map裏面,並以beanName爲鍵。
- 將自己註冊到調度中心:新起一個線程,使用注入的restTemplate,根據註解配置的z-job-admin平臺地址信息,將當前應用信息(應用名稱和IP端口等)發送到任務調度平臺進行應用的自動註冊。那邊有專門的接口會做靈活處理(針對集羣現象),後面會說。
/**
* 任務執行器
*
* @author z_hh
*/
@Slf4j
public class JobExecutor {
@Setter
private JobProperties jobProperties;
@Autowired
private RestTemplate restTemplate;
@Autowired
private ApplicationContext applicationContext;
private ConcurrentHashMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap();
public IJobHandler registJobHandler(String name, IJobHandler jobHandler) {
log.info("【任務調度平臺】成功註冊JobHandler >>>>>>>>>> name={},handler={}", name, jobHandler.getClass().getName());
return (IJobHandler)jobHandlerRepository.put(name, jobHandler);
}
public IJobHandler loadJobHandler(String name) {
return (IJobHandler)jobHandlerRepository.get(name);
}
public void init() {
log.info("【任務調度平臺】JobExecutor init...");
// 初始化所有JobHandler
initJobHandler();
// 將自己註冊到調度中心
new RegisterAppToAdminThread().start();
}
public void destroy() {
log.info("【任務調度平臺】JobExecutor destroy...");
}
public JobInvokeRsp jobInvoke(String name, String params) {
IJobHandler jobHandler = jobHandlerRepository.get(name);
if (Objects.isNull(jobHandler)) {
return JobInvokeRsp.error("任務不存在!");
}
try {
return jobHandler.execute(params);
} catch (Exception e) {
log.error("【任務調度平臺】任務{}調用異常:{}", name, e);
return JobInvokeRsp.error("任務調用異常!");
}
}
private void initJobHandler() {
String[] beanNames = applicationContext.getBeanNamesForType(IJobHandler.class);
if (beanNames == null || beanNames.length == 0) {
return;
}
Arrays.stream(beanNames).forEach(beanName -> {
registJobHandler(beanName, (IJobHandler)applicationContext.getBean(beanName));
});
}
private class RegisterAppToAdminThread extends Thread {
private RegisterAppToAdminThread() {
super("AppToAdmin-T");
}
@Override
public void run() {
log.info("【任務調度平臺】開始往調度中心註冊當前應用信息...");
Map<String, Object> paramMap = new HashMap<>(4);
paramMap.put("appName", jobProperties.getAppName());
paramMap.put("appDesc", jobProperties.getAppDesc());
paramMap.put("address", jobProperties.getIp() + ":" + jobProperties.getPort());
try {
restTemplate.postForObject("http://" + jobProperties.getAdminIp() + ":"
+ jobProperties.getAdminPort() + "/api/job/app/auto_register",
paramMap,
Object.class);
log.info("【任務調度平臺】應用註冊到調度中心成功!");
} catch (Throwable t) {
log.warn("【任務調度平臺】應用註冊到調度中心失敗:{}", ThrowableUtils.getThrowableStackTrace(t));
}
}
}
}
JobInvokeServletRegistrar類的代碼如下,繼承ServletRegistrationBean類並提供newInstance的工廠方法用於創建Servlet實例注入到Spring容器。它將接收來自z-job-admin平臺的任務調用請求,並根據參數的任務名稱和執行參數利用JobExecutor去調用對應的定時任務執行,最後將結果寫入相應。
/**
* 任務調度Servlet
*
* @author z_hh
*/
@Slf4j
public class JobInvokeServletRegistrar<JobInvokeServlet> extends ServletRegistrationBean {
@Setter
private JobExecutor jobExecutor;
public JobInvokeServletRegistrar() {
super();
}
public static JobInvokeServletRegistrar newInstance() {
JobInvokeServletRegistrar jobInvokeServletRegistrar = new JobInvokeServletRegistrar();
jobInvokeServletRegistrar.setServlet(jobInvokeServletRegistrar.new JobInvokeServlet());
jobInvokeServletRegistrar.setUrlMappings(Collections.singletonList("/api/job/invoke"));
jobInvokeServletRegistrar.setLoadOnStartup(1);
return jobInvokeServletRegistrar;
}
private class JobInvokeServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setHeader("Content-Type", "application/json");
try {
Pair reqAndRsp = run(req, resp);
log.info("【任務調度平臺】執行作業:req={},rsp={}", reqAndRsp.getKey(), reqAndRsp.getValue());
} catch (Throwable t) {
String msg = ThrowableUtils.getThrowableStackTrace(t);
log.warn("任務調用異常:{}", msg);
String rspStr = JsonUtils.writeValueAsString(JobInvokeRsp.error(msg));
resp.getOutputStream().write(rspStr.getBytes(Charset.defaultCharset()));
}
}
private Pair run(HttpServletRequest req, HttpServletResponse resp) throws Throwable {
// 反序列化
ServletInputStream inputStream = req.getInputStream();
byte[] body = new byte[req.getContentLength()];
inputStream.read(body);
JobInvokeReq jobInvokeReq = (JobInvokeReq)SerializationUtils.deserialize(body);
// 調用任務
JobInvokeRsp jobInvokeRsp = JobInvokeServletRegistrar.this.jobExecutor.jobInvoke(jobInvokeReq.getName(), jobInvokeReq.getParams());
// 響應結果
String rspStr = JsonUtils.writeValueAsString(jobInvokeRsp);
resp.getOutputStream().write(rspStr.getBytes(Charset.defaultCharset()));
// 返回請求和響應提供日誌記錄
return new Pair(jobInvokeReq, jobInvokeRsp);
}
}
}
2)@EnableJobAutoConfiguration第二個Import的是JobConfiguration:它目前只有一個作用,當Spring容器不存在RestTemplate實例時,就創建一個註冊進去。
/**
* 任務配置類
*
* @author z_hh
*/
@Slf4j
public class JobConfiguration {
{
log.info("【任務調度平臺】Loading JobAutoConfiguration!");
}
@Bean
@ConditionalOnMissingBean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
此外,前面沒說到的類還有幾個,其中獲取當前機器IP地址的工具代碼如下
/**
* 網絡工具類
*
* @author z_hh
*/
@Slf4j
public class NetUtil {
private NetUtil() {}
public static String getIp() {
try {
return InetAddress.getLocalHost().getHostAddress();
} catch (UnknownHostException e) {
log.error("獲取IP地址異常:", ThrowableUtils.getThrowableStackTrace(e));
throw new RuntimeException("獲取IP地址異常!");
}
}
}
其它的話,可以去看對應的源碼。
三、z-job-admin模塊
1、表結構,以及對應的增刪改查
表定義了3張,開始曾經想過:應用的信息能否直接放在任務裏面,後來覺得實在不妥,不太方便管理(參考過xxl-job的表哈,它們的也是這樣的,嘻嘻)。所以,3張表就是:任務應用表、任務信息表、任務調度記錄表。
1)任務應用表
CREATE TABLE `z_job_app` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`app_name` varchar(64) NOT NULL COMMENT '應用名稱',
`app_desc` varchar(128) NOT NULL COMMENT '應用描述',
`creator` varchar(32) NOT NULL COMMENT '創建人',
`create_time` datetime NOT NULL COMMENT '創建時間',
`create_way` tinyint(1) NOT NULL COMMENT '創建方式:1-自動,2-手工',
`update_time` datetime DEFAULT NULL COMMENT '最後更新時間',
`address_list` varchar(512) NOT NULL COMMENT '應用地址列表,多個逗號分隔',
`enabled` tinyint(1) NOT NULL COMMENT '啓用狀態:1-啓用,0-停用',
`is_deleted` tinyint(1) NOT NULL COMMENT '是否刪除:1-是,0-否',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_app_name` (`app_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2)任務信息表
CREATE TABLE `z_job_info` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`job_app_id` int(11) NOT NULL COMMENT '任務所屬應用id',
`job_name` varchar(64) NOT NULL COMMENT '任務名稱',
`job_desc` varchar(512) DEFAULT '' COMMENT '任務描述',
`alarm_email` varchar(512) DEFAULT '' COMMENT '報警郵件,多個逗號分隔',
`creator` varchar(32) NOT NULL COMMENT '創建人',
`create_time` datetime NOT NULL COMMENT '創建時間',
`create_way` tinyint(1) NOT NULL COMMENT '創建方式:1-自動,2-手工',
`update_time` datetime DEFAULT NULL COMMENT '最後更新時間',
`run_cron` varchar(128) NOT NULL COMMENT '任務執行CRON',
`run_strategy` tinyint(1) NOT NULL COMMENT '任務執行策略:1-隨機,2-輪詢',
`run_param` varchar(512) DEFAULT '' COMMENT '任務執行參數',
`run_timeout` smallint(3) NOT NULL COMMENT '任務執行超時時間,單位秒',
`run_fail_retry_count` smallint(3) NOT NULL COMMENT '任務執行失敗重試次數',
`trigger_last_time` datetime DEFAULT NULL COMMENT '上次調度時間',
`trigger_next_time` datetime DEFAULT NULL COMMENT '下次調度時間',
`enabled` tinyint(1) NOT NULL COMMENT '啓用狀態:1-啓用,0-停用',
`is_deleted` tinyint(1) NOT NULL COMMENT '是否刪除:1-是,0-否',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name_appid` (`job_name`,`job_app_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3)任務調度記錄表
CREATE TABLE `z_job_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`job_id` int(11) NOT NULL COMMENT '任務ID',
`run_address_list` varchar(512) NOT NULL COMMENT '本次運行的地址',
`run_fail_retry_count` smallint(3) NOT NULL COMMENT '任務執行失敗重試次數',
`trigger_start_time` datetime NOT NULL COMMENT '調度開始時間',
`trigger_end_time` datetime NOT NULL COMMENT '調度結束時間',
`trigger_result` tinyint(1) NOT NULL COMMENT '調度結果:1-成功,0-失敗',
`trigger_msg` varchar(3000) DEFAULT '' COMMENT '調度日誌',
`job_run_result` tinyint(1) DEFAULT '0' COMMENT '任務執行結果:1-成功,0-失敗',
`job_run_msg` varchar(3000) DEFAULT '' COMMENT '任務執行日誌',
PRIMARY KEY (`id`),
KEY `idx_job_id` (`job_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
4)增刪改查
使用spring-data-jpa,因爲簡單。該技術不在本文討論範圍內,你也可以使用Mybatis、甚至JDBC。
2、任務應用的自動註冊
前面z-job-core模塊說過,應用啓動時會將自己的信息註冊到任務調度平臺,這邊提供接口,並且會靈活處理。怎麼個靈活法呢?君請看,會判斷應用傳過來的應用名稱和地址信息
- 應用名稱不存在,直接插入。
- 應用名稱存在,手動新增的話提示重複,否則:如果地址也存在,忽略;如果地址不存在,合併地址。
PS:Controller層面需要加一個獨佔鎖保證併發時線程安全。
public Result<JobApp> insert(JobApp jobApp) {
if (Objects.nonNull(jobApp.getId())) {
jobApp.setId(null);
}
// 根據appName查詢數據
JobApp exampleObj = new JobApp();
exampleObj.setAppName(jobApp.getAppName());
exampleObj.setIsDeleted(IsDeletedEnum.NO.getCode());
Example<JobApp> example = Example.of(exampleObj);
Optional<JobApp> JobAppOptional = dao.findOne(example);
// 如果不存在,直接保存
if (!JobAppOptional.isPresent()) {
return Result.ok(save(jobApp));
}
// 如果是手動添加,提示重複
if (Objects.equals(jobApp.getCreateWay(), CreateWayEnum.MANUAL.getCode())) {
return Result.err("應用名稱已存在!");
}
// 比較地址
JobApp existsJobApp = JobAppOptional.get();
List<String> addressList = Arrays.stream(existsJobApp.getAddressList().split(","))
.filter(StringUtils::hasText)
.collect(Collectors.toList());
// 存在,但是地址已經包含,忽略
if (addressList.contains(jobApp.getAddressList())) {
return Result.ok(existsJobApp);
}
// 存在,但是地址還沒包含,合併地址
addressList.add(jobApp.getAddressList());
String newAddressList = addressList.stream().reduce((s1, s2) -> s1 + "," + s2).orElse("");
existsJobApp.setAddressList(newAddressList);
return Result.ok(save(existsJobApp));
}
3、Quartz Job的註冊以及移除(重點)
1)開始介紹z-job任務調度平臺架構的時候說過,使用Quartz作爲定時器的。在Spring Boot下怎麼使用呢?
引入依賴並註冊一個Scheduler的bean到Spring容器之後,就可以在需要的地方注入並使用了。
<!-- 作業調度框架Quartz -->
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>${quartz-scheduler-version}</version>
</dependency>
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz-jobs</artifactId>
<version>${quartz-scheduler-version}</version>
</dependency>
@Bean
public Scheduler scheduler() throws SchedulerException {
return StdSchedulerFactory.getDefaultScheduler();
}
2)新增任務或者將任務從停用改爲啓用時都需要註冊一個定時作業。除此之外,任務調度平臺啓動的時候也需要將啓用狀態的任務進行註冊。當任務停用的時候,需要將對應的定時作業移除。
定時作業:所有的Quartz Job都使用一個類相同的邏輯,執行的時候根據傳遞進來的任務信息和它的應用信息,通過JobInvoker調度器發起遠程調用,如果失敗了會根據任務的重試機制進行對應的處理,終究沒成功的話會發送告警郵件,最後將整個調度過程插入到任務調度記錄裏,並修改任務的下一次調度時間。
/**
* Quartz任務
*
* @author z_hh
*/
@Slf4j
public class QuartzJob implements Job {
private static final byte SUCCESS = 1;
private static final byte ERROR = 0;
private JobInvoker jobInvoker;
private MailSendService mailSendService;
public QuartzJob() {
this.jobInvoker = BeanUtils.getBean(JobInvoker.class);
this.mailSendService = BeanUtils.getBean(MailSendService.class);
}
@Override
public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
JobDataMap jobDataMap = jobExecutionContext.getJobDetail().getJobDataMap();
JobApp jobApp = (JobApp) jobDataMap.get("JobApp");
JobInfo jobInfo = (JobInfo) jobDataMap.get("jobInfo");
log.info("任務{}開始調度...", jobInfo.getJobName());
JobLog jobLog = new JobLog();
jobLog.setJobId(jobInfo.getId());
jobLog.setTriggerStartTime(new Date());
try {
jobRun(jobApp, jobInfo, jobLog);
jobLog.setTriggerResult((byte)1);
jobLog.setTriggerMsg("調度成功!");
} catch (Throwable t) {
String msg = ThrowableUtils.getThrowableStackTrace(t);
log.warn("任務{}調度出現異常:{}", jobInfo.getJobName(), msg);
jobLog.setTriggerResult((byte)0);
jobLog.setTriggerMsg("調度異常:" + msg);
}
jobLog.setTriggerEndTime(new Date());
// 記錄任務的本次和下次調用時間
jobInfo.setTriggerLastTime(jobExecutionContext.getFireTime());
jobInfo.setTriggerNextTime(jobExecutionContext.getNextFireTime());
// 插入日誌
jobLog.save();
// 更新任務
jobInfo.save();
// 發送郵件
sendMail(jobApp, jobInfo, jobLog);
log.info("任務{}調度結束!", jobInfo.getJobName());
}
private void jobRun(JobApp jobApp, JobInfo jobInfo, JobLog jobLog) {
List<String> addressList = Arrays.stream(jobApp.getAddressList().split(","))
.filter(StringUtils::hasText)
.collect(Collectors.toList());
Iterator<String> iterator = addressList.iterator();
JobInvokeRsp jobInvokeRsp = null;
List<String> hasInvokeAddress = new ArrayList<>();
int failRetryCount = jobInfo.getRunFailRetryCount(),
readyRetryCount = -1;
while (iterator.hasNext() && ++readyRetryCount <= failRetryCount) {
String address = iterator.next();
hasInvokeAddress.add(address);
try {
jobInvokeRsp = jobInvoker.invoke(address, jobInfo.getJobName(), jobInfo.getRunParam());
if (jobInvokeRsp.isOk()) {
break;
}
log.warn("調用{}的{}任務失敗:{}", address, jobInfo.getJobName(), jobInvokeRsp.getMsg());
} catch (Throwable t) {
String msg = ThrowableUtils.getThrowableStackTrace(t);
log.warn("調用{}的{}任務時出現異常:{}", address, jobInfo.getJobName(), msg);
jobInvokeRsp = JobInvokeRsp.error("任務調用異常:" + msg);
}
iterator.remove();
}
if (Objects.isNull(jobInvokeRsp)) {
jobInvokeRsp = JobInvokeRsp.error("沒有進行任務調用!");
}
jobLog.setJobRunResult(jobInvokeRsp.getCode());
jobLog.setJobRunMsg(sub3000String(jobInvokeRsp.getMsg()));
jobLog.setRunFailRetryCount(Objects.equals(readyRetryCount, -1) ? 0 : readyRetryCount);
jobLog.setRunAddressList(hasInvokeAddress.stream().reduce((s1, s2) -> s1 + "," + s2).orElse(""));
}
private void sendMail(JobApp jobApp, JobInfo jobInfo, JobLog jobLog) {
// 調度失敗並且郵件不爲空
if ((Objects.equals(jobLog.getTriggerResult(), (byte)0)
|| Objects.equals(jobLog.getJobRunResult(), (byte)0))
&& StringUtils.hasText(jobInfo.getAlarmEmail())) {
String alarmEmailStr = jobInfo.getAlarmEmail();
List<String> mailList = null;
if (alarmEmailStr.contains(",")) {
mailList = Arrays.asList(alarmEmailStr.split(","));
} else {
mailList = Collections.singletonList(alarmEmailStr);
}
String subject = String.format("應用%s的%s任務調度失敗!", jobApp.getAppName(), jobInfo.getJobName());
try {
String content = new ObjectMapper().writeValueAsString(jobLog);
mailList.forEach(m -> {
mailSendService.sendSimpleMail(m, subject, content);
});
} catch (JsonProcessingException e) {
log.error("任務調度失敗告警郵件發送失敗:{}", ThrowableUtils.getThrowableStackTrace(e));
}
}
}
private String sub3000String(String str) {
if (StringUtils.hasText(str) && str.length() > 3000) {
return str.substring(0, 2888) + "...更多請查看日誌記錄!";
}
return str;
}
}
任務調度器:使用RestTemplate將任務名稱和執行參數發送給具體的應用。
/**
* 任務調度器
*
* @author z_hh
*/
@Component
public class JobInvoker {
@Autowired
private RestTemplate restTemplate;
private static final String PREFIX = "http://";
private static final String PATH = "/api/job/invoke";
/**
* 任務調度器
*
* @param url 目標地址
* @param jobHandler 任務名稱
* @param params 執行參數
* @return 調用任務結果
*/
public JobInvokeRsp invoke(String url, String jobHandler, String params) {
JobInvokeReq req = new JobInvokeReq();
req.setName(jobHandler);
req.setParams(params);
byte[] dataBytes = SerializationUtils.serialize(req);
return restTemplate.postForObject(PREFIX + url + PATH, dataBytes, JobInvokeRsp.class);
}
}
發送郵件:引入依賴,配置發送方郵箱,編寫發送服務。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
# 發送郵箱配置
mail:
# QQ郵箱主機
host: smtp.qq.com
# 用戶名
username: [email protected]
# QQ郵箱開啓SMTP的授權碼
password: 000xxx
/**
* 郵件發送服務
*
* @author z_hh
*/
@Component
public class MailSendService {
@Autowired
private JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String from;
/**
* 發送普通郵件
*
* @param to 收件人
* @param subject 郵件主題
* @param content 郵件內容
* @throws MailException
*/
public void sendSimpleMail(String to, String subject, String content) throws MailException {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(to);
message.setSubject(subject);
message.setText(content);
mailSender.send(message);
}
}
註冊定時作業:首先根據任務的名稱、描述以及所屬應用名稱使用QuartzJob類構建一個JobDetail實例,然後根據任務的執行計劃構建Trigger實例,接着將任務信息、應用信息傳遞到JobDetail實例的JobDataMap裏,最後使用scheduler註冊到作業調度中。
public Result register(JobInfo jobInfo) {
// 獲取任務組信息
Result<JobApp> JobAppResult = JobAppService.getById(jobInfo.getJobAppId());
if (JobAppResult.isErr()) {
return JobAppResult;
}
JobApp JobApp = JobAppResult.get();
// 創建jobDetail實例
JobDetail jobDetail = JobBuilder.newJob(QuartzJob.class)
.withIdentity(jobInfo.getJobName(), JobApp.getAppName())
.withDescription(jobInfo.getJobDesc())
.build();
// 定義調度觸發規則corn
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(jobInfo.getJobName(), JobApp.getAppName())
.startAt(DateBuilder.futureDate(1, DateBuilder.IntervalUnit.SECOND))
.withSchedule(CronScheduleBuilder.cronSchedule(jobInfo.getRunCron()))
.startNow()
.build();
// 傳遞一些數據到任務裏面
JobDataMap jobDataMap = jobDetail.getJobDataMap();
jobDataMap.put("JobApp", JobApp);
jobDataMap.put("jobInfo", jobInfo);
// 把作業和觸發器註冊到任務調度中
try {
scheduler.scheduleJob(jobDetail, trigger);
} catch (SchedulerException e) {
log.error("註冊任務異常:{}", ThrowableUtils.getThrowableStackTrace(e));
return Result.err("註冊任務異常!");
}
// 啓動
try {
if (!scheduler.isShutdown()) {
scheduler.start();
}
} catch (SchedulerException e) {
log.error("啓動scheduler異常:{}", ThrowableUtils.getThrowableStackTrace(e));
return Result.err("啓動scheduler異常!");
}
return Result.ok();
}
移除定時作業:很簡單,根據任務的名稱以及它的應用名稱組成一個JobKey,然後使用scheduler進行刪除即可。
public Result disable(Long id) {
// 查詢和校驗
Result<JobInfo> jobInfoResult = getById(id);
if (jobInfoResult.isErr()) {
return jobInfoResult;
}
JobInfo jobInfo = jobInfoResult.get();
if (Objects.equals(jobInfo.getEnabled(), EnabledEnum.NO.getCode())) {
return Result.err("任務已經處於停用狀態!");
}
// 查詢對應任務組
Result<JobApp> JobAppResult = JobAppService.getById(jobInfo.getJobAppId());
if (JobAppResult.isErr()) {
return JobAppResult;
}
// 從scheduler移除任務
JobApp jobApp = JobAppResult.get();
JobKey jobKey = JobKey.jobKey(jobInfo.getJobName(), jobApp.getAppName());
try {
boolean deleteJobResult = scheduler.deleteJob(jobKey);
if (!deleteJobResult) {
return Result.err("停用定時任務失敗!");
}
} catch (SchedulerException e) {
log.error("停用定時任務異常:{}", ThrowableUtils.getThrowableStackTrace(e));
return Result.err("停用定時任務異常!");
}
// 修改數據狀態
jobInfo.setEnabled(EnabledEnum.NO.getCode());
jobInfo.setUpdateTime(new Date());
save(jobInfo);
return Result.ok("停用定時任務成功!");
}
任務調度平臺啓動時將啓用的任務註冊到作業調度中:
/**
* 應用啓動後註冊定時任務
*
* @author z_hh
*/
@Component
@Slf4j
public class RegisterJobOnAppStart implements ApplicationListener<ApplicationReadyEvent> {
@Autowired
private JobInfoService jobInfoService;
@Override
public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) {
jobInfoService.queryAll().stream()
.filter(jobInfo ->
Objects.equals(jobInfo.getIsDeleted(), IsDeletedEnum.NO.getCode())
&& Objects.equals(jobInfo.getEnabled(), EnabledEnum.YES.getCode())
)
.forEach(jobInfo -> {
Result registerResult = jobInfoService.register(jobInfo);
if (registerResult.isErr()) {
log.error("任務[id={},jobName={}]註冊失敗:{}", jobInfo.getId(), jobInfo.getJobName(), registerResult.getMsg());
}
log.info("任務[id={},jobName={}]註冊成功!", jobInfo.getId(), jobInfo.getJobName());
});
}
}
好像沒有特別重要的了,其它的話找源碼看一下吧?
四、z-job-example模塊
一切就緒之後,就可以編寫一個示例爽一把了,這是最開心的時刻。
1、引入z-job-core的依賴
<dependency>
<groupId>cn.zhh</groupId>
<artifactId>z-job-core</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
2、啓動類加上EnableJobAutoConfiguration註解並配置任務調度平臺的IP和端口,以及當前應用的名稱和描述
@EnableJobAutoConfiguration(adminIp = "127.0.0.1",
adminPort = 8888,
appName = "example",
appDesc = "示例應用")
3、編寫一個定時任務類,實現IJobHandler接口並重寫execute方法,加上Component註解
/**
* 示例任務1
*
* @author z_hh
*/
@Component
public class JobExample1 implements IJobHandler {
@Override
public JobInvokeRsp execute(String params) throws Exception {
return JobInvokeRsp.success("我執行成功啦!收到參數:" + params);
}
}
4、首先啓動z-job-admin項目,然後啓動項目,看到控制檯輸出。應用自動註冊到任務調度平臺
5、任務調度平臺添加對應的任務信息。默認啓用狀態,它會自動註冊到Quartz作業調度中
6、時間到了之後會執行任務調度
7、查看調度日誌
至此,z-job整個核心流程已經開發完成了。
五、前端技術
前端使用的主要框架有Bootstrap、JQuery以及Vue。不是我擅長的領域,你們看一下代碼就好。
六、項目涉及的其它技術
除了介紹核心代碼以外,還有一些個人覺得是亮點的分享一下。
1、定義通用結果返回對象,並使用Aspect切面處理帶來的事務問題。
一般是Controller層使用的。如果是Service層使用,就要考慮事務回滾的問題(因爲Spring的Transactional是默認拋出RuntimeException時才觸發事務回滾的),一般推薦拋出自定義業務異常並結合統一異常處理器進行轉化處理。
/**
* 通用結果返回對象
*
* @author z_hh
*/
@ToString
public class Result<T> implements Serializable {
private static final long serialVersionUID = 6547662806723050209L;
private static final int SUCCESS = 200;
private static final int ERROR = 500;
@Getter
private Integer code;
@Getter
private String msg;
@Getter
private T content;
private Result(Integer code, String msg, T content) {
this.code = code;
this.msg = msg;
this.content = content;
}
public static <T> Result<T> ok() {
return new Result<>(SUCCESS, null, (T)null);
}
public static <T> Result<T> ok(String msg) {
return new Result<>(SUCCESS, msg, (T)null);
}
public static <T> Result<T> ok(T content) {
return new Result<>(SUCCESS, null, content);
}
public static <T> Result<T> ok(String msg, T content) {
return new Result<>(SUCCESS, msg, content);
}
public static <T> Result<T> err() {
return new Result<>(ERROR, null, (T)null);
}
public static <T> Result<T> err(String msg) {
return new Result<>(ERROR, msg, (T)null);
}
public boolean isOk() {
return Objects.equals(this.code, SUCCESS);
}
public boolean isErr() {
return Objects.equals(this.code, ERROR);
}
public T get() {
if (isErr()) {
throw new UnsupportedOperationException("result is error!");
}
return this.content;
}
}
如果Service層使用,並需要返回Result.ok() == false時進行事務回滾,那麼就需要結合Aspect切面進行手工回滾處理了。
/**
* 對Spring的事務註解@Transactional做進一步處理,
* 結合Service的返回值類型Result,做出是否啓動事務回滾
*
* @author z_hh
*/
@Aspect
@Component
public class TransactionalAspect {
@Around(value = "@annotation(org.springframework.transaction.annotation.Transactional)&&@annotation(transactional)")
public Object verify(ProceedingJoinPoint pjp, Transactional transactional) throws Throwable {
// 執行切面方法,獲得返回值
Object result = pjp.proceed();
// 檢測&強行回滾
boolean requireRollback = requireRollback(result);
if (requireRollback) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
// 返回切面方法運行結果
return result;
}
private static boolean requireRollback(Object result) throws Exception {
// 如果方法返回值不是Result對象,則不需要回滾
if (!(result instanceof Result)) {
return false;
}
// 如果result.isOk() == true,也不需要回滾
Result r = (Result) result;
if (!r.isOk()) {
return false;
}
// 如果@Transactional啓用了新事物(propagation = Propagation.REQUIRES_NEW),需要回滾
boolean isNewTransaction = TransactionAspectSupport.currentTransactionStatus().isNewTransaction();
if (isNewTransaction) {
return true;
}
// 如果方法沒有被其它@Transactional註釋的方法嵌套調用,說明該線程的事物已運行完畢,則需要回滾
// 此處使用了較多的反射底層語法,強行訪問Spring內部的private/protected 方法、字段,存在一定的風險
Object currentTransactionInfo = executePrivateStaticMethod(TransactionAspectSupport.class, "currentTransactionInfo"),
oldTransactionInfo = getPrivateFieldValue(currentTransactionInfo, "oldTransactionInfo");
if (oldTransactionInfo == null) {
return true;
}
// 其它情況,不回滾
return false;
}
private static Object getPrivateFieldValue(Object target, String fieldName) throws NoSuchFieldException, SecurityException, IllegalArgumentException, IllegalAccessException {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(target);
}
private static Object executePrivateStaticMethod(Class<?> targetClass, String methodName) throws NoSuchMethodException, SecurityException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
Method method = targetClass.getDeclaredMethod(methodName);
method.setAccessible(true);
return method.invoke(null);
}
}
2、Swagger2的正確使用姿勢
怎麼使用我就不說了,就講下怎麼樣可以讓Controller看起來舒服點。處理方式就是,每個Controller定義一個Api接口,將註解信息寫在接口上面。如
/**
* 任務信息API
*
* @author z_hh
*/
@Api(tags = "任務信息API")
public interface JobInfoApi {
@ApiOperation("分頁查詢任務")
@GetMapping("/page_query")
public Result<Page<JobInfoPageQueryRsp>> pageQuery(@RequestParam(required = false, defaultValue = "1") Integer pageNum,
@RequestParam(required = false, defaultValue = "10") Integer pageSize);
}
/**
* 任務信息控制器
*
* @author z_hh
*/
@RestController
@RequestMapping("/job/info")
@Slf4j
public class JobInfoController implements JobInfoApi {
@Autowired
private JobInfoService jobInfoService;
@Override
public Result<Page<JobInfoPageQueryRsp>> pageQuery(@RequestParam(required = false, defaultValue = "1") Integer pageNum,
@RequestParam(required = false, defaultValue = "10") Integer pageSize) {
Result<Page<JobInfoPageQueryRsp>> pageResult = jobInfoService.queryByPage(pageNum, pageSize);
return pageResult;
}
}
3、利用Aspect切面優雅對validation的請求參數進行校驗
如果是在Controller層進行校驗,那麼可以直接在方法的參數前面加@Valid註解,然後定義全局統一異常處理器處理MethodArgumentNotValidException即可。這裏是給假如要在Service層做校驗的提供一種思路。
我的相關博客:https://blog.csdn.net/qq_31142553/article/details/89430100、https://blog.csdn.net/qq_31142553/article/details/86547201、https://blog.csdn.net/qq_31142553/article/details/85645957
1)定義一個標記接口(什麼也沒有,類似Serializable),讓需要校驗的類實現。
/**
* 需要校驗的請求對象
*
* @author z_hh
*/
public interface ValidateReq {
}
/**
* 添加任務應用請求
*
* @author z_hh
*/
@ApiModel("添加任務應用請求")
@Data
public class JobAppAddReq implements ValidateReq {
@ApiModelProperty(value = "應用名稱", required = true)
@NotBlank(message = "應用名稱不能爲空")
private String appName;
}
2)定義一個Aspect切面,在控制層對這些請求對象進行校驗。
這裏可以使用@Around環繞切面,如果校驗不通過,直接返回錯誤的Result結果,那麼就沒有第三步了。
/**
* 校驗請求對象切面
*
* @author z_hh
*/
@Aspect
@Component
public class ValidateReqAspect {
private static final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Before("execution(* cn.zhh.admin.controller.*.*(..))")
public void validateReq(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
for(int i = 0, length = args.length; i < length; ++i) {
Object obj = args[i];
if (obj instanceof ValidateReq) {
validate(obj);
}
}
}
private <T> void validate(T t) {
// 校驗對象
Set<ConstraintViolation<T>> constraintViolations = validator.validate(t, new Class[0]);
// 存在校驗錯誤的話,拼接所有錯誤信息
if (constraintViolations.size() > 0) {
StringBuilder validateError = new StringBuilder();
ConstraintViolation constraintViolation;
for(Iterator iterator = constraintViolations.iterator(); iterator.hasNext(); validateError.append(constraintViolation.getMessage()).append(";")) {
constraintViolation = (ConstraintViolation)iterator.next();
}
// 拋出異常,統一異常處理器將會處理
throw new IllegalArgumentException(validateError.toString());
}
}
}
3)定義全局統一異常處理器將IllegalArgumentException轉化爲錯誤的Result對象。
/**
* 全局異常處理器
*
* @author z_hh
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 處理Throwable異常
* @param t 異常對象
* @return 統一Result
*/
@ExceptionHandler(Throwable.class)
public Result handleThrowable(Throwable t) {
String msg = ThrowableUtils.getThrowableStackTrace(t);
log.error("統一處理未知異常:{}", msg);
return Result.err(msg);
}
/**
* 處理非法參數異常
* @param e 異常對象
* @return 統一Result
*/
@ExceptionHandler(IllegalArgumentException.class)
public Result handleIllegalArgumentException(IllegalArgumentException e) {
log.error("統一處理非法參數異常:{}", ThrowableUtils.getThrowableStackTrace(e));
return Result.err(e.getMessage());
}
}
4、Spring Data Jpa的Active Record模式實現
我的相關博客:https://blog.csdn.net/qq_31142553/article/details/82959626
Active Record,即AR模式,就是讓實體對象本身具備數據庫操作(增刪改查)的能力。違反了低耦合的設計原則,但有時候使用確實方便。
1)定義一個公共實體基類,子類繼承時需要在泛型上面寫入自己的類型以及主鍵類型、對應DAO的類型。
這裏面會根據DAO泛型找到對應的Dao Bean實例以實現對數據庫的操作。然後,子類可以選擇重寫獲取主鍵值的方法,如果沒有覆蓋,就會通過反射找到有@ID註解的那個字段取值。
/**
* AR模式的實體基類
*
* @author z_hh
*/
public class ActiveRecord<T extends ActiveRecord, ID, DAO> {
private JpaRepository<T, ID> jpaRepository;
/**
* 達到延遲加載的效果
*
* @return dao對象
*/
private JpaRepository<T, ID> dao() {
return Optional.ofNullable(jpaRepository).orElseGet(() -> {
Type type = this.getClass().getGenericSuperclass();
Type[] parameter = ((ParameterizedType) type).getActualTypeArguments();
Class<DAO> daoClazz = (Class<DAO>)parameter[2];
if (daoClazz.isAnnotationPresent(Repository.class)) {
Repository annotation = daoClazz.getAnnotation(Repository.class);
return jpaRepository = (JpaRepository<T, ID>) BeanUtils.getBean(annotation.value());
}
String clazzName = daoClazz.getSimpleName();
String beanName = clazzName.substring(0, 1).toLowerCase() + clazzName.substring(1);
return jpaRepository = (JpaRepository<T, ID>) BeanUtils.getBean(beanName);
});
}
/**
* 保存this對象
*
* @return 保存過的對象
*/
public T save() {
return dao().save((T)this);
}
/**
* 根據this的主鍵刪除數據
*/
public void deleteById() {
dao().deleteById(pkVal());
}
/**
* 通過this構造Example進行查詢
*
* @return 結果列表
*/
public List<T> findAllByExample() {
return dao().findAll(Example.of((T)this));
}
/**
* 根據this的主鍵查詢數據
*
* @return Optional對象
*/
public Optional<T> findById() {
return dao().findById(pkVal());
}
/**
* 推薦子類重寫該方法,返回主鍵的值
*
* @return
*/
protected ID pkVal() {
return Arrays.stream(this.getClass().getDeclaredFields())
.filter(f -> f.isAnnotationPresent(Id.class))
.map(f -> {
f.setAccessible(true);
return (ID) ReflectionUtils.getField(f, this);
})
.findAny()
.orElse((ID)null);
}
}
2)子類繼承基類,並設置上面的泛型參數。
/**
* 任務應用
*
* @author z_hh
*/
@Data
@Entity(name = "z_job_app")
public class JobApp extends ActiveRecord<JobApp, Long, JobAppDao> implements Serializable {
private static final long serialVersionUID = 1L;
/**
* id
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
}
3)這樣,一個實體類就具備了增刪改查的能力。
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class ActiveRecordTest {
@Test
public void testSave() throws Exception {
JobApp jobApp = new JobApp();
jobApp.setAppName("example");
jobApp.setAppDesc("實例應用");
jobApp.setCreator("ZHH");
jobApp.setCreateTime(new Date());
jobApp.setCreateWay((byte) 0);
jobApp.setAddressList("127.0.0.1:8080");
jobApp.setEnabled((byte) 0);
jobApp.setIsDeleted((byte) 0);
jobApp = jobApp.save();
Assert.assertNotNull(jobApp.getId());
}
@Test
public void testFindAllByExample() {
JobApp jobApp = new JobApp();
jobApp.setId(1L);
List<JobApp> jobAppList = jobApp.findAllByExample();
Assert.assertFalse(jobAppList.isEmpty());
}
@Test
public void findById() {
JobApp jobApp = new JobApp();
jobApp.setId(1L);
Optional<JobApp> jobAppOptional = jobApp.findById();
Assert.assertTrue(jobAppOptional.isPresent());
}
}
寫了大半天終於搞完了?,有什麼問題歡迎在評論區留言哦!
本文項目Github地址:https://github.com/zhouhuanghua/z-job
本文項目源碼CSDN下載地址:https://download.csdn.net/download/qq_31142553/11330231