乐趣区

关于java:结合实战和源码来聊聊Java中的SPI机制

写在后面

SPI 机制可能十分不便的为某个接口动静指定其实现类,在某种程度上,这也是某些框架具备高度可扩展性的根底。明天,咱们就从源码级别深入探讨下 Java 中的 SPI 机制。

注:文章已收录到:https://github.com/sunshinelyz/technology-binghe

SPI 的概念

SPI 在 Java 中的全称为 Service Provider Interface,是 JDK 内置的一种服务提供发现机制,是 Java 提供的一套用来被第三方实现或者扩大的 API,它能够用来启用框架扩大和替换组件。

JAVA SPI = 基于接口的编程+策略模式+配置文件的动静加载机制 

SPI 的应用场景

Java 是一种面向对象语言,尽管 Java8 开始反对函数式编程和 Stream,然而总体来说,还是面向对象的语言。在应用 Java 进行面向对象开发时,个别会举荐应用基于接口的编程,程序的模块与模块之前不会间接进行实现类的硬编码。而在理论的开发过程中,往往一个接口会有多个实现类,各实现类要么实现的逻辑不同,要么应用的形式不同,还有的就是实现的技术不同。为了使调用方在调用接口的时候,明确的晓得本人调用的是接口的哪个实现类,或者说为了实现在模块拆卸的时候不必在程序里动静指明,这就须要一种服务发现机制。Java 中的 SPI 加载机制可能满足这样的需要,它可能主动寻找某个接口的实现类。

大量的框架应用了 Java 的 SPI 技术,如下:

(1)JDBC 加载不同类型的数据库驱动
(2)日志门面接口实现类加载,SLF4J 加载不同提供商的日志实现类
(3)Spring 中大量应用了 SPI

  • 对 servlet3.0 标准
  • 对 ServletContainerInitializer 的实现
  • 主动类型转换 Type Conversion SPI(Converter SPI、Formatter SPI) 等

(4)Dubbo 外面有很多个组件,每个组件在框架中都是以接口的造成形象进去!具体的实现又分很多种,在程序执行时依据用户的配置来按需取接口的实现

SPI 的应用

当服务的提供者,提供了接口的一种实现后,须要在 Jar 包的 META-INF/services/ 目录下,创立一个以接口的名称(包名. 接口名的模式)命名的文件,在文件中配置接口的实现类(残缺的包名 + 类名)。

当内部程序通过 java.util.ServiceLoader 类装载这个接口时,就可能通过该 Jar 包的 META/Services/ 目录里的配置文件找到具体的实现类名,装载实例化,实现注入。同时,SPI 的标准规定了接口的实现类必须有一个无参构造方法。

SPI 中查找接口的实现类是通过 java.util.ServiceLoader,而在 java.util.ServiceLoader 类中有一行代码如下:

// 加载具体实现类信息的前缀,也就是以接口命名的文件须要放到 Jar 包中的 META-INF/services/ 目录下
private static final String PREFIX = "META-INF/services/";

这也就是说,咱们必须将接口的配置文件写到 Jar 包的 META/Services/ 目录下。

SPI 实例

这里,给出一个简略的 SPI 应用实例,演示在 Java 程序中如何应用 SPI 动静加载接口的实现类。

留神:实例是基于 Java8 进行开发的。

1. 创立 Maven 我的项目

在 IDEA 中创立 Maven 我的项目 spi-demo,如下:

2. 编辑 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

<artifactId>spi-demo</artifactId>
<groupId>io.binghe.spi</groupId>
<packaging>jar</packaging>
<version>1.0.0-SNAPSHOT</version>
<modelVersion>4.0.0</modelVersion>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>3.6.0</version>
            <configuration>
                <source>1.8</source>
                <target>1.8</target>
            </configuration>
        </plugin>
    </plugins>
</build>

</project>

3. 创立类加载工具类

在 io.binghe.spi.loader 包下创立 MyServiceLoader,MyServiceLoader 类中间接调用 JDK 的 ServiceLoader 类加载 Class。代码如下所示。

package io.binghe.spi.loader;
 
