手把手教你打造最好用的任务框架

最近两个项目中都使用了任务框架。说实话,任务框架和流程引擎有点像,鉴于我曾猛烈批评过流程引擎,我对这玩意自然也没什么好感:( 。既然如此,我为什么还要实现一个任务框架呢?主要是出于以下考虑:

  1. 我们很多现有功能都是基于任务框架实现的。大家的使用和运维习惯已经养成。即使重构,最好还是保留任务框架的能力。

  2. 任务框架提倡把一个任务拆分成多个step,会自动引导开发人员进行结构化分解,分而治之,对处理复杂业务有益。

  3. 任务框架会自动持久化任务的执行过程,方便问题排查,必要时,还能实现任务的回滚、断点继续等能力。

既然一定要用,现成的任务框架不能用吗? 不能用倒不至于,但不好用是肯定的,要不也不至于再造个轮子了。后续我会在COLA社区发布cola-component-job组件,如果你有需要,可直接使用,但我不会怂恿你使用,除非你的业务有一定的复杂度,且对任务管理有强烈诉求,那么cola-job绝对是比其它框架更好的选择。

开源代码地址:https://github.com/alibaba/COLA/tree/master/cola-components/cola-component-job

看完这篇文章,你就能明白为什么我有这样的信心了。

1. 调研与设计

1.1 调研SpringBatch

设计的起点是概念建模,也就是要从需求描述中,找到重要的领域概念,并建立模型。好在,对于任务框架的概念建模我并不需要从零开始,我可以从开源框架SpringBatch中获得一些参考。因为在我决定自己打造一个任务框架之前,先是调研了一下SpringBatch,看看能否直接使用,一番调研下来,实在是咽不下去:

  1. 首当其冲的过于复杂,在他大大小小6张表中,在我看来,JobParameter和ExecutionContext肯本没必要用单独表存储,作为字段用Json存就好了

  2. 有些设计有些重叠,或者说是用处不大。比如JobInstance是介于Job和JobExecution之间,显得多余。又如JobParameter作为任务标识的必要性也不大,用uuid会更好

  3. 只有基于数据库事务的rollback,没有Step的显示rollback控制,不满足我们诉求

  4. SpringBatch过多的关注数据处理,所以内置了ItemReader、ItemProcessor、ItemWriter这些概念,这不是我们想要的

虽然对于SpringBatch我不是很满意,但其核心概念Job、JobExecution、Step、StepExecution、ExecutionContext是非常好的输入,特别是Job和JobExecution,是把任务定义和任务执行进行了概念区隔,分别建模,这一点对于我后续的设计帮助很大。

1.2 制定设计目标

看不上SpringBatch的复杂和冗余,要构建一个更加简洁的任务框架,结合我们自己的业务诉求,它应该具备以下功能目标:

  1. 任务执行可同步,可异步

  2. 任务过程数据可持久化(redis,数据库)

  3. 任务作业失败可回滚,断点可继续

2. 迭代设计开发

软件工程方法这么多年最大的进步莫过于从瀑布式向迭代式的演进,迭代就是承认不确定性,拥抱变化。用华为的话说就是,“方向大致正确,组织充满活力”。本次任务框架,我正是用这样渐进式的方法徐徐展开的。

2.1 初版概念模型

基于我之前对于问题域的分析,以及SpringBatch给我的输入,结合我的设计目标,我不难得出如下的概念模型。

c5d74fde6619cff2dd1e4395e8efb043.png

  1. JobLauncher:是任务启动器,通过它来启动任务,可以看做是任务框架的入口。

  2. Job:代表着一个需要被执行的任务,一个Job可以包含多个Step。

  3. JobExecution:表示Job的执行,是可以被持久化的。

  4. Step:表示一个Job中的一个具体的执行步骤。

  5. StepExecution: 表示Step的执行,是可以被持久化的。

  6. JobRepository:任务框架的仓储,存储任务执行、上下文数据等。可以有多种实现,1)内存; 2)Redis; 3)数据库等。

2.2 同步的非持久化

接下来我们就可以着手开始代码实现了,实现的路径最好是从易到难,最简单的是同步任务,然后也不需要持久化。也就是我们需要在JobLauncher上实现一个executeSync方法,然后执行数据我们存在内存就好了。所以我们需要有一个JobRepository的抽象,这个抽象是对具体持久化技术的解耦。关键是要定义清晰的API接口,其具体实现是技术细节问题。

