写点什么

架构团队如何重构内部系统

作者:智联大前端
  • 2021 年 12 月 02 日
  • 本文字数:8282 字

    阅读完需:约 27 分钟

前端团队难免需要维护一些内部系统,有些内部系统由于开始的架构设计不合理,随着业务复杂度的增加,“坏味道”代码也越来越多,从而导致认知和沟通成本上升,甚至问题频出,此时,重构就自然成了一个选择。但重构不是一时兴起,也不是一蹴而就的,需要仔细的分析和有序的实施,以实验平台为例,介绍一下智联大前端的重构经验。


实验平台是智联招聘自主研发的 A/B 实验生态,依托于数据平台,并结合公司的业务和技术特点量身定制,提供了丰富的实验能力,科学的实验机制和完整的流程管理。


Web 端是使用了基于 Vue 实现的 Ant Design Vue 组件库开发实现;API 层是基于 Node.js 开发,预先处理、组合、封装后端微服务所返回的原始数据,有效降低 UI 与后端接口的耦合,实现并行开发和接口变更。

现状

UI 排版和布局的整体设计不统一,前端交互复杂,功能冗余,“坏味道”代码不断增多更是加大了开发与维护的难度;Api 层没有遵循主流 RESTful Web API 标准,只负责了后端接口的转发,逻辑全放在 Web 层实现,没有有效降低 UI 与 Api 层接口的耦合,加重了 Web 层的负担;


基于以上原因,我们决定对实验平台系统进行重构,进一步提高其易用性、内聚性和可维护性。

分析

首先逐个页面分析一下实验平台功能及使用情况,以方便对接下来重构工作有初步的了解:

概览页:主要展现自实验平台上线以来使用情况的统计信息,为了更好展现统计内容,以及日后方便对数据结构的维护,我们决定数据不再由后端接口提供,改由自己的 Api 层计算;

实验列表页:列表页主要用于展示用户关注的实验的关键信息,所以尽可能的精简展示字段以及优化信息主次排版;同时,提供快速跳转入口(直达统计、直达调试),优化用户体验;添加可搜索实验名和创建人,优化搜索体验;对于实验状态,有些状态不再需要(如申请发布、同意发布、已发布、归档),同时还需要兼容旧的实验状态,为此我们对实验状态做了新的调整:

  • 草稿:新建

  • 调试:调试状态

  • 运行:运行、申请发布、同意发布

  • 停止:放弃、停止、已发布、归档

  • 世界概览页:基本功能不变,按原则重构代码即可

变量页:经过考量,变量没有必要再执行释放、或者恢复等操作,所以只需要展示正在“运行”实验的变量列表;

设置页:主要目的是展示和添加管理员,所以没有必要显示所有的用户,所以可以简化为删除和添加管理员即可;

基本信息页:此页面基本功能不变,优化页面布局排版,统一用户体验,编辑权限由 Api 层统一控制;

统计分析页:此页面基本功能不变,为了便于维护,统计数据全部由 Api 层计算生成;另外,经分析大盘指标页面实时功能可以去掉;优化页面整体布局排版及重构代码;

操作记录页:需要添加克隆实验 id 的信息,优化用户体验;

控制页:此页面基本功能不变,统一用户体验,编辑权限由 Api 层统一控制,优化页面布局排版及重构代码;

总结页:用于总结实验结果,这个页面经过分析,已经不再需要;

原则

至此,根据之前的分析,我们已经对实验平台的现状有了初步的认识。接下来,总结一下形成一些有用的指导原则:

分层,Web 层和 Api 层应各司其职:

  • Web 层只负责 UI 的交互和展示;

  • API 层遵循 Restful Web API 标准,采用强类型检查的 Typescript 开发,负责所有的功能逻辑处理及权限控制;

布局,整体布局保持设计一致:

  • 布局自然化,提升可维护性;

  • 版块规范化,保持设计统一;

  • 各版块的上下左右间距一致,版块间对齐;

模块,保持职责单一,方便维护原则:

  • 按照职责拆分模块,并相互解耦;

  • 尽最大可能不维护状态(尤其是全局状态),而是通过与其他模块交互实现;

  • 逻辑去中央化,分散到各功能组件;

  • 组件化,保持组件职责单一;

  • 未复用的组件均置放于父容器组件的目录之下;

