程序编排核心方式解析:从表达式到并行化,高德数据处理实践

探索程序编排的核心方法:表达式、类映射、流程配置及并行调度。本文结合高德数据处理实践,助您灵活构建数据系统。

原文标题:一文讲透程序编排的核心方式:从表达式语言到并行化实践

原文作者:阿里云开发者

冷月清谈:

本文深入探讨了程序编排的几种核心方式,包括表达式语言、类结构映射、流程化配置和并行化调度。首先介绍了使用表达式语言进行单语句编排,如MVEL、OGNL和Aviator,可以实现快速开发和动态计算。接着,讨论了使用model-view-builder框架进行类编排,通过函数式编程和树状结构关联加载类,可以解决手动拼接属性的问题。然后,阐述了有限状态机和语法树编排(Antlr4)在流程编排中的应用,以及使用规则引擎(Drools)和流程引擎(BPMN)进行引擎编排的实践。最后,介绍了并行化编排,包括CompletableFuture和Reactor,以及使用图的广度优先遍历进行算法编排的思路。文章旨在帮助读者理解各种编排方案的优缺点及适用场景,从而在实际项目中选择合适的编排方式。

怜星夜思:

1、文章提到了多种表达式引擎,例如MVEL、OGNL和Aviator,在实际项目中,你通常如何选择最适合的表达式引擎?性能、灵活性、易用性,你会如何权衡?
2、文章中提到了model-view-builder框架,但同时也指出了学习成本高、接口限制等缺点。有没有其他类似的框架或者设计模式,可以解决类属性自动关联的问题,并降低学习成本?
3、文章提到了多种编排方式,你认为在微服务架构下,哪种编排方式更适合用于服务之间的协作?为什么?

原文内容

阿里妹导读


高德的poi数据来源多种多样,处理流程也多种多样,但因流程相对固定,因此使用了流程化配置简化开发,使用表达式语言保证灵活性。为了加深对平台的理解,并帮助大家对编排有一定的了解,本文会以影响范围的视角去总结当前编排的方案。

引言

在构建复杂的数据处理系统或任务调度平台时,如何将业务逻辑高效、灵活地表达出来,并以可扩展的方式进行执行,是许多工程师和架构师面临的核心挑战之一。本文从“程序编排”的角度出发,探讨在实际项目中常见的几种核心编排方式——包括单语句表达式、类结构映射、流程化配置以及并行化调度,并结合一个真实系统的演进过程,分享我们在设计高灵活性任务处理引擎中的实践经验。

这些编排方式不仅适用于特定的数据处理场景,更是一种通用的系统设计思维。无论你是开发数据流水线、搭建自动化运营平台,还是构建低代码任务引擎,掌握这些模式,都能帮助你在面对复杂逻辑时更加游刃有余。

编排介绍

单语句编排(表达式语言)

表达式语句相对比较容易上手一些,其语法更接近 Java,而且有些表达式引擎还会将表达式编译成字节码,在执行速度和资源利用方面可能就更有优势。因此在业务没那么复杂,并且不想硬编码写的话,使用表达式语句性价比最高。犀牛系统中多处使用了表达式语句,通过数据库中配置表达式语句,提高系统灵活性。

MVEL(MVFLEX Expression Language)

MVEL是一个基于java语法的表达式,为JAVA语言提供便捷灵活的动态性,是部分规则引擎的底层调用。

使用示例如下:

        String expression = "score > 60";
        HashMap<String, Integer> vars = new HashMap<>(1);
        vars.put("score", 100);
        if ((Boolean)MVEL.eval(expression, vars)) {
            System.out.println("分数及格");
        }
OGNL(Object-Graph Navigation Language)

OGNL主要用于获取和设置Java对象的属性,主要通过反射的方式实现。

使用方式:

        Map<String, Object> map = new HashMap<>();
        map.put("key", "value");
        String expression = "#{key}";
        OgnlContext context = buildContext();
        Object result = Ognl.getValue(expression,context, map);
        System.out.println(result);
Aviator

