建立开发方法:我如何完成一个功能需求
引子
拿到一个功能需求,高级工程师恐怕会说:“惟手熟尔”。那么,你是否仔细思考过,自己是如何完成一个功能的呢?你是否建立了适合自己的开发方法呢?
本文将以一个“爆破检测”的功能需求,来阐述要完成一个功能需求,需要经过哪些步骤,有哪些考量。
功能设计与实现
分解功能点
拿到 PRD 如下所示。第一步是分解功能点。
分解功能点,可以采用“分而治之”的策略,结合逻辑树方法、思维导图工具来实现。
看到 PRD ,我们可以先将功能分解为如下四部分:
- 检测配置。1. 开关控制,用于控制主机是否参与检测;2. 内外网爆破,对检测流程进行区分处理。
- 阈值配置:1. 要支持的爆破服务,需要在规则平台配置,然后同步到检测配置页面,用户不可控制; 2. 阈值配置,用于控制达到什么条件触发告警。用户可控制。
- 告警详情:展示生成的告警详细信息。点击“查看更多”会展示历史攻击记录。这里又是一个功能点。
- 告警检测流程: 要生成告警详情,肯定需要一个告警检测流程来生成告警。
注意到,告警详情页面左右上角还有三个按钮:“加入白名单”、“容器响应”、“未处置”。这都是对告警的操作。
将控制功能、检测功能、展示类功能与操作类功能分开,这样,我们可以将功能点分解如下:
仔细阅读 PRD, 进行功能点分解,并挖掘出各种细小的功能点,是程序员开发功能的基本素养之一。
接下来,咱们来“各个击破”。
开关控制
开关控制,是主机维度的,即:登录事件所对应的主机是否参与爆破检测。也就是说,每来一个登录事件,需要查询开关配置来判断这个登录事件是否参与检测。这个是结合整个检测配置(用户自定义配置)来设计的。咱们不展开。
要注意的是,这里会存在一个潜在的稳定性问题。因为登录事件是短时间密集上报的,频繁查询数据库会把数据库打爆的。这说明,程序员需要对产品设计里可能对性能和稳定性等系统质量有影响的地方保持敏感,及时提出并沟通解决方案。别到了要上线或上线后才发现有问题。这是合格程序员的一个基本专业素养。
阈值配置
定义内存对象结构
对于阈值配置,需要定义一个内存对象结构,如下所示:
public class BruteCrackConfig extends BaseGlobalConfig {
/**
* 暴力破解阈值
*/
private List<BruteCrackThreshold> thresholds;
/**
* 爆破服务
*/
private List<BruteCrackSerivceSwitch> serviceSwitches;
}
public class BruteCrackSerivceSwitch {
/**
* 服务名称
*/
private String service;
/**
* 服务描述
*/
private String desc;
/**
* 服务爆破是否开启
*/
private Boolean enable;
}
public class BruteCrackThreshold {
/** 阈值类型。 */
private ThresholdTypeEnum thresholdType;
/** 多少分钟 */
private Integer minutes;
/** 失败次数 */
private Integer failedCount;
}
根据需求,定义合适的对象结构,是基本功。这个对象结构通常是要存储在数据库中的。对于 mongo 来说,可以直接将这个嵌套结构存储在 Document 里。接下来就是 CRUD 的事情了。
定义API
注意到,这里页面展示爆破阈值配置,因此,需要给前端定义API接口。 接口采用 Restful 形式,采用 swagger 自动生成 API 文档。比如查询爆破阈值配置接口,类似这样:
@ApiOperation(value = "查询爆破配置", notes = "查询爆破配置", produces = MediaType.APPLICATION_JSON_VALUE)
@PostMapping(value = "/configs/brute-config")
public BruteCrackConfigVO getBruteCrackConfig(@RequestBody @Validated BruteCrackConfigQueryParam param) {
// code implementation
}
@Setter
@Getter
@ApiModel(description = "暴力破解阈值配置")
public class BruteCrackConfigVO {
@ApiModelProperty("配置ID")
private String configId;
@ApiModelProperty("平台类型")
private PlatformTypeEnum platformType;
@ApiModelProperty("配置类型")
private GlobalConfigTypeEnum configType;
@ApiModelProperty("爆破服务配置")
private List<BruteCrackSerivceSwitch> serviceSwitches;
@ApiModelProperty("阈值配置")
private List<BruteCrackThreshold> thresholds;
}
定义 API 是服务端开发的基本功,不可不重视之。
爆破检测流程
爆破检测流程涉及客户端、服务端和大数据端的三端交互。
- 客户端: 上报登录事件;
- 服务端: 进行过滤处理,将主机开启的登录事件发送给大数据端;
- 大数据端:使用 Flink 进行统计计算,将达到阈值的登录事件聚合成告警,发送给服务端。服务端接受到告警后走通用告警生成流程。
那么,这里涉及哪些技能点呢?
定义数据交互格式
多端交互,肯定要定义数据交互格式。其中登录事件是 agent 上报的,因此这个是 agent 制定的。服务端制定的与大数据之间的交互数据及格式。这里,我们不讨论具体的交互数据,只讨论如何制定出比较适宜的数据格式。
数据格式要求:
- 满足需求
- 简单,易于理解
- 易于解析和使用
- 可复用可扩展
- 通用数据与业务数据分离
数据格式:
采用 JSON 。
依据: JSON 是标准的 API 和消息通信数据格式。
数据字段:
- 命名贴近业务含义,无拼写错误
- 字段命名与工程中的命名统一
- 对于业界常用业务用语,与业界命名保持统一
数据字段类型:
- 进程ID、端口、金额等数值型,采用数值;
- 如果可能有多项,采用数组;
- 如果是扩展类信息,可采用嵌套 JSON,但嵌套不宜超过两层;
- 通用性的采用字符串。
消息:
- topic 命名与公司规范保持一致;
- 消息体尽量只包含必要的信息,扩展类或详情类信息可提供 API 查询(除非性能关键场景)。
注意这里要考虑数据是否能够复用。比如一份登录事件,可以为多个业务所用(除了爆破,还有爆破登录、一对多失败后登录、一对多成功登录)。由于登录事件往往是非常密集大量的,每个业务都订阅一份相同的登录事件数据,会造成很大的传输开销。因此,需要设计一个结构,来表示一个登录事件可以被哪些业务所处理。
存储设计
一项重要的功能,服务端总少不了存储设计。存储设计是功能设计和实现的核心和基石。不可不重视。
在爆破检测流程中,需要记录登录事件,也需要记录具体告警对应的登录事件。
存储设计通常采用表格来表示。如下所示:
字段名 | 字段类型 | 字段含义 | 是否非空 | 备注 |
---|---|---|---|---|
id | Long | 主键 | 是 | |
loginTime | Long | 登录时间 | 是 | 10 位时间戳 |
loginType | String | 登录类型 | ||
srcIp | String | 登录来源 | 是 | |
service | String | 爆破服务名 | 是 | 规则平台配置 |
port | Integer | 爆破服务端口 | 是 | |
detectionId | String | 登录事件对应的告警ID |
表及字段的设计通常会尽可能遵循数据库范式1NF, 2NF, 3NF 或 BCNF,适当使用外键。存储设计基础可阅:“软件设计要素初探:存储设计 ”
规则推送
由于是大数据来做统计计算,因此,需要将爆破配置规则推送给大数据。这是一个细小的点。
爆破配置规则推送有两个时机:1. 服务端应用启动时; 2. 大数据应用启动或重启时。
这里也涉及到服务端与大数据之间的交互,需要定义好数据交互格式和交互流程。
服务端告警生成流程
服务端告警生成流程是通用可扩展的,只需要写两个类,搜集检测结果、填充告警标题即可。
这说明代码(流程、模块)复用是多么的重要!服务端告警生成流程改动很少,节省了很多力气,整个功能需求的大头部分工作量比较小(评估工作量和时间的时候可以稍微留多一点 😃 )。
关于告警检测流程,就谈到这里。定义数据交互格式和存储设计,又是服务端开发的两项基本功。
告警详情
告警详情就比较琐碎了。它建立的基础是告警检测流程生成足够丰富的信息,存储在告警表里,然后从告警表和资产表里取出数据,拼接起来,返回给前端。
这里需要同样需要定义 API,需要仔细定义和核对返回给前端的字段。需要一些细致功夫:
- 字段名和字段类型要仔细定义;
- 要覆盖告警详情所需要的各种信息。
- 遵循常用约定。注意告警详情页面有一些图标和链接。比如,主机 IP 下有个链接,点击会进入主机详情。前端需要服务端返回主机是否存在、主机状态等字段,才能进入主机详情。这些都是常用约定,需要遵循。
告警详情接口,是已有接口,只需要添加爆破信息对象即可。查看更多,会列举这个告警下的所有历史攻击记录,这里需要定义一个新接口。历史攻击记录需要以时间倒序。
关于时间,还有一个点,就是时区不同步的问题。agent 上报的时间是 agent 机器上的时间(可能会早 8 个小时),并不一定与服务端的机器同步。但历史攻击记录需要展示 agent 的那个原始登录时间。这个问题估计只有报上问题才会意识到。如果能够意识到,那说明这个人对时区问题比较敏感,或者踩过类似的坑。
这都是些细节,PRD 不会“大声”告诉你,但需要去挖掘和注意。
告警操作
处置告警
处置告警,只是将告警置为真实告警或错误告警,告警表的状态更新操作。
这个是已有接口支持的,无需开发,只需要检查下即可。这说明接口的复用性很重要,可以减少一部分工作量,而无需全部重新开发。
加入白名单
加入白名单,已有接口和实现,需要支持爆破告警类型。这样, 就需要决定:究竟是在已有接口改,还是新创建一个接口。一般来说,除非是结构差异很大,才需要创建新接口。大多数情形,都是在已有接口里改。
如果在已有接口改,如何才能不影响已有功能呢? 通常有两种办法:
- if-else 与 switch。 这是程序员广为熟悉的方式。不过这种方式容易导致代码膨胀堆砌。简单的情形,可以这么做。
- 策略模式。用不同的策略来分离相似的逻辑。定义一个 n 元组,这个 n 元组的属性组合可以标识一个业务,通过定义业务策略类来实现业务的具体逻辑。关于策略模式的使用,网上有很多例子,这里就不多说了。
实质是工程中的业务解耦。可参考:软件工程中的耦合与解耦方式 。
响应
这又是一个大模块。这里就不展开了。
功能考量与关联
对上述功能点一一击破后,基本上就完成了爆破功能的最小可用版。这时,需要考虑这个功能是否有一些特殊性。比如爆破就有累加性。
假设爆破阈值是在 5 分钟失败 10 次生成告警,那么在 3 分钟内失败了 1000 次,是不是要生成 100 个告警呢 ? 显然客户是很难接受的。这就要求暴力破解告警具有累加性:在生成告警后的指定一段时间内,如果同一个 IP 登录继续失败,则会累加到已有告警上,而不是生成新的告警。 读者可以思考下,这种如何实现呢?
此外,如果对告警加白,又需要重新开始生成新的告警,这又怎么实现呢? 这个就涉及到功能之间的关联了。也就是说,告警加白处理,会影响告警检测流程及告警的产生。同样,将告警置为错误告警,也需要重新生成告警。在设计和实现功能时,就需要考虑功能点之间的关联,处理好这种关联关系。
可以采用缓存来实现这个功能。缓存的使用,是服务端开发的一个进阶能力。
程序员常常打趣自己是 CRUD 程序员。实际上服务端开发的 90% 的工作基本都是 CRUD,逃不开操作各种数据源,比如 MySQL, Mongo, ES, HBase, Redis, Kafka 等。能够熟练掌握各种数据源的 CRUD,做好存储设计和流程实现,基本上就能胜任很多服务端开发工作了。当然,如果能够更进一步,理解数据库引擎的实现原理,理解并能掌握分布式的 CAP,有扎实的算法基础和抽象设计思维,那就脱颖而出了。
功能质量属性
健壮性
完成功能之后,要考虑增强系统的健壮性,这样才不会被 BUG 淹没。我通常考虑的是如下几点:
- 防止 NPE。 哪怕代码写得丑一点,也要 if (xxx != null) {} (也可以用 optional)。在这点上,我认为 防 BUG 优于代码的干净。毕竟,有时一个 NPE 是能够让你头发少上几百根的。
- 防数组越界和 JSON 解析出错。防 JSON 出错可以加如下注解,或者添加异常捕获。
@JsonIgnoreProperties(ignoreUnknown = true)
- 异常捕获。 主要是防止局部次要失败影响到整体流程的运行。
明智的人学会从别人的错误中汲取经验教训,不去踩坑,善莫大焉。踩过的坑越多,就越会尽力去避免踩坑。迄今为止,我不敢说自己完全不踩坑,但绝大多数坑都是可以避开的。现在踩的坑多是“高级坑”,比如异步时间差导致的偶现问题。代码问题及对策可参阅: “代码问题及对策” 。
性能与稳定性
在解决健壮性之后,就需要考虑性能与稳定性。
性能通常指接口的 RT 和流程的吞吐量。接口的 RT ,通过建立合适的索引,基本就可以解决。流程的吞吐量,则可以采用并发、批量、合并简化流程等方式来提升。“webshell cdc 检测流程性能优化实战及经验教训” 一文中有实战有总结。
稳定性问题通常是瞬时大流量导致。前面谈到,如果黑客使用工具密集爆破,就会瞬间产生大量登录事件,就会造成瞬时大流量。如果系统处理不过来,就可能导致问题。
如何解决这个问题呢?可以使用二级缓存,即本地缓存 + Redis 缓存。
在提测的时候,需要说明这个情况,让测试同学做一些密集爆破测试,看看系统状态如何,是否会有异常表现。
保持简单性
保持简单性,始终是要重点追求的第一目标。
保持简单,是指功能的心智模型保持简单。比如,查爆破告警的历史攻击记录,条件就只有一个告警ID,而不要增加额外的条件。通过完善告警检测流程,来保证这个假设始终成立。不要因为告警检测流程的某种需要,导致历史攻击记录的查询条件增加不必要的负担(尤其是奇怪的负担)。否则,告警检测流程一改,详情也要跟着改,那 BUG 和修改就可能无休无止了。
这实际上就是保持功能的正交性。功能保持各自的边界和假设,不相互影响,没有连锁反应。
API接口自测
功能开发完成之后,需要自测下。尤其是给到前端的 API 接口。
API 接口自测,可以使用 Postman。工欲善其事,必先利其器。如果你有更好的工具,欢迎推荐哦!
完善开发方法
至此,我们展示了开发一个服务端功能所需要的各种基本功:
- 仔细阅读 PRD,功能分解,挖掘细小功能点;
- 定义内存对象结构;
- 定义和实现 API ;
- 定义数据交互格式和交互流程;
- 重视和做好存储设计;
- 改动已有接口,不影响现有功能,评估影响范围;
- 功能的特殊考量;
- 功能点之间的关联处理;
- 健壮性考量;
- 性能与稳定性考量;
- API 接口自测;
- 大量的细节处理;
- 掌握常用软件开发工具。
- 与产品和测试同学沟通确认。
随着接受和处理更多的功能,你将能进一步完善这个开发方法。
比如,在一对多成功登录检测中,需要算法学习和算法检测两个阶段。就需要去设计算法。可以先实现一个原型算法,这个算法完全在内存中计算。验证后,可将内存计算的部分迁移到 Redis 里。
比如,需要反复与产品确认各种细节。爆破服务的设置是在规则平台,需要从规则平台动态同步到检测配置里。
比如,需要给测试同学讲解复杂功能的方案实现,与测试同学一起推敲方案的细节,解决测试中出现的各种 BUG。
比如,需要处理多个功能的解耦。比如爆破功能、爆破异常登录功能、一对多登录功能、非正常范围登录。这些都是基于登录事件的,功能点都包含开关控制、检测配置、检测流程、白名单、响应、与大数据的交互。如何保证这些功能互不影响,在同一份数据的基础上解耦各个功能业务,也是一项重要考量。
比如,需要时不时停下来重新审视现有的设计和实现,重构不够清晰灵活的设计和不佳的代码实现。需要持续小步重构和优化的节奏和技能。
在更大的层面上,需要处理各个功能所属的模块之间的关联关系,在添加新功能的时候不引入复杂的模块依赖关系(尤其是模块循环依赖)。比如检测配置模块、白名单模块对检测流程模块的影响。
看上去你似乎需要掌握很多技能点,应对很多事情,实际上归结起来就是:思考、表达、沟通。编程即是精炼逻辑、思考与表达。需要时不时去重新审视,看看是否把握住了本质。
理解力和沟通力是程序员要具备的最重要最核心的两项基本能力。理解力决定你在机器世界能够抵达的上限,沟通力决定你在人类社会能够抵达的上限。
小结
本文通过一个实际功能的求解,展示了开发一项服务端功能所需要具备的各种基本功,以及如何去思考问题的求解。
我个人觉得,不仅要解决实际具体问题,也要思考和观察,自己是怎么去思考和解决这些问题的。一来,可以建立相应的方法论,另一方面,也可以“窥探”到自己思维里的不足之处。你在哪些方面处理比较顺手,在哪些方面有点绕不过弯来?这正是“认识你自己”的箴言所在。