写点什么

XXL-Job 启动源码详解,Java 日常开发的 12 个坑,你踩过几个

作者:Java高工P7
  • 2021 年 11 月 10 日
  • 本文字数:10445 字

    阅读完需:约 34 分钟

<table border="1"><tbody><tr><td><pre><code class="language-html"># web port spring web 容器端口


server.port=8083

no web

#spring.main.web-environment=false

log config

logging.config=classpath:logback.xml

xxl-job 注册中心地址

xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin

xxl-job access token

xxl.job.accessToken=

xxl-job 执行器名称

xxl.job.executor.appname=omsJob

xxl-job executor 执行器 ip

xxl.job.executor.ip=

xxl-job executor 执行器端口

xxl.job.executor.port=9999

xxl-job executor log-path 日志配置

xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler

xxl-job executor log-retention-days 日志定时清理时间

xxl.job.executor.logretentiondays=30</code></pre></td></tr></tbody></table>


2、初始化执行器


<table border="1"><tbody><tr><td><pre><code class="language-html">@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;


}</code></pre></td></tr></tbody></table>


3、使用 xxl-job 注解配置任务


<table border="1"><tbody><tr><td><pre><code class="language-html">/**


  • 1、简单任务示例(Bean 模式)


*/


@XxlJob("demoJobHandler")


public ReturnT<String> demoJobHandler(String param) throws Exception {


XxlJobLogger.log("XXL-JOB, Hello World.");


for (int i = 0; i < 5; i++) {


XxlJobLogger.log("beat at:" + i);


TimeUnit.SECONDS.sleep(2);


}


return ReturnT.SUCCESS;


}</code></pre></td></tr></tbody></table>


4、启动成功后,服务会开启两个端口,一个是业务端口,一个是调度中心下发 job 执行的端口。(此处要优化,服务和 job 触发其实使用一个端口即可)



3、启动源码分析


========


1、服务启动流程


<table border="1"><tbody><tr><td><pre><code class="language-html">@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;


}</code></pre></td></tr></tbody></table>


服务主要启动是初始化 XxlJobSpringExecutor 这个 bean 对象,该对象定义了执行器 xxlJobSpringExecutor 的相关配置,如注册中心地址,服务提供地址,以及授权 token 等。该对象类图如下,


XxlJobSpringExecutor 执行器继承 XxlJobExecutor 并实现 Spring 的 ApplicationContextAware 等类,对该 bean 进行了增强。主要核心类为 XxlJobExecutor。


因为继承 SmartInitializingSingleton,所以 bean 初始化完成之后会执行 afterSingletonsInstantiated 方法,该类主要为 initJobHandlerMethodRepository 这个方法,用于扫描 xxl-job 注解,进行任务加载和管理


<table border="1"><tbody><tr><td><pre><code class="language-html">// start


@Override


public void afterSingletonsInstantiated() {


// 扫描 xxl-job 注解,进行任务加载和管理


initJobHandlerMethodRepository(applicationContext);


// refresh GlueFactory


GlueFactory.refreshInstance(1);


// super start


try {


super.start();


} catch (Exception e) {


throw new RuntimeException(e);


}


}</code></pre></td></tr></tbody></table>


initJobHandlerMethodRepository 方法主要如下


该方法主要是通过获取 Spring 管理的容器 bean,然后扫描带有 xxljob 注解的方法,将他们保存在 jobHandlerRepository 对象中


<table border="1"><tbody><tr><td><pre><code class="language-html">private void initJobHandlerMethodRepository(ApplicationContext applicationContext) {


if (applicationContext == null) {


return;


}


// 扫描 Spring 管理的 bean


String[] beanDefinitionNames = applicationContext.getBeanNamesForType(Object.class, false, true);


for (String beanDefinitionName : beanDefinitionNames) {


Object bean = applicationContext.getBean(beanDefinitionName);


Map<Method, XxlJob> annotatedMethods = null; // referred to :org.springframework.context.event.EventListenerMethodProcessor.processBean


try {


annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(),


new MethodIntrospector.MetadataLookup<XxlJob>() {


@Override


public XxlJob inspect(Method method) {


// 获取注解为 XxlJob 的方法,并保存在 annotatedMethods


return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);


}


});


} catch (Throwable ex) {


logger.error("xxl-job method-jobhandler resolve error for bean[" + beanDefinitionName + "].", ex);


}


