一文说清 OCLint 源码解析及工作流分析

目标读者

一线工程师,架构师

预计阅读时间

15-20min

完成阅读的收获

  1. 了解静态代码审核技术的原理
  2. 了解静态代码审核技术工作流

不得不提的 Clang

由于 OCLint 是一个基于 Clang tool 的静态代码分析工具,所以不得不提一下 Clang。
Clang 作为 LLVM 的子项目, 是一个用来编译 c,c++,以及 oc 的编译器。

OCLint 本身是基于 Clang tool 的,换句话说相当于做了一层封装。
它的核心能力是对 Clang AST 进行分析,最后输出违反规则的代码信息,并且导出指定格式的报告。

接下来就让我们看看作为输入信息的 Clang AST 是什么样子的。

Clang AST

Clang AST 是在编译器编译时的一个中间产物,从词法分析,语法分析(生成 AST),到语义分析,生成中间代码。

一文说清 OCLint 源码解析及工作流分析_第1张图片

抽象语法树示例

这里先对抽象语法树有一个初步的印象。

//Example.c
#include 
int global;
void myPrint(int param) {
    if (param == 1)
        printf("param is 1");
    for (int i = 0 ; i < 10 ; i++ ) {
        global += i;
    }
}
int main(int argc, char *argv[]) {
    int param = 1;
    myPrint(param);
    return 0;
}

一文说清 OCLint 源码解析及工作流分析_第2张图片

这里可以清晰的看到,这一段代码的每一个元素与其子节点的关系。其中的节点有两大类型,一个是 Stmt 类,包括 Expr 表达式类也是继承于 Stmt,它是语句,有一定操作;另一大类元素是 Decl 类,即定义。所有的类,方法,函数变量均是一个 Decl 类 (这两个类互不兼容,需要特殊容器节点来转换,比如 DeclStmt 节点) 。另外从数据结构中可以看到,这个树是单向的,只有从某一个顶层元素向下访问。

在终端中可以用如下指令查看语法树:

clang -Xclang -ast-dump -fsyntax-only Example.c

访问抽象语法树

无论是 Stmt 还是 Decl 都自带迭代器,可以方便的遍历所有节点元素,再判断其类型进行操作。不过在 Clang 中还有更方便的方法:继承 RecursiveASTVisitor 类。
它是一个 AST 树递归器,可以递归的访问一个 AST 树的所有节点。最常用的方法是 TraverseStmt 和 TraverseDecl。

例如我要访问这么一段代码中所有的函数,即 FunctionDecl,并且输出这些函数的名字,我就要重写 (通过自定义 checker) 这么一个方法:

bool VisitFunctionDecl(FunctionDecl *decl){
    string name = decl->getNameAsString();
    printf(name);
    return true;
}

这样,我们就能够访问到这棵 AST 树中所有的 FunctionDecl 节点,并且把其中函数名字给输出出来了。

接下来我们看看 OCLint 的源码,看看 OCLint 到底是如何工作的!

OCLint 源码解析

首先看一下核心类关系图,有一点初步的印象后,我们开始看代码

一文说清 OCLint 源码解析及工作流分析_第3张图片

1 首先找到入口文件 oclint/driver/main.cpp,及入口函数 main()

该文件的精简后的代码框架如下所示:

int main(int argc, const char **argv)
{
    llvm::cl::SetVersionPrinter(oclintVersionPrinter);
    // 构造 parser 分析程序
    CommonOptionsParser optionsParser(argc, argv, OCLintOptionCategory);
    // 配置
    oclint::option::process(argv[0]);
    
    ...

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

    // 执行分析
    driver.run(optionsParser.getCompilations(), optionsParser.getSourcePathList(), analyzer);
    
    std::unique_ptr results(std::move(getResults()));

    ostream *out = outStream();
    // 输出报告
    reporter()->report(results.get(), *out);
    disposeOutStream(out);

    return handleExit(results.get());
}

2 接着查看核心的 Driver 类的关键代码片段,有三个比较核心的方法 constructCompilers(),invoke(),run()

