写点什么

简洁至上——探索产品与技术的优雅原则

  • 2024-10-25
    浙江
  • 本文字数:8155 字

    阅读完需:约 27 分钟

作者:京东物流 冯志文

背景

上周开发了一个需求,发现一个历史功能,从产品和技术代码的角度看,将简单的事情变得复杂。这一经历再次深化了我对一个核心理念的认识:简化复杂性是产品设计和软件开发中永恒的挑战。我们必须不断努力,将复杂的逻辑转化为直观、易用的用户功能,并将冗长、难以维护的代码结构变为简洁、效率高的形式。


在《人月神话》中作者提到,软件开发的复杂度可以划分为本质复杂度和偶然复杂度。本质复杂度它是一个客观的东西,跟你用的工具、经验或解决渠道都没有任何关系。而偶然复杂度是因为我们在处理任务的时候选错了方向,或者使用了错误的方法。


作为工程师,我们的追求不仅仅局限于代码的编写。更深层次的,我们探索的是如何对抗软件本身产生的复杂度,如何将繁杂的需求转化为简洁、优雅的解决方案。


不单单是程序员,任何化繁为简的能力才是一个人功力深厚的体现,没有之一。越简单,越接近本质。这个“简单”指的是整体的简单,而不是通过局部的复杂让另一个局部简单。


附:需求案例复杂点 1)业务产品设计方面:Promise 业务类型(比如生鲜时效、航空时效、普通中小件时效等)与单据类型、作业类型之间存在一系列复杂的转换关系。但这几个类型本质是一样的,没必要转换,术语统一,对业务使用来说也简单。 2)技术代码方面(组内同学 CodeReview 发现的):代码方法“副作用”(side effect),即方法除了返回值之外,还通过修改某些外部状态或对象来传递信息。比如filterBusinessType方法的主要作用是返回一个int类型的值,但它也修改了入参的response对象作为一个副作用,外部链路会使用 reponse 对象属性值。并且代码内部调用链路复杂,对于新人来说成本较高。为了确保清晰理解这些关系,并有效地进行代码维护,特意对这些关系及代码链路进行了详细的梳理。

一、为什么要简单?

为什么我们要追求简单性?不应该是复杂,才能显得技术牛吗?


对应简单,各有个的说法,个人理解如下:所见即所得


1.比如你架构图别人一看就明白这是干什么的,系统之间如何交互,模板之间如何交互。


2.比如定义 API 别人一看文档就明白功能职责,请求入参,出参含义,有基本的计算机知识人都能看明白,这叫所见即所得。


3.比如新人在 1 周左右就能快速更改代码,而不是要记住代码各种注意事项,尽可能减少用户的学习曲线和理解成本


接下来从本次需求的复杂案例着手,引入自己的一些思考

二、案例详细

1)产品设计

1.1)现状

Promise 业务类型 > 单据类型 > 作业类型 各种转换关系


1.业务类型 转换单据类型(1 VS 1)


2.单据类型 转换为 作业类型:根据单据类型找到 仓、干支线、本地 作业类型


3.仓 &干支线 &本地 作业类型:

1.2)思考点

1.一对一映射的简化


“业务类型 &单据类型 其实是 1V1 映射”,这表明系统当初设计时考虑了业务类型和单据类型之间的直接关联。如果这种一对一的关系确实存在,那么单据类型可能是一个冗余的概念,因为每个业务类型已经隐含了单据类型的信息。简化模型,去除冗余的单据类型,可以减少系统的复杂性,并可能简化数据库设计和代码实现。


1.概念统一


“作业类型本质还是业务类型”,这意味着在不同的上下文中可能使用了不同的术语来描述相同的概念。在编码和产品设计中,使用统一的术语可以减少混淆,提高团队成员之间的沟通效率,并使得新成员更容易理解系统。


1.维度划分


将业务类型进一步细分为“仓、干支线、本地维度”,这表明系统有不同的操作维度或者分类标准。这种维度划分有助于在不同层面上组织和处理业务逻辑。

2)代码问题

2.1)内部链路太长

业务类型逻辑入口之一:getBusinessTypeInfoForAll > getBusinessTypeInfo > getOrderCategoryNew > obtainOrderCategoryByCode > filterBusinessType



