【PowerJob语雀转载】处理器(Processor)开发

56 阅读13分钟

处理器概述

基本概念

PowerJob 支持 Python、Shell、HTTP、SQL 等众多通用任务的处理,开发者只需要引入依赖,在控制台配置好相关参数即可,关于这部分详见 官方处理器 ,此处不再赘述。本章将重点阐述 Java 处理器开发方法与使用技巧。

  • Java 处理器可根据代码所处位置划分为内置 Java 处理器和外置 Java 处理器,前者直接集成在宿主应用(也就是接入本系统的业务应用)中,一般用来处理业务需求;后者可以在一个独立的轻量级的 Java 工程中开发,通过 JVM 容器技术(详见容器章节)被 worker 集群热加载,提供 Java 的“脚本能力”,一般用于处理灵活多变的需求。
  • Java 处理器可根据对象创建者划分为 SpringBean 处理器和普通 Java 对象处理器,前者由 Spring IOC 容器完成处理器的创建和初始化,后者则由 PowerJob 维护其生命周期。如果宿主应用支持 Spring,强烈建议使用 SpringBean 处理器,开发者仅需要将 Processor 注册进 Spring IOC 容器(一个 @Component 注解或一句 bean 配置)即可享受 Spring 带来的便捷之处。
  • Java处理器可根据功能划分为单机处理器、广播处理器、Map 处理器和 MapReduce 处理器。
    • 单机处理器(BasicProcessor)对应了单机任务,即某个任务的某次运行只会有某一台机器的某一个线程参与运算。

    • 广播处理器(BroadcastProcessor)对应了广播任务,即某个任务的某次运行会调动集群内所有机器参与运算

    • Map处理器(MapProcessor)对应了Map任务,即某个任务在运行过程中,允许产生子任务并分发到其他机器进行运算

    • MapReduce 处理器(MapReduceProcessor)对应了 MapReduce 任务,在 Map 任务的基础上,增加了所有任务结束后的汇总统计

核心方法 process

BasicProcessor#process 是任意 Java 处理器都需要实现的核心方法,描述了本次任务具体的工作内容,其方法签名如下:

ProcessResult process(TaskContext context) throws Exception;

入参 TaskContext

TaskContext 包含了本次任务的上下文信息,具体信息如下

属性列表(红色标注的为常用属性)
属性名称意义/用法
jobId任务 ID,开发者一般无需关心此参数
instanceId任务实例 ID,全局唯一,开发者一般无需关心此参数
subInstanceId子任务实例 ID,秒级任务使用,开发者一般无需关心此参数
taskId采用链式命名法的 ID,在某个任务实例内唯一,开发者一般无需关心此参数
taskNametask 名称,Map/MapReduce 任务的子任务的值为开发者指定,否则为系统默认值,开发者一般无需关心此参数
jobParams任务参数对于非工作流中的任务其值等同于控制台录入的任务参数; 如果该任务为工作流中的任务且有配置节点参数信息,那么接收到的是节点配置的参数信息
instanceParams任务实例参数对于非工作流中的任务 其值 等同于 OpenAPI 传递的实例参数,非 OpenAPI 触发的任务则一定为空。 如果该任务为工作流中的任务那么这里实际接收到的是工作流上下文信息,建议使用 getWorkflowContext 方法获取上下文信息
maxRetryTimesTask 的最大重试次数
currentRetryTimesTask 的当前重试次数,和 maxRetryTimes 联合起来可以判断当前是否为该 Task 的最后一次运行机会
subTask子 Task,Map/MapReduce 处理器专属,开发者调用map方法时传递的子任务列表中的某一个
omsLogger在线日志,用法同 Slf4J,记录的日志可以直接通过控制台查看,非常便捷和强大!不过使用过程中需要注意频率,滥用在线日志会对 Server 造成巨大的压力
userContext用户在 PowerJobWorkerConfig 中设置的自定义上下文
workflowContext工作流上下文,更多信息见下方说明
工作流上下文( WorkflowContext )

该属性是 v4.0.0 版本的重大变更之一,移除了原来的参数传递机制,提供了 API 让开发者可以更加灵活便捷地在工作流中实现信息的传递。