if (annotatedMethods==null || annotatedMethods.isEmpty()) {


continue;


}


//获取方法属性,并存储在 jobHandlerRepository 对象中


for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {


Method method = methodXxlJobEntry.getKey();


XxlJob xxlJob = methodXxlJobEntry.getValue();


if (xxlJob == null) {


continue;


}


String name = xxlJob.value();


if (name.trim().length() == 0) {


throw new RuntimeException("xxl-job method-jobhandler name invalid, for[" + bean.getClass() + "#" + method.getName() + "] .");


}


if (loadJobHandler(name) != null) {


throw new RuntimeException("xxl-job jobhandler[" + name + "] naming conflicts.");


}


// execute method


if (!(method.getParameterTypes().length == 1 && method.getParameterTypes()[0].isAssignableFrom(String.class))) {


throw new RuntimeException("xxl-job method-jobhandler param-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +


"The correct method format like " public ReturnT<String> execute(String param) " .");


}


if (!method.getReturnType().isAssignableFrom(ReturnT.class)) {


throw new RuntimeException("xxl-job method-jobhandler return-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +


"The correct method format like " public ReturnT<String> execute(String param) " .");


}


method.setAccessible(true);


// init and destory


Method initMethod = null;


Method destroyMethod = null;


if (xxlJob.init().trim().length() > 0) {


try {


initMethod = bean.getClass().getDeclaredMethod(xxlJob.init());


initMethod.setAccessible(true);


} catch (NoSuchMethodException e) {


throw new RuntimeException("xxl-job method-jobhandler initMethod invalid, for[" + bean.getClass() + "#" + method.getName() + "] .");


}


}


if (xxlJob.destroy().trim().length() > 0) {


try {


destroyMethod = bean.getClass().getDeclaredMethod(xxlJob.destroy());


destroyMethod.setAccessible(true);


} catch (NoSuchMethodException e) {


throw new RuntimeException("xxl-job method-jobhandler destroyMethod invalid, for[" + bean.getClass() + "#" + method.getName() + "] .");


}


}


// registry jobhandler


//将任务存储在 jobHandlerRepository 对象中,后续下发任务使用


registJobHandler(name, new MethodJobHandler(bean, method, initMethod, destroyMethod));


}


}


}</code></pre></td></tr></tbody></table>


之后执行 super.start(),执行父类 XxlJobExecutor 的 start 方法。该方法主要有日志初始化,日志清理任务初始化,RPC 调用触发器回调线程启动,调度中心列表初始化以及执行器端口初始化。


<table border="1"><tbody><tr><td><pre><code class="language-html">public void start() throws Exception { // init logpath


//初始化任务执行日志路径


XxlJobFileAppender.initLogPath(logPath);


// 日志定时清理任务


JobLogFileCleanThread.getInstance().start(logRetentionDays);


// 初始化触发器回调线程(用 RPC 回调调度中心接口)


TriggerCallbackThread.getInstance().start();


//初始化调度中心列表


initAdminBizList( adminAddresses, accessToken);


// init executor-server 执行器端口启动


initEmbedServer(address, ip, port, appname, accessToken);


}</code></pre></td></tr></tbody></table>


XxlJobFileAppender.initLogPath(logPath)和 JobLogFileCleanThread.getInstance().start(logRetentionDays)主要对执行日志进行初始化,就不多解释了,直接往下看。


TriggerCallbackThread.getInstance().start();