public interface JobRepository {
    JobExecution saveJobExecution(JobExecution jobExecution);
    void updateJobExecution(JobExecution jobExecution);
    JobExecution getJobExecutionByExecutionId(String jobExecutionId);
    void saveStepExecution(StepExecution stepExecution);
    void updateStepExecution(StepExecution stepExecution);
    StepExecution getStepExecution(String jobExecutionId, String stepName);
    List<StepExecution> listStepExecutions(String jobExecutionId);
}

因为一开始我们只需要内存存储,所以实现一个简单的MemoryJobRepository就好了,内存实现就是用ConcurrentHashMap缓存一下数据,很简单。实现MemoryJobRepository是为了暂时屏蔽持久化层的复杂度,专注在业务功能的实现上即可,有点像Mock,实际的商用中不会直接使用MemoryJobRepository。

cfd77d1f2132e8796257b0383448a2b8.png

2.3 异步

异步任务是需要异步执行,也就是框架在接收到任务执行请求时,需要立马返回一个jobExecutionId,然后让任务在后台执行。在Java中用ExecutorService很容易就能实现异步线程池,这里没什么特别的。只是有一点,一开始下面的这段Job执行的主逻辑是直接写在JobLauncher.executeSync()里的。

for (Step step : this.getSteps()) {
    StepExecution stepExecution = jobRepository.getStepExecution(jobExecution.getExecutionId(),
        step.getClass().getSimpleName());
    // 全新的step
    if (stepExecution == null) {
        stepExecution = new StepExecution(step);
        stepExecution.setJobExecution(jobExecution);
        stepExecution.setExecutionStatus(ExecutionStatus.STARTED);
        stepExecution.setStartTime(LocalDateTime.now());
        log.info("[Step] start : {}", stepExecution);
        jobRepository.saveStepExecution(stepExecution);
        step.execute(stepExecution);
        continue;
    }
    // step已经执行成功,跳过
    if (stepExecution.isCompleted()) {
        log.info("[Step] skip: {}", stepExecution);
    }
    else{
        log.info("[Step] rerun: {}", stepExecution);
        // 因为stepExecution是持久层取出的,所以要重新设置step和jobExecution
        stepExecution.setStep(step);
        stepExecution.setJobExecution(jobExecution);
        stepExecution.setExecutionStatus(ExecutionStatus.STARTED);
        jobRepository.updateStepExecution(stepExecution);
        step.execute(stepExecution);
    }
}
jobExecution.setExecutionStatus(ExecutionStatus.COMPLETED);

为了实现JobLauncher.executeAsync(),最简单的做法就是把这段代码copy过去,这个大家都知道不好。好一点的可能是抽成一个共用方法doExecute()。实际上,这样做也不是最佳。再深入思考一下,对Job的execute实际上是Job自己可以承担的责任,用Job封装这段代码的execute逻辑是非常合适的,既能优雅的复用,也更加内聚。类似的,Step也应该具备execute的能力。实际上,在SpringBatch中,他们也是这么做的。代码放在哪里都能被执行,但只有遵循高内聚、低耦合的原则,将代码放置在它应该在的地方,才是正道。

// Job的执行逻辑
public class Job {
    private List<Step> steps = new ArrayList<>();
    public void execute(JobExecution jobExecution) {
for (Step step : this.getSteps()) {
    // ...上面的代码片段
}
    }
}
// Step的执行逻辑
public abstract class AbstractStep implements Step {
@Override
public void execute(StepExecution stepExecution) {
    JobRepository jobRepository = stepExecution.getRepository();
    try {
        this.doExecute(stepExecution);
        stepExecution.setExecutionStatus(ExecutionStatus.COMPLETED);
    } catch (Exception e) {
        log.error("[Step] execute error: {}", e.getMessage());
        stepExecution.setMessage("[execute error]: " + e.getMessage() + "; ");
        if (this.needRollBack(stepExecution)) {
            this.rollback(stepExecution);
        } else {
            stepExecution.setExecutionStatus(ExecutionStatus.FAILED);
        }
        throw e;
    } finally {
        log.info("[Step] end: {},{}", stepExecution.getStepExecutionId(), stepExecution.getExecutionStatus());
        stepExecution.setEndTime(LocalDateTime.now());
        jobRepository.updateStepExecution(stepExecution);
    }
}
}

2.4 Redis和数据库持久化

持久化的工作相对来说很单纯,就是用不同的技术实现JobRepository接口就可以了。这也是为什么我们说持久化是“技术细节”的原因,因为与核心业务逻辑比起来,用什么方式持久化,真的没那么重要,就是技术实现的细节问题。而我们有很多同学,包括早期的我自己,把数据库设计作为软件设计核心,实际上是本末倒置的。

