ApacheShiro源码解读之SecurityManager的创建-成都快上网建站

ApacheShiro源码解读之SecurityManager的创建

对于Shiro(v1.2+)的SecurityManager的创建,在普通的应用程序中一般可以在main方法中这么创建

成都创新互联主营洪洞网站建设的网络公司,主营网站建设方案,重庆App定制开发,洪洞h5微信小程序开发搭建,洪洞网站营销推广欢迎洪洞等地区企业咨询

Factory factory = new IniSecurityManagerFactory("classpath:shiro.ini");
SecurityManager securityManager = factory.getInstance();

该方法读取classpath路径下的shiro.ini文件来构建SecurityManager,然而在web应用程序中,其是怎么创建的我们接下来逐步分析。

在web环境中我们会使用以下的Listener,而SecurityManager的创建就在Listener的初始化过程中【该Listener在shrio-web.jar中】


        org.apache.shiro.web.env.EnvironmentLoaderListener

EnvironmentLoaderListener的继承关系很简单,如下所示
Apache Shiro源码解读之SecurityManager的创建

EnvironmentLoader的作用是负责在应用程序启动的时候负责加载Shiro,同时将org.apache.shiro.web.mgt.WebSecurityManager设置到ServletContext中。

在初始化Shiro的过程中,在web.xml文件中配置的上下文参数“shiroEnvironmentClass”和“shiroConfigLocations”可以指导Shiro的初始化过程,当然,这两个参数不是必须配置的,有默认值。

shiroEnvironmentClass:制定继承自WebEnvironment的自定义类,默认对象为IniWebEnvironment。
shiroConfigLocations:制定shiro初始化时用的配置文件路径,默认会先查询/WEB-INF/shiro.ini,如果没找到再查找classpath:shiro.ini。

public class EnvironmentLoaderListener extends EnvironmentLoader implements ServletContextListener {

    /**
     * Initializes the Shiro {@code WebEnvironment} and binds it to the {@code ServletContext} at application
     * startup for future reference.
     *
     * @param sce the ServletContextEvent triggered upon application startup
     */
    public void contextInitialized(ServletContextEvent sce) {
        initEnvironment(sce.getServletContext());
    }

    /**
     * Destroys any previously created/bound {@code WebEnvironment} instance created by
     * the {@link #contextInitialized(javax.servlet.ServletContextEvent)} method.
     *
     * @param sce the ServletContextEvent triggered upon application shutdown
     */
    public void contextDestroyed(ServletContextEvent sce) {
        destroyEnvironment(sce.getServletContext());
    }
}
public class EnvironmentLoader {
    public WebEnvironment initEnvironment(ServletContext servletContext) throws IllegalStateException {

        if (servletContext.getAttribute(ENVIRONMENT_ATTRIBUTE_KEY) != null) {
            String msg = "There is already a Shiro environment associated with the current ServletContext.  " +
                    "Check if you have multiple EnvironmentLoader* definitions in your web.xml!";
            throw new IllegalStateException(msg);
        }

        servletContext.log("Initializing Shiro environment");
        log.info("Starting Shiro environment initialization.");

        long startTime = System.currentTimeMillis();

        try {

            WebEnvironment environment = createEnvironment(servletContext);
            servletContext.setAttribute(ENVIRONMENT_ATTRIBUTE_KEY,environment);

            log.debug("Published WebEnvironment as ServletContext attribute with name [{}]",
                    ENVIRONMENT_ATTRIBUTE_KEY);

            if (log.isInfoEnabled()) {
                long elapsed = System.currentTimeMillis() - startTime;
                log.info("Shiro environment initialized in {} ms.", elapsed);
            }

            return environment;
        } catch (RuntimeException ex) {
            log.error("Shiro environment initialization failed", ex);
            servletContext.setAttribute(ENVIRONMENT_ATTRIBUTE_KEY, ex);
            throw ex;
        } catch (Error err) {
            log.error("Shiro environment initialization failed", err);
            servletContext.setAttribute(ENVIRONMENT_ATTRIBUTE_KEY, err);
            throw err;
        }
    }

