客户端架构设计的过程

引言

工作中我们会讨论很多关于架构设计的话题,最常见的是围绕某个具体的业务场景,结合程序设计的基本原则、设计模式,讨论具体的架构设计方案。对于架构设计的过程,大家却讨论的很少,以至于每个工程师都会有这样的经历:眼前有一个问题,代码已经很难维护,但不知道是否应该重构,架构设计从何下手,如何判断方案合理。

本文的目的是总结对架构设计过程的一些思考,抛砖引玉,欢迎讨论。

一些前置想法

在开始讨论架构设计问题之前,我们需要先讨论2个问题:

  1. 架构设计的目标是什么?

  2. 什么时候需要设计新的架构?

架构设计的目标

简单的说,架构设计的目标是支撑业务的变化。这句话可以从几个方面来解读:

  1. 业务的发展方向可能是横向(增加功能,满足更多人群的需求),也可能是纵向(把某个功能做细做深,提升用户体验),此时,架构设计的目的是支持好这些业务的需求,保证可用性。

  2. 随着业务的持续迭代,工程复杂度提升,往往会发现一些架构瓶颈,影响迭代效率。此时,架构需要做的事情是预测/感知/收集到这个变化的趋势,做好工程、业务代码的调整,保持业务灵活迭代。

  3. 著名的康威定律指出,组织架构决定系统架构。业务可能变大或变小,也可能拆分或合并,与之对应的,团队也会调整,为了方便沟通,架构的组织结构可能会逐渐向团队的组织结构靠拢。

是否需要新架构?

我们看到很多代码劣化的原因,往往不一定是架构不能适应,而是写代码的人没有遵循架构设计者的思想,违背了原架构的规范。其实大部分时候原架构都能满足需求,仅仅需要深入分析原架构和当前的问题,理解设计思想,在此基础上扩展、局部重构,比起盲目重写,能够用更低的成本更好地解决问题。

什么时候会需要新的架构呢?

  1. 做一个全新的业务,需要新建一个系统。可以了解一些已有系统的设计,借鉴/组合已有的方案,少走弯路

  2. 业务变化导向的新需求很难在原架构上通过扩展实现,频繁地修改系统底层模块,系统可用性变的越来越差,此时可能需要对原架构进行调整,或设计一套新的架构

架构设计的过程

架构设计的过程会经历几个阶段,与软件开发的工程类似,如下图所示:

需求分析

这一步我们需要收集所有的需求和当前遇到的问题,将这些信息整理成用例。

以一个Feed场景为例,我们可以梳理出以下用例:

实际情况还会涉及到埋点、推荐场景特殊的传参规则、视频、图片预加载等需求,远比上述用例要多,但为了讨论架构设计问题,我们只看核心需求。

下文将继续使用Feed场景的例子,一步步来看我们是怎么做列表架构设计的。

梳理业务流程

整理出所有的用例之后,需要将我们对当前业务场景的认识转化成功能的业务流程,明确流程中的关键角色、角色的职责和角色之间的关系,同时梳理出核心流程的生命周期(状态机)。

继续以前面说到的列表场景为例,我们可以梳理出如下核心业务流程。

其中我们会发现有几个关键的角色,他们职责如下:

  1. 页面:负责维护页面加载的状态,触发加载流程,并通过骨架图、空态、内容等给到用户反馈。页面的状态变化如下:
  1. 数据源:负责加载页面的数据,可以设计成有状态(DataSource)或无状态(Repository)

  2. 列表:通常包含一个系统列表组件(RecyclerView/UICollectionView),支持根据数据加载卡片,并且在列表内容的滚动过程中,动态创建/复用卡片

  3. 卡片代理:作为列表和卡片之间的代理,负责将列表中每一个数据Item转换为一个卡片

  4. 卡片:列表中的各种卡片

关注核心问题

对比需求分析中我们列举的用例,会发现在我们梳理出来的核心业务流程中,有几个功能/角色并没有覆盖到,例如登录相关的操作、首页底Tab点击刷新、卡片局部差异等,原因是我们始终要关注核心问题,其他的角色要么可以被核心问题所覆盖,要么不应该在当前被讨论。