属性列表
属性名称意义/用法
wfInstanceId工作流实例 ID
data工作流上下文数据,键值对
appendedContextData当前任务向工作流上下文中追加的数据。在任务执行完成后 ProcessorTracker 会将其上报给 TaskTracker,TaskTracker 在当前任务执行完成后会将这个信息上报给 server ,追加到当前的工作流上下文中,供下游任务消费

上游任务通过 WorkflowContext#appendData2WfContext(String key,Object value)  方法向工作流上下文中追加数据,下游任务便可以通过 WorkflowContext#fetchWorkflowContext()  方法获取到相应的数据进行消费。注意,当追加的上下文信息的 key 已经存在于当前的上下文中时,新的 value 会覆盖之前的值。另外,每次任务实例追加的上下文数据大小也会受到 worker 的配置项 powerjob.worker.max-appended-wf-context-length 的限制,超过这个长度的会被直接丢弃

返回值 ProcessResult

方法的返回值为 ProcessResult,代表了本次 Task 执行的结果,包含 successmsg 两个属性,分别用于传递 Task 是否执行成功和 Task 需要返回的信息。

处理器开发示例

单机处理器:BasicProcessor

单机执行的策略下,server 会在所有可用 worker 中选取健康度最佳的机器进行执行。单机执行任务需要实现接口 BasicProcessor,代码示例如下:

// 支持 SpringBean 的形式
@Component
public class BasicProcessorDemo implements BasicProcessor {

    @Resource
    private MysteryService mysteryService;

    @Override
    public ProcessResult process(TaskContext context) throws Exception {

        // 在线日志功能,可以直接在控制台查看任务日志,非常便捷
        OmsLogger omsLogger = context.getOmsLogger();
        omsLogger.info("BasicProcessorDemo start to process, current JobParams is {}.", context.getJobParams());
        
        // TaskContext为任务的上下文信息,包含了在控制台录入的任务元数据,常用字段为
        // jobParams(任务参数,在控制台录入),instanceParams(任务实例参数,通过 OpenAPI 触发的任务实例才可能存在该参数)

        // 进行实际处理...
        mysteryService.hasaki();

        // 返回结果,该结果会被持久化到数据库,在前端页面直接查看,极为方便
        return new ProcessResult(true, "result is xxx");
    }
}

广播处理器:BroadcastProcessor

广播执行的策略下,所有机器都会被调度执行该任务。为了便于资源的准备和释放,广播处理器在BasicProcessor 的基础上额外增加了 preProcesspostProcess 方法,分别在整个集群开始之前/结束之后选一台机器执行相关方法。代码示例如下:

@Component
public class BroadcastProcessorDemo implements BroadcastProcessor {

    @Override
    public ProcessResult preProcess(TaskContext taskContext) throws Exception {
        // 预执行,会在所有 worker 执行 process 方法前调用
        return new ProcessResult(true, "init success");
    }

    @Override
    public ProcessResult process(TaskContext context) throws Exception {
        // 撰写整个worker集群都会执行的代码逻辑
        return new ProcessResult(true, "release resource success");
    }

    @Override
    public ProcessResult postProcess(TaskContext taskContext, List<TaskResult> taskResults) throws Exception {

        // taskResults 存储了所有worker执行的结果(包括preProcess)

        // 收尾,会在所有 worker 执行完毕 process 方法后调用,该结果将作为最终的执行结果
        return new ProcessResult(true, "process success");
    }
}

并行处理器:MapReduceProcessor

MapReduce 是最复杂也是最强大的一种执行器,它允许开发者完成任务的拆分,将子任务派发到集群中其他Worker 执行,是执行大批量处理任务的不二之选!实现 MapReduce 处理器需要继承 MapReduceProcessor类,具体用法如下示例代码所示:

@Slf4j
@Component("demoMapReduceProcessor")
public class MapReduceProcessorDemo implements MapReduceProcessor {