    protected WebEnvironment createEnvironment(ServletContext sc) {

        //查找WebEnvironment对象,并将其实例化
        WebEnvironment webEnvironment = determineWebEnvironment(sc);
        if (!MutableWebEnvironment.class.isInstance(webEnvironment)) {
            throw new ConfigurationException("Custom WebEnvironment class [" + webEnvironment.getClass().getName() +
                    "] is not of required type [" + MutableWebEnvironment.class.getName() + "]");
        }

        String configLocations = sc.getInitParameter(CONFIG_LOCATIONS_PARAM);
        boolean configSpecified = StringUtils.hasText(configLocations);

        if (configSpecified && !(ResourceConfigurable.class.isInstance(webEnvironment))) {
            String msg = "WebEnvironment class [" + webEnvironment.getClass().getName() + "] does not implement the " +
                    ResourceConfigurable.class.getName() + "interface.  This is required to accept any " +
                    "configured " + CONFIG_LOCATIONS_PARAM + "value(s).";
            throw new ConfigurationException(msg);
        }

        MutableWebEnvironment environment = (MutableWebEnvironment) webEnvironment;
        //保存当前的ServletContext对象
        environment.setServletContext(sc);

        //如果在web.xml设置了配置文件的路径,则在此设置到environment中
        if (configSpecified && (environment instanceof ResourceConfigurable)) {
            ((ResourceConfigurable) environment).setConfigLocations(configLocations);
        }

        //构造方法,默认未实现
        customizeEnvironment(environment);

        //调用environment的init方法初始化environment对象
        LifecycleUtils.init(environment);

        return environment;
    }

    protected WebEnvironment determineWebEnvironment(ServletContext servletContext) {

        //从ServletContext的参数中获取WebEnvironment的配置--shiroEnvironmentClass,如果有则创建实例返回
        Class webEnvironmentClass = webEnvironmentClassFromServletContext(servletContext);
        WebEnvironment webEnvironment = null;

        // 尝试通过Java的ServiceLoader来查找WebEnvironment的实现类
        if (webEnvironmentClass == null) {
            webEnvironment = webEnvironmentFromServiceLoader();
        }

        // 如果上面的步骤都没找到,则使用默认的WebEnvironment实现类IniWebEnvironment
        if (webEnvironmentClass == null && webEnvironment == null) {
            webEnvironmentClass = getDefaultWebEnvironmentClass();
        }

        // 创建WebEnvironment的实例
        if (webEnvironmentClass != null) {
            webEnvironment = (WebEnvironment) ClassUtils.newInstance(webEnvironmentClass);
        }

        return webEnvironment;
    }

    private WebEnvironment webEnvironmentFromServiceLoader() {

        WebEnvironment webEnvironment = null;
        /*
         * 使用Java的ServiceLoader方式来查找WebEnvironment的实现类(查找jar包中META-INF下的services文件夹中的文件);
         * 例如在某个services文件夹中有个名为org.apache.shiro.web.env.WebEnvironment的文件,然后在文件里面保存WebEnvironment的实现类全路径;
         * 可见,文件名为接口的全路径,里面的内容为接口的实现类
         * */
        ServiceLoader serviceLoader = ServiceLoader.load(WebEnvironment.class);
        Iterator iterator = serviceLoader.iterator();

        // 如果找到则使用第一个
        if (iterator.hasNext()) {
            webEnvironment = iterator.next();
        }
        // 如果不止找到一个,则抛出异常
        if (iterator.hasNext()) {
            List allWebEnvironments = new ArrayList();
            allWebEnvironments.add(webEnvironment.getClass().getName());
            while (iterator.hasNext()) {
                allWebEnvironments.add(iterator.next().getClass().getName());
            }
            throw new ConfigurationException("ServiceLoader for class [" + WebEnvironment.class + "] returned more then one " +
                    "result.  ServiceLoader must return zero or exactly one result for this class. Select one using the " +
                    "servlet init parameter '"+ ENVIRONMENT_CLASS_PARAM +"'. Found: " + allWebEnvironments);
        }
        return webEnvironment;
    }
}

综上得知,查找WebEnvironment的实现类经历了三次查找
1)从ServletContext的初始化参数
2)从jar包查找实现类
3)使用默认的IniWebEnvironment

在得到WebEnvironment的实现类并创建好实例后,接着便会调用其init方法,这里假设得到的是默认的IniWebEnvironment。

public class IniWebEnvironment extends ResourceBasedWebEnvironment implements Initializable, Destroyable {

