软件开发复杂性的体现:命名的艺术
英文原文:Software Complexity: The Art of Naming
想把一个东西写好很难。为什么呢?因为只要写好了,才会有很好的阅读体验。我们往往关注了前者而忽略了后者。我们忘记了代码只写一次,但要读很多次。
写得好是指写出来的东西读起来容易,而不是指写作本身,这一过程会产生大量的共鸣。它是指,退后一步,从读者的角度来理解所写的东西。人们必须以人的思维来理解问题,然后用其它人能够理解的方式表达出来。在我看来,软件属于社会科学的一部分。我们要搞清楚代码写出来是给谁看的,不是给人看的吗?
因此,理解如何将思想和过程传达给我们的同行甚至我们自己,这就是编程的核心。
为组件命名
为了说清楚第一个概念,我们来玩一个叫“我们在哪个房间?”的游戏。我会给出一张图,然后你告诉我这是哪个房间。
3个问题中的第1个
从这个图很容易判断出来是在客厅。我们从一个组件就能知道所处的房间。这非常容易,我们继续。
3个问题中的第2个
从这个物体很清楚的知道这是在卫生间。
发现什么规律了吗?房间的名称是一个标签,它定义了这个房间里有什么。有了这个标签,我们不知道进去看也知道里面有些什么东西。这足以建立我们的第一个推论:
推论 1: 容器的名称包含了其功能元素
注意这是最基本的“鸭子类型”[译者注:如果它的动作像一只鸭子,那它就是鸭子]。如果有一张床,那这里就是卧室。
反过来也是如此:基于容器的名称,我们可以推断出它的组成部分。如果我们谈论一个卧室,很可能它有一张床。这样产生了我们的第二个推论:
推论 2: 可以根据容器的名称推断其中的组件
显然我们已经有了一些规则,让我们把这些规则应用到下一个房间。
3个问题中的最后一个
哇,床和马桶怎么会在同一个房间?这个房间的定义很模糊,朦朦胧胧,如果一定要用前面的两个推论来为这个房间命名,它只能称为怪物房间。
这里的问题不在于房间里物体的数量,而在于完全无关的事物被看作有同样的功能。在家里,我们会把相关的有类似作用或意图的物品放在一起。如果把作用不同的东西胡乱放在一起,就让人搞不明白架构师到底想怎么来使用这些东西。由于混乱,我们在这里不知所措。
推论 3: 容器定义的明确程度与其内部组件的紧密程度成正比。
这似乎不容易理解,那来看看图示:
如果组件相关,就很容易找到一个好名字[译者注:指容器的名字]。如果事务各不相干,找个合适的名字就会变得困难。这里提到的关系,可能是指它们的功能、目的、策略、类型等。在我们谈到标准之前,关系本身并不包含太多意思。现在先不要急,我们很快就会讲到。
这对于软件同样适用。我们有组件、类、函数、服务、应用程序和其它一些东西。Robert Delaunay 曾经说过“我们的理解与我们的感知相关。”在当前的技术背景下,我们的代码是否能让读者以最简单的方式感知到业务需求呢?
示例 1: HTTP 领域和汽车 domain and a car
HTTP 是一个领域,它有请求和响应。如果我们我们在其中放入一个汽车组件,那就不能再称这为 HTTP。这种情况下它就已经变得混乱了。
public interface WhatIsAGoodNameForThis { /* methods for a car */ public void gas(); public void brake(); /* methods for an HTTP client */ public Response makeGetRequest(String param); }
示例 2: 通过词语来耦合
在类名中添加 Builder 或者其它以 er 结尾的单词是种常见的模式。SomethingBuilder、UserBuilder、AccountBuilder、AccountCreator、UserHelper、JobPerformer。
通过名称,我们可以了解三件事情。首先,在类名中使用 Build 这个动词意味着它是穿着类这件外衣的程序。第二,它有两个隐藏在内部的元素,User 和 Builder,这意味着可能违反了封闭性原则。第三,这意味着 Builder 可以访问到 User 的内部工作,毕竟它们彼此纠缠。
这类似于工厂模块。我们的示例代码在整个代码库中滥用时,它就会成为一个问题。此外,我得提醒你,在工厂模式中不需要什么类。应用程序的 createUser() 就能完成工厂的工作。
[译者注:Builder 也是一种模式,所以关于作者的这个观点,请慎思]
示例 3: Base
来看一点实际项目中的例子。第一个例子是 I18n(国际化)的 Ruby Gem (为了简便起见,只列出了类和方法的名称):
class Base def config def translate def locale_available?(locale) def transliterate end
这里的 Base 并不能表达什么意思。它可以进行配置和翻译,也可以描述一个位置是否可用。它做了一些各不相同,毫不相干的事情。
示例 4: 名称引导设计
我们在谈论名称如何引导我们的设计时,提到了好几个例子,让我们感兴趣的例子中,有一个如下:
class PostAlerter def notify_post_users def notify_group_summary def notify_non_pm_users def create_notification def unread_posts def unread_count def group_stats end
PostAlerter 这个名称暗示我们它的功能是在提交的时候提醒某人。然而,unread_posts、unread_count 和 group_stats 却很明显在干别的事情,这就使得类名称不太理想。如果把这三个方法改到名为 PostsStatistics 的类中,表达出来就更清晰,让新接触的人一看就能明白。
class PostAlerter def notify_post_users def notify_group_summary def notify_non_pm_users def create_notification end class PostsStatistics def unread_posts def unread_count def group_stats end
示例 5: 奇怪的名称
Spring 框架中有一些例子说明组件做的事情太多,其名称类似于我们的怪物房间。这里就有一个 (因为这个就太多了点):
class SimpleBeanFactoryAwareAspectInstanceFactory { public ClassLoader getAspectClassLoader() public Object getAspectInstance() public int getOrder() public void setAspectBeanName(String aspectBeanName) public void setBeanFactory(BeanFactory beanFactory) }
示例 6: 改变一下,说说好名称
我们讲了太多不好的名称。D3 的 arc 中就定义了不错的名称,比如:
export default function() { /* ... */ arc.centroid = function() { /* ... */ } arc.innerRadius = function() { /* ... */ } arc.outerRadius = function() { /* ... */ } arc.cornerRadius = function() { /* ... */ } arc.padRadius = function() { /* ... */ } arc.startAngle = function() { /* ... */ } arc.endAngle = function() { /* ... */ } arc.padAngle = function() { /* ... */ } return arc; }
这些方法每一个意义都很有完整的意义:它们都是基于弧所拥有的属性来命名的。下图中我非常喜欢的一点在于它真的很简单。
方法 1: 拆解
应用场景:你不能为类或组件找到好名称,但你知道如何拆解它们,而且期望给它们的组合找到一个好名称。
这包含两个步骤:
1. 确认我们拥有的概念
2. 拆解它们
在马桶 + 床的情况下,我们把不同的事物拉开,床在左,马桶在中。好了,我们终于把事物拆分成两个部分,使它们不再那么别扭了。
如果你不能为某个事物找到好名称的时候,也许是因为你面前不只一件事物。不过你现在已经知道对多个事物命名是件困难的事情。当遇到麻烦的时候,不妨确认一下构成这个事物的部分和动作。
示例
我们有一个尚未命名的类,包含 request、response、headers、URLs、body、caching 和 timeout。把所有这些从主类中拉出来,我们剩下了这样一些组件:Request、Response、Headers、URLs、ResponseBody、Cache、Timeout 等。如果我们已知这些类的名称,就会相当确定我们正在处理一个 Web 请求。HTTPClient 会是个不错的 Web 请求组件名称。
遇到困难的代码时,不要一开始就想着整体。考虑一下部分。
方法 2: 发现新概念
应用场景:某个类并不简单或者不清晰时
发现新的概念需要业务领域的知识。如果在软件中使用业务术语,因为专业语言已经建立了而且到处都在使用(Evans, 2003),不同专业领域的专业人士就会使用同样的习语。
示例 1: 将组件封装到新的概念中
几年前,一家公司即将失去一份大合同。为什么呢?因为该团队发布新功能和修复错误的速度太慢。
这个市场电子商务为不同国家的学生提供不同规则的多个支付网关,要求相当复杂。当我看到付款代码– PaymentGateway 时,我对其复杂性感到震惊,其中包括:User、UserAddress、CreditCard、BillingAddress、SellerAddress、LineItems、Discounts 等等。它的构造函数是巨大的,这种复杂性使得很难添加新的规则,因为改动一处会破坏其他规则,同时要求我们改动所有网关适配器。
问题延伸到付款之外的事情。电子邮件会发送给学生,通过 messaging 类再次聚合所有这些数据。技术支持也有自己的显示端,这个数据第三次发生聚合,除了在这个特定的地方使用一个叫做 Aggregator(没有上下文的单词)的类。我们不得不做一些更改以修复这个架构阻碍。
为了解决这个问题,我开始了一个头脑练习。这是一个如何去做的想法:
在这里,我用于关于此事的这些细节,我需要你(PaymentGateway)对我负责。如果这是一张桌子,我会组织这些文案,我可能会把它们称为 Invoice(发票)。那么如果我创建了一个名为“Invoice”的类,这只不过是所有这些其他细节的汇总,这样网关就不需要知道这些规则是如何完成的,因为 Invoice 是知道的?不是插入一百万件物品,我只是把它交给你?
术语 Invoice 未在任何其他地方使用过。我们花了一个月时间在此重构,一旦完成,我们就能够更快更新软件。
Invoice 是概念的一个很好的示例,它是来自许多源的数据汇总,并且大多数人都知道它是什么。 最终的解决方案增加了 Invoice 类,把它单独注入到网关中,使用门面模式并隐藏其他类。
良好的命名不仅仅是写出美丽的语句,还要更准确地描述以前无法表达的内容。
示例 2: 基于业务域的名称变动
在一个绿地拼车项目中,我们从新设计了该系统,在研究其他交通运输解决方案时发现,在某一天从起点到目的地描述某人旅程的最适当的词是旅行(trip),而这群人被称为乘客(ride)。 我们发布了一个词汇表,所以公司的其余人员可以讨论和分享相同的通用语言。
推出之后,我们的客户总是把 trips 称为 rides 。 不久之后,我们在将客户要求转换为必须完成的任务遇到麻烦,痛定思痛之后,我们决定是时间将 trips 重构为 rides ,将 rides 重构为 carpools 了。这就解决了在一家公司讲两种不同语言的问题。
示例 3: 抽象的等级
一个人说,移动右腿然后左腿然后右腿,另一个说走路。两者都是一样的,但后者据说更抽象。
理想情况下,随着代码越来越接近其公开 API ,它越接近于企业术语。随着它越接近数据库和底层,它使用与其上下文相关的机器术语。在这之间,存在一个从多到少抽象渐变过程。
在一家公司,一个商人会说 post Tweet,所以一个如 postTweet() 的名字将会比一个公开的 API(比如makeHttpRequest())更有意义。在一家拥有更多技术服务的公司中,后者将更为充实。
第二,考虑特异性。postTweet() 非常具体,而 makeHttpRequest() 是如此通用,它可以用于 Facebook 或基本上涉及 HTTP 的任何内容。一个通用名称可以轻易地重用,代价是不确定性。 这就解释了为什么框架代码与商业软件代码的有如此大的区别。
示例 4: generalization 泛化
很久以前,CMS 中有数据库表 news、history、videos、articles、pages、other。他们中大多数具有相同的列:title、summary、text。videos 表具有额外的属性,例如 url(嵌入YouTube); history 表具有日期属性,以便页面可以按年份显示历史事件列表。所有这些表格看起来像是副本,在这里和那里仅有一些差异,而且添加新的功能需要重写大量的样板。
我将所有这些表折叠成一个称为 contents 的外键,指向一个名为 sections 的表,其中包含 news、history、videos、others 的列表。现在,contents 的一个编码就足够了。多年以后,一个朋友不得不写一个小的 CMS,我推荐使用同样的做法。一旦管理内容的表单完成,它一般花费了1/N时间来实现任何功能,因为对于同一类型的每个新的部分,它都已经完成了。
通过赋予它另一个名字极大得提高生产力。news 是一类内容,Article 也是一类内容,history 又是一类内容。所有这些都可以共享相同的属性吗?是的。
方法 3: 分组的标准
什么时候使用:当名字很好但是他们不能很好地配合时。
组件可以按照各种标准进行分组,包括物理性质、经济性、感情色彩、社会性和软件中最常用的功能。Photo 框架根据感情色彩方面分组,而产品则根据经济动机分组。 沙发和电视留在同一个房间,根据功能标准分组在一起,因为它们具有相同的功能或目的,均用于休闲。
在软件中,我们倾向于按功能对组件进行分组。列出你的项目文件,你可能会看到像 controller/、models/、adapters/、templates/ 等等。 然而,有些时候,这些分组可能不太合理,这将是重新评估模块结构的最佳时间。
示例: 使用策略进行分组
用于自动化文档操作的库根据代码、lints 描述文件(保证格式正确)生成规范文档(比如 API 蓝图)并上传到云中(比如 S3 )。
根据文件格式,将自动进行各种后续决定。选择 API 蓝图将会选择不同的 linter,不同的测试器和不同的 API 元素转换器。 这里基于一个输入来组合所有这些不同的功能的关键词是策略(strategy)。此后,该库中包含一个将文件格式、linter、文档测试器和存储提供程序组合在一起的模块或者命名空间,被称为Strategy(策略)。这使得库可以将业务核心策略中的普通文件操作(如上传者、解析器和命令行等)分开。
利用上下文
每个应用程序都有不同的上下文,包括其中的每个模块,它们中的每个类,每个函数都有。单独的 User 一词可以表示系统的用户,但也可能是数据库表或第三方服务凭证。 lib/billing/user 与 lib/booking/user 是不同的,但仍然属于用户范畴。
设想一下,每个诸如模块的容器,都是一个桶。 在其中,组件与外界绝缘。 你可以自由地命名这些类,无论你想要什么样的名字。这使得不必为常见事物寻找深奥的名字而绞尽脑汁。
针对微服务(许多独立的桶)胜过整体式架构(一个内嵌小桶的大桶)的一个强有力的论据是它加强了对每个服务中的责任的限制,因为现在你不能轻易地将完全不相关的事情互相整合在一起。 Billing 位于 BillingApp 内部,booking 位于 BookingApp 内等等。但在整体架构中,这些相应的服务名称可以是简单的模块名称,但并不是每个人都有责任来保持整洁。
示例: Namespaces (命名空间)
马克正在建立一个需要生成数十万个广告的 ads 平台,然后将其发送到 AdWords、Facebook 和 Bing ,所有这些都通过图形用户界面(GUI)进行管理。
马克从一个称为 Ad 的实体开始,很快就变得膨胀。AdWords 的广告有 headline_part1 和 headline_part2 ,但 Facebook 里面不是这样,而 Bing 只有一个 headline(标题)。 他需要想办法分开他的实体。他考虑到不同的语境,以及他如何利用语言的命名空间来表达这一点。 他想出了以下结构:
- Adwords::Ad:这表示 Adwords 中一个 ad 对象。它用于专属于 Adwords 的属性以及可包含在该类的逻辑处理。
- Facebook::Ad:和上一个类似,但是它拥有 Facebook 专有的要求和逻辑处理。
- Bing::Ad:和上面的类似。
- RemoteAdService::Ad:这个作为 Adwords::Ad、Facebook::Ad、Bing::Ad 与系统的其他部分交互的接口。这意味着这三个类将会拥有同样的公共 API,允许系统使用多态。
- Database::Ad:这是 ads 表的 ORM。它使用 ActiveRecord、DataMapper 或者其他自定义方案。
- GUI::Ad:这表示在 UI 上用于显示广告的属性。它可能包含展示和国际化的功能。
- API::Ad:针对那些用于自定义属性的广告的 HTTP 终端,因此序列化的逻辑保存在这里是有道理的。
单词在不同的上下文中可以表示不同的东西,当我们考虑上下文时,我们可以为组件选择更简单的单词。 在这个示例中,我们没有必要做任何复杂动作来找到这些组件名称,因为它们是一回事,ad(广告)。
无意义和新词
多年来,名字变革并获得新的含义。其他人来补充新的意义。
小助手 Helper:Helper 是那些支持应用程序的主要目标的函数。但是,那么用来定义应用程序的主要目标的标准是什么呢?应用程序中的所有东西都是用于支持应用程序主要目标的。
在实践中,它们被集中在一个非自然的分组中,为一些其他常用的操作提供可重用模块。他们倾向于遇到依赖情结Feature Envy,他们需要访问另一个组件的内部数据来工作。他们也是那些找不到合适名字的东西的缘由。
Base:名为 Base 的类是很久以前在 C# 中在缺少一个更好的名字时用于指定继承的惯例。例如,汽车和自行车的父类将是 Base 而不是 Vehicle。尽管微软的编码规范中建议避免了这个名字(Cwalina,2009),但它影响了 Ruby 世界,其中最著名的是 ActiveRecord。到目前为止,我们依然可以看到在开发者找不到合适名字时使用 Base 作为类名的情况。
Base 的变体包括 Common 和 Utils 。例如,JSON Ruby gem Common 类具有解析、生成、加载和 jj 的方法,但是这里的 Common 究竟是什么意思呢?
Tasks:在 Javascript 社区有一个指示来调用异步函数,tasks。 它起源于 task.js ,即使原始库不存在,该术语也依然被使用。
团队中的每个人都能理解这个吗? 那就好了。但是,当一个新人加入该团队,遇到被抛弃在垃圾堆中的 60年代以来就存在的命名法,又会是怎样呢?
我在一个项目中工作,其中一个类的名字,猜猜看,Atlanta。 是的,亚特兰大, 操蛋的亚特兰大。没有人知道或可以告诉我为什么使用这种叫法。
沟通
“Reality exists in the human mind and nowhere else.(事实存在于人的思想中,而不是其他任何地方)” George Orwell
我认为沟通交流的做法是一个利他主义的行为,我们提高技能的努力与我们对他人的关心程度有关。我们希望人们更容易理解,我们想要消除冲突和障碍。
其次,我们希望别人能理解我们。通过承认投递消息给接收者是发件人的责任,我们建立一个移情的环境。 这是一个双赢的局面。 没有任何借口不去练习我们的沟通技巧 – 除非你住在丛林中。
随着写作,我们优化阅读,移情的练习可能是枯燥的。但是,正如生活中的一切,熟练度只会出现在那些常练习的人身上。
参考书目
Cwalina,Krzysztof.2009,框架设计指南:可重用 .NET 库的约定、惯用语和模式,第二版。 Boston: Pearson Education, Inc. 206。
Evans, Eric. 2003。域驱动设计:解决软件核心复杂性。Boston: Addison-Wesley Professional。
本文文字及图片出自 OSchina
你也许感兴趣的:
- 【程序员搞笑图片】只是一个简单的按钮
- 【译文】一个年轻程序员的奋斗
- 【译文】程序员不善于管理状态
- 【译文】软件工程中的软技能
- 【译文】爱国者导弹软件问题
- 软件研发的道德情操
- 软件开发最难的不是编码,而是需求,你认同吗?
- 揭秘亚马逊内部与众不同的软件开发系统
- 软件开发是一种十赌九输的游戏
- “干掉”产品说明书的,才是好设计?
你对本文的反应是: