软件设计要素初探:一些子主题
在 “软件设计要素初探” 一文,尝试从软件设计的整体角度,综合讨论了软件设计的各种要素。本文主要探讨一些稍小的设计子主题,主要包括:错误处理、结构性难题、整体与兼容、设计取舍、设计与重构、设计与质量、设计与细节、维护与扩展、测量技术。
错误处理###
错误处理关乎系统的健壮性,且是全局性设计问题。一个整体的错误处理架构主要包括两部分:
-
参数的严格校验、规范而易于理解的错误码和错误消息、无遗漏的异常捕获和转译、警告和错误日志输出;
-
一致的错误处理机制、不同级别错误的处理策略。
第一部分并不需要复杂的技术,更多是规范、细心;第二部分则需要有整体的考虑,错误处理策略是采用忽略、捕获并处理、捕获并转译、直接向上传递,需要仔细的思考和权衡。
一般来说,关键环节出错,快速返错,绝不姑息和存侥幸心理;非关键环节出错,考虑是否可以忽略、设置默认值、兼容处理;依赖的底层服务出错,进行捕获并转译异常,并保留底层出错原因供问题排查;在高层更能合适地处理,则采用向上传递异常。无论采取何种策略,打印合理的错误日志是必要之径。 可参阅文章:“如何使错误日志更加方便排查问题”
若过程中涉及未完成的部分数据存储,必要的时候需要回滚或补偿措施、自动恢复机制。宁可为空,也不返回错误数据,误导用户。
错误处理体现了思考问题的缜密程度。一个流程里,有哪些可能出现的错误和异常场景? 对于每种错误和异常该如何有效地处理? 需要多仔细推敲,这可比智力题更有挑战。
数据健壮性####
系统健壮性主要可分为流程健壮性和数据健壮性。流程健壮性是系统的某一环节出错了如何处理,是设计错误处理的通常考虑。数据健壮性是系统健壮性的另一种情形。在订单导出中尤为重要。由于历史遗留问题,必然有一些订单有脏数据,有一些订单的数据不符合当前设计。这样,在订单导出时,要保证两个:a. 字段之间的导出相互独立,一个字段出错不影响其他字段导出; b. 不同的订单和商品之间的信息导出相互独立,一个商品的出错不影响其他商品的导出;c. 脏数据也能合理显示。 不要吝啬 try-ctch 语句,捕获异常后不要吝啬加上一句 logger.warn 或 logger.error. 实际可以编写一个通用函数来捕获各种异常。Python可参阅文章:“python使用装饰器捕获异常”, Java 可使用函数接口来实现类似功能。
防御式编程是保证系统健壮性的核心技巧。不假定系统的流程或数据是可靠的,而是假定它不可靠或调用失败或数据为空会怎样。对空值和临界值敏感,对于预计到的情形,宁可if-else代码难看一点(可重构优化),也不要抛出NPE不利于问题排查;对于操作,宁可报错,也不做出根据错误假定作出错误操作。
结构性难题###
软件开发中遇到的技术问题通常是结构性难题。现有的设计,往往创建一种相对可扩展的空间结构,便于维护者更好地实现常见需求和功能。与此同时,设计也创造了不可见的束缚。当需求难以满足时,就是发现设计束缚的最佳时机。
例子一,顺序结构简单可控,但是不能充分利用多核的潜力,一个核心工作的时候,其他核心干等着,这是束缚; 并发结构可同时释放多个核心的能力,束缚是难以控制多个核心同时工作的时序;对等应用结构能够避免单点故障,可是当多个对等应用同时操作一个不可重复的互斥资源时,则必须进行并发同步控制。
例子二,针对一对一的实体关联的设计,简单易用易理解;可是当实体关联扩展到一对多或多对多的时候,就显得不灵活了。 在设计之初,及时与产品沟通和仔细斟酌,为一对多或多对多的场景要预留余地。比如周期购发货。初期设计是一个订单一个周期购商品,每个订单有多个期次的发货(期次对应订单维度);现在需要改造成一个订单多个周期购商品,每个商品都有多个期次的发货(期次对应商品维度)。如果事先已经考虑到一个订单对应多个周期购商品的发货,并且期次对应商品,那么此次就比较自然地兼容多商品多期次发货了。
例子三,一个字段的同步通常来源于一个表,可是有的字段来源于多个表,需要根据不同业务场景进行覆写。这就需要设计一个更具通用和可扩展的同步覆写机制来处理。或者从另一个角度思考,若不希望把同步做得太复杂,则需要应用程序提供一个可扩展的数据聚合机制,从不同的数据源中获取和聚合数据。订单导出实际上是一个数据聚合应用,需要从多个分散的业务表中获取数据,而这些业务表往往不是为了聚合而设计的。因此,导出的前置工作是设计通用可扩展的数据同步机制将所需数据从业务表同步到数据存储中心,然后设计通用可扩展的数据聚合机制获取数据,格式化并输出。
例子四,订单导出应用原来只支持固定字段的商品维度的CSV导出,现在需要支持指定字段集合的订单(需要去重)和商品两个维度的CSV/Excel导出,势必要重构原来的代码结构,支持更灵活的导出能力。可以使用策略模式来分离和组合不同的维度和格式导出。
常常为了简便而硬编码。硬编码固然能解一时之需,却容易诱发设计束缚。
几乎所有设计结构都具备某种优点,同时也具备某种束缚力。要让应用从束缚力中解脱出来,则必须借助多种结构的组合,取长补短,才能实现最终的大设计。
整体与兼容####
整体与兼容是结构性难题的常见情形。软件功能之间往往不是孤立的。比如订单管理中,商家根据各种条件筛选后导出待发货的订单,然后使用批量发货的功能将订单发货,接着在订单详情页查看订单是否如预期发货,或者重新再导出一次看看是否已经发货。发货后还可能修改物流。这样,订单搜索/导出/发货/详情,实际上是一个组合操作场景。当要支持新业务比如周期购时,这些都是必须整体化考虑的。单从某个功能模块的实现来说并不困难,困难的是将四个组件的功能服务串联成一个紧密联系的逻辑严谨的整体。
兼容现有系统则是开发者最头疼的任务类型,尤其是系统已经发展到比较大的时候。事实上,每次代码变更都是一次现有系统兼容,只是有些兼容看起来令人深为苦恼甚至“不堪回首”。通常是由于原有设计对关注点没有解耦清晰,没有考虑到新需求的情况,没有预留足够的余地,兼容起来必须努力寻找空间,更像是一种英勇的突围行动。兼容系统的比较好的一种方式是,先分析清楚原有系统的业务逻辑和设计思路,然后尝试从一种更通用的角度去容纳原有的设计,有时会需要改动不少,需要可靠的回归测试来护航。因此,测试用例的充分覆盖程度,往往决定了系统能否大胆进行重构,实现更佳的更安全的设计优化,而不是简单增加一坨条件分支语句。
设计取舍###
设计需要大量取舍,需要优秀的判断力。理解是一回事,设计和取舍是另一回事。能够是一回事,应该是另一回事。设计应该尽可能简洁实用。哪些需要进行权衡呢?个人遇到的情形主要有三点可参考:
(1) 为了更优的体验而把事情弄复杂。现代产品设计强调用户体验,有时用力过猛,反倒容易忽视简单性带来的隐形良好体验。人容易犯错。复杂而完美的事情,尽管往往蕴含了很多“贴心的思考”,可这些“贴心的思考”在真实场景未必成立或只是偶尔出现,或者受到更多限制而无法施展,甚至起到反制作用,令人困扰和沮丧。相反,简单的事物,尽管不完美,却预留了很多空间,人们总能想到“奇妙方法”应对现实的阻挠因素并广为传播,而这些“奇妙方法”反而成了事实上的解决方案。因此,产品设计应尽量简单而预留余地,流程尽量直线式单一化且短小,消减分支和重试,减少诱发出问题的潜在因素。而对于系统设计而言,要尽量做到关注点正交分离解耦,更好地支持产品的灵活性;而即使能够做到产品的灵活性,也要仔细斟酌是否必须做到如此。
举个例子,使用第三方配送,允许商家一个订单多个包裹配送、每个包裹允许第三方配送失败后切换快递配送、允许商家多次拆分包裹和重组包裹。这些要求诚然是比较合理的,也足够灵活,却忽视了兼容现有系统带来的连锁问题:快递配送后有修改物流,结合允许多次拆分和重组包裹,意味着商家可反复修改物流,会增加发货和显示复杂度;商家拆分和重组包裹后,基于之前商品组合计算的支付金额可能不正确;前端展示上需要做不少兼容工作,容易出BUG。因此,灵活性固然很美好,实际上一则商家未必用得上那么多功能,二则某个功能失败后会衍生出更多的组合场景难以覆盖完全,三则需要复杂度较高的设计和大量的研发测试成本,四则出了问题难以排查和修复,耗费商家、客服和技术研发的时间。实际上,一个订单有多个包裹是直观无疑的认识,而大多数场景只需要一个包裹配送;“允许第三方配送失败后切换快递配送”是产品针对“配送员不接单如何处理”的依托现有功能的解决方案,并不是原始需求,尽管如此还是合理的;“允许商家多次拆分包裹和重组包裹” 是产品针对问题“多包裹配送及包裹配送失败如何处理”想出来的解决方案,也不是原始需求。 和产品同学对需求时,要仔细识别哪些是原始需求和问题,哪些是用于解决需求和问题的带有方案性质的伪需求,还要仔细识别出兼容现有系统设计实现所引发的潜在问题。需求和问题是产品出,方案是研发出而经过产品认可。上线后,研发和产品一起关注效果和反馈,并改进优化。
(2) 过度设计可扩展性。预先思考过多,想做的更灵活,结果实际发展方向并非所料,增加了系统设计复杂度、研发成本却没有收到实效。做订单导出时,为了根据不同业务更灵活地输出报表字段,将报表字段按照业务划分,分别定义主字段集和不同业务需要的子字段集,而生成报表的最终字段集需要主字段集合与若干子字段集组合而成,略显复杂,实际没用到。而按照指定字段集输出,却是正确的思考方向。凡基础而必要的总是正确的方向。
真实需求是分别按照订单维度和商品维度来进行导出,不同业务可指定不同维度以及所需的报表字段集。新版设计是将订单维度的字段与商品维度的导出字段分别定义,将字段格式化定义与输出字段集指定解耦,将订单维度导出与商品维度导出做成两种不同的策略,这样,无论按照订单维度还是商品维度,无论指定什么字段集合,都可以按照指定需求输出报表。这是按照真实需求做出的有效设计。有效的设计应当是根据需求对原有设计结构进行重构优化,得到更灵活的设计,而不是开始就想很多,做得复杂。
(3) 设计不可偷懒。尽管设计要提倡节制,可是也不能偷懒,在必要的地方设计不足。一旦设计不足,很快就会受到“惩罚”。一个产品初始诞生时,常常从最简单的情形考虑,比如一对一的情形。而设计却要考虑,将来是否会发展到一对多的情形,并留下设计余地。如果心存侥幸觉得不太会有这种可能或者掩耳盗铃,那么现实不久后就会来一次生动的教训课。
“如无必要,勿增实体”,或可作为设计基本原则之一。设想去掉它,是不是会运转得非常艰难或造成资损?
设计与重构###
好设计不是一蹴而就的。好设计是可演化的。需求与问题是好设计的催化剂。持续地对设计进行小步重构优化,使设计更有活力和适应力。
对设计做小步重构,首要是抽离出关注点。推敲关注点的行为和目标,抽象成适当的接口并实现成小组件。抽离出关注点后,需要将关注点有序地融入到整体流程中。可以想象,应用由许多小组件组成,每个小组件实现某个关注点的接口;而应用通过若干主流和支流串联起所有的小组件而实现其功能和服务。简而言之,“关注点-接口-组件-流-应用”。设计软件有时像设计游乐园,先构思各种微小景致(微组件),然后设计四通发达曲径通幽的小路(流)串联起所有的微小景致。
比如,原来只支持商品维度的CSV导出,现在需要支持订单维度和商品维度的CSV/Excel导出。首先抽离关注点。订单/商品维度是数据维度,而CSV/Excel是输出维度。数据维度关注将报表原始数据列表转化为对应报表字段的报表行列表;输出维度关注生成报表文件以及将报表行列表添加到报表文件中。根据关注点定义相应的维度策略接口和文件输出策略接口,然后分别实现订单/商品维度报表行生成组件和CSV/Excel文件输出组件,最后在导出流程中根据参数选择相应的策略来调用相应组件的接口,替换原来的实现。分离关注点,并定义关注点的合适接口,是设计与重构的基本功。
设计重构主要包含两个层面: 代码层面,使用设计模式进行对象交互结构的重构,使功能和服务实现更具柔性,容易修改和扩展;模型层面,使用深化和显化领域概念,优化存储设计,使得领域模型更加清晰。
设计与质量###
好设计不仅仅是满足软件功能,还必须满足预期软件质量指标。软件质量指标可参阅文章:“Web服务端软件的服务品质概要”。在进行软件设计时,务必明确软件预期的质量期望,并在功能实现之后进行衡量和评估。
设计与细节###
问题是一系列关注点的聚合体,细节则是具体的小的关注点,设计则是针对系列关注点的一致性处理机制。“关注点分离”,是设计的基本原则。
为什么会产生“细节”呢?当设计能够覆盖到问题的所有关注点时,细节就被囊括到设计所考虑的范围内;而一旦问题的关注点有所变化发展时,设计就无法覆盖到所有关注点,就产生了所谓的“细节”以及特殊处理,特殊处理带来的就是一堆堆的条件分支语句。为什么设计无法覆盖到所有的关注点呢? 因为设计常常是一些通用套路,而这些套路往往是比较大的聚合体,很实用但未必灵活。 理想情况下, 设计也应该包含一系列正交的小的子设计,通过子设计的组合来构建更大的设计,覆盖更多的关注点,这样,细节及特殊处理就会更少, 代码也更具可维护性。
因此,从细节反推设计的不足,可促进设计的优化,覆盖更多的关注点。
维护与扩展###
可维护、可扩展是软件能够持续健康发展的重要质量属性。可维护的要点是,持续对代码进行小步重构精练;可扩展的要点就是:识别、分离和组合关注点。在可扩展的基础上,进一步可实现可配置化。“Java8Map示例:一个略复杂的数据映射聚合例子及代码重构”展示了一个例子,通过分离和配置关注点(通过itemIdConf配置来关联各个表的对应数据),消除了一大段的if-elseif-elseif 语句的,开心!使用适当的配置,辅以枚举、Map,可有效改善或消除代码里大段的if-else, switch语句。
测量技术###
从结构角度看软件,软件就是一种虚拟建筑。牢固的实体建筑需要仔细的测量和组装,持续稳定运行的虚拟建筑也需要仔细的测量。测量指标是设计可行性和可靠性的关键衡量。缺失测量指标的设计是不可信的。性能(响应速度与吞吐量)、占用资源(CPU与内存)、稳定性(成功率)、可用性(故障时长及比例)、峰值与平均负载能力(压力表现)是常见的软件测量指标。
最基本的方式是,循环调用服务接口若干次,统计平均/最大/最小响应速度;多线程并发调用服务接口,设置并发数及占用资源上限,测量其吞吐量;持续指定时间多线程并发调用服务接口,统计成功次数及成功率。压测则需要找QA同学帮忙部署正规的压测环境,然后使用专门的工具和方法进行测量。