百度统计
一面之猿网
让这个世界,因为我,有一点点的不一样
纯序员给你介绍图化框架的简单实现——面向切面

大家好,我是不会写代码的纯序员——Chunel Feng。各位绅士们,很高兴又在这里见面了。

十一放假期间,励志要精通Java的我,学习了一个叫春(Spring)的框架。第一章的内容就是介绍了IoC(Inversion of Control,依赖控制翻转)和AOP(Aspect Oriented Programming,面向切面编程)。看的我内心十分冲动,想着今后转行做Java开发,赚大钱的日子,仿佛春天真的要来了。至于第二章的内容是什么我就不知道了,因为我还没看。

不过,事后我想,AOP这种思想,在图执行框架中也很有用。于是,我们手动在CGraph中实现了切面(GAspect)功能,对图中节点的功能,进行了横向非入侵的扩展,从而实现了极大的增强。

首先,还是照例,先上源码链接:CGraph源码链接

功能介绍

考虑到一些写C++的童鞋,实际上并没有接触过切面,我们先来简单介绍一下aspect切面。

我们常说的面向对象(OOP)编程,是针对一类具体的事物,抽象出一个实际的对象,从而进行封装。我举个例子:

class Player {
    virtual void play() = 0;    // 提供纯虚接口
}

class BasketballPlayer : public Player {
    void play() override {
        printf("i can play basketball.");
    }
}

class FootballPlayer : public Player {
    void play() override {
        printf("i can play football.");
    }
}

class ChineseFootballPlayer : public FootballPlayer {
    void play() override {
        printf("sorry, i cannot play football. but i can play b*tch.");
    }
}

看上面一段代码,很简单,就是对Player这个类,抽象出了一个play()的方法。无论Player这个类被谁继承、继承了多少层,每种Player都会实现一个play()方法,在被调用的时候执行。

我们再往下想一步:“在被调用的时候” 这句话,被调用的动作(也就是play)被抽象出来了,但是调用前(也就是play函数执行前)发生了什么,调用后又会怎样?一种很容易的想法,是把不同Player调用play()前后的逻辑,都写在play()这个方法中,这样就可以对不同Player子类的play()方法功能,进行自定义设定。

不过这又引出一个问题:有几种Player在play()之前的动作是一致的,而另外几种Player前后动作,是不一致的。比如,BasketballPlayer和FootballPlayer在play前,都是【训练】,在play后是【休息】。而ChineseFootballPlayer在play前,是【逛夜店】,而play后是【开发布会道歉】。

再抽象一个play前的方法和play后的方法?Duck不必,这样做,一方面是会有代码冗余,另一方面是会破坏Player类的抽象逻辑和可解释性。通常推荐的做法,是加入切面逻辑。

image.png

在这个例子中,play()前和play()后,就是所谓的【切点】,在切点处添加对应的逻辑,这就是AOP的思想。如果说,OOP是抽象通用事物逻辑的纵向编程的话,那AOP就是在横向上,对逻辑进行的通用扩展。

实现逻辑

我们刚才提到,cpp里是没有原生的aspect(切面)逻辑的,我们要手动实现一个。实现切面的思路又有很多,比如通过Proxy(代理)、Adaptor(适配器)、Decorator(装饰器)、if/else等,需要注意以上设计模式的异同。

设计模式这东西,本来就是见仁见智,不好一概而论。简单介绍一下我眼中,以上几种模式的区别:

  • Proxy 侧重于对 原有类的功能 扩充,如:添加校验
  • Adaptor 侧重于对 原有类的接口 的适配和改动
  • Decorator 侧重于对 原有类的具体某个对象 功能的改动和扩展

考虑到最终是在GElement类型的对象特定方法前后添加一个或多个切面,而且切面自身不需要实现注册、执行等因素,CGraph中最终采用的是Decorator模式。

image.png

如上图,GAspect是具体切面的实现类的基类;GAspectManager是对应的管理类,其中包含了一个或者多个GAspect对象指针,并且以懒加载的形式附着在GElement类中,在相应切点的位置执行。

