寫程序時時刻記着,這個未來要維護你寫的程序的人是一個有嚴重傾向,而且知道你住在哪裏的精神變態者。node
重構一個項目還不如新開發一個項目...
這代碼是誰寫的,我真想...
大家的項目中是否也存在下面的問題?git
單個項目也愈來愈龐大,團隊成員代碼風格不一致,沒法對總體的代碼質量作全面的掌控
沒有一個準確的標準去衡量代碼結構複雜的程度,沒法量化一個項目的代碼質量
重構代碼後沒法當即量化重構後代碼質量是否提高
針對上面的問題,本文的主角 圈複雜度 重磅登場,本文將從圈複雜度原理出發,介紹圈複雜度的計算方法、如何下降代碼的圈複雜度,如何獲取圈複雜度,以及圈複雜度在公司項目的實踐應用。github
圈複雜度 在 1976 年由 Thomas J. McCabe, Sr. 提出。算法
圈複雜度大說明程序代碼的判斷邏輯複雜,可能質量低且難於測試和維護。程序的可能錯誤和高的圈複雜度有着很大關係。npm
2.2 衡量標準
代碼複雜度低,代碼不必定好,但代碼複雜度高,代碼必定很差。數組
圈複雜度 代碼情況 可測性 維護成本
1 - 10 清晰、結構化 高 低
10 - 20 複雜 中 中
20 - 30 很是複雜 低 高markdown
30 不可讀 不可測 很是高數據結構
- 計算方法
3.1 控制流程圖
控制流程圖,是一個過程或程序的抽象表現,是用在編譯器中的一個抽象數據結構,由編譯器在內部維護,表明了一個程序執行過程當中會遍歷到的全部路徑。它用圖的形式表示一個過程內全部基本塊執行的可能流向, 也能反映一個過程的實時執行過程。
下面是一些常見的控制流程:架構
3.2 節點斷定法
有一個簡單的計算方法,圈複雜度實際上就是等於斷定節點的數量再加上1。向上面提到的:if else 、switch case 、 for循環、三元運算符等等,都屬於一個斷定節點,例以下面的代碼:dom
function testComplexity(param) {
let result = 1;
if (param > 0) {
result--;
}
for (let i = 0; i < 10; i++) {
result += Math.random();
}
switch (parseInt(result)) {
case 1:
result += 20;
break;
case 2:
result += 30;
break;
default:
result += 10;
break;
}
return result > 20 ? result : result;
}
複製代碼
上面的代碼中一共有1個if語句,一個for循環,兩個case語句,一個三元運算符,因此代碼複雜度爲 1+2+1+1+1=6。另外,須要注意的是 || 和 && 語句也會被算做一個斷定節點,例以下面代碼的代碼複雜爲3:
function testComplexity(param) {
let result = 1;
if (param > 0 && param < 10) {
result--;
}
return result;
}
複製代碼
3.3 點邊計算法
M = E − N + 2P
複製代碼
E:控制流圖中邊的數量
N:控制流圖中的節點數量
P:獨立組件的數目
前兩個,邊和節點都是數據結構圖中最基本的概念:
P表明圖中獨立組件的數目,獨立組件是什麼意思呢?來看看下面兩個圖,左側爲連通圖,右側爲非連通圖:
連通圖:對於圖中任意兩個頂點都是連通的
一個連通圖即爲圖中的一個獨立組件,因此左側圖中獨立組件的數目爲1,右側則有兩個獨立組件。
對於咱們的代碼轉化而來的控制流程圖,正常狀況下全部節點都應該是連通的,除非你在某些節點以前執行了 return,顯然這樣的代碼是錯誤的。因此每一個程序流程圖的獨立組件的數目都爲1,因此上面的公式還能夠簡化爲 M = E − N + 2 。
重構需謹慎,示例代碼僅僅表明一種思想,實際代碼要遠遠比示例代碼複雜的多。
4.1 抽象配置
經過抽象配置將複雜的邏輯判斷進行簡化。例以下面的代碼,根據用戶的選擇項執行相應的操做,重構後下降了代碼複雜度,而且若是以後有新的選項,直接加入配置便可,而不須要再去深刻代碼邏輯中進行改動:
4.2 單一職責 - 提煉函數
單一職責原則(SRP):每一個類都應該有一個單一的功能,一個類應該只有一個發生變化的緣由。
在 JavaScript 中,須要用到的類的場景並不太多,單一職責原則則是更多地運用在對象或者方法級別上面。
函數應該作一件事,作好這件事,只作這一件事。 — 代碼整潔之道
關鍵是如何定義這 「一件事」 ,如何將代碼中的邏輯進行抽象,有效的提煉函數有利於下降代碼複雜度和下降維護成本。
4.3 使用 break 和 return 代替控制標記
咱們常常會使用一個控制標記來標示當前程序運行到某一狀態,不少場景下,使用 break 和 return 能夠代替這些標記並下降代碼複雜度。
4.4 用函數取代參數
setField 和 getField 函數就是典型的函數取代參數,若是麼有 setField、getField 函數,咱們可能須要一個很複雜的 setValue、getValue 來完成屬性賦值操做:
4.5 簡化條件判斷 - 逆向條件
某些複雜的條件判斷可能逆向思考後會變的更簡單。
4.6 簡化條件判斷 -合併條件
將複雜冗餘的條件判斷進行合併。
4.7 簡化條件判斷 - 提取條件
將複雜難懂的條件進行語義化提取。
咱們將開啓 rules 中的 complexity 規則,並將圈複雜度大於 0 的代碼的 rule severity 設置爲 warn 或 error 。
rules: { complexity: [ 'warn', { max: 0 } ] }
複製代碼
這樣 eslint 就會自動檢測出全部函數的代碼複雜度,並輸出一個相似下面的 message。
Method 'testFunc' has a complexity of 12. Maximum allowed is 0
Async function has a complexity of 6. Maximum allowed is 0.
...
複製代碼
5.2 CLIEngine
咱們能夠藉助 eslint 的 CLIEngine ,在本地使用自定義的 eslint 規則掃描代碼,並獲取掃描結果輸出。
初始化 CLIEngine :
const eslint = require('eslint');
const { CLIEngine } = eslint;
const cli = new CLIEngine({
parserOptions: {
ecmaVersion: 2018,
},
rules: {
complexity: [
'error',
{ max: 0 }
]
}
});
複製代碼
使用 executeOnFiles 對指定文件進行掃描,並獲取結果,過濾出全部 complexity 的 message 信息。
const reports = cli.executeOnFiles(['.']).results;
for (let i = 0; i < reports.length; i++) {
const { messages } = reports[i];
for (let j = 0; j < messages.length; j++) {
const { message, ruleId } = messages[j];
if (ruleId === 'complexity') {
console.log(message);
}
}
}
複製代碼
5.3 提取message
經過 eslint 的檢測結果將有用的信息提取出來,先測試幾個不一樣類型的函數,看看 eslint 的檢測結果:
function func1() {
console.log(1);
}
const func2 = () => {
console.log(2);
};
class TestClass {
func3() {
console.log(3);
}
}
async function func4() {
console.log(1);
}
複製代碼
執行結果:
Function 'func1' has a complexity of 1. Maximum allowed is 0.
Arrow function has a complexity of 1. Maximum allowed is 0.
Method 'func3' has a complexity of 1. Maximum allowed is 0.
Async function 'func4' has a complexity of 1. Maximum allowed is 0.
複製代碼
能夠發現,除了前面的函數類型,以及後面的複雜度,其餘都是相同的。
函數類型:
Function :普通函數
Arrow function : 箭頭函數
Method : 類方法
Async function : 異步函數
截取方法類型:
const REG_FUNC_TYPE = /^(Method |Async function |Arrow function |Function )/g;
function getFunctionType(message) {
let hasFuncType = REG_FUNC_TYPE.test(message);
return hasFuncType && RegExp.$1;
}
複製代碼
將有用的部分提取出來:
const MESSAGE_PREFIX = 'Maximum allowed is 1.';
const MESSAGE_SUFFIX = 'has a complexity of ';
function getMain(message) {
return message.replace(MESSAGE_PREFIX, '').replace(MESSAGE_SUFFIX, '');
}
複製代碼
提取方法名稱:
function getFunctionName(message) {
const main = getMain(message);
let test = /'([a-zA-Z0-9_$]+)'/g.test(main);
return test ? RegExp.$1 : '*';
}
複製代碼
截取代碼複雜度:
function getComplexity(message) {
const main = getMain(message);
(/(\d+)./g).test(main);
return +RegExp.$1;
}
複製代碼
除了 message ,還有其餘的有用信息:
函數位置:獲取 messages 中的 line 、column 即函數的行、列位置
當前文件名稱:reports 結果中能夠獲取當前掃描文件的絕對路徑 filePath ,經過下面的操做獲取真實文件名:
filePath.replace(process.cwd(), '').trim()
複製代碼
複雜度等級,根據函數的複雜度等級給出重構建議:
圈複雜度 代碼情況 可測性 維護成本
1 - 10 清晰、結構化 高 低
10 - 20 複雜 中 中
20 - 30 很是複雜 低 高
30 不可讀 不可測 很是高
圈複雜度 代碼情況
1 - 10 無需重構
11 - 15 建議重構
15 強烈建議重構
6.架構設計
將代碼複雜度檢測封裝成基礎包,根據自定義配置輸出檢測數據,供其餘應用調用。
上面的展現了使用 eslint 獲取代碼複雜度的思路,下面咱們要把它封裝爲一個通用的工具,考慮到工具可能在不一樣場景下使用,例如:網頁版的分析報告、cli版的命令行工具,咱們把通用的能力抽象出來以 npm包 的形式供其餘應用使用。
在計算項目代碼複雜度以前,咱們首先要具有一項基礎能力,代碼掃描,即咱們要知道咱們要對項目裏的哪些文件作分析,首先 eslint 是具有這樣的能力的,咱們也能夠直接用 glob 來遍歷文件。可是他們都有一個缺點,就是 ignore 規則是不一樣的,這對於用戶來說是有必定學習成本的,所以我這裏把手動封裝代碼掃描,使用通用的 npm ignore 規則,這樣代碼掃描就能夠直接使用 .gitignore這樣的配置文件。另外,代碼掃描做爲代碼分析的基礎能力,其餘代碼分析也是能夠公用的。
基礎能力
代碼掃描能力
複雜度檢測能力
...
應用
命令行工具
代碼分析報告
...
awesome-cli 是我新建的一個開源項目:有趣又實用的命令行工具,後面會持續維護,敬請關注,歡迎 star。
代碼掃描(c-scan)源碼:github.com/ConardLi/aw…
代碼掃描是代碼分析的底層能力,它主要幫助咱們拿到咱們想要的文件路徑,應該知足咱們如下兩個需求:
我要獲得什麼類型的文件
我不想要哪些文件
7.1 使用
npm i c-scan --save
const scan = require('c-scan');
scan({
extensions:'*/.js',
rootPath:'src',
defalutIgnore:'true',
ignoreRules:[],
ignoreFileName:'.gitignore'
});
複製代碼
7.2 返回值
符合規則的文件路徑數組:
7.3 參數
extensions
掃描文件擴展名
默認值:*/.js
rootPath
掃描文件路徑
默認值:.
defalutIgnore
是否開啓默認忽略(glob規則)
glob ignore規則爲內部使用,爲了統一ignore規則,自定義規則使用gitignore規則
默認值:true
默認開啓的 glob ignore 規則:
const DEFAULT_IGNORE_PATTERNS = [
'node_modules/',
'build/',
'dist/',
'output/',
'common_build/**'
];
複製代碼
ignoreRules
自定義忽略規則(gitignore規則)
默認值:[]
ignoreFileName
自定義忽略規則配置文件路徑(gitignore規則)
默認值:.gitignore
指定爲null則不啓用ignore配置文件
7.4 核心實現
基於 glob ,自定義 ignore 規則進行二次封裝。
/**
${rootPath}${extensions}
,/**
/**
代碼檢測基礎包應該具有如下幾個能力:
自定義掃描文件夾和類型
支持忽略文件
定義最小提醒代碼複雜度
8.1 使用
npm i c-complexity --save
const cc = require('c-complexity');
cc({},10);
複製代碼
8.2 返回值
fileCount:文件數量
funcCount:函數數量
result:詳細結果
funcType:函數類型
funcName;函數名稱
position:詳細位置(行列號)
fileName:文件相對路徑
complexity:代碼複雜度
advice:重構建議
8.3 參數
scanParam
繼承自上面代碼掃描的參數
min
最小提醒代碼複雜度,默認爲1
9.1 指定最小提醒複雜度
能夠觸發提醒的最小複雜度。
默認爲 10
經過命令 conard cc --min=5 自定義
9.2 指定掃描參數
自定義掃描規則
掃描參數繼承自上面的 scan param
例如: conard cc --defalutIgnore=false
代碼複雜複雜度變化趨勢
定時任務爬取代碼每日的代碼複雜度、代碼行數、函數個數,經過每日數據繪製代碼複雜度和代碼行數變化趨勢折線圖。
經過 [ 複雜度 / 代碼行數 ] 或 [ 複雜度 / 函數個數 ] 的變化趨勢,判斷項目發展是否健康。
比值若一直在上漲,說明你的代碼在變得愈來愈難以理解。這不只使咱們面臨意外的功能交互和缺陷的風險,因爲咱們在具備或多或少相關功能的模塊中所面臨的過多認知負擔,也很難重用代碼並進行修改和測試。(下圖1)
若比值在某個階段發生突變,說明這段期間迭代質量不好。(下圖2)
複雜度曲線圖能夠很快的幫你更早的發現上面這兩個問題,發現它們後,你可能須要重構代碼。複雜性趨勢對於跟蹤你的代碼重構也頗有用。複雜性趨勢的降低趨勢是一個好兆頭。這要麼意味着您的代碼變得更簡單(例如,把 if-else 被重構爲多態解決方案),要麼代碼更少(將不相關的部分提取到了其餘模塊中)。(下圖3)
代碼重構後,你還須要繼續探索複雜度變化趨勢。常常發生的事情是,咱們花費大量的時間和精力來重構,沒法解決根本緣由,很快複雜度又滑回了原處。(下圖4)你可能以爲這是個例,可是有研究標明,在分析了數百個代碼庫後,發現出現這種狀況的頻率很高。所以,時刻觀察代碼複雜度變化趨勢是有必要的。
代碼複雜度文件分佈
統計各複雜度分佈的函數數量。
代碼複雜度文件詳情
計算每一個函數的代碼複雜度,從高到低依次列出高複雜度的文件分佈,並給出重構建議。
實際開發中並不必定全部的代碼都須要被分析,例如打包產物、靜態資源文件等等,這些文件每每會誤導咱們的分析結果,如今分析工具會默認忽略一些規則,例如:.gitignore文件、static目錄等等,實際這些規則還須要根據實際項目的狀況去不斷完善,使分析結果變得更準確。