雲音樂iOS端程式碼靜態檢測實踐

語言: CN / TW / HK

圖片來自:https://unsplash.com
本文作者:塵心

一、前言

隨著專案的擴大,依靠純人工 Code Review 來保障程式碼質量、防止程式碼劣化變得”力不從心“。此時有必要藉助程式碼靜態分析能力,提升專案可持續發展所需要的自動化水平。針對 C、Objective-C 主流的靜態分析開源專案包括:Clang Static Analyzer、Infer、OCLint 等。它們各自特點如下:

對比圖

結合以上分析和對實際應用中可定製性的強烈訴求,最終我們選擇了可定製性最強的 OCLint 作為程式碼靜態檢測工具。接下來將從以下四點介紹 OCLint 的實踐應用過程:

  1. OCLint 環境部署、編譯和分析。
  2. 自定義規則實現。
  3. 靜態檢測耗時優化。
  4. 利用靜態檢測能力持續對啟動效能防劣化控制。

二、OCLint 簡介

上面有對 OCLint 做一個簡單介紹,具體來看其總體結構如下:

oclint結構

Core Module:是 OCLint 的引擎。它會將任務按順序分配給其他模組,驅動整個分析過程,並生成輸出報告。

Metrics Module:是一個獨立的庫。這個模組實際上不依賴於任何其他 OCLint 模組。意思是我們也可以在其他程式碼檢測專案中單獨使用這個模組。

Rules Module:OCLint 是一個基於規則的工具。規則就是動態庫,可以在執行時輕鬆載入到系統中,基於此 OCLint 擁有很強的可擴充套件性。此外,通過遵循開/閉原則,OCLint 可通過動態載入擴充套件規則而不用修改或重新編譯自身,所有規則都作為 RuleBase 的子類實現。

Reporters Module:在分析完成後,對於檢測到的每一個問題,我們都知道節點的詳細資訊、規則、診斷資訊。Reporters 將獲取這些資訊,並將其轉換為可讀的報告。

三、環境部署

3.1 OCLint

brew tap oclint/formulae brew install oclint

上述方法是官方推薦,但安裝的版本並不是最新的,這裡建議使用brew install --cask oclint安裝最新版本。

3.2 xcpretty

是一個格式化 xcodebuild 輸出的工具。

gem install xcpretty

四、輸出編譯產物

環境安裝好後,接下來就可以 clone 工程,準備好全原始碼編譯環境。通過 xcodebuild 與 xcpretty 格式化輸出編譯產物。

在工程目錄下通過終端執行:

xcodebuild -workspace "${project_name}.xcworkspace" -scheme ${scheme} -destination generic/platform=iOS -configuration Debug COMPILER_INDEX_STORE_ENABLE=NO | xcpretty -r json-compilation-database -o compile_commands.json

五、Clang 簡介

由於 OCLint 基於 Clang Tooling,可以簡單的理解為對 Clang Tooling 做了一層封裝,其核心能力是對 Clang AST 進行分析,統計出所有違反規則的程式碼資訊,並輸出分析報告。所以在使用 OCLint 做靜態分析之前,理解 Clang 將大有裨益。

既然核心能力是分析 Clang AST,那麼 Clang AST 到底是什麼樣子的,讓我們一起來看看。

5.1 Clang AST

Clang AST 是編譯前端的中間產物,發生在詞法分析之後的語法分析階段。一個 AST 節點表示宣告、語句、型別,因此,有三個表示 AST 的核心類:Decl、Stmt、Type。在 Clang 中,每個語言結構都必須繼承上述核心類之一。