在我们的代码库中,上面关键的五个方法被多个调用入口所使用,这种情况使得管理这些入口变得极为棘手。由于调用点的广泛分布,理解代码的影响范围变得复杂,难以一目了然地掌握。此外,这种做法也显露出我们的代码缺乏清晰的分层架构。这一原则的缺失,不仅使得现有代码难以维护,也给未来的功能扩展和迭代带来了不必要的复杂性和风险。

2.2)副作用

在 Java 编程语言中,术语“副作用”(side effects) 指的是一个函数或表达式在计算结果以外对程序状态(如修改全局变量、改变输入参数的值、进行 I/O 操作等)产生的影响。


如下filterBusinessType方法的主要作用是返回一个业务类型int类型的值,但它也修改了传入的response对象的 A 值作为一个副作用。在外面链路使用了 A 属性值做逻辑判断


副作用问题:在filterBusinessType方法中如果是在 response 之前 return 了数据,从方法角度看不出问题,但整个链路会出现问题。


错误写法


public int filterBusinessType( Request request,Response response) {    if(...){      return ...    }    boolean flag = isXXX(request, response);}
复制代码


✅正确写法


public int filterBusinessType( Request request,Response response) {    /**     * 切记:return必须在下面这行代码(isXXX方法)后面,因为外面会使用response.A()来判断逻辑     * 你可以理解本filterBusinessType方法会返回业务类型,同时如果isXXX方法会修改response.setA()属性    */    boolean flag = isXXX(request, response);    if(...){      return ...    }}
复制代码

2.3)思考点

思考点 1:代码链路太长


内部代码链路太长是一个常见的维护问题,通常源于缺乏良好的模块化和抽象设计。以下是一些思考点:


1.避免过度抽象:虽然抽象可以帮助简化代码,但过度抽象反而可能会增加复杂性。确保你的抽象层次是合理的,并且每个抽象都有明确的目的和价值。


2.分层架构:将代码按照逻辑和职责分成不同的层次,每一层只对其下一层有依赖性,而不需要知道更深层次的实现细节。这样可以减少代码之间的耦合度,简化内部链路。


3.合并重复代码:如果发现多个方法都在执行类似的操作,尝试合并这些重复的代码段,创建一个通用的方法来处理它们。这样可以减少代码的总量,提高代码的可读性和可维护性。


4.团队共识和标准:与团队成员讨论并达成共识,制定一些编码标准和最佳实践,以便在日常开发中就能够遵循简洁性原则,避免产生新的长链路。


思考点 2:副作用


1)注意事项


1.将副作用明确化:如果一个方法有副作用,应该在方法的名称、文档或使用方式中明确指出。这样可以帮助其他开发者更好地理解该方法的行为。


2.避免使用静态变量:静态变量可以被多个线程或方法共享,容易引起副作用问题。除非有明确的理由,否则应尽量避免使用静态变量。


3.完全消除副作用可能是不现实的,尤其是在需要与外部交互的应用程序中。关键是要理解副作用的存在,并采取合适的策略来管理和控制它们。


2)Java 的设计约定鼓励我们遵循以下原则:


1.单一责任原则:每个方法或类都应该有单一的责任或功能。


2.最小惊奇原则:方法的行为应该符合预期,避免出现意外的副作用。


在这个例子中,filterBusinessType方法既返回一个整数结果,又更新了response对象,这违反了单一责任原则和最小惊奇原则。因为调用者可能会预期这个方法只会过滤业务类型,而不清楚它还会修改response对象。


3)如何规避这种现象


为了避免这种情况,可以采用以下几种策略:


1.分离关注点: 可以将获取业务类型和响应设置分离成两个不同的方法。这样,调用者就可以清晰地看到每个方法的职责。