import java.util.ServiceLoader;
 
/**
 * @author binghe
 * @version 1.0.0
 * @description 类加载工具
 */
public class MyServiceLoader {
 
    /**
     * 应用 SPI 机制加载所有的 Class
     */
    public static <S> ServiceLoader<S> loadAll(final Class<S> clazz) {return ServiceLoader.load(clazz);
    }
}

4. 创立接口

在 io.binghe.spi.service 包下创立接口 MyService,作为测试接口,接口中只有一个办法,打印传入的字符串信息。代码如下所示:

package io.binghe.spi.service;
 
/**
 * @author binghe
 * @version 1.0.0
 * @description 定义接口
 */
public interface MyService {
 
    /**
     *  打印信息
     */
    void print(String info);
}

5. 创立接口的实现类

(1)创立第一个实现类 MyServiceA

在 io.binghe.spi.service.impl 包下创立 MyServiceA 类,实现 MyService 接口。代码如下所示:

package io.binghe.spi.service.impl;
import io.binghe.spi.service.MyService;
 
/**
 * @author binghe
 * @version 1.0.0
 * @description 接口的第一个实现
 */
public class MyServiceA implements MyService {
    @Override
    public void print(String info) {System.out.println(MyServiceA.class.getName() + "print" + info);
    }
}

(2)创立第二个实现类 MyServiceB

在 io.binghe.spi.service.impl 包下创立 MyServiceB 类,实现 MyService 接口。代码如下所示:

package io.binghe.spi.service.impl;
 
import io.binghe.spi.service.MyService;
 
/**
 * @author binghe
 * @version 1.0.0
 * @description 接口第二个实现
 */
public class MyServiceB implements MyService {
    @Override
    public void print(String info) {System.out.println(MyServiceB.class.getName() + "print" + info);
    }
}

6. 创立接口文件

在我的项目的 src/main/resources 目录下创立 META/Services/ 目录,在目录中创立 io.binghe.spi.service.MyService 文件,留神:文件必须是接口 MyService 的全名,之后将实现 MyService 接口的类配置到文件中,如下所示:

io.binghe.spi.service.impl.MyServiceA
io.binghe.spi.service.impl.MyServiceB

7. 创立测试类

在我的项目的 io.binghe.spi.main 包下创立 Main 类,该类为测试程序的入口类,提供一个 main() 办法,在 main() 办法中调用 ServiceLoader 类加载 MyService 接口的实现类。并通过 Java8 的 Stream 将后果打印进去,如下所示:

package io.binghe.spi.main;
 
import io.binghe.spi.loader.MyServiceLoader;
import io.binghe.spi.service.MyService;
 
import java.util.ServiceLoader;
import java.util.stream.StreamSupport;
 
/**
 * @author binghe
 * @version 1.0.0
 * @description 测试的 main 办法
 */
public class Main {public static void main(String[] args){ServiceLoader<MyService> loader = MyServiceLoader.loadAll(MyService.class);
        StreamSupport.stream(loader.spliterator(), false).forEach(s -> s.print("Hello World"));
    }
}

8. 测试实例

运行 Main 类中的 main() 办法,打印出的信息如下所示:

io.binghe.spi.service.impl.MyServiceA print Hello World
io.binghe.spi.service.impl.MyServiceB print Hello World

Process finished with exit code 0

通过打印信息能够看出,通过 Java SPI 机制正确加载出接口的实现类,并调用接口的实现办法。

源码解析

这里,次要是对 SPI 的加载流程波及到的 java.util.ServiceLoader 的源码的解析。

进入 java.util.ServiceLoader 的源码,能够看到 ServiceLoader 类实现了 java.lang.Iterable 接口,如下所示。

public final class ServiceLoader<S>  implements Iterable<S> 

阐明 ServiceLoader 类是能够遍历迭代的。

java.util.ServiceLoader 类中定义了如下的成员变量:

// 加载具体实现类信息的前缀,也就是以接口命名的文件须要放到 Jar 包中的 META-INF/services/ 目录下
private static final String PREFIX = "META-INF/services/";

