软件技术学习笔记

个人博客,记录软件技术与程序员的点点滴滴。

跳出泥潭的经历

在我的软件职业生涯中,大部分时间都在设计与开发新系统。刚开始参加工作的时候,代码也写得不好,但也不会糟糕到开发效率越来越低的程度,也有业务不够复杂的原因。后来开始学大厂的开发规范、极限编程、敏捷软件开发与设计模式,再后来开始学软件架构相关的知识。在软件设计上,“Uncle Bob"对我的影响颇深,学习他写的书也比较多。

在这类书籍当中,里面讲到了稳定依赖原则、稳定与不稳定划分等概念。一开始并没有体会到这些概念的重要程度,只需遵守原则、重构坏代码,一切都很顺利。在亲自接手腐烂的代码项目之后,才深刻感触到代码与业务的关系就是“水能载舟,亦能覆舟”,处理好这些概念就变成跳出泥潭的关键。

1. 初识“大泥球”

我接手的这个项目,也是在一定的背景条件下产生的。先了解这个项目的相关背景,不可否认它的价值所在,现在分析它的目的是避免重蹈覆辙,争取选择最优解。

阶段一:在部门还没有成立前端小组之前,也有合作方的前端开发人员,都是由后端的业务专家在指导做什么,没有人管该如何做的。部门以前主要是开发Windows程序,老员工对Web前端也不了解。有合作方的支持,确实可以上到Web,初步进入前端领域,也引入了MVC/MVVM的前端概念。客户的反馈是因为没其它选择才使用的,我们定义该版本为v0.5。

阶段二:在成立自己的前端团队初期,活多人少,自己人员的主要职责在大型业务组件的开发,还没有自己的UE人员,没有现代效果的设计经验。这时,需要借助外部力量提升前端的设计效果,也想了解前端设计的流程。这时业务也在新的方向做探索,于是跟一家主要开发广告的公司合作,让他们负责设计到实现。代码都是离岸开发,每隔一段时间才发过来集成。期间,项目问题很多,对方开发人员经常熬夜,对方项目经理离职;我们前端领导去对方公司常驻一个多月,我们也帮忙救火很多次。从效果上,达到主流的现代效果。该版本与v0.5的业务不一样,我们还是先定义为v1.0。

阶段三:前端有了专业的UE、UI,大型业务组件也开发完成。v1.0经过演示与初步使用,发现这是一个新的、比较大的业务方向。于是,业务专家与客户开始研讨新的版本,确定v2.0初稿,但是发现需求量太大了,无法吃得下,最终选择v1.5的方案。v1.5是把v0.5的部分内容加上v1.0的内容,有差异交付两个产品。我就是过去负责v1.5版本的前端交付,从此我也负责这边业务前端的所有事宜。

v1.5说是融合两个版本,同时变更和新需求都是不少,业务也是很复杂,历时4个多月,前端开发投入大概35人月。期间,加班到深夜是常态,到转测结点时部分人熬夜到凌晨两三点或通宵。v1.5发布之后,唯一从0.5版本一直跟这个项目的老员工也离职了,我刚到这项目时他给我的帮助很大。

2. 业务特点

我们先看看与前端相关的业务特点,再探讨架构问题,否则,很难对齐概念与复杂程度。下图是2018年初期的主要布局(懒得重新画了),虽然后面做了多次优化,但是主要业务内容还是这些。

界面主要布局

点开“所有应用”,下面有7~8个菜单组,每个组中又有几个菜单。打开一个菜单就是大页面,大部分都是如上图的布局,有TOPO、GIS、饼图、柱状图、堆积图、折线图、业务组件图、表格、详情页;少部分菜单打开的是表单页面,也有很长的页面。这些页面看似都是使用这些视图组件,但是处理的数据内容都不同。

内容多还不是其真正的难点,而复杂的多级过滤条件与多方联动才是令人头疼的地方。工具栏上有年份过滤、区域和类型过滤,作为前面两级过滤条件,且有跨页面的记忆要求。点击TOPO或GIS上的点或线,下面多个Tab页的内容是过滤或高亮,右侧的饼图也会过滤;点击下面Tab页中的内容,TOPO或GIS上会高亮、别的Tab页的内容会过滤或高亮;有的页面点击饼图,TOPO或GIS会高亮,Tab页中的内容也会过滤;点击TOPO或GIS的空白处,返回常态。