public int filterBusinessType(String logPrefix, Request request) {    // 过滤逻辑...    int businessType=...;    return businessType;}public void setResponseData(int filterResult, Response response) {    // 根据过滤结果设置响应数据...    response.setFilteredData(...);}
复制代码


1.返回复合对象(上下文 context) : 如果业务类型结果和响应数据是紧密相关的,可以考虑创建一个包含这两个信息的复合对象,并将其作为方法的返回值。


public FilterResultAndResponse filterBusinessType(Request request) {    // 过滤逻辑...    int result=...;    Response response=new Response();    response.setFilteredData(...);    return new FilterResultAndResponse(result, response);}class FilterResultAndResponse {    private int filterResult;    private Response response;        public FilterResultAndResponse(int filterResult, Response response) {        this.filterResult = filterResult;        this.response = response;    }        // Getters and setters for filterResult and response}
复制代码


总的来说,副作用有时候是不可避免的,但我们可以通过以上方法来规避和管理它们,写出更可靠、更易于维护的代码。

三、案例解决方案

回到本文开头说的产品和技术复杂性案例,考虑到产品和技术影响 promise 时效内核最底层逻辑范围。我是采取保守的策略:维持现状不变。这是因为对核心逻辑进行改动会带来广泛的影响和较高的风险,可能会牵扯到整个系统的稳定性和一致性。 然而,这并不意味着我们放任复杂性存在。相反,我做了以下两点改进措施:


1.增加注释和注意事项:在代码中添加详细的注释和注意事项,帮助团队其他开发者理解这部分代码的工作原理和潜在风险。这样可以降低新成员上手的难度,并且减少在维护或扩展时出现错误的可能性。


2.团队分享和知识传递:我将分享这个案例和相应的经验教训,向团队成员解释为什么在这个特定情况下我们选择了保持现状不变,以及如何在未来的需求、架构设计和代码编写中更好地管理复杂性和副作用。通过这种方式,我们可以共同学习和成长,避免在类似情况下重蹈覆辙。


这两种方法虽然不能立即简化复杂性,但它们可以提高代码的可读性和可维护性,减少长期的技术债务。同时,团队分享也能促进知识共享和团队协作,让大家知道什么是正确的做法,帮助我们在面对类似挑战时做出更明智的决策。

四、如何做到简单--思考点

KISS 原则是指在设计当中应当注重简约的原则。总结工程专业人员在设计过程中的经验,大多数系统的设计应保持简洁和单纯,而不掺入非必要的复杂性,这样的系统运作成效会取得最优;因此简单性应该是设计中的关键目标,尽量避免不必要的复杂性。

1)产品设计

作为技术从业者,我们常常需要从用户(无论是面向 C 端消费者还是面向企业内部的产品)的视角出发,来探讨产品设计中的简洁性原则。以下是我的一些观点,说的并不一定对,但愿能为您提供一些启发。


以用户为中心


•深入理解用户需求:深刻洞察用户的核心需求和痛点,用客观数据驱动决策,而不是单凭个人直觉。


•简化用户旅程:力求打造一个直观的用户旅程,尽量减轻用户的决策压力和学习负担。一个优秀的界面应该是清晰、易懂的,使用户能够毫不费力地完成所需任务。


减法设计


•在设计每一个功能环节时,我们需要反复自问:这个功能是否真正必要?如果它的缺失不会损害用户体验,那它很可能是多余的。


直观交互


•设计时应确保控件和操作逻辑能够自然地映射其功能,使用户能够直觉地理解产品的使用方法。


持续迭代


•不断地收集用户画像和分析用户反馈,将其作为产品设计迭代的重要依据,以此不断地精进和完善产品。


案例: 1、Alfred:这个我觉得根本无需介绍,神器,使用 macOS 的同学应该都知道。一句话来说就是,Alfred 是 macOS 上神级的效率应用,能够在实际操作中大幅提升工作效率。


2、1Password:使用 1Password 生成并管理密码后,就再也不用费心思去想密码的事情了,只需要记住 1Password 主密码就万事大吉。 原先你需要 4 步骤:比如 1)打开浏览器 2)输入网站(或者打开收藏夹) 3)打开网站输入用户名密码 4)点击登录 使用 1Password 只需要 一步到位,自动打开浏览器登录相关页面

2)架构设计

不要跟风选择所谓高大上的技术,适合的才是最重要的。够用+1 即可。什么意思呢,就是系统目前够用再往前走一步就可以了。至于这一步是什么?可能需要你在实践过程中,慢慢找到你认为比较合适。很多时候,我们系统架构引入一个新框架或者新技术,它本身带来的复杂性其实比你这个问题还要复杂。


简化架构也是提高技术稳定性的重要步骤。一个复杂的架构可能会导致系统的各个部分难以协同工作,从而影响系统的稳定性。因此,我们应该尽量采用简单的架构设计,使得各个部分可以更容易地协同工作。


案例:业务架构简单化-小件日历天数 30 天扩充到 90 天 复杂解法: 1)目前是根据业务的时效配置预计算好 30 天日历,依赖 N 个配置(仓-干支线-本地缓存等),在现有基础上,预计算 90 天日历。 2)缺点:牵扯数据预计算 N 个地方改造,并且增加了数据量的存储。改造排期长并且数据存储成本高 简单解法: 1)还是保持现有 30 天日历的算法。第 31 天以后的日历按照最后一天日历进行复制。如果日历计算命中集约地址(比如 3 天 1 送),过滤对应日历即可。 2)优点:代码改造工作量小,数据存储成本保持不变