// 构建编译器
static void constructCompilers(std::vector &compilers,
    CompileCommandPairs &compileCommands,
    std::string &mainExecutable)
{
    for (auto &compileCommand : compileCommands) // 遍历编译命令集
    {
        std::vector adjustedCmdLine =
            adjustArguments(compileCommand.second.CommandLine, compileCommand.first);

#ifndef NDEBUG
        printCompileCommandDebugInfo(compileCommand, adjustedCmdLine);
#endif

        LOG_VERBOSE("Compiling ");
        LOG_VERBOSE(compileCommand.first.c_str());
    std::string targetDir = stringReplace(compileCommand.second.Directory, "\\ ", " ");

        if(chdir(targetDir.c_str()))
        {
            throw oclint::GenericException("Cannot change dictionary into \"" +
                targetDir + "\", "
                "please make sure the directory exists and you have permission to access!");
        }
        clang::CompilerInvocation *compilerInvocation =
            newCompilerInvocation(mainExecutable, adjustedCmdLine);// 创建 CompilerInvocation 对象
        oclint::CompilerInstance *compiler = newCompilerInstance(compilerInvocation);
// 使用 clang 的 CompilerInvocation 对象 创建 oclint 的 CompilerInstance 对象,oclint 做了封装
        compiler->start(); // clang::FrontendAction 核心是获取到 action 并执行
        if (!compiler->getDiagnostics().hasErrorOccurred() && compiler->hasASTContext())
        {
            LOG_VERBOSE(" - Success");
            compilers.push_back(compiler); // oclint 封装的 CompilerInstance 对象放入集合中
        }
        else
        {
            LOG_VERBOSE(" - Failed");
        }
        LOG_VERBOSE_LINE("");
    }
}

// 实际的进行分析的唤起方法
static void invoke(CompileCommandPairs &compileCommands,
    std::string &mainExecutable, oclint::Analyzer &analyzer)
{
    std::vector compilers; // 编译器容器
    constructCompilers(compilers, compileCommands, mainExecutable);  // 构建编译器

    // collect a collection of AST contexts
    std::vector localContexts;
    for (auto compiler : compilers) // 遍历编译器集合
    {
        localContexts.push_back(&compiler->getASTContext()); // 将 AST 上下文放入 上下文集合
    }

    // use the analyzer to do the actual analysis
    analyzer.preprocess(localContexts); // 将上下文集合送入分析器 预处理
    analyzer.analyze(localContexts); // 分析
    analyzer.postprocess(localContexts); // 发送处理

    // send out the signals to release or simply leak resources
    for (size_t compilerIndex = 0; compilerIndex != compilers.size(); ++compilerIndex)
    {
        compilers.at(compilerIndex)->end();
        delete compilers.at(compilerIndex);
    }
}
// main.cpp 调用的核心方法,执行分析
void Driver::run(const clang::tooling::CompilationDatabase &compilationDatabase,
    llvm::ArrayRef sourcePaths, oclint::Analyzer &analyzer)
{
    CompileCommandPairs compileCommands; // 生成编译指令对容器
    constructCompileCommands(compileCommands, compilationDatabase, sourcePaths); // 构造编译指令对

    static int staticSymbol; // 静态符号
    std::string mainExecutable = llvm::sys::fs::getMainExecutable("oclint", &staticSymbol);// 获取 oclint 可执行程序的路径

    if (option::enableGlobalAnalysis()) // 启用全局分析的情况
    {
        invoke(compileCommands, mainExecutable, analyzer);// 调用 invoke 方法,注意 analyzer 也一并入参
    }
    else 
    { // 非全局分析的情况 逐个 compileCommand 进行分析
        for (auto &compileCommand : compileCommands)
        {
            CompileCommandPairs oneCompileCommand { compileCommand };
            invoke(oneCompileCommand, mainExecutable, analyzer);
        }
    }

    if (option::enableClangChecker()) // 启用 clang checker
    {
        invokeClangStaticAnalyzer(compileCommands, mainExecutable); // 调用 clang 的静态分析器
    }
}

3 最后一个就是 RulesetBasedAnalyzer 类,这个类的代码量非常少,如下所示

void RulesetBasedAnalyzer::analyze(std::vector &contexts)
{
    for (const auto& context : contexts)
    {
        LOG_VERBOSE("Analyzing ");
        auto violationSet = new ViolationSet();
        auto carrier = new RuleCarrier(context, violationSet); // 规则运载者,context 是传递给规则来分析的数据,violationSet 是用于存放处理好的结果集
        LOG_VERBOSE(carrier->getMainFilePath().c_str());
        for (RuleBase *rule : _filteredRules) // 遍历已经过滤的规则集合
        {
            rule->takeoff(carrier); // 调用规则的 takeoff
        }
        ResultCollector *results = ResultCollector::getInstance(); // 取得结果收集器实例
        results->add(violationSet); // 将规则处理好的数据加入收集器
        LOG_VERBOSE_LINE(" - Done");
    }
}