    public static final String DEFAULT_WEB_INI_RESOURCE_PATH = "/WEB-INF/shiro.ini";
    public static final String FILTER_CHAIN_RESOLVER_NAME = "filterChainResolver";

    private static final Logger log = LoggerFactory.getLogger(IniWebEnvironment.class);

    /**
     * The Ini that configures this WebEnvironment instance.
     */
    private Ini ini;

    private WebIniSecurityManagerFactory factory;

    public IniWebEnvironment() {
        //实例化WebIniSecurityManagerFactory对象
        factory = new WebIniSecurityManagerFactory();
    }

    /**
     * 初始化本实例
     */
    public void init() {
        //解析shiiro.ini配置文件并生成对应的Ini实例
        setIni(parseConfig());

        //使用Ini信息,通过WebIniSecurityManagerFactory创建WebSecurityManager实例
        configure();
    }
}

protected Ini parseConfig() {
        //直接取,首次运行肯定为null
        Ini ini = getIni();

        //获取配置文件路径【该路径信息就是web.xml文件中配置的,实例化该类的时候已经在EnvironmentLoader中设置】
        String[] configLocations = getConfigLocations();

        if (log.isWarnEnabled() && !CollectionUtils.isEmpty(ini) &&
                configLocations != null && configLocations.length > 0) {
            //如果Ini对象不为空,并且configLocations也不为空,给出提示信息
            log.warn("Explicit INI instance has been provided, but configuration locations have also been " +
                    "specified.  The {} implementation does not currently support multiple Ini config, but this may " +
                    "be supported in the future. Only the INI instance will be used for configuration.",
                    IniWebEnvironment.class.getName());
        }

        if (CollectionUtils.isEmpty(ini)) {
            log.debug("Checking any specified config locations.");
            //从指定路径下的配置文件中创建Ini实例
            ini = getSpecifiedIni(configLocations);
        }

        if (CollectionUtils.isEmpty(ini)) {
            log.debug("No INI instance or config locations specified.  Trying default config locations.");
            /*
             * 如果没有在web.xml中配置,则从默认的路径下读取配置文件并创建实例
             * 1,/WEB-INF/shiro.ini
             * 2,classpath:shiro.ini
             * */
            ini = getDefaultIni();
        }

        /*
         * 为了保持向后兼容而提供getFrameworkIni方法来创建Ini对象并与上面得到的Ini对象合并.
         * getFrameworkIni的默认实现返回null,经过合并处理后返回的还是上面的Ini对象
         * */
        ini = mergeIni(getFrameworkIni(), ini);

        if (CollectionUtils.isEmpty(ini)) {
            String msg = "Shiro INI configuration was either not found or discovered to be empty/unconfigured.";
            throw new ConfigurationException(msg);
        }
        return ini;
    }

        /**
     * 解析配置文件创建Ini实例对象
     * */
    protected Ini createIni(String configLocation, boolean required) throws ConfigurationException {

        Ini ini = null;

        if (configLocation != null) {
            ini = convertPathToIni(configLocation, required);
        }
        if (required && CollectionUtils.isEmpty(ini)) {
            String msg = "Required configuration location '" + configLocation + "' does not exist or did not " +
                    "contain any INI configuration.";
            throw new ConfigurationException(msg);
        }

        return ini;
    }

        /**
     * 加载制定路径的配置文件,然后将文件流作为参数调用Ini实例对象的load方法来初始化Ini对象
     * */
    private Ini convertPathToIni(String path, boolean required) {

        Ini ini = null;

        if (StringUtils.hasText(path)) {
            InputStream is = null;

            //SHIRO-178: Check for servlet context resource and not only resource paths:
            if (!ResourceUtils.hasResourcePrefix(path)) {
                is = getServletContextResourceStream(path);
            } else {
                try {
                    is = ResourceUtils.getInputStreamForPath(path);
                } catch (IOException e) {
                    if (required) {
                        throw new ConfigurationException(e);
                    } else {
                        if (log.isDebugEnabled()) {
                            log.debug("Unable to load optional path '" + path + "'.", e);
                        }
                    }
                }
            }
            if (is != null) {
                ini = new Ini();
                ini.load(is);
            } else {
                if (required) {
                    throw new ConfigurationException("Unable to load resource path '" + path + "'");
                }
            }
        }

        return ini;
    }

再看看Ini对象的初始化过程