    @Override
    public ProcessResult process(TaskContext context) throws Exception {

        // PowerJob 提供的日志 API,可支持在控制台指定多种日志模式(在线查看 / 本地打印)。最佳实践:全部使用 OmsLogger 打印日志,开发阶段控制台配置为 在线日志方便开发;上线后调整为本地日志,与直接使用 SLF4J 无异
        OmsLogger omsLogger = context.getOmsLogger();

        // 是否为根任务,一般根任务进行任务的分发
        boolean isRootTask = isRootTask();
        // Task 名称,除了 MAP 任务其他 taskName 均由开发者自己创建,某种意义上也可以按参数理解(比如多层 MAP 的情况下,taskName 可以命名为,Map_Level1, Map_Level2,最终按 taskName 判断层级进不同的执行分支)
        String taskName = context.getTaskName();
        // 任务参数,控制台任务配置中直接填写的参数
        String jobParamsStr = context.getJobParams();
        // 任务示例参数,运行任务时手动填写的参数(等同于 OpenAPI runJob 的携带的参数)
        String instanceParamsStr = context.getInstanceParams();

        omsLogger.info("[MapReduceDemo] [startExecuteNewTask] jobId:{}, instanceId:{}, taskId:{}, taskName: {}, RetryTimes: {}, isRootTask:{}, jobParams:{}, instanceParams:{}", context.getJobId(), context.getInstanceId(), context.getTaskId(), taskName, context.getCurrentRetryTimes(), isRootTask, jobParamsStr, instanceParamsStr);

        // 常见写法,优先从 InstanceParams 获取参数,取不到再从 JobParams 中获取,灵活性最佳(相当于实现了实例参数重载任务参数)
        String finalParams = StringUtils.isEmpty(instanceParamsStr) ? jobParamsStr : instanceParamsStr;
        final JSONObject params = Optional.ofNullable(finalParams).map(JSONObject::parseObject).orElse(new JSONObject());

        if (isRootTask) {

            omsLogger.info("[MapReduceDemo] [RootTask] start execute root task~");

            /*
             * rootTask 内的核心逻辑,即为按自己的业务需求拆分子任务。比如
             *  - 从数据库/数仓拉一批任务出来做计算,那 MAP 任务就可以 stream 读全库,每 N 个 ID 作为一个 SubTask 对外分发
             *  - 需要读取几千万个文件进行解析,那么 MAP 任务就可以将 N 个文件名作为一个 SubTask 对外分发,每个子任务接收到文件名称进行文件处理
             *
             * eg. 现在需要从文件中读取100W个ID,并处理数据库中这些ID对应的数据,那么步骤如下:
             * 1. 根任务(RootTask)读取文件,流式拉取100W个ID,并按100个一批的大小组装成子任务进行派发
             * 2. 非根任务获取子任务,完成业务逻辑的处理
             *
             * 以下 demo 进行该逻辑的模拟
             */


            // 构造子任务

            // 需要读取的文件总数
            Long num = MapUtils.getLong(params, "num", 100000L);
            // 每个子任务携带多少个文件ID(此参数越大,每个子任务就“越大”,如果失败的重试成本就越高。参数越小,每个子任务就越轻,当相应的分片数量会提升,会让 PowerJob 计算开销增大,建议按业务需求合理调配)
            Long batchSize = MapUtils.getLong(params, "batchSize", 100L);

            // 此处模拟从文件读取 num 个 ID,每个子任务携带 batchSize 个 ID 作为一个分片
            List<Long> ids = Lists.newArrayList();
            for (long i = 0; i < num; i++) {
                ids.add(i);

                if (ids.size() >= batchSize) {

                    // 构造自己的子任务,自行传递所有需要的参数
                    SubTask subTask = new SubTask(ThreadLocalRandom.current().nextLong(), Lists.newArrayList(ids), "extra");
                    ids.clear();

                    try {
                        /*
                        第一个参数:List<子任务>,map 支持批量操作以减少网络 IO 提升性能,简单起见此处不再示例,开发者可自行优化性能
                        第二个参数:子任务名称,即后续 Task 执行时从 TaskContext#taskName 拿到的值。某种意义上也可以按参数理解(比如多层 MAP 的情况下,taskName 可以命名为,Map_Level1, Map_Level2,最终按 taskName 判断层级进不同的执行分支)
                         */
                        map(Lists.newArrayList(subTask), "L1_FILE_PROCESS");
                    } catch (Exception e) {
                        // 注意 MAP 操作可能抛出异常,建议进行捕获并按需处理
                        omsLogger.error("[MapReduceDemo] map task failed!", e);
                        throw e;
                    }
                }
            }

            if (!ids.isEmpty()) {
                map(Lists.newArrayList(new SubTask()), "L1_FILE_PROCESS");
            }

            // map 阶段的结果,由于前置逻辑为异常直接抛出,执行到这里一定成功,所以无脑设置为 success。开发者可自行调整逻辑
            return new ProcessResult(true, "MAP_SUCCESS,totalNum:" + num);

        }

        // 如果是简单的二层结构(ROOT - SubTASK),此处一定是子 Task,无需再次判断。否则可使用 TaskContext#taskName 字符串匹配 或 TaskContext#SubTask 对象内自定义参数匹配,进入目标执行分支

        // 获取前置节点 map 传递过来的参数,进行业务处理
        SubTask subTask = (SubTask) context.getSubTask();
        log.info("[MapReduceDemo] [SubTask] taskId:{}, taskName: {}, subTask: {}", context.getTaskId(), taskName, JsonUtils.toJSONString(subTask));
        Thread.sleep(MapUtils.getLong(params, "bizProcessCost", 233L));

        // 模拟有成功有失败的情况,开发者按真实业务执行情况判断即可
        long successRate = MapUtils.getLong(params, "successRate", 80L);
        long randomNum = ThreadLocalRandom.current().nextLong(100);
        if (successRate > randomNum) {
            return new ProcessResult(true, "PROCESS_SUCCESS:" + randomNum);
        } else {
            return new ProcessResult(false, "PROCESS_FAILED:" + randomNum);
        }
    }

