踩坑實戰(zhàn):如何走出“萬劫不復”的代碼重構深淵?
原創(chuàng)【51CTO.com原創(chuàng)稿件】 Martin Fowler 在其經典著作《重構:改善既有代碼的設計》中給出了下述定義。
“重構是一個改變軟件系統(tǒng)的過程,它旨在不改變代碼外部行為的前提下,改善代碼內部的結構。它用一種規(guī)范的方式來清理代碼,從而***限度地減少錯誤出現的幾率。從本質上說,重構是在代碼寫完之后對其設計的改善。”
我最近有幸參與了一個復雜的代碼重構項目,它是由 React 和 Redux 來構建的一個銷售預算管理與分析的應用系統(tǒng)。
該系統(tǒng)大約由 300 個文件,共計 13000 行代碼所組成。由于能夠定期地接觸到代碼庫,因此我對于在遵從代碼標準的基礎上如何進行代碼改進比較熟悉。
以下是我的最終目標列表:
- 更新文件夾結構,以集中 Redux 的各種文件(actions、reducers、selectors)。
- 將 Foundation 框架轉換為 Semantic UI React。
- 實現 CSS 模塊。
- 升級到 webpack3(一種模塊打包器)。
- 將 Fetch 替換為 jQuery,以處理各種 HTTP 請求(使用 polyfill)。
- 刪除不必要的抽象類和“死代碼”。
- 安裝并配置 Jest 測試框架,編寫各種單元測試。
經過一番周折之后,我最終完成了上述目標。在此,我將自己從該項目以及其他過往項目中所獲取的寶貴經驗與技術分享給大家。
提出正確的問題
在決定開始進行代碼重構項目之前,讓我們先理清一些重要的問題。
首先,捫心自問:我需要重構嗎?
所有程序員都希望能寫出干凈優(yōu)雅的代碼,但是事實并非如此。各種截止日期的臨近和需求的變更,往往只是大量問題的開始而已。
面對龐大的代碼庫,您可能早已喪失了重構的動力。那么請您在閱讀了下列問題并能夠回答“是”之后,再考慮如何開展重構工作吧:
- 是否存在重大的技術缺陷而造成了系統(tǒng)的巨大問題?
- 添加各種新功能是否困難?
- 對于某部分代碼庫的細微更改,是否會破壞應用中另一部分的某個不相關的功能?
- 您是否還使用了那些存在著安全與性能問題的過時依賴關系?
- 在 JavaScript 環(huán)境中,ES5 的語法是否能夠被箭頭函數(arrow functions)和解構(destructuring)等新的語言功能所增強?
我建議您多與同事,特別是該領域的高級工程師討論,而不是盲目開始。他們可能會詳細地說明為什么會以此種方式來編寫代碼,或者給您提供一些能夠影響決斷的有價值的見解。
某位經驗豐富的工程師甚至還會提醒您他們曾在代碼重構中失敗過,因此在某種程度上說這次也不適合再次進行重構。
接下來,問問自己:我能夠重構嗎?
現在您面臨著下一個障礙是:確定重構是否可行。可行的條件包括如下限制因素:
- 根據我目前的技能組合,我能勝任重構嗎?
- 根據我的時間安排,我能勝任重構嗎?
- 重構時,我可以添加新的功能嗎?
- 根據我當前的預算,我能勝任重構嗎?
如果您不能對上述所有的問題回答“Yes”,那么重構可能對您來說就是自尋煩惱了。
我的經驗是:您需要得到軟件產品所有者的批準與支持,因為他們會經常與客戶直接合作并能管理預算,切忌擅自行事!
***,自問自答:我愿意重構嗎?
代碼重構向來是一項巨大的工程。那些嚴苛的預算和時間表往往會給人們帶來難以想象的壓力。
因此,您可能會時常詢問自己如下的問題:
為什么代碼不是在一開始就以正確的方式被編寫呢?
如果應用能夠正常工作的話,何必要重構它們呢?
如果它沒那么糟糕的話,就不能只是添加點新的功能嗎?
這樣做能夠增加企業(yè)的價值嗎?
面對上述問題,代碼重構常被人們誤解為一項吃力不討好的任務。而如果一些現有的功能因為代碼重構而產生中斷的話,那么重構工作就會變得更加“萬劫不復”了。
盡管困難重重、弊端多多,但是在完成了上述“盡職調查”之后,您會發(fā)現代碼重構還是非常值得我們投入寶貴的時間去開展的。
在清理代碼庫的同時,添加良好的單元測試會有益于新功能的輕松添加,以及回歸類錯誤的(regression bug)大幅降低。
制定計劃
“如果沒有計劃,您就是在計劃失敗。”
--本杰明富蘭克林如是說。
一旦開始決定重構某個應用程序,您的***本能應該是:深挖代碼并清理不適合的代碼。然而事實證明:如果沒有一個事先的計劃,您將難逃“劫難”。
在按照時間和預算的范圍制定出行動計劃之前,您先別急著修改代碼。代碼重構的目的是為了讓整個團隊受益于代碼價值的***化。
如果代碼中的某些部分雖然顯得特別混亂,但是能夠提供正常服務的話,那么就沒有必要迅速對它們進行重構。
我們稍后會討論區(qū)分優(yōu)先級的問題,而當前您至少應該先對代碼的潛在價值有所預判。
以下列出了能夠保證您的重構項目正確開始的若干步驟。即使您的重構項目相對較小,我仍然建議您參考并使用下列技術。
選擇一種項目管理工具
無論代碼重構的范圍是大還是小,您都需要用一種工具來跟蹤項目進度。我是 Trello 的忠實粉絲,并認為其“看板(Kanban)”風格界面非常適合于項目管理。
當然,您完全可以挑選自己喜歡的工具,而且***具有對任務進行排序、分組、添加注釋、以及說明的功能。同時,如果它能夠添加附件或創(chuàng)建標簽的話,那就更是錦上添花了。
以我的項目為例,我創(chuàng)建了一個 Trello 看板,并通過如下列表名稱來跟蹤各項任務:
- 積壓工作
- 下一步
- 進行中
- 拉取請求(PullRequest)
- 關閉
我還創(chuàng)建了一個標簽專門用來表示某個任務是否涉及到 React 組件、Redux元素(如 actions、reducers、selectors)、以及應用配置等方面。
如果您和我一樣屬于“視覺系”的,則可以通過給標簽添加顏色,來迅速了解其表征的特性,而不需仔細閱讀其標題或相關說明。
例如,我創(chuàng)建了一個將 webpack 從 1.0 升級到 3.0 的任務,并為 Configuration 標簽設定了特殊顏色,以便我能在面板上快速地識別出來。
查找邏輯上下文
如果代碼量非常大的話,您可以試著將應用程序解構為多個模塊或上下文的關系。
此處“上下文”表示為:隸屬于某個特定業(yè)務實體或是應用程序配置的代碼段。如果您所面對的代碼庫頗為混亂的話,該過程將具有挑戰(zhàn)性。
不過,即使只是粗略地研究與劃分,也會有助于您更進一步熟悉代碼庫,甚至讓您受益匪淺。在多數情況下,您可以根據應用的服務流程來推斷出上下文關系。
例如,一個為牙科診所安排日程表的應用,就可以被分為如下的上下文關系:
- 病人
- 約診
- 用戶導航
- 牙科記錄
- 用戶管理
在實踐中,我們經常會碰到的棘手部分是:如何正確把控粒度。對于我所開發(fā)過的應用而言,我一般會根據 API 的調用和現有 Redux 的 reducer 來確定上下文關系。
我通常會定義出:用戶類上下文、超級用戶類上下文(用于各種管理操作)、應用類上下文(用于 UI 的狀態(tài))、以及其他類型。
注意:不要過于苛求***的上下文關系,這一步的目的只是為了簡化任務創(chuàng)建的過程。
創(chuàng)建任務
您必須創(chuàng)建出具有明確范圍的任務。在此,您可以根據“拉取請求”的方式來考慮范圍。
雖然誰都不想一次性提交具有 5000 行代碼之多的變更,但是在 5000 行代碼中只提交 2 處修改顯然也是遠遠不夠的。
以我曾經參與過的一個重構項目為例,其目標是從 Foundation 框架轉換到語義 UI 的 React,并且實現 CSS 模塊。
請參考:https://foundation.zurb.com/
我最初創(chuàng)建了單一的任務來表示這一轉化,但是我立即意識到了它所牽扯到的巨大工作量。
在該應用中,有著近 100 個 React 組件需要被更新,而我并不想在 Trello 中創(chuàng)建 100 個任務來代表每一個單獨的組件。
在此情況下,我定義了一些簡單的上下文關系:
- 首先,我在每個上下文中創(chuàng)建了不同的任務,來重構與 Redux 相連接的各個容器組件。
- 接下來,我查看了共享的 /components 目錄,并按照表單控件、圖表等類別對它們進行分組。
- ***,我創(chuàng)建了單獨的任務來重構每一組共享的組件。
下圖是我的看板界面截圖,上面包含了一些示例:
當然,您也需要考慮到自己的變更對于應用的影響。如果可能需要恢復到舊的版本,您一定不想在大段的變更代碼中費力地深挖出造成某個 Bug 的原因。
曾經有一次,我為了某個 Bug 而不得不撤銷了自己的絕大部分的更改。在此之后我試著用小塊的程序代碼去進行重構。
為任務排序
如果您愿意的話,可以考慮為任務制定排序或優(yōu)先級的一些標準。當然,如果您已經為每一項任務創(chuàng)建了明確的范圍,那么完成它們的順序也就一目了然了。
我傾向于將具有相似特定功能的任務進行分組處理,例如根據 Redux 的元素或 API 管理來區(qū)分。
當然,如果您是新手的話,我會建議您先去“摘取那些低垂的果實”,即一些僅付出少量的努力就會效果明顯的任務。
持續(xù)更新您的計劃
隨著對于代碼的“深耕”,您可能會發(fā)現在前期重構計劃里不準確的地方,那么請不要猶豫,盡快修正以免覆水難收。
重構代碼庫的好處往往是無形且難以衡量的。哪怕最終不得不終止重構計劃,您的這份詳細的計劃也能為下一次重構的“重啟”提供寶貴的資源和“追蹤”的線索。
測試的重要作用
如果連您都不喜歡測試自己的產品,那么很可能您的客戶也不會樂意試用它。
下面我將引導您來建立一個有效的測試架構。考慮到方便搭建與運行,我使用了Jest 和 Enzyme。
參考:https://facebook.github.io/jest/
http://airbnb.io/enzyme/
您可以根據如下鏈接的指引,將這兩個庫配置到 React/Redux 的應用之中:
- 使用 Jest 和 Enzyme 實施基本組件測試
https://hackernoon.com/implementing-basic-component-tests-using-jest-and-enzyme-d1d8788d627a
- 如何用 Jest 測試 React 的各種組件
https://www.sitepoint.com/test-react-components-jest/
設置模擬數據
如果測試的是現有應用,那么您應該了解現有數據的形態(tài)和使用方式。在大多數情況下,隨著新功能的添加,API 會被微調與改進。
對于我的重構應用項目,我創(chuàng)建了兩個帶有數據的文件:一個帶有API的各種響應,而另一個則帶有 Redux 的狀態(tài)。
您可能需要通過稍許的修改和大量的復制/粘貼來創(chuàng)建這兩個文件,不過這些工作都是一次性的。
產生這兩個數據文件的目的有兩個:
- 首先(也是最明顯的),您需要測試的許多元素都會以某種方式來顯示或操縱數據。
- 其次,通過快速地參考數據的形態(tài),以便有效地確定如何撰寫測試程序,并分析代碼可能出現的問題。
如果所測數據過于敏感的話,有時候存儲 API 的響應與狀態(tài)并不太可行。在此情況下,您可以使用帶有 fakcer 的 json-schema-faker 庫,或 chance 庫來生成隨機數據。
參考:https://www.npmjs.com/package/json-schema-faker
https://www.npmjs.com/package/chance
我建議您一次性生成數據并存儲到庫中,而不是每次在運行測試時都使用其“種子”來生成新的數據。
我將自己的文件存放在__fixtures__文件夾中,目錄結構如下所示:
- /src
- /components
- /constants
- /containers
- /redux
- /__fixtures__
- /state.js
- /responses.js
- /app
- /appActions.js
- /appReducer.js
- /appSelectors.js
- /...
獲取 Redux 整體狀態(tài)的最簡便方法是使用 Redux DevTools 的擴展。
參考:https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en
您可以在狀態(tài)視圖中選擇 Raw 選項卡,復制所有內容,并將其粘貼到一個帶有 module.exports 聲明的 JavaScript 文件中。
我建議您只從狀態(tài)和 API 的響應中獲取小部分的記錄,以減少 Jest 整體快照的大小。而具體保留多少條記錄則完全取決于您自己的判斷。
例如:某個 API 的響應返回了一個包含著 400 條記錄的數組,那么您肯定需要去除其中的絕大部分,以提高測試效率。
值得注意的是:使用有效的數據對于防止回歸錯誤的產生是至關重要的。如果你用到的數據并不能代表應用中真實環(huán)境所用到的內容,那么測試的效果是無法保證重構質量的。
標準化您的測試
您在重構項目中往往需要編寫大量的測試。在剛開始寫測試的時候,我經常會出現命名不統(tǒng)一的情況。
例如:在測試兩個非常相似的組件時,我所用到的 describe 時常不盡相同。同時,標準化您的測試將有利于減少團隊花費在理解測試程序上的時間。
確定測試文件的位置
大部分人喜歡復制整個 src/ 目錄,并將測試文件放在那里。無論您喜歡用 .spec.js 還是用 .test.js 作為測試文件的擴展名,都要注意一致性。
Jest 的默認配置會指定將測試文件放置在 __tests__ 目錄,并以 .test.js 作為擴展名。
一旦您將此作為了標準,請務必添加到自述文件(README)之中,以便將來使用該應用的程序員能夠籍此遵守下去。
建立格式
您應當為每個正在測試的上下文(如:React 組件、Redux 的 selectors 等)建立起“格式/結構”(format/structure)的關系。
例如,我創(chuàng)建的每個 React 組件和容器的測試文件都會在其頂部有一個如下所示的 setup 函數:
- const setup = (propOverrides, renderFn = shallow) => {
- const props = {
- propA: 'Some Value',
- propB: false,
- onClick: jest.fn(),
- ...propOverrides,
- };
- const wrapper = renderFn(<AppComponent {...props} />);
- return { props, wrapper };
- };
這會使得我在測試組件時非常輕松,而無需編寫大量額外的樣板引用。同時,我還為 React 組件建立了一個特定的 describe 塊結構。
- describe('Component A', () => {
- describe('Snapshot validation', () => {
- it('matches its snapshot with valid props', () => {
- const { wrapper } = setup();
- expect(wrapper).toMatchSnapshot();
- });
- });
- describe('Event validation', () => {
- it('fires props.onClick when button is clicked', () => {
- const { wrapper, props } = setup();
- wrapper.find('button').simulate('click');
- expect(props.onClick).toHaveBeenCalled();
- });
- });
- // Note: This is only for connected components.
- describe('Redux validation', () => {
- const store = {
- getState: () => state,
- dispatch: jest.fn(),
- subscribe: () => {},
- };
- it('renders when connected to Redux state', () => {
- const wrapper = shallow(<ComponentA store={store} />);
- expect(wrapper).toHaveLength(1);
- });
- });
- });
對于 Redux 的 actions、reducers 和 selectors,我進行了同樣的操作。根據具體的測試環(huán)境,我還會使用 WebStorm 的文件模板功能,以快速地創(chuàng)建各種測試文件。
參考:https://www.jetbrains.com/help/webstorm/creating-and-editing-file-templates.html
如果您使用的程序編輯器能夠支持代碼片段或文件模板的話,我建議您事先創(chuàng)建好相應的模板,以保持格式上的規(guī)范。同樣,請記得在自述文件中留下簡要的概述說明。
編寫測試
假設您正在著手開始重構 Redux 的 actions、reducer 和 selectors 的 UI 狀態(tài)。由于您已經掌握了相應的狀態(tài)數據,因此編寫測試相對來說會比較簡單。
您只需要使用類似 redux-mock-store 的庫來模擬出用于測試 actions 的狀態(tài)即可。
參考:https://github.com/arnaudbenard/redux-mock-store
請務必在更改任何代碼之前編寫好了所有的測試。通過對重構之后的代碼進行測試,我們能夠發(fā)現一些人為的或無意犯下的錯誤。
而事先保存好快照,則有助于我們發(fā)現那些字段或對象關鍵字上的拼寫錯誤。
您應當對完成了重構的代碼部分,和任何直接受到變更影響的代碼編寫測試程序。
雖說深挖程序間的依賴關系、并編寫出涉及到應用各個方面的測試,會是一項比較繁瑣的工程,但是這會給您提供對于整個代碼庫的深入解析,并為代碼的重構提供更多的整改機會。
應該測試什么?確定測試內容的最簡單和可靠的方式是:代碼覆蓋率。
Jest 具有內置的代碼覆蓋率檢查功能,您可以用來生成帶有覆蓋率百分比的 HTML 報告,以顯示代碼中的哪些部分目前未被測試所覆蓋到。
雖然行覆蓋(Line coverage,查看是否每一行都執(zhí)行了?)會讓您頗有成就感,但是分支覆蓋(branch coverage,查看是否每個 if 代碼塊都執(zhí)行了?)才是您需要去關注的地方。
更多有關不同覆蓋類型的概念,請參考:http://jasonrudolph.com/blog/2008/06/10/a-brief-discussion-of-code-coverage-types/
如何知道自己已經完成了?如前面所述,覆蓋率是評估代碼的某個部分是否通過了測試的***工具。
如果您的某個函數中包含一個 if 的聲明,而它的 else 條件卻沒被測試所覆蓋到,那么就會被覆蓋率報告所指出。
我經常會習慣性地多看幾次函數代碼,并理解其邏輯關系,然后編寫出能夠故意破壞它的測試用例,包括:如果 API 的響應中缺少某個字段會怎么樣?如果響應為空又會發(fā)生什么?
例如,假設有個 selector 能夠加總某個特定區(qū)域里每個銷售員所分配到的預算。而銷售經理則擁有該地區(qū)所有可用的預算總和。
顯然,所有可用預算的總和應當始終大于或等于分配出去的總預算。那么,如果小于的話,會發(fā)生什么?代碼中是否有 if 的聲明來涉及這方面呢?
通過閱讀代碼和編寫測試,您可考慮到更多的極端情況。由此可見,代碼覆蓋率(function coverage)可以反映出每個函數是否被測試所調用到的情況。
重寫代碼
通過上述部分,您應該已經制定出了計劃、選取了相應任務、編寫出了測試,那么是否現在就可以開始打開某個文件、修正變量名稱并清理代碼了呢?
為了確保重構的順利進行,我建議您先熟悉一些基本的概念。下面,我將向您介紹一些在代碼重構過程中的常見錯誤和化繁為簡的技巧。
識別自動化的可能性
您很可能會碰到需要移動并梳理到正確的位置的大量文件。
例如,我曾經在重構一個應用時發(fā)現其 Redux 的 actions、reducers 和 selectors 都分屬于自己單獨的文件夾,而我需要將它們按照模塊(例如 appActions.js、appReducer.js 和 appSelectors.js)進行分類。
因此,我需要運行一條 git mv 的命令,將 /actions/app.js 移動到 /redux/app/appActions.js,并且對于 /reducers/app.js 和 /selectors/app.js 要執(zhí)行相同的操作。
由于該應用項目中有 11 個模塊,因此我必須輸入 33 次 git mv 命令。另外,我還需要再運行 150 次 git mv,以將 React 的容器和組件放置到正確的文件夾位置。
因此,面對如此“崩潰”的任務,我并沒有手動地逐條輸入命令,而是使用 JavaScript 和 Node.js 編寫了一個腳本來實現:
- const fs = require('fs');
- const path = require('path');
- const chalk = require('chalk');
- const sh = require('shelljs');
- const _ = require('lodash');
- const sourcePath = path.resolve(process.cwd(), 'src');
- // This is the new /src/redux folder that gets created:
- const reduxPath = path.resolve(sourcePath, 'redux');
- // I used "entities" instead of "modules", but they represent the same thing:
- const entities = [
- 'app',
- 'projects',
- 'schedules',
- 'users',
- ];
- const createReduxFolders = () => {
- if (!fs.existsSync(reduxPath)) fs.mkdirSync(reduxPath);
- // Code to create entities folders in /src/redux...
- };
- // Executes a `git mv` command (I omitted some additional code that validates
- // if the file already exists for brevity).
- const gitMoveFile = (sourcePath, targetPath) => {
- console.log(chalk.cyan(`Moving ${sourcePath} to ${targetPath}`));
- const command = `git mv ${sourcePath} ${targetPath}`;
- sh.exec(command);
- console.log(chalk.green('Move successful.'));
- };
- const moveReduxFiles = () => {
- entities.forEach(entity => {
- ['actions', 'reducers', 'selectors'].forEach(reduxType => {
- // Get the file associated with the specified entity for the specified reduxType,
- // so the first file might be /src/actions/app.js:
- const sourceFile = path.resolve(sourcePath, reduxType, `${entity}.js`);
- if (fs.existsSync(sourceFile)) {
- // Capitalize the reduxType to append to the file name (e.g. appActions.js):
- const fileSuffix = _.capitalize(reduxType);
- // Build the path to the target file, so this would be /src/redux/app/appActions.js:
- const targetPath = `${reduxPath}/${entity}`;
- const targetFile = `${targetPath}/${entity}${fileSuffix}.js`;
- // Execute a `git mv` command for the file:
- gitMoveFile(sourceFile, targetFile);
- }
- });
- });
- };
- moveReduxFiles();
您既可以通過腳本來自動遷移文件的路徑,也可以通過腳本來直接修改路徑的名稱。
當然,您需要注意投入產出比,不要花費了 20 小時去編寫一個腳本,卻只是節(jié)省了 1 個小時手動工作量。
由于大多數代碼庫、及其結構都相對獨特,因此一般您編寫腳本的復用性都不高。
持續(xù)提交
您所重構的應用程序越多、時間越長,就越難以記住和追蹤那些在不同文件里的細微修改。
而對于各種文件的累計且大量更改,勢必給您的應用測試帶來失敗的風險。因此,無論代碼的修改量大或小、多或少,請記得予以持續(xù)提交。
以某次應用重構為例,我就進行了 1747 次提交,涉及到 659 個文件中的 76080 行代碼,總體占用的存儲空間為 10MB。
另外,在多次且持續(xù)的提交過程中,您可以通過限制每一次更改的內容和文件的數量,以便您能夠隨時按需“跳回”到某一個可靠的“保存點”。
抵御“分心”
請暫時避免清理那些手頭任務范圍之外的代碼,這也是代碼重構過程中最困難的方面之一。
假設您遇到了一個使用 Object.assign() 的 selector,而您的后續(xù)任務之一是更新代碼,使用類似 spread syntax 新的 ESNext 功能。
參考:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
那么請不要偏離當前的任務走向,哪怕只是對該代碼進行微不足道的改變,都不要放在現在進行。
我的經驗是:在相應的代碼處添加了一條 //REFACTOR: Fix this later 的注釋,然后繼續(xù)自己的當前任務。
有關拉取請求的方法論
有時候,您花費了大量的時間去清理一部分代碼庫,而難以后退到過去的某個時間點,或無法對修改后的代碼質量進行評估。
那么拉取請求往往就能夠幫助您和同事來評審這些修改,以確定是否有益于代碼的優(yōu)化:
- 當您在提交拉取請求時,請在摘要處給予盡可能詳細的描述。如果您使用的是 GitHub,那么新的拉取請求會伴隨著一個模板。您可以在其中填寫相應的標題、總體描述、重要章節(jié)的變更列表等各種審評人員所關注的信息。
- 您需要注意的一個指標是:與拉取請求相關的代碼更改數量。請盡量限制更改的代碼行數在 500 行左右。由于 Jest 快照文件的添加會使得拉取請求動輒添加數千行,因此請務必在摘要中包含與之相關的注釋。
- 如果您無法控制改變的數量,那么就請盡量降低其復雜性。
- 如果您只是對重要的聲明語句進行重新排序的話,只要此類更改并不太復雜,超過 2000 行的代碼量還是可以接受的。
- 請將同類變更盡量限定在同一次拉取請求之中。
- 您可以大膽地在注釋中寫上“本次并未做邏輯上的修改”,以節(jié)約審閱者的理解時間。
寫在***
我們從代碼重構項目的實施角度向您提供了:盡可能自動化、持續(xù)提交、抵御“分心”,以及善用拉取請求等方面的建議。
【51CTO原創(chuàng)稿件,合作站點轉載請注明原文作者和出處為51CTO.com】