// 须要加载的接口
private final Class<S> service;

// 类加载器,用于加载以接口命名的文件中配置的接口的实现类
private final ClassLoader loader;

// 创立 ServiceLoader 时采纳的访问控制上下文环境
private final AccessControlContext acc;

// 用来缓存曾经加载的接口实现类,其中,Key 是接口实现类的残缺类名,Value 为实现类对象
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

// 用于提早加载实现类的迭代器
private LazyIterator lookupIterator;

能够看到 ServiceLoader 类中定义了加载前缀为“META-INF/services/”,所以,接口文件必须要在我的项目的 src/main/resources 目录下的 META-INF/services/ 目录下创立。

从 MyServiceLoader 类调用 ServiceLoader.load(clazz) 办法进入源码,如下所示:

// 依据类的 Class 对象加载指定的类,返回 ServiceLoader 对象
public static <S> ServiceLoader<S> load(Class<S> service) {
    // 获取以后线程的类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    // 动静加载指定的类,将类加载到 ServiceLoader 中
    return ServiceLoader.load(service, cl);
}

办法中调用了 ServiceLoader.load(service, cl) 办法,持续跟踪代码,如下所示:

// 通过 ClassLoader 加载指定类的 Class,并将返回后果封装到 ServiceLoader 对象中
public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader){return new ServiceLoader<>(service, loader);
}

能够看到 ServiceLoader.load(service, cl) 办法中,调用了 ServiceLoader 类的构造方法,持续跟进代码,如下所示:

// 结构 ServiceLoader 对象
private ServiceLoader(Class<S> svc, ClassLoader cl) {
    // 如果传入的 Class 对象为空,则判处空指针异样
    service = Objects.requireNonNull(svc, "Service interface cannot be null");
    // 如果传入的 ClassLoader 为空,则通过 ClassLoader.getSystemClassLoader() 获取,否则间接应用传入的 ClassLoader
    loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
    acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    reload();}

持续跟 reload() 办法,如下所示。

// 从新加载
public void reload() {
    // 清空保留加载的实现类的 LinkedHashMap
    providers.clear();
    // 结构提早加载的迭代器
    lookupIterator = new LazyIterator(service, loader);
}

持续跟进懒加载迭代器的构造函数,如下所示。

private LazyIterator(Class<S> service, ClassLoader loader) {
    this.service = service;
    this.loader = loader;
}

能够看到,会将须要加载的接口的 Class 对象和类加载器赋值给 LazyIterator 的成员变量。

当咱们在程序中迭代获取对象实例时,首先在成员变量 providers 中查找是否有缓存的实例对象。如果存在则间接返回,否则调用 lookupIterator 提早加载迭代器进行加载。

迭代器进行逻辑判断的代码如下所示:

// 迭代 ServiceLoader 的办法
public Iterator<S> iterator() {return new Iterator<S>() {
        // 获取保留实现类的 LinkedHashMap<String,S> 的迭代器
        Iterator<Map.Entry<String,S>> knownProviders = providers.entrySet().iterator();
        // 判断是否有下一个元素
        public boolean hasNext() {
            // 如果 knownProviders 存在元素,则间接返回 true
            if (knownProviders.hasNext())
                return true;
            // 返回提早加载器是否存在元素
            return lookupIterator.hasNext();}
        // 获取下一个元素
        public S next() {
            // 如果 knownProviders 存在元素,则间接获取
            if (knownProviders.hasNext())
                return knownProviders.next().getValue();
            // 获取提早迭代器 lookupIterator 中的元素
            return lookupIterator.next();}

        public void remove() {throw new UnsupportedOperationException();
        }
    };
}

LazyIterator 加载类的流程如下代码所示

