设计模式是软件开发中的重要经验总结,Gang of Four (GoF) 提出的经典设计模式则被誉为设计模式中的“圣经”。但是设计模式往往是以抽象和理论化的方式呈现,对于初学者或者没有太多实战经验的开发者来说,直接学习设计模式往往会显得枯燥乏味。
市面上或者网上也经常有一些书籍或者文章,尝试以实际的应用场景深入浅出地介绍设计模式。但是这些资料所列举的样例或应用实践,往往都是一些构造的虚拟场景,缺乏生产级软件的真实应用。而软件理论最重要的是学以致用,那是否有真实生产级代码的学习机会呢?
iLogtail 作为一款阿里云日志服务(SLS)团队自研的可观测数据采集器,目前已经在 Github 开源,其核心定位是帮助开发者构建统一的数据采集层。iLogtail 在多年的技术演进过程中,也一直在尝试进行各种设计模式的应用,这些设计模式的应用大大提升了软件的质量与可维护性。本文我们将结合 iLogtail 项目,从实践角度探讨一些常见设计模式的技术原理。在这里也要感谢字节跳动多位同学对 iLogtail Golang 部分架构的一些升级优化。
如果你曾经感到学习设计模式枯燥无味,那么来学习 iLogtail 吧!欢迎参与任何形式的社区讨论交流,相信你会发现学习设计模式也可以是一件非常有趣的事情!
创建型模式
创建型模式的作用是提供一个通用的解决方案来创建对象,并隐藏创建的细节创建对象。说到创建一个对象,最熟悉的就是 New 一个对象,然后设置相关属性。但是,在很多场景下,我们需要给应用方提供更加友好的创建对象的方式,尤其在创建各种复杂类的场景下。
单例模式
模式简介
单例模式是指在整个系统生命周期内,保证一个类只能产生一个实例,确保该类的唯一性。对于一些资源管理类的场景(例如配置管理),往往需要拥有一个全局对象,这样有利于协调系统整体的行为。
iLogtail实践
在 iLogtail 中,采集配置管理扮演着衔接用户采集配置和内部采集任务的重要角色,通过加载与解析用户采集配置,建立具体的采集任务。
作为一个进程级的管理机制,ConfigManager 非常适合采用单例模式。iLogtail 启动时会初始加载所有采集配置,并支持运行过程中动态加载变更的采集配置。通过单例模式,可以有效避免多个实例间状态同步的问题;也提供了统一的全局接口,方便各个模块进行调用。
class ConfigManager : public ConfigManagerBase {
public:
static ConfigManager* GetInstance() {
static ConfigManager* ptr = new ConfigManager();
return ptr;
}
// 构造、析构、拷贝构造、赋值构造等均为私有,防止构造多个对象
private:
ConfigManager();
virtual ~ConfigManager();
ConfigManager(const ConfigManager&) = delete;
ConfigManager& operator=(const ConfigManager&) = delete;
ConfigManager(ConfigManager&&) = delete;
ConfigManager& operator=(ConfigManager&&) = delete;
};
GetInstance() 函数是单例模式的关键,该函数内使用了静态变量、静态函数的方式,以确保在应用程序中只有一个 ConfigManager 类的实例。为了防止通过拷贝或赋值实例化多个 ConfigManager 对象,将拷贝构造函数和赋值运算符定义为私有,并将其标记为删除。
同时,利用 C++11标准中的Magic Static特性:若变量在初始化时,并发同时进入声明语句,并发线程将会阻塞等待初始化结束,保证了并发程序中的线程安全。
If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization.
工厂模式
模式简介
工厂模式提供了一种创建对象的最佳方式。创建对象时不会对客户端暴露创建逻辑,客户端仅需要告诉工厂类要创建的对象,其余工作由工厂类完成。
iLogtail实践
为了应对众多可观测数据类型的采集、处理需求,在 iLogtail C++ Pipeline 中定义了 Log、Metric、Trace,并抽象出了 Pipeline Event 作为 Pipeline 数据流的通用格式。Pipeline Event 作为 Pipeline 中的数据流转的基本单元,往往会涉及大量的 Event 申请,因此在core/models中定义了 Pipeline Event 工厂,提供 Log、Metric、Span 等对象的创建,便于数据流灵活调用,降低了业务场景上的耦合,同时提高了数据模型新增时可扩展性。
生成器模式
模式简介
生成器模式又称建造者模式,该模式能够分步骤创建复杂对象,允许使用相同的创建代码生成不同类型和形式的对象。生成器模式所构建的对象一定是庞大而复杂的,并且一定是按照既定的制造工序将组件组装起来的,例如汽车生产线等。
生成器模式由四个角色组成:
- Product(产品):复杂对象,它由多个部件组成,每个部件都有自己的构建方法和表示。
- Builder(抽象生成器):负责定义构建复杂对象的抽象接口,包括构建每个部件的方法。
- ConcreteBuilder(具体生成器):实现 Builder 接口,负责实现各个部件的构建方法,并最终组合成一个完整的复杂对象。
- Director(指挥者):负责管理 Builder 对象,调用 Builder 对象的方法来构建复杂对象。它不直接创建复杂对象,而是通过 Builder 对象来构建复杂对象。
iLogtail实践
iLogtail 的 Go Pipeline 可以视作一个复杂的生产线,是一种典型的生成器模式应用场景。首先,Pipeline 管理器(Director)将 Pipeline 的构建过程分解为多个插件的构建步骤,并由 PipeBuilder 完成各阶段插件的创建和初始化;最后将这些插件组合成一个完整的Pipeline对象(Product)。
通过生成器模式的应用,大大提高 iLogtail 插件机制的可扩展性和可维护性,方便用户根据实际需求进行扩展各类采集和处理场景。
原型模式
模式简介
原型模式允许通过复制现有对象来创建新的对象,而不是通过显式的实例化来创建。
iLogtail实践
原型模式通常用于创建大量相似对象的场景。在 iLogtail 数据处理过程中,使用原型模式创建多个相似的 PipelineEvent 对象可以有效提高数据处理的效率和可维护性。
总结
创建型模式总体上比较简单,它们的作用就是为了产生实例对象。
- 单例模式:保证一个类只有一个实例,并提供一个访问该实例的全局点。适用于管理一些全局的共享资源,避免多个实例之间的竞争和冲突,但是需要注意实现上的问题。
- 工厂模式:定义一个用于创建对象的接口,但让子类决定将哪一个类实例化。适用于具有相似性质的对象的创建,更加灵活。
- 生成器模式:将一个复杂对象的构建过程分成多个步骤来完成。适用于创建一些复杂的对象,方便代码的维护和扩展。
- 原型模式:利用拷贝对象的方法,减少一些复杂的创建过程。
结构型模式
结构型模式的作用是提供一种组织对象的方式,以便实现对象之间的关系和交互。
适配器模式
模式简介
适配器模式将一种类型的接口转换成希望的另一类接口,使得原本接口不兼容对象能够一起配合工作。
iLogtail应用
iLogtail 进程由两部分组成,一是 C++ 编写的主体二进制进程,提供了管控、文件采集、C++加速处理、SLS 发送等功能;二是 Golang 编写的插件部分(libPluginBase.so),通过插件系统实现了处理能力的扩展以及更丰富的上下游生态支持。
在 iLogtail 中,SLS 发送场景主要的实现逻辑在 C++ Sender.cpp,提供了完善的发送可靠性增强能力(异常处理、重试、反压等)。而对于 Go Pipeline 中 SlsFlusher 也需要将采集、处理后的数据发送到 SLS,如果在 Go 插件侧也实现相同的逻辑,会造成代码的冗余。因此,Go SlsFlusher 的实现原理是将处理后的数据转发到 C++ 部分完成最终数据发送。但是跨语言场景必然存在不适配的因素,此时 libPluginAdaptor.so 充当一个适配器层,实现了 Golang 发送接口与 C++ 发送接口之间的衔接。
外观模式
模式简介
外观模式旨在为程序库、 框架或其他复杂类提供一个简单的接口。 外观类通常会屏蔽一些子系统的复杂交互,提供一个简单的接口,使得客户端聚焦在真正关心的功能上。
iLogtail应用
在 K8s 日志采集到 SLS 场景下,iLogtail 通过支持环境变量( aliyun_logs_{key} )的方式自动完成采集配置,包括创建 Project、Logstore、机器组、采集配置等 SLS 相关资源。整体操作较多,需要考虑配置详情、容器过滤项、操作顺序、失败等众多因素。
而对于 iLogtail Env 采集场景来说,仅需关心少数几个核心的配置项即可。因此,实现了一个封装所需功能并隐藏代码细节的外观类,不仅简化了当前的调用关系;还能将未来后端 API 升级所造成的影响最小化, 因为只需修改程序中外观方法的实现即可。
桥接模式
模式简介
桥接模式(Bridge Pattern)可将一个大类或一系列紧密相关的类拆分为抽象和实现两个独立的层次结构, 从而能在开发时分别使用。概念比较晦涩,换一种理解方式:一个类存在两个(或多个)独立变化的维度,可以通过组合的方式,让这两个(或多个)维度可以独立进行扩展。
iLogtail应用
在 iLogtail 中,使用 flusher_http 发送到不同后端系统时,往往需要支持请求加签、追加auth header,请求的加签算法可能因后端平台而异。为了实现更好的可扩展性,iLogtail 提供了 extensions 机制,将 flusher_http 插件的实现与具体的发送策略的实现分离,进而实现了Authenticator、FlushInterceptor、RequestInterceptors的可扩展性。
代理模式
模式简介
代理模式就是使用一个代理类来隐藏具体实现类的实现细节,通常还用于在真实的实现的前后添加一部分逻辑。既然说是代理 ,那就要对客户端隐藏真实实现,由代理来负责客户端的所有请求。
iLogtail应用
在 iLogtail 中,最核心的步骤就是保证数据准确地发送到后端服务。在将数据发送到 SLS 场景下,最根本的就是调用 SDK 将打包好的数据发送,整个过程看似简单却蕴含着大智慧。因为后端服务是复杂多变的,往往会存在着这种不确定因素,例如网络不稳定、后端Quota满、鉴权失败、偶尔服务不可用、流控、进程重启等。如果每个数据发送方独立处理直接调用 SLS SDK 进行发送,必然导致大量重复代码,造成代码复杂度增加。因此,iLogtail 引入了 Sender 代理类,增强了直接 SDK 发送的可靠性。数据发送方仅需要调用 Sender::Instance()->Send 即可认为已经完成了数据发送,剩下的复杂场景处理全都交给 Sender 类完成,由 Sender 类保证将数据成功发送到后端系统。
总结
代理模式用来做方法的增强;适配器模式实现了类似“把鸡包装成鸭”的接口适配;桥梁模式通过组合,实现系统的解耦;外观模式可以让客户端不需要关心实例化过程,只要调用需要的方法即可。
此外,还有组合模式用于描述具有层次结构的数据;享元模式为了在特定的场景中缓存已经创建的对象,用于提高性能。
行为型模式
行为模式负责对象间的高效沟通和职责委派,它关注的是各个类之间的相互作用,将职责划分清楚,使得我们的代码更加地清晰。
观察者模式
模式简介
观察者模式定义了一种对象间的一对多的依赖关系,类似于订阅和发布的机制。当可观察对象的状态发生改变时, 所有依赖于它的对象都得到通知并自动进行事件处理。通过观察者模式可以实现灵活的事件处理,使对象间的关系更加松散,便于系统的扩展和维护。
iLogtail实践
文件采集场景可以认为是观察者模式比较典型的应用场景。为了兼顾采集效率以及跨平台的支持,iLogtail 采用了轮询(polling)与事件(inotify)并存的模式,既借助了inotify的低延迟与低性能消耗的特点,也通过轮询的方式兼顾了运行环境的全面性。
iLogtail 内部以事件的方式触发日志读取行为。其中,polling 和 inotify 作为两个独立模块,分别将各自产生的 Create/Modify/Delete 事件,存入 Polling Event Queue和 Inotify Event Queue 中,并最终合并成一个统一的 Event Queue。
- 轮询模块由 DirFilePolling 和 ModifyPolling 两个线程组成,DirFilePolling 负责根据用户配置定期遍历文件夹,将符合日志采集配置的文件加入到 modify cache 中;ModifyPolling 负责定期扫描modify cache 中文件状态,对比上一次状态(Dev、Inode、Modify Time、Size),若发现更新则生成modify event。
- inotify 属于事件监听方式,根据用户配置监听对应的目录以及子目录,当监听目录存在变化,内核会产生相应的通知事件。
最终,LogInput 模块完成对 Event Queue 消费的消费,并交由 Event Handler 处理Create/Modify/Delete 等事件,进而进行实际的日志采集。
责任链模式
模式简介
责任链模式允许你将请求沿着处理者链进行发送。收到请求后, 每个处理者均可对请求进行处理, 或将其传递给链上的下个处理者。
责任链会将特定行为转换为被称作处理者的独立对象。在一个冗长的流程中,每个步骤都可被抽取为仅有单个方法的类, 并执行操作,请求及其数据则会被作为参数传递给该方法。
iLogtail实践
iLogtail 中的数据处理 Pipeline,是非常经典的责任链模式。插件系统目前的主体由 Input、Processor、Aggregator 和 Flusher 四部分组成,其中 Processor 作为处理层,可以对输入的数据进行过滤,比如检查特定字段是否符合要求或是对字段进行增删改。每一个配置可以同时配置多个 Processor,它们之间采用串行结构,即上一个 Processor 的输出作为下一个 Processor 的输入,最后一个 Processor 的输出会传递到 Aggregator。
备忘录模式
模式简介
备忘录模式允许在不暴露对象实现细节的情况下,捕获一个对象的内部状态,并在该对象之外保存这个状态,便于后来将该对象恢复到原先保存的状态。
备忘录模式主要有以下几个组成部分:
- 发起人类(Originator):主要记录当前时刻的内部状态,并且负责定义哪些是属于备份范围的状态,负责创建和恢复备忘录数据。
- 备忘录类(Memento):负责存储发起人对象的内部状态,并且在需要的时候向发起人提供需要的内部状态。
- 管理类(Caretaker):备忘录的管理类,保存和提供备忘录。但不能对备忘录的内容进行访问与修改。
iLogtail实践
日志采集场景下最重要的特性是保证日志不丢。iLogtail 通过 Checkpoint 机制,及时将文件采集的状态备份到本地磁盘,保证在极端场景下数据的可靠性。两个比较典型的应用场景:
- 采集配置更新/进程升级
配置更新或进行升级时需要中断采集并重新初始化采集上下文,iLogtail需要保证在配置更新/进程升级时,即使日志发生轮转也不会丢失日志。
解决思路:为保证配置更新/升级过程中日志数据不丢失,在 iLogtail 在配置重新加载前或进程主动退出前,会将当前所有采集的状态保存到本地的 checkpoint 文件中;当新配置应用/进程启动后,会加载上一次保存的 checkpoint,并通过 checkpoint 恢复之前的采集状态。
- 进程crash、宕机等异常情况
在进程crash或宕机时,iLogtail需要提供容错机制,不丢数据,尽可能地少重复采集。
解决思路:进程 crash 或宕机没有退出前记录 checkpoint 的时机,因此 iLogtail 还会定期将采集进度dump到本地:除了恢复正常日志文件状态外,还会查找轮转后的日志,尽可能降低日志丢失风险。
迭代器模式
模式简介
迭代器模式提供一种在不暴露对象的内部细节的前提下,访问对象中各个元素的方法。
iLogtail实践
Golang 插件使用 LevelDB 进行一些上下文资源的备份,并基于迭代器模式恢复数据。
// Iterator iterates over a DB's key/value pairs in key order.
type Iterator interface {
CommonIterator
// Key returns the key of the current key/value pair, or nil if done.
// The caller should not modify the contents of the returned slice, and
// its contents may change on the next call to any 'seeks method'.
Key() []byte
// Value returns the key of the current key/value pair, or nil if done.
// The caller should not modify the contents of the returned slice, and
// its contents may change on the next call to any 'seeks method'.
Value() []byte
}
总结
行为模式主要关注对象之间的通信和交互的方式和模式。
- 观察者模式:定义了一种一对多的依赖关系,当一个对象的状态发生变化时,其所有依赖者都会得到通知并自动更新。
- 职责链模式:将请求的发送者和接收者解耦,使多个对象都有机会处理该请求,直到其中一个对象处理成功为止。
- 备忘录模式:允许在不暴露对象实现细节的情况下保存和恢复对象之前的状态。
- 迭代器模式:提供一种统一的方式来访问聚合对象中的各个元素,而不需要暴露其内部结构。
参考:
谈谈我工作中的23个设计模式
一文讲透设计模式(C++版)
如何理解这6种常见设计模式?
25000 字详解 23 种设计模式
C++ 常用设计模式:https://refactoringguru.cn/desi
作者|烨陌
点击立即免费试用云产品 开启云上实践之旅!
原文链接
本文为阿里云原创内容,未经允许不得转载。