Understanding the SPI Mechanism in Dubbo Extensions

1. What is SPI

1.1 Introduction to SPI

SPI, which stands for Service Provider Interface, is a service discovery mechanism. It automatically loads the classes defined in files located in the META-INF/services directory on the ClassPath. This mechanism enables extensibility for many frameworks, such as Dubbo and JDBC. Let’s start with a simple example to see how it works.

In simple terms, SPI is an extension mechanism that configures services without modifying the core code. By loading services from configuration files and determining the logic to execute based on passed parameters, it adheres to the open-closed principle: open for extension, closed for modification.

1.2 Application Scenarios of SPI

When writing core code, if a certain point involves different logic based on varying parameters, without SPI, you might end up writing a lot of if-else statements, making the code inflexible. If a new logic is added one day, the code must be modified, which violates the open-closed principle. The emergence of SPI addresses this extension issue; you can configure all implementation classes in a configuration file, and in the core code, simply load the configuration file and match the loaded classes based on input parameters. If a match is found, that logic is executed. Thus, if new logic is added in the future, the core code remains unchanged; only the configuration file and new classes in your project need to change, complying with the open-closed principle.

2. SPI Mechanism in JDK

Having introduced what SPI is and where it is used, we will now focus on its implementation in the JDK.

2.1 Example

2.1.1 Top-Level Interface

public interface KLog {
   boolean support(String type);
   void debug();
   void info();
}

2.1.2 Interface Implementation

public class Log4j implements KLog {
    @Override
    public boolean support(String type) {
        return "log4j".equalsIgnoreCase(type);
    }
    @Override
    public void debug() {
        System.out.println("====log4j.debug======");
    }
    @Override
    public void info() {
        System.out.println("====log4j.info======");
    }
}
public class Logback implements KLog {
    @Override
    public boolean support(String type) {
        return "Logback".equalsIgnoreCase(type);
    }
    @Override
    public void debug() {
        System.out.println("====Logback.debug======");
    }
    @Override
    public void info() {
        System.out.println("====Logback.info======");
    }
}
public class Slf4j implements KLog {
    @Override
    public boolean support(String type) {
        return "Slf4j".equalsIgnoreCase(type);
    }
    @Override
    public void debug() {
        System.out.println("====Slf4j.debug======");
    }
    @Override
    public void info() {
        System.out.println("====Slf4j.info======");
    }
}

2.1.3 File Configuration

Create a file in the resources/META-INF/services directory, and the file name must match the fully qualified name of the interface. As shown in the image:

Understanding the SPI Mechanism in Dubbo ExtensionsThis interface file configures the fully qualified names of all implementation classes for that interface, as shown in the image:Understanding the SPI Mechanism in Dubbo Extensions

Now, based on the input parameters, we can decide whether to execute the logic for Log4j or Logback.

2.1.4 Unit Test

public class MyTest {
    // Different input parameters correspond to different logic calls
    public static void main(String[] args) {
        // This is the core business code, which will decide which instance to call based on external parameters
        // It can read the properties configuration file to determine which instance to call
        // JDK API loads the configuration file to configure instances
        ServiceLoader<KLog> all = ServiceLoader.load(KLog.class);
        Iterator<KLog> iterator = all.iterator();
        Scanner scanner = new Scanner(System.in);
        String s = scanner.nextLine();
        while (iterator.hasNext()) {
            KLog next = iterator.next();
            // Check if this instance is the one we need to call
            // Strategy pattern: check if the current instance matches the input parameter
            if (next.support(s)) {
                next.debug();
            }
        }
    }
}

2.2 Summary

We can view this unit test code as the core code of our business, which does not need to be modified. The only extensions added are the implementation classes corresponding to the interface. In the core code, we only need to call different logic based on the input parameters. This is the charm of SPI extension: the core code remains unchanged.

From the above test code, we also learn that the SPI in the JDK is a way to obtain class instances, and when combined with the strategy pattern, it allows for instance selection based on parameters.

