产研提效 01|那些违反认知的反模式

每隔一段时间内,我都会去尝试“重构”公司内的某一种模块代码。从“大任务拆分成小任务”,到“面向覆盖率编程”。有时是专门提一个任务,而有时是是在当前的任务中去修改一个让自己极度难受的模块。但是在几乎所有任务的最后,我只能在修复各种 bug 中,麻木且无奈地喊出一句:“还有谁(给我提 bug)!“。

错的不是我,是这个世界!

这句话似乎是一个完全不负责的甩锅模式。但是很遗憾,我们越是陷入“只要我在一天,我就要捍卫者一切!”的思维模式,就越容易陷入“明明我做了那么多,为什么就是没啥效果”的失落与惆怅中。软件工程从来不是写代码这么简单,它还涉及到问题解决、需求分析、系统设计、测试和维护等方面。如果作为一个单一职能的个体,无法提供一个有效的解决方案。那么原因有且只有一个:团队有太多符合直觉但违不符合软件工程的反模式。

软件工程作为一个团队协作工程,涉及知识的产出与消费两个方面。知识产出指的是开发过程中产生的各种形式的知识,如设计文档、代码、测试用例等;而知识消费则指的是对这些知识的理解、使用和维护。

在软件开发中,知识产出的质量直接关系到软件的质量。Fred Brooks在《人月神话》中提到:“Conceptual integrity is the most important consideration in system design.” 这表明在知识产出过程中,保持设计的一致性和清晰性是至关重要的。一个好的设计文档不仅能够指导开发人员编写代码,还能够帮助未来的维护人员理解系统的架构。

另一方面,知识消费同样重要。在《代码大全》中,Steve McConnell 强调了理解代码的重要性:“Understanding code is harder than writing it.” 这意味着,即使是最优秀的代码,如果没有人能够理解它,它的价值也会大打折扣。因此,软件工程师在消费知识时,必须具备良好的阅读和理解能力,以便能够有效地维护和扩展现有的系统。

在绝大多数情况下,我们即是知识的生产者,也是知识的消费者。我们必须花费大量时间,去理解现有的业务和代码知识,才能有效地分解与实现新功能。而我们新生产的知识,也将成为下一个消费知识的人的重要参考。所以,当你的任务所带来的代码变更造成了非预期的问题,放宽心,绝大多数都是上一任产出知识的人所遗留下来的问题,这个问题一直都存在,只是当前你作为知识消费者,发现了它罢了。

cynefin 框架:一切行为皆可分类

Pasted image 20241227003112.png

在 cynefin 认知框架下,我们对事物的反应所带来的行为模式皆都可以分类为四个模式:清晰(Clear)、庞杂(Complicated)、复杂(Complex)、混乱(Chaotic)模式。

Clear

  • 特点:因果关系明确,问题有已知的解决方案,可以通过最佳实践解决。
  • 例子:
    • 编写简单的脚本:例如用Python写一个文件重命名工具,步骤清晰,结果可预测。
    • 安装软件:按照官方文档的步骤安装和配置软件。
    • 使用版本控制:按照Git的标准流程提交代码和创建分支。
      Complicated
  • 特点:因果关系需要分析或专业知识,可能有多个正确答案,需要专家意见或良好实践。
  • 例子:
    • 优化数据库性能:需要分析查询语句、索引和数据库结构,可能需要DBA的专业知识。
    • 设计系统架构:需要根据业务需求和性能要求设计复杂的分布式系统。
    • 调试复杂Bug:需要通过日志分析、代码审查和工具调试来定位问题。
      Complex
  • 特点:因果关系不明确,问题无法提前预测,需要通过实验和探索来发现解决方案。
  • 例子:
    • 开发创新功能:例如开发一个基于AI的推荐系统,需要通过用户反馈和实验不断优化算法。
    • 敏捷开发:通过迭代和用户反馈逐步完善产品。
    • 应对未知的技术挑战:对遗留系统 debug。
      Chaotic
  • 特点:没有明显的因果关系,需要立即采取行动以恢复秩序。
  • 例子:
    • 服务器宕机:需要立即重启服务或切换到备用服务器。
    • 安全漏洞被利用:需要紧急修复漏洞并防止进一步攻击。
    • 生产环境数据丢失:需要快速恢复数据并排查原因。

反模式一:用贫血模型处理业务代码

贫血模型是一种将数据和行为完全分离的设计模式,这种模式本身并无问题,尤其在算法实现中广泛应用。然而,在表达业务逻辑时,贫血模型可能并不总是最佳选择。以下是一个前端开发中常见的例子,展示了贫血模型在实际应用中的挑战:

在一个项目中,有三个以 user 为前缀的类,它们都可以处理 user 数据对象:

  1. UserStore:使用状态管理工具,用于存储全局用户信息。
  2. UserUtil:为了减少代码重复,封装了用户相关的逻辑。
  3. UserService:为了与模型层区分,封装了服务层的逻辑。

现在有一个需求:新增一个判断用户是否有权限的方法。在拆解任务时,我们需要决定将这个逻辑放在哪个类中:

  • 放在 UserStore 里似乎合理,因为全局可以直接调用。
  • 放在 UserUtil 里也有道理,因为其他数据接口可能会引用用户相关信息,抽象起来更方便。
  • 放在 UserService 里也未尝不可,因为 service 本身就是用来封装对数据层的处理。

在这个过程中,我们无法依据现有经验来衡量代码变动的合理性,始终处于 Complex 的行为模式中。最终,我们可能会提供一个看似合理审核人员也觉得有道理的解决方案(很多企业的 code review 实际上就是做了个表面工作),但问题往往在测试阶段甚至功能被客户使用时才会暴露。

