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
主要负责三大重要事务。一是找到源码中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 ; }
动态日志开关(即上面的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 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映射实现数据量压缩。
参考
Android Framework Service.core Android.bp
ProtoLogTool