例如:

  1. 登录/退登导致的刷新:可以通过重置页面状态+开始加载来实现,已经被核心问题所覆盖

  2. 卡片局部差异:由于卡片的业务属性很强,灵活度很高,且不依赖于列表。因此卡片内部结构的处理应该作为单独问题讨论

问题拆分

目前的核心流程中,我们可以看到2个部分:

  1. 关于页面状态,这部分描述页面状态如何变化,以及如何将数据更新到列表,交给卡片代理

  2. 关于卡片代理,这部分描述列表更新数据 -> 卡片代理接到数据以后,如何将数据转换成各种卡片

这两个问题虽然是我们需要解决的重点,但他们之间似乎没有耦合关系,可以在列表 - 卡片代理这一处剪断,变成下面的2个核心流程


这样我们就在梳理业务流程的过程中,将整个业务场景的架构问题简化为2个不同的小问题,后面我们可以通过组合2个小问题的解决方案,完成整个业务场景的架构设计。

寻找模式

梳理完业务核心流程,对问题进行拆分后,就可以开始解决问题,也就是设计核心架构了。

其实我们经常会发现,架构的雏形已经在梳理业务核心流程的时候出现了,我们做的事情是:

  1. 解决角色之间的协同关系,也就是说我们设计的架构,是为了让整个流程跑起来。我们需要解决的问题:定义哪些角色是稳定不变的,由架构的核心来实现?业务场景能够扩展哪些角色,如何扩展?角色之间如何互相驱动?

  2. 解决架构和角色的命名问题,如何让整个架构更容易理解,各个角色的职责更清晰?

这两个问题,本质上是在"寻找模式",也就是说找到一种已有的模式,或者创建一种容易理解的模式,用这个模式来概括整个架构,使得整个架构更加易于理解和使用。我们常见的业务架构范式MVVM,设计模式,以及模块化/组件化的工程架构等,都是经典的"模式"。

如何找到模式?需要发挥工程师的想象力,例如:

  1. 可以从计算机已有的概念中寻找模式,例如状态机,经典的数据结构,经典的设计模式等

  2. 也可以从生活中的场景寻找模式,例如生产者/消费者模式,广播模式,列车模式,票据模式等

  3. 实在找不到合适的模式,可以结合当前的问题来定义新的模式

下面看一下Feed场景的2个小问题的模式

  1. 页面状态问题,当作普通的页面来处理,使用MVVM,其中VM结合页面状态流转的状态机设计即可
  1. 卡片代理问题,我们希望能够灵活地组合任意卡片,而一种卡片的数据结构、样式、业务逻辑等,可以仅由这种卡片的代理关注,因此我们让每个卡片都有一个代理,这里使用的是Delegate模式

找到模式以后,我们可以很容易地明确角色的命名,以及角色之间的协同关系,其中最关键的是:

  1. 架构核心实现哪些角色,以保证他们稳定

  2. 业务场景能够扩展哪些角色,如何扩展

此时我们需要用到两个工具:"分层"和"模块化"。

分层

分层是指将核心流程和各个角色,放在不同的层,从而明确他们的职责。在分层的时候,使用"三层法则",通常可以很清晰地看到各个角色的定位。

三层法则

对任意架构问题中的角色进行分层时,根据流程和角色的职责,将他们放置到对应的层。

遵循以下三层法则,从下到上依次是:

  1. 能力层/基础层:一切与实际业务无关的基础能力,例如系统API、动画能力、UI组件等
  2. 框架层/领域层:对当前架构问题进行抽象 ,实现稳定的核心流程; 通过正交分解找到最小的抽象维度,对外提供配置、扩展能力
  3. 策略层/应用层:利用基础层和框架层的能力,根据业务的策略 ,通过配置等方式,决定最终的功能结果

有时候会出现需要超过3层的情况,通常是因为问题拆解不清晰导致的,此时将问题进行拆解,会发现每个问题依然遵守三层法则。