Aviator是一门高性能、轻量级的Java语言实现的表达式动态求值引擎,可将表达式编译成字节码。其有两种执行方式: 编译执行和不编译执行。

使用方式: 

        String expression = "a * (b - c)";
        Map<String, Object> env = new HashMap<>();
        env.put("a", 3.3);
        env.put("b", 2.2);
        env.put("c", 1.1);
        int num = 10000;
        // 编译
        long l = System.nanoTime();
        Expression compliedExp = AviatorEvaluator.compile(expression);
        for (int i = 0; i < num; i++) {
            compliedExp.execute(env);
        }
        System.out.println((System.nanoTime() - l) / 1000000);
        l = System.nanoTime();
        //不编译
        for (int i = 0; i < num; i++) {
            AviatorEvaluator.execute(expression, env);
        }
        System.out.println((System.nanoTime() - l) / 1000000);

编译和不编译耗时相差7倍,但由于编译之后占用内存,首次编译加载耗时较长,因此需要根据调用次数以及编译成本,判断是否有必要进行编译。

总结:

如果追求性能,并且主要是简单的动态计算的话,不需要对对象进行操作,可以使用Aviator如果方法调用和对象属性的访问等,倾向于使用OGNL,其基于反射,操作比较灵活。而MCEL介于两者之间。 较为适合快速开发。当然以上限制也不是绝对的,如果追求性能可以对其进行优化,如在OGML或者MVEL加一层缓存,命中缓存可以加快响应速度。

类编排

在项目开发当中,经常会出现某个类的属性或几个属性是另外一个类的查询条件,无论是数据库查询还是rpc调用,需要显式手动拼接,并且由于使用范围不同,只会取某些类,随着项目的迭代,这往往会导致代码中混乱调用,以及业务中有众多相似代码,但如果统一在一起加载,例如使用聚合根的思想,又会造成某些不需要的类加载,造成读放发大。 本节介绍model-view-builder框架,来解决上述问题。

model-view-builder

github地址:  https://github.com/PhantomThief/model-view-builder,这是一个能力非常强大的框架,其使用函数式编程定义加载动作,树状结构关联加载类,懒加载延时构建。

函数式编排
SimpleModelBuilder builder = new SimpleModelBuilder<SimpleBuildContext>()
                .self(TaskInfo.class, TaskInfo::getTaskId)
                .on(TaskInfo.class).id(TaskInfo::getTaskId).to(Decision.class)
                .on(TaskInfo.class).id(TaskInfo::getTaskId).to(Disposal.class)
                .on(TaskInfo.class).id(TaskInfo::getTaskId).to(RiskControl.class)
                .on(TaskInfo.class).id(TaskInfo::getPoiId).to(DeepInfo.class)
                .on(TaskInfo.class).id(TaskInfo::getPoiId).to(PoiInfo.class)
                .build(Decision.class, decisionDao::getDecisionMap)
                .build(Disposal.class, disposalDao::getDisposalMap)
                .build(RiskControl.class, riskControlDao::getRiskControlMap)
                .build(DeepInfo.class, deepInfoDao::getDeepInfoMap)
                .build(PoiInfo.class, poiInfoDao::getPoiInfoMap);
树状结构关联

懒加载构建
builder = new SimpleModelBuilder<UgcBuildContext>()
        .self(A.class, A::getId)
        .lazyBuild(A.class,
                        (BuildContext context, Collection<String> ids)
                                -> BService.get(ids),
                        B.class)
//只有在调用获取的B的时候,才会执行BService.get取值逻辑
buildContext.get(B.class)

其中lazyBuild是懒加载,只有在读取的时候会调用;重复获取数据的时候也不会重复调用,可以避免读放大。

该框架使用多层Map维护类与函数,以及类与类之间的映射关系,使其具有编排、聚合的能力,这些能力天然就适合领域划分,通过领域划分确定边界作为上下文信息,在方法中传递使用,提高代码可读性的同时,也为研发人员提供高效获取数据的体验。

业务示例描述:有这么一个需求,先查询帖子,再查询评论,最后查询是否是粉丝。

