来自平行世界的救赎

神级的拷问来了

来自平行世界的救赎
为什么要说是救赎呢?先跟各位讨论个“死亡问题”,如果你的女票或者你老婆问你,“我跟你妈落水了,你先救谁?”
来自平行世界的救赎
哈哈,没错,就是这个来之中国的古老声音,这个拷问你内心的世纪难题!怕了没?
可以抛硬币,也可以找个渔网一次性捞起来,可是等等,在这紧急关头你真的有这么多时间?
此时的你肯定最想变成超人,或者修得绝世秘法“分身术”,这样就不用做这道艰难的选择题了。
平行宇宙论告诉我们,这世界有无数个copy,你也有无数个copy,只要找另外一个世界借一个你过来,你的内心就能得到神圣的救赎了。
来自平行世界的救赎


怎么做

好了,假设真的由一个平行世界,为了保证这个方法落地的可行性,我们还需要保证

  • 平行世界的真的是我

    我代表着不仅仅是名字外貌,还有我的人生种种组成才是完整的我,最佳结果是什么?平行世界的我就是从这一刻跟我分离出来的,是我的真正copy,通一个版本的copy!

  • 另一个我能到这个世界来帮我救一个人

    这是我们讨论这个问题的本质,解决不了的话,再来100个平行世界的我又能怎么样?所以他要能干涉到我们这个世界,能在这个世界行动!可是既然是平行世界,那么肯定是无法过来的啊,怎么办呢?大家都知道,作为高纬度的神可以投影到低纬度的世界中,通过“投影”来行动,或许我们可以这么干?

整理下思路
来自平行世界的救赎

两个同样的我同时救了两个生命中最重要的人,不踩坑不扎心,再来几个老婆都救得了,简直完美!


圣者(程序猿)时间

解决哲学问题而内心得到升华的我们,此时回归真我(现实),利用仅存的圣者模式思考这个方法的现实意义。
“高大上”的工程师职业过程中,我们会遇上前人有意或者无意在代码中留下的坑,譬如

  • 某些设计得很不合理的单例模式,这让我们在一个JVM中只有一个实例存在
  • 将某些数据(如状态)存在静态字段中,如果修改可能导致运行出错
  • 或者其它蛋疼不考虑后来人的设计

但我们需要为这类对象创建全新一个实例去拯救世界时,除了内心被千万草泥马践踏而过之外,似乎只能感受到这世界满满的恶意了。
不,肯定不是!
我们可是在圣者模式!!
在操蛋的现实社会中我们只是屌丝,但在0和1的世界里,我们可是神!
无所不能的神!
来自平行世界的救赎
神爱世人,怎么会让自己的羔羊生活在水深火热中呢?
就像拯救你妈你老婆和你内心那样,我们可以创造出一个平行世界出来啊,从虚无中造物不就是我们的本能么?


设计

前面我们已经讨论过世纪难题的解决方案,也给出了设计图,此时的我们只要把这个思维转换为由0和1组成的另一个世界的方式表达,似乎就可以了?
来自平行世界的救赎
我们要通过救世主对象去操作一堆“待拯救的对象”,嗯,这就是救世主应该做的。
但是,另外一边出现灾难了,又有一堆“待拯救的对象”排排坐,等着救世主来拯救。
救世主说,卧槽,我TM分身乏术啊,上帝没给我分身这个超能力,我也很无助啊。
来自平行世界的救赎
好了,这个时候就是英雄闪亮登场的机会啦。
来自平行世界的救赎
你爹妈不给你分身术,咱不分身啦,咱直接开一个新的世界,拉一个过来呗,别问为啥,就是这么任性。
来自平行世界的救赎
嗯,具体操作就像如何把大象放进冰箱一样分3步

1、新开辟一个世界;
2、复制一个救世主过去;
3、把救世主投影过来;