案例 2:技术架构简单化-避免过度使用技术栈 以缓存(本地、分布式缓存)为例,它的引入确实能显著提高系统的响应速度和效率。然而,这同时也带来了新的挑战,如数据一致性问题和缓存策略的选择。



1)数据一致性问题可能导致用户获取到旧的或不正确的信息。 2)而缓存策略的选择则需要在系统资源利用和数据时效性之间找到平衡点。 为了解决这些问题,我们可能需要引入更复杂的缓存失效策略, 1)如基于时间的失效、事件驱动的失效机制, 这些策略的引入和管理本身就增加了系统的复杂性,因此在设计缓存解决方案时,我们需要仔细权衡其带来的效率提升和潜在的复杂性增加,以找到最适合当前系统需求的平衡点。

3)最小 API

对外 API 的设计决定了系统的可扩展性和兼容性。一个清晰、简洁且易于理解的 API 设计可以减少各种交互问题。编写一个明确的、最小的 API 是管理软件系统简单性的必要条件,我们向 API 消费者提供的方法和参数越少,这些 API 就越容易理解,就有更多的时间去完善这些方法,


将复杂的 API 设计简化为更易用、更直观的形式,以便用户能够更容易地理解和使用。


1.使用标准格式:遵循一致的命名约定、数据格式和错误处理机制。这将使 API 更加一致和易于使用。


2.API 功能单一职责原则:在 API 设计中,单一职责原则也非常重要。如果一个 API 具有多个职责,那么它将变得复杂且难以维护。因此,建议将 API 拆分为多个简单的 API,每个 API 只负责一个特定的职责。明确其功能和用途。这将有助于确保 API 具有清晰的职责划分,避免不必要的复杂性。


3.简化参数:尽量避免使用过多的参数,而是使用简单、易于理解的参数。如果必须使用多个参数,请确保它们之间有明确的关系。


4.提供简洁的文档:编写简洁明了的 API 文档,解释每个端点的功能、请求方法、参数和响应格式。确保文档易于阅读和理解,以便用户能够快速上手。


5.提供示例代码:为 API 提供示例代码,展示如何使用不同的请求方法和参数。这将帮助用户更快地掌握 API 的使用技巧。


在软件工程上,少即是多,一个很小、很简单的 API 通常也是一个对问题深刻理解的标志。


案例 1:Promise 适配 M 系统 API 背景:M 系统的时效是自己计算闭环的,promise 是对外统一收口时效,在 M 系统时效业务线上,promise 只是透传,不做任何时效逻辑 复杂解法: 1)每次 M 系统相关时效需求,下游 M 系统的 API 需要变更,promise 也需要参与改造,改造点 2 个,第一个是从订单中间件 xml 获取 M 系统需要的参数。第二点把获取的参数调用 M 系统 API 透传 2)缺点:需求改造系统多,但都是转发适配,无核心逻辑,工作量耗时长,项目排期协调,沟通成本大 简单解法: 1)跟 M 系统沟通,M 系统时效要的信息从 X 节点获取,promise 把该节点的 json 信息全部透传给 M 系统,这样后期需求 promise 不参与改造, 2)优点:从 promise 角度来说新需求不用改造,从 M 系统角度来说时效自己闭环。这是双赢的局面,从全局来说,减少了链路的开发/联调/沟通/协调成本,整个项目交互效率提升了.


案例 2:❌错误码设计---未传播错误码 案例:外单无妥投时间,目前链路是 A---->B---->C 系统。但错误码是各自封装,没有把根本原因传播出去,而是各自加工,导致最终看到的原因跟真实的原因千差万别。 导致整个链路牵扯 业务方--->A 研发---->B 研发---->C 研发---->C 业务同事 总共 5 个环节,如下图:



案例 2: ✅错误码信息--传播错误码信息 1、如果 API 在翻译错误时,需要把底层根本原因返回上去,比如上面案例,把没有妥投日期的根本原因【XXXXXX】周知 2、改造后链路 A 业务方---->C 业务同事 总共 2 个环节(改造前 5 个环节),因为界面提示错误信息,所见即所得,减少了中间环节。提升了业务效率,减少了研发内部中间环节的排查成本。