开发规范,遵循智联前端开发规范及自定义原则:

  • 样式规范化,降低更新成本;

  • 统一输入输出规范;

  • 禁止所有魔法数字,而是通过变量实现;

  • 禁止所有内联样式,而是通过更加通用的 Class 实现;

  • 尽最大可能不使用绝对定位和浮动,而是通过 a-layout 组件、标准文档流或 Flex 实现;

流程,采用渐进式重构方式:

  • 渐进式重构方式,分阶段进行重构,每一阶段都不破坏现有功能,具备单独发布的能力;

阶段

接下来,我们将重构周期划分几个不同的阶段进行有序实施。

第一步:js 迁移到 ts

众所周知,JS 是一门动态语言,运行时动态处理类型,使用非常灵活,这就是动态语言的魅力所在,然而,灵活的语言有一个弊端就是没有固定数据类型,缺少静态类型检查,这就导致多人开发时乱赋值的现象,这样就很难在编译阶段排除更多的问题,因此,对于需要长期迭代维护以及众多开发者参与的项目,选一门类型严格的语言、可以在编译期发现错误是非常有必要的,而 TypeScript 采用强类型约束和静态检查以及智能 IDE 的提示,可以有效的降低软件腐化的速度,提升代码的可读性可维护性。

所以,这次重构工作首先从 js 迁移到 ts 开始,为后续模型梳理奠定语言基础。

ts 仅限于 API 工程的 node 层,因为,前端使用 Vue2 对 ts 支持不太友好,所以还保持使用原有 js。

第二步:梳理数据模型

这个步骤比较简单,主要是梳理现有的 API 接口请求的输入和输出的信息,对后续梳理数据实体打好基础。首先,整理出实验平台系统所有的页面,如下所示:

  • 设置

  • 变量

  • 世界

  • 概览

  • 实验列表

  • 创建实验

  • 查看基本信息

  • 编辑基本信息

  • 查看操作记录

  • 查看统计

  • 控制

然后,分别对每个页面涉及的 Api 接口进行进一步统计,例如,设置页:获取用户列表、新增和删除用户,设置用户角色等 API 接口。

其次,根据上一步整理的结果,对每个 API 接口请求输入输出信息进行归纳整理,例如,打开实验基本信息页,找到浏览器的开发工具并切换到【NetWork】,鼠标右击请求接口找到【Copy as fetch】复制请求结果,如下图所示:



如下展示了 API 接口请求输入输出信息的代码结构:

【示例】
// [分组]: 获取实验分组信息列表fetch( "https://example.com/api/exp/groups?trialId=538", { credentials: "include", headers: { accept: "application/json, text/plain, */*", "accept-language": "zh-CN,zh;q=0.9,en;q=0.8", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin" }, referrer: "https://example.com/exps/538", referrerPolicy: "no-referrer-when-downgrade", body: null, method: "GET", mode: "cors" });
const response = { code: 200, data: { groups: [ { desp: "c_app_default_baselinev", flow: 20, groupId: 1368, imageUrl: "", type: "A,对照组", vars: ["c_app_default_baselinev"] }, { desp: "c_App_flowControl_baseline", flow: 20, groupId: 1369, imageUrl: "", type: "B", vars: ["c_app_flowControl_baseline"] } ], varName: ["gray_router_prapi_deliver"] }, time: "2019-12-20 17:25:37", message: "成功", taskId: "5f4419ea73d8437e9b851a0915232ff4"};
复制代码


同样的,我们按照以上流程依次对所有页面的对应 API 接口请求的输入输出分别进行整理,最后,得到如下文件列表:


接下来,分析每个接口的返回值,提取 UI 层交互会用到的字段,从而定义基本的数据模型,数据模型要能够直观的展示数据的基本组成结构,如下所示:

【示例】
// 分组const group = { id: 123, name: 'A', // 组别 description: 'CAPP变量', // 描述 variableValue: 'c_app_baselinev', // 变量值 preview: 'http://abc.jpg', // 预览图 bandwidth: 10 // 流量}
复制代码

第三步:梳理数据实体

前面我们梳理了数据模型,接下来,我们需要根据数据模型进一步梳理模型对应的数据实体,如下所示:

【示例】
class ExperimentGroup { id: number name: string description: string variableValue: string previewImage: string bandwidth: number
constructor () { this.id = null this.name = null this.description = null this.variableValue = null this.previewImage = null this.bandwidth = 0 }}
复制代码