讓我們來看一個簡單的 AST 示例: ```

include "test.hpp"

int f(int x) { int result = (x / 42); return result; } 在工程目錄下執行 `clang -Xclang -ast-dump -fsyntax-only test.cpp`,輸出 AST: TranslationUnitDecl 0x7f7cb3040408 <> |-TypedefDecl 0x7f7cb3040c70 <> implicit __int128_t '__int128' | -BuiltinType 0x7f7cb30409d0 '__int128' ...-FunctionDecl 0x7f7cb4823f78 line:3:5 f 'int (int)' |-ParmVarDecl 0x7f7cb4823ee0 col:11 used x 'int' -CompoundStmt 0x7f7cb4824198 <col:14, line:6:1> |-DeclStmt 0x7f7cb4824138 <line:4:3, col:24> |-VarDecl 0x7f7cb4824038 col:7 used result 'int' cinit | -ParenExpr 0x7f7cb4824118 <col:16, col:23> 'int' |-BinaryOperator 0x7f7cb48240f8 'int' '/' | |-ImplicitCastExpr 0x7f7cb48240e0 'int' | | -DeclRefExpr 0x7f7cb48240a0 <col:17> 'int' lvalue ParmVar 0x7f7cb4823ee0 'x' 'int' |-IntegerLiteral 0x7f7cb48240c0 'int' 42 -ReturnStmt 0x7f7cb4824188 <line:5:3, col:10>-ImplicitCastExpr 0x7f7cb4824170 'int' `-DeclRefExpr 0x7f7cb4824150 'int' lvalue Var 0x7f7cb4824038 'result' 'int' ```

頂層的 AST 節點是 TranslationUnitDecl。它是其它所有 AST 節點的根,代表整個翻譯單元。FunctionDecl 是函式宣告,CompoundStmt 包含了其他的語句和表示式。 下圖是它的 AST 的圖形檢視:

clang-ast

5.2 遍歷解析 Clang AST

這裡我們可以通過官方教程 《How to write RecursiveASTVisitor based ASTFrontendActions》 來了解這一過程。內容很詳細,就不過多贅述了,大致流程如下圖:

prase-ast

六、OCLint 程式碼靜態分析與輸出

6.1 OCLint 如何工作?

上面我們拿到了編譯產物 compile_commands.json 檔案,並簡單瞭解了 Clang AST 的遍歷解析過程,那 OCLint 是如何工作的呢?我們不妨從 OCLint 原始碼入手,窺探一二。

下面是 oclint/oclint-driver/main.cpp 入口檔案的 main() 函式: ``` int main(int argc, const char **argv) { llvm::cl::SetVersionPrinter(oclintVersionPrinter); //構造 parser auto expectedParser = CommonOptionsParser::create(argc, argv, OCLintOptionCategory); if (!expectedParser) { llvm::errs() << expectedParser.takeError(); return COMMON_OPTIONS_PARSER_ERRORS; } CommonOptionsParser &optionsParser = expectedParser.get(); oclint::option::process(argv[0]);

//準備工作  檢查rule & reporter
int prepareStatus = prepare();
if (prepareStatus)
{
    return prepareStatus;
}

//篩選 rule
if (oclint::option::showEnabledRules())
{
    listRules();
}

//構造 analyzer & driver
oclint::RulesetBasedAnalyzer analyzer(oclint::option::rulesetFilter().filteredRules());
oclint::Driver driver;

//開始分析
try
{
    driver.run(optionsParser.getCompilations(), optionsParser.getSourcePathList(), analyzer);
}
catch (const exception& e)
{
    printErrorLine(e.what());
    return ERROR_WHILE_PROCESSING;
}

//得到分析結果 & 輸出報告
std::unique_ptr<oclint::Results> results(std::move(getResults()));

try
{
    ostream *out = outStream();
    reporter()->report(results.get(), *out);
    disposeOutStream(out);
}
catch (const exception& e)
{
    printErrorLine(e.what());
    return ERROR_WHILE_REPORTING;
}

//退出程式
return handleExit(results.get());

} ``` 看完這個函式我想你應該對 OCLint 分析流程一目瞭然,關於更多實現細節,建議仔細閱讀原始碼。

6.2 使用預設規則分析

compile_commands.json 檔案所在目錄下執行:(oclint-json-compilation-database 是一個幫助程式,可以簡化我們執行 OCLint 程式。)

oclint-json-compilation-database --verbose -report-type html -o oclint.html -max-priority-1 100000 -max-priority-2 100000 -max-priority-3 100000

注意: 執行成功會返回 0,除此之外意味著失敗。例如,當編譯失敗時,返回 3;當違規數量大於閥值時,返回 5;當原始碼有錯誤時,返回 6;

沒錯,分析失敗了。我們來看看原因:

oclint: error: Cannot change dictionary into "${本地檔案路徑,含中文或者特殊字元}", please make sure the directory exists and you have permission to access!

從提示看可能是許可權問題,然而並不是。根因是檔案路徑中包含了中文或特殊字元。想到類似存量問題可能還有很多,寫了個指令碼掃描一下,具體實現如下:

``` import os

