基于 LibTooling 的自动埋点

4,214 阅读6分钟

这一篇我们继续来学习 Clang,以及如何将它用在工作中。在此之前你可能需要了解一点 iOS 的编译知识,可以看这一篇

一、 Clang、LibClang、LibTooling

我想你应该是听过 Clang 的,Clang 为分析代码语法、语义信息的工具提供了很好的基础设施。以此衍生出来了 3 个东西: LibClang、Clang Plugin 和 LibTooling。

LibClang

LibClang 提供了一个稳定的高级 C 接口,Xcode 使用的就是 LibClang。LibClang 可以访问 Clang 的上层高级抽象的能力,比如获取所有 Token、遍历语法树、代码补全等。由于 API 很稳定,Clang 版本更新对其影响不大。但是,LibClang 并不能完全访问到 Clang AST 信息。 使用 LibClang 可以直接使用它的 C API。官方也提供了 Python binding 脚本供你调用。还有开源的 node-js/ruby binding。你要是不熟悉其他语言,还有个第三方开源的 Objective-C 写的ClangKit 库可供使用。

Clang Plugins

Clang Plugins 可以让你在 AST 上做些操作,这些操作能够集成到编译中,成为编译的一部分。插件是在运行时由编译器加载的动态库,方便集成到构建系统中。 使用 Clang Plugins 一般都是希望能够完全控制 Clang AST,同时能够集成在编译流程中,可以影响编译的过程,进行中断或者提示。相关实操文章可看这篇:《Clang 代码规范检查插件》

LibTooling

LibTooling 是一个 C++ 接口,通过 LibTooling 能够编写独立运行的语法检查和代码重构工具。LibTooling 的优势如下:

  • 所写的工具不依赖于构建系统,可以作为一个命令单独使用,比如 clang-check、clang-fixit、clang-format;
  • 可以完全控制 Clang AST;
  • 能够和 Clang Plugins 共用一份代码;

与 Clang Plugins 相比,LibTooling 无法影响编译过程;与 LibClang 相比,LibTooling 的接口没有那么稳定,也无法开箱即用,当 AST 的 API 升级后需要更新接口的调用。但是,LibTooling 基于能够完全控制 Clang AST 和可独立运行的特点,可以做的事情就非常多了。比如代码语言转换、坚持代码规范、分析甚至重构代码等。

在 LibTooling 的基础之上有个开发人员工具合集 Clang tools,Clang tools 作为 Clang 项目的一部分,已经提供了一些工具,主要包括:

  • 语法检查工具 clang-check;
  • 自动修复编译错误工具 clang-fixit;
  • 自动代码格式工具 clang-format;
  • 新语言和新功能的迁移工具;
  • 重构工具;

二、自动埋点

接下来我们使用 LibTooling 来做一个类似自动埋点功能,需求大概是这样的:在不影响原来代码的情况下,可以实现当调用某个方法的时候,会自动 NSLog,输出方法的名称。 自动埋点其实就是在不手动处理的情况下,在合适的地方加入一段代码。这次我们使用 LibTooling,利用编译阶段对代码的语义分析,在合适的地方加入埋点代码。 因为本身 LibTooling 可以完全控制 Clang AST,所以这个在理论上是没问题的。但是 LibTooling 本身无法跟 Xcode 的编译(LibClang)过程交互,所以目前我们的思路是这样:

  1. 利用 LibTooling 实现代码插入,同时保存一份原代码;
  2. 进行 Xcode 编译;
  3. Xcode 编译完成之后,将原代码替换回来;

LibTooling 开发

接下来我们要建一个 LibTooling 工程,第一步需要下载 LLVM 项目。

下载编译 LLVM

在本地新建一个文件夹 LLVM,打开命令行 cd 到该目录下,输入命令:

git clone https://github.com/llvm/llvm-project.git
cd llvm-project
mkdir build
cd build
cmake -G Xcode -DLLVM_ENABLE_PROJECTS=clang ../llvm

其中 cmake -G Xcode -DLLVM_ENABLE_PROJECTS=clang ../llvm 会生成 LLVM 的 Xcode 编译工程,此时可以看到本地目录如下:

目录中 clang 是类 C 语言编译器的代码目录;llvm 目录的代码包含两部分,一部分是对源码进行平台无关优化的优化器代码,另一部分是生成平台相关汇编代码的生成器代码;lldb 目录里是调试器的代码;lld 里是链接器代码。

编译工程

接下来编译 LLVM 工程,双击打开 LLVM.Xcodeproj ,选择 Autocreat Schemes,添加 schemes All_BUILD

然后点击 Running,等待编译完成,预计要大半个小时:

新建 LibTooling 项目

等 LLVM 编译完成之后,我们来新建一个 LibTooling 工程,名字就叫做 AddCodePlugin。 打开clang-tools-CMakeLists.txt文件,在末尾加入一句话 add_clang_subdirectory(AddCodePlugin)

同时在这个目录下新建一个 AddCodePlugin 的文件夹,文件夹里面新建两个文件:AddCodePlugin.cppCMakeLists.txt

AddCodePlugin-CMakeLists.txt文件中写入:

set(LLVM_LINK_COMPONENTS support)

add_clang_executable(AddCodePlugin
  AddCodePlugin.cpp
)

target_link_libraries(AddCodePlugin
    PRIVATE
    clangAST
    clangBasic
    clangDriver
    clangFormat
    clangLex
    clangParse
    clangSema
    clangFrontend
    clangTooling
    clangToolingCore
    clangRewrite
    clangRewriteFrontend
)

AddCodePlugin.cpp 的具体代码可以看这里,我贴出它的核心代码: main 函数,是 LibTooling 的启动入口:

int main(int argc, const char **argv) {
    CommonOptionsParser op(argc, argv, AddOptionCategory);
    ClangTool Tool(op.getCompilations(), op.getSourcePathList());
    int result = Tool.run(newFrontendActionFactory<AddCodePlugin::ClangAutoStatsAction>().get());
    return result;
}

在 handleObjcMethDecl 中处理替换字符,找到方法的大括号后一位,在这里添加一段代码,这里我加入的是一段 NSLog 打印,可以将该方法名称打印出来。

bool handleObjcMethDecl(ObjCMethodDecl *MD) {
        if (!MD->hasBody()) return true;

        cout << "handleObjcMethDecl" << endl;
        //找到方法的括号后面 同时避免宏定义没展开
        CompoundStmt *CS = MD->getCompoundBody();
        SourceLocation loc = CS->getBeginLoc().getLocWithOffset(1);
        if (loc.isMacroID()) {
            loc = rewriter.getSourceMgr().getImmediateExpansionRange(loc).getBegin();
        }
        
        static std::string varName("%__FUNCNAME__%");
        std::string funcName = MD->getDeclName().getAsString();
        std::string codes(CodeSnippet);
        size_t pos = 0;
         //替换特殊字符%__FUNCNAME__%为方法名
        while ((pos = codes.find(varName, pos)) != std::string::npos) {
            codes.replace(pos, varName.length(), funcName);
            pos += funcName.length();
        }
        //写入
        rewriter.InsertTextBefore(loc, codes);
        
        return true;
    }

在文件处理结束的时候,将修改的内容写入到文件中,顺便打印出修改的内容:

void EndSourceFileAction() {
        cout << "EndSourceFileAction" << endl;
        
        SourceManager &SM = fileRewriter.getSourceMgr();
        std::string filename = SM.getFileEntryForID(SM.getMainFileID())->getName().str();
        std::error_code error_code;
        llvm::raw_fd_ostream outFile(filename, error_code, llvm::sys::fs::F_None);
        // 将Rewriter结果输出到文件中
        fileRewriter.getEditBuffer(SM.getMainFileID()).write(outFile);
        // 将Rewriter结果输出在控制台上
        fileRewriter.getEditBuffer(SM.getMainFileID()).write(llvm::outs());
    }

上面都做完之后,需要回到 build 文件夹,重新运行一下命令:

cd build
cmake -G Xcode -DLLVM_ENABLE_PROJECTS=clang ../llvm

然后回到 LLVM 的工程,编译运行一下 scheme:

编译完成会得到一个 AddCodePlugin 可执行文件,找到这个文件的路径,记录下来

