首发于taowen
要写系统设计文档了,但是不知道写什么,该怎么办?

要写系统设计文档了,但是不知道写什么,该怎么办?

Ralph Jonhson 有一句绝对正确,但是毫无用处的名言:

architecture is “the design decisions that need to be made early in a project”, but Ralph complained about this too, saying that it was more like the decisions you wish you could get right early in a project.
His conclusion was that“Architecture is about the important stuff. Whatever that is”.
引用自:martinfowler.com/archit

经常遇到的问题是,要开始一个新项目了,得像模像样的写系统设计文档了,但是写啥呢?

如果没有提前做好设计,以下两个症状是最常见的:

  • 所有人似乎都在修改同一个文件,例如 order.php。所有需求似乎都堆积在少数几个人的身上。所有团队似乎都等着同一个模块的上线,但是一天只有24小时,没有足够的时间去在生产环境验证新部署的可靠性。
  • 很多地方都在写非常类似的代码,比如每个 RPC 调用之后,都要手工再写一行日志。所有的列表页面,都要重复实现一次分页,排序等非常类似的功能。当出现性能问题,稳定性问题的时候,似乎需要修改一亿个地方才能改完。

需要提前设计的东西,就是为了避免这两个项目到晚期会出现的症状。这两个症状的共同特点就是修复成本特别高,高到了“我们不如重写一遍”的地步。如何避免每次都走入同样的陷阱?

  • 良好的分工:啥叫良好,啥叫分工? 谁来天天盯着新代码被写到正确的git仓库,正确的目录,正确的文件里呢? 谁有这么无穷的精力来盯着每个人来写代码? 架构师么? 架构师不是第一天写好白板,剩下的实施工作都不管的甩手掌柜么? 需求变了,架构师管不管?
  • 复用模块:啥叫复用,啥叫模块? 谁来写这些可复用的东西? 可复用的东西只满足我 80% 的需求怎么办? 我不知道有这个可复用的模块啊,我重复实现一遍又不是故意的。谁来盯着重复代码不会被反复地写出来呢? 我们团队分布在北京,杭州,成都,大家都不知道对方在干啥,咋复用?

有没有什么办法不去指望有一个万能的“独裁者”去天天盯着架构不被腐化,而是让架构朝着正确的方向自然而然的生长出来呢? 或者至少让这个“独裁者”能够不那么痛苦。一生二,二生三,三生万物。要做什么神奇的初始设计才能比较省力地达到理想的彼岸?

如何做好分工

我在 taowen/modularization-examples 里已经写了比较多的具体的例子了。需要看具体例子的同学,去读读里面的具体业务场景的例子。结论就是,最初始的设计就是要设计好分多少个模块,以及这些模块的依赖关系。如果你写的是 javascript,这里就是说需要分多少个 npm package,以及每个 npm package 中 package.json 的 dependencies 填什么。

要达成的目的是写限时折扣的人,不用去管团购业务是怎么做的。每块功能都可以最大程度地独立开发。传统的做法是需要有一个人一直盯着所有人写代码的时候把代码写到了正确的地方,这个做法对于 code review 的人压力很大,而且也容易造成返工。避免人来盯着的办法就是让“编译器”来盯着。也就是代码写错了地方,编译就无法通过。怎么做到这一点呢?

其秘诀就是让模块之间不产生直接的依赖关系。假设我们有 xszk-promotion, group-purchase 分别放了限时折扣的表,以及团购的表。这两个项目之间是没有直接的 dependency 关系的。这样如果在不正确的地方写了代码,这代码编译就无法通过。那我如果有地方需要集成两块业务怎么办? 解决办法就是“依赖倒置”,我们在最底层设置一个 motherboard 模块,它被 xszk-promotion 和 group-purchase 等业务模块所依赖。在 motherboard 中提供 SPI,也就是扩展点。每个业务模块往 motherboard 上注册。这个注册可以是编译期的扩展点,也可以是运行时的扩展点。

在这样的依赖关系下,我们只需要做一个初始的模块切分,以及依赖关系。然后日常的工作就是在新需求来的时候,决定是新建一个模块,还是写到已有的模块里。这个决策非常低频,日常的文件和目录级别的调整都是不用去关心的实现细节。另外一个日常工作就是盯着 motherboard 的改动,要严格控制被下沉的公共代码,确保98%的代码写在具体的独立的业务模块里,只有必要的业务集成变成 SPI 下沉到 motherboard 模块内。