3. SPI Mechanism in Dubbo

The SPI mechanism in Dubbo is generally similar to that in the JDK, but there are many differences in details. Let’s take a closer look at the SPI mechanism in Dubbo.

3.1 Example

3.1.1 Top-Level Interface

// This is mandatory
@SPI("spring")
public interface ActivateApi {
    @Adaptive
    String todo(String param, URL url);
}

3.1.2 Interface Implementation

@Adaptive
public class DubboActivate implements ActivateApi {
    @Override
    public String todo(String param, URL url) {
        return param;
    }
}
public class MybatisActivate implements ActivateApi {
    private ActivateApi activateApi;
    @Override
    public String todo(String param, URL url) {
        return param;
    }
    public void setActivateApi(ActivateApi activateApi) {
        this.activateApi = activateApi;
        System.out.println(activateApi);
    }
}
public class Rabbitmq1Activate implements ActivateApi {
    @Override
    public String todo(String param, URL url) {
        return param;
    }
}
public class Rabbitmq2Activate implements ActivateApi {
    @Override
    public String todo(String param, URL url) {
        return param;
    }
}
public class RabbitmqActivate implements ActivateApi {
    @Override
    public String todo(String param, URL url) {
        return param;
    }
}
public class SpringActivate implements ActivateApi {
    @Override
    public String todo(String param, URL url) {
        return param;
    }
}
public class SpringCloudActivate implements ActivateApi {
    @Override
    public String todo(String param, URL url) {
        return param;
    }
}

3.1.3 File Configuration

Configure the interface file under resources/META-INF/dubbo, and the file name must match the fully qualified name of the interface, as shown:Understanding the SPI Mechanism in Dubbo ExtensionsThe content configured in the interface file is the fully qualified names of the implementation classes for that interface. Unlike the JDK configuration, Dubbo can include keys, which are the mapping keys for the implementation classes, allowing retrieval of the corresponding class instance based on this key, as shown:Understanding the SPI Mechanism in Dubbo Extensions

3.1.4 Unit Test

@Test
public void adaptive() {
  ActivateApi adaptiveExtension =
ExtensionLoader.getExtensionLoader(ActivateApi.class).getAdaptiveExtension();
  System.out.println(adaptiveExtension.getClass());
}

Next, we will introduce several very important SPI APIs in Dubbo.

3.2 getAdaptiveExtension()

3.2.1 Method Functionality

This method retrieves an implementation class of an interface, with the following retrieval methods:

1. Retrieve an instance of the class annotated with @Adaptive.2. If none of the interface’s implementation classes have the @Adaptive annotation, Dubbo dynamically generates one.

3.2.2 Method Usage

// Specify the interface type to retrieve its implementation class
ActivateApi adaptiveExtension =
ExtensionLoader.getExtensionLoader(ActivateApi.class).getAdaptiveExtension();

3.2.3 Source Code Analysis

3.2.3.1 ExtensionLoader
public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
    if (type == null) {
        throw new IllegalArgumentException("Extension type == null");
    }
    if (!type.isInterface()) {
        throw new IllegalArgumentException("Extension type (" + type + ") is not an interface!");
    }
    // If the interface does not have the @SPI annotation, throw an error
    if (!withExtensionAnnotation(type)) {
        throw new IllegalArgumentException("Extension type (" + type + ") is not an extension, because it is NOT annotated with @" + SPI.class.getSimpleName() + "!");
    }
    // Retrieve from cache
    ExtensionLoader<T> loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    if (loader == null) {
        // Each @SPI interface type corresponds to an ExtensionLoader object
        // Note that one interface type corresponds to one ExtensionLoader object
        EXTENSION_LOADERS.putIfAbsent(type, newExtensionLoader<T>(type));
        loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    }
    return loader;
}
3.2.3.2 getAdaptiveExtension

