传统和敏捷开发冲击下:有价值的技术文档该如何写?

背景

传统瀑布开发模式下非常重视文档,每个开发环节的衔接都通过文档实现。这种重视在CMMI达到了极致,软件开发的每一步从形式到内容都要求文档化,需要设计者花费大量的精力在文档的撰写和维护上。高度文档化需要投入巨大的成本,这种成本在相对固定,变化较少的问题域(如传统的制造、管理)可以从软件后期的维护收益上得到补偿,实践中也得到了较好的效果。但在变化较多的问题域(如互联网、创业企业),高度文档化会造成整个软件生产过程的反应迟滞,进而造成企业竞争力的下降。于是这些要求快速反应,快速迭代的行业逐步放弃了高度文档化的要求,开始追求原型设计、分步迭代以及“代码及文档”。

可是物极必反,实践过程中很多“敏捷”项目却从“高度文档化”走向了“无文档”:需求只有几句定性的描述,一个或几个开发自己鼓捣着就把功能完成了,最终交付的只有一个svn地址,基本没有任何文档,或者只有少数更新不及时的随笔。从结果看,绝大多数这样的项目无论技术上还是业务上,最终都是失败的。个别项目业务需求很大,技术后期满足不了,只能进行痛苦的重新设计和重写,这个过程往往耗时甚多,并对业务有或多或少的影响,背离了当初通过"敏捷"保障业务快速发展的初衷。

作为开发,我们的目标是正确认识文档的作用,制定合适的规范,撰写必要而够用的文档。提升文档的编写和阅读能力,同时使文档为我们服务,为开发和维护过程创造价值。

文档的作用

帮助设计者克服恐惧

面对新的业务需求,尤其是脱离了熟悉"甜区"的需求,设计者的内心多少都会有恐惧。

新的业务需要全新的设计,需要通盘考虑各种情况如何处理,这个过程中往往还存在很多互相抵触的点,需要设计者作取舍。如果不写文档,设计者在脑海中构建一个初步的想法之后就会动手编码。这个初步的想法一般来说并不全面,但恐惧往往会驱使程序员尽快开始行动,试图看到一些产出,同时也能给(管理)上层一些响应。

再差的设计者都能想办法满足眼前的需求

可是未经全面考虑的产出往往有着较大的(设计)漏洞。幸运的情况下这种产出后续会被覆盖和改写成更有好的设计和实现,如果比较不幸,这些有缺陷的产出则会直接把后续的开发带歪。

写文档能帮助设计者抵御立即动手编码的冲动。因为**文档比代码的抽象程度更高**,写文档促使设计者从更加抽象的角度思考问题。借助文档的抽象,设计者能从概念,而非实现的角度看待整个系统。脱离了实现细节,设计者更容易发现哪些概念属于错误的抽象(错误的抽象使某个概念和其它概念间存在不合理的依赖或交叉)以及整个设计拼图中有哪些缺失(概念间缺少必要的联系)。通过撰写文档,设计者为自己提供了一幅“全景图”,从而有勇气去作全局的设计。

不谋万世者 不足谋一时

不谋全局者 不足谋一域

软件的设计者规划出模块、接口、服务等一系列概念,由实现者将其变成代码。这个过程中,设计者最重要的责任就是保证所有这些组件彼此间兼容,能够正常通信并实现需求,同时还要考虑到未来的可扩展性。设计者要不停的追问自己“如果发生了某种变化,现有的组件布局是否能够处理?如果不能,是否能快速定位要找到的组件,用最小最清晰的修改承载这种变化”。这个高度抽象的过程,脱离了文档的帮助,直接在代码层面进行效率会低很多,也更容易出错。

沟通和交流

作为一个团队成员,仅仅交付功能是不够的,我们要交付的是可理解,可维护的功能。为了这个目标,我们和各方的交流,把设计思路向他们讲清楚:

项目中有哪些状态,状态的格式,状态的物理分布;