在处理复杂业务的同时,项目的交付周期也逐渐缩短,需每月一个版本给客户使用。每次需求量又比较大,需要支持很多开发人员并行开发。

3. “大泥球”的架构面貌

一个大项目,其开发效率很低或越来越低,不是说它完全没有架构,只是它选用的架构对不适用于当前项目,造成边界不清晰、各种藕断丝连。在v0.5和v1.0版本中,你会发现它们都有架构,只是好坏的问题。

v0.5版本:

  1. AngularJS,使用ES5编写,没有构建工程,没有静态检查,没有自动化测试。
  2. 基本模式:MVC/MVVM。
  3. 模块划分:TOPO共用,各个页面传入不同参数给后台请求;菜单页与详情页的各一个控制器;
  4. 其他特点:项目管理还是jQuery,使用jQuery老表格与EasyUI表格,ECharts也未封装,
  5. 业务复杂程度:相对简单,下方一般只有一个表格。

v1.0版本:

  1. AngularJS,使用ES6编写,有构建工程,有静态检查,没有自动化测试。
  2. 基本模式:MVC/MVVM。
  3. 模块划分:先菜单组划分,再每个菜单页一个文件夹;每个页面的三大块都在Components中。看得这还以为很好,但是他们干了一件违法软件设计原则的事情:把不同的页面使用的功能(含请求后端接口、数据转换等)都写到Components中的同一个组件中,各个页面都是藕断丝连了。于是,他们的代码就变成最难以维护的代码。
  4. 其他特点:代码重复度高,有复制粘贴的情况。
  5. 业务复杂程度:相对复杂,除了一二级过滤,一样不落。

v1.0版本违法设计原则

为什么v1.0版本会比v0.5版本还更难以维护?大概原因有以下几点:

  1. 整个构建工程与模块划分,他们只是参照项目启动时我给的样例,也许他们并没有理解其中的含义。
  2. 也许他们从未设计过复杂的软件项目,没有经验,也许投标这个项目时他们的评估偏差就很大。这一点,从整个项目的交付情况、人员疲惫情况,可以反应出来。
  3. 项目开始时,我方也未料到前端会这么复杂,未考察对方的软件设计水平,只看得他们以前做的效果很好。

小结:v0.5版本的架构无法支撑复杂的业务,也没有哪个开发人员喜欢看代码行数很多的文件。v1.0版本的架构问题是致命的。

3. 设计新的架构

在开发v1.5的过程中,我们把相关的代码都改到ES6,支持统一构建与静态检查,但项目进行的艰难程度是每个人都不想再经历了。在完成v1.5版本的交付之后,我就向领导反馈“不能再这样下去了,需要改进软件架构”,也得到领导和大项目经理的支持。在项目进度困难的时候,没有哪位领导愿意听到“加入更多的人也没有用”。

3.1 架构演进(第一版)

联动数据太多,打算引进Redux,我先研究Redux、容器组件、视图组件等React+Redux那一套玩法,然后做了一个PPT讨论。再后来的一个月时间里,留下其他人做需求或改问题,我、组内成员A、和借来的组外成员B开始实施这个计划。

我们拿其中的一个大页面做实验田,我们新建或封装视图组件(布局组件、TOPO组件、ECharts组件、表格组件、图例组件),抽象联动数据,再连接到Redux封装容器组件,页面再使用容器组件拼装出来,最后替换路由上的组件。在完成这么多内容之后,因为这个页面还有很多细节没有实现,并没有直接替换路由上的组件合入主干,我只能等待下一个时机了。

没过多久接到一个大需求,这些全是新页面,新的机会来了。我决定使用新的架构去交付,因为老的方式开发效率太低,还不可靠。因为时间交付周期比较短,领导还是有点担心不能按时交付,我也解释了“我们已经准备好了这些视图组件,也实验过一个复杂页面,有把握。虽然是新的架构方式,开发初期效率可能不高,但是后期维护成本要比以前低很多”。这段时间里,我这边的需求量太大了,只能继续向别的组借两人,:) 在完成这个需求的过程中,有几个按钮我们都封装成容器组件,因为我们的效果经常调整,搬容器组件最容易、不伤功能代码。

