11:33 am
Thursday, 7 July 2022 (GMT+8)
Time in Guangzhou, Guangdong Province, China


概述

本文基于Android 11介绍动态日志ProtoLog的实现原理。

ProtoLog编译期插入代码,用于实现动态启停的判断逻辑,在编译期实现字符串哈希管理,用于实现日志数据量的压缩。执行这些工作的主要工具是ProtoLogTool

原理

本节介绍ProtoLog在编译期的工作、以及动态启停的实现。

编译

在Framework Services编译过程中,会执行动态日志相关的编译。关键的编译配置在framework/services/core/Android.bp中,如下。可以看到,services在编译过程中会调用protologtool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
genrule {
name: "services.core.protologsrc",
srcs: [
":services.core.wm.protologgroups",
":services.core-sources",
],
tools: ["protologtool"],
cmd: "$(location protologtool) transform-protolog-calls " +
"--protolog-class com.android.server.protolog.common.ProtoLog " +
"--protolog-impl-class com.android.server.protolog.ProtoLogImpl " +
"--protolog-cache-class 'com.android.server.protolog.ProtoLog$$Cache' " +
"--loggroups-class com.android.server.wm.ProtoLogGroup " +
"--loggroups-jar $(location :services.core.wm.protologgroups) " +
"--output-srcjar $(out) " +
"$(locations :services.core-sources)",
out: ["services.core.protolog.srcjar"],
}

protologtool的编译配置如下。它通过javaparser将源码中ProtoLog相关的调用进行替换,并通过jsonlib生成Config、通过platformprotos提供ProtoBuf支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java_library_host {
name: "protologtool-lib",
srcs: [
"src/com/android/protolog/tool/**/*.kt",
],
static_libs: [
"protolog-common",
"javaparser",
"platformprotos",
"jsonlib",
],
}

java_binary_host {
name: "protologtool",
manifest: "manifest.txt",
static_libs: [
"protologtool-lib",
],
}

总结来说,**ProtoLog会借助ProtoLogTool在编译期通过JavaParser将动态日志进行实现**。下面分析ProtoLogTool即可了解ProtoLog的实现了。

ProtoLogTool

ProtoLogTool主要负责三大重要事务。一是找到源码中ProtoLog的调用;二是将ProtoLog调用替换为真正的动态日志实现,这个过程会执行日志数据量压缩工作所要求的哈希计算和引用替换;三是将生成对应的哈希-字符串Map、Group、TAG到Json中——称为Config,没有该文件的情况下是无法解析ProtoBuf的

其中,找到ProtoLog的调用、替换调用等功能一般会基于源码词法分析或字节码分析、插桩。ProtoLog采用的是JavaParser,它是源码级的Analyse、Transform工具。

ProtoLogTool关键流程如下(源码可以参考“参考”节中列出的源码地址)。ProtoLogTool将三个关键步骤分为对应的三个分支流程,包括生成Config、解析读取PB文件以及编译处理。

1
2
3
4
5
6
7
8
9
10
11
12
fun invoke(command: CommandOptions) {
StaticJavaParser.setConfiguration(ParserConfiguration().apply {
setLanguageLevel(ParserConfiguration.LanguageLevel.RAW)
setAttributeComments(false)
})

when (command.command) {
CommandOptions.TRANSFORM_CALLS_CMD -> processClasses(command)
CommandOptions.GENERATE_CONFIG_CMD -> viewerConf(command)
CommandOptions.READ_LOG_CMD -> read(command)
}
}