项目采用什么原则进行模块划分,出于什么考虑(如果有多个方案时选择了其中一种);

某些特殊设计是出于什么考虑,背景知识(性能、吞吐量、一致性、复杂性...)。

设计评审者

设计评审者通常对项目细节不会非常熟悉,他们关注的是整个项目的核心诉求,技术难点和实现方案是否自洽。他们比设计者(文档撰写者)考虑的更加抽象,看的往往只是几张图或者表格,但这几张图和表格并不会凭空出现,一定是从设计文档中抽象出来的最核心的设计要素。

服务使用者

按照“对接口编程”的思想,工作的边界应该落在接口上。接口上的文档通常有两类,一类是独立的接口描述文档和示意图,用于团队内部review;另一类是程序内文档(javadoc),作为接口说明(spec)供接口使用者参考。由于javadoc支持HTML,设计时可以先写interface,用详细的javadoc描述接口信息,再用工具抽取成独立的接口描述文档。这样即可以避免两份文档之间不一致,也更容易实现代码和文档的一致。当然,示意图这类更抽象的文档仍然需要手工整理。

服务维护者

包括进入项目的新同学和(项目交接过程中)的接手者。这些同学需要更加详细的文档,才能了解最初设计者的意图,并在后续设计中保持这个意图。

最后一点尤为重要。很多时候接手的同学通过翻代码能了解作者是怎么作的,但缺乏文档很难去了解作者是怎么想的。如果维护者不知道设计者的思路,再好的设计也无法得到贯彻。如果你作了一个正确的设计并为这个设计骄傲, 务必在文档中说清楚你的想法和目标 ,就像手工艺大师在作品上刺上自己的名字一样。

衡量产出

种瓜得瓜 种豆得豆

衡量程序员的产出是特别麻烦的事。各种衡量方式会带来不同的导向:

程序员代码中价值最大的部分。

设计者思路是否清晰,是否有原则性错误。

程序员是否有能力提交工业级别的设计和代码(重点在于合理、可读和可维护性)。

统计代码行 这是外包经常采用的指标,统计代码行会造成大量的复制/粘贴。但实际上完成同样的功能,篇幅少的方案往往更清楚,也更易维护。所以代码行不适合我们的需要。

看业务产出 从更高层次衡量团队贡献时,业务价值毫无疑问是最重要的指标,但衡量单个程序员的能力和产出时,业务价值并不是一个很好的指标,毕竟很多业务因素不是程序员能控制的。

看技术产出 这要求能明确程序员的技术产出包括哪些方面,比较客观的指标就是看技术文档和代码。由于评审者实际不可能看完一个人产出的所有代码,技术文档就在这里起到了索引的作用。技术文档可以让评审者快速了解。衡量产出时,文档和代码的比重通常会在三七开或者四六开。**我们随后的考核中会采用文档占40%,代码占60%这样一个标准**。

必要的文档

增一分则太长 减一分则太短

我们需要文档,但不需要冗余的文档浪费程序员的生命和精力。我们希望程序员写的每份文档都是有价值的,有信息量的。

目前来说,对新功能需要提供以下设计文档:

实体关系图(必选)

实体关系图是对功能抽象程度最高的文档,它包括:

  • 新的功能要引入哪些(主要)实体;

  • 新实体之间有什么关系(一对一,一对多,多对多,父子,组合,继承...);

  • 新实体和原有实体之间有什么关系。

通过实体关系图,可以尽快了解设计者的思路。实体关系图的重点是看实体抽象是否正确,新的抽象能否正确实现所有用例。

//TODO 补充例子

状态设计(必选)

系统设计中很大一个工作就是规划系统状态(数据)的分布,通过状态分布可以大致了解实现能达到的性能、一致性和鲁棒性。这份文档包括:

  • 新的功能会新增哪些状态(包括持久化状态和非持久化状态),会对已有状态造成什么影响。

  • 状态的格式(数据库的DDL或者no-sql的json/KV)。

  • 状态的分布(集中式,分片,对分片要指明Sharding方法)。

  • 状态的一致性方案(对不同状态的一致性需求,实时/定时, 推/拉, 读写分离等)。

  • 状态的存取(状态通过什么方式存取和暴露给外界,直接访问,消息,API等)。