步骤有啦,分析下怎么执行。

  1. 新开辟一个世界

    我们是务实的工程师,不能吹逼,所以不应该叫新开辟世界,应该叫做制作一个相对比较隔离的环境出来,要求呢?这个环境应该

    • 工作在里面的对象跟外面的能力应该是完全一样的
    • 环境外面应该是无法感知里面的情况的
    • 环境内外的对象应该是完全不同的

    我们暂且为这个环境命名为“沙箱”(Sandbox)吧。
    以单例设计为参考,单例设计一般是寄托于类(Class)存在的,为了复制这个对象,我们需要做的是将整个Class复制一份。
    来自平行世界的救赎
    我们知道Java中的Class是由ClassLoader装载进内存的,而ClassLoader采用的是双亲委派机制,一个ClassLoader内独有的业务对象对其它ClassLoader是不存在的,这不就完美满足我们上面说的三个点吗?Good,就它了!
    方案:采用ClassLoader作为沙箱环境隔离

  2. 复制一个救世主过去

    前面我们确定了ClassLoader方案后思路自然豁然开朗,现在考虑将Class复制进沙箱(ClassLoader)内就非常简单啦!
    我们知道,ClassLoader装载Class时候其实是读取.class文件,再通过ClassLoader的defineClass来实际定义一个类的,嗯,那我们将沙箱外的类定义复制过来也可以这样,两步
    首先读取.class内容。这个文件在哪里呢?当jar包被ClassLoader装入内存后,通过getResource就可以将文件数据读取到啦,完美!
    在沙箱内定义类。简单,就一个defineClass,打完收工~
    嘿,别急,小心类重新定义哦,记得记录下定义过哪些类。
    来自平行世界的救赎

  3. 把救世主投影过来

    对,这也是个问题。
    刚刚我们有说过,不同ClassLoader的独有业务对象对其它ClassLoader而言是不存在的!这就引发出问题了,外面无法使用里面创造出来的对象实例!
    来自平行世界的救赎
    举个例子

    BizObject biz = new BizObject(); //OK
    BizObject biz2 = Sandbox.createObject(BizObject.class); //出错

    为什么出错呢?因为沙箱内外的BizObject是不一样的啊,正反粒子在一起会湮灭的。。。
    所以我们需要投影。
    好吧,不是投影,我们需要有一个代理,在沙箱外培养一个傀儡,哦不是,是代理,对这个代理的所有操作都能反馈到沙箱内去执行。
    来自平行世界的救赎

嗯,到这里为止,我们基本将问题梳理一遍了,那么下一步。。。。。。
来自平行世界的救赎


神说,要有光

通过上面分析和梳理,我们基本已经确定了方向和逻辑,现在呢,万事俱备,只缺一道神奇的东风我们就可以进入全新世界里了,那我们开始撸代码!
来自平行世界的救赎
等等这位同学,我们是不是漏了什么?
撸代码前我们先要进行设计啊!
来自平行世界的救赎
好吧,我们讨论下本次需求。。。
首先,我们假定了已经设定了一个神奇的“沙箱”,沙箱内外隔离,所以内外的通信只能通过一座也是非常神奇的桥梁来进行,这就是“代理”;
当外部的某位同学需要创建一个对象但又受到各种限制的时候,他可以在沙箱内创建一个此对象的分身,然后通过分身的代理进行操作就可以实现对分身的操纵,从而达成目的。
嗯,需求只有这么多,接下来我们谈谈设计。
上面讨论中我们决定了使用ClassLoader对沙箱内外进行隔离,可是不是直接暴露ClassLoader接口给外部使用呢?
ClassLoader能对底层类进行操作,虽然功能强大,但操作复杂度高,一不留神容易出现问题,所以我们应该对它进行封装,仅提供我们期望用户去使用的接口,而且我们认为它应该具备这些特点

  • 功能单一
  • 与沙箱不相干的都不要暴露
  • 创建对象后直接可以使用

这对ClassLoader来说有些强人所难,所以我们需要把它隐藏起来,创造一个沙箱对外提供服务,而将ClassLoader隐藏在沙箱内部,假定它叫“SandboxClassLoader”。
这样我们就有了

  • 调用者
  • 沙箱
  • SandboxClassLoader
  • 外部ClassLoader

