寫程式時時刻記著,這個將來要維護你寫的程式的人是一個有嚴重暴力傾向,並且知道你住在哪裡的精神變態者。
1. 導讀
你們是否也有過下面的想法?
- 重構一個專案還不如新開發一個專案...
- 這程式碼是誰寫的,我真想...
你們的專案中是否也存在下面的問題?
- 單個專案也越來越龐大,團隊成員程式碼風格不一致,無法對整體的程式碼質量做全面的掌控
- 沒有一個準確的標準去衡量代 碼結構複雜的程度,無法量化一個專案的程式碼質量
- 重構程式碼後無法立即量化重構後程式碼質量是否提升
針對上面的問題,本文的主角 圈複雜度
重磅登場,本文將從圈複雜度原理出發,介紹圈複雜度的計算方法、如何降低程式碼的圈複雜度,如何獲取圈複雜度,以及圈複雜度在公司專案的實踐應用。
2. 圈複雜度
2.1 定義
圈複雜度 (Cyclomatic complexity) 是一種程式碼複雜度的衡量標準,也稱為條件複雜度或迴圈複雜度,它可以用來衡量一個模組判定結構的複雜程度,數量上表現為獨立現行路徑條數,也可理解為覆蓋所有的可能情況最少使用的測試用例數。簡稱 CC 。其符號為 VG 或是 M 。
圈複雜度 在 1976 年由 Thomas J. McCabe, Sr. 提出。
圈複雜度大說明程式程式碼的判斷邏輯複雜,可能質量低且難於測試和維護。程式的可能錯誤和高的圈複雜度有著很大關係。
2.2 衡量標準
程式碼複雜度低,程式碼不一定好,但程式碼複雜度高,程式碼一定不好。
圈複雜度 | 程式碼狀況 | 可測性 | 維護成本 |
---|---|---|---|
1 - 10 | 清晰、結構化 | 高 | 低 |
10 - 20 | 複雜 | 中 | 中 |
20 - 30 | 非常複雜 | 低 | 高 |
>30 | 不可讀 | 不可測 | 非常高 |
3. 計算方法
3.1 控制流程圖
控制流程圖,是一個過程或程式的抽象表現,是用在編譯器中的一個抽象資料結構,由編譯器在內部維護,代表了一個程式執行過程中會遍歷到的所有路徑。它用圖的形式表示一個過程內所有基本塊執行的可能流向, 也能反映一個過程的實時執行過程。
下面是一些常見的控制流程:
3.2 節點判定法
有一個簡單的計算方法,圈複雜度實際上就是等於判定節點的數量再加上1。向上面提到的:if else
、switch case
、 for
迴圈、三元運算子等等,都屬於一個判定節點,例如下面的程式碼:
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
語句,一個三元運算子,所以程式碼複雜度為 4+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. 降低程式碼的圈複雜度
我們可以通過一些程式碼重構手段來降低程式碼的圈複雜度。
重構需謹慎,示例程式碼僅僅代表一種思想,實際程式碼要遠遠比示例程式碼複雜的多。
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 簡化條件判斷 - 提取條件
將複雜難懂的條件進行語義化提取。
5. 圈複雜度檢測方法
5.1 eslint規則
eslint
提供了檢測程式碼圈複雜度的rules
:
我們將開啟 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
這樣的配置檔案。另外,程式碼掃描作為程式碼分析的基礎能力,其他程式碼分析也是可以公用的。
-
基礎能力
- 程式碼掃描能力
- 複雜度檢測能力
- ...
-
應用
- 命令列工具
- 程式碼分析報告
- ...
7. 基礎能力 - 程式碼掃描
本文涉及的 npm
包和 cli
命令原始碼均可在我的開源專案 awesome-cli中檢視。
awesome-cli 是我新建的一個開源專案:有趣又實用的命令列工具,後面會持續維護,敬請關注,歡迎 star。
程式碼掃描(c-scan
)原始碼:https://github.com/ConardLi/a...
程式碼掃描是程式碼分析的底層能力,它主要幫助我們拿到我們想要的檔案路徑,應該滿足我們以下兩個需求:
- 我要得到什麼型別的檔案
- 我不想要哪些檔案
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
規則進行二次封裝。
/**
* 獲取glob掃描的檔案列表
* @param {*} rootPath 跟路徑
* @param {*} extensions 擴充套件
* @param {*} defalutIgnore 是否開啟預設忽略
*/
function getGlobScan(rootPath, extensions, defalutIgnore) {
return new Promise(resolve => {
glob(`${rootPath}${extensions}`,
{ dot: true, ignore: defalutIgnore ? DEFAULT_IGNORE_PATTERNS : [] },
(err, files) => {
if (err) {
console.log(err);
process.exit(1);
}
resolve(files);
});
});
}
/**
* 載入ignore配置檔案,並處理成陣列
* @param {*} ignoreFileName
*/
async function loadIgnorePatterns(ignoreFileName) {
const ignorePath = path.resolve(process.cwd(), ignoreFileName);
try {
const ignores = fs.readFileSync(ignorePath, 'utf8');
return ignores.split(/[\n\r]|\n\r/).filter(pattern => Boolean(pattern));
} catch (e) {
return [];
}
}
/**
* 根據ignore配置過濾檔案列表
* @param {*} files
* @param {*} ignorePatterns
* @param {*} cwd
*/
function filterFilesByIgnore(files, ignorePatterns, ignoreRules, cwd = process.cwd()) {
const ig = ignore().add([...ignorePatterns, ...ignoreRules]);
const filtered = files
.map(raw => (path.isAbsolute(raw) ? raw : path.resolve(cwd, raw)))
.map(raw => path.relative(cwd, raw))
.filter(filePath => !ig.ignores(filePath))
.map(raw => path.resolve(cwd, raw));
return filtered;
}
8. 基礎能力 - 程式碼複雜度檢測
程式碼複雜度檢測(c-complexity
)原始碼:https://github.com/ConardLi/a...
程式碼檢測基礎包應該具備以下幾個能力:
- 自定義掃描資料夾和型別
- 支援忽略檔案
- 定義最小提醒程式碼複雜度
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. 應用 - 程式碼複雜度檢測工具
程式碼複雜度檢測(conard cc
)原始碼:https://github.com/ConardLi/a...
9.1 指定最小提醒複雜度
可以觸發提醒的最小複雜度。
- 預設為
10
- 通過命令
conard cc --min=5
自定義
9.2 指定掃描引數
自定義掃描規則
- 掃描引數繼承自上面的
scan param
- 例如:
conard cc --defalutIgnore=false
10. 應用 - 程式碼複雜度報告
部分截圖來源於我們內部的專案質量監控平臺,圈複雜度作為一項重要的指標,對於衡量專案程式碼質量起著至關重要的作用。
程式碼複雜複雜度變化趨勢
定時任務爬取程式碼每日的程式碼複雜度、程式碼行數、函式個數,通過每日資料繪製程式碼複雜度和程式碼行數變化趨勢折線圖。
通過 [ 複雜度 / 程式碼行數 ] 或 [ 複雜度 / 函式個數 ] 的變化趨勢,判斷專案發展是否健康。
- 比值若一直在上漲,說明你的程式碼在變得越來越難以理解。這不僅使我們面臨意外的功能互動和缺陷的風險,由於我們在具有或多或少相關功能的模組中所面臨的過多認知負擔,也很難重用程式碼並進行修改和測試。(下圖1)
- 若比值在某個階段發生突變,說明這段期間迭代質量很差。(下圖2)
- 複雜度曲線圖可以很快的幫你更早的發現上面這兩個問題,發現它們後,你可能需要重構程式碼。複雜性趨勢對於跟蹤你的程式碼重構也很有用。複雜性趨勢的下降趨勢是一個好兆頭。這要麼意味著您的程式碼變得更簡單(例如,把 if-else 被重構為多型解決方案),要麼程式碼更少(將不相關的部分提取到了其他模組中)。(下圖3)
- 程式碼重構後,你還需要繼續探索複雜度變化趨勢。經常發生的事情是,我們花費大量的時間和精力來重構,無法解決根本原因,很快複雜度又滑回了原處。(下圖4)你可能覺得這是個例,但是有研究標明,在分析了數百個程式碼庫後,發現出現這種情況的頻率很高。因此,時刻觀察程式碼複雜度變化趨勢是有必要的。
程式碼複雜度檔案分佈
統計各複雜度分佈的函式數量。
程式碼複雜度檔案詳情
計算每個函式的程式碼複雜度,從高到低依次列出高複雜度的檔案分佈,並給出重構建議。
實際開發中並不一定所有的程式碼都需要被分析,例如打包產物、靜態資原始檔等等,這些檔案往往會誤導我們的分析結果,現在分析工具會預設忽略一些規則,例如:.gitignore檔案、static目錄等等,實際這些規則還需要根據實際專案的情況去不斷完善,使分析結果變得更準確。
參考
文章開頭小丑圖片來源於網路,如有侵權請聯絡我刪除,其餘圖片均為本人原創圖片。
小結
希望看完本篇文章能對你有如下幫助:
- 理解圈複雜度的意義和計算方法
- 在專案中能實際應用圈複雜度提升專案質量
文中如有錯誤,歡迎在評論區指正,如果這篇文章幫助到了你,歡迎點贊和關注。
本文涉及的 npm
包和 cli
命令原始碼均可在我的開源專案 awesome-cli中檢視。
想閱讀更多優質文章、可關注我的github部落格,你的star✨、點贊和關注是我持續創作的動力!
推薦關注我的微信公眾號【code祕密花園】,每天推送高質量文章,我們一起交流成長。