arrow_back返回文章列表

好的模块,应该替调用者承担多少复杂性?

有些模块只有几个公开方法,接入时却需要读完半个项目。先初始化,再绑定账号,随后恢复缓存;切换账号之前必须停掉请求,退出时还要按特定顺序清理。这些步骤没有写在类型里,调用错了也未必立刻报错。

接口很短,使用成本却很高。《软件设计的哲学》提供了一个适合检查这种问题的视角:模块的价值,与它替使用者隐藏了多少复杂性有关。John Ousterhout 在公开课程中把深模块描述为较简单的接口承载较丰富的功能,并指出接口还包括行为、副作用和使用约束。作者课程笔记

这里的“深”不指调用层级,也不能靠代码行数判断。真正的问题是:调用者需要理解多少内部知识,才能正确完成自己的工作?

一个需要调用者善后的缓存模块

考虑下面这个构造的移动端示例。

图片缓存最初只提供读取和写入。后来为了节省流量,增加了过期检查;为了避免显示上一个账号的图片,增加了账号目录;为了防止同一张图重复下载,又增加了正在下载的标记。

每个方法都不长,职责看起来也很清楚。可是页面必须先查内存,再查磁盘,判断过期,查看下载标记,发请求,然后选择正确的账号目录写回。五个页面复制了这套流程,有的忘记检查过期,有的在切换账号后仍把旧结果写入新目录。

把这些方法分别放进更小的类,并没有改变页面需要承担的责任。模块只管理零件,调用者仍然负责把它们拼成一次正确的读取。

更有用的接口,可以围绕“为当前账号取得图片”设计。内部处理存储位置、重复请求合并和结果归属,调用方只表达图片标识以及确实不同的读取要求。至于允许过期内容还是必须获取最新结果,要由产品场景决定,不能靠缓存模块悄悄选择。

应当隐藏的是容易变化的内部决定

信息隐藏经常被理解成把字段改成私有,但私有字段仍可能通过调用顺序泄漏。如果写入前必须由页面调用“计算缓存目录”,页面事实上已经知道了存储布局。

在这个示例里,文件如何命名、请求如何合并、淘汰使用何种算法,都可以由缓存模块负责。调用者主要关心结果是否可用,是否属于当前账号,以及取消后还会不会收到回调。

存储格式改变时,如果页面不用跟着改,说明原来的边界确实隔离了一项变化。如果换一种缓存算法就要修改所有页面,说明策略已经散到了外面。这个检查比统计公开方法数量更有帮助。

不过,不同场景真正需要不同语义时,就应把差别放进接口。头像可以短暂显示旧图,身份凭证中的图片可能必须刷新。把二者统一成一个无法控制的读取方式,只会迫使调用方另建旁路。

接口简单,不等于把失败吞掉

一种危险的“简化”是让读取永远返回图片:网络失败便返回旧文件,账号不匹配便返回占位图,取消请求也当作成功结束。方法签名确实简洁,但调用方失去了作出业务判断的信息。

本文对图片场景的判断是:模块应当收拢内部处理,却保留使用者必须知道的结果差别。例如返回内容来自缓存还是网络,可以作为结果信息;是否过期,若影响页面展示,也需要明确表达。至于底层文件描述符和下载队列状态,通常无需暴露。

失败处理也要分清责任。模块可以自动清理损坏文件并尝试重新获取,但没有权限替用户选择是否使用移动网络,或把必须刷新的内容降级为旧内容。隐藏信息的边界,到业务决策处就该停下。

因此,一个参数更多的接口,有时反而比“万能读取”更容易理解。前提是参数表达真实需求,而不是要求调用者设置队列长度、锁策略和若干来历不明的开关。

小类也会产生维护成本

另一个常见做法,是把每个步骤都拆成一个对象。目录计算、缓存键拼接、任务包装各有一层,对外仍然保留相同参数,只是逐层转发。

有些边界确实有价值:平台 API 适配可以隔离系统差异,存储接口可以支持测试替身,身份校验可以集中约束。判断它们是否必要,要看这一层承担了什么。若删除一层后,调用者并未增加知识,测试也没有失去控制点,那么它很可能只增加了阅读路径。

深模块同样不意味着把全部代码塞进一个大类。账号身份、存储和下载可以在内部保持清楚的分工,只是使用图片的页面不必亲自协调它们。内部可理解性与外部易用性需要一起检查,不能拿其中一项为另一项的混乱辩护。

用两种设计比较,而不是凭感觉定接口

设计新模块时,可以先写两份最小调用示例。一份保留底层步骤,由页面组合;另一份提供完整操作,在模块内部协调。分别模拟普通读取、切换账号和网络失败,看看哪个设计需要调用者记住更多约定。

这种比较不需要先完成所有实现。只要把调用方的步骤写出来,很多信息泄漏就会出现:为什么页面需要知道数据库是否打开?为什么每个页面都要给同一种错误转换名称?这些重复要求,通常指向一个可以收拢的责任。

也要比较实现成本。如果为了隐藏一项很少发生的操作,引入大量状态和隐式回调,可能得不偿失。可以把常见路径做完整,把特殊能力通过独立且清楚的入口开放,不要求每位使用者都理解所有例外。

接入已有模块时,我会做四项检查:

  • 不进入实现,能否知道成功、失败和取消分别意味着什么?
  • 是否有两个调用方在重复同一段必需的准备或收尾流程?
  • 一项内部策略调整,是否会迫使无关业务同步修改?
  • 模块有没有替调用者作出本应由业务决定的选择?

评审时还可以让一位没有参与实现的人,仅凭接口说明写出调用流程。若他需要反复询问内部状态,先检查说明是否缺失,再判断接口是否把太多责任交给了外部。不能直接把所有问题归为使用者“不熟悉项目”:频繁解释同一项约束,本身就是设计需要承担的维护成本。

这些问题比“类是不是够小”更接近使用体验。一个模块真正成熟的迹象,是维护者能改进内部实现,而使用者仍能凭已有约定正确工作。

flare

保持好奇,继续思考。