class GAspect : public GAspectObject {
public:
    virtual CSTATUS beginInit() {
        return STATUS_OK;
    }

    virtual void finishInit(CSTATUS curStatus) {}

    virtual CSTATUS beginRun() {
        return STATUS_OK;
    }

    virtual void finishRun(CSTATUS curStatus) {}

    virtual CSTATUS beginDeinit() {
        return STATUS_OK;
    }

    virtual void finishDeinit(CSTATUS curStatus) {}
};

看一下上面这段代码,我们之前说过,每个GElement的子类,都有三个函数,依次是:init、run和deinit。其中,init和deinit方法均为单次执行,run可循环执行多次。再联系我们刚才说的内容,这三个方法开始(begin)和执行结束(finish)的位置,天然的形成了6个切面。每个Aspect的实现类,可以选择其中的一个或者几个方法实现。

在设计切面接口的时候,我们将所有begin_的方法都设置为CSTATUS返回值,目的是可以添加一些截断逻辑(比如,参数阈值校验),随时调控下游的执行。而所有的finish_对应的接口,入参均设置为CSTATUS,目的是可以记录具体方法执行的结果。

切面参数

还有一个功能点需要考量:同样功能的切面,很可能需要切的内容不一样。比如,测试网络是否联通的切面吧,就会遇到相同功能的Aspect需要传入不同值(ip和port)的参数。又比如,校验pipeline中某个参数值是否为空或超过max值的切面吧,就需要传入不同的待校验的参数。

为此,GAspect中还提供了内部参数注册的逻辑,和获取对应pipeline中参数的逻辑,从而实现可以更方便的实现远程连接、标准化日志埋点、统一参数校验等功能。

class MyConnAspect : public GAspect {
public:
    CSTATUS beginInit() override {
        auto* param = this->getParam<MyConnAspectParam>();
        if (param) {
            // 如果传入类型不匹配,则返回param值为空
            mockConnect(param->ip, param->port);
        }

        return STATUS_OK;
    }

    void finishDeinit(CSTATUS curStatus) override {
        auto* param = this->getParam<MyConnAspectParam>();
        if (param) {
            mockDisconnect(param->ip, param->port);
        }
    }
};

void tutorial_aspect_param() {
    GPipelinePtr pipeline = GPipelineFactory::create();
    MyConnAspectParam paramA;
    paramA.ip = "127.0.0.1";
    paramA.port = 6666;

    MyConnAspectParam paramB;
    paramB.ip = "255.255.255.255";
    paramB.port = 9999;

    GElementPtr a, b = nullptr;
    pipeline->registerGElement<MyNode1>(&a, {}, "nodeA", 1);
    pipeline->registerGElement<MyNode2>(&b, {a}, "nodeB", 1);

    /** 给a节点添加 MyConnAspect 切面的逻辑,并且传入 paramA 相关参数 */
    a->addGAspect<MyConnAspect, MyConnAspectParam>(&paramA);

    b->addGAspect<MyConnAspect, MyConnAspectParam>(&paramB);
    pipeline->process();
    GPipelineFactory::clear();
}

看上面这两段代码,就实现了参数往同一个切面(MyConnAspect)传递不同参数(paramA和paramB)的逻辑,模拟的是在不同的节点中,通过相同的切面去连接不同 ip+port 的逻辑。

源码的/tutoral/文件夹中,还包含了其他有意思的 添加切面、切面抓取pipeline中参数 的例子,有兴趣的可以看一看T09-AspectT10-AspectParam

切面使用

在CGraph中添加切面,作用跟Spring中其实是类似的:都是为了在横向层面,对逻辑节点做通用功能的扩充。

至于说具体可以扩充哪些层面,比如:统一格式的日志埋点、trace链路信息、运行耗时记录、鉴权问题、参数校验问题等,这些都是跟具体功能节点无关,但是又非常标准化和通用化的横向逻辑。