The core idea is to first retrieve the instance from the cache; if it does not exist, call createAdaptiveExtension() to create an instance. Once created, it is stored in the cache. The cache is a Holder object in ExtensionLoader, such as: Holder cachedAdaptiveInstance = new Holder<>();

public T getAdaptiveExtension() {
    // First retrieve the instance from the cache
    Object instance = cachedAdaptiveInstance.get();
    // DCL principle
    if (instance == null) {
        if (createAdaptiveInstanceError != null) {
            throw new IllegalStateException("Failed to create adaptive instance: " + createAdaptiveInstanceError.toString(), createAdaptiveInstanceError);
        }
        synchronized (cachedAdaptiveInstance) {
            instance = cachedAdaptiveInstance.get();
            if (instance == null) {
                try {
                    // Core method for creating interface instances
                    instance = createAdaptiveExtension();
                    cachedAdaptiveInstance.set(instance);
                } catch (Throwable t) {
                    createAdaptiveInstanceError = t;
                    throw new IllegalStateException("Failed to create adaptive instance: " + t.toString(), t);
                }
            }
        }
    }
    return (T) instance;
}
3.2.3.3 createAdaptiveExtension

This method is responsible for creating instances and performing IOC property dependency injection on them.

private T createAdaptiveExtension() {
    try {
        // injectExtension is Dubbo's IOC logic for assigning values to properties
        return injectExtension((T) getAdaptiveExtensionClass().newInstance());
    } catch (Exception e) {
        throw new IllegalStateException("Can't create adaptive extension " + type + ", cause: " + e.getMessage(), e);
    }
}
3.2.3.4 getAdaptiveExtensionClass

Retrieve and return the Class object of the class to be instantiated.

private Class<?> getAdaptiveExtensionClass() {
    // Core method, focus here. Establish the mapping between names and classes
    getExtensionClasses();
    // If there is a class with the @Adaptive annotation, return that class
    if (cachedAdaptiveClass != null) {
        return cachedAdaptiveClass;
    }
    // Dynamically assemble the class, dynamically compile and generate it
    return cachedAdaptiveClass = createAdaptiveExtensionClass();
}
3.2.3.5 getExtensionClasses

This method is a very core method; many APIs in Dubbo SPI require calling this method first to establish the mapping between keys and classes. The process of obtaining the mapping is also to first retrieve from the cache; if not present, read the configuration file to establish the mapping and cache it.

The purpose of this method:

1. Establish the mapping between keys and classes.2. Assign values to many global variables in ExtensionLoader that are used in Dubbo SPI APIs.

private Map<String, Class<?>> getExtensionClasses() {
    // First retrieve from cache
    Map<String, Class<?>> classes = cachedClasses.get();
    // DCL
    if (classes == null) {
        synchronized (cachedClasses) {
            classes = cachedClasses.get();
            if (classes == null) {
                // Load the key-class relationship from local files
                classes = loadExtensionClasses();
                // Cache the loaded mapping
                cachedClasses.set(classes);
            }
        }
    }
    return classes;
}
3.2.3.6 loadExtensionClasses

This method reads configuration files under resources/META-INF/dubbo and META-INF/dubbo/internal, parses the configuration files, establishes the mapping between keys and classes, assigns values to global variables in ExtensionLoader, and more.

private Map<String, Class<?>> loadExtensionClasses() {
    // Get the default implementation class name and cache it
    cacheDefaultExtensionName();
    Map<String, Class<?>> extensionClasses = new HashMap<>();
    // Retrieve LoadingStrategy instances from JDK's SPI mechanism
    for (LoadingStrategy strategy : strategies) {
        // Load files in the directory to establish the mapping between names and classes. Core logic
        loadDirectory(extensionClasses, strategy.directory(), type.getName(),
        strategy.preferExtensionClassLoader(), strategy.overridden(), strategy.excludedPackages());
        loadDirectory(extensionClasses, strategy.directory(),
        type.getName().replace("org.apache", "com.alibaba"), strategy.preferExtensionClassLoader(),
        strategy.overridden(), strategy.excludedPackages());
    }
    return extensionClasses;
}
3.2.3.6.1 cacheDefaultExtensionName

