2.2.3 对象模型自文档化原则
许多框架都是由成百上千种类型和更多的成员及参数所组成的。开发者在使用这样的框架的过程中,需要大量的指导,以及频繁地去回忆API地意图和正确地使用方法。它自身的参考文档不能满足这一需求。如果需要参考文档来回答最简单的问题,一是可能很耗时,二是打断了开发者的工作流。而且,如前所述,许多开发者更喜欢通过反复试验来编码,只有在直觉失效时才会诉诸文档。
出于上述这些原因,设计不需要开发者每次执行简单的任务时都要查阅文档的API非常重要。我们发现,遵循这一套简单的准则可以帮助开发者生成相对自文档化的直观API。
#框架设计原则
在简单场景中,框架必须可用且不需要文档。
&& CHRIS SELLS 在预计开发者将会怎样学习使用你的框架时,永远不要低估IntelliSense 的作用。如果你的API符合直觉,那么IntelliSense将可以满足一个新开发者80%的需求。要优化IntelliSense。
&& KRZYSZTOF CWALINA 参考文档仍然是框架中很重要的一部分,设计出完全自文档化的API是不可能的。不同的开发者,根据他们的技术水平和过去的经验,他们会认为框架中的不同部分是不解自明的。同时,对于那些会花时间预先了解框架总体设计的用户来说,文档仍然是至关重要的。对于所有这些用户来说,信息丰富、简洁且完整的文档与不解自明的对象模型一样至关重要。
√DO要确保API符合直觉,且在基本场景中,要让开发者不需要参考文档就能够成功地使用API。
√DO要为所有的API提供出色的文档。
√DO要为通用场景中的重要API提供代码示例来说明其用法。
不是所有的API都可以不解自明,一些开发者希望能够在使用API之前彻底地了解它们。
为了使框架可以自文档化,在选择名称和类型、设计异常等方面必须要小心。下面介绍了与自文档化API设计有关地一些重要的考虑因素。
2.2.3.1 命名
使框架自文档化最简单也最常被忽略的方式,就是为最常见场景中所使用的类型保留简单且直观的名称。框架设计者经常为那些不怎么通用、大多数用户都不怎么在意的类型“烧”掉最好的名称。
例如,以File命名一个抽象基类,然后提供一个具体的类型NtfsFile,如果所有用户在使用API之前都了解其中的继承关系,那么也没什么问题。但是,如果用户不了解这层关系,那么它们首先使用(且通常不会成功)的将是File类型。尽管该命名在面向对象设计的意义上可以很好地工作(毕竟,NtfsFile是一种文件),但它无法通过可用性测试,因为File是大多数开发者直觉上认为应该使用的名称。
&& KRZYSZTOF CWALINA .NET框架的设计者花了大量时间来讨论主要类型的命名替代方案。.NET中的大多数标识符都有精心挑选的名称。一些命名得不那么好的情况是由专注于概念和抽象而非主要场景导致的。
另一个建议是使用描述性的标识符名称,它应清楚地说明每个方法的作用,以及每种类型和参数所代表的含义。框架设计者在选择标识符的名称时不应该担心它过于冗长。例如,EventLog.DeleteEventSource(string source,string machineName)看起来可能相当冗长,但是我们认为它具有肯定的可用性价值。
描述性的方法名只适用于那些简单的具有清晰语义的方法。这是我们应该遵循避免复杂的语义这个很好的通用设计原则的另一个原因。
总的来说,你在选择标识符的名称时应该格外小心。命名选择是框架设计者不得不做的重要抉择之一。在API发布后再去改变标识符的名称,将及其困难并且代价高昂。
√DO要使关于标识符命名选择的讨论成为规范审阅的一个重要部分。
大多数场景会以哪种类型开头?在尝试实现此场景时,大多数人首先想到的名称是什么?用户首先想到的是通用类型的名称吗?例如,由于“File”是大多数人在处理文件I/O场景时会想到的名称,因此用于文件访问的主要类型应被命名为File。同时,还应该讨论最常用类型的最常用方法和它们所有的参数。是不是任何熟悉你的技术,但不熟悉这个特定设计的人都能快速、正确、轻松地识别和调用这些方法?
×DON'T不要担心再使API自文档化地过程中使用冗长的标识符名称。
大多数标识符名称都应该清楚地表明每个方法地作用,以及每种类型和参数所代表地含义。
&& BRENT RECTOR 开发者阅读地标识符名称比其键入地名称多数百倍,甚至数千倍。现代编辑器更是将打字这样的琐事减少到最少,更长的名称使开发者可以通过IntelliSense更快地找到合适地类型或成员。此外,长期而言,那些使用具有良好类型命名的代码更容易理解和维护。
针对C语言家族开发者的一个特别注意事项是:要摆脱使用隐晦的标识符命名这一习惯带来的生产力下降,要从这个枷锁中解放出来。
√CONSIDER在设计过程的前期就要让技术作家参与进来。他们可以成为一个很好的资源,帮助你发现那些命名不当和很难向用户解释的设计。
√CONSIDER要为最常用的类型保留最佳的类型名称。
如果你相自己会在以后的版本中添加更多的高级API,则请放心地在框架地第一个版本中为后续的API保留最佳名称。
&& ANTHONY MOORE 即使你从未想过要在以后使用该名称,也仍然有其他理由要求你避免使用过于笼统的名称。更具体地名称有助于使API变得更容易理解和可读。如果有人在代码中看到通用名称,则其可能会假定这是一个非常通用地应用程序,因此,对更特定地东西使用通用名称具有一定地误导性。此外,更具描述性地名称还有助于使用者分辨出类型与哪些场景或技术相关联。
2.2.3.2 异常
异常在自文档化框架设计中扮演了重要角色。通过异常消息,可以向开发者传达API正确的使用方法。例如,下面这段代码将会抛出一个消息为“在写入事件日志之前没有设置Source属性”的异常。
//C# var log =new EventLog(); //log没有设置Source属性 log.WriteEntry("Hello World");
√DO要利用异常消息向开发者传达框架的使用错误。
例如,如果用户在施一公EventLog这个组件时忘记了设置Source属性,那么任何依赖该属性的调用都应该在异常消息中声明这一点。第7章为异常及异常消息的设计提供了更多的指导。
2.2.3.3 强类型
强类型或许是决定API直观性的最重要因素。显然,调用Customer.Name要比调用Customer.Properties["Name"]容易。此外,以String形式返回名称的Name属性要比直接返回的Object更可用。
在有些情况下,使用属性包(property bag)、后期绑定调用(late-bound call)和其他弱类型的API也是必要的,但是它们应该只是该规则的一个例外,而不是通用实践。此外,设计者应该考虑为用户在非强类型API层上执行的最常见操作提供强类型辅助方法。例如,Customer类型可能有一个属性包,但也应该为大多数常见属性(如Name、Address等)提供强类型API支持。
√DO要尽一切可能提供强类型API。
不要完全依赖弱类型API,如属性包。在必须要用到属性包时,也要为属性包中最常见的属性提供强类型支持。
&&VANCE MORRISON 强类型(有更好的IntelliSense支持)是.NET框架比一半的COMAPI更容易“在编程中学习”的重要原因。我仍不时地需要使用由COM提供地功能,只要它是强类型,我就可以很好地使用。但是,当需要使用枚举值时,API经常返回或接受一个泛型对象,或字符串参数,或传递的DWORD,我需要花费10倍的时间来搞清楚到底需要传递什么。
2.2.3.4 一致性
与用户已经熟悉的现有API保持一致是设计自文档化框架的另一个强有力要素。这包括与其他的.NET API以及一些遗留的API保持一致。尽管如此,你不应该以遗留的API或设计不当的现有框架API为借口,以避开书本中所述的任何规则,但也不应该在没有理由的情况下随意更改符合标准的既定模式与设计。
√DO要确保.NET以及用户可能与之进行交互的其他框架的一致性。
一致性对于可用性来说非常重要。如果你的API和框架中用户熟悉的某些部分相似,那么他(她)将会认为你的设计自然且直观。你的API与其他.NET API的区别,应仅限于你的特定API的一些独有之处。
2.2.3.5 有限的抽象
通用场景API 不应该使用太多的接口和抽象类,它应该符合系统的物理结构或众所周知的逻辑。
正如前面所提到的,标准的面向对象设计方法是针对代码的可维护性来优化的。这很合理,因为维护成本是开发软件产品的总体开销中最大的一部分。提升可维护性的方式之一就是使用诸如接口、抽象类这样的抽象。因此,现代设计方法倾向于使用大量的抽象。
问题在于,具有大量抽象概念的框架迫使用户在开始实现哪怕是最简单的场景之前就得成为框架架构的专家。然而,很多开发者并不渴望也没有业务上的理由来成为一名熟知该框架提供的所有API的专家。对于简单的场景,开发者要求API足够简单易用,且不需要它们来了解整个功能领域如何组合在一起。这是标准设计方法没有对其进行优化也从未声称要优化的问题。
当然,抽象在框架设计中有它们的地位。例如,抽象在提升框架的可测试性和一般可扩展性方面非常有用。由于设计良好的抽象,这种可扩展性通常是可能的。第6章讨论了如何设计可扩展API,帮助你在过量的可扩展性与过少的可扩展性之间取得适当的平衡。
×AVOID 避免在主线场景API中使用太多的抽象。
&& KRZYSZTOF CWALINA 抽象几乎总是必要的,但是太多的抽象表示系统过度工程化。框架设计者应该仔细地为客户设计,而不是为了自己的智力享受。
&& JEFF PROSISE 有过多抽象的设计也可能会影响性能。我曾经与一位客户合作,该客户对产品进行了重新设计,加入了大量的面向对象设计。他们将“所有”内容都用类来建模,最终得到了一个嵌套深得荒谬得对象层次结构。本来重新设计得部分目的是提高性能,但是“改进”后的软件运行速度比原来慢了4倍!
&& VANCE MORRISON 任何曾“乐于”调试C++ STL库的人都明白抽象是一把双刃剑。太多的抽象和代码会使之变得很难理解,因为你必须记住场景中所有抽象名称的真正含义。过度地使用泛型和继承是你可能过度泛化地常见症状。
&& CHRIS SELLS 老话常说,计算机科学中的任何问题都可以通过一层抽象来解决。遗憾的是,开发者遇到的问题往往都是由它们造成的。
2.2.4 分层架构原则
不是所有的开发者都被要求来解决同一类问题,不同的开发者通常需要所使用的框架提供不通过层次的抽象和不同数量的控制器。一些经常使用C++和C#的开发者看API的表现力和功能,我们将此类型的API称为底层(low-level)API,因为它们通常提供底层的抽象。相反,一些经常使用C#和VB.NET的开发者则更看重API的生产力和简单性,我们把这类API称为高级(high-level)API,因为它们提供了更高级别的抽象。通过使用分层设计,构建一个单一框架来满足这些截然不同的需求是完全可能的。
#框架设计原则
分层设计使单一框架能同时提供功能和易用性。
&& PAUL VICK 将VisualBasic 迁移到.NET平台的一部分原因是,许多VB开发者需要使用底层API来访问特定的功能,然而,我们提供的高级API并不具备这样的能力。VB开发者在开始时可能会花费大量的时间使用高级API来快速开发应用程序,但这并不能改变大多数开发者迟早需要调整或优化应用程序的事实。为了优化应用程序,开发者通常需要引入底层API来实现额外一小部分功能。因此,底层API的设计应当充分考虑到VB开发者。
要构建一个面向广大开发者的单一框架,一般准则是将API集合进行拆分,底层类型暴露出它所具备的所有能力,高级类型则应该基于更底层的实现封装出更方便的API。
这是一个非常强大且简单的技巧。如果只有单层API,你往往不得不在更复杂的设计和放弃某些场景的支持之间做出选择。拥有一个低阶的功能层,为将高级API真正用于主线场景提供了自由。
在某些情况下,我们可能并不需要其中的某个层次。例如,一些功能领域可能就只会暴露底层API。
.NET JSON API 正是这种分层设计的一个例子。为了功能和表现力,Utf8JsonReader提供了一个底层的JSON语法分析器,允许开发者针对JSON中的个别token进行编码。然而,.NET也有JsonDocument和JsonElement类型,它们是基于Utf8JsonReader实现的,允许开发者面向高级的概念编程,例如文档结构,他们不需要关心对象深度的计数或转义字符。类型具有一致性的行为和标识符,只是不同的层次面向的是不同的场景和受众。
对于API分层,有两种主要的命名空间拆解方式:
- 将不同的层次划分到不同的命名空间中。
- 将所有层次暴露在同一个命名空间中。
2.2.4.1 将不同的层次划分到不同的命名空间中
拆分框架的一种方式是将高级和底层的类型放在不同但又相关联的命名空间中。这样做的好处是,当开发者需要实现更复杂的场景时,可以在主流场景中将底层类型隐藏起来,而又不至于将它们置于遥不可及的地方。
与.NET网络相关的API正是以这种方式来拆分的,有底层的System.Net.Sockets.Socket类型、中级的System.Net.Security.SslStream类型和高级的System.Net.HttpClient类型。HttpClient的实现最终依赖Socket和SslStream,但是大多数需要使用HTTP的开发者可以直接使用HttpClient,无须用到底层类型。
绝大多数框架应该遵循这种命名空间拆分方式。
2.2.4.2 将所有层次暴露在同一个命名空间中
另一种拆分的方式是将高级和底层的类型放在同一个命名空间中。好处是,当我们有需要时,它能够自动回退到更复杂的功能上。缺点是,将复杂的类型放在同一个命名空间下会使一些场景实现变得更加困难,即使我们没有用到这些复杂的类型。
这种拆分适用于简单的功能,例如,System.Text命名空间既包含底层类型,如Encoder类和Decoder类,也包含高级的Encoding类的子类。
&& STEVEN CLARKE 仔细考虑分层API的运行时行为。例如,如果开发者在某一个层上工作,确保其不会捕获从不同的层抛出的异常。确保在编写、阅读和理解代码时,开发者只需要真正关心某一层中发生的事情,并且可以将其他层安全地视为黑盒。
√CONSIDER 建议采用分层架构,针对生产力来优化高级API,针对功能和表现力来优化底层API。
×AVOID 避免在底层API很复杂(如包含很多类型)的情况下,将底层API和高级API放到同一个命名空间下。
√DO要确保一个功能领域的各个层次能被很好地集成在一起。开发者应该能够使用其中任意一个层次来进行编程,当他们需要将相应地代码更改为使用另一个层次来实现时,不需要重写整个应用程序。
总结
在设计一个框架时,很重要地一点是要意识到框架地受众是形形色色的,无论是他们的需求还是技能水平,皆是如此。遵循本章中所介绍的原则,将确保你的框架可以为广大的开发者群体所使用。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?