    @Override
    public ProcessResult reduce(TaskContext context, List<TaskResult> taskResults) {

        // 子任务结果太大,上报在线日志会有 IO 问题,直接使用本地日志打
        log.info("List<TaskResult>: {}", JSONObject.toJSONString(taskResults));

        OmsLogger omsLogger = context.getOmsLogger();
        omsLogger.info("================ MapReduceProcessorDemo#reduce ================");

        // 所有 Task 执行结束后,reduce 将会被执行,taskResults 保存了所有子任务的执行结果。(注意 reduce 由于保存了所有子任务的执行结果,在子任务规模巨大时对内存有极大开销,超大型计算任务慎用或使用流式 reduce(开发中))

        // 用法举例:统计执行结果
        AtomicLong successCnt = new AtomicLong(0);
        AtomicLong failedCnt = new AtomicLong(0);
        taskResults.forEach(tr -> {
            if (tr.isSuccess()) {
                successCnt.incrementAndGet();
            } else {
                failedCnt.incrementAndGet();
            }
        });


        double successRate = 1.0 * successCnt.get() / (successCnt.get() + failedCnt.get());

        String resultMsg = String.format("succeedTaskNum:%d,failedTaskNum:%d,successRate:%f", successCnt.get(), failedCnt.get(), successRate);
        omsLogger.info("[MapReduceDemo] [Reduce] {}", resultMsg);

        // reduce 阶段的结果,将作为任务真正执行结果
        if (successRate > 0.8) {
            return new ProcessResult(true, resultMsg);
        } else {
            return new ProcessResult(false, resultMsg);
        }

    }


    /**
     * 自定义的子任务,按自己的业务需求定义即可
     * 注意:代表子任务参数的类:一定要有无参构造方法!一定要有无参构造方法!一定要有无参构造方法!
     * 最好把 GET / SET 方法也加上,减少序列化问题的概率
     */
    @Data
    @AllArgsConstructor
    public static class SubTask implements Serializable {

        /**
         * 再次强调,一定要有无参构造方法
         */
        public SubTask() {
        }

        private Long siteId;

        private List<Long> idList;

        private String extra;
    }
}