Sets the value of the global variable cachedDefaultName in ExtensionLoader, which is derived from the value of the @SPI(“spring”) annotation in the interface.

private void cacheDefaultExtensionName() {
    // Get the @SPI annotation on the class
    final SPI defaultAnnotation = type.getAnnotation(SPI.class);
    if (defaultAnnotation == null) {
        return;
    }
    String value = defaultAnnotation.value();
    // If there is a value in the @SPI annotation
    if ((value = value.trim()).length() > 0) {
        String[] names = NAME_SEPARATOR.split(value);
        if (names.length > 1) {
            throw new IllegalStateException("More than 1 default extension name on extension " + type.getName() + ": " + Arrays.toString(names));
        }
        // Set the value to cachedDefaultName, which is the default implementation class
        if (names.length == 1) {
            cachedDefaultName = names[0];
        }
    }
}
3.2.3.6.2 loadDirectory

Loads all files in the directory, establishes the mapping between names and classes, and sets the values of global variables. It calls loadResource in a loop.

while (urls.hasMoreElements()) {
    java.net.URL resourceURL = urls.nextElement();
    // Core method for loading files corresponding to the interface
    loadResource(extensionClasses, classLoader, resourceURL, overridden, excludedPackages);
}
3.2.3.6.3 loadResource

This method processes each file, establishing the mapping between names and classes and setting the values of global variables. It processes each line of data read from the file.

private void loadResource(Map<String, Class<?>> extensionClasses, ClassLoader classLoader,
java.net.URL resourceURL, boolean overridden, String... excludedPackages) {
    try {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(resourceURL.openStream(), StandardCharsets.UTF_8))) {
            String line;
            String clazz = null;
            // Read a line of data
            while ((line = reader.readLine()) != null) {
                final int ci = line.indexOf('#');
                // If there is a # in the line, only keep the part before it; the rest can be comments
                if (ci >= 0) {
                    line = line.substring(0, ci);
                }
                line = line.trim();
                if (line.length() > 0) {
                    try {
                        String name = null;
                        // Split by =
                        int i = line.indexOf('=');
                        if (i > 0) {
                            // The part before = is the key
                            name = line.substring(0, i).trim();
                            // The part after = is the class
                            clazz = line.substring(i + 1).trim();
                        } else {
                            clazz = line;
                        }
                        if (StringUtils.isNotEmpty(clazz) && !isExcluded(clazz, excludedPackages)) {
                            // Core logic for loading the class
                            loadClass(extensionClasses, resourceURL, Class.forName(clazz, true, classLoader), name, overridden);
                        }
                    } catch (Throwable t) {
                        IllegalStateException e = new IllegalStateException("Failed to load extension class (interface: " + type + ", class line: " + line + ") in " + resourceURL + ", cause: " + t.getMessage(), t);
                        exceptions.put(line, e);
                    }
                }
            }
        }
    } catch (Throwable t) {
        logger.error("Exception occurred when loading extension class (interface: " + type + ", class file: " + resourceURL + ") in " + resourceURL, t);
    }
}
3.2.3.6.4 loadClass

This method follows three main logic paths:

1. If the class has the @Adaptive annotation.2. If the class is a wrapper class.3. If it is neither an @Adaptive class nor a wrapper class.