构建modelBuilder

构建与读取结果

框架缺点同样也很明显:

  • 学习成本高, 需要熟悉函数式编程,才能熟练驾驭;

  • 接口限制,接口返回限定Map,需要接口改造和兼容(也可以改造框架适应);

  • 隐式调用,可读性不好;

在考虑引入该框架的时候,需要考虑以上缺点在团队中是否成问题,如果没问题,可放心使用,你大概率会成为它的重度用户。

流程编排

有限状态机编排

如果项目中有很多实体,每个实体都有很多状态,各状态会经过不同事件触发后转换到另一个状态,当实体状态或者状态转换变多时,处理这些状态的业务代码会分散在各处,往往会对开发和维护造成不小的挑战,有限状态机可以缓解这一情况。

使用状态机来表达状态的流转,语义会更加清晰,会增强代码的可读性和可维护性。

Stateless4j

stateless4j是一个轻量级的状态机库,其核心概念是基于枚举的事件和状态。

例子: 超级玛丽闯关

    publicfinalstatic StateMachineConfig<CurrentState,Trigger> config = new StateMachineConfig<>();

    static {
        //最初为小状态
        config.configure(CurrentState.SMALL)
            .permit(Trigger.MUSHROOM,CurrentState.BIG) //遇到蘑菇触发–>big状态
            .permit(Trigger.MONSTER,CurrentState.DEAD); //妖怪触发,死亡状态
        //最初为大状态
        config.configure(CurrentState.BIG)
            .ignore(Trigger.MUSHROOM)
            .permit(Trigger.FLOWER,CurrentState.ATTACH) //遇到花花,状态变为可攻击状态
            .permit(Trigger.MONSTER,CurrentState.SMALL); //遇到妖怪,状态变为small
        //最初为攻击状态
        config.configure(CurrentState.ATTACH)
            .ignore(Trigger.MUSHROOM)
            .ignore(Trigger.FLOWER)
            .permit(Trigger.MONSTER,CurrentState.SMALL); //遇到妖怪,状态变为small
        //最初为死亡状态
        config.configure(CurrentState.DEAD)
            .ignore(Trigger.MUSHROOM)
            .ignore(Trigger.FLOWER)
            .ignore(Trigger.MONSTER);

    }

代码逻辑如下:

    privatestatic StateMachine<CurrentState,Trigger> stateMachine = 
      new StateMachine<CurrentState, Trigger>(CurrentState.BIG,StateConver.config);
    publicstaticvoidmain(String[] args){
        stateMachine.fire(Trigger.FLOWER);
        System.out.println("currentState-->"+stateMachine.getState());
    }

当前状态为大马里奥,当遇到花之后,状态就变为可攻击了。

Cola-StateMachine

是一个功能丰富的状态机框架,支持XML和Java API定义状态机,提供图形化建模工具。

使用起来比较复杂,这里就贴一个github地址:https://github.com/spring-projects/spring-statemachine.git

总结:Cola-StateMachine比stateless4j功能更丰富,更完备,有更多高级的玩法。

语法树编排
Antlr4

Antlr4具有强大的语法分析能力,可以生成解析指定语言的Java代码;可用于解析和处理各种数据格式,帮助我们快速构建数据解析器,同时也有静态代码分析的能力。

使用示例:解析某文件格式

定义g4文件:

grammar load;    //定义规则文件grammar
//parsers
sta:(load ender)*;  //定义sta规则,里面包含了*(0个以上)个 load ender组合规则
ender:';';  //定义ender规则,是一个分号
load   //定义load
    :  LOAD format '.' path  as tableName //load语法规则,大致就是 load json.'path' as table1,load语法里面含有format,path, as,tableName四种规则
    ;    //load规则结束符