四个对象了。
还有一点,上面说过我们的调用者通过代理对沙箱内对象进行操作,还记得为什么要使用代理吗?使用代理的本质原因是沙箱内外的类分属不同ClassLoader,即使同名类也是不同的
同样道理,当我们通过代理对象进行调用时,参数传递使用的是沙箱外的对象,进入沙箱内也是不能直接使用的,因此,我们同样需要对这类对象进行转换。
此处我们仅考虑值对象参数,各位同学如果关心其它对象传参的话,需要进行类似的代理转换,但值对象的话,我们只要进行值复制就行了,无需太过复杂处理
我们通过一幅图来说明下这个关系
来自平行世界的救赎
图片很直观,就不再重复解说啦
嗯,基本梳理应该已经非常清晰了,图中只有蓝色的“沙箱内某对象”属于工作在沙箱内,动态创建出来的,其它都是在沙箱外;
而方框画出了沙箱组件边界,调用者和APPClassLoader都属于已存在的实例无需关心,组件内部就属于需要实现的部分了。
列一下关键几个类
来自平行世界的救赎
可以看出,Sandbox的API已经变得非常单一和简单了。
为了简化设计,这里规定了待创建的对象必须有无参构造函数,如果同学有需要通过有参构造函数构造对象的话,可以进行扩展实现,欢迎一起做好这个沙箱工具
为什么这里要分开枚举和非枚举对象呢?有同学清楚吗?
枚举的概念是指能有限列举出来的东西,在java中,枚举对象继承自Enum,不能通过new方法进行构造,只能从枚举的值中选取
而对象继承自Object,大家都非常的熟悉

创世纪

终于进入最重要的撸代码环节了。。。
来自平行世界的救赎
挑重点的代码出来,咱撸一撸

public class Sandbox {
    private SandboxClassLoader classLoader;
    private SandboxUtil util = new SandboxUtil();
    private List<String> redefinedPackages;

    public Sandbox(List<String> packages){
        redefinedPackages = packages;
        classLoader = new SandboxClassLoader(getContextClassLoader());
    }

    /**
     * 沙箱对象构造方法
     * @param redefinedPackages 需工作在沙箱内的包
     *                          此包下面所有类都在工作在沙箱内
     */
    public Sandbox(String... redefinedPackages){
        this(Lists.newArrayList(redefinedPackages));
    }
    // ......
}

先说说构造方法
既然是沙箱对象,为什么要设计有参构造方法呢?
实际使用中,我们会考虑某些类之间内聚,当一个类放在沙箱内运行时,其它也建议放在沙箱内跑,而我们学过“单一性原则”,知道一个包内一般都是比较内聚的,所以这里设计就是指定某些package路径,沙箱将会对这些包内对象进行接管。
对于不在这些包内的类,如果我们调用了沙箱来构造会怎么样呢?所谓“Talk is cheap, show me the code”~~
请稍后,我们继续构造函数,哈哈~~这个问题我们标记为问题1稍后讨论
这里出现了SandboxClassLoader,使用了getContextClassLoader()作为参数传递,此处做了什么呢?我们先看看SandboxClassLoader的构造方法

    /**
     * 沙箱隔离核心
     *
     * 通过ClassLoader将进行类级别的运行时隔离
     *
     * 此类本质上是代理了currentContextClassLoader对象,并增加了对部分需要在沙箱内运行的类处理能力
     */
    class SandboxClassLoader extends ClassLoader{
        //当前上下文的ClassLoader,用于寻找类实例并克隆进沙箱
        private final ClassLoader contextClassLoader;
        //缓存已经创建过的Class实例,避免重复定义
        private final Map<String, Class> cache = Maps.newHashMap();

        SandboxClassLoader(ClassLoader contextClassLoader) {
            this.contextClassLoader = contextClassLoader;
        }
        //......
    }

SandboxClassLoader的构造方法仅仅是将传入的contextClassLoader进行暂存备用,那么我们还是看看getContextClassLoader方法

    /**
     * 获取当前上下文的类装载器
     *
     * 此类装载器需包含MQClient相关类定义
     * PS:单独定义为一个方法,是担心当这个上下文类装载器满足不了要求时可以快速更换
     * @return 当前类装载器
     */
    private ClassLoader getContextClassLoader() {
        //从类装载器机制而言,线程上下文的类转载器是最符合要求的
        return Thread.currentThread().getContextClassLoader();
    }

好简单!!
其实这里是有一些设计依据的:我们要去创建一个对象,那么这个对象的类定义必然在当前代码可访问的。
基于这个考虑,我们可以确定,当用户使用类似A a = Sandbox.createObject(A.class)进行创建沙箱内对象时,A类在这段代码执行的上下文必然可以访问,此时我们可以通过此上下文的ClassLoader去获取到这个A类对应的.class资源文件,然后重定义该类了。
继续看看相关代码,为了阅读方便,我重新组织了下代码结构