private void loadClass(Map<String, Class<?>> extensionClasses, java.net.URL resourceURL,
Class<?> clazz, String name, boolean overridden) throws NoSuchMethodException {
    // If the class type does not match the interface type, throw an error
    if (!type.isAssignableFrom(clazz)) {
        throw new IllegalStateException("Error occurred when loading extension class (interface: " + type + ", class line: " + clazz.getName() + "), class " + clazz.getName() + " is not subtype of interface.");
    }
    // If the class has the @Adaptive annotation
    if (clazz.isAnnotationPresent(Adaptive.class)) {
        // Assign value to cachedAdaptiveClass variable in this method
        cacheAdaptiveClass(clazz, overridden);
    } else if (isWrapperClass(clazz)) {
        // If it is a wrapper class, it must hold a reference to the target interface with a corresponding constructor
        // Assign value to cachedWrapperClasses variable
        cacheWrapperClass(clazz);
    } else {
        // Get the no-argument constructor of the class; if it is a wrapper class, this will throw an error, but wrapper classes are handled first
        clazz.getConstructor();
        // If no key is configured
        if (StringUtils.isEmpty(name)) {
            // If the class has an Extension annotation, it is the value of the annotation
            // If there is no annotation, the lowercase class name is used as the name
            name = findAnnotationName(clazz);
            if (name.length() == 0) {
                throw new IllegalStateException("No such extension name for the class " + clazz.getName() + " in the config " + resourceURL);
            }
        }
        String[] names = NAME_SEPARATOR.split(name);
        if (ArrayUtils.isNotEmpty(names)) {
            // If the class has an @Activate annotation, establish the mapping between names and annotations
            // Assign value to cachedActivates global variable
            cacheActivateClass(clazz, names[0]);
            for (String n : names) {
                cacheName(clazz, n);
                // Here, extensionClasses establishes the mapping between key and class
                saveInExtensionClass(extensionClasses, clazz, n, overridden);
            }
        }
    }
}
3.2.3.7 createAdaptiveExtensionClass

1. Automatically generating the class string based on annotations and parameter configurations in the interface.2. Dynamically compiling and generating bytecode files to load into the JVM.

Rules defined by the interface:

1. At least one method in the interface must have the @Adaptive annotation; otherwise, an error will be thrown.2. The method parameters must include a URL parameter; otherwise, an error will be thrown.

Why is this designed this way?

Because this way, Dubbo will automatically generate a class, which is essentially a proxy class. However, Dubbo does not know which logic to execute, meaning this proxy class does not know which implementation class to use for the interface. Therefore, when invoking methods on the proxy object, we must inform Dubbo which implementation class to use based on the method’s input parameters, and the parameter for selecting the implementation class is in the URL parameter. Thus, the URL parameter must be included. Below is the proxy class generated by Dubbo:

import org.apache.dubbo.common.extension.ExtensionLoader;
public class ActivateApi$Adaptive implements cn.enjoy.dubbospi.ActivateApi {
    public java.lang.String todo(java.lang.String arg0, org.apache.dubbo.common.URL arg1) {
        if (arg1 == null) throw new IllegalArgumentException("url == null");
        org.apache.dubbo.common.URL url = arg1;
        String extName = url.getParameter("activate.api", "spring");
        if (extName == null) throw new IllegalStateException("Failed to get extension (cn.enjoy.dubbospi.ActivateApi) name from url (" + url.toString() + ") use keys([activate.api])");
        cn.enjoy.dubbospi.ActivateApi extension = (cn.enjoy.dubbospi.ActivateApi) ExtensionLoader.getExtensionLoader(cn.enjoy.dubbospi.ActivateApi.class).getExtension(extName);
        return extension.todo(arg0, arg1);
    }
}

From the code, it can be seen that the instance is selected based on the URL input parameter, and this instance is then used to invoke the method. The class ActivateApi$Adaptive is merely a proxy layer without substantive business logic; it is a process of selecting an instance based on parameters.

3.2.3.8 injectExtension

From our previous analysis, we have already generated an instance. This method is responsible for performing property dependency injection on that instance, essentially invoking the setXXX methods of that instance to assign parameter values.