public class Ini implements Map {
    private static transient final Logger log = LoggerFactory.getLogger(Ini.class);

    public static final String DEFAULT_SECTION_NAME = ""; //empty string means the first unnamed section
    public static final String DEFAULT_CHARSET_NAME = "UTF-8";

    public static final String COMMENT_POUND = "#";
    public static final String COMMENT_SEMICOLON = ";";
    public static final String SECTION_PREFIX = "[";
    public static final String SECTION_SUFFIX = "]";

    protected static final char ESCAPE_TOKEN = '\\';

    private final Map sections;

    /**
     * Creates a new empty {@code Ini} instance.
     */
    public Ini() {
        this.sections = new LinkedHashMap();
    }

    public void load(InputStream is) throws ConfigurationException {
        if (is == null) {
            throw new NullPointerException("InputStream argument cannot be null.");
        }
        InputStreamReader isr;
        try {
            isr = new InputStreamReader(is, DEFAULT_CHARSET_NAME);
        } catch (UnsupportedEncodingException e) {
            throw new ConfigurationException(e);
        }
        load(isr);
    }

    public void load(Reader reader) {
        Scanner scanner = new Scanner(reader);
        try {
            load(scanner);
        } finally {
            try {
                scanner.close();
            } catch (Exception e) {
                log.debug("Unable to cleanly close the InputStream scanner.  Non-critical - ignoring.", e);
            }
        }
    }

        public void load(Scanner scanner) {

        String sectionName = DEFAULT_SECTION_NAME;
        StringBuilder sectionContent = new StringBuilder();

        //循环读取每一行
        while (scanner.hasNextLine()) {

            String rawLine = scanner.nextLine();
            //取出两边的空格
            String line = StringUtils.clean(rawLine);

            if (line == null || line.startsWith(COMMENT_POUND) || line.startsWith(COMMENT_SEMICOLON)) {
                //忽略空行和注释
                continue;
            }
            //获取section名称,格式为 [main] 这种,此时返回“main”
            String newSectionName = getSectionName(line);
            if (newSectionName != null) {
                //前面section的配置信息收集完成,添加section配置
                addSection(sectionName, sectionContent);

                //为本次的section重置StringBuilder对象,用户存放该section的配置信息
                sectionContent = new StringBuilder();

                sectionName = newSectionName;

                if (log.isDebugEnabled()) {
                    log.debug("Parsing " + SECTION_PREFIX + sectionName + SECTION_SUFFIX);
                }
            } else {
                //添加配置信息
                sectionContent.append(rawLine).append("\n");
            }
        }

        //添加Section的配置信息
        addSection(sectionName, sectionContent);
    }

    private void addSection(String name, StringBuilder content) {
        if (content.length() > 0) {
            String contentString = content.toString();
            String cleaned = StringUtils.clean(contentString);
            if (cleaned != null) {
                //构建Section对象【静态内部类】
                Section section = new Section(name, contentString);
                if (!section.isEmpty()) {
                    //以键值对的方式保存Section对象
                    sections.put(name, section);
                }
            }
        }
    }

    public static class Section implements Map {
            private final String name;
            private final Map props;

            /*
         * 解析收集的配置信息,将配置信息保存到props对象中
         * */
        private Section(String name, String sectionContent) {
            if (name == null) {
                throw new NullPointerException("name");
            }
            this.name = name;
            Map props;
            if (StringUtils.hasText(sectionContent) ) {
                props = toMapProps(sectionContent);
            } else {
                props = new LinkedHashMap();
            }
            if ( props != null ) {
                this.props = props;
            } else {
                this.props = new LinkedHashMap();
            }
        }

    }
}

到此,在IniWebEnvironment实例中通过解析配置文件得到了Ini对象,该对象里面保存了配置文件中的每个Section信息,那么接着就要使用该Ini对象来构建WebSecurityManager了,也就是调用IniWebEnvironment 的configure方法

public class IniWebEnvironment extends ResourceBasedWebEnvironment implements Initializable, Destroyable {
    protected void configure() {
        //Map对象
        this.objects.clear();

        WebSecurityManager securityManager = createWebSecurityManager();
        setWebSecurityManager(securityManager);

        FilterChainResolver resolver = createFilterChainResolver();
        if (resolver != null) {
            setFilterChainResolver(resolver);
        }
    }

