大家好,我是不会写代码的纯序员——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类的抽象逻辑和可解释性。通常推荐的做法,是加入切面逻辑。
在这个例子中,play()前和play()后,就是所谓的【切点】,在切点处添加对应的逻辑,这就是AOP的思想。如果说,OOP是抽象通用事物逻辑的纵向编程的话,那AOP就是在横向上,对逻辑进行的通用扩展。
实现逻辑 |
我们刚才提到,cpp里是没有原生的aspect(切面)逻辑的,我们要手动实现一个。实现切面的思路又有很多,比如通过Proxy(代理)、Adaptor(适配器)、Decorator(装饰器)、if/else等,需要注意以上设计模式的异同。
设计模式这东西,本来就是见仁见智,不好一概而论。简单介绍一下我眼中,以上几种模式的区别:
- Proxy 侧重于对 原有类的功能 扩充,如:添加校验
- Adaptor 侧重于对 原有类的接口 的适配和改动
- Decorator 侧重于对 原有类的具体某个对象 功能的改动和扩展
考虑到最终是在GElement类型的对象特定方法前后添加一个或多个切面,而且切面自身不需要实现注册、执行等因素,CGraph中最终采用的是Decorator模式。
如上图,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>(¶mA);
b->addGAspect<MyConnAspect, MyConnAspectParam>(¶mB);
pipeline->process();
GPipelineFactory::clear();
}
看上面这两段代码,就实现了参数往同一个切面(MyConnAspect)传递不同参数(paramA和paramB)的逻辑,模拟的是在不同的节点中,通过相同的切面去连接不同 ip+port 的逻辑。
源码的/tutoral/
文件夹中,还包含了其他有意思的 添加切面、切面抓取pipeline中参数 的例子,有兴趣的可以看一看T09-Aspect
和T10-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来实现具体逻辑鸭,哈哈。
最后,还是很欢迎大家添加我的个人微信,今后方便多多交流,请多多指教鸭。
[2021.10.07 by Chunel]
推荐阅读
- 纯序员给你介绍图化框架的简单实现——执行逻辑
- 纯序员给你介绍图化框架的简单实现——循环逻辑
- 纯序员给你介绍图化框架的简单实现——参数传递
- 纯序员给你介绍图化框架的简单实现——条件判断
- 纯序员给你介绍图化框架的简单实现——线程池优化(一)
- 纯序员给你介绍图化框架的简单实现——线程池优化(二)
- 纯序员给你介绍图化框架的简单实现——线程池优化(三)
- 纯序员给你介绍图化框架的简单实现——线程池优化(四)
- 纯序员给你介绍图化框架的简单实现——线程池优化(五)
个人信息
微信: ChunelFeng
邮箱: chunel@foxmail.com
个人网站:www.chunel.cn
github地址: https://github.com/ChunelFeng