可扩展思考一例:思考变化方向
可扩展性,本质上就是预测变化的方向并做好预留设计。
程序员总是在修修补补。在已有代码的基础上,应对新的需求。
在这里加个 if ,在那里加个 else, 千缠百绕,copy-paste-modify, 总能解决需求完成功能。直到改不动了,改起来特费事。什么叫特费事呢?就是要涉及多处改动,容易改漏,还容易改错,把现有功能给破坏掉了。
要保证改动较小就能支持新功能新需求,就涉及到代码的可扩展性。在 [“实现可扩展代码的四步曲”](https://www.cnblogs.com/lovesqcc/p/15982721.html)一文中,谈到了实现代码可扩展性的基本设计原则和实现方法。
可扩展性,本质上就是预测变化的方向并做好预留设计。预测变化的方向,最难,也最基础。这里以一个元素响应的例子来探讨下变化方向。
基本流程
元素响应。当接收到一个元素响应请求时,服务端会创建两条记录,一条响应记录,一条操作历史记录,同时给 agent 下发响应指令。当 agent 处理完指令后,会通过 kafka 上报结果给服务端,服务端更新响应记录和操作历史记录,展示在前端界面上。
嗯,就是这么简单。对于服务端来说,就是插入两条数据库记录、调用一次 API、从 kafka 接收数据处理、更新两条数据库记录。
对于这个需求,我们需要设计好响应记录和操作历史记录的表结构,同时要确定好对接 agent 的响应指令消息格式。这两个要素,决定了可扩展性基础。
用一个简化的例子:
响应记录:(_id, element_id, type, method, status) 分别是元素ID、元素类型、响应操作和响应状态。
操作历史记录: (_id, element_id, type, method, status) 分别是元素ID、元素类型、响应操作和响应状态。与响应记录的表结构几乎一样。所不同的是,操作历史记录,每次操作,无论成功或失败,都会有一条记录;响应记录只有最近一次成功操作的记录。
对于下发指令,简单写一个 {"resp_type", "op", "op_id", "code", "msg"},这五个字段足以。resp_type 标识响应元素类型, op 标识响应操作, op_id 对应操作历史记录的 _id 字段,与服务端记录对应,便于更新操作历史记录状态和响应记录状态。
元素方向的变化
最开始只有杀进程操作。显然 element_id = pguid, type = resp_type = process, method = op = kill_process。 看上去很简单,刷刷刷就写出来了。
接着,需要支持文件操作。文件操作有三个,隔离文件、删除文件、解除隔离文件。这时 type = resp_type = file, method = op = isolate_file|delete_file|disisolate_file。嗯,看上去似乎毫无难度。
接着,又来了个容器操作。容器操作要支持:暂停容器、解除暂停、隔离容器、解除隔离、杀容器。 当然,type = resp_type = container, op = pause_container|unpause_container|isolate_container|disisolate_container|kill_container.
当然,不同元素的响应操作,agent 需要的信息不同,下发指令的代码会有所不同。需要分别对进程、文件、容器进行处理。有一定经验的开发人员,会采用策略模式(结合工厂模式和单例模式)来分离不同元素的处理,如下所示:
根据不同的 type 就有不同的 strategy。比如进程的是 ProcessElementOperationStategy,文件是 FileElementOperationStrategy。
func (eom *ElementOperationManager) HandleElementOperation(ctx context.Context, execOperationParam *dto.ElementOperationExecuteCoreInfo) error {
err := eom.elementOperationStrategyFactory.GetInstance(execOperationParam.ElementType).HandleElementOperation(ctx, execOperationParam)
if err != nil {
logw.FromContext(ctx).Error("failed to handle element operation", "param", execOperationParam)
return err
}
return nil
}
一开始,使用策略模式的优势并不明显,但当再加入主机隔离/解除隔离、网络封禁/解除封禁响应后,你就很难忍受把所有元素的处理逻辑用一个巨大的 switch 或 if-elif-else 来处理了。
从这个小例子能得到什么启示?
- 当一种元素有一种操作时,思考还有哪些类似的元素(也是业务中常常用到的)有哪些类似的操作,使用策略模式来分离。比如,如果需要绘制 CPU 的监控曲线,通常也需要内存、磁盘的类似监控曲线。
- 元素方向的变化是最容易预见的。类似的,如果订单导出今天增加一个字段,可预见的,以后会增加更多字段。
联动方向的变化
现在,又需要支持一种操作:杀进程成功后,需要联动隔离进程文件。也就是在接收 杀进程的 kafka 消息处理中,需要再做隔离进程文件的操作(插入隔离文件的响应记录、操作历史记录、下发隔离文件响应指令)。
紧接着,又来了一种操作:在隔离文件导致文件隔离空间超限时,需要自动删除文件。也就是在隔离文件的结果中,还需要上报删除文件的结果并进行处理。
紧接着,又来了一种操作:文件修复的时候,需要同时隔离文件,生成修复和隔离的不同响应记录和操作历史记录。
联动方向的变化,会导致原有结构的变更,不可不小视。很多时候,代码就是从支持联动方向的变化开始腐化的。
单个转批量的变化
现在,要支持网络封禁的操作。网络封禁的操作有所不同。之前都只是针对一个 agent 插入记录和下发响应指令,现在需要给多个 agent 插入记录和下发指令。 此时,需要考虑性能问题,比如有 10000 个 agent 的情形。有三种策略:
(1) 并发、单个处理;
(2) 批量处理;
(3) 并发、批量处理。
显然,第(3)种策略最为高效。但是要做到并发批量处理,就需要对之前的单个处理的结构进行更改,使之支持批量操作。
因此,如果预见到以后会有支持单个到支持多个的处理,要为批量处理预留设计空间。否则,到时候改成批量处理模式,代码会改动很多,也容易改出问题。
类似的,一对一到一对多的扩展,也会导致代码结构变更较大,改动较多。
延迟操作的变化
以上操作,都是单次即时的操作。又来一个需求,需要在主机隔离后的 N 时间之后解除主机隔离。需要添加一个定时任务。可以把解除隔离主机写成一个可复用的方法,在多处调用。
手动到自动响应的变化
前面都是用户在界面手动的响应。存在一种场景,用户设置了自动响应(比如自动杀进程、自动隔离文件),当上报告警时,需要对告警中的元素做自动响应处理。这时,需要把响应操作做成一个可复用的方法,在多处调用。
这里展示了代码可复用性的优势。即如果要在多处做同一个操作处理,要做成可复用的。可复用的手段,可以是一个方法、一个类、一个 facade service 、一个 API、一个框架、甚至是一个微服务 。
体现程序开发效率的最重要的三个质量属性:可复用、可扩展、可配置化。
细微的变化
前面主机文件有文件隔离、容器也有文件隔离。容器的文件隔离,响应指令有所不同,需要传容器ID且操作是 ctn_op, op 是主机操作名。同时,前端的响应记录和操作历史记录又并不区分是主机文件隔离和容器文件隔离,因此,服务端需要在这里做一个兼容,既能满足 agent 的传参要求,也能满足前端的展示要求。
小结
小结一下,对于元素响应操作,变化方向可以是:
- 元素方向的变化:类似元素的类似操作处理;
- 联动方向的变化:多个操作的组合;
- 单个到批量的变化:为批量并发操作预留空间(主要用于提升性能)。
- 手动到自动的变化:提高代码可复用性,一次写好,多处使用。
- 细微的变化:主要是一些变体,针对不同载体的不同元素的处理有一些小的差异需要兼容处理。
元素响应例子的变化方向,显然不能说明所有业务中的各种事物的普遍变化方向,但可以给予一种提示,指引我们怎么去思考。要预测变化,需要把业务中所涉及的要素都梳理出来,建立一个整体的理解,才能有清晰的思考。常常思考变化方向,结合已有的需求开发和业务经验,锻炼预测变化的肌肉,也能提升可扩展性的见地和洞察力。
做软件开发做到一定程度,会发现,技术并不是技术人员拥有的最重要的品质,优质的思考力和思维力才是最关键的品质和法宝。