Java从入门到精通之SPI机制
小职 2020-11-25 来源 :冰河技术 阅读 617 评论 0

摘要:SPI是Java提供的一套用来被第三方实现或者扩展的API,它可以用来启用框架扩展和替换组件,本篇结合实战与源码来讲解Java中的SPI机制,希望对Java的学习有所帮助。

SPI是Java提供的一套用来被第三方实现或者扩展的API,它可以用来启用框架扩展和替换组件,本篇结合实战与源码来讲解Java中的SPI机制,希望对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="//maven.apache.org/POM/4.0.0"

         xmlns:xsi="//www.w3.org/2001/XMLSchema-instance"

         xsi:schemaLocation="//maven.apache.org/POM/4.0.0 //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也算是使用的延迟加载,但是基本只能通过遍历全部获取,也就是接口的实现类全部加载并实例化一遍。



关注“职坐标在线”(Zhizuobiao_Online)公众号,免费获取源码资料、技术就业咨询。

Java从入门到精通之SPI机制

本文由 @小职 发布于职坐标。未经许可,禁止转载。
喜欢 | 0 不喜欢 | 0
看完这篇文章有何感觉?已经有0人表态,0%的人喜欢 快给朋友分享吧~
评论(0)
后参与评论

您输入的评论内容中包含违禁敏感词

我知道了

助您圆梦职场 匹配合适岗位
验证码手机号,获得海同独家IT培训资料
选择就业方向:
人工智能物联网
大数据开发/分析
人工智能Python
Java全栈开发
WEB前端+H5

请输入正确的手机号码

请输入正确的验证码

获取验证码

您今天的短信下发次数太多了,明天再试试吧!

提交

我们会在第一时间安排职业规划师联系您!

您也可以联系我们的职业规划师咨询:

小职老师的微信号:z_zhizuobiao
小职老师的微信号:z_zhizuobiao

版权所有 职坐标-一站式IT培训就业服务领导者 沪ICP备13042190号-4
上海海同信息科技有限公司 Copyright ©2015 www.zhizuobiao.com,All Rights Reserved.
 沪公网安备 31011502005948号    

©2015 www.zhizuobiao.com All Rights Reserved

208小时内训课程