Skip to content

Code Analysis SLF4J

Tu Shaokun edited this page Nov 6, 2026 · 5 revisions

SLF4J 由什么决定实际使用的日志实现?

上游提交 9783320b 给 LoggerFactory 增加了按类名指定实现的路径,并新增一份测试。该历史提交读取的属性名是 slf4j.binding。这里核对的两版分别是它的父提交 aa13da39 和该提交本身。上游差异

五组运行对照

用 JDK 17 从各版本的 API、Simple 和 NOP 三个模块直接编译 51 个上游 Java 源文件,再加入一个读结果的小程序。两种提供者类始终在 classpath 中,一次只加入表里指定的服务描述文件。每项使用新的 JVM,分别核对 ServiceLoader 看到的提供者、LoggerFactory 选定的对象和公开方法返回的工厂。

放入 classpath 的描述文件 属性值 变更前实际选择 变更后实际选择
NOP 未设置 NOP NOP
NOP Simple 实现的全限定类名 NOP Simple
NOP java.lang.String,类型不符 NOP NOP
NOP 不存在的类 NOP NOP
Simple 未设置 Simple Simple

变更后,显式类名能反射构造提供者时,代码初始化它并提前返回。类名无效时则回到服务发现。两组运行可以使用完全相同的编译产物和 NOP 描述文件,只靠一个系统属性改变实际对象。选择与回退 · 上游对错误类型的测试

Labs 能回答其中哪部分?

  • 公开 Labs bf0a29e7 对两个完整源码目录分别捕获 259、260 个 Java 文件,输出 2,415、2,436 条导入或类型引用。后版能定位 bind(181–215 行)、loadExplicitlySpecified(217–236 行)和服务发现方法。两版各报告一份迁移文本样本解析失败,路径是 slf4j-migrator/jcl/jclcontent.java。
  • LoggerFactory、Simple 和 NOP 提供者到 SLF4JServiceProvider 的三个显式导入,在两版都指到正确的接口文件。后版还捕获 8 份 module-info.java。Simple 的 provides … with … 产生两条静态类型记录,分别指向接口和实现。由此可以找到实现源码。Simple 模块声明
  • 仓库有四份同名的 META-INF/services/org.slf4j.spi.SLF4JServiceProvider 描述文件,均在 Java 源文件清单之外。Java 调用记录为 0。要解释一次 classpath 运行,应同时查实际资源目录、系统属性和选择代码。上表采用普通 classpath;JPMS 模块路径和完整 Maven 构建需要各自的运行对照。NOP 描述文件 · Simple 描述文件
用 Git 与 JDK 17 复现十次独立运行

在 Unix shell 配好 java 和 javac,使用下面固定的两版。每行输出依次是服务描述中声明的提供者、实际选中的对象、公开工厂的类名。

set -eu
w=$(mktemp -d)
git init -q "$w/slf4j"
git -C "$w/slf4j" fetch -q --depth=2 https://github.com/qos-ch/slf4j.git 9783320b6c4411a0b120cad9e32cd9a981b1dac2
git -C "$w/slf4j" checkout -q --detach FETCH_HEAD
git -C "$w/slf4j" worktree add -q --detach "$w/parent" 'HEAD^'
cat > "$w/ProviderProbe.java" <<'JAVA'
package org.slf4j;

import java.util.ServiceLoader;
import org.slf4j.spi.SLF4JServiceProvider;

public final class ProviderProbe {
    public static void main(String[] args) {
        String installed = ServiceLoader.load(SLF4JServiceProvider.class).iterator().next().getClass().getName();
        String factory = LoggerFactory.getILoggerFactory().getClass().getName();
        String chosen = LoggerFactory.getProvider().getClass().getName();
        System.out.println(installed + "|" + chosen + "|" + factory);
    }
}
JAVA
for side in parent slf4j; do
  src="$w/$side"
  out="$w/classes-$side"
  mkdir "$out"
  (cd "$src"
   git ls-files 'slf4j-api/src/main/java/*.java' 'slf4j-simple/src/main/java/*.java' 'slf4j-nop/src/main/java/*.java' > "$w/sources"
   javac --release 8 -encoding UTF-8 -d "$out" @"$w/sources" "$w/ProviderProbe.java")
  run() {
    provider=$1
    shift
    java "$@" -cp "$out:$src/slf4j-$provider/src/main/resources" org.slf4j.ProviderProbe
  }
  echo "$side"
  run nop
  run nop -Dslf4j.binding=org.slf4j.simple.SimpleServiceProvider
  run nop -Dslf4j.binding=java.lang.String 2>"$w/$side-type.log"
  run nop -Dslf4j.binding=example.MissingProvider 2>"$w/$side-missing.log"
  run simple
done

Guava

Guava v33.4.8 的 JRE 和 Android 发布物各有一个 guava 模块,分别从 guava/src 和 android/guava/src 取源码。两棵树有 606 个相同的 Java 相对路径,其中 152 对文件内容不同。跟踪某个版本的实现时,先从它的 POM 确认源码目录,再把这个目录传给分析器。JRE 父 POM · Android 父 POM

两边各选了三处导入。Strings.java 第 17、18 行从 Preconditions 静态导入两个方法,两个版本的目标文件内容相同。Files.java 第 29 行导入 ImmutableList,两个版本的目标文件内容不同。用 JDK 17 的标准文件管理器逐一指定源码目录,独立查出这两个类型在各版本的文件路径。对照的是导入所属的源码文件,未执行 Maven 构建。两个静态导入 · 普通导入 · JRE 实现 · Android 实现