把需要经过计算处理才能得到字段定义在 Object.defineProperties 中,如下所示:

【示例】
class ExperimentCompositeMetric extends ExperimentMetric { unit: string
constructor () { super() this.unit = ''
Object.defineProperties(this, { displayName: { get: () => (this.unit ? `${this.title}(${this.unit})` : this.title), enumerable: true } }) }}
复制代码


与此同时,根据接口的输出,定义了统一的 Api 接口输出规范的数据实体,如下所示:

【示例】
class Result { error: boolean|string|Error data: any requestId: string|null constructor () { this.error = false this.data = null this.requestId = null }}
复制代码

第四步:交互类型接口重新梳理

接下来,根据 UI 层的交互功能,定义接口的输入规范(包含方法、路径,及参数等),一边就把接口关联的实体丰富起来,最后,在实际开发中根据需要不断调整优化实体,如下所示:

【示例】
// 获取指标图表数据get('/api/v2/experiment/stats/trending', { params: { id: 123, type: "key", period: "day", // or hour from: "2019-12-17", to: "2019-12-18" }})
复制代码

第五步:UI 方面的重新梳理

这一步骤,主要是 UI 方面的重新梳理(页面、布局、组件等等),定义了页面的基本展示形式及输入输出规范:

【示例】
<template> <editable-section @click="onEdit"> <h2 slot="header">分组</h2> <table> <thead> <tr> <th>组别</th> <th>组名</th> <th>变量 {{ experiment.variable.name }} 的值</th> <th>预览图</th> </tr> </thead> <tbody> <tr v-for="group in experiment.groups"> <td rowspan="group.name | toRowspan">group.name | toType</td> <th>group.name</th> <td> <p> {{ group.variableValue }} <small>{{ group.description }}</small> </p> </td> <td> <img src="group.previewImage"/> </td> </tr> </tbody> </table> </editable-section></template>
<script>import BaseSection from 'shared/components/EditableSection'
export default { props: { experiment: Object }, filters: { toType (value) { return value === 'A' ? '对照组' : '实验组' }, toRowspan (value) { return value === 'A' ? 1 : this.experiment.groups.length - 1 } }, methods: { onEdit () { alert('暂未实现,请使用V1。') } }}</script>
复制代码

第六步:定义文件布局

在开始重构前,我们定义 Web 工程的基本文件目录,同时,根据以往经验,我们还提取了常用公共的变量及方法,如下图所示:


shared 文件存用于放公共文件资源,其中,components 文件存放公共组件资源,api.js 文件存放所有的 API URL 资源,styles 文件存放公共 css 文件资源,images 文件存放公共图片,fonts 存放公共字体等。


在 variables.postcss 文件里,定义了一些常用 css 变量,如下所示:

:root {  --font-family--code: cascadia, pingfang sc, microsoft yahei ui light, 微软雅黑, arial, sans-serif;  --font-size--super: 70px;  --font-size--xl: 24px; /* 超大字号 */  --font-size--lg: 18px; /* 大字号 */  --font-size: 14px; /* 常规字号 */  --font-size--sm: 12px; /* 小字号 */  --space: 16px; /* 常规间距,适用于padding及margin */  --space--sm: 12px; /* 小间距 */  --space--xs: 8px; /* 超小间距 */  --color--white: #fff;  --color--black: #000;  --color--subtle: rgba(0, 0, 0, 0.45); /* 非显著颜色 */  --color--message: #93a1a1;  --color--info: #859900;  --color--warning: #b58900;  --color--trace: #657b83;  --color--error: #dc322f;  --color--normal: #268bd2;  --color--lightgrey: #f0f2f5;  --color--active: #1890ff;}
复制代码


在 global.postcss 文件里,对第三方库的样式覆盖及全局样式做了定义,如下所示:

@import './variables.postcss'; @font-face {  font-family: 'Cascadia';  src: url('../fonts/cascadia.ttf');} // 全局样式html,body {  min-width: 1200px;}  .text--description {  color: var(--color--subtle);} .text--mono {  font-family: var(--font-family)} // 第三方样式覆盖.ant-modal-body {  max-height: calc(100vh - 240px);  overflow: auto;} .ant-table-body td {  vertical-align: top;} .ant-table-thead > tr > th {  background: #fafafa !important;} .ant-table-small > .ant-table-content > .ant-table-body {  margin: 0;} .ant-table {  & .ant-empty {    & .ant-empty-description {      display: none;    }     &::after {      content: '目前啥也没有';      display: block;    }  }}
复制代码


而 template.js 文件,用于存放获取 html 模版的方法,如下所示:

import favicon from 'shared/images/favicon.png' function generate ({  ctx, title, ...pageContexts}) {  const prepareDataString = Object.entries(pageContexts)    .map(([key, value]) => `var ${key} = ${typeof value === 'object' ? JSON.stringify(value) : value}\n`)    .join('')   const template = `<!DOCTYPE html>  <html>    <head>      //自定义title      <title>${title ? `${title} | ` : ''}智联实验平台</title>      <link rel='shortcut icon' href='${favicon}' />      // 资源文件占位      ${ctx.template.placeholders.head.style}      ${ctx.template.placeholders.head.link}      ${ctx.template.placeholders.head.script}      <script>        //传入自定义全局页面数据        ${prepareDataString}      </script>    </head>    <body>      // 资源文件占位      ${ctx.template.placeholders.body.root}      ${ctx.template.placeholders.body.script}    </body>  </html>`   return template} export default {  generate}
复制代码


同样的,我们也定义了 Api 工程的文件目录,如下图所示:



shared 用于存放公共文件资源,utils 存放一些公共的方法,models 文件里的文件就是我们前面所整理的数据模型。

第七步:渐进式开发

接下来,我们就可以正式进入下一阶段,进行渐进式重构了。根据项目的难易程度及功能的依赖关联度,对所有页面定义优先级,如下所示:

  1. 框架

  2. 设置

  3. 变量

  4. 世界

  5. 概览

  6. 实验列表

  7. 查看操作记录

  8. 查看统计

  9. 查看基本信息

  10. 控制

  11. 创建实验

  12. 编辑基本信息

从低到高依次进行重构,每一阶段都不破坏现有功能,具备单独发布的能力,定义现版本为 v1,重构版本 v2,在 url 上进行区分,例如 v2/exps。node 层版本 v1 是 js 的,然后,逐步替换成用 ts 写的版本 v2(因为 ts 向下兼容 js,所以工程中可以同时存在);


随着,重构工作的有序进行,我们会发现重构变的越来越得心应手,需要注意的依然是要控制重构范围,完成一个功能测试之后,再开始下一个功能。

第八步:抽离公共组件

不难发现,这一步骤,其实和上一步骤同时进行的,在重构过程中,为了保证代码的简洁、统一、易维护性,我们不断的根据使用场景和功能抽离组件,来保障了代码和界面的统一,哪些场景可以抽离组件呢?

  • 使用超过 3 次的重复代码;

  • 使用场景类似;

  • 逻辑比较接近,总是一起更新的代码;

并遵循以下原则:

  • 保持组件职责单一,高内聚低耦合;

  • 保持参数的配置简单、灵活;

  • 保持颗粒度合适,代码行数适中;

在此基础上,我们抽离了布局,编辑,头像、菜单组件,实验状态等常用组件,大大的减轻了繁琐重复的工作量,如下:

BaseLayout:主框架布局容器,其左侧导航栏、右侧上可嵌套 Header、右侧下可嵌套 section 容器。Header:顶部布局,自带默认样式,左侧标题 Title,右侧操作按钮等。

BaseSection:只读布局容器,其左上可设置标题、下嵌套内容区域。

EditableSection:可编辑布局容器,其左上可设置标题、右上设置操作按钮、下嵌套内容区域。

第九步:统一整体布局、交互体验及提示信息等

随着重构工作的进行,我们需要对 UI 的一些细节做进一步的优化,形成一套统一设计体系:

  • 整体布局,导航及各版块占比排查/调整,例如:

  • 间距

  • 各版块的上下左右间距一致,版块间对齐;

  • 版块内部边距(padding)统一;

  • 相同元素的间距达到统一;

  • 布局

  • 同一组件的位置(比如居左或居右)统一;

  • 采用左侧导航菜单、右侧内容模式布局,使用 BaseLayout 组件;

  • 页面右侧顶部使用 Header 组件;

  • 只读内容模块使用 BaseSection 组件,

  • 带编辑则使用 EditableSection 组件等;

  • 表格

  • 表格数据一律使用紧凑模式;

  • 表格首行锁定、部分表格首列锁定;

  • 对于一次性返回所有数据的表格原则上不使用分页;

  • 对于必须有分页的表格,分页区域应显示在可见区域内;

  • 表格内容主要字段自动列宽,次要内容列宽设置最小宽度;

  • 交互体验,例如:

  • 表单编辑使用 EditableSection 组件,点击右上角“编辑”按钮弹出 modal 框进行编辑;

  • “操作按钮”在 Header 的右侧;

  • 删除操作弹出提示框 PopConfirm 组件;

  • 吐司提示框使用 Tooltip 组件;

  • 统一 message 提示,标题,点击详情展示错误的具体信息;

  • 规范字体,同一级别同一场景的字号,颜色、字体样式统一,例如:

  • 顶部 header 的标题的 font-size: 24px;

  • section 的标题 font-size: 20px;

  • 统计数字、英文变量等内容设置宽字体 text--mono;

  • 描述等辅助文字内容设置样式 text–description;

  1. message 提示信息统一

  • 使用方式;

  • 交互形式;

  • 展示信息;

第十步:渐进上线 v2 页面

为了防止新上线的 v2 页面,在某些情况出现错误无法正常使用时,我们在页面右上角添加了“切换版本“功能,可以快速的切换到 v1 页面,不影响用户正常使用。且在一段时间自测和用户的反馈情况下,逐渐优化 v2 功能。

第十一步:上线全部 v2 版本

所有的 v2 页面和 Api 都全部上线后,通过一周自测和用户使用反馈,不再有使用功能性问题,即可去掉右上角的版本“切换按钮”。

第十二步:删除 v1 版本

运行观察一个月后,v2 版本线上运行稳定,且使用无明显功能问题后,对 v1 页面和代码进行下线操作。自此,重构的大部分工作已经完成。

第十三步:抽离公共组件库

现在重构工作已经接近尾声,实验平台较之前从架构设计、编码规范、布局及用户体验等都有了脱胎换骨的变化。


在宣告重构完成之前,为了进一步统一用户体验、提高系统迭代效率,我们抽离了一个公共组件库,公共组件是与业务无关的,可以服用在其他场景。


由此,AntNest 组件库雏形就产生了,它基于 Ant Design Vue 组件库实现,其中涵盖了布局、容器、卡片、提示、编辑、数据展示等功能的公共组件。并很快发布 v1.0.0 版本,首先在实验平台和 Ada 工作台、魔方管理系统进行应用替换,不断的进行迭代优化,观察一段时间后运行稳定。并在智联的其他内部系统中逐渐推广使用,目前已成功应用到多个系统,如鲲鹏、伏羲、运营平台、性能监控平台等十几个项目中,未来其他内部系统都会替换成 AntNest 组件,形成一套统一管理系统体系,实现真正的统一用户体验。

第十四步:提供工程模版

为了进一步简化开发流程,方便大家快速创建项目,为此,我们还提供了基于 Web 工程和 Api 工程的轻量级模版。


其中,Web 工程模版中基于 AntNest 组件库,除了基本的 Web 框架结构外,我们还在其中内置管理系统常用的概览页(普通概览和瀑布流概览)、列表页、详情页(只读和可编辑)、布局页及错误页等页面,满足用户基本需求。


Api 工程模版是基于 Node.js,并采用强类型检查的 Typescript 开发,预制了和实验平台一致的的文件目录结构,方便大家快速进行开发。

总结

回顾一下整个重构过程,会发现我们做的第一件事情并不是编码,而是对现状进行深入的剖析。在这个过程中,求同存异,一些模式会自然而然地呈现出来,它们都是重构的“素材”。


在真正进行编码时,我们采取了渐进式的策略,将整个过程分解成多个步骤。争取做到每一个步骤完成后,整个模块都能达到发布标准。这就意味着需要把每一步所涉及的改动都限定到一个可控的范围内,并且每个步骤都需要包含完整的测试。


以上就是本次实验平台重构的历程及经验,希望给日后开发新项目或重构老项目提供帮助及借鉴。

发布于: 3 小时前阅读数: 7
用户头像

zhaopin.com 2020.08.04 加入

作为智联招聘的前端架构团队,我们开创了细粒度的前端研发和发布模式,统一了移动端和桌面端的技术栈,搭建了灵活可靠的Serverless运行环境,率先落地了微前端方案,并且还在向FaaS和轻研发等方向不断迈进。

评论

发布
暂无评论
架构团队如何重构内部系统