rootdir=os.getcwd() if not os.path.isdir(rootdir+'/logout'): os.makedirs(rootdir + '/logout') logPath=os.path.abspath('logout') file_nonstandard_info=open(logPath+'/non_standard_filename.txt','w') file_nonstandard_dirname=open(logPath+'/non_standard_dirname.txt','w')

nor_source_file=['png', 'pdf', 'json', 'jpg', 'webp', 'jpeg', 'gif', 'mp3'] #通用資源型別

symbolList=[] #符號庫

def initSymbolList(): # 標準的符號庫 num="0123456789" word="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" sym="_-+. " for key in word: symbolList.append(key)

for key in num:
    symbolList.append(key)

for key in sym:
    symbolList.append(key)

def runCheck(): for parent,dirnames,filenames in os.walk(this_folder): for dirname in dirnames: if (dirname[0] == '.'): continue dirpath = parent+"/"+dirname totalDirList=[] for value in dirname: totalDirList.append(value) if not set(totalDirList).issubset(symbolList): file_nonstandard_dirname.write(dirpath+'\n') for filename in filenames: if filename.find(".") == -1: continue #過濾資原始檔 if set([filename.split(".")[-1]]).issubset(nor_source_file): continue totalList=[]; tempFilename = filename[0:filename.index('.')] filepath = parent+"/"+filename for value in tempFilename: totalList.append(value) # 判斷檔名是否規範 if not set(totalList).issubset(symbolList): file_nonstandard_info.write(filepath + '\n')