// 判断是否领有下一个实例
private boolean hasNextService() {
    // 如果领有下一个实例,间接返回 true
    if (nextName != null) {return true;}
    // 如果实现类的全名为 null
    if (configs == null) {
        try {
            // 获取全文件名,文件相对路径 + 文件名称(包名 + 接口名)String fullName = PREFIX + service.getName();
            // 类加载器为空,则通过 ClassLoader.getSystemResources() 办法获取
            if (loader == null)
                configs = ClassLoader.getSystemResources(fullName);
            else
                // 类加载器不为空,则间接通过类加载器获取
                configs = loader.getResources(fullName);
        } catch (IOException x) {fail(service, "Error locating configuration files", x);
        }
    }
    while ((pending == null) || !pending.hasNext()) {
        // 如果 configs 中没有更过的元素,则间接返回 false
        if (!configs.hasMoreElements()) {return false;}
        // 解析包构造
        pending = parse(service, configs.nextElement());
    }
    nextName = pending.next();
    return true;
}

private S nextService() {if (!hasNextService())
        throw new NoSuchElementException();
    String cn = nextName;
    nextName = null;
    Class<?> c = null;
    try {
        // 加载类对象
        c = Class.forName(cn, false, loader);
    } catch (ClassNotFoundException x) {
        fail(service,
             "Provider" + cn + "not found");
    }
    if (!service.isAssignableFrom(c)) {
        fail(service,
             "Provider" + cn  + "not a subtype");
    }
    try {// 通过 c.newInstance() 生成对象实例
        S p = service.cast(c.newInstance());
        // 将生成的对象实例保留到缓存中(LinkedHashMap<String,S>)providers.put(cn, p);
        return p;
    } catch (Throwable x) {
        fail(service,
             "Provider" + cn + "could not be instantiated",
             x);
    }
    throw new Error();          // This cannot happen}

public boolean hasNext() {if (acc == null) {return hasNextService();
    } else {PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {public Boolean run() {return hasNextService(); }
        };
        return AccessController.doPrivileged(action, acc);
    }
}

public S next() {if (acc == null) {return nextService();
    } else {PrivilegedAction<S> action = new PrivilegedAction<S>() {public S run() {return nextService(); }
        };
        return AccessController.doPrivileged(action, acc);
    }
}

最初,给出整个 java.util.ServiceLoader 的类,如下所示:

package java.util;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.security.AccessControlContext;
import java.security.AccessController;
import java.security.PrivilegedAction;


public final class ServiceLoader<S>  implements Iterable<S> {
    // 加载具体实现类信息的前缀,也就是以接口命名的文件须要放到 Jar 包中的 META-INF/services/ 目录下
    private static final String PREFIX = "META-INF/services/";

    // 须要加载的接口
    private final Class<S> service;
    
    // 类加载器,用于加载以接口命名的文件中配置的接口的实现类
    private final ClassLoader loader;
    
    // 创立 ServiceLoader 时采纳的访问控制上下文环境
    private final AccessControlContext acc;
    
    // 用来缓存曾经加载的接口实现类,其中,Key 是接口实现类的残缺类名,Value 为实现类对象
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
    
    // 用于提早加载实现类的迭代器
    private LazyIterator lookupIterator;
    
    // 从新加载
    public void reload() {
        // 清空保留加载的实现类的 LinkedHashMap
        providers.clear();
        // 结构提早加载的迭代器
        lookupIterator = new LazyIterator(service, loader);
    }
    
    // 结构 ServiceLoader 对象
    private ServiceLoader(Class<S> svc, ClassLoader cl) {
        // 如果传入的 Class 对象为空,则判处空指针异样
        service = Objects.requireNonNull(svc, "Service interface cannot be null");
        // 如果传入的 ClassLoader 为空,则通过 ClassLoader.getSystemClassLoader() 获取,否则间接应用传入的 ClassLoader
        loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
        acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
        reload();}
    
    private static void fail(Class<?> service, String msg, Throwable cause)
        throws ServiceConfigurationError
    {throw new ServiceConfigurationError(service.getName() + ":" + msg,
                                            cause);
    }
    
    private static void fail(Class<?> service, String msg)
        throws ServiceConfigurationError
    {throw new ServiceConfigurationError(service.getName() + ":" + msg);
    }
    
    private static void fail(Class<?> service, URL u, int line, String msg)
        throws ServiceConfigurationError
    {fail(service, u + ":" + line + ":" + msg);
    }
    
    // Parse a single line from the given configuration file, adding the name
    // on the line to the names list.
    //
    private int parseLine(Class<?> service, URL u, BufferedReader r, int lc,
                          List<String> names)
        throws IOException, ServiceConfigurationError
    {String ln = r.readLine();
        if (ln == null) {return -1;}
        int ci = ln.indexOf('#');
        if (ci >= 0) ln = ln.substring(0, ci);
        ln = ln.trim();
        int n = ln.length();
        if (n != 0) {if ((ln.indexOf('') >= 0) || (ln.indexOf('\t') >= 0))
                fail(service, u, lc, "Illegal configuration-file syntax");
            int cp = ln.codePointAt(0);
            if (!Character.isJavaIdentifierStart(cp))
                fail(service, u, lc, "Illegal provider-class name:" + ln);
            for (int i = Character.charCount(cp); i < n; i += Character.charCount(cp)) {cp = ln.codePointAt(i);
                if (!Character.isJavaIdentifierPart(cp) && (cp != '.'))
                    fail(service, u, lc, "Illegal provider-class name:" + ln);
            }
            if (!providers.containsKey(ln) && !names.contains(ln))
                names.add(ln);
        }
        return lc + 1;
    }
    
    private Iterator<String> parse(Class<?> service, URL u)
        throws ServiceConfigurationError
    {
        InputStream in = null;
        BufferedReader r = null;
        ArrayList<String> names = new ArrayList<>();
        try {in = u.openStream();
            r = new BufferedReader(new InputStreamReader(in, "utf-8"));
            int lc = 1;
            while ((lc = parseLine(service, u, r, lc, names)) >= 0);
        } catch (IOException x) {fail(service, "Error reading configuration file", x);
        } finally {
            try {if (r != null) r.close();
                if (in != null) in.close();} catch (IOException y) {fail(service, "Error closing configuration file", y);
            }
        }
        return names.iterator();}
    
    // Private inner class implementing fully-lazy provider lookupload
    private class LazyIterator
        implements Iterator<S>
    {
    
        Class<S> service;
        ClassLoader loader;
        Enumeration<URL> configs = null;
        Iterator<String> pending = null;
        String nextName = null;
    
        private LazyIterator(Class<S> service, ClassLoader loader) {
            this.service = service;
            this.loader = loader;
        }
    
        // 判断是否领有下一个实例
        private boolean hasNextService() {
            // 如果领有下一个实例,间接返回 true
            if (nextName != null) {return true;}
            // 如果实现类的全名为 null
            if (configs == null) {
                try {
                    // 获取全文件名,文件相对路径 + 文件名称(包名 + 接口名)String fullName = PREFIX + service.getName();
                    // 类加载器为空,则通过 ClassLoader.getSystemResources() 办法获取
                    if (loader == null)
                        configs = ClassLoader.getSystemResources(fullName);
                    else
                        // 类加载器不为空,则间接通过类加载器获取
                        configs = loader.getResources(fullName);
                } catch (IOException x) {fail(service, "Error locating configuration files", x);
                }
            }
            while ((pending == null) || !pending.hasNext()) {
                // 如果 configs 中没有更过的元素,则间接返回 false
                if (!configs.hasMoreElements()) {return false;}
                // 解析包构造
                pending = parse(service, configs.nextElement());
            }
            nextName = pending.next();
            return true;
        }
    
        private S nextService() {if (!hasNextService())
                throw new NoSuchElementException();
            String cn = nextName;
            nextName = null;
            Class<?> c = null;
            try {
                // 加载类对象
                c = Class.forName(cn, false, loader);
            } catch (ClassNotFoundException x) {
                fail(service,
                     "Provider" + cn + "not found");
            }
            if (!service.isAssignableFrom(c)) {
                fail(service,
                     "Provider" + cn  + "not a subtype");
            }
            try {// 通过 c.newInstance() 生成对象实例
                S p = service.cast(c.newInstance());
                // 将生成的对象实例保留到缓存中(LinkedHashMap<String,S>)providers.put(cn, p);
                return p;
            } catch (Throwable x) {
                fail(service,
                     "Provider" + cn + "could not be instantiated",
                     x);
            }
            throw new Error();          // This cannot happen}
    
        public boolean hasNext() {if (acc == null) {return hasNextService();
            } else {PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {public Boolean run() {return hasNextService(); }
                };
                return AccessController.doPrivileged(action, acc);
            }
        }
    
        public S next() {if (acc == null) {return nextService();
            } else {PrivilegedAction<S> action = new PrivilegedAction<S>() {public S run() {return nextService(); }
                };
                return AccessController.doPrivileged(action, acc);
            }
        }
    
        public void remove() {throw new UnsupportedOperationException();
        }
    
    }
    
    // 迭代 ServiceLoader 的办法
    public Iterator<S> iterator() {return new Iterator<S>() {
            // 获取保留实现类的 LinkedHashMap<String,S> 的迭代器
            Iterator<Map.Entry<String,S>> knownProviders = providers.entrySet().iterator();
            // 判断是否有下一个元素
            public boolean hasNext() {
                // 如果 knownProviders 存在元素,则间接返回 true
                if (knownProviders.hasNext())
                    return true;
                // 返回提早加载器是否存在元素
                return lookupIterator.hasNext();}
            // 获取下一个元素
            public S next() {
                // 如果 knownProviders 存在元素,则间接获取
                if (knownProviders.hasNext())
                    return knownProviders.next().getValue();
                // 获取提早迭代器 lookupIterator 中的元素
                return lookupIterator.next();}
    
            public void remove() {throw new UnsupportedOperationException();
            }
    
        };
    }
    
    // 通过 ClassLoader 加载指定类的 Class,并将返回后果封装到 ServiceLoader 对象中
    public static <S> ServiceLoader<S> load(Class<S> service,
                                            ClassLoader loader)
    {return new ServiceLoader<>(service, loader);
    }
    
    // 依据类的 Class 对象加载指定的类,返回 ServiceLoader 对象
    public static <S> ServiceLoader<S> load(Class<S> service) {
        // 获取以后线程的类加载器
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        // 动静加载指定的类,将类加载到 ServiceLoader 中
        return ServiceLoader.load(service, cl);
    }
    
    public static <S> ServiceLoader<S> loadInstalled(Class<S> service) {ClassLoader cl = ClassLoader.getSystemClassLoader();
        ClassLoader prev = null;
        while (cl != null) {
            prev = cl;
            cl = cl.getParent();}
        return ServiceLoader.load(service, prev);
    }
    
    /**
     * Returns a string describing this service.
     *
     * @return  A descriptive string
     */
    public String toString() {return "java.util.ServiceLoader[" + service.getName() + "]";
    }
}

SPI 总结

最初,对 Java 提供的 SPI 机制进行简略的总结。

长处:

可能实现我的项目解耦,使得第三方服务模块的拆卸管制的逻辑与调用者的业务代码拆散,而不是耦合在一起。应用程序能够依据理论业务状况启用框架扩大或替换框架组件。

毛病:

  • 多个并发多线程应用 ServiceLoader 类的实例是不平安的
  • 尽管 ServiceLoader 也算是应用的提早加载,然而根本只能通过遍历全副获取,也就是接口的实现类全副加载并实例化一遍。

参考:深刻了解 Java 中的 spi 机制

重磅福利

微信搜一搜【冰河技术】微信公众号,关注这个有深度的程序员,每天浏览超硬核技术干货,公众号内回复【PDF】有我筹备的一线大厂面试材料和我原创的超硬核 PDF 技术文档,以及我为大家精心筹备的多套简历模板(不断更新中),心愿大家都能找到心仪的工作,学习是一条时而郁郁寡欢,时而开怀大笑的路,加油。如果你通过致力胜利进入到了心仪的公司,肯定不要懈怠放松,职场成长和新技术学习一样,逆水行舟。如果有幸咱们江湖再见!

另外,我开源的各个 PDF,后续我都会继续更新和保护,感激大家长期以来对冰河的反对!!

退出移动版