运行 LibTooling 工具

接下来我们来运行一下这个工具,先写一个测试文件 test.m:

#import <UIKit/UIKit.h>

@interface HelloViewController : UIViewController
- (void)sayHi;
@end

@implementation HelloViewController
- (void)sayHi {
  NSLog(@"Hello world!");
}

- (void)sayHi123 {
  NSLog(@"Hello world!");
}
@end

在命令行中,进入 test.m 所在文件夹执行命令:

/Users/wuyanji/Desktop/LLVM/llvm-project/build/Debug/bin/AddCodePlugin test.m -- -x objective-c -arch x86_64 -std=gnu99 -fobjc-arc -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk -mios-simulator-version-min=8.0 -F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks -I/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1 -I/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/usr/include

后面的一些参数是为了编译时防止找不到头文件等错误,也可以简单直接输入:

/Users/wuyanji/Desktop/LLVM/llvm-project/build/Debug/bin/AddCodePlugin test.m --

可以看到运行结果如下:

wuyanjideMac-mini-2:YourClang wuyanji$ /Users/wuyanji/Desktop/LLVM/llvm-project/build/Debug/bin/AddCodePlugin test.m -- -x objective-c -arch x86_64 -std=gnu99 -fobjc-arc -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk -mios-simulator-version-min=8.0 -F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks -I/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1 -I/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/usr/include
handleObjcMethDecl
handleObjcMethDecl
EndSourceFileAction
#import <UIKit/UIKit.h>

@interface HelloViewController : UIViewController
- (void)sayHi;
@end

@implementation HelloViewController
- (void)sayHi {NSLog(@"sayHi");
  NSLog(@"Hello world!");
}

- (void)sayHi123 {NSLog(@"sayHi123");
  NSLog(@"Hello world!");
}
@end

test.m 文件里面的方法已经加入我们需要的代码块了。现在我们的工具已经可以了,接下来就是如何跟 Xcode 编译一起运行了。

嵌入 Xcode 编译

接下来我们把这个工具加入到 Xcode 的编译中,可以看一下 Targets-Build Phases

这里就是 Xcode 编译执行的任务顺序,其中 Compile Sources 就是编译相关的文件。那其实我们可以这样,在编译文件之前先运行工具添加代码,在编译文件之后再吧文件改回来,这样就做到了既不修改文件,又能将我们相关的代码编译到 APP 中。修改后 Build Phases 如下:

上面的脚本会复制保存一份原代码,等编译完成之后下面的脚本会将原代码复制回原文件中。这次我们尝试对一个文件进行,试试看效果如何。这两个命令分别如下:

cp /Users/wuyanji/Documents/Build/Build/ViewController.m /Users/wuyanji/Documents/Build/Build/ViewController.m.temp
/Users/wuyanji/Desktop/LLVM/llvm-project/build/Debug/bin/AddCodePlugin /Users/wuyanji/Documents/Build/Build/ViewController.m -- -x objective-c -arch $ARCHS -std=gnu99 -fobjc-arc -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk -mios-simulator-version-min=9.0 -F/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks -I/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include/c++/v1 -I/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/usr/include
mv /Users/wuyanji/Documents/Build/Build/ViewController.m.temp /Users/wuyanji/Documents/Build/Build/ViewController.m

最后看一下运行结果,果然如我们所愿,触发方法的时候会同时把方法名打印出来:

三、 总结

这次我们开发了一个基于 LibTooling 的自动添加打印方法名的功能,要做到自动埋点只需在此基础上继续开发即可。关键在于 LibTooling 可以完全的控制编译过程,这给我们留出了很多的操作空间,据此可以做的其他功能还有很多,有想法可以自己尝试。

代码

四、相关文章

打造基于Clang LibTooling的iOS自动打点系统CLAS(一)

打造基于Clang LibTooling的iOS自动打点系统CLAS(二)

打造基于Clang LibTooling的iOS自动打点系统CLAS(三)

TUTORIAL FOR BUILDING TOOLS USING LIBTOOLING AND LIBASTMATCHERS

Objective-C混淆之方法名混淆

如何利用 Clang 为 App 提质?