阅读分析slf4j的日志源码,发现其中涵盖了许多知识点和优秀的设计,关键它们都是活生生的实践案例。写成系列文章与大家分享,欢迎持续关注。第2篇。

前言

在上篇文章中我们讲到了LoggerFactory获取SLF4JServiceProvider实例类时使用到了双重加锁的机制,同时拓展了单例模式的典型场景。在完成加锁及判断之后,调用了初始化方法performInitialization。在该方法中最重要的操作便是调用bind方法。

在bind方法中才真正进行了SLF4JServiceProvider实例的初始化及获取操作。我们将bind方法进行简化处理,核心代码如下:

private static void bind() {
    List<SLF4JServiceProvider> providersList = findServiceProviders();
    if(!providersList.isEmpty()) {
        PROVIDER = providersList.get(0);
        PROVIDER.initialize();
        INITIALIZATION_STATE = SUCCESSFUL_INITIALIZATION;
    } else {
        INITIALIZATION_STATE = NOP_FALLBACK_INITIALIZATION;
    }
    postBindCleanUp();
}

这节课我们重点来了解findServiceProviders是如何加载和获取SLF4JServiceProvider实例的底层机制。

Java的SPI机制

findServiceProviders方法的代码如下:

private static List<SLF4JServiceProvider> findServiceProviders() {
    ServiceLoader<SLF4JServiceProvider> serviceLoader = ServiceLoader.load(SLF4JServiceProvider.class);
    List<SLF4JServiceProvider> providerList = new ArrayList<>();
    for(SLF4JServiceProvider provider : serviceLoader) {
        providerList.add(provider);
    }
    return providerList;
}

通过调用ServiceLoader#load方法,加载了一个具有遍历(实现了Iterable)能力的ServiceLoader对象。然后遍历ServiceLoader中的SLF4JServiceProvider对象,存入List当中并返回List。看起来很简单的一个操作,便涉及到我们今天要讲的SPI机制。ServiceLoader#load方法的调用便是使用SPI的标志。

什么是SPI

SPI是Service Provider Interface的缩写,是Java内置的服务发现机制。

在开发的过程中,可以将一些通用的功能抽象成API,对API提供各种具体的实现,这是很正常的场景。但以日志框架为例,是否可以通过提供一套API,当切换底层日志框架时不用修改调用它们API的代码,只用替换成新的实现了该API的jar包即可?很明显slf4j就是用来做这件事的,而它底层便运用了SPI机制。

通过Java的SPI机制,可以实现框架的动态扩展,让第三方的实现能像插件一样嵌入到系统中,我们经常使用数据库API便是另外一个应用场景。

SPI的简单实现

实现SPI主要分三步:1、定义一个接口;2、提供方的“META-INF/services”目录下新建一个名称为接口全限定名的文本文件,内容为接口实现类的全限定名。3、调用方通过ServiceLoader#load方法加载接口的实现类实例。

在此我们就不单独写示例了,直接以slf4j中的实现为例,来进行讲解。

第一步,定义一个接口,这个很显然,接口便是SLF4JServiceProvider:

public interface SLF4JServiceProvider {

    ILoggerFactory getLoggerFactory();

    IMarkerFactory getMarkerFactory();

    MDCAdapter getMDCAdapter();

    String getRequesteApiVersion();

    void initialize();
}

那么,这个接口是由谁来实现呢?理论说应该是有各个日志框架提供方来实现。比如Logback日志框架就默认实现了该接口,实现类为ch.qos.logback.classic.spi.LogbackServiceProvider。为什么说是理论上呢?这里Logback与slf4j是同一作者,可以在Logback中实现该接口,但像Logback的竞争对手log4j2,并不会主动实现该接口的。

因此,在slf4j的项目中出现了一些名称为:slf4j-log4j12、slf4j-jdk14、slf4j-simple的项目。这些项目存在的目的之一就是来间接实现SLF4JServiceProvider接口。同时,还会提供第二步的配置。

下面来看第二步:提供方的“META-INF/services”目录下新建一个名称为接口全限定名的文本文件,内容为接口实现类的全限定名。

在slf4j中默认提供了一个空的Logger,对应的也有一个NOPServiceProvider实现类。与sl4j的同级项目中单独有一个slf4j-nop项目,就是来提供空日志记录操作的。

首先NOPServiceProvider实现了接口SLF4JServiceProvider,其次在slf4j-nop的META-INF/services目录下有一个名称为org.slf4j.spi.SLF4JServiceProvider的文本文件,文件内的内容为:

org.slf4j.helpers.NOPServiceProvider
image

完成了接口的定义,接口的实现,以及接口实现方按照指定的格式提供配置文件之后,就可以通过ServiceLoader#load方法加载接口的实现类实例了。关于加载的具体示例,可参考findServiceProviders方法中的代码。值得留意的是ServiceLoader#load方法会加载类路径中所有的jar里面对应的SPI配置类。

其实,ServiceLoader调用load方式并没有进行加载操作,只有当遍历的时候才真正加载和初始化配置文件中的类。

SPI实现的关键代码

上面我们已经提到,在调用load方法时ServiceLoader并不会加载对应的配置项,而是在遍历的时候进行加载。先来看一下ServiceLoader的load方法源码:

public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return new ServiceLoader<>(Reflection.getCallerClass(), service, cl);
}

可以看到只是初始化了一个ServiceLoader,并赋值相应的属性值。而真正进行初始化或加载操作是在foreach方法执行时。在iterator方法中会初始化一个LazyClassPathLookupIterator类,该类为ServiceLoader的内部类,同样实现了Iterator接口,在实现的hasNext和next方法中完成了配置文件的加载和类的实例化。下面来看一下该类的核心内容:

private final class LazyClassPathLookupIterator<T> implements Iterator<Provider<T>>{
    // 扫描路径的前缀
    static final String PREFIX = "META-INF/services/";

    // 避免重复,采用Set存储
    Set<String> providerNames = new HashSet<>(); 
    // 扫描到的路径集合
    Enumeration<URL> configs;
    Iterator<String> pending;

    Provider<T> nextProvider;
    ServiceConfigurationError nextError;

    LazyClassPathLookupIterator() { }

    private Class<?> nextProviderClass() {
        if (configs == null) {
            try {
                // 拼接扫描路径+名称
                String fullName = PREFIX + service.getName();
                if (loader == null) {
                    configs = ClassLoader.getSystemResources(fullName);
                } else if (loader == ClassLoaders.platformClassLoader()) {
                    // The platform classloader doesn't have a class path,
                    // but the boot loader might.
                    if (BootLoader.hasClassPath()) {
                        configs = BootLoader.findResources(fullName);
                    } else {
                        configs = Collections.emptyEnumeration();
                    }
                } else {
                    configs = loader.getResources(fullName);
                }
            } catch (IOException x) {
                fail(service, "Error locating configuration files", x);
            }
        }
        while ((pending == null) || !pending.hasNext()) {
            if (!configs.hasMoreElements()) {
                return null;
            }
            // 解析配置文件中的内容
            pending = parse(configs.nextElement());
        }
        String cn = pending.next();
        try {
            // 根据配置进行类的初始化
            return Class.forName(cn, false, loader);
        } catch (ClassNotFoundException x) {
            fail(service, "Provider " + cn + " not found");
            return null;
        }
    }
    // ...
}

上述代码省略了细节操作,我们可以看到该内部类会根据默认的路径类名进行拼接扫描,这也就是为什么我们第二步要按照指定的格式进行定义。扫描到对应的文件之后,会对文件中配置的类的全限定名字符串进行加载,最后通过Class#forName进行初始化。

关于ServiceLoader的源代码就讲这么多,大家如果有兴趣,可以通过断点来跟踪一下,更方便理解细节。看完源代码其实就很容易明白SPI机制,本质上SPI机制就是定义一个约定,大家都按照这个约定来,然后使用者根据约定去解析使用即可。明白了这个底层原理,读者自己也可以实现一个简单的SPI。而dubbo对SPI的实现就是基于自身的功能场景进行了拓展,这就是为什么我们要活学活用的原因。

小结

通过本篇文章我们了解了LoggerFactory在获取SLF4JServiceProvider时所使用的SPI机制,不同的日志框架直接或间接实现sl4j定义的接口,并遵从SPI机制,sl4j便会对其进行加载和初始化处理。同时,也阅读了一下ServiceLoader的核心代码,更方便学习和了解SPI。

本篇文章就到这里了,阅读源码是不是也很有意思,很有收获吧?下一篇我们继续跟进,期待一下吧。



02 slf4j中是如何运用SPI机制兼容各种日志框架的?插图1

关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台

除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接

本文链接:http://www.choupangxia.com/2020/11/17/slf4j-2/