public class Sandbox {
    private SandboxClassLoader classLoader;
    //......

    /**
     * 在沙箱内创建指定名称的类实例
     *
     * 如该名称类不属于redefinedPackages所指定的包内,则直接返回外部类实例
     * @param clzName 待创建实例的类名称
     * @return 指定类名称的实例对象
     */
    public <T extends Object> T createObject(String clzName) throws ClassNotFoundException, SandboxCannotCreateObjectException {
        Class clz = Class.forName(clzName);
        return (T) createObject(clz);
    }

    /**
     * 在沙箱内创建指定Class的实例
     * @param clz 待创建实例的Class
     * @return 跟clz功能相同并工作在沙箱内的类实例
     */
    public synchronized <T extends Object> T createObject(Class<T> clz) throws SandboxCannotCreateObjectException {
        try {
            final Class<?> clzInSandbox = classLoader.loadClass(clz.getName());
            final Object objectInSandbox = clzInSandbox.newInstance();

            //如果对象的类装载器和clz的类装载器一致,说明不是需要工作在沙箱内的对象,直接返回即可,无需代理
            if(objectInSandbox.getClass().getClassLoader() == clz.getClassLoader()){
                return (T) objectInSandbox;
            }

            /*
            创建生产者的代理:由于沙箱内外的对象本质上属于不同的类,因此需要将两者能力桥接起来
                            这里采用了代理模式,通过创建沙箱外的对象实例,并将其所有方法调用通过代理转发到沙箱内执行
                            另外,由于沙箱内外的所有实例都属于不同的类,因此,对于参数和返回值还需要进行对象转换,将沙箱内外的对象进行对等克隆
             */

            //通过cglib创建对象的子类代理
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(clz);
            enhancer.setCallback((MethodInterceptor) (o, method, args, methodProxy) -> {
                Method targetMethod = clzInSandbox.getMethod(method.getName(), method.getParameterTypes());
                //调用前需对参数进行克隆,转换为沙箱内对象
                Object[] targetArgs = args == null ? null : util.cloneTo(args, classLoader);
                Object result = targetMethod.invoke(objectInSandbox, targetArgs);
                //调用后续对结果进行克隆,转换为沙箱外对象
                return util.cloneTo(result, getContextClassLoader());
            });
            return (T) enhancer.create();
        }catch (IllegalAccessException | InstantiationException | ClassNotFoundException e) {
            throw new SandboxCannotCreateObjectException("无法在沙箱内创建对象", e);
        }
    }

    //......
}