值得注意的是,即便拆分微服务未必就控制了依赖关系。只要两个微服务之间可以直接 RPC 通信,那么这样的依赖关系并不会比函数直接调用更弱。只是从编译期的链接,改成了动态链接,从依赖关系的本质上没有变化。

如何避免重复

重复最难的是如何发现重复,其实是如何让其他人心甘情愿地复用你的东西。

一开始设计好所有可复用的东西并不是那么靠谱。虽然做过一两个项目之后,你会得出一些经验。例如一定要有一个日志库,一定要把 RPC 都封装到一个公共的地方。但是这些经验一般都局限于“非功能性需求”领域。这也是为啥架构师最后都沦为运维的命运,因为他们一般也就负责解决性能和稳定性问题。这些问题的特点就是通用,不管啥业务,都有“非功能性需求”。

对于具体业务里可能出现的重复,例如每个列表页都长得差不多,为啥要重复实现。这样的问题是没法提前预见的。依靠所有一线干活的人去判断是不是要抽个啥公共模块也是不靠谱的。因为具体到每一个人,他做的每一个业务对他来说都是第一次做。而且大家都是社恐,非常抗拒与同僚沟通,去让他们改已经写好的代码。

这个问题的正确的解决办法是“需求评审委员会”。所有的业务需求,都要由那么固定的几个人来准入。委员会的构成是资深的研发和产品经理。他们负责是

  • 需求合理性以及优先级
  • UI一致性
  • 发现类似的业务

因为所有的需求都要经过有限的几个人来准入,所以他们可以很容易发现业务中的重复。如果应该重复而没有重复的地方,可以用“UI一致性”的大帽子扣上去,让产品经理去修改。

在发现了重复之后,总是需要某一部分人来做轮子给其他人复用的。要让别人愿意用你的轮子首先是政治问题(利益分配)。在解决了政治问题之后,就是你这个轮子是不是真的好用了。评价轮子易用性的唯一标准就是看复用这个轮子所需要传递的参数个数和复杂度。如果参数不明确,参数很诡异,参数几十个,谁都不愿意你用的轮子的。代码生成,运行时反射,动态链接库,谁也不一定比谁高级。都是要回答参数个数的问题,为什么要搞反射,要搞基于元数据的代码生成,其实都是在减少复用的人需要传递的参数个数。

初始设计

项目一开始应该做的:

  • 预估出几个初始的模块,以及倒置的依赖关系
  • 非功能性需求等可预见的复用代码

日常需要做的

  • 严守 motherboard 模块的修改,避免倒置的依赖关系被破坏
  • 合理判断新需求是否需要新增模块
  • 需求评审委员会
  • 不断减少参数个数

=============

2022/01/20 更新

如何做好分工:根据一年来的实践来看是ok的。通过编译器和linter来确保依赖关系。用UI集成等更稳定的接口来做集成。大部分情况下实现需求,只要阅读和修改一个模块的代码。达到了分而治之的目的。

如何避免重复:这个方面就不是那么顺利了。遇到的主要是这些麻烦

  • 避免重复是建立在需求可以归纳的前提的。需求来自多个产品经理,这些人类个体之间本身就有想法上的差异。软件编写是一个社会活动。可以归纳的需求要建立在不断给产品经理施加压力,博弈讨价还价的基础上。什么时候去做这样的讨价还价,值不值得做,这个就很头大。小明哥说,不要你觉得,我要我觉得。
  • 逐字翻译需求,直接拷贝figma里的css是最省事的。只要效果看起来ok,那就交差了事。只要有不动脑筋直接翻译需求文档的写法,哪怕是一个后门,也会变成开发者的默认行为。毕竟人都是懒得思考的。
  • 识别可抽象的概念,和利用已有的抽象概念组合出来完成需求。这两个任务中,复用已有概念更容易,而识别新的可抽象概念更难。通过技术和管理手段强迫开发者不去走后门,尽可能地以组合更大粒度的组件和函数方式来表达需求,可以减少一些code review的负担。但是远远不够。
  • 识别可归纳的需求,说到底仍然是依靠人类的L5级别的高级智能。没有任何客观的可计算指标来衡量这个工作是不是做了,是不是尽力了。靠一两个人的自觉来保障工作的质量,还是太难持久了。

Understanding the nature of abstraction and developing algorithms capable of autonomous abstraction (i.e. general intelligence) François Chollet

真要更进一步解决这个问题,还是要从 Program Synthesis 社区汲取一些灵感。

编辑于 2022-01-20 16:29