架構團隊如何重構內部系統

智聯大前端發表於2021-12-02

前端團隊難免需要維護一些內部系統,有些內部系統由於開始的架構設計不合理,隨著業務複雜度的增加,“壞味道”程式碼也越來越多,從而導致認知和溝通成本上升,甚至問題頻出,此時,重構就自然成了一個選擇。但重構不是一時興起,也不是一蹴而就的,需要仔細的分析和有序的實施,以實驗平臺為例,介紹一下智聯大前端的重構經驗。

實驗平臺是智聯招聘自主研發的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】複製請求結果,如下圖所示:
image.png

如下展示了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介面請求的輸入輸出分別進行整理,最後,得到如下檔案列表:
image.png
接下來,分析每個介面的返回值,提取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工程的基本檔案目錄,同時,根據以往經驗,我們還提取了常用公共的變數及方法,如下圖所示:
image.png

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工程的檔案目錄,如下圖所示:
image.png

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的一些細節做進一步的優化,形成一套統一設計體系:

  1. 整體佈局,導航及各版塊佔比排查/調整,例如:

    • 間距
      各版塊的上下左右間距一致,版塊間對齊;
      版塊內部邊距(padding)統一;
      相同元素的間距達到統一;
    • 佈局
      同一元件的位置(比如居左或居右)統一;
      採用左側導航選單、右側內容模式佈局,使用BaseLayout元件;
      頁面右側頂部使用Header元件;
      只讀內容模組使用BaseSection元件,帶編輯則使用EditableSection元件等;
    • 表格
      表格資料一律使用緊湊模式;
      表格首行鎖定、部分表格首列鎖定;
      對於一次性返回所有資料的表格原則上不使用分頁;
      對於必須有分頁的表格,分頁區域應顯示在可見區域內;
      表格內容主要欄位自動列寬,次要內容列寬設定最小寬度;
  2. 互動體驗,例如;

    • 表單編輯使用EditableSection元件,點選右上角“編輯”按鈕彈出modal框進行編輯;
    • “操作按鈕”在Header的右側;
    • 刪除操作彈出提示框PopConfirm元件;
    • 吐司提示框使用Tooltip元件;
    • 統一message 提示,標題,點選詳情展示錯誤的具體資訊;
  3. 規範字型,同一級別同一場景的字號,顏色、字型樣式統一,例如:

    • 頂部 header的標題的font-size: 24px;
    • section的標題font-size: 20px;
    • 統計數字、英文變數等內容設定寬字型text--mono;
    • 描述等輔助文字內容設定樣式text–description;
  4. 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開發,預製了和實驗平臺一致的的檔案目錄結構,方便大家快速進行開發。

總結

回顧一下整個重構過程,會發現我們做的第一件事情並不是編碼,而是對現狀進行深入的剖析。在這個過程中,求同存異,一些模式會自然而然地呈現出來,它們都是重構的“素材”。

在真正進行編碼時,我們採取了漸進式的策略,將整個過程分解成多個步驟。爭取做到每一個步驟完成後,整個模組都能達到釋出標準。這就意味著需要把每一步所涉及的改動都限定到一個可控的範圍內,並且每個步驟都需要包含完整的測試。

以上就是本次實驗平臺重構的歷程及經驗,希望給日後開發新專案或重構老專案提供幫助及借鑑。

相關文章