Sandbox中创建对象的主要方法出现了!为了方便阅读,我将无关代码剔除,仅保留createObject方法。
T createObject(String clzName)方法无具体实现,仅进行参数clzName的校验,然后就转给T createObject(Class clz),因此主要分析这个方法。
其实代码量不多(仅19行还包括各种花括号),主要都是注释,脉络如下

  1. 先获取参数clz在沙箱内的对于类定义clzInSandbox,并通过clzInSandboxnewInstance创建该类的一个具体实例objectInSandbox因此这里要求clz有无参构造函数
  2. 判断clzInSandbox是否运行在沙箱内,如果不是运行在沙箱内的话,无需创建代理直接将对象objectInSandbox返回;
    为什么要做这个判断嗯?这里可以顺带解答前面的问题1了,从代码

    //如果对象的类装载器和clz的类装载器一致,说明不是需要工作在沙箱内的对象,直接返回即可,无需代理
    if(objectInSandbox.getClass().getClassLoader() == &gt; clz.getClassLoader()){
        return (T) objectInSandbox;
    }

    我们可以看出来,当创建出来的objectInSandbox也是运行在外部的ClassLoader时,其实是不去创建代理的,因为它就是一个沙箱外的对象,又何必去创建代理这么多此一举呢?
    可我们明明调用的是classLoader.loadClass(clz.getName())去取得沙箱内的类定义,为什么得到的却是沙箱外的呢?这跟我们对SandboxClassLoader这个类的设计是否矛盾了呢?
    好,去看看对应的代码,show me the code

    class SandboxClassLoader extends ClassLoader{
        //当前上下文的ClassLoader,用于寻找类实例并克隆进沙箱
        private final ClassLoader contextClassLoader;
        //......  
    
        /**
         * 覆盖父类的转载类进内存的方法
         * @param name 指定类名称
         * @return 已转载进内存的Class实例
         * @throws ClassNotFoundException
         */
        @Override
        public Class&lt;?&gt; loadClass(String name) throws ClassNotFoundException {
            return findClass(name);
        }
    
        /**
         * 重定义类转载逻辑
         *
         * 1、对于需要运行在沙箱内的类(redefinedPackages中声明),通过复制contextClassLoader类定义的方式,直接运行在此ClassLoader下
         * 2、对于不需要运行在沙箱内的类,直接返回上下文类定义,以减少资源占用
         * @param name 类名称(全路径)
         * @return 类定义
         */
        @Override
        protected Class&lt;?&gt; findClass(String name) throws ClassNotFoundException {
            if(isRedefinedClass(name)) {
                return getSandboxClass(name);
            } else {
                return contextClassLoader.loadClass(name);
            }
        }
    
        //......
    }

    看得出实际实现逻辑的代码是findClass方法,仅几句而已,翻译过来就是“需要重定义的类我们从沙箱内取得,不需要的直接从外部取”,所以会有对象的ClassLoader是外部的。
    那什么是“需要重定义的类”呢?

    /**
     * 是否需要运行在沙箱内的类
     * @param name 类名称
     */
    boolean isRedefinedClass(String name) {
        //校验是否沙箱约定的需要重定义的包
        for (String redefinedPackage : redefinedPackages) {
            if(name.startsWith(redefinedPackage)){
                return true;
            }
        }
        return false;
    }

    只要是Sandbox类构造时指定的包下面的类,统统都属于需要重新在SandboxClassLoader中重定义的。

  3. 利用cglib库创建objectInSandbox的代理对象,拦截该代理对象的所有方法执行,全部转去实际的对象objectInSandbox中执行;
    cglib创建对象的代码不分析了,本质就是通过创建一个指定类的子类对方法进行拦截的过程;
    我们关心的应该是拦截器干了什么?

    enhancer.setCallback((MethodInterceptor) (o, method, args, methodProxy) -&gt; {
                Method targetMethod = clzInSandbox.getMethod(method.getName(), method.getParameterTypes());
                //调用前需对参数进行克隆,转换为沙箱内对象
                Object[] targetArgs = args == null ? null : util.cloneTo(args, classLoader);
                Object result = targetMethod.invoke(objectInSandbox, targetArgs);
                //调用后续对结果进行克隆,转换为沙箱外对象
                return util.cloneTo(result, getContextClassLoader());
            });

    我们会从沙箱内的对象中取得同名同参的方法,然后将参数进行转换到沙箱内,再执行沙箱内对象方法并得到结果,最后还要将结果进行转换到沙箱外对象才返回;
    逻辑非常清晰,但沙箱内外对象如何转换呢?
    这里代码有些长且无聊就不单独贴出来了,有兴趣的同学可以上github上自行下载,大体逻辑如下

    1. 判断对象是否需要转换成沙箱内/外,不需要则返回此对象,需要就转2;
    2. 创建沙箱内/外对应的对象实例;
    3. 遍历该对象实例的每一个字段,对该字段执行步骤1,并将复制后的值赋值给新对象中对应字段;

    嗯,就是这样。
    前面我们有提到,我们假定传参对象都是值对象,所以这里的设计相对简单,如有哪位同学需要传非值对象,那么就需要对外部对象做代理

  4. 将代理对象返回;

有些同学关心类如何从沙箱外复制到沙箱内重定义的是吧?这是SandboxClassLoader的核心部分,展示下代码逻辑

class SandboxClassLoader extends ClassLoader {
    //......
    //缓存已经创建过的Class实例,避免重复定义
    private final Map<String, Class> cache = Maps.newHashMap();

    /**
        * 内部方法:获取需要在沙箱内运行的Class实例
        * @param name 类名称
        * @return 沙箱内的类实例
        * @throws ClassNotFoundException
        */
    private synchronized Class<?> getSandboxClass(String name) throws ClassNotFoundException {
        //1、先从缓存中查找是否已经转载过该类,有则直接返回
        if(cache.containsKey(name)){
            return cache.get(name);
        }
        //2、缓存不存在该类时,从currentContextClassLoader中复制一份到当前缓存中
        Class<?> clz = copyClass(name);
        cache.put(name, clz);
        return clz;
    }

