图片来自:unsplash.com
本文作者:尘心

一、前言

跟着项目的扩大,依托纯人工 Code Review 来保障代码质量、避免代码劣化变得”力不从心“。此刻有必要凭借代码静态剖析才能,提升项目可持续发展所需求的自动化水平。针对 C、Objective-C 干流的静态剖析开源项目包括:Clang Static Analyzer、Infer、OCLint 等。它们各自特点如下:

云音乐iOS端代码静态检测实践

结合以上剖析和对实践运用中可定制性的强烈诉求,终究咱们选择了可定制性最强的 OCLint 作为代码静态检测东西。接下来将从以下四点介绍 OCLint 的实践运用进程:

  1. OCLint 环境布置、编译和剖析。
  2. 自定义规矩完成。
  3. 静态检测耗时优化。
  4. 运用静态检测才能持续对发动功能防劣化控制。

二、OCLint 简介

上面有对 OCLint 做一个简略介绍,具体来看其总体结构如下:

云音乐iOS端代码静态检测实践

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 <<invalid sloc>> <invalid sloc>
|-TypedefDecl 0x7f7cb3040c70 <<invalid sloc>> <invalid sloc> implicit __int128_t '__int128'
| `-BuiltinType 0x7f7cb30409d0 '__int128'
...
`-FunctionDecl 0x7f7cb4823f78 <test.cpp:3:1, line:6:1> line:3:5 f 'int (int)'
  |-ParmVarDecl 0x7f7cb4823ee0 <col:7, col:11> col:11 used x 'int'
  `-CompoundStmt 0x7f7cb4824198 <col:14, line:6:1>
    |-DeclStmt 0x7f7cb4824138 <line:4:3, col:24>
    | `-VarDecl 0x7f7cb4824038 <col:3, col:23> col:7 used result 'int' cinit
    |   `-ParenExpr 0x7f7cb4824118 <col:16, col:23> 'int'
    |     `-BinaryOperator 0x7f7cb48240f8 <col:17, col:21> 'int' '/'
    |       |-ImplicitCastExpr 0x7f7cb48240e0 <col:17> 'int' <LValueToRValue>
    |       | `-DeclRefExpr 0x7f7cb48240a0 <col:17> 'int' lvalue ParmVar 0x7f7cb4823ee0 'x' 'int'
    |       `-IntegerLiteral 0x7f7cb48240c0 <col:21> 'int' 42
    `-ReturnStmt 0x7f7cb4824188 <line:5:3, col:10>
      `-ImplicitCastExpr 0x7f7cb4824170 <col:10> 'int' <LValueToRValue>
        `-DeclRefExpr 0x7f7cb4824150 <col:10> 'int' lvalue Var 0x7f7cb4824038 'result' 'int'

顶层的 AST 节点是 TranslationUnitDecl。它是其它一切 AST 节点的根,代表整个翻译单元。FunctionDecl 是函数声明,CompoundStmt 包含了其他的句子和表达式。 下图是它的 AST 的图形视图:

云音乐iOS端代码静态检测实践

5.2 遍历解析 Clang AST

这儿咱们能够经过官方教程 《How to write RecursiveASTVisitor based ASTFrontendActions》 来了解这一进程。内容很具体,就不过多赘述了,大致流程如下图:

云音乐iOS端代码静态检测实践

六、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。 全体流程如下:

云音乐iOS端代码静态检测实践

现在 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<ObjCVerifyLoadCallRule>
{
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

云音乐iOS端代码静态检测实践

此刻咱们能够用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 移到新建的文件目录下,剖析完毕后把成果挪回原目录下,终究进行合并操作。剖析时目录结构如下:

云音乐iOS端代码静态检测实践

要害代码如下:

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 个。

云音乐iOS端代码静态检测实践

终究咱们经过上述办法,把运转一次完好 OCLint 的时刻缩短到 2.5 小时左右,总耗时优化了 58.3%,OCLint 耗时优化了 67.7%。

云音乐iOS端代码静态检测实践

九、其他

上面拿到的 OCLint 剖析数据或许有些粗浅,实践运用时能够按需解析,并可结合线上大盘数据对剖析成果做深加工,终究生成 html 格式的陈述愈加方便阅览。相似下图:

云音乐iOS端代码静态检测实践

十、实践事例 – 发动耗时代码检测

此前云音乐技能团队进行了长时刻的发动功能优化专项管理,效果显著。在此基础上,怎么避免发动功能优化专项管理成果劣化,成为了下个阶段的重中之重。因而咱们测验运用代码静态检测才能检测剖析发动耗时相关办法,如 +load 办法、App 发动生命周期办法等,现在已上线稳定运转,取得的效果如下:

  1. 检测到或许的耗时代码 600+,涉及事务库 120+。
  2. 结合上述剖析成果,咱们预估完成一期管理后,将优化发动耗时 250ms+。

十一、下一步作业

OCLint 的现状不算太好,且远未完成,好在许多方面都在不断改进,例如准确性、功能和可用性。对于 iOS 开发者来说,Swift 已成为干流,并已在云音乐部分产品和事务中运用,之后咱们会考虑接入生态更好的 SwiftLint。

现阶段,云音乐技能团队正在积极的建立和完善自己的代码静态检测平台,值得等待。

总结

凭借代码静态检测才能,能够及时有效的帮助咱们发现问题、保障代码质量、避免代码劣化、节省人力本钱。 OCLint 作为一种静态代码剖析东西,致力于提高代码质量、削减代码缺点,并被广泛运用。结合它的高扩展性,可定制满意各种需求,例如检测发动耗时代码,并经过多进程技能可大大缩短剖析时刻。事务方也能够轻松参加共建,丰厚规矩仓库,一起其他产品线也能够参照此事例快速建立各自的代码静态检测服务。

参考资料

  • Clang documentation
  • Clang Static Analyzer
  • Infer
  • OCLint Documentation
  • oclint_argument_list_too_long_solution
  • Python 教程
  • SwiftLint

本文发布自网易云音乐技能团队,文章未经授权禁止任何形式的转载。咱们终年招收各类技能岗位,如果你预备换作业,又恰好喜欢云音乐,那就加入咱们 grp.music-fe(at)corp.netease.com!