如何写一手容易维护的代码

2024-01-18
8分钟阅读时长

免责声明

程序员对抽象、设计可能会有不同的习惯。同样一个功能有人用发布订阅,有人用消息队列,他们都有自己的一套设计理念。最终是谁能说服谁的问题,哪一种方式都不是绝对正确的,就像本文一样。

内容有点偏意识流,不过总结的都是些很简单的东西。文中提到的诸多“本质”,应该会再写一篇来举例说明吧。

人类智商受限

人类的智商难以理解庞大而复杂的软件系统,所以我们尝试用抽象来设计软件,设法以人类的智商也可以理解庞大的软件系统。

抽象是什么

管理自己的磁盘时,都会建立类似 Work、Software、Temp、Repo …之类的文件夹。怎么分类文件夹最合理在这里不重要,重要的是分类文件夹前,肯定会进行的简单的设计。这个设计的思考过程我认为就是正在抽象。

举个不是很恰当的例子。我们不会为某一个 IDE 单独建立文件夹,来存放用它开发的代码文件。例如 Intellij ProjectsVisual Studio Projects 。而是会尝试建立 ProjectsRepoWorkspace 存放代码文件。这期间我们就是在抽象,把文件和具体的软件分离开,思考了这些文件的本质是”代码文件“,而非”某款软件的文件“。

抽象本质就是简化信息,是为了降低复杂度、是控制软件系统混乱程度的外在做功。

从三层架构开始思考

三层架构是一个很简单的,但抽象程度很高的模式。它可以解释大多数程序的组成:”有对外交互、有核心逻辑、有数据读写“。这就是它的高度抽象,把信息简化到了极致,所有程序都可以遵循三层架构模式写出来。三层本质是规范了代码边界,划分出三层边界:

  • 用户交互层:接收“用户”的输入,向“用户”输出处理结果。
  • 核心逻辑层:只负责程序的核心“算法”,如何处理数据。
  • 数据交互层:妥协层,内存实现不了持久化,抽象出一个数据读写层代替内存。

代码设计的前期阶段,如何思考出一个模块的组成部分?三层架构给了我们一个优秀的示范。不需要思考功能的太多细节,全力以赴的简化功能的细节,简单直接的阐述功能的本质

不过三层架构太抽象了,就像将「用户注册」抽象成「新增数据」,相当于没有细节。如果只从三层中学到了“把代码按照交互、核心、读写划分”的话,那实现细节还是会剪不断理还乱。只会套用三层架构范式,而不去思考功能本质的软件最终会难以维护,也许是屎山多是三层架构的原因(风评被害)。

最小人力成本

简单的东西却蕴藏大设计,架构大道反而在最简单的三层之中。只要适当的简化信息,边界划分的足够合适,架构最终会形成一个个聚合,开始有了领域驱动的味道。可难点就是边界如何划分的足够合适,信息如何简化才算符合抽象。

因为现实世界复杂的,软件系统的复杂度是不断熵增的。不变的设计总会有一天会遇到冲击。总会遇见两难抉择:重构还是硬怼?

例如我曾碰见的 getRowDef(rowIndex) ,起初这个方法是为了获取数据的类型定义,以此解析并转换数据内容。但后来 Excel 变的很复杂,我们需要根据内容来推断列定义。所以方法变成了 getRowDef(rowIndex, rowReader, totalLines, prevRowDef...),多了很多参数,以便在方法内部推断此行的类型定义。变成了让人困惑的屎山代码:获取数据的类型定义,需要先读取数据,然后再拿定义去转换数据。理念上有了冲突,让人产生了困惑。但还是选择了硬怼,因为这样改动最省力,写好注释后也能让人理解。

看似上面例子重构一下成本也不大,但其实功能本质已经变了。也就是设计理念变了。类型定义和数据内容的关系变了。重构就要从类型定义和数据内容这种基层代码改起。结合实际工期限制,适当的抽象减轻维护难度就可以了。如果碰到设计瑕疵就要重构,反而会给自己增加压力,延后工期,浪费人力~~,失去工作~~。

《架构整洁之道》里提到:==“软件架构的终极⽬标是,⽤最⼩的⼈⼒成本来满⾜构建和维护该系统的需求”==。遇到设计瑕疵时,有重构的想法时,需要慎重思考自己新的设计,减少的维护复杂度和花费的精力相比是否值得。

代码设计

百科对抽象的解释是「找出事物的本质,剥离其它表象、杂质,最终形成一个概念」。

抽象的本质

务实的看,抽象可以帮助我们简化代码,封装复用、继承多态、接口声明。都是在抽象代码,以形成“某某功能”的概念,实现细节则是在具象(补完)这个概念。这个概念就是前文一直在提到的功能本质、设计理念等抽象的词汇。

使用第三方库时,遇到一些不清楚的方法,一般只需要在源码中找几个接口定义(注释)看看,或者阅读官网文档的 Api Reference 就能理解。这些框架都是作者的匠心之作,单从它们的版本发布就能略知一二,它们的更新维护通常都是非 breaks 的。屎山才会经常不停的重构,导致 breaks

这些框架怎么做到的?虽然框架支持的特性多样且复杂。但框架作者依靠抽象简化了信息,思考本质。不论是修复 bug,还是新增功能,作者只需要确保框架的概念还是不变的。这里的概念相当于其抽象的出发点,或者本质。每次维护、更新只需要确定这个本质不会发生改变。