从上面的代码可以看出 analyzer 会遍历规则集合,来调用 rule 的 takeoff 方法。rule 的基类是 RuleBase,这个基类含有一个 RuleCarrier 的示例作为成员,RuleCarrier包含了每个文件对应的 ASTContext 和 violationSet,violationSet 用来存放违例的相关信息。
rule 的职责就是,检查其成员变量 ruleCarrier 的 ASTContext,有违例的情况,就将结果写入 ruleCarrier 的 violationSet 中。

高级:自定义规则

到目前为止,我们已经了解到 oclint 的基本用法,以及工作流程。

接下来更灵活也是有更高的使用难度的部分--自定义规则

规则必须实现 RuleBase 类或其派生的抽象类。不同的规则专注于不同的抽象级别,例如,某些规则可能必须非常深入地研究代码的控制流,相反,某些规则仅通过读取源代码的字符串来检测缺陷。

oclint 提供了三个抽象类,以便我们来编写自定义规则。
AbstractSourceCodeReaderRule(源代码读取器规则),AbstractASTVisitorRule(AST 访问者规则),以及 AbstractASTMatcherRule(AST 匹配器规则)。

按照官方文档的说法,由于 AST 匹配器规则 具有良好的可读性,除非性能是个大问题,我们可能大多数时候都会选择编写AST匹配器规则。

AST 访问者规则是基于访问者模式,你只需要重载某些方法(该抽象类提供了一系列节点被访问的接口),即可处理相应节点内的校验逻辑。(由于 OCLint 使用的是 Clang 生成的抽象语法树,因此了解 Clang AST 的 API 在编写规则时非常有帮助相关链接)。