列表场景的2个问题,分层之后的结果如下:

通用列表容器组件

卡片代理框架

此时我们会发现问题2(卡片代理)在问题1中位于能力层,只是我们把它拆出去单独考虑,简化了问题。

小提示,架构设计中的分层问题,往往都可以用三层法则来解决。如下图所示,应用于业务架构范式、模块化、App工程架构等场景:

模块化

除了分层以外,业务场景需要实现不同的功能,这些功能对于框架来说位于相同层级、是相同的角色。为了做到关注点分离,将复杂问题拆解成简单的小问题,需要支持业务层将功能拆分成不同的模块。框架需要支持多个模块的接入,以及模块之间的交互,此时的架构会需要用到模块化的设计。

客户端的模块化设计通常比较简单,广泛采用SPI(Service Provider Interface) 架构设计。基于依赖注入原则,框架层提供一个模块容器,支持业务层注入多个模块,模块之间通过接口进行服务查找和调用。类似下图所示结构:

常见的客户端模块化框架ServiceManager(IService)、播放器框架VideoShop(Layer)以及Block拆分框架都是基于这个思路实现。

Feed场景的2个问题,其中只有卡片代理需要实现模块化能力,目标是支持卡片与页面通信,也是基于SPI的方案,支持了模块化能力(ListContext)的卡片代理方案如下:

ListContext的定义就是典型的ServiceContainer:

Kotlin 复制代码
public interface ListContext {
    fun registerService(cls: Class, service: Any)
}

SPI方案的优点是简单易用,接口定义很清晰,缺点是模块间耦合比较严重,尤其是在一些大型项目功能复杂以后(例如西瓜播放器场景,业务代码动辄几千上万行),很难弄清楚一个模块的状态如何变化,模块间互操作的影响是什么。此时可以考虑基于SPI,升级为面向Lifecycle的模块化方案,优点是模块的生命周期定义清晰,模块间通过生命周期变化为契机进行交互,模块的可解释性和可维护性更强,缺点是模块定义成本增加了不少。

面向Lifecycle的模块定义如下,框架层可围绕这个定义提供一些通用实现,这里不展开。

Kotlin 复制代码
S: State
E: Event

public interface LifecycleObserver<S, E> {
    fun onStateChanged(state: S, event: E)
}

public interface LifecycleInterceptor<S, E> {
    fun intercept(old: S, event: E): S
}

public interface Lifecycle<S, E> {
    val currentState: S
    fun addInterceptor(interceptor: LifecycleInterceptor<S, E>)
    fun addObserver(observer: LifecycleObserver<S, E>)
}

SPI只是客户端最常见的模块化设计,其他模块化方案还有很多。

在做模块化设计的时候,有一个很重要的权衡问题:模块间做到何种程度的约束和解耦。

模块化理念的两极

在模块化方案设计中,模块间以何种方式通信,将会决定模块接口的约束强度以及模块之间的耦合程度。

两极分别是:强约束:耦合度高 <--> 弱约束:耦合度低

虽然架构设计中往往会认为应该"解耦、再解耦",因为降低耦合度能够带来更强的灵活性和可扩展性,但需要强调的是,约束有其优点,例如接口定义更明确,限制业务层的调用方式避免出错等。

通常不需要为了极致的解耦而过度设计,只需要满足当前已知需求+未来一段时间可见的需求即可。

关于如何约束和解耦,没有很明确的标准,更多是基于设计者对一段时间内业务发展方向的判断。下面用两个例子讨论模块化方案的选择:

  1. 工程架构中的模块间通信

    1. 最解耦的方式是字符串路由,通过字符串拼接调用其他模块的方法、页面,常用于跨端场景,如

      1. 服务端下发Banner的时候配置了点击跳转的Schema

      2. H5、Lynx等跨平台页面通过JSB调用端上能力

    2. 其次是事件广播,EventBus,虽然这种方式耦合程度较低,但很容易产生问题:

      1. 发布者/接收者都需要依赖事件定义,依然产生了模块之间的耦合

      2. 事件定义往往比较简单,不支持复杂的回调能力

      3. 发布者不能够知道到底有哪些接收者消费事件,也不知道一个事件会引起哪些影响,事件用起来简单治理起来很困难

    3. 耦合程度最高的是ServiceManager(SPI),虽然这种方式耦合程度较高,但在客户端中使用较多,原因是:

      1. 通过接口能够更清晰地定义模块的功能、参数要求等
  2. 卡片系统的设计,从上到下依次耦合程度更强