举两个例子吧:

一、算子运行耗时分析

【需求】
我们需要以一种统一的日志格式,记录某个pipeline中的每个GNode算子的运行耗时,且日志格式后期可能会频繁改动。

【分析】
这个需求很常见吧。最简单的做法,是在每个算子的run()方法中,添加计时逻辑,并在run()执行的最后打印对应日志。

但是,如果每个算子都实现一遍这种相同逻辑,是不是很冗余?如果后期要修改输出格式,怎么办?每个地方都修改一下么?

还有一种方法,是封装一个输出计时日志的类,在run方法中调用。那如果同样是A算子,有时候需要输出日志,有时候不需要输出日志,如何控制?在日志类中加入开关么?那在哪里控制这个开关呢?

所以啊,还是用注册切面的方式吧。

【实现】
制作一个Timer切面类,添加到具体代码如下:

class MyTimerAspect : public GAspect {
public:
    /**
     * 实现计时切面逻辑,打印 run() 方法的执行时间
     */
    CSTATUS beginRun() override {
        start_ts_ = std::chrono::high_resolution_clock::now();
        return STATUS_OK;
    }

    void finishRun(CSTATUS curStatus) override {
        std::chrono::duration<double, std::milli> time_span = std::chrono::high_resolution_clock::now() - start_ts_;
        CGRAPH_ECHO("----> [MyTimerAspect] [%s] time cost is : [%0.2lf] ms", this->getName().c_str(), time_span.count());
    }

private:
    std::chrono::high_resolution_clock::time_point start_ts_;
};

这样就可以反复利用了,在想要输出计时日志的算子里,加上这个切面就可以了。今后如果要修改日志格式的话,只要修改CGRAPH_ECHO这一句话就可以了啊。

二、链路信息追踪

【需求】
pipeline中运行报错(返回值不是STATUS_OK),设计一个功能,快速定位出具体是哪个方法返回异常。已知,pipeline中注册了100+算子。

【分析】
最容易想到的方法,就是在判断状态的地方,加入一条打印信息。但是有一个问题,我在每个算子里加个功能么?当我不需要定位的时候,再一条一条的删么?

还有一个问题,比如:经过粗定位,100+个算子中,只有15个算子可能有问题。那咋办,有的改,有的不改么?

所以啊,还是使用切面逻辑吧。

【实现】
实现一个Trace切面,参考链接:Trace切面简单实现 ,在对应的地方输出信息,然后添加到需要的算子中即可。

还有一个比较现实的问题,100个算子中,每个都可能出问题,我还要写100次注册逻辑么?CGraph中还提供了算子批量注册切面的逻辑,直接调用GPipeline中的addGAspectBatch()方法,传入需要添加切面的算子即可,无参数表示所有pipeline内部的节点,都添加。

本章小节

本章内容,我们主要介绍了在CGraph中,切面(GAspect)功能的一些实现思路和用法。主要涉及到切面添加、切面参数和批量添加切面等逻辑。更多的更有趣的用法和例子,欢迎来github上查看源码:CGraph源码链接。我们之所以要在在十一期间花大力气去实现这一套切面逻辑,目的主要是为了今后更好的实现CGraph分布式中跨进程通信的功能。

AOP编程思路,是OOP思路的重要补充,主要是解决了一些非实体不可抽象的通用逻辑的复用问题。Java中可以通过注解的形式实现,Python中可以采取装饰器实现,C++自身并没有一切皆对象的机制,可以采取手动敲代码的方式实现——可怜的cpp程序员。不过也没关系,工作中,我们可以写Python或Java来实现具体逻辑鸭,哈哈。

最后,还是很欢迎大家添加我的个人微信,今后方便多多交流,请多多指教鸭。

image

    					[2021.10.07 by Chunel]

推荐阅读


个人信息

微信: ChunelFeng
邮箱: chunel@foxmail.com
个人网站:www.chunel.cn
github地址: https://github.com/ChunelFeng

image