AST 匹配器规则是基于匹配模式,你需要构造一些匹配器并加载。只要找到匹配项,callback 就以该 AST 节点作为参数调用 method,你就可以在 callback 中收集违例信息。(关于匹配器的更多信息看这里

这里简单就说这么多,我们只需要知道 oclint 提供了抽象类,用于实现自定义规则。关于如何编写一个规则的部分会在下一节展开。

创建规则——scaffoldRule 脚本

这是由 oclint 提供的一个脚手架。相关介绍如下使用脚手架创建规则
可以使用该脚本可以方便的创建自定义规则。

编写规则

通过阅读 oclint 的官方文档,以及阅读 Clang AST 的介绍。现在我们已经知道了,oclint 的大致工作方式。首先通过调用 Clang 的 api 把源文件一个个的生成对应的 AST;其次遍历 AST 中的每个节点,并根据相应的规则将违例情况写入违例结果集;最后根据配置的报告类型,将违例结果输出成指定的报告格式。

先上一个 oclint 规则编写思路的脑图,有个初步的印象即可。

一文说清 OCLint 源码解析及工作流分析_第4张图片

按照上文,我们现在已经得到了一个 xcodeproj 工程。现在可以打开我们创建的规则的 cpp 源文件。

首先我们可以看到,使用脚手架生成的规则,模板代码有近 2000 行,是不是有点慌? 不用担心。这些模板里,大多都是 Visit 开头的方法,这是 oclint 提供给我们的回调方法, 也就是说在访问到 AST 上相应的节点时就会触发的方法。


下面我们来看一个实际的案例,已经用在 iOS 组的代码检查中的一个规则。
这个规则所做的工作大致如下,按照 cocoa 的规范要求来检查 if else 条件分支的格式。
具体的格式要求是这样的,if else 和后面跟着的括号以及花括号要分割开,可以使用空格和换行符。
示例代码如下:

void example()
{
    int a = 1;
    if(a > 0) { // (左侧无空格或换行不合规
        a = 10;
    }
    
    if (a > 0){ // )右侧无空格或换行不合规
        a = 10;
    }
    
    if (a > 0)
    {
        a = 10;
    }else { // }右侧无空格或换行不合规
        a = -1;
    }
    
    if (a > 0)
    {
        a = 10;
    } else{ // {左侧无空格或换行不合规
        a = -1;
    }
}

1 首先在终端中使用 dump 查看 AST(上文已经介绍了如何查看 AST,如果没看过建议先看看)。

屏幕上一连串花花绿绿的字符闪过,最后停在了这里!
没错,这正是我们需要找的。

一文说清 OCLint 源码解析及工作流分析_第5张图片

可以很清楚的看到,最上方的变量声明 VarDecl,以及下方的条件语句 IfStmt。

2 需要检验的节点名称已经确定,就是 IfStmt。
3 接下来,在已经生成的规则模板中找对应的回调方法。
我推测,应该叫做 VisitXXIfStmt 之类的。
果然不出所料,我们找到了!VisitIfStmt 这个方法,看起来正是我们所需要的。
4 紧接着,我们需要获取节点名称和节点描述。(详细的代码可以参看下方提供的完整规则文件)
5 最后是判断这里的方法名是否符合规则。(可以使用 llvm,Clang,以及 std 提供的各种函数,如果有你需要的)
6 如果检测出来的方法名是不符合规范的,将节点及描述信息加入 violationSet。

到这里,整体的编写流程已经完成了。相信你看完下方的实例代码,以及再多读几个官方提供的规则代码之后,很快就可以举一反三的写出自己的规则了。

这里直接给出上文规则的完整实现:

#include "oclint/AbstractASTVisitorRule.h"
#include "oclint/RuleSet.h"

using namespace std;
using namespace clang;
using namespace oclint;

class KirinzerTestRule : public AbstractASTVisitorRule
{
public:
    virtual const string name() const override
    {
        return "if else format";
    }

    virtual int priority() const override
    {
        return 2;
    }

    virtual const string category() const override
    {
        return "controversial";
    }

#ifdef DOCGEN
    virtual const std::string since() const override
    {
        return "20.11";
    }

    virtual const std::string description() const override
    {
        return "用于检查 if else 条件分支中的括号是否符合编码规范";
    }

    virtual const std::string example() const override
    {
        return R"rst(
.. code-block:: cpp

        void example()
        {
        int a = 1;
        if(a > 0) { // (左侧无空格或换行不合规
        a = 10;
        }
        
        if (a > 0){ // )右侧无空格或换行不合规
        a = 10;
        }
        
        if (a > 0)
        {
        a = 10;
        }else { // }右侧无空格或换行不合规
        a = -1;
        }
        
        if (a > 0)
        {
        a = 10;
        } else{ // {左侧无空格或换行不合规
        a = -1;
        }
        }
        )rst";
    }

#endif
    
    bool VisitIfStmt(IfStmt *node)
    {
        clang::SourceManager *sourceManager = &_carrier->getSourceManager();
        
        SourceLocation begin = node->getIfLoc();
        SourceLocation elseLoc = node->getElseLoc();
        SourceLocation end = node->getEndLoc();
        
        int length = sourceManager->getFileOffset(end) - sourceManager->getFileOffset(begin) + 1; // 计算该节点源码的长度
        string sourceCode = StringRef(sourceManager->getCharacterData(begin), length).str(); // 从起始位置按指定长度读取字符数据
//        printf("%s\n", sourceCode.c_str());
        
        // 检查 if 左括号
        std::size_t found = sourceCode.find("if (");
        if (found==std::string::npos) {
//            printf("if ( 格式不正确\n");
            AppendToViolationSet(node, Description());
        }
        
        // 检查 if 右括号
        found = sourceCode.find(") {");
        if (found==std::string::npos) {
            found = sourceCode.find(")\n");
            if (found ==std::string::npos) {
//                printf("if 右括号 格式不正确\n");
                AppendToViolationSet(node, Description());
            }
        }
        
        // 没有 else 分支就不再进行检查
        if (!elseLoc.isValid()) {
            return true;
        }
        
        // 检查 else 左括号
        found = sourceCode.find("} else");
        if (found==std::string::npos) {
            found = sourceCode.find("}\n");
            if (found==std::string::npos) {
//                printf("} else 格式不正确\n");
                AppendToViolationSet(node, Description());
            }
        }
        
        // 检查 else 右括号
        found = sourceCode.find("else {");
        if (found==std::string::npos) {
            found = sourceCode.find("else\n");
            if (found==std::string::npos) {
//                printf("else { 格式不正确\n");
                AppendToViolationSet(node, Description());
            }
        }
        
        return true;
    }
    
    // 将违例信息追加进结果集
    bool AppendToViolationSet(IfStmt *node, string description) {
        addViolation(node, this, description);
    }
    
    string Description() {
        return "格式不正确";
    }
};