as: AS;   //定义as规则,其内容指向AS这个lexer
tableName: identifier;  //tableName 规则,指向identifier规则
format: identifier;   //format规则,也指向identifier规则
path: quotedIdentifier; //path,指向quotedIdentifier
identifier: IDENTIFIER | quotedIdentifier;  //identifier,指向lexer IDENTIFIER  或者parser quotedIdentifier
quotedIdentifier: BACKQUOTED_IDENTIFIER;  //quotedIdentifier,指向lexer BACKQUOTED_IDENTIFIER
//lexers antlr将某个句子进行分词的时候,分词单元就是如下的lexer
//keywords  定义一些关键字的lexer,忽略大小写
AS: [Aa][Ss];
LOAD: [Ll][Oo][Aa][Dd];
//base  定义一些基础的lexer,
fragment DIGIT:[0-9];   //匹配数字
fragment LETTER:[a-zA-Z];  //匹配字母
STRING        //匹配带引号的文本
    : '\'' ( ~('\''|'\\') | ('\\' .) )* '\''
    | '"' ( ~('"'|'\\') | ('\\' .) )* '"'
    ;
IDENTIFIER    //匹配只含有数字字母和下划线的文本
    : (LETTER | DIGIT | '_')+
    ;
BACKQUOTED_IDENTIFIER   //匹配被``包裹的文本
    : '`' ( ~'`' | '``' )* '`'
    ;
//--hiden  定义需要隐藏的文本,指向channel(HIDDEN)就会隐藏。这里的channel可以自定义,到时在后台获取不同的channel的数据进行不同的处理
SIMPLE_COMMENT: '--' ~[\r\n]* '\r'? '\n'? -> channel(HIDDEN);   //忽略行注释
BRACKETED_EMPTY_COMMENT: '/**/' -> channel(HIDDEN);  //忽略多行注释
BRACKETED_COMMENT : '/*' ~[+] .*? '*/' -> channel(HIDDEN) ;  //忽略多行注释
WS: [ \r\n\t]+ -> channel(HIDDEN);  //忽略空白符

// 匹配其他的不能使用上面的lexer进行分词的文本
UNRECOGNIZED: .;

文件自动生成:

覆盖BaseListen的enterLoad方法:

测试代码:

    publicstaticvoidmain(String[] args){
        String oriStr = "load json.`F:\\tmp\\temp.json` as temp;";
        ANTLRInputStream input = new ANTLRInputStream(oriStr);
        loadLexer lexer = new loadLexer(input);
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        loadParser parser = new loadParser(tokens);
        loadParser.LoadContext tree = parser.load();
        MyLoadListener listener = new MyLoadListener();
        ParseTreeWalker.DEFAULT.walk(listener,tree);
    }

结果:

可以看到这个框架非常强大,但是理解成本和实现成本比较高,如果你发现市面上没有框架实现你想要的编排能力,可以考虑使用该框架进行挑战。

引擎编排
Drools(规则引擎)

规则引擎的主要思想是将应用程序中的业务决策部分分离出来,使用指定的语句编写业务规则,由用户或开发者在需要时进行配置、管理。

drools是基于Java语言开发的开源规则引擎,将复杂的业务规则从硬编码中解放出来,以规则脚本的形式存储起来,当业务规则改变的时候不用修改代码。

示例如下:

规则配置:

//图书优惠规则
package book.discount
import drools.Order

//规则一:所购图书总价在100元以下的没有优惠
rule “book_discount_1”
    when
        $order:Order(originalPrice < 100)
    then
        $order.setRealPrice($order.getOriginalPrice());
        System.out.println(“成功匹配到规则一:所购图书总价在100元以下的没有优惠”);
end

//规则二:所购图书总价在100到200元的优惠20元
rule “book_discount_2”
    when
        $order:Order(originalPrice < 200 && originalPrice >= 100)
    then
        $order.setRealPrice($order.getOriginalPrice() - 20);
        System.out.println(“成功匹配到规则二:所购图书总价在100到200元的优惠20元”);
end

//规则三:所购图书总价在200到300元的优惠50元
rule “book_discount_3”
    when
        $order:Order(originalPrice <= 300 && originalPrice >= 200)
    then
        $order.setRealPrice($order.getOriginalPrice() - 50);
        System.out.println(“成功匹配到规则三:所购图书总价在200到300元的优惠50元”);
end