<table border="1"><tbody><tr><td><pre><code class="language-html">public void start() {


// 调度中心注册表会否为空


if (XxlJobExecutor.getAdminBizList() == null) {


logger.warn(">>>>>>>>>>> xxl-job, executor callback config fail, adminAddresses is null.");


return;


}


// callback


triggerCallbackThread = new Thread(new Runnable() {


@Override


public void run() {


// 监听阻塞队列


while(!toStop){


try {


HandleCallbackParam callback = getInstance().callBackQueue.take();


if (callback != null) {


// 组装 callback 返回的参数


List<HandleCallbackParam> callbackParamList = new ArrayList<HandleCallbackParam>();


int drainToNum = getInstance().callBackQueue.drainTo(callbackParamList);


callbackParamList.add(callback);


// 执行回调


if (callbackParamList!=null && callbackParamList.size()>0) {


doCallback(callbackParamList);


}


}


} catch (Exception e) {


if (!toStop) {


logger.error(e.getMessage(), e);


}


}


}


// last callback


try {


List<HandleCallbackParam> callbackParamList = new ArrayList<HandleCallbackParam>();


int drainToNum = getInstance().callBackQueue.drainTo(callbackParamList);


if (callbackParamList!=null && callbackParamList.size()>0) {


doCallback(callbackParamList);


}


} catch (Exception e) {


if (!toStop) {


logger.error(e.getMessage(), e);


}


}


logger.info(">>>>>>>>>>> xxl-job, executor callback thread destory.");


}


});


triggerCallbackThread.setDaemon(true);


triggerCallbackThread.setName("xxl-job, executor TriggerCallbackThread");


triggerCallbackThread.start();


// retry


triggerRetryCallbackThread = new Thread(new Runnable() {


@Override


public void run() {


while(!toStop){


try {


retryFailCallbackFile();


} catch (Exception e) {


if (!toStop) {


logger.error(e.getMessage(), e);


}


}


try {


TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);


} catch (InterruptedException e) {


if (!toStop) {


logger.error(e.getMessage(), e);


}


}


}


logger.info(">>>>>>>>>>> xxl-job, executor retry callback thread destory.");


}


});


triggerRetryCallbackThread.setDaemon(true);


triggerRetryCallbackThread.start();


}</code></pre></td></tr></tbody></table>


doCallback(callbackParamList)如下


<table border="1"><tbody><tr><td><pre><code class="language-html">/**


  • do callback, will retry if error

  • @param callbackParamList


*/


private void doCallback(List<HandleCallbackParam> callbackParamList){


boolean callbackRet = false;


// 向所有的调度中心发送回调信息


for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {


try {


//本质上是调用注册中心的 api/callback 接口。记录调用结果。


ReturnT<String> callbackResult = adminBiz.callback(callbackParamList);


if (callbackResult!=null && ReturnT.SUCCESS_CODE == callbackResult.getCode()) {


callbackLog(callbackParamList, "<br>----------- xxl-job job callback finish.");


callbackRet = true;


break;


} else {


callbackLog(callbackParamList, "<br>----------- xxl-job job callback fail, callbackResult:" + callbackResult);


}


} catch (Exception e) {


callbackLog(callbackParamList, "<br>----------- xxl-job job callback error, errorMsg:" + e.getMessage());


}


}


if (!callbackRet) {


appendFailCallbackFile(callbackParamList);


}


}</code></pre></td></tr></tbody></table>


adminBiz.callback(callbackParamList)


调用注册中心 api 接口


<table border="1"><tbody><tr><td><pre><code class="language-html">@Override


public ReturnT<String> callback(List<HandleCallbackParam> callbackParamList) {


return XxlJobRemotingUtil.postBody(addressUrl+"api/callback", accessToken, timeout, callbackParamList, String.class);


}</code></pre></td></tr></tbody></table>


initAdminBizList( adminAddresses, accessToken); 初始化注册中心列表,用于后期和注册中心交互


<table border="1"><tbody><tr><td><pre><code class="language-html">//扫描 xxl.job.admin.addresses 配置,将他们加入注册中心列表 adminBizList 对象中。用于后期发送回调


private void initAdminBizList(String adminAddresses, String accessToken) throws Exception {


if (adminAddresses!=null && adminAddresses.trim().length()>0) {


for (String address: adminAddresses.trim().split(",")) {


if (address!=null && address.trim().length()>0) {


AdminBiz adminBiz = new AdminBizClient(address.trim(), accessToken);


if (adminBizList == null) {


adminBizList = new ArrayList<AdminBiz>();


}


adminBizList.add(adminBiz);


}


}


}


}</code></pre></td></tr></tbody></table>


// init executor-server


initEmbedServer(address, ip, port, appname, accessToken);<核心>


<table border="1"><tbody><tr><td><pre><code class="language-html">//初始化 xxljob 执行器服务


private void initEmbedServer(String address, String ip, int port, String appname, String accessToken) throws Exception {


//初始化 ip 和端口,如果没有 ip 则自动获取本地 ip


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);


}