    protected Map getDefaults() {
        Map defaults = new HashMap();
        defaults.put(FILTER_CHAIN_RESOLVER_NAME, new IniFilterChainResolverFactory());
        return defaults;
    }

    protected WebSecurityManager createWebSecurityManager() {
        //已经创建好的Ini对象
        Ini ini = getIni();
        if (!CollectionUtils.isEmpty(ini)) {
            factory.setIni(ini);
        }

        Map defaults = getDefaults();
        if (!CollectionUtils.isEmpty(defaults)) {
            factory.setDefaults(defaults);
        }
        //从WebIniSecurityManagerFactory实例中创建WebSecurityManager
        WebSecurityManager wsm = (WebSecurityManager)factory.getInstance();

        //SHIRO-306 - get beans after they've been created (the call was before the factory.getInstance() call,
        //which always returned null.
        Map beans = factory.getBeans();
        if (!CollectionUtils.isEmpty(beans)) {
            this.objects.putAll(beans);
        }

        return wsm;
    }
}

接着看看WebIniSecurityManagerFactory的getInstance方法的实现

Apache Shiro源码解读之SecurityManager的创建

由图可见,在调用getInstance方法的时候,其实执行的是位于AbstractFactory中的getInstance方法

public abstract class AbstractFactory implements Factory {
    public T getInstance() {
        T instance;
        if (isSingleton()) {
            if (this.singletonInstance == null) {
                this.singletonInstance = createInstance();
            }
            instance = this.singletonInstance;
        } else {
            instance = createInstance();
        }
        if (instance == null) {
            String msg = "Factory 'createInstance' implementation returned a null object.";
            throw new IllegalStateException(msg);
        }
        return instance;
    }
     /*
     * 子类(IniFactorySupport)实现创建实例的过程
     * */
    protected abstract T createInstance();
}
public abstract class IniFactorySupport extends AbstractFactory {
    public T createInstance() {
        /*
         * 获取Ini对象,前面已经设置进来。
         * 如果ini对象不存在,还会从默认的路径来创建Ini对象
         * */
        Ini ini = resolveIni();

        T instance;

        if (CollectionUtils.isEmpty(ini)) { 
            //如果Ini对象不存在,则调动子类(IniSecurityManagerFactory)使用默认的SecurityManager实例对象
            log.debug("No populated Ini available.  Creating a default instance.");
            instance = createDefaultInstance();
            if (instance == null) {
                String msg = getClass().getName() + " implementation did not return a default instance in " +
                        "the event of a null/empty Ini configuration.  This is required to support the " +
                        "Factory interface.  Please check your implementation.";
                throw new IllegalStateException(msg);
            }
        } else {
            log.debug("Creating instance from Ini [" + ini + "]");
            //调用子类(IniSecurityManagerFactory),根据Ini对象的信息来构建SecurityManager对象
            instance = createInstance(ini);
            if (instance == null) {
                String msg = getClass().getName() + " implementation did not return a constructed instance from " +
                        "the createInstance(Ini) method implementation.";
                throw new IllegalStateException(msg);
            }
        }

        return instance;
    }

    protected abstract T createInstance(Ini ini);

    protected abstract T createDefaultInstance();
}
public class IniSecurityManagerFactory extends IniFactorySupport {
    public static final String MAIN_SECTION_NAME = "main";

    public static final String SECURITY_MANAGER_NAME = "securityManager";
    public static final String INI_REALM_NAME = "iniRealm";

    private ReflectionBuilder builder;

    public IniSecurityManagerFactory() {
        this.builder = new ReflectionBuilder();
    }

    //默认的SecurityManager对象【其实被WebIniSecurityManagerFactory复写,返回的是DefaultWebSecurityManager】
    protected SecurityManager createDefaultInstance() {
        return new DefaultSecurityManager();
    }

    //根据Ini来创建SecurityManager对象
    protected SecurityManager createInstance(Ini ini) {
        if (CollectionUtils.isEmpty(ini)) {
            throw new NullPointerException("Ini argument cannot be null or empty.");
        }
        SecurityManager securityManager = createSecurityManager(ini);
        if (securityManager == null) {
            String msg = SecurityManager.class + " instance cannot be null.";
            throw new ConfigurationException(msg);
        }
        return securityManager;
    }

    private SecurityManager createSecurityManager(Ini ini) {
        return createSecurityManager(ini, getConfigSection(ini));
    }

