OCLint静态代码检测实践

2,765 阅读16分钟

一、概述

随着业务迭代速度的加快,团队也变得越来越大,人工codeReview的成本也越来越高,而且完全依赖人工也无法确保能review到每一处改动,这个时候,运用静态代码检测就能极大的提高检测效率,节省开发人员的时间。

目前iOS端主流的静态代码分析工具大概是这三个:Clang静态分析,infer,OCLint,三者各有优缺点。

Clang: 苹果自带的词法分析器,如果需要自定义检测规则,需要重新下载编译llvm,还需要修改Xcode的编译器,配置各种路径。比较繁琐,不推荐。

infer: facebook推出的的静态代码检测工具,检测效率高,支持增量分析,支持OC,java等多种语言。但是定制性不强,可自定义规则程度不高,而且暂无法和sonarQube平台配合分析。

OCLint: 可用于持续集成,常和Jenkins、SonarQube平台配合适用。可定制化程度高,更多的检查规则和定制,能够发现很多潜在的问题。

由于考虑到需要和Jenkins、SonarQube配合使用,而且需要根据团队的代码规范来高度自定义检测规则,这里暂选用OCLint做静态代码分析。

二、OCLint集成

1.通过Homebrew安装

  • 安装homebrew
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
  • 安装OCLint
brew install OCLint
  • 安装xcpretty
sudo gem install xcpretty

2.源码安装(⚠️如果想自定义规则,源码安装是必不可少的️)

安装CMake,前往cmake官网,下载Mac操作系统对应的安装包。安装完成之后,运行cmake图形界面程序,在左上角的选项栏中选择Tools,点击How to install for Command Line Use。 之后弹出来一个消息框:

One may add CMake to the PATH:
PATH="/Applications/CMake.app/Contents/bin":"$PATH"
Or, to install symlinks to '/usr/local/bin', run:
sudo "/Applications/CMake.app/Contents/bin/cmake-gui" --install
Or, to install symlinks to another directory, run:
sudo "/Applications/CMake.app/Contents/bin/cmake-gui" --install=/path/to/bin

安装Ninja

brew install Ninja
  • cd进入源码目录的oclint-scripts文件夹中,执行./make,执行后,网速快的话,大概要等半个小时,慢的话,估计个把小时。这个过程会下载llvm,clang源码,然后编译llvm,clang,oclint源码,安装完毕后,见下图:

build文件夹就是release环境下的安装好的oclint,自带的规则已编译好,以dylib动态库的形式放在build/oclint-release/lib/oclint/rules文件夹下。

3.检测项目

  • 附一段适用于小项目,项目文件不多的检测脚本。 xxx修改为自己的项目名即可
#!/bin/bash -il
source ~/.bashrc
myworkspace=xxx.xcworkspace
myscheme=xxx

# clean cache
rm -rf ~/Library/Developer/Xcode/DerivedData/;
rm compile_commands.json;
rm oclint_result.xml;

# clean -- build -- OCLint analyse
echo 'start analyse';
xcodebuild -workspace $myworkspace -scheme $myscheme clean&&
xcodebuild -workspace $myworkspace -scheme $myscheme \
-configuration Debug GCC_PRECOMPILE_PREFIX_HEADER=YES CLANG_ENABLE_MODULE_DEBUGGING=NO COMPILER_INDEX_STORE_ENABLE=NO \
-destination 'platform=iOS Simulator,name=iPhone X' \
| xcpretty -r json-compilation-database -o compile_commands.json&&
oclint-json-compilation-database -e Pods -e node_modules -- \
-report-type pmd \
-rc LONG_LINE=300 \
-rc LONG_METHOD=200 \
-rc LONG_VARIABLE_NAME=40 \
-rc LONG_CLASS=3000 \
-max-priority-1=1000 \
-max-priority-2=1000 \
-max-priority-3=2000 \
-disable-rule=UnusedMethodParameter \
-disable-rule=AvoidPrivateStaticMembers \
-disable-rule=ShortVariableName \
-allow-duplicated-violations=false >> oclint_result.xml; \
echo 'end analyse';

# echo result
if [ -f ./oclint_result.xml ]; 
then echo 'done';
else echo 'failed';
fi

如果分析中出现以下错误:

Traceback (most recent call last):
File "/usr/local/bin/oclint-json-compilation-database", line 87, in <module> exit_code = subprocess.call(oclint_invocation, shell=True)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/subprocess.py", line 522, in call return Popen(*popenargs, **kwargs).wait()
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/subprocess.py", line 710, in __init__ errread, errwrite)
File "/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/subprocess.py", line 1335, in _execute_child raise child_exception OSError: [Errno 7] Argument list too long