例子:启动流程编排

在App首页的启动流程中,我们常常会有如下的一些需求:

  1. 首页启动流程中,有很多流程控制需求,例如

    • 新用户首次启动,需要在内容加载前展示隐私弹窗和权限申请
    • 众测阶段为了卡安装人数,或者为了优质的裂变增长,会首先验证邀请码
    • 新用户首次启动,希望用户选择感兴趣的标签,提升推荐质量
  2. 冷启优化中为了减少一次Activity启动时间,常常会将开屏 SplashActivity 和主界面 MainActivity 放在一起。实现方案是在 MainActivity 中增加控制逻辑来调度开屏和主界面的展示/消失。这个Case本质上也是1所说的流程控制

上述流程控制,常常会在MainActivity中写很多条件分支,造成代码复杂度的快速上升,后续难以维护和性能劣化。

解决这个问题,可以借鉴生活中 **舞台 - 演出 **的模式,设计 Stage - Play 架构,让上述流程编排更加直观且易于理解。

Stage 和 Play 指的是舞台和演出,通常情况下,满足以下规则:

  • 一个舞台(Stage)可以串行多个演出(Play)

  • 上一个演出结束(Play.finish) 之后,下一个演出开始(Play.start)

  • 一个演出正在进行(Play.start - ),后面的其他演出可以在后台准备(Play.prepare),也就是预加载

这个流程非常类似启动阶段的各种弹窗、界面流程。

Stage 核心的生命周期,包含 Play 的编排,各个 Play 创建、准备、开始和结束,如下图:

在这个例子中,分层和模块化的结果是

架构验证

前面介绍了如何进行架构设计,然而一件常常被忽略的事情是,架构和功能一样,都是需要经过测试验证的

这一部分关注的问题就是如何验证架构设计的合理性。下面提供几个角度:

  1. 用例检测,在最开始已经收集了所有的需求用例,此时需要对比用例一个个看,设计的架构是否能实现所需的每个功能,例如:
  1. 可扩展性校验,拿最近的一些需求,看看这些需求我们能否通过框架提供的扩展能力实现。最直接的方式是检查在实现这些需求的过程中,我们修改了框架层还是策略层,理论上框架层应该保持稳定,新增需求尽量通过组合不同策略/模块来实现

  2. 框架本身的设计,检查是否遵循架构设计的原则和最佳实践。这里想提一个可能影响架构易用性的想法,最少定义原则:某个功能相关的信息,在尽可能少的地方定义。以卡片代理问题为例

  1. 性能,虽然我们在说架构设计的过程中没有提到性能问题,但一个设计良好的架构需要从框架层保证性能表现,一方面是由于框架的性能问题可能影响更大,另一方面从框架角度优化性能,往往能取得更大的收益。例如声明式UI框架,React在最初提出的时候,发现这种架构模式会存在局部大量无效重绘的问题,为此引入了虚拟DOM树的diff算法,尽可能缩小重绘的范围,从框架层面有效地提升了页面性能;同时社区在状态管理框架中,讨论并推广了immutable等数据框架,大大降低了性能优化的成本。

  2. 可用性,架构设计的重要目标之一就是保证业务可用性。框架层面做好异常处理和监控,能够很大程度降低业务层发现问题的成本,提升可用性。

架构演进