//规则四:所购图书总价在300元以上的优惠100元
rule “book_discount_4”
    when
        $order:Order(originalPrice >= 300)
    then
        $order.setRealPrice($order.getOriginalPrice() - 100);
        System.out.println(“成功匹配到规则四:所购图书总价在300元以上的优惠100元”);
end

测试代码:

        KieServices kieServices = KieServices.Factory.get();
        KieContainer kieClasspathContainer = kieServices.getKieClasspathContainer();
        //创建会话,用于和规则引擎交互
        KieSession kieSession = kieClasspathContainer.newKieSession();
        //构造订单对象,设置原始价格
        Order order = new Order();
        order.setOriginalPrice(210.0);
        //将数据提供给规则引擎,规则引擎会根据提供的数据进行规则匹配
        kieSession.insert(order);
        //激活规则引擎,如果规则匹配成功则执行规则
        kieSession.fireAllRules();
        //关闭会话
        kieSession.dispose();

        System.out.println(“优惠前原始价格:” + order.getOriginalPrice() +
            “,优惠后价格:” + order.getRealPrice());

结果:

配置文件和代码逻辑分开来,如果活动变动,只需要修改配置文件就行了。

BPMN(流程引擎)

流程引擎的关注的是业务流程,编排业务流程,使用文件或者指定语言规则进行配置。

示例代码: 犀牛系统中任务的调度流程:

其中streamMainProcess为xml文件的key。

调用逻辑:

其中STREAM_PROCESS_DEF_KEY=streamMainProcess。

流程的配置和代码逻辑分开,如果变动流程只需要变动xml文件就可以了。

