netty作為一種高效能的網路程式設計框架,在很多開源專案中大放異彩,十分亮眼,但是在有些專案中卻被濫用,導致使用者使用起來非常的難受。
本篇文章將會講解xxl-job作為一款分散式任務排程系統是如何濫用netty的,導致了怎樣的後果以及如何修改原始碼解決這些問題。
筆者使用的是2.3.0版本的xxl-job,也是當前的最新版本;下面所有的程式碼修改全部基於2.3.0版本的xxl-job原始碼
https://github.com/xuxueli/xxl-job/tree/2.3.0
其中,xxl-job-admin對應著專案:https://github.com/xuxueli/xxl-job/tree/2.3.0/xxl-job-admin
spring-boot專案對應著示例專案:https://github.com/xuxueli/xxl-job/tree/master/xxl-job-executor-samples/xxl-job-executor-sample-springboot
一、xxl-job存在的多埠問題
關於xxl-job如何使用的問題,可以參考我的另外一篇文章:分散式任務排程系統:xxl-job
現在java開發基本上已經離不開spring boot了吧,我在spring boot中整合了xxl-job-core元件並且已經能夠正常使用,但是一旦部署到測試環境就不行了,這是因為測試環境使用了docker,spring boot整合xxl-job-core元件之後會額外開啟9999埠號給xxl-job-admin呼叫使用,如果docker不開啟宿主機到docker的埠對映,xxl-job-admin自然就會呼叫失敗。這導致了以下問題:
- 每個spring boot程式都要開兩個埠號,意味著同時執行著兩個服務進行埠監聽,浪費計算和記憶體資源
- 如果使用docker部署,需要再額外做宿主機和容器的9999埠號的對映,否則外部的xxl-job-admin將無法訪問。
那如果兩個不同的服務都整合了xxl-job,但是部署在同一臺機器上,又會發生什麼呢?答案是如果不指定特定埠號,兩個服務肯定都要使用9999埠號,勢必會埠衝突,但是xxl-job已經想到了9999埠號被佔用的情況,如果9999埠號被佔用,則會埠號加一再重試。
xxl-job-core元件額外開啟9999埠號到底合不合理?
舉個例子:spring boot程式整合swagger-ui是很常見的操作吧,也沒見swagger-ui再額外開啟埠號啊,我認為是不合理的。但是,我認為作者這樣做也有他的考慮---並非所有程式都是spring-boot的程式,也有使用其它框架的程式,使用獨立的netty server作為客戶端能夠保證在使用java的任意xxl-job客戶端都能穩定的向xxl-job-admin提供服務。然而java開發者們絕大多數情況下都是使用spirng-boot構建程式,在這種情況下,作者偷懶沒有構建專門在spirng boot框架下使用的xxl-job-core,而是想了個類似萬金油的蠢招解決問題,讓所有在spring-boot框架下的開發者都一起難受,實在是令人費解。
二、原始碼追蹤
一切的起點要從spring-boot程式整合xxl-job-core說起,整合方式很簡單,只需要成功建立一個XxlJobSpringExecutor
Bean物件即可。
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
logger.info(">>>>>>>>>>> xxl-job config init.");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setAddress(address);
xxlJobSpringExecutor.setIp(ip);
xxlJobSpringExecutor.setPort(port);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
return xxlJobSpringExecutor;
}
在XxlJobSpringExecutor
物件建立完成之後會做一些xxl-job初始化的操作,包含連線xxl-job-admin以及啟動netty server。
展開XxlJobSpringExecutor
原始碼,可以看到它實現了SmartInitializingSingleton
介面,這就意味著Bean物件建立完成之後會回撥afterSingletonsInstantiated
介面
// start
@Override
public void afterSingletonsInstantiated() {
// init JobHandler Repository
/*initJobHandlerRepository(applicationContext);*/
// init JobHandler Repository (for method)
initJobHandlerMethodRepository(applicationContext);
// refresh GlueFactory
GlueFactory.refreshInstance(1);
// super start
try {
super.start();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
在super.start();
這行程式碼中,會呼叫父類XxlJobExecutor
的start方法做初始化
public void start() throws Exception {
// init logpath
XxlJobFileAppender.initLogPath(logPath);
// init invoker, admin-client
initAdminBizList(adminAddresses, accessToken);
// init JobLogFileCleanThread
JobLogFileCleanThread.getInstance().start(logRetentionDays);
// init TriggerCallbackThread
TriggerCallbackThread.getInstance().start();
// init executor-server
initEmbedServer(address, ip, port, appname, accessToken);
}
在initEmbedServer(address, ip, port, appname, accessToken);
這行程式碼做開啟netty-server的操作
private void initEmbedServer(String address, String ip, int port, String appname, String accessToken) throws Exception {
// fill ip port
port = port>0?port: NetUtil.findAvailablePort(9999);
ip = (ip!=null&&ip.trim().length()>0)?ip: IpUtil.getIp();
// generate address
if (address==null || address.trim().length()==0) {
String ip_port_address = IpUtil.getIpPort(ip, port); // registry-address:default use address to registry , otherwise use ip:port if address is null
address = "http://{ip_port}/".replace("{ip_port}", ip_port_address);
}
// accessToken
if (accessToken==null || accessToken.trim().length()==0) {
logger.warn(">>>>>>>>>>> xxl-job accessToken is empty. To ensure system security, please set the accessToken.");
}
// start
embedServer = new EmbedServer();
embedServer.start(address, port, appname, accessToken);
}
可以看到這裡會建立EmbedServer物件,並且使用start方法開啟netty-server,在這裡就能看到熟悉的一大坨了
除了開啟讀寫空閒檢測之外,就只做了一件事:開啟http服務,也就是說,xxl-job-admin是通過http請求呼叫客戶端的介面觸發客戶端的任務排程的。最終處理方法在EmbedHttpServerHandler
類中,順著EmbedHttpServerHandler
類的方法找,可以最終找到處理的方法com.xxl.job.core.server.EmbedServer.EmbedHttpServerHandler#process
private Object process(HttpMethod httpMethod, String uri, String requestData, String accessTokenReq) {
// valid
if (HttpMethod.POST != httpMethod) {
return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, HttpMethod not support.");
}
if (uri==null || uri.trim().length()==0) {
return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, uri-mapping empty.");
}
if (accessToken!=null
&& accessToken.trim().length()>0
&& !accessToken.equals(accessTokenReq)) {
return new ReturnT<String>(ReturnT.FAIL_CODE, "The access token is wrong.");
}
// services mapping
try {
if ("/beat".equals(uri)) {
return executorBiz.beat();
} else if ("/idleBeat".equals(uri)) {
IdleBeatParam idleBeatParam = GsonTool.fromJson(requestData, IdleBeatParam.class);
return executorBiz.idleBeat(idleBeatParam);
} else if ("/run".equals(uri)) {
TriggerParam triggerParam = GsonTool.fromJson(requestData, TriggerParam.class);
return executorBiz.run(triggerParam);
} else if ("/kill".equals(uri)) {
KillParam killParam = GsonTool.fromJson(requestData, KillParam.class);
return executorBiz.kill(killParam);
} else if ("/log".equals(uri)) {
LogParam logParam = GsonTool.fromJson(requestData, LogParam.class);
return executorBiz.log(logParam);
} else {
return new ReturnT<String>(ReturnT.FAIL_CODE, "invalid request, uri-mapping("+ uri +") not found.");
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
return new ReturnT<String>(ReturnT.FAIL_CODE, "request error:" + ThrowableUtil.toString(e));
}
}
從這段程式碼的邏輯可以看到
- 只接受POST請求
- 如果有token,則會校驗token
- 只提供/beat、/idelBeat、/run、/kill、/log 五個介面,所有請求的處理都會委託給executorBiz處理。
最後,netty將executorBiz處理結果寫回xxl-job-admin,然後請求就結束了。這裡netty扮演的角色非常簡單,我認為可以使用spring-mvc非常容易的替換掉它的功能。
三、使用spring-mvc替換netty的功能
1.新增spring-mvc程式碼
這裡要修改xxl-job-core的原始碼,首先,加入spring-mvc的依賴
<!-- spring-web -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>${spring.version}</version>
<scope>provided</scope>
</dependency>
然後新增Controller檔案
package com.xxl.job.core.controller;
import com.xxl.job.core.biz.impl.ExecutorBizImpl;
import com.xxl.job.core.biz.model.*;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* @author kdyzm
* @date 2021/5/7
*/
@RestController
public class XxlJobController {
@PostMapping("/beat")
public ReturnT<String> beat() {
return new ExecutorBizImpl().beat();
}
@PostMapping("/idleBeat")
public ReturnT<String> idleBeat(@RequestBody IdleBeatParam param) {
return new ExecutorBizImpl().idleBeat(param);
}
@PostMapping("/run")
public ReturnT<String> run(@RequestBody TriggerParam param) {
return new ExecutorBizImpl().run(param);
}
@PostMapping("/kill")
public ReturnT<String> kill(@RequestBody KillParam param) {
return new ExecutorBizImpl().kill(param);
}
@PostMapping("/log")
public ReturnT<LogResult> log(@RequestBody LogParam param) {
return new ExecutorBizImpl().log(param);
}
}
2.刪除老程式碼&移除netty依賴
之後,就要刪除老的程式碼了,修改com.xxl.job.core.server.EmbedServer#start
方法,清空所有程式碼,新增
// start registry
startRegistry(appname, address);
然後刪除EmbedServer
類中的以下兩個變數及相關的引用
private ExecutorBiz executorBiz;
private Thread thread;
之後刪除netty的依賴
<!-- ********************** embed server: netty + gson ********************** -->
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>${netty-all.version}</version>
</dependency>
將報錯的程式碼全部刪除,之後就可以編譯成功了,當然這還不行。
3.修改註冊到xxl-job-admin的埠號
註冊的ip地址可以不用改,但是埠號要取spring-boot程式的埠號。
因為要複用springk-boot容器的埠號,所以這裡註冊的埠號要和它保持一致,修改com.xxl.job.core.executor.XxlJobExecutor#initEmbedServer
方法,註釋掉
port = port > 0 ? port : NetUtil.findAvailablePort(9999);
然後修改spring-boot的配置檔案,xxl-job的埠號配置改成server.port
server.port=8081
xxl.job.executor.port=${server.port}
在建立XxlJobSpringExecutor
Bean物件的時候將改值傳遞給它。
@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
logger.info(">>>>>>>>>>> xxl-job config init.");
XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
xxlJobSpringExecutor.setAppname(appname);
xxlJobSpringExecutor.setAddress(address);
xxlJobSpringExecutor.setIp(ip);
xxlJobSpringExecutor.setPort(port);
xxlJobSpringExecutor.setAccessToken(accessToken);
xxlJobSpringExecutor.setLogPath(logPath);
xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
return xxlJobSpringExecutor;
}
4.將xxl-job-core改造成spring-boot-starter
上面改造完了之後已經將邏輯變更為使用spring-mvc,但是spring-boot程式還沒有辦法掃描到xxl-job-core中的controller,可以手動掃描包,這裡推薦使用spring-boot-starter,這樣只需要將xxl-job-core加入classpath,就可以自動生效。
在 com.xxl.job.core.config包下新建Config類
package com.xxl.job.core.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
/**
* @author kdyzm
* @date 2021/5/7
*/
@Configuration
@ComponentScan(basePackages = {"com.xxl.job.core.controller"})
public class Config {
}
在src/main/resources/META-INF
資料夾下新建spring.factories
檔案,檔案內容如下
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.xxl.job.core.config.Config
這樣就改造完了。
四、測試
重啟xxl-job-executor-sample-springboot專案,檢視註冊到xxl-job-admin上的資訊
可以看到埠號已經不是預設的9999,而是和spring-boot程式保持一致的埠號,然後執行預設的job
可以看到已經執行成功,在檢視日誌詳情
日誌也一切正常,表示一切都改造成功了。
完整的程式碼修改:https://github.com/kdyzm/xxl-job/commit/449ee5c7bbb659356af25b164c251f960b9a6891
五、實際使用
由於原作者基本上不理睬人,我克隆了專案並且新增了2.4.0版本:https://github.com/kdyzm/xxl-job/releases/tag/2.4.0
有需要的可以下載原始碼自己打包xxl-job-core
專案上傳私服後就可以使用了