    //获取[main]的配置,如果没得到则获取默认的配置
    private Ini.Section getConfigSection(Ini ini) {

        Ini.Section mainSection = ini.getSection(MAIN_SECTION_NAME);
        if (CollectionUtils.isEmpty(mainSection)) {
            //try the default:
            mainSection = ini.getSection(Ini.DEFAULT_SECTION_NAME);
        }
        return mainSection;
    }

    @SuppressWarnings({"unchecked"})
    private SecurityManager createSecurityManager(Ini ini, Ini.Section mainSection) {
        /*
         * 注意,createDefaults被子类WebIniSecurityManagerFactory复写,
         * 但其实也会首先调用本类的createDefaults方法,只是在结果中再添加了些默认的Filter实例。
         * 
         * 然后将结果保存在ReflectionBuilder对象的objects【Map】属性中,此时里面包含了默认的SecurityManager、Realm以及各种默认Filter实例;
         * 
         * 最后将createDefaults返回的Map全部加到ReflectionBuilder对象的objects【Map】中取缓存
         * */
        getReflectionBuilder().setObjects(createDefaults(ini, mainSection));

        //使用ReflectionBuilder构建对象【创建实例对象,加入到objects变量中,然后执行各个对象的init方法,同时返回objects对象】
        Map objects = buildInstances(mainSection);

        //直接从ReflectionBuilder对象中取出SecurityManager类型的对象
        SecurityManager securityManager = getSecurityManagerBean();

        /*
         * 如果securityManager不为RealmSecurityManager类型则返回true;
         * 如果是RealmSecurityManager类型,但是里面没有Realm实例,返回为true;
         * 否则返回false
         * */
        boolean autoApplyRealms = isAutoApplyRealms(securityManager);

        if (autoApplyRealms) {
            //筛选其中的Realms对象【Realm或RealmFactory类型】
            Collection realms = getRealms(objects);

            if (!CollectionUtils.isEmpty(realms)) {
                //如果securityManager不是RealmSecurityManager类型则抛出异常,否则给securityManager设置Realms
                applyRealmsToSecurityManager(realms, securityManager);
            }
        }
        return securityManager;
    }
}

到此,SecurityManager实例创建完成,并设置到IniWebEnvironment的属性objects[Map]中

public class IniWebEnvironment extends ResourceBasedWebEnvironment implements Initializable, Destroyable {
    protected void configure() {

        //Map对象
        this.objects.clear();

        WebSecurityManager securityManager = createWebSecurityManager();
        setWebSecurityManager(securityManager);

        //获取shiro.ini文件中配置的'filters' 或 'urls'项的Filter,加入objects对象中
        FilterChainResolver resolver = createFilterChainResolver();
        if (resolver != null) {
            setFilterChainResolver(resolver);
        }
    }
}

到此,Shiro的初始化过程完成,在EnvironmentLoaderListener 中将会把该IniWebEnvironment对象保存在ServletContext下供后面使用。

大致流程总结

系统启动的时候执行EnvironmentLoaderListener初始化方法并创建WebEnvironment实例,同时将实例对象保存到ServletContext中

1,创建WebEnvironment对象
1)读取web.xml中的上下文参数shiroEnvironmentClass
2)通过ServiceLoader方式查找jar包中的配置
3)是用默认的IniWebEnvironment类型

2,调用WebEnvironment的init方法初始化WebEnvironment实例
注:WebEnvironment构造犯法里面会创建WebIniSecurityManagerFactory实例factory。
1)从指定或默认的路径下解析shiro.ini文件生成Ini实例
2)将Ini实例设置给factory的ini属性
3)将默认的IniFilterChainResolverFactory设置给factory的defaultBeans(Map)属性
4)调用factory的getInstance方法创建SecurityManager对象
--解析Ini对象里面的信息,创建Realm等对象并设置给SecurityManager实例
5)将SecurityManager返回的objects(Map)添加到WebEnvironment的objects中。

默认的SecurityManager: DefaultWebSecurityManager

后面讲接着介绍Session和Realm的使用


当前题目:ApacheShiro源码解读之SecurityManager的创建
文章路径:http://kswjz.com/article/iigiee.html
扫二维码与项目经理沟通

我们在微信上24小时期待你的声音

解答本文疑问/技术咨询/运营咨询/技术建议/互联网交流