公开 Labs bf0a29e7 的扫描范围 Java 文件清单 所选导入的结果 报告跳过的文件
两套生产源码一起扫描 1,229 6/6 标为未解析,均未指定目标文件 14
仅 JRE 源码目录 615 3/3 指向 JRE 文件,与 JDK 一致 7
仅 Android 源码目录 614 3/3 指向 Android 文件,与 JDK 一致 7

两边跳过的是相同的七个相对路径,诊断都指向 Java Unicode 转义预处理。例如 Stopwatch 的微秒字符串和 Cut 的无穷符号都触发了这一限制。即使 CLI 返回成功,这些文件的导入和方法仍缺席。复查覆盖时可以直接看 raw-facts.json 中的 parse_errors。后续已完成这七类文件的逐项验证,并将修复提交为 Draft #1218。Unicode 转义验证与 PR · Stopwatch 源码 · Cut 源码 · Labs 的跳过条件

复现 Guava 的三次扫描,逐处与 JDK 源码查询比对

在 Unix shell 中准备 Git、JDK 17、Node 22、npm 和 Python 3。脚本会拉取两个固定提交,只为临时分析器副本安装锁定依赖。输出包括六个导入位置在两种扫描范围下的结果,以及每次扫描的文件数和解析错误数。

set -eu
w=$(mktemp -d)
pin() {
  git init -q "$w/$1"
  git -C "$w/$1" fetch -q --no-tags --depth=1 "https://github.com/$2.git" "$3"
  git -C "$w/$1" checkout -q --detach FETCH_HEAD
}
pin guava google/guava f06690fa3e874f65515e8fd338a74d636e2c792f
pin archify tt-a1i/archify bf0a29e7664f52973d06a8e6a608df2db1ecbd39
(cd "$w/archify/archify/modules/code-analysis" && npm ci --ignore-scripts --no-audit --no-fund)
cli="$w/archify/archify/bin/archify.mjs"
scan() {
  mode=$1
  root=$2
  shift 2
  node "$cli" code-analysis check "$root" --language java --fail-on never \
    --json --out "$w/$mode" "$@" > "$w/$mode-receipt.json"
}
printf '%s\n' '{"include":["guava/src/**/*.java","android/guava/src/**/*.java"]}' > "$w/both.json"
scan both "$w/guava" --config "$w/both.json"
scan jre "$w/guava/guava/src"
scan android "$w/guava/android/guava/src"
cat > "$w/SourceLookup.java" <<'JAVA'
import java.nio.file.*;
import java.util.List;
import javax.tools.*;

public class SourceLookup {
    public static void main(String[] args) throws Exception {
        Path repo = Paths.get(args[0]).toRealPath();
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        for (String root : List.of("guava/src", "android/guava/src")) {
            try (StandardJavaFileManager fm = compiler.getStandardFileManager(null, null, null)) {
                fm.setLocationFromPaths(StandardLocation.SOURCE_PATH, List.of(repo.resolve(root)));
                for (String type : List.of("com.google.common.base.Preconditions", "com.google.common.collect.ImmutableList")) {
                    JavaFileObject source = fm.getJavaFileForInput(StandardLocation.SOURCE_PATH, type, JavaFileObject.Kind.SOURCE);
                    Path found = Paths.get(source.toUri()).toRealPath();
                    Path expected = repo.resolve(root).resolve(type.replace('.', '/') + ".java");
                    if (!Files.isSameFile(found, expected)) throw new AssertionError(found);
                    System.out.println(root + "|" + type + "|" + repo.relativize(found));
                }
            }
        }
    }
}
JAVA
java "$w/SourceLookup.java" "$w/guava" > "$w/jdk.txt"
python3 - "$w" <<'PY'
import json, pathlib, sys
w = pathlib.Path(sys.argv[1])
facts = {k: json.loads((w / k / 'raw-facts.json').read_text()) for k in ('both', 'jre', 'android')}
jdk = {}
for row in (w / 'jdk.txt').read_text().splitlines():
    root, owner, target = row.split('|')
    jdk[root, owner] = target
sites = [('base/Strings.java', 17, 'base.Preconditions.checkArgument', 'base.Preconditions'),
         ('base/Strings.java', 18, 'base.Preconditions.checkNotNull', 'base.Preconditions'),
         ('io/Files.java', 29, 'collect.ImmutableList', 'collect.ImmutableList')]
def record(mode, path, line, spec):
    item, = (r for r in facts[mode]['imports'] if
             (r['from'], r['line'], r['specifier']) == (path, line, 'com.google.common.' + spec))
    return item
for mode, root in [('jre', 'guava/src'), ('android', 'android/guava/src')]:
    for short, line, spec, owner in sites:
        path = 'com/google/common/' + short
        combined = record('both', root + '/' + path, line, spec)
        single = record(mode, path, line, spec)
        target = jdk[root, 'com.google.common.' + owner]
        assert not combined['resolved'] and 'to' not in combined
        assert single['resolved'] and root + '/' + single['to'] == target
        print(f'{mode} {short}:{line} | combined: unresolved | single: {target}')
for mode, raw in facts.items():
    print(mode, 'files', len(raw['files']), 'parse_errors', len(raw['parse_errors']))
PY

返回团队验证记录

Static import namespace follow-up

A separate compiler-verified static-import case found that a field or method import could hide an unrelated same-name type from the analyzer. Draft #1231 corrects two authored missing targets. The pinned SLF4J and Guava scopes above are preservation controls: all their import/type-reference records remain unchanged, including existing parse-error counts. No occurrence of this trigger was found in the Guava audit.

Clone this wiki locally