processClasses()是决定动态日志功能实现的关键。阅读源码可知,它通过JavaParser实现了源码级插桩。细节是找到所有ProtoLog的日志方法的调用,然后通过Transformer进行源码级别的替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
private fun processClasses(command: CommandOptions) {
val groups = injector.readLogGroups(
command.protoLogGroupsJarArg,
command.protoLogGroupsClassNameArg)
val out = injector.fileOutputStream(command.outputSourceJarArg)
val outJar = JarOutputStream(out)
val processor = ProtoLogCallProcessor(command.protoLogClassNameArg,
command.protoLogGroupsClassNameArg, groups)

val executor = newThreadPool()

try {
command.javaSourceArgs.map { path ->
executor.submitCallable {
val transformer = SourceTransformer(command.protoLogImplClassNameArg,
command.protoLogCacheClassNameArg, processor)
val file = File(path)
val text = injector.readText(file)
...
...

open fun process(code: CompilationUnit, callVisitor: ProtoLogCallVisitor?, fileName: String):
CompilationUnit {
CodeUtils.checkWildcardStaticImported(code, protoLogClassName, fileName)
CodeUtils.checkWildcardStaticImported(code, protoLogGroupClassName, fileName)

val isLogClassImported = CodeUtils.isClassImportedOrSamePackage(code, protoLogClassName)
val staticLogImports = CodeUtils.staticallyImportedMethods(code, protoLogClassName)
val isGroupClassImported = CodeUtils.isClassImportedOrSamePackage(code,
protoLogGroupClassName)
val staticGroupImports = CodeUtils.staticallyImportedMethods(code, protoLogGroupClassName)

code.findAll(MethodCallExpr::class.java)
.filter { call ->
isProtoCall(call, isLogClassImported, staticLogImports)
}.forEach { call ->
val context = ParsingContext(fileName, call)
if (call.arguments.size < 2) {
throw InvalidProtoLogCallException("Method signature does not match " +
"any ProtoLog method: $call", context)
}

val messageString = CodeUtils.concatMultilineString(call.getArgument(1),
context)
val groupNameArg = call.getArgument(0)
val groupName =
getLogGroupName(groupNameArg, isGroupClassImported,
staticGroupImports, fileName)
if (groupName !in groupMap) {
throw InvalidProtoLogCallException("Unknown group argument " +
"- not a ProtoLogGroup enum member: $call", context)
}

callVisitor?.processCall(call, messageString, LogLevel.getLevelForMethodName(
call.name.toString(), call, context), groupMap.getValue(groupName))
}
...

可以看到,ProtoLogTool为了实现日志压缩,会将字符串做哈希替换,并将日志打印方法替换为真正具有动态能力的实现。这是源码级别的插桩,它通过JavaParser实现。如果熟悉JavaParser容易读懂代码。

下面看看经过ProtoLogTool处理后的产物,看看ProtoLog的动态能力的技术原理。

反编译service.jar,找一个ProtoLog的案例。如下,以DisplayPolicy.finishScreenTurningOn()为例。

1
2
3
4
5
public boolean finishScreenTurningOn() {
...
ProtoLog.i(WM_DEBUG_SCREEN_ON, "Finished screen turning on...");
...
}

编译后的代码如下(从dex反编译而来,只列出ProtoLog部分):

1
2
3
4
5
6
7
8
if (!protoLogParam02 && this.mScreenOnEarly && this.mWindowManagerDrawComplete && (!this.mAwake || this.mKeyguardDrawComplete)) {
if (ProtoLog.Cache.WM_DEBUG_SCREEN_ON_enabled) {
ProtoLogImpl.i(ProtoLogGroup.WM_DEBUG_SCREEN_ON, 1140424002, 0, (String) null, (Object[]) null);
}
this.mScreenOnListener = null;
this.mScreenOnFully = true;
return true;
}
  • 可以看到,动态日志的动态能力实现的基本思路是判断一个开关是否被打开,该开关控制了对应的动态日志打印代码是否被执行

  • 动态日志打印方法ProtoLog实际上被替换成了ProtoLogImpl,并且不再直接使用字符串,而是使用了一个整型常量

  • 动态日志开关

动态日志开关(即上面的WM_DEBUG_SCREEN_ON_enabled)是由ProtoLogImpl在编译期生成的。该值是Group内的一个属性,并有IProtoLogGroup接口用作动态设置:

1
2
3
4
5
6
7
8
9
10
11
12
public interface IProtoLogGroup {
String getTag();
boolean isEnabled();
boolean isLogToLogcat();
boolean isLogToProto();
String name();
void setLogToLogcat(boolean z);
void setLogToProto(boolean z);
default boolean isLogToAny() {
return isLogToLogcat() || isLogToProto();
}
}

WindowMangager暴露了adb ShellCommand接口用于接收动态日志的启停命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// frameworks/base/services/core/java/com/android/server/protolog/ProtoLogImpl.java
private int setLogging(ShellCommand shell, boolean setTextLogging, boolean value) {
String group;
while ((group = shell.getNextArg()) != null) {
IProtoLogGroup g = LOG_GROUPS.get(group);
if (g != null) {
if (setTextLogging) {
g.setLogToLogcat(value);
} else {
g.setLogToProto(value);
}
} else {
logAndPrintln(shell.getOutPrintWriter(), "No IProtoLogGroup named " + group);
return -1;
}
}
sCacheUpdater.run();
return 0;
}

public int onShellCommand(ShellCommand shell) {
PrintWriter pw = shell.getOutPrintWriter();
String cmd = shell.getNextArg();
if (cmd == null) {
return unknownCommand(pw);
}
switch (cmd) {
case "start":
startProtoLog(pw);
return 0;
case "stop":
stopProtoLog(pw, true);
return 0;
case "status":
logAndPrintln(pw, getStatus());
return 0;
case "enable":
return setLogging(shell, false, true);
case "enable-text":
mViewerConfig.loadViewerConfig(pw, VIEWER_CONFIG_FILENAME);
return setLogging(shell, true, true);
case "disable":
return setLogging(shell, false, false);
case "disable-text":
return setLogging(shell, true, false);
default:
return unknownCommand(pw);
}
}

总结:ProtoLog动态启停的原理是以一个个的Group的开关为桥梁的。动态日志打印前会判断这个开关,并能够通过ShellCommand这一接口实时设置开关值。其中,对开关的判断和管理代码均是由ProtoLogTool在编译期完成生成的,属于源码级Transform,并不需要用户手动添加难看的开关判断逻辑。

  • 日志数据量压缩

可以看到ProtoLogTool除了将ProtoLog替换成ProtoLogImpl外,还将打印的字符串替换成哈希值。ProtoLogImpl实现数据量压缩的原理如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static void i(IProtoLogGroup group, int messageHash, int paramsMask,
@Nullable String messageString,
Object... args) {
getSingleInstance().log(LogLevel.INFO, group, messageHash, paramsMask, messageString, args);
}

public void log(LogLevel level, IProtoLogGroup group, int messageHash, int paramsMask,
@Nullable String messageString, Object[] args) {
if (group.isLogToProto()) {
logToProto(messageHash, paramsMask, args);
}
if (group.isLogToLogcat()) {
logToLogcat(group.getTag(), level, messageHash, messageString, args);
}
}

private void logToProto(int messageHash, int paramsMask, Object[] args) {
if (!isProtoEnabled()) {
return;
}
try {
ProtoOutputStream os = new ProtoOutputStream();
long token = os.start(LOG);
os.write(MESSAGE_HASH, messageHash);
os.write(ELAPSED_REALTIME_NANOS, SystemClock.elapsedRealtimeNanos())
...
...
}

日志数据量压缩的关键逻辑就是ProtoOutputStream.write(MESSAGE_HASH,messageHash)了。可见,Message确实被ProtoLogTool转化成了对应的哈希,并通过ProtoOutputStream写入到ProtoBuf中,最后输出成为二进制日志。这就是日志数据量压缩的基本原理——日志不再存储字符串本身,而是存储字符串的哈希。解析器根据字符串与哈希的映射,反向操作解析出字符串。这样做能够使冗余的字符串(一个日志打印方法在业务逻辑中、进程运行过程中会多次调用,使日志存储重复的冗余的串)被替换成定长的简短的哈希。

总结

ProtoLog提供的简单接口,需要在编译期由ProtoLogTool进行源码级的替换,生成对日志开关的检查代码来实现动态日志。并通过哈希-String映射实现数据量压缩。

参考

  1. Android Framework Service.core Android.bp
  2. ProtoLogTool