    /**
        * 从currentContextClassLoader中复制一份类到本ClassLoader中
        *
        * 此复制是将字节码copy到当前ClassLoader进行定义,因此与sandbox外部的Class已经完全不同实例,不能给外部直接赋值
        * @param name 待复制的类名称
        * @return 工作在当前ClassLoader中的Class
        * @throws ClassNotFoundException
        */
    private synchronized Class<?> copyClass(String name) throws ClassNotFoundException {
        //取得.class文件所在路径
        String path = name.replace('.', '/') + ".class";
        //通过上下文类装载器获取资源句柄
        try (InputStream stream = contextClassLoader.getResourceAsStream(path)) {
            if(stream == null) throw new ClassNotFoundException(String.format("找不到类%s", name));

            //读取所有字节内容
            byte[] content = readFromStream(stream);
            return defineClass(name, content, 0, content.length);
        } catch (IOException e) {
            throw new ClassNotFoundException("找不到指定的类", e);
        }
    }

    //......
}

涉及到的方法主要有两个,getSandboxClass方法主要负责获取对象时进行缓存层面的校验,缓存的目的一个是加速获取类定义的性能,一个是避免同一个类定义重复多次执行导致出错。
copyClass顾名思义就是复制类定义,是从contextClassLoader中将类对应的.class文件进行复制,并在SandboxClassLoader中defineClass的过程,具体请阅读代码。

Sandbox中我们还有一个getEnumValue方法,过程有些类似就不重复介绍,请下载代码阅读。

至此,我们完成了代码的编写了。
至此,我们完成了新世界的构建了!
至此,我们完成了所有工作了!!??
高兴得太早了。。。
来自平行世界的救赎


到来的救赎

测试是代码质量的保障,是设计的保障,是运行的保障,是……的保障,总之,就是保障。
所以,我们还要通过测试,为我们的“世界”进行验证,看看它是否跟我们预期一致。
这只需要使用单元测试就可以做到了。代码

public class SandboxTest {

    @Test
    public void getEnumValue() throws SandboxCannotCreateObjectException {
        //设定重定义的包
        Sandbox sandbox = new Sandbox("com.google.common.collect");

        //获取沙箱内对象,虽然是同名同值,但由于分属沙箱内外,因此预期应该不等
        Enum type = sandbox.getEnumValue(com.google.common.collect.BoundType.CLOSED);
        assertNotEquals(type, com.google.common.collect.BoundType.CLOSED);

        //通过沙箱获取非设定需要重定义的包内对象,预期应该是相等
        Enum property = sandbox.getEnumValue(com.google.common.base.StandardSystemProperty.JAVA_CLASS_PATH);
        assertEquals(property, com.google.common.base.StandardSystemProperty.JAVA_CLASS_PATH);
    }

    @Test
    public void createObject() throws SandboxCannotCreateObjectException, ClassNotFoundException {
        //设定重定义的包
        Sandbox sandbox = new Sandbox("com.google.common.eventbus");

        //获取沙箱内对象,预期中类定义应该与沙箱外的类定义不等
        com.google.common.eventbus.EventBus bus = sandbox.createObject(com.google.common.eventbus.EventBus.class);
        assertNotEquals(bus.getClass(), com.google.common.eventbus.EventBus.class);

        //通过名称获取,如上
        bus = sandbox.createObject("com.google.common.eventbus.EventBus");
        assertNotEquals(bus.getClass(), com.google.common.eventbus.EventBus.class);

        //通过沙箱获取无需重定义的类,预期应该跟沙箱外相等
        List<String> list = sandbox.createObject(ArrayList.class);
        assertEquals(list.getClass(), ArrayList.class);
    }
}

运行结果
来自平行世界的救赎
OK,测试通过~~~
来自平行世界的救赎


世界的坐标

  • -> github
  • -> 码云gitee

落地案例:如何在同一个Java进程中连接多个RocketMQ服务器

原创文章,作者:3628473679,如若转载,请注明出处:https://blog.ytso.com/192114.html

(0)
上一篇 2021年11月14日
下一篇 2021年11月14日

相关推荐

发表回复

登录后才能评论