// 启动服务


embedServer = new EmbedServer();


embedServer.start(address, port, appname, accessToken);


}</code></pre></td></tr></tbody></table>


embedServer.start(address, port, appname, accessToken); 本质上是一个 Netty 服务,标准的 Netty 服务启动,我们只看 EmbedHttpServerHandler,Netty 处理请求的 handler


<table border="1"><tbody><tr><td><pre><code class="language-html">public void start(final String address, final int port, final String appname, final String accessToken) {


executorBiz = new ExecutorBizImpl();


thread = new Thread(new Runnable() {


@Override


public void run() {


// param


EventLoopGroup bossGroup = new NioEventLoopGroup();


EventLoopGroup workerGroup = new NioEventLoopGroup();


ThreadPoolExecutor bizThreadPool = new ThreadPoolExecutor(


0,


200,


60L,


TimeUnit.SECONDS,


new LinkedBlockingQueue<Runnable>(2000),


new ThreadFactory() {


@Override


public Thread newThread(Runnable r) {


return new Thread(r, "xxl-rpc, EmbedServer bizThreadPool-" + r.hashCode());


}


},


new RejectedExecutionHandler() {


@Override


public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {


throw new RuntimeException("xxl-job, EmbedServer bizThreadPool is EXHAUSTED!");


}


});


try {


// start server


ServerBootstrap bootstrap = new ServerBootstrap();


bootstrap.group(bossGroup, workerGroup)


.channel(NioServerSocketChannel.class)


.childHandler(new ChannelInitializer<SocketChannel>() {


@Override


public void initChannel(SocketChannel channel) throws Exception {


channel.pipeline()


.addLast(new IdleStateHandler(0, 0, 30 * 3, TimeUnit.SECONDS)) // beat 3N, close if idle


.addLast(new HttpServerCodec())


.addLast(new HttpObjectAggregator(5 * 1024 * 1024)) // merge request & reponse to FULL


.addLast(new EmbedHttpServerHandler(executorBiz, accessToken, bizThreadPool));


}


})


.childOption(ChannelOption.SO_KEEPALIVE, true);


// bind


ChannelFuture future = bootstrap.bind(port).sync();


logger.info(">>>>>>>>>>> xxl-job remoting server start success, nettype = {}, port = {}", EmbedServer.class, port);


//注册到调度中心


startRegistry(appname, address);


// wait util stop


future.channel().closeFuture().sync();


} catch (InterruptedException e) {


if (e instanceof Interru


《Android学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》
浏览器打开:qq.cn.hn/FTe 免费领取
复制代码


ptedException) {


logger.info(">>>>>>>>>>> xxl-job remoting server stop.");


} else {


logger.error(">>>>>>>>>>> xxl-job remoting server error.", e);


}


} finally {


// stop


try {


workerGroup.shutdownGracefully();


bossGroup.shutdownGracefully();


} catch (Exception e) {


logger.error(e.getMessage(), e);


}


}


}


});


thread.setDaemon(true); // daemon, service jvm, user thread leave >>> daemon leave >>> jvm leave


thread.start();


}</code></pre></td></tr></tbody></table>


<table border="1"><tbody><tr><td><pre><code class="language-html">public static class EmbedHttpServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {


private static final Logger logger = LoggerFactory.getLogger(EmbedHttpServerHandler.class);


private ExecutorBiz executorBiz; //执行器


private String accessToken; //token


private ThreadPoolExecutor bizThreadPool;//执行器线程池


public EmbedHttpServerHandler(ExecutorBiz executorBiz, String accessToken, ThreadPoolExecutor bizThreadPool) {


this.executorBiz = executorBiz;


this.accessToken = accessToken;


this.bizThreadPool = bizThreadPool;


}


@Override


protected void channelRead0(final ChannelHandlerContext ctx, FullHttpRequest msg) throws Exception {


// request parse


//final byte[] requestBytes = ByteBufUtil.getBytes(msg.content()); // byteBuf.toString(io.netty.util.CharsetUtil.UTF_8);


String requestData = msg.content().toString(CharsetUtil.UTF_8);//获取请求数据


String uri = msg.uri();


HttpMethod httpMethod = msg.method();


boolean keepAlive = HttpUtil.isKeepAlive(msg);


String accessTokenReq = msg.headers().get(XxlJobRemotingUtil.XXL_JOB_ACCESS_TOKEN);


// invoke


bizThreadPool.execute(new Runnable() {


@Override


public void run() {


// 处理请求


Object responseObj = process(httpMethod, uri, requestData, accessTokenReq);


// 格式化为 JSON


String responseJson = GsonTool.toJson(responseObj);


// 写回客户端


writeResponse(ctx, keepAlive, responseJson);


}


});


}


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));


}


}</code></pre></td></tr></tbody></table>


