传统和敏捷开发冲击下:有价值的技术文档该如何写?
背景
传统瀑布开发模式下非常重视文档,每个开发环节的衔接都通过文档实现。这种重视在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的一些要求:
20个字以内写清楚这个接口是干嘛的。写完后站在接手者的角度读一下,描述是否清楚。如果没法在20个字内描述清楚,多半就是设计上有问题,不符合单一责任原则,需要考虑下是否要重新设计。
如有必要,用几句话描述下背景。这个一般出现在有特殊业务背景,进而需要某些特殊设计的场合。通过描述背景,接手者可以了解上下文,知道如何演进现有设计。
对入参和出参的描述。如果参数本身是专门的实体或bean,代码的类型已经很明确,不需要详细描述。但如果参数是泛类型(Object,集合类)。一定要详细说明具体的值是什么。
如果采用了特殊的选型或设计者有特殊的想法,要在文档中说明此决定所基于的前提,使用的场景,作了哪些折衷。避免接手的人踩坑。
这里有几个例子,考虑到脱敏,抹去了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。