一般Web Server无状态,系统扩展性多半取决于状态分布,所以需要专门的状态设计文档详细阐述。 状态设计关注的重点是设计方案能否满足性能和扩展性需求 ,另外对C端系统还要考虑是否有高可用性方案(放松一致性,提供可用性)。

// TODO 补充例子

系统交互(可选)

新功能牵涉到系统交互时,需要提供系统交互文档。系统交互文档重点描述系统间的数据流,这份文档包括:

  • 新功能牵涉到系统内部哪些模块,模块内的交互方式(API/MESSAGE/直接访问/etc.)。

  • 和哪些外部系统发生交互,包括引入的新系统以及之前有交互的老系统,采用什么具体的交互方式。

  • 交互接口是否有限制(性能/吞吐量/稳定性/etc.)。

  • 外部系统哪些是强依赖,哪些是弱依赖。

  • 数据流图,描述完成特定功能的闭环中,数据在各个系统(模块)间如何流转,从一个模块到另外一个模块的过程中,数据的形式如何转换。

通过系统交互文档,可以从更高的层次了解整个系统的复杂度和依赖。这里的重点是**数据流转过程中是否暴露了过多细节或引入了不必要的依赖**,评审的重点是数据流图有没有可能简化,将系统间的依赖降到最低。

// TODO 补充例子

接口文档(必选)

接口文档是接口两端程序员的约定(Contract),任何需要多人合作的边界上都需要提供接口文档。

前后端接口文档

采用前后端分离的开发模式,前后端接口文档需要详细列明每一个前后端接口的格式和说明。这个文档一般由前端提供,后端实现。形如:

接口名称 listFoo

描述 查找Foo

Request:

```javascript

{

"id": 1, //主键,可以为空

"keyworkd": "abc" //关键词,可以为空,需模糊

}

```

Response:

```json

{

{

"id":1,

"name": "Clinton"

},

{

"id":2,

"name": "Obama"

}

}

```

后端接口文档

为了便于同步代码和文档,后端接口文档以javadoc为主,评审时抽取javadoc即可。javadoc也可以直接用IDE书写,更加方便。评审的以interface javadoc为主,当然对class/method也能有清楚的javadoc更好。

以下内容必须有接口文档:

  • 所有HSF服务的接口

  • 跨开发者调用的接口 (提供给别的开发者使用的接口)

  • 有复杂实现的接口 (实现超过200行)

javadoc的目标不是应付评审,而是让别人了解设计者的想法。以下是对javadoc的一些要求:

  1. 20个字以内写清楚这个接口是干嘛的。写完后站在接手者的角度读一下,描述是否清楚。如果没法在20个字内描述清楚,多半就是设计上有问题,不符合单一责任原则,需要考虑下是否要重新设计。

  2. 如有必要,用几句话描述下背景。这个一般出现在有特殊业务背景,进而需要某些特殊设计的场合。通过描述背景,接手者可以了解上下文,知道如何演进现有设计。

  3. 对入参和出参的描述。如果参数本身是专门的实体或bean,代码的类型已经很明确,不需要详细描述。但如果参数是泛类型(Object,集合类)。一定要详细说明具体的值是什么。

  4. 如果采用了特殊的选型或设计者有特殊的想法,要在文档中说明此决定所基于的前提,使用的场景,作了哪些折衷。避免接手的人踩坑。

这里有几个例子,考虑到脱敏,抹去了package name:

/**

* {@link ValveChainAuditor} consists of some valves, each valve may permit or deny an access request independently, an

* {@link Permission} is granted only when all valves permit the access.

*

* @author lotus.jzx

*/