虽然规则引擎和流程引擎都可以将相应配置分开,但当业务较为复杂时,会有不易维护的特点,因此需要更方便的可视化来帮助理解,代表引擎有ice(https://waitmoon.com/),通过二叉树组织,一个节点表示条件,另一个节点表示结果/动作。除此之外,规则引擎还有一定的性能问题,高并发业务下容易成为性能瓶颈。

并行化编排

如果有a,b,c,d四个线程,b,c运行的前提是a运行结束,d运行的前提是b,c运行结束。业务简单的话硬编码是没有问题的,但当并行线程达到十几个,你要如何进行进行编排呢,本节就此问题抛砖引玉,聊聊并行化编排。

CompleteFuture

CompletableFuture 是一种高级的并发工具,它允许你组合多个异步操作,创建复杂的异步流程。

使用示例如下:

        CompletableFuture<String> taskA = CompletableFuture.supplyAsync(() -> executeA());
        CompletableFuture<String> taskC = taskA.thenApplyAsync(result -> executeC());
        CompletableFuture<String> taskB = taskA.thenApplyAsync(result -> executeB());
        CompletableFuture<Void> voidCompletableFuture = 
          CompletableFuture.allOf(taskC, taskB).whenComplete((result1, result2) -> executeD());
        voidCompletableFuture.join();

可以看到CompletableFuture为我们提供了并发编排的能力,但如果线程数量较多,其维护性就比较差了。

Reactor

reactor是基于reactive stream规范实现的一个响应式编程框架,使用函数式编程的概念来操作数据流。相比于CompletableFuture,代码量少,流程更清晰,功能更加强大。

使用示例如下:

其实现了响应式编程模型思想,有自定义操作符,同时支持背压机制,能够控制生产消费速度,因此它更适合响应式开发以及复杂数据流处理。

算法编排

当线程数量比较多的时候,线程与线程之间相互关联,并以图的方式进行链接,开源框架也不适用此类场景。可以考虑使用图的广度优先遍历来进行编排,参考LeetCode207(https://leetcode.com/problems/course-schedule/description/)。

入参之需要描述线程与线程之前的关系,通过关系先建图,然后一层一层遍历图,每一层都并发执行,最后将结果汇总。

总结

编排相对比较小众,主要是因为在用它的时候既需要平衡性能和灵活性的关系,又要考虑学习成本和适应场景,考虑不当往往会为了用而用。 本文主要为了抛砖引玉,提供一些市面上流行的编排框架,供大家参考。

Lindorm泛时序数据一站式解决方案


随着业务增长带来的数据量激增,如何高效地获取和分析这些数据成为业务洞察和决策的关键挑战,Lindorm作为阿里云自研的云原生多模数据库,具备低成本存储、弹性高可用的能力,提供一站式的分析与洞察。    


点击阅读原文查看详情。

从学术角度讲,选择表达式引擎涉及到一个多目标优化问题。性能、灵活性和易用性可以看作是三个目标函数,需要根据实际需求设定权重。例如,在需要高并发处理的场景下,性能的权重会更高,而在快速迭代的场景下,易用性的权重会更高。此外,还需要考虑引擎的安全性和可扩展性,以及与现有系统的兼容性。

这取决于具体的业务场景。如果对性能要求很高,而且表达式主要是一些简单的数学计算,我会优先考虑Aviator,毕竟它能编译成字节码。如果需要频繁操作Java对象,比如调用方法或者访问属性,OGNL会更方便,虽然性能可能稍逊。如果追求快速开发,MVEL可能更适合,因为它在两者之间找到了平衡。另外,我会考虑团队成员对这些引擎的熟悉程度,毕竟上手快的工具能更快产生价值。

其实这个问题本质上是在解决对象关系映射(ORM)的问题。传统的Hibernate之类的ORM框架虽然强大,但也比较重。现在有一些轻量级的ORM框架,比如MyBatis,通过XML或者注解配置SQL语句,可以灵活地实现类属性的关联。另外,我个人比较推崇使用领域驱动设计(DDD)的思想,通过领域模型的划分,将相关的类聚合在一起,从而避免属性的过度分散。

我更倾向于使用事件驱动架构。每个微服务都可以发布和订阅事件,服务之间的协作通过事件的触发和响应来实现。这种方式能够实现服务之间的解耦,提高系统的可扩展性和容错性。可以使用消息队列(如Kafka、RabbitMQ)来实现事件的发布和订阅。
当然,事件驱动架构也有一些缺点,比如难以追踪事件的流向、需要处理事件的幂等性等。

在微服务架构下,我觉得流程引擎(BPMN)可能更适合。因为微服务通常是独立部署和演进的,服务之间的协作需要一个清晰、可视化的流程来管理。BPMN可以通过图形化的方式描述服务之间的调用关系、数据流转和异常处理,方便团队成员理解和维护。而且,一些BPMN引擎还支持热部署和动态修改流程,可以灵活应对业务变化。

我觉得可以考虑一下使用MapStruct这样的对象转换工具。虽然它主要用于DTO和DO之间的转换,但其实也可以用来做类属性的自动关联。通过配置简单的映射规则,就可以实现属性值的自动复制,而且学习成本不高,容易上手。
另外,如果项目使用了Spring框架,可以考虑使用Spring Data JPA的@Query注解,结合SpEL表达式,也能实现类似的功能。

从架构演进的角度来看,我觉得应该优先考虑使用API组合的方式。通过API网关将多个微服务的API组合成一个新的API,提供给客户端使用。这种方式简单直接,易于理解和维护。但是,如果服务之间的协作逻辑非常复杂,或者需要跨多个服务进行事务处理,那么可能需要考虑使用Saga模式或者编排器模式。

可以试试GraphQL,虽然用GraphQL的成本也不低,但是它能够精确的描述需要的数据,不多不少,也算是解决了类似问题吧?另外,也可以考虑一下使用组合模式或者装饰器模式,将多个类的属性组合成一个新的类,从而实现属性的自动关联。不过,这种方式需要手动编写更多的代码,灵活性相对较低。

我一般是先看哪个引擎我最熟练… 开玩笑啦!其实我会做一个简单的benchmark测试,模拟实际场景中的表达式计算,对比一下性能数据。然后,我会评估一下表达式的复杂度,如果表达式很复杂,我会更倾向于选择功能更强大的引擎,比如OGNL。最后,我会考虑一下维护成本,选择社区活跃、文档完善的引擎,这样遇到问题更容易解决。