必须强调一点,好的架构是演进出来的,而不是设计出来的。完成了架构设计和验证,我们需要思考如何进行架构演进,从老架构迁移到新架构。如果说架构设计需要花10分的精力,那么完成架构的迁移落地则需要也值得花费90分的精力。明确各个场景的迁移计划,在有限时间内完成。架构设计者需要确保及时完成新架构在所有场景落地,老架构被完全替换删除。多套架构方案同时存在,可能造成更大的认知和维护负担。

一种做法是在老架构之上构建新架构,设计防劣化层。上层使用新架构API,老代码逐步迁移到新API,最后再废弃老的API,清理老架构。

另一种做法是在新场景中考虑到新老场景的所有用例,设计新的架构。在新场景充分验证后,后续逐步将老场景代码迁移到新架构。

关于架构演进的几个建议:

  1. 多个小重构,优于一个大重构。每次重构确保影响范围可控,包括线下可测试,线上可观测

  2. 类似问题的解决方案,一个项目中应当存在<=2种,每当引入新的方案之前,应该思考如何在老的方案基础上支持新的特性,如果一定要引入新的方案,则删除1种老方案作为交换

总结

总的来说,架构设计需要经过需求分析,梳理业务流程,寻找模式,架构验证和架构演进等过程。为了设计好的、简洁的、可理解、可扩展的架构,每个过程都至关重要。

这里总结几点:

  1. 需求用例需要穷举场景

  2. 关注核心业务流程,提炼重点问题;将大问题拆解成小问题,解决完再组合回来

  3. 寻找模式来降低架构理解、使用成本;使用分层、模块化等方法明确职责和接口

  4. 对架构进行验证,确保满足功能、易用性、可用性、性能等要求

  5. 架构设计出来只是完成了10%,真正完成迁移才算做完了100%

最后附上一段很棒的话,与诸君共勉。

著名物理学家薛定谔在《生命是什么》中提到:"人活着就是在对抗熵增定律,生命以负熵为生"。其实何止是人类的生命,软件的开发同样遵循着熵增定律,在开发中我们不得不面对那些"熵增"的代码,他们混乱、繁杂、bug 频出,却不能随意修改,因为一点细小的改动就可能导致整个系统的瘫痪。任何一个软件的开发,或许最终都会走向"人月神话"的终局。

但是就好像太阳的能量碰撞在地球的岩石上,会产生负熵的浪花------生命。程序员们同样不会"温和地走进这个良夜"。软件工程就是我们理解中的对抗"混乱与无序"的武器,我们或许避免不了在没有文档和注释的茫然中与天书般的代码缠斗,避免不了只要相互靠近就会彼此伤害的刺猬寓言。但是我们依然且一定会利用我们的智慧让我们的作品向负熵前进,变得更加有秩序,更加优雅与浪漫。

来源:ficus.world/pages/bf7fc...

相关推荐
弥琉撒到我2 小时前
微服务swagger解析部署使用全流程
java·微服务·架构·swagger
_.Switch9 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
韩楚风10 小时前
【linux 多进程并发】linux进程状态与生命周期各阶段转换,进程状态查看分析,助力高性能优化
linux·服务器·性能优化·架构·gnu
_.Switch15 小时前
Python机器学习:自然语言处理、计算机视觉与强化学习
python·机器学习·计算机视觉·自然语言处理·架构·tensorflow·scikit-learn
feng_xiaoshi19 小时前
【云原生】云原生架构的反模式
云原生·架构
架构师吕师傅21 小时前
性能优化实战(三):缓存为王-面向缓存的设计
后端·微服务·架构
团儿.1 天前
解锁MySQL高可用新境界:深入探索MHA架构的无限魅力与实战部署
数据库·mysql·架构·mysql之mha架构
艾伦~耶格尔1 天前
Spring Boot 三层架构开发模式入门
java·spring boot·后端·架构·三层架构
_.Switch1 天前
Python机器学习框架介绍和入门案例:Scikit-learn、TensorFlow与Keras、PyTorch
python·机器学习·架构·tensorflow·keras·scikit-learn
神一样的老师2 天前
构建5G-TSN测试平台:架构与挑战
5g·架构