public interface Valve {

interface AccessResult {

/**

* If the valve permit the request

*

* @return true if permitted, false else

*/

boolean isPermitted();

/**

* Valve can attach an object to the {@link ValveChainAuditor}, this attachment will be returned when

* releaseAccess is invoked. With attachment valve can store and fetch state in

* auditor that itself can be designed as stateless service.

*

* @return the state needed when releaseAccess is invoked. Return null if extra state is unnecessary.

*/

Object getAttachment();

}

/**

* If valve to current time to make decision, current time will be passed in when tryAccess and releaseAccess are

* invoked, or the now param will be null.

*

* @return true if need, false else

*/

boolean needNowTimestamp();

/**

* Return result of access request

*

* @param key resource key

* @param now current time, null if needNowTimestamp() return false

*

* @return {@link AccessResult}, can not be null

*/

AccessResult tryAccess(String key, Date now);

/**

* release access

*

* @param key resource key

* @param now current time, null if needNowTimestamp() return false

* @param accessHappened true if all valves accepted the request (commit), false else (rollback)

* @param attachment the attachment returned in the {@link AccessResult}.

*/

void releaseAccess(String key, Date now, boolean accessHappened, Object attachment);

}

/**

* 数据项操作符,能对数据项进行操作

* @author lotus.jzx

*/

public interface DataItemOperator {

/**

* 操作符知道如何解析数据项上用户需求(的字符串),将其转换为具体对象,供后续使用以及露出

*

* @param dataItem 数据项

* @param attributes 校验用到的属性(最初是validateAttributes+sessionContext.params,经过FilterExecutor处理),

* 可以在这里添加需要露出的变量

*

* @return 需求对象

*/

Object parseRequirement(QualificationDataItem dataItem, Map<String, Object> attributes);

/**

* 给定以下内容,操作符知道如何对其进行运算,得到资质项校验的结果

*

* @param dataItem 资质数据项

* @param value 资质数据项(从数据源)取到(经过Filter处理)的值

* @param attributes 校验用到的属性(最初是validateAttributes+sessionContext.params,经过FilterExecutor处理),

* 可以在这里添加需要露出的变量

* @param parsedRequirementObject 操作符自身parse后的对象

*

* @return

*/

QualificationDataItemValidateResult runOn(QualificationDataItem dataItem, Object value,

Map<String, Object> attributes,

Object parsedRequirementObject);

/**

* 是否允许需求为空(一些表单操作符不是从活动上而是从sessionContext取requirement,允许活动上的requirement不配置)

*

* @return

*/

boolean isAllowNullRequirement();

}

接口文档评审的重点主要有:

命名是否清楚,interface中的func与其所在的interface是否有 "has-a"关系

入参、出参最小化,尽量针对接口而非实现,模块间暴露最少的信息,便于模块间隔离。

站在使用者的角度,说明文档是否易懂,能否无歧义的使用API。

单元测试

单元测试也是文档的一部分,尤其是在持续集成中,单元测试除了验证正确性,自身也是一个(始终和代码同步)的说明文档。具体详见 《写有价值的单元测试》一文。

此时此刻,非你莫属

由于业务、排期、环境等原因,很多开发都写过"脏"代码,可能也都接手过"脏"代码。每个接手"脏"项目的人都会吐槽没有文档的项目就是一堆坑,每次交接带来一堆问题。可是这样的吐槽并没有实际价值,尤其是在你并没有为项目的文档化作出任何贡献时。

临渊慕鱼 不如退而结网

在我们团队中,我们尝试改变"苦恼没有文档,又不生产文档"的困局,达成以下这些目标:

我们要认识到 文档并不是负担,而是帮助开发提升效率的工具。

我们要实现 通过文档高效沟通,而不是通过代码低效沟通,提升所有人的效率

我们要作到 把文档能力作为评价程序员的重要指标。在评价体系中,把文档放到和代码相同甚至更高的位置上。

文档化,开始行动吧!

更多深度技术内容,请关注云栖社区微信公众号:yunqiinsight。

相关推荐