于此同时,我们还做了其它的改进:

  1. 新增了代码重复的检查。
  2. 完善本地JSON API Mock与REST API Mock,不依赖后端我们就可以看到真实的效果。这个技术特点,做演示版本最方便,行销人员最喜欢拿它到客户内部网络中演示。
  3. Nginx反向代理到测试环境也集成到开发脚本中,执行本地开发命令时会自动帮你启动与打开浏览器。

小结:共用的地方需要封装成相对稳定的视图组件,大一点功能都封装成容器组件,易挪动的小功能也要封装成容器组件。刚开始时,为了避免开发人员对新架构不熟悉,我会把目录结构、文件、类名都定义好,完成一个可以运行的空壳子,每个人往里面填空就可以了(给培训之后上第二套保险)。时间证明,我们的项目没有延期,产生的问题也比以前显著减少。

我们的用法与"React Redux"官方用法有点差异:1、容器组件本身也定义为一个class,包含差异化处理、后端数据请求;2、不像官方一个connect函数就包装完成。使用“React+Redux”框架且面对复杂的业务时,我也建议在中间插入一层差异化视图组件,变成“容器组件 -> 差异化视图组件 -> 复用视图组件”这种模式。

选择Redux的原因:除了设计原则,反馈也是我软件工程实践里面的一个准则,Redux Dev Tool可以很方便地查看组件的输入状态。

选择“视图组件+容器组件”的原因:符合单一职责原则、稳定依赖原则,我们把共用的部分设计成稳定的视图组件,易改变的、不稳定的部分设计成容器组件。

3.2 架构演进(第二版)

在第一版中,我们使用Redux的方法已经符合常见的教程了。但是,我发现每次都需要写一堆Action和Reducer,感觉功能逻辑方面都差不多。在联动时,需要在容器组件中派遣Action,关联的业务逻辑均散落到各个容器组件中。这样的结果,我感觉还是不大理想。

经过一段时间的思考,也看了GraphQL的Apollo项目,我也得到一点启发。我想改进前端的网络数据性能与跨容器组件间的多方联动,也想让开发人员的日子更加舒服。我们前后端都没有使用GraphQL,只能自己想方法了。

于是,开始设计一个数据依赖加载框架,把复杂的业务关系表达为一个有向无环图,该框架完成以下内容:(假设数据项A依赖数据项B、C、D)

  1. 并行求解依赖的数据项,再求解当前数据项。在实现上,求解数据项A时,先求解数据项B、C、D中未求解的项。
  2. 支持返回Promise与普通函数的求解。在实现上,数据项B、C、D求解完成时,通知数据项A检查是否满足求解条件。
  3. 缓存数据项的求解结果,除非有人置脏它。数据项置脏时,会触发依赖树上所有依赖它的数据项置脏。
  4. 数据项的求解结果同步到Redux状态树上。
  5. 支持订阅数据项置脏动作,也可以自动重新求解。

有了这个框架,在业务代码中只需进行这几项工作:

  1. 每个大模块的Data文件中需要定义每个数据项ID、数据项求解过程、数据项依赖关系。
  2. 容器组件中只需订阅Redux状态中需显示数据项与其脏动作。
  3. 用户操作界面时,只需置脏数据项即可(数据项一般是真实的显示数据,复杂情况时数据项可以是动作)。

新架构模式数据流

从上图中,我们的数据架构模式比单纯的React+Redux多了一层“数据依赖加载框架”,目的是为了解决复杂的业务问题。数据回流到容器组件的过程中,我们还是使用已经包装的connect函数,没有特意向上层屏蔽Redux。在演进的第一版中创建的组件,仍然保留单纯Redux操作。

SPA内部模块划分

在SPA内部与其较大的业务模块中,我们也分得更细,确保每一个部分功能单一。按照这个架构去开发新页面,可以支持很多人并行开发,一个人可以只开发一个小组件。经过长时间的积累,Components中的视图组件会越来越多,再开发新页面就可以复用,效率大幅提升。