private T injectExtension(T instance) {
    if (objectFactory == null) {
        return instance;
    }
    try {
        // Perform property injection via setXXX methods on the instance
        for (Method method : instance.getClass().getMethods()) {
            if (!isSetter(method)) {
                continue;
            }
            /**
            * Check {@link DisableInject} to see if we need auto injection for this property
            */
            // If there is a DisableInject annotation, do not inject
            if (method.getAnnotation(DisableInject.class) != null) {
                continue;
            }
            Class<?> pt = method.getParameterTypes()[0];
            if (ReflectUtils.isPrimitives(pt)) {
                continue;
            }
            try {
                // Get the property name from the method name
                String property = getSetterProperty(method);
                // Get the value to be injected
                Object object = objectFactory.getExtension(pt, property);
                if (object != null) {
                    // Reflectively assign the value
                    method.invoke(instance, object);
                }
            } catch (Exception e) {
                logger.error("Failed to inject via method " + method.getName() + " of interface " + type.getName() + ": " + e.getMessage(), e);
            }
        }
    } catch (Exception e) {
        logger.error(e.getMessage(), e);
    }
    return instance;
}

However, it is important to emphasize the sources of the parameter values for invoking the setXXX methods:

1. Obtained through Dubbo’s SPI method.2. Obtained through Spring container’s getBean method.

Using Dubbo SPI to Obtain Values

This method essentially calls the getAdaptiveExtension method to obtain the instance.

public class SpiExtensionFactory implements ExtensionFactory {
    @Override
    public <T> T getExtension(Class<T> type, String name) {
        if (type.isInterface() && type.isAnnotationPresent(SPI.class)) {
            ExtensionLoader<T> loader = ExtensionLoader.getExtensionLoader(type);
            if (!loader.getSupportedExtensions().isEmpty()) {
                return loader.getAdaptiveExtension();
            }
        }
        return null;
    }
}
Using Spring Container to Obtain Values

This method retrieves instances from the container using getBean.

public class SpringExtensionFactory implements ExtensionFactory, Lifecycle {
    private static final Logger logger = LoggerFactory.getLogger(SpringExtensionFactory.class);
    private static final Set<ApplicationContext> CONTEXTS = new ConcurrentHashSet<ApplicationContext>();
    public static void addApplicationContext(ApplicationContext context) {
        CONTEXTS.add(context);
        if (context instanceof ConfigurableApplicationContext) {
            ((ConfigurableApplicationContext) context).registerShutdownHook();
            // see https://github.com/apache/dubbo/issues/7093
            DubboShutdownHook.getDubboShutdownHook().unregister();
        }
    }
    public static void removeApplicationContext(ApplicationContext context) {
        CONTEXTS.remove(context);
    }
    public static Set<ApplicationContext> getContexts() {
        return CONTEXTS;
    }
    // currently for test purpose
    public static void clearContexts() {
        CONTEXTS.clear();
    }
    @Override
    @SuppressWarnings("unchecked")
    public <T> T getExtension(Class<T> type, String name) {
        // SPI should be obtained from SpiExtensionFactory
        if (type.isInterface() && type.isAnnotationPresent(SPI.class)) {
            return null;
        }
        for (ApplicationContext context : CONTEXTS) {
            T bean = BeanFactoryUtils.getOptionalBean(context, name, type);
            if (bean != null) {
                return bean;
            }
        }
        // logger.warn("No spring extension (bean) named:" + name + ", try to find an extension (bean) of type " + type.getName());
        return null;
    }
    @Override
    public void initialize() throws IllegalStateException {
        clearContexts();
    }
    @Override
    public void start() throws IllegalStateException {
        // no op
    }
    @Override
    public void destroy() {
        clearContexts();
    }
}

3.2.4 Summary

The getAdaptiveExtension method is a core method in Dubbo SPI that performs two main functions: obtaining instances and assigning values to global variables in ExtensionLoader, which will be used in other APIs. Instance retrieval can be divided into two types: obtaining instances of classes annotated with @Adaptive and obtaining proxy instances generated by Dubbo, which are generated based on interface configurations. The interface methods must have at least one method annotated with @Adaptive, and the method parameters must include a URL parameter.

Leave a Comment