4)代码简单

编码简单化也是提高技术稳定性的有效方法。过于复杂的编码可能会导致错误和漏洞的出现,从而影响系统的稳定性。因此,我们应该尽量使用简单、清晰的代码。此外,我们还应该注重代码的可读性和可维护性,这样可以更容易地找到和修复错误。


1.遵循单一职责原则:每个函数或类应该只负责一个特定的任务。这样可以使代码更易于理解和维护,并减少错误的可能性。


2.避免冗余代码:尽量避免重复的代码。如果需要多次使用相同的代码块,请将其封装为函数或方法,以便在需要时调用。


3.使用注释来解释复杂的逻辑:如果代码中包含复杂的逻辑或算法,请使用注释来解释其工作原理。这可以帮助其他人更好地理解代码。


4.将长代码段拆分为多个小段:如果一个代码段很长,可以考虑将其拆分为多个小段,每个小段只做一件事情。这可以使代码更加清晰明了,并有助于调试和维护。


5.使用有意义的变量名和函数名:变量名和函数名应具有描述性,以便其他人可以快速了解其用途。


总之,编写简单的代码需要考虑多个方面,包括可读性、可维护性和可重用性等。

五、简单原则--践行中

我也是正在积极践行以下原则。虽然在实践中仍面临挑战,但正不断学习和改进

1)复杂(重复)的事情简单(工具)化

当我们面对重复而无差别的任务时,工具化的价值便凸显出来。引入合适的工具不仅简化工作流程,还能大幅提升效率。


对于复杂的业务逻辑,我们应致力于深入梳理和理解。详尽的文档是理解这些逻辑的钥匙,它能够将复杂性降低。


对于系统架构,我们应该梳理上下游依赖、交互、核心接口、业务场景、应急预案等,具备全局视图


对于技术密集的代码,充分的注释和示例案例是必不可少的,它们是简化理解过程的桥梁。


我们还应该将复杂的系统解构为小型、可管理的模块,这是一个将复杂事物简化的过程。

2)简单的事情标准化

一旦这些复杂的系统被拆分成多个简单的组件,我们就可以对每个组件进行定制化和标准化。

3)标准的事情流程化

这样的标准化模块,一旦定制完成,就能够形成一个简洁且固定的流程。这种流程化不仅为防止最糟糕情况的发生提供了保障,也使得任务能以统一和高效的方式运行。

4)流程的事情自动化

正如自动化测试所示,一旦流程化得以实施,自动化的基础便已铺垫。基于这一基础,我们可以将复杂的任务转化为自动化的操作,从而尽可能地减少手动干预,实现高效运作。


案例:行云部署发布上线 简单提效快 背景:为解决用户手动部署操作耗时高、分组多人工容易遗漏、对人依赖度高等痛点,2 个以上分组,20 个容器以上的应用,强烈推荐您使用【部署编排】功能,用户可灵活制定部署策略,实现从编译构建到实例部署的自动化运行,提高部署效率! 复杂-->简单-->标准-->流程-->自动化: 部署编排接入了丰富的原子,提供了部署策略、流量管理、编译构建等功能,可基于这些功能进行任务排布,形成一个独立的部署编排。部署时,只需执行此编排任务即可,解放双手实现自动化部署!同时部署编排支持多分组同时部署。

六、总结

1.复杂的事情简单化


2.简单的事情标准化


3.标准的事情流程化


4.流程的事情自动化


我们先踏出第一步化繁为简


简化复杂性不仅能在短期内提高开发效率和代码质量,也对产品和技术的长期价值产生深远影响


1.当我们考虑如何简化一个给定的任务的每一步时,这不并是在偷懒。相反,我们是在明确实际上要完成的任务是什么,以及如何更容易做到


2.我们对某些无意义的新功能说“不”的时候,不是在限制创新,是在保持环境整洁,以免分心。


3.软件的简单性是可靠性的前提条件。这样我们可以持续关注创新,并且可以进行真正的有价值的事、长期的事。


本文旨在抛砖引玉,仅就偶然复杂度的议题,从产品与技术的角度,分享一些关于简单化的个人思考。希望这些初步的观点能激发更多精彩的思考和深入的实践。如果文中有任何不足之处,恳请各位不吝赐教,留言指正。谢谢大家的阅读和反馈!

发布于: 刚刚阅读数: 5
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
简洁至上——探索产品与技术的优雅原则_京东科技开发者_InfoQ写作社区