放出这两张图,是因为他们与业务没有关联,也没有跟在内部画过的一样。另外还画了一张图,但与以前画过的一样,以前工作未公开的资料不方便透露。它是描述Git代码库级别模块的分层,主要分为4层,向下稳定依赖,但可以跨层使用,目的在于:能够在多个SPA之间复用,也可以快速组装出新的SPA。

小结:这次演进,只比学来的知识多走一小步,这一小步正是解决我们业务问题的关键点。我们也组装容器组件做复用组件,通过换数据的方式用在不同的页面,仍然满足组件化开发要求。

4. 制定计划与实施

在架构演进的过程中,我们的计划是这样的:

  1. 对新功能的开发,必需使用新的架构。
  2. 对老功能,使用逐步替换的策略(蚕食战术)。老功能都是好的,为了甩掉历史包袱,我们先替换风险较小且容易替换的,如表格、项目管理与部分ECharts图。
  3. 对功能变动比较大的老页面,使用整体替换的策略。原因有二:1、在老的基础上再堆代码,开发效率低,后期问题又多;2、新架构模式,已经积累不少可复用的组件,初始开发效率也不低,还可以保证“功能开发完成之后一切都是稳稳当当的”。
  4. 对老功能组件间的边界,使用新架构的数据处理方式。边界管好,问题不会影响一大片。

对计划的第1、2、3条,都会排到版本计划的Story列表中。对计划的第4条,只能自身去发现边界问题,再解决。

5. 人员安排与培养

一个新架构的落地,离不开团队的全员参与。

人员安排的情况如下:

  1. 在依赖关系的底层、需设计成稳定的组件,由能力较强的成员负责开发,也包括我。
  2. 公共数据部分,由能力较强的成员负责开发。
  3. 对外层的容器组件或无复用的视图组件,参加过新架构培训的同学都可以开发。

人员培养的步骤如下:

  1. 统一培训一个晚上,让大家对新架构有一个认识,知道我们要解决什么问题、为什么用它,改变思想为目的。
  2. 参与到已经搭建好的新架构空壳子中填空式开发,初尝高效率、高质量。
  3. 不给空壳子,先让他串讲如何去划分组件,如果不对就及时纠正,再让他去开发。
  4. 只分配任务,提醒使用新架构,看他设计与编码的结果,检视代码即可。到这阶段,一般不会出问题,有的人还可以带新同学了。

6. 其它演进

  1. 使用NodeJS本地Mock后端API提升前端开发效率。
  2. E2E自动化测试。有点遗憾,这个项目的前端开发一直没有落地单元测试。原因有二:1、接手的代码,如果设计时未考虑易测试,单元测试很难进行,高投入低回报;2、测试已经投入了比较重型的E2E自动化测试,测试用例也多,高质量也意味着高成本。
  3. 逐步加强的静态检查规则。ESLint的规则也越来阅严格,警告的都升级到错误级别,对文件行数、函数行数限制严格,大家都不喜欢又长又臭的代码。
  4. 从ES6改到TypeScript。团队成员也一直向前学习,从ES5到ES6,再到TypeScript,大家的目的都是一致的:“高效构建高质量的前端产品”。不仅产品做得好,自己又有进步,玩得开心。

7. 结束语

在这个项目中,我的角色是前端Leader,主要负责前端的开发进度、质量、效率。遇到软件架构问题,抓住机会进行一次自我训练。团队成员从5人到10人之间不等,我也想对他们的软件职业生涯有点影响,希望他们少陷入泥潭,对他们进行了一些软件设计的训练。因此,我也把自己定位为跟别人不一样的Leader,成果就是“产品做好,团队能力提升,架构稳定之后我也比较轻松了,并行开发人数可谓多多益善”。

结尾也推荐几本书籍:《敏捷软件开发:原则、模式与实践》、《架构整洁之道》、《程序员必读之软件架构》、《跃迁:从技术到管理的硅谷路径》,从中要学会“稳定依赖原则”、“稳定与不稳定的划分”、“C4方法”、“基本的团队管理”。

还有一条经验:低成本的快速反馈是提升效率的一个方法。这也许体现在编辑器、静态检查等工具上。