然而,目前几乎所有的 Web 项目都基于这种贫血模型的开发模式,甚至 Java Spring 框架的官方示例也是如此。这主要是因为,充血模型只有在复杂的软件结构中才能体现出其优势。在软件开发早期,我们主要进行的是相对简单的 CRUD 操作,重心在于快速实现功能,而不是花费大量精力去精心设计成充血模型。毕竟,在业务行为不多的情况下,设计出的充血模型也会显得贫瘠。

正如 Martin Fowler 在其经典著作《Patterns of Enterprise Application Architecture》中所提到的,充血模型是一种只有在复杂软件中才能体现出优势的开发模式。因此,在项目初期,也许采用贫血模型是更为实际和高效的选择,毕竟好学易用教程多。但随着业务复杂度增加,我们不得不思考这个开发模式的合理性。

相比于贫血模型,充血模型的因果关系就很明确,在“添加判断用户是管理员的逻辑”需求下达的情况下,我们就已经处于 Clear 认知模式下。即我们知道应该找 user 的 class 对象,并给它增添一个 isAdmin 方法。可以说,你想出现问题也很难。

1
2
3
4
5
6
7
8
9
class User {
name: string
email: string
auth: AuthEnum

isAdmin() {
return auth === AuthEnum.Admin
}
}

反模式二:特性分支

所谓特性分支,即长时间不合并到主干任务分支,一般以任务编号区分(即长时间不做集成),这意味着主干上已经有了很多其他人的代码,与当前特性分支的差异会很大,合并冲突的风险和因合并而产生缺陷的风险都很高。尤其在重构的场景中,我们通常会全局更改变量或者函数名,比如下面一个场景:我的代码中有一个叫做 AccountLevel 的 enum,但是为了提升代码的可读性,我们需要一个叫做 AccountLevel 的 class,为了区分 class 和 enum,我们需要把原来的 enum 对象,添加 Enum 后缀,改为 AccountLevelEnum 。在多数情况下,我们会将这个任务,放在一个任务的特性分支中,在一个月或者几个月后随着任务一起上线。

乍一看好像没什么问题。一个特性对应一个分支,当特性开发完成开始自测时,很自然地将特性分支合并到 test / release 分支开始手工测试,更有甚者,会给每一个特性分支都加上独立的测试环境,测完就一起合,从而发挥特性分支的最大功效——不同特性彼此独立、互不影响。这下不但开发彼此独立,连测试都互不影响了。

但是现实呢?测试阶段几乎所有的问题,都是特性分支所带来的:

  1. 首先最明显的就是“当测试环境出现问题,应该哪个任务的负责人的问题”。这个问题其实测试不知道,研发更不知道。研发只能抱怨一句:“明明我本地是没有问题的,为什么一合就问题了!”,然后不得不深入代码,手动对比变化。
  2. 其次就是”任务 revert 了,它的影响范围真的只有当前任务吗“,比如我上面的任务更改了一个全局变量名,我在合并任务时为了让测试环境正常运行起来,不得不时刻关心别的任务合并,有没有带入应该被 deprecated 用法。而某一天,这个任务不用上了,需要调整的代码,远比任务本身来的多。那么这时测试应该测当前任务回滚的范围,还是把其它任务重新简单测一遍呢。好像没得选,测试只能重新把所有的任务,都简单回归一遍。
  3. 最后就是把开发折磨地痛不欲生的合并地狱问题了,明明我已经做好了自己该做的事情,但是冲突怎么那么多。

在整个过程中,我们无法知道,特性被合并到测试分支时,别人做了什么,也无法确定,本身没有问题的内容,是否会和别人的代码产生逻辑上的冲突。当然花点时间肯定是可以的,但每个人都要了解当前任务别人做了什么,肯定是理论上可行,现实上不可能的事情。

反模式三:写一点跑一下

这应该是,绝大多数开发者,已经习以为常的卡发模式的写几行代码,重新运行下构建,然后通过 postman 或者页面点一下看看表现是否符合预期的开发模式,其实也是一种 Complex 的行为模式,因为你在写的时候,并不能确保自己的改动会造成什么影响,只能通过手动回归各种可能场景的形式去验证。比如对一个列表,有 3 种筛选条件,在写到第三种筛选条件时,还要去手动回归一下是否影响前两种筛选条件。

而违反直觉要先写测试的 TDD 开发模式,反而是 Clear 行为模式,我们每次的代码变更,只是为了通过我们编写的单测用例,单测只有通过与不通过两种场景,只要旧有的单测覆盖率足够且没有报出错误,就表示我们的代码变更并没有造成预期外的问题。

审视开发的理所当然

以上几个 Complex 反模式出现的原因,一般是个体或者小团队的开发习惯,没有跟上业务复杂度的变化导致的。我们习惯于当初从书本中学到的简单模式,误以为通过学习更多的技术,所有的问题都可以引刃而解。而当我们足够努力,学了那些设计模式、算法,沉迷于各种源代码,却依旧受困于“屎山代码”的泥潭时,我们必须脱离个体,从团队协作的角度,去提出问题。

为什么“重复代码很多”,因为我们不知道逻辑应该聚合在哪。有什么实践可以参考,领域驱动模型
为什么“本地可以,测试环境不行”,因为特性分支造成知识孤岛。有什么实践可以参考,主分支+特性开关
为什么“总是该一行,出 3 个 bug”,因为我们开发时,无法确定代码变更的预期表现。有什么实践可以参考,TDD

技术,当然要学。但是我们学了那么多技术后,从定义问题选择技术的角度上,我们真的比那些 1-2 年工作经验的人更优秀吗?