我们主要看下 run 方法的执行过程


<table border="1"><tbody><tr><td><pre><code class="language-html">@Override


public ReturnT<String> run(TriggerParam triggerParam) {


// 根据 jobid 加载对应的 job 执行信息,第一次执行为 null


JobThread jobThread = XxlJobExecutor.loadJobThread(triggerParam.getJobId());


IJobHandler jobHandler = jobThread!=null?jobThread.getHandler():null;//根绝 jobThread 获取 job 处理 handler


String removeOldReason = null;


// valid:jobHandler + jobThread


GlueTypeEnum glueTypeEnum = GlueTypeEnum.match(triggerParam.getGlueType()); //获取任务类型


if (GlueTypeEnum.BEAN == glueTypeEnum) {


// new jobhandler


IJobHandler newJobHandler = XxlJobExecutor.loadJobHandler(triggerParam.getExecutorHandler());//获取任务的执行器


// 校验新老 job 是否一致,不一致将老的进行初始化。有可能任务更新。通过 jobid 获取的是老的


if (jobThread!=null && jobHandler != newJobHandler) {


// change handler, need kill old thread


removeOldReason = "change jobhandler or glue type, and terminate the old job thread.";


jobThread = null;


jobHandler = null;


}


// valid handler


if (jobHandler == null) {


jobHandler = newJobHandler; //将新处理 handler 赋值给老的


if (jobHandler == null) {


return new ReturnT<String>(ReturnT.FAIL_CODE, "job handler [" + triggerParam.getExecutorHandler() + "] not found.");


}


}


} else if (GlueTypeEnum.GLUE_GROOVY == glueTypeEnum) {


// valid old jobThread


if (jobThread != null &&


!(jobThread.getHandler() instanceof GlueJobHandler


&& ((GlueJobHandler) jobThread.getHandler()).getGlueUpdatetime()==triggerParam.getGlueUpdatetime() )) {


// change handler or gluesource updated, need kill old thread


removeOldReason = "change job source or glue type, and terminate the old job thread.";


jobThread = null;


jobHandler = null;


}


if (jobHandler == null) {


try {


IJobHandler originJobHandler = GlueFactory.getInstance().loadNewInstance(triggerParam.getGlueSource());


jobHandler = new GlueJobHandler(originJobHandler, triggerParam.getGlueUpdatetime());


} catch (Exception e) {


logger.error(e.getMessage(), e);


return new ReturnT<String>(ReturnT.FAIL_CODE, e.getMessage());


}


}


} else if (glueTypeEnum!=null && glueTypeEnum.isScript()) {


// valid old jobThread


if (jobThread != null &&


!(jobThread.getHandler() instanceof ScriptJobHandler


&& ((ScriptJobHandler) jobThread.getHandler()).getGlueUpdatetime()==triggerParam.getGlueUpdatetime() )) {


// change script or gluesource updated, need kill old thread


removeOldReason = "change job source or glue type, and terminate the old job thread.";


jobThread = null;


jobHandler = null;


}


// valid handler


if (jobHandler == null) {


jobHandler = new ScriptJobHandler(triggerParam.getJobId(), triggerParam.getGlueUpdatetime(), triggerParam.getGlueSource(), GlueTypeEnum.match(triggerParam.getGlueType()));


}


} else {


return new ReturnT<String>(ReturnT.FAIL_CODE, "glueType[" + triggerParam.getGlueType() + "] is not valid.");


}

用户头像

Java高工P7

关注

还未添加个人签名 2021.11.08 加入

还未添加个人简介

评论

发布
暂无评论
XXL-Job启动源码详解,Java日常开发的12个坑,你踩过几个