只要接口定义的合理,持久化的工作完全可以并行展开。实际开发中,我们的确也是这样做的。我在实现Redis持久化的同时,另外一个同事在协助现实数据库的持久化,然后我们把之前我基于MemoryJobRepository写的大量任务相关的Test Cases,把MemoryJobRepository替换成RedisJobRepository和DatabaseJobRepository都能测试通过的话,基本就没有问题了。

984f25c49cc9c4c6904f051316e6c301.png

这里我稍微提一下uuid的设计,一开始我用的是jdk的UUID.randomUUID(),形式是这样的:70cd0ab8-314c-4222-aaa2-5d51e86064e2。这玩意唯一性绝对没问题,只是有两个问题:1)太长了,占空间;2)毫无意义的字符串,提供不了任何有价值信息。为此,我找到了一个可以自定义uuid的方式,结合开源的NanoUuid,我将时间作为前缀,随机码作为后缀来作为job的uuid,比如,0119T113859-eyqnddvd代表这个任务是1.19号11:38:59的一个任务。相比较无意义的字符串,时间信息至少也是价值信息。另外,我们人类天然喜欢顺序和规律,不管怎么样,一个跟时间相关的有章可循的uuid,怎么都要比一段毫无意义的字符串拼接的uuid要强。

2.5 BatchJob功能

持久化工作做完之后,我们之前既定的任务框架设计目标,基本已经完成了。这时,同事告诉我,我们的业务场景中有一个批量任务的诉求。也就是客户的请求不是执行一个任务,而是一批任务。这就意味着当前的job-step两层模型无法满足要求,必须要再加一层,演变为batchJob-job-step三层模型才行。如下图所示,为了新的三层模型,我们需要在原来模型的基础上,加上BatchJob相关的概念。

68ab5c646ee03a9386874ff09fa66a34.png

好在,这个新需求不用打破原来的设计,只是叠加一层。因此,实现起来,也很简单。BatchJobLauncher的核心代码就是下面几行:

/**
 * 批量任务只支持单个Job的异步执行
 */
public class BatchJobLauncher {
    public static BatchJobExecution execute(BatchJob batchJob) {
        JobRepository jobRepository = batchJob.getJobRepository();
        BatchJobExecution batchJobExecution = new BatchJobExecution(UuidGenerator.nextBatchId());
        for (JobInstance jobInstance : batchJob.getJobInstances()) {
            jobInstance.getExecutionContext().setBatchJobId(batchJobExecution.getBatchJobId());
            String jobExecutionId = JobLauncher.executeAsync(jobInstance.getJob(), jobInstance.getExecutionContext(),
                batchJob.getExecutorService());
            batchJobExecution.addJobExecution(jobExecutionId);
        }
        batchJobExecution.setStatus(ExecutionStatus.STARTED);
        jobRepository.saveBatchJobExecution(batchJobExecution);
        log.info("[BatchJob] started: {}", batchJobExecution);
        return batchJobExecution;
    }
}

这里你会看到一个新概念——JobInstance。这个新概念代表了一个Job实例,即Job的定义+Job的执行上下文,BatchJob是由JobInstance组合而成。我对这个新抽象很满意,因为它让我后续的设计和实现变得很smooth。想知道我是如何创造出JobInstance这个概念的吗?接着往下看。

3. 心得体会

3.1 想不清楚,先写测试

这里我就不扯TDD大旗了,说点自己在项目中关于测试先行的体会。这个项目中我基本是开发、测试交叉进行,大部分时候是先写测试。特别是在做BatchJob功能的时候,我一开始没什么头绪,所以就先写测试用例,为了写这个测试用例,我首先需要构造一个BatchJob。

// 尝试构造BatchJob
Job job = JobBuilderFactory.create()
    .addStep(new MyStep1())
    .addStep(new MyStep2())
    .addStep(new LongTimeStep())
    .build("asyncJob", jobRepository);
ExecutionContext context1 = new ExecutionContext();
context1.putString("hostId", "111");
JobInstance jobInstance1 = new JobInstance(job, context1);
ExecutionContext context2 = new ExecutionContext();
context2.putString("hostId", "222");
JobInstance jobInstance2 = new JobInstance(job, context2);
ExecutionContext context3 = new ExecutionContext();
context3.putString("hostId", "333");
JobInstance jobInstance3 = new JobInstance(job, context3);
BatchJob batchJob = new BatchJob();
batchJob.add(jobInstance1).add(jobInstance2).add(jobInstance3).jobRepository(jobRepository);