this_folder = input("需要檢測的檔案路徑:").replace("\",'/') initSymbolList() runCheck() ``` 針對上述問題,建議後續做 MR 卡口,防止類似新增問題出現。

上述問題解決後,retry,新問題又來了。

Traceback (most recent call last): File "/usr/local/bin/oclint-json-compilation-database", line 86, in <module> exit_code = subprocess.call(oclint_arguments) File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/subprocess.py", line 340, in call with Popen(*popenargs, **kwargs) as p: File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/subprocess.py", line 858, in __init__ self._execute_child(args, executable, preexec_fn, close_fds, File "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/subprocess.py", line 1704, in _execute_child raise child_exception_type(errno_num, err_msg, err_filename) OSError: [Errno 7] Argument list too long: '/usr/local/bin/oclint

看最後一行,意思是 compile_commands.json 檔案太大了。幸運的是,找到了推薦的解法 - 《oclint_argument_list_too_long_solution》。大概的思路是,把 compile_commands.json 檔案分割成 n 個小 json 檔案,然後迴圈解析得到 n 個解析結果,最後把所有的結果合併成一個大的 oclint.xml。 整體流程如下:

oclintsplit

現在 OCLint 分析鏈路算是初步完成了,預設規則包含的內容非常多,通過分析結果你可以知道專案程式碼中有哪些問題,後續可根據分析報告優化程式碼,提高程式碼質量、減少程式碼缺陷,例如 ObjCVerifyMustCallSuperRule 可以檢測出哪些地方沒有呼叫 super 函式。

但有時候你並不想 care 所有的規則,比如命名規範,函式名超長等規則你可能想忽略它們。此時可以通過 -disable-rule 忽略它們。同時針對特定需求,預設規則可能無法滿足我們的訴求,此時就需要自定義規則了。

七、如何自定義規則?

這裡可以參考 OCLint Documentation,內容非常詳盡。但需要注意以下兩點:

  1. clone 下來的原始碼版本需要與 Homebrew 安裝的版本對齊,不然會因版本相容問題無法使用。
  2. 為了相容 M1,編譯動態庫時需要加上 arm64。

編寫自定義規則前,我們可以先熟悉下已有的規則,可以幫助我們更快更好的掌握。

舉個例子,在進行 App 啟動過耗時分析時,+load 和 App 啟動相關生命週期方法耗時影響佔有一定比重,現在我們需要檢測出專案中所有的 +load 和 App 啟動相關生命週期方法,以便我們改進和優化它們,該如何實現呢?

7.1 +load 規則實現

關鍵程式碼如下: ``` class ObjCVerifyLoadCallRule : public AbstractASTVisitorRule { public: ... //規則優先順序 virtual int priority() const override { return priority; //把priority替換成你想要的優先順序,如 3 }

//override 該方法,這裡可以拿到所有的 OC 方法,我們在此寫邏輯,找出 +load 方法
bool VisitObjCMethodDecl(ObjCMethodDecl *node)
{
    string selectorName = node->getSelector().getAsString(); //拿到方法名
    if (node->isClassMethod() && selectorName == "load") { // 判斷是 +load 方法
        string desc = "xxx(替換成描述文案)";
        //把該節點加到違規集合中
        addViolation(node, this, desc);
        return false;
    }
    return true;
}

} ```

同理,可以完成檢測 App 啟動相關生命週期方法的 Rule。

規則編寫完成後,編譯生成動態庫,複製到 OCLint 的規則路徑下 /usr/local/Caskroom/oclint/22.02/oclint-22.02/lib/oclint/rules

oclintrulse

此時我們可以用oclint -list-enabled-rules x命令簡單的驗證一下規則是否可用,接下來我們拿自定義的規則分析試試。

7.2 指定 Rule 分析

關鍵程式碼如下: def lint(out_file): lint_command = '''oclint-json-compilation-database -- \ --verbose \ -rule ObjCVerifyLoadCall \ -rule NEModuleHubLaunch \ -enable-global-analysis \ -max-priority-{替換成自定義的priority}=100000 \ --report-type pmd \ -o %s''' % (out_file) os.system(lint_command) 我們指定了 ObjCVerifyLoadCall 和 NEModuleHubLaunch 兩個自定義規則,之後按照上述流程就可以輕鬆搞定了。 但因為雲音樂工程編譯產物特別大,導致執行一次完整 OCLint 的時間約 6 個小時。It's too long!該如何優化呢?

八、完整分析耗時優化

梳理流程我們發現耗時主要是以下兩個地方:

  1. 通過 xcodebuild 與 xcpretty 格式化輸出編譯產物,50 分鐘左右。
  2. 分析編譯產物 compile_commands.json,5 個小時左右。

我們來思考下分析編譯產物時有沒有優化的空間呢? 不難發現,上面解決編譯產物大的問題時,通過把大 json,分割成了 n 個小 json,最後迴圈解析得到各自的分析結果。那麼我們是不是可以利用多執行緒/程序的方式,來減少分析時間呢?答案顯而易見。

接下來通過優化指令碼,先嚐試用多執行緒方案去現實,結果命令指令碼確實多執行緒同時觸發了,但是 OCLint 分析依然是 one by one,無奈只能改成多程序的方式。

關鍵程式碼如下: ``` def subProcessLint(): manager = Manager() list = manager.list(lintpy_files) #用於程序間資料同步 sub_p = [] for i in range(process_count): process_name = 'Process------%02d' %(i+1) p = Process(target=lint_subProcess, args=(process_name, list)) sub_p.append(p) p.start() for p in sub_p: p.join()

def lint_subProcess(name, files): while len(files)>0: print('process name is ', name) lint_command = files[0] files.remove(lint_command) start_time = time.time() print('before lint:', lint_command) os.system(r'python3 %s' %lint_command) print("lint time:",time.time()-start_time) ```

需要注意的是,OCLint 分析時預設只識別 compile_commands.json 檔案,所以不能在同一檔案路徑下進行多程序分析。這裡的做法是把上面的子 json 移到新建的檔案目錄下,分析結束後把結果挪回原目錄下,最後進行合併操作。分析時目錄結構如下:

oclintsplitdir

關鍵程式碼如下: ``` import os import sys import shutil

def lint(out_file): lint_command = '''oclint-json-compilation-database -- \ --verbose \ -rule ObjCVerifyLoadCall \ -rule NEModuleHubLaunch \ -enable-global-analysis \ -max-priority-{替換成自定義的priority}=100000 \ --report-type pmd \ -o %s''' % (out_file) os.system(lint_command)

def rename(file_path, new_name): paths = os.path.split(file_path) new_path = os.path.join(paths[0], new_name) os.rename(file_path, new_path) return new_path

dir_path = os.path.dirname(file) #當前目錄 os.chdir(dir_path) #改變當前工作目錄 cur_dir = dir_path.rsplit("/", 1)[1] #資料夾名 out_file = cur_dir+'.xml' json_name = 'compile_commands'+cur_dir[6:]+'.json' rename(os.path.join(dir_path, json_name), 'compile_commands.json')

lint(out_file)

if os.path.isfile(out_file): print (out_file + "is exist") #產物移到上層目錄 shutil.move(os.path.join(os.path.dirname(file), out_file), os.pardir) #刪除當前目錄 shutil.rmtree(dir_path) else: print (out_file + "is not exist") ```

下圖是活動監視器的截圖,可以看到 5 個 OCLint 分析程序。在實際應用時,因為 OCLint 高記憶體與 CPU 消耗,我們把程序數定為了 3 個。

oclintprocess

最終我們通過上述方式,把執行一次完整 OCLint 的時間縮短到 2.5 小時左右,總耗時優化了 58.3%,OCLint 耗時優化了 67.7%。

oclint優化結果

九、其他

上面拿到的 OCLint 分析資料可能有些粗淺,實際應用時可以按需解析,並可結合線上大盤資料對分析結果做深加工,最後生成 html 格式的報告更加方便閱讀。類似下圖:

oclintreport

十、實際案例 - 啟動耗時程式碼檢測

此前雲音樂技術團隊進行了長時間的啟動效能優化專項治理,效果顯著。在此基礎上,如何防止啟動效能優化專項治理成果劣化,成為了下個階段的重中之重。因此我們嘗試利用程式碼靜態檢測能力檢測分析啟動耗時相關方法,如 +load 方法、App 啟動生命週期方法等,目前已上線穩定執行,取得的效果如下:

  1. 檢測到可能的耗時程式碼 600+,涉及業務庫 120+。
  2. 結合上述分析結果,我們預估完成一期治理後,將優化啟動耗時 250ms+。

十一、下一步工作

OCLint 的現狀不算太好,且遠未完成,好在許多方面都在不斷改進,例如準確性、效能和可用性。對於 iOS 開發者來說,Swift 已成為主流,並已在雲音樂部分產品和業務中使用,之後我們會考慮接入生態更好的 SwiftLint。

現階段,雲音樂技術團隊正在積極的搭建和完善自己的程式碼靜態檢測平臺,值得期待。

總結

藉助程式碼靜態檢測能力,能夠及時有效的幫助我們發現問題、保障程式碼質量、防止程式碼劣化、節省人力成本。 OCLint 作為一種靜態程式碼分析工具,致力於提高程式碼質量、減少程式碼缺陷,並被廣泛使用。結合它的高擴充套件性,可定製滿足各種需求,例如檢測啟動耗時程式碼,並通過多程序技術可大大縮短分析時間。業務方也可以輕鬆參與共建,豐富規則倉庫,同時其他產品線也可以參照此案例快速搭建各自的程式碼靜態檢測服務。

參考資料

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!