那么是xcpretty -r json-compilation-database -o compile_commands.json这一步,生成的compile_commands.json文件过大,这个问题具体可以查看oclint的issue233,里面有相应的解决方案。

4.集成到Xcode中

由于Xcode目前已经禁止集成插件,如果想把oclint的自定义的规则提示,集成到Xcode中,变成即时提示,需要HackXcode,还需要重新下载编译llvm,修改Xcode自带的llvm编译器,比较麻烦。这里采用OCLint官网对Xcode的配置方法,该方法虽不能即时提醒,但是可以通过编译Target,把项目所有不符合oclint规则的提示都显示出来。

三、自定义规则前期准备

前面OCLint集成的第二步源码安装,有说到,要想自定义规则,必须通过源码安装,因为我们需要把OCLint自带的规则和我们自定义的规则集成到Xcode中,这样方便我们写自定义规则的代码和编译。

1.生成自定义规则模版

  • cd到源码根目录,执行下面的命令。
oclint-scripts/scaffoldRule RuleName -t ASTVisitor
 -t <Type> -n <Name> -c <Category> -p <Priority>

-t: 表示自定义的规则继承自那个模版,OCLint的规则模版有三个:

RuleBase
 |
 |-AbstractASTRuleBase
 |      |_ AbstractASTVisitorRule
 |             |_AbstractASTMatcherRule
 |
 |-AbstractSourceCodeReaderRule

AbstractSourceCodeReaderRule:eachLine 方法,读取每行的代码,如果想编写的规则是需要针对每行的代码内容,则可以继承自该类。

AbstractASTVisitorRule:可以访问 AST 上特定类型的所有节点,可以检查特定类型的所有节点是递归实现的。在 apply 方法内可以看到代码实现,开发者只需要重载 bool visit* 方法来访问特定类型的节点。其值表明是否继续递归检查。

AbstractASTMatcherRule️ :实现setUpMatcher 方法,在方法中添加 matcher,当检查发现匹配结果时会调用 callback 方法。然后通过 callback 方法来继续对匹配到的结果进行处理。

-n: 即规则检查完成后的规则名。 -c: 规则的分类。 -p: 规则的优先级。

2.将自定义规则生成Xcode工程,方便编写代码,和编译。

  • 在OCLint源码目录下建立一个文件夹oclint-xcodeproject,cd到该目录,执行命令:
touch create_xcodeproject.sh
vim create_xcodeproject.sh
chmod 777 create_xcodeproject.sh
  • 将下面脚本复制进去
#! /bin/sh -e
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
  • 结束编辑输入wq保存退出后,执行命令
./create_xcodeproject.sh

执行该脚本后,会在oclint-xcodeproject下生成Xcode工程,见下图:

四、Clang与AST简介

在上述Xcode中,找到自己自定义的规则文件,可以看到里面有很多bool visitXXX回调函数被注释了,该回调函数就是我们后面写自定义规则代码的主要区域。

要想编写自定义的规则,需要有一点点C++的基础,还需要懂一点Clang,不懂也没关系,写的时候看源码就可以了,Clang的API都很清楚明了。

1.AST语法树

先看一张图:

LLVM是构架编译器(compiler)的框架系统,以C++编写而成,用于优化以任意程序语言编写的程序的编译时间(compile-time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time), 目前Apple的编译器采用的是LLVM。

从图中可以看出Clang是LLVM的编译器前端,用来分析语言的语法,它能够将语言转换成抽象语法树(abstract syntax code,AST)。

比如现在有一段OC代码:

self.testBlock = ^{
    [self testDemoBlock];
};

[self setObjcBlock:^{
    BViewController *BVC = [[BViewController alloc] init];
    [self.navigationController pushViewController:BVC animated:YES];
}];

BOOL res = arc4random()%2;
if (res == NO) NSLog(@"%@",@(res));

BOOL conditionA = arc4random()%3;
BOOL conditionB = arc4random()%3;
    
if (conditionA == YES && conditionB) {
    NSLog(@"%@", @(conditionA));
}else {
    NSLog(@"%@", @(conditionA));
}

我现在想查看这个文件的AST语法结构,可以执行下面这个命令

clang -Xclang -ast-dump -fsyntax-only test.m 

执行后,结构如下:

-ImplicitParamDecl 0x7f9409825750 <<invalid sloc>> <invalid sloc> implicit _cmd 'SEL':'SEL *'
| | |-BlockDecl 0x7f9409831948 <line:34:22, line:36:5> line:34:22
| | | |-capture ImplicitParam 0x7f94098256f0 'self' 'ViewController *'
| | | `-CompoundStmt 0x7f9409831a88 <col:23, line:36:5>
| | |   `-ObjCMessageExpr 0x7f9409831a58 <line:35:9, col:28> 'void' selector=testDemoBlock
| | |     `-ImplicitCastExpr 0x7f9409831a40 <col:10> 'ViewController *' <LValueToRValue>
| | |       `-DeclRefExpr 0x7f9409831a18 <col:10> 'ViewController *const' lvalue ImplicitParam 0x7f94098256f0 'self' 'ViewController *'
| | |-BlockDecl 0x7f9409831b88 <line:39:24, line:42:5> line:39:24
| | | |-capture ImplicitParam 0x7f94098256f0 'self' 'ViewController *'
| | | |-CompoundStmt 0x7f9409831db0 <col:25, line:42:5>
| | | | `-DeclStmt 0x7f9409831d58 <line:40:9, col:62>
| | | |   `-VarDecl 0x7f9409831c70 <col:9, col:61> col:26 BVC 'BViewController *' cinit
| | | |     `-ImplicitCastExpr 0x7f9409831d40 <col:32, col:61> 'BViewController *' <BitCast>
| | | |       `-ObjCMessageExpr 0x7f9409831d10 <col:32, col:61> 'id':'id' selector=init
| | | |         `-ObjCMessageExpr 0x7f9409831ce0 <col:33, col:55> 'id':'id' selector=alloc class='BViewController'
| | | `-VarDecl 0x7f9409831c70 <col:9, col:61> col:26 BVC 'BViewController *' cinit
| | |   `-ImplicitCastExpr 0x7f9409831d40 <col:32, col:61> 'BViewController *' <BitCast>
| | |     `-ObjCMessageExpr 0x7f9409831d10 <col:32, col:61> 'id':'id' selector=init
| | |       `-ObjCMessageExpr 0x7f9409831ce0 <col:33, col:55> 'id':'id' selector=alloc class='BViewController'
| | `-CompoundStmt 0x7f9409832040 <line:28:21, line:57:1>
| |   |-ExprWithCleanups 0x7f9409831e40 <line:39:5, line:42:6> 'void'
| |   | |-cleanup Block 0x7f9409831b88
| |   | `-ObjCMessageExpr 0x7f9409831e08 <line:39:5, line:42:6> 'void' selector=setObjcBlock:
| |   |   |-ImplicitCastExpr 0x7f9409831df0 <line:39:6> 'ViewController *' <LValueToRValue>
| |   |   | `-DeclRefExpr 0x7f9409831b60 <col:6> 'ViewController *' lvalue ImplicitParam 0x7f94098256f0 'self' 'ViewController *'
| |   |   `-BlockExpr 0x7f9409831dd8 <col:24, line:42:5> 'void (^)(void)'
| |   |     `-BlockDecl 0x7f9409831b88 <line:39:24, line:42:5> line:39:24
| |   |       |-capture ImplicitParam 0x7f94098256f0 'self' 'ViewController *'
| |   |       |-CompoundStmt 0x7f9409831db0 <col:25, line:42:5>
| |   |       | `-DeclStmt 0x7f9409831d58 <line:40:9, col:62>
| |   |       |   `-VarDecl 0x7f9409831c70 <col:9, col:61> col:26 BVC 'BViewController *' cinit
| |   |       |     `-ImplicitCastExpr 0x7f9409831d40 <col:32, col:61> 'BViewController *' <BitCast>
| |   |       |       `-ObjCMessageExpr 0x7f9409831d10 <col:32, col:61> 'id':'id' selector=init
| |   |       |         `-ObjCMessageExpr 0x7f9409831ce0 <col:33, col:55> 'id':'id' selector=alloc class='BViewController'
| |   |       `-VarDecl 0x7f9409831c70 <col:9, col:61> col:26 BVC 'BViewController *' cinit
| |   |         `-ImplicitCastExpr 0x7f9409831d40 <col:32, col:61> 'BViewController *' <BitCast>
| |   |           `-ObjCMessageExpr 0x7f9409831d10 <col:32, col:61> 'id':'id' selector=init
| |   |             `-ObjCMessageExpr 0x7f9409831ce0 <col:33, col:55> 'id':'id' selector=alloc class='BViewController'
| |   `-IfStmt 0x7f9409832008 <line:50:5, line:55:5>
| |     |-<<<NULL>>>
| |     |-<<<NULL>>>
| |     |-OpaqueValueExpr 0x7f9409831fe8 <<invalid sloc>> '_Bool'
| |     |-CompoundStmt 0x7f9409831f50 <line:50:49, line:52:5>
| |     `-CompoundStmt 0x7f9409831fd8 <line:53:10, line:55:5>

从上面结构图中,我们大致可以看出: block被分析成了BlockDecl节点,if语句分析成了IfStmt,中间的花括号{}就是CompoundStmt,定义的变量就是VarDecl节点,等等,上图一层一层的结构,就形成了树状结构。

  • Clang中视所有代码单元为语句(statement),Clang中使用类Stmt来代表statement。Clang构造出来的语法树,其节点类型就是Stmt。针对不同类型的语句,Clang有对应的Stmt子类,例如GotoStmt、IfStmt。Clang中的表达式也被视为语句,Clang使用Expr类来表示表达式,而Expr本身就派生于Stmt。

  • 一种是Stmt(Statement),另外一种是Decl(Declaration)。Stmt很明显,就是表达式的节点,具有操作性,比如CompoundStmt对应的{},ifstmt对应if,以上所有的Operator、Expr、Literal结尾的都是stmt的子类。Decl就对应的var,method,function, BlockDecl。 我们可以通过某个节点往下遍历所有节点。比如FucntionDecl往下遍历可以得到ParamVarDecl和CompoundStmt,然后通过CompoundStmt可以得到IfStmt和ReturnStmt。

  • 每个语法树节点都会有一个子节点列表,在Clang中一般可以使用如下语句遍历一个节点的子节点

for (Stmt::child_iterator it = stmt->child_begin(); it != stmt->child_end(); ++it) {
    Stmt *child = *it;
}

当用Clang进行进行语法分析的时候,它每遍历到一个Stmt或者Decl,都会执行对应的回调函数,在回调函数里,我们就能根据Clang的API分析代码的规范性或者结构,甚至能直接拿到分析的源码,比如:

//遍历OC属性节点
bool VisitObjCPropertyDecl(ObjCPropertyDecl *decl)
{
    return true;
}
//遍历block节点
bool VisitBlockDecl(BlockDecl *node)
{
    return true;
}
//遍历到了if语句
bool VisitIfStmt(IfStmt *node)
{
    return true;
}

五、编写自定义规则及常用访问Stmt,Decl

1.bool VisitVarDecl(VarDecl *node)

VarDecl:即变量和常量的声明。

如何区分静态变量、本地变量、文件变量.

//静态变量
node->isStaticLocal()
//本地变量
node->isLocalVarDecl()
//文件变量
node->isFileVarDecl()

检测是不是常量:

private:
    ASTContext *Context;
    
public:
    
    void setASTContext (ASTContext &context)
    {
        this->Context = &context;
    }
    
node->getType().isConstant(*this->Context)

2.bool VisitObjCPropertyDecl(ObjCPropertyDecl *decl)

即访问属性节点。

  • 获取属性修饰词
//拿到属性的修饰词
ObjCPropertyDecl::PropertyAttributeKind attribute = decl->getPropertyAttributes();
//注意拿到的不是一个数组,而是一个十六进制的枚举,如果想判断该属性是否包含某个修饰词,要用与运算,如下:检测是否存在copy修饰词。
attribute & ObjCPropertyDecl::PropertyAttributeKind::OBJC_PR_copy
  • 获取属性声明类型
//转为了字符串类型
StringRef type = StringRef(decl->getType().getAsString());
  • 获取属性名
string name = decl->getPropertyDecl()->getNameAsString();

3.bool VisitIfStmt(IfStmt *node)

即访问if语句,这里的if语句,是包含else语句的。

  • 拿到if花括号里的语句
Stmt *thenStmt = node->getThen();
//判断if 语句有没有{}
if (!thenStmt || !(isa<CompoundStmt>(thenStmt))) {
    addViolation(node, this, "if 语句没有{}");
}
  • 获取if中的条件
//该方法只是获取if中的条件表达式
Expr *condition = node->getCond();
  • 获取if条件中的源码
Expr *condition = node->getCond();
SourceManager *sourceManager = &_carrier->getSourceManager();
SourceLocation startLocation = sourceManager->getFileLoc(condition->getBeginLoc());
SourceLocation endLoc = sourceManager->getFileLoc(condition->getEndLoc());

通过condition->getBeginLoc()和condition->getEndLoc()拿到的源码位置是有问题的,它默认碰到二元运算符,就会返回,如下图:

要想拿到真实的源码,需要用到Lexer,导入#include "clang/Lex/Lexer.h",接着上面内容

///此方法会定位到 ')',而上面那个方法则定位到最后一个条件的 ‘==’
SourceLocation endLocation = Lexer::findLocationAfterToken(endLoc, tok::r_paren, _carrier->getSourceManager(), LangOptions(), false);
int length = sourceManager->getFileOffset(endLocation)
sourceManager->getFileOffset(startLocation) - 1;
StringRef stmtStr = StringRef(sourceManager->getCharacterData(startLocation), length);

Lexer::findLocationAfterToken这个方法中第二个参数tok::r_paren有四个选项,表示碰到哪个字符,就返回相应的位置信息,拿到源码后,我们就能进行相应的操作了。

4.bool VisitObjCMethodDecl(ObjCMethodDecl *node)

即访问OC方法。

  • 获取返回值类型
std::string returnType = node->getReturnType().getAsString();
  • 获取方法名
StringRef selectorString = StringRef(node->getSelector().getAsString());
  • 获取方法参数列表
ArrayRef<ParmVarDecl*> parameters = node->parameters();

ParmVarDecl继承自VarDecl,通过它可以再拿到参数类型,和参数名。

5.bool VisitObjCMessageExpr(ObjCMessageExpr *node)

即方法调用。

  • 判断是类方法还是实例方法
//获取方法调用类型,有四个取值Class,Instance,SuperClass,SuperInstance
ObjCMessageExpr::ReceiverKind kind = node->getReceiverKind();
//类方法
node->isClassMessage()
//实例方法
node->isInstanceMessage()
  • 获取传参
Expr **exprArray = node->getArgs();
  • 获取方法调用类型
string type = node->getReceiverType().getAsString();

6.bool VisitBlockDecl(BlockDecl *node)

即访问Block结点,使用这个结点,我们可以对block里面使用了self做警告处理,下面是检测block中可能造成循环引用的大概思路。

这里说一下可能造成循环引用的情况,self持有对象的方法或者属性中含有block,如果block代码块中含有self,基本都会造成循环引用,还有一种情况就是,一个局部对象或者类持有了一个block代码块,代码块里面含有self,这种情况可能会造成,也可能不会,比如UIView的动画块或者一些线程函数dispatch代码中,使用self,不会造成循环引用。

既然是为了统一代码规范,那不管self里面有没有持有block,如果block里面没有书写strong或者weak,我们都给它添加warning(由于reactive-obj有weakify和strongify,block中可以直接写self,所以不能简单得通过block代码块中含有self,就给予警告)。

六、检测成果与分析

OCLint可输出很多格式的分析报告,比如:

html报告: 将结果可视化,并定位到具体那一行代码,给出相应的提示。

xml/json/text等报告: 此格式的报告,可通过jenkins构建分析出xml文件,再由solar-scanner插件扫描上传至sonarQube平台,sonarQube会将结果分析展示出来。

检测成果:

1.Block循环引用

项目中可能存在的Block循环引用予以告警并告知负责人。该类问题可能导致内存泄漏,所以解决优先级较高。

2.属性命名规范告警

  • 不合规范的属性修饰词
  • 属性名以大写或者下划线开头
  • 以UI或者new开头的属性名

3.构造方法返回类型告警

对于构造方法的返回类型为id的给予告警,给出更改提示为instancetype。

4.变量命名规范告警

局部变量以大写字母开头,或者常量不以项目前缀为开头的给予告警,此类告警优先级较低。

5.非字面量创建对象告警

项目中,NSArray、NSNumber、NSDictionary类型的对象,如果初始化时,带有默认值,提倡使用字面量的方式创建,即使用@[],@{}等。

6.Case语句告警

Switch, Case语句中,如果一个case条件没有以break结尾,可能会导致逻辑错误或其他问题,给予告警,优先级较高。

7.条件语句告警

如果if后的语句没有{},给予告警;if条件中如果含有对布尔值的判断,给予提示,可以通过用取反或省略布尔值代替。

8.代码重复率检测

通过jenkins将项目代码上传到sonarQube平台,该平台会分析项目代码的重复率,通过整理,可以提高项目代码的复用性和封装性。

七、总结

实现OCLint静态代码检测,其实有很多坑在里面,比如脚本编写,官方的脚本不一定可用,需要做一定的修改,项目大了,也不一定适用,再就是源码安装OCLint,可能安装过程中,会有很多奇奇怪怪的问题,后面自定义规则上,需要分析代码的AST树结构,再查看Clang的源码和API,思考实现检测的逻辑。总之,实现过程中会有很多坑坑洼洼,但是实现完成后,可以节省大量的人力,对提高代码质量、开发效率以及App的使用体验都有很大的帮助。

八、参考资料