static RuleSet rules(new KirinzerTestRule());

调试规则

根据前面的所学到的内容,我们知道了规则的实际体现形式为 dylib 文件。那么如果编写 cpp 的时候没办法调试,那真的是噩梦一般的体验。将我们现在遇到的问题,如何调试 oclint 规则?

1 首先需要一个 Xcode 工程。

oclint 工程使用 CMakeLists 来维护依赖关系。我们也可利用 CMake 来将 CMakeLists 生成 xcodeproj。你可以对每个文件夹生成一个 Xcode 工程,在这里我们对 oclint-rules 生成对应的 Xcode 工程。

// 在OCLint源码目录下建立一个文件夹,我这里命名为oclint-xcoderules
mkdir oclint-xcoderules
cd oclint-xcoderules
// 执行如下命令
cmake -G Xcode -D CMAKE_CXX_COMPILER=../build/llvm-install/bin/clang++  -D CMAKE_C_COMPILER=../build/llvm-install/bin/clang -D OCLINT_BUILD_DIR=../build/oclint-core -D OCLINT_SOURCE_DIR=../oclint-core -D OCLINT_METRICS_SOURCE_DIR=../oclint-metrics -D OCLINT_METRICS_BUILD_DIR=../build/oclint-metrics -D LLVM_ROOT=../build/llvm-install/ ../oclint-rules

2 Xcode 工程创建好之后,我们需要对指定的 Scheme 添加启动参数。并且在 Scheme 的 Info 一栏选择 Executable ,选择上文中编译完成的 oclint 可执行文件。

Tip: 编译生成的oclint可执行文件在根目录下 build/oclint-release/bin 目录下,以最新版的 oclint 20.11 为例,生成的文件名为 oclint-20.11,会被 Finder 识别为 Document 类型。(.11被识别为了后缀),虽然并不影响在终端的直接调用,但是我们后续的调试中会需要在 Xcode 中通过 Finder 来选取这个可执行文件,但是由于类型被识别错误,会导致无法点击选中。所以在这里我们就删除小数点,修改可执行文件名为 oclint-2011 并且没有任何后缀即可。(注意修改的时候,右键getInfo,在文件名和扩展名那一栏来修改,还有注意是否隐藏了拓展名)。

启动参数如下:
(第一个参数是规则加载路径,第二个是测试规则用文件)

>-R=/Users/developer/TempData/oclint/oclint-xcoderules/rules.dl/Debug /Users/developer/TempData/oclint/oclint-xcoderules/test2.m -- -x objective-c -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk

准备完成后即可运行规则,在控制台中可以输出你的规则运行的结果以及调试信息。

一文说清 OCLint 源码解析及工作流分析_第6张图片

使用规则

使用 Xcode 编写的规则完成编译后,可以在 Xcode 的 Products group 中找到相应的 dylib 文件。

默认情况下,规则将从$(/path/to/bin/oclint)/../lib/oclint/rules目录中加载,我们将其命名为“ 规则搜索路径”或“ 规则加载路径”。规则搜索路径由一组动态库组成,这些库在Linux,macOS和 Windows中具有扩展名 so, dylib 以及 dll。

通过将新规则拖放到规则加载路径中,可以立即使用它们。 因此,只需要将我们自定义规则生成的 dylib 放入默认的规则加载目录即可。当然这里的规则目录也是可以配置的。一个项目可以使用多个规则搜索路径,可以为不同的项目指定不同的规则加载路径。

更多详细的配置参考这里的官方文档:

选择OCLint检查规则

总结

使用静态代码检查工具,可以高效的检查出代码中的潜在问题,在做持续的业务交付过程中,提高开发同学们对于编码规范的重视,防止代码的劣化,减少一些由于粗心导致的错误。希望本文提及的静态检查工具,以及自定义规则的编写的说明,能帮助大家写出更高质量,更优雅,更美观的代码。

参考资料

简述 LLVM 与 Clang 及其关系
Clang Tutorial
Clang Users Manual
oclint-docs v20.11

一文说清 OCLint 源码解析及工作流分析_第7张图片

你可能感兴趣的:(c++objective-c)