为了构造BatchJob,我发现,我需要一个新概念来包装Job和Job的执行上下文(ExecutionContext),于是便产生了JobInstance这个新概念的想法。在构造过程中,我意识到我并需要为每个JobInstance都new一个Job,即Job承载的只是任务定义,可以是Singleton的,不应该持有任何“状态”,应该是stateless的。由此,我进一步意识到把jobExecutionId安置在Job身上是不合理的。关于这一点,我在“概念不清晰,实现就混乱”中会详细阐述。完整的BatchJob和Job之间的关系,应该如下图所示。

515cbb41b82b418669f32bf17c70414b.png

所以,TDD不仅仅是一个方法论,更是一种以终为始的思考方式。思绪不会总是清晰,需要一个靶点,TDD正是这样的靶点。

3.2 概念不清晰,实现就混乱

在我的第一版代码实现中,我在Job里冗余放置了一个jobExecutionId属性,想让这个jobExecutionId代表已经执行过的Job的执行jobExecutionId,从而在我们重复执行Job时,可以用来传递已经执行过的jobExecutionId。

这个“别扭”的安排一直没造成什么问题,直到我做BatchJob这个功能的时候,我发现BatchJob是一群Job实例的集合,而Job实例并不是简单的new Job( ),因为按照我们对Job概念的设计,它只是Job的定义,是Step的集合,不应该持有数据,应该是Singleton的。因此,所有的Job实例都可以reference到同一个Singleton的Job对象。但如果jobExecutionId是放在Job身上,我们就无法做到这一点,因为一旦Job持有jobExecutionId,就不再是线程安全的,就不能是Singleton的,这显然超出了Job作为Job定义的职责范围。想到这些,我内心有一种卡壳、别扭、不舒服的感觉。

其实,我已经意识到jobExecutionId不应该归属于Job,应该移出去,可移到哪里呢?最简单的做法是给executeSync方法加一个新的形式参数,可以这样做,但不优雅。ExecutionContext怎么样?好像可以,因为执行上下文(ExecutionContext)就是用来传递执行期间的数据之用。放在它身上表示这是一次任务的重复执行,没毛病。

这样调整之后,整个概念定义才真正清晰,类的职责和边界才更加明确,代码实现也更加容易。此时,内心有一种舒畅的感觉,原来不好的设计在直觉上是能给到你不舒服的感觉,问题解决了,你也就舒畅了。这里给我的启发是,在概念模型(领域模型)上仔细斟酌,做清晰地定义,并严格按照概念定义界定对象的职责和代码实现,其意义重大。而这样对属性细节的斟酌考量,如果不深入代码细节,就不能被发现,这就是我们说架构师要双手沾泥的原因。

一个属性放在哪里,看起来是一件小事,但当其关系到我们的核心概念定义和边界的时候,它就不再是一件小事情,而是关乎我们设计完整性和项目成败的大事。所以,我们要时刻保持警惕,思维保持清晰,即使是像我这样的建模老手。也会在属性归属的“小问题”上犯“大错误”。

3.3 上下文设计模式

关于上下文设计模式,我在再谈软件设计中的抽象思维一文中已经阐述过,可以说,这是框架设计中,使用最广泛的设计方法之一。因为我们使用框架,主要是复用框架的能力(function)。而对于框架而言,他最主要的职责是帮用户处理“数据”,对于用户数据,我们在框架中通常叫它们Context(上下文)。对照下图,你可以看到,在我们的任务框架中,ExecutionContext是典型的Procedure Context。

378b8c90447184496114f7f6952b3437.png

基于这样的定位,我们的ExecutionContext定义如下:

public class ExecutionContext<T> {
    /**
     * 这是任务执行的主参数,通常是client自定义的类
     */
    private T param;
    /**
     * 为了灵活性,主参数之外的数据,放在extensions
     */
    private final Map<String, Object> extensions;
    /**
     * 当执行批量任务的时候,需要传入batchJobId
     */
    @JsonIgnore
    private String batchJobId;
    /**
     * 当重复执行一个Job的时候,需要传入上一次执行的jobExecutionId
     */
    @JsonIgnore
    private String jobExecutionId;
}

正是这样的职责定义,才让jobExecutionId在ExecutionContext身上是合理的。因为它属于执行上下文,而不是Job的定义。纯粹的Job定义,不仅使得Job作为单例成为可能,也让我们的概念划分,职责定义更加清晰合理。类似的,在cola-component-statemachine,cola-component-ruleengine中,我们也有类似的Context定义,用来传递“用户数据”。放眼其它框架,你可以看到ServletContext,ApplicationContext等等,都是上下文设计模式在框架中的运用。因此,如果你要自己实现一个框架,上下文是一个不错的选择。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值