信息隐藏

理解抽象,彻底的理解什么是“简化信息”很关键。并非是单纯的精简代码,而是一种形意拳。精简代码只是其形。意在降低复杂度。

我在《代码大全》里看到信息隐藏时,意识到到这个东西可以和简化信息联系起来,更直观的解释抽象。我们降低复杂度、抽象代码、简化信息。最终达成的效果就是隐藏了代码所蕴含的信息

结合一段代码,有点极端但简单地例子。直观的看一下信息隐藏是什么。

// domain
class User {
	fun login(): Boolean {
	     // 核心逻辑:只有周一允许登录
	     return if(today() == '周一') true else false
	}
}

// service
fun loginService() {
	val user = getUser()
    if(!user.login()) {
        // 错误的
        return "登录失败,失败原因:只有周一才能登录"
        // 正确的
        return "登录失败" // “我”不知道失败原因,因为原因被“隐藏”了
    } else {
	    // ...
    }
}

例子中,代码的边界体现在「今天是周一才能登录系统」这个核心逻辑。服务层不应该知道这个信息。只应根据业务对象的返回值来做判断,或者说我们隐藏了这个信息

为什么”告知用户“「只有周一才能登录」的写法是错误的?因为核心逻辑层只返回了 false,没有说原因。服务层及更上层理应不知道原因,即使所有代码都是同一个程序员写的。

程序员应该克制,或者说欺骗自己。这样才能保持住代码的边界,即使告知用户「只有周一才能登录」是更好的用户体验,仅从代码设计来看,隐藏信息是首要的。

简单来说就是,“我”忘记了核心逻辑,业务对象只返回了一个 Boolean,只包含了“登录是否成功”这条信息。这样“我”的做法是「登录失败,但我不知道原因」就很合理了。

当然上边的例子有点极端。来点实际的例子,就像我们写 java,不用去理解 public void main() 背后发生了什么,只需要知道这是程序的主入口。这就是设计 java 语言的人,隐藏了信息,我们只需要往 main 方法里浇灌屎山代码就行了,降低不少复杂度。

简化信息,降低复杂度的本质,似乎就是信息隐藏。

依赖接口

再来理解依赖接口而非实现就很简单了,接口就是隐藏信息的集大成者,如果没有 go to implementation,就是在天然的在隐藏信息,达成:

  • 这个接口的实现是哪个同事写的?
    • 我不在意(除非要找个人背锅了)
  • 这个接口的实现具体做了什么,怎么写的,用到了什么技术?
    • 关我什么事。我只关注它可以达成什么效果,我要给它什么参数,它返回了什么。
  • 接口调用出错了,这可咋办?
    • 确认调用方式没问题。那抽空修复、替换下实现吧。

就算脱离了接口实现,软件代码也能被理解。这样说明了简化信息很成功,复杂度理所应当的被降低了。

边界与约定

想要理解领域驱动,理解限界上下文必不可少。直接看限界上下文会感觉很抽象。不过通过信息隐藏,可以很简单的理解、接纳限界上下文的理念

约束与限界上下文

边界像是一种约束,只允许边界外知道边界内泄漏出的信息。例如登录失败的原因,只泄漏了 TrueFalse,真正的登录失败原因,被我们隐藏起来了。

这种隐藏方式和我们在面向对象代码语言里的 private 私有特性密切相关。例如边界内的业务对象的 set 方法通常是私有的,不对外公开。这就是一种约束、或者隐藏。边界外只允许获取边界内公开的信息。

构成边界内/边界外约束就可以理解为限界上下文。上下文内一个领域、一个聚合,都是一些很纯粹、完整的业务信息,因为他们被约束了,不会泄漏核心信息,不会被外界影响。上下文外则是服务层,应用层,包含诸多的技术细节等“噪音信息”。

约定先于配置

约定大于配置你可能在某些技术框架里看到过这个理念,可以理解为一种更轻量的约束。这种约定随处可见:

  • Spring 中,我们依赖注入一个 @Resource 。一般是不会特意配置 name 的 。而是采用默认的约定,即按属性名称注入。
  • Asp Net Core 会约定文件夹结构,如 ControllerViewsHomeController.Index() 会被”翻译“为 /Home/Index 路由等。

或者更通用的一种约定,客户端调用后端 REST 规范接口时,一般没有强制性的约束。意味着客户端无法确定接口的输入输出结构。这种情况多靠程序猿们之间的约定,如文档注释,甚至是口头传达。

这种约定看似需要我们多记住一些规则,增加了心智负担。但反过来想,如果没有这些约定,我们要做的工作是不是会更复杂,要显示配置很多东西,引入更多的技术框架,如 SwaggerSpring XML Configuration

例子说的太多,有点偏上层应用了。回到约定的抽象意义。有时我们只想为特定的服务公开一些领域内的能力,但不可避免地泄漏了这部分能力给所有的服务,此时我们会定下约定:在注释里写上“只允许在 XXXXService 内使用此方法,所有程序员必须遵守此约定“。这条约定就成为了边界的一部分,为功能的抽象边界填砖加瓦。

总之,这些约定也可以形成边界

后言

受限于个人表达水平以及技术尚未炉火纯青。我只能分享这点个人理解了。然后推荐看些 DDD 相关的文章,就算不用,知道 DDD 中的诸多概念后,对写一手容易维护的代码会有很大的帮助。