注:Map 处理器相当于 MapReduce 处理器的阉割版本(阉割了 reduce 方法),此处不再单独举例。

  • reduce 阶段需要全量读取子任务结果,会对内存一定要求。如果只需要计算,不需要二次统计或自行完成统计,推荐使用 Map 处理器即可,对执行节点性能更友好。

最佳实践:MapReduce 实现静态分片

虽然说这有点杀鸡焉用牛刀的感觉,不过既然目前市面上同类产品都处于静态分片的阶段,我也就在这里给大家举个例子吧~

@Component
public class StaticSliceProcessor implements MapReduceProcessor {

    @Override
    public ProcessResult process(TaskContext context) throws Exception {
        OmsLogger omsLogger = context.getOmsLogger();
        
        // root task 负责分发任务
        if (isRootTask()) {
            // 从控制台传递分片参数,假设格式为KV:1=a&2=b&3=c
            String jobParams = context.getJobParams();
            Map<String, String> paramsMap = Splitter.on("&").withKeyValueSeparator("=").split(jobParams);

            List<SubTask> subTasks = Lists.newLinkedList();
            paramsMap.forEach((k, v) -> subTasks.add(new SubTask(Integer.parseInt(k), v)));
			map(subTasks, "SLICE_TASK");
			return new ProcessResult(true, "ROOT_PROCESS_SUCCESS");
        }

        Object subTask = context.getSubTask();
        if (subTask instanceof SubTask) {
            // 实际处理
            // 当然,如果觉得 subTask 还是很大,也可以继续分发哦
            
            return new ProcessResult(true, "subTask:" + ((SubTask) subTask).getIndex() + " process successfully");
        }
        return new ProcessResult(false, "UNKNOWN BUG");
    }

    @Override
    public ProcessResult reduce(TaskContext context, List<TaskResult> taskResults) {
        // 按需求做一些统计工作... 不需要的话,直接使用 Map 处理器即可
        return new ProcessResult(true, "xxxx");
    }

    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class SubTask {
        private int index;
        private String params;
    }
}

最佳实践:MapReduce 多级分发处理

利用 MapReduce 实现 Root -> A -> B/C -> Reduce)的 DAG 工作流。

注:PowerJob 现已提供正式的 Workflow 工作流支持(since v2.0.0)

@Component
public class DAGSimulationProcessor implements MapReduceProcessor {

    @Override
    public ProcessResult process(TaskContext context) throws Exception {

        if (isRootTask()) {
            // L1. 执行根任务

            // 执行完毕后产生子任务 A,需要传递的参数可以作为 TaskA 的属性进行传递
            TaskA taskA = new TaskA();
			map(Lists.newArrayList(taskA), "LEVEL1_TASK_A");
			return new ProcessResult(true, "L1_PROCESS_SUCCESS");
        }

        if (context.getSubTask() instanceof TaskA) {
            // L2. 执行A任务

            // 执行完成后产生子任务 B,C(并行执行)
            TaskB taskB = new TaskB();
            TaskC taskC = new TaskC();
            map(Lists.newArrayList(taskB, taskC), "LEVEL2_TASK_BC");
            return new ProcessResult(true, "L2_PROCESS_SUCCESS");
        }

        if (context.getSubTask() instanceof TaskB) {
            // L3. 执行B任务
            return new ProcessResult(true, "xxx");
        }
        if (context.getSubTask() instanceof TaskC) {
            // L3. 执行C任务
            return new ProcessResult(true, "xxx");
        }

        return new ProcessResult(false, "UNKNOWN_TYPE_OF_SUB_TASK");
    }

    @Override
    public ProcessResult reduce(TaskContext context, List<TaskResult> taskResults) {
        // L4. 执行最终 Reduce 任务,taskResults保存了之前所有任务的结果
        taskResults.forEach(taskResult -> {
            // do something...
        });
        return new ProcessResult(true, "reduce success");
    }

    public static class TaskA {
    }
    public static class TaskB {
    }
    public static class TaskC {
    }
}

更多示例

没看够?更多示例请见:powerjob-worker-samples