繁体   English   中英

如何在Java中实现与单应性方法的接口?

[英]How to implement interfaces with homographic methods in Java?

在英语中,同形异义词对是两个具有相同拼写但含义不同的单词。

在软件工程中,一对同形方法是两种具有相同名称但要求不同的方法。 让我们看一个人为的例子,让问题尽可能清晰:

interface I1 { 
    /** return 1 */ 
    int f()
}
interface I2 {
    /** return 2*/
    int f()
}
interface I12 extends I1, I2 {}

我该如何实施I12 C# 有办法做到这一点,但Java没有。 所以唯一的方法是破解。 怎样才能最可靠地使用反射/字节码技巧/等(也就是说它不一定是一个完美的解决方案,我只想要一个效果最好的解决方案)?


需要注意的是遗留代码,我不能合法逆向工程现有的一些封闭源代码的庞大的一块需要类型的参数I12和代表I12既具有代码I1作为参数,和代码具有I2作为参数。 所以基本上我需要创建一个I12的实例,它知道它何时应该作为I1 ,何时它应该作为I2 ,我相信可以通过直接调用者的运行时查看字节码来完成。 我们可以假设调用者没有使用反射,因为这是简单的代码。 问题是I12的作者没想到Java会从两个接口合并f ,所以现在我必须想出最好的解决问题的方法。 没有任何东西可以调用I12.f (显然如果作者写了一些实际调用I12.f代码,他会在出售之前注意到这个问题)。

请注意,我实际上是在寻找这个问题的答案,而不是如何重构我无法改变的代码。 我正在寻找可行的最佳启发式或者如果存在的话,可以找到精确的解决方案。 请参阅Gray的答案以获得有效示例(我确信有更强大的解决方案)。


是一个具体的例子,说明两个接口内的单应方法问题是如何发生的。 这是另一个具体的例子:

我有以下6个简单的类/接口。 它类似于剧院周围的商业和在其中表演的艺术家。 为了简单起见,我们假设它们都是由不同的人创建的。

Set代表一个集合,如集合论:

interface Set {
    /** Complements this set,
        i.e: all elements in the set are removed,
        and all other elements in the universe are added. */
    public void complement();
    /** Remove an arbitrary element from the set */
    public void remove();
    public boolean empty();
}

HRDepartment使用Set来代表员工。 它使用复杂的流程来解码雇用/解雇的员工:

import java.util.Random;
class HRDepartment {
    private Random random = new Random();
    private Set employees;

    public HRDepartment(Set employees) {
        this.employees = employees;
    }

    public void doHiringAndLayingoffProcess() {
        if (random.nextBoolean())
            employees.complement();
        else
            employees.remove();
        if (employees.empty())
            employees.complement();
    }
}

Set雇员的世界可能是申请雇主的雇员。 因此,当在该集合上调用complement时,将触发所有现有员工,并且之前应用的所有其他员工都被雇用。

Artist代表艺术家,如音乐家或演员。 艺术家有自我。 当其他人赞美他时,这种自我会增加:

interface Artist {
    /** Complements the artist. Increases ego. */
    public void complement();
    public int getEgo();
}

Theater使Artist表演,这可能使Artist得到补充。 剧院的观众可以在表演之间评判艺术家。 表演者的自我越高,观众就越有可能喜欢Artist ,但如果自我超越某一点,艺术家将受到观众的负面看法:

import java.util.Random;
public class Theater {
    private Artist artist;
    private Random random = new Random();

    public Theater(Artist artist) {
        this.artist = artist;
    }
    public void perform() {
        if (random.nextBoolean())
            artist.complement();
    }
    public boolean judge() {
        int ego = artist.getEgo();
        if (ego > 10)
            return false;
        return (ego - random.nextInt(15) > 0);
    }
}

ArtistSet只是一个ArtistSet

/** A set of associated artists, e.g: a band. */
interface ArtistSet extends Set, Artist {
}

TheaterManager运行节目。 如果剧院的观众对艺术家负面评价,剧院会与人力资源部门进行对话,人力资源部门将反过来解雇艺术家,雇用新的艺术家等等:

class TheaterManager {
    private Theater theater;
    private HRDepartment hr;

    public TheaterManager(ArtistSet artists) {
        this.theater = new Theater(artists);
        this.hr = new HRDepartment(artists);
    }

    public void runShow() {
        theater.perform();
        if (!theater.judge()) {
            hr.doHiringAndLayingoffProcess();
        }
    }
}

一旦你尝试实现ArtistSet ,问题就变得清晰了:两个超级ArtistSet都指定complement应该做其他事情,所以你必须以同样的方式在同一个类中实现具有相同签名的两个complement方法。 Artist.complement是单应Set.complement

新想法,有点凌乱......

public class MyArtistSet implements ArtistSet {

    public void complement() {
        StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();

        // the last element in stackTraceElements is the least recent method invocation
        // so we want the one near the top, probably index 1, but you might have to play
        // with it to figure it out: could do something like this

        boolean callCameFromHR = false;
        boolean callCameFromTheatre = false;

        for(int i = 0; i < 3; i++) {
           if(stackTraceElements[i].getClassName().contains("Theatre")) {
               callCameFromTheatre = true;
           }
           if(stackTraceElements[i].getClassName().contains("HRDepartment")) {
               callCameFromHR = true;
           }
        }

        if(callCameFromHR && callCameFromTheatre) {
            // problem
        }
        else if(callCameFromHR) {
            // respond one way
        }
        else if(callCameFromTheatre) {
            // respond another way
        }
        else {
            // it didn't come from either
        }
    }
}

如何解决您的具体案例

ArtistSet只是一个艺术家和一套:

 /** A set of associated artists, e.g: a band. */
 interface ArtistSet extends Set, Artist { }

从OO的角度来看,这不是一个有用的声明。 艺术家是一种名词,一种定义了属性和动作(方法)的“东西”。 集合是事物的集合 - 独特元素的集合。 相反,尝试:

ArtistSet只是一组艺术家。

 /** A set of associated artists, e.g: a band. */
 interface ArtistSet extends Set<Artist> { };

然后,对于您的特定情况,同音词方法是在从不在一种类型中组合的接口上,因此您没有冲突并且可以编程...

此外,您不需要声明ArtistSet因为您实际上并未使用任何新声明扩展Set。 您只是实例化一个类型参数,因此您可以使用Set<Artist>替换所有用法。

如何解决更一般的案例

对于这种冲突,方法名称甚至不需要在英语意义上是单应性的 - 它们可以是具有相同英语含义的相同单词,在java中的不同上下文中使用。 如果您希望将两个接口应用于某个类型但它们包含具有冲突语义/处理定义的相同声明(例如方法签名),则会发生冲突。

Java不允许您实现您请求的行为 - 您必须有另一种解决方法。 Java不允许类为来自多个不同接口的相同方法签名提供多个实现(多次实现相同的方法,并使用某种形式的限定/别名/注释来区分)。 请参阅Java覆盖两个接口,方法名称的冲突Java - 接口实现中的方法名称冲突

例如,如果您有以下内容

 interface TV {
     void switchOn();
     void switchOff();
     void changeChannel(int ChannelNumber);
 }

 interface Video {
     void switchOn();
     void switchOff();
     void eject();
     void play();
     void stop();
 }

然后,如果你有一个同时具有这两个东西的对象,你可以将它们组合在一个新的界面中(可选)或输入:

interface TVVideo {
     TV getTv();
     Video getVideo();
}


class TVVideoImpl implements TVVideo {
     TV tv;
     Video video;

     public TVVideoImpl() {
         tv = new SomeTVImpl(....);
         video = new SomeVideoImpl(....);
     }

     TV getTv() { return tv };
     Video getVideo() { return video };
}

尽管Gray Kemmey的勇敢尝试,我会说问题,因为你已经说过它是不可解决的。 作为给定ArtistSet的一般规则,您无法知道调用它的代码是否期望ArtistSet

此外,即使你可以根据你对各种其他答案的评论,你实际上还需要将ArtistSet传递给供应商提供的函数,这意味着函数没有给编译器或人类任何关于它期望的线索。 。 对于任何技术上正确的答案,你完全没有运气。

作为完成工作的实际编程问题,我将执行以下操作(按此顺序):

  1. 向创建需要ArtistSet的界面的任何人以及自己生成ArtistSet界面的人提交错误报告。
  2. 向提供需要ArtistSet的函数的供应商ArtistSet支持请求,并询问他们对complement()的行为的期望。
  3. 实现complement()函数以抛出异常。
public class Sybil implements ArtistSet {
  public void complement() { 
    throw new UnsupportedOperationException('What am I supposed to do'); 
  }
  ...
}

因为认真,你不知道该怎么做。 当像这样调用时,做什么是正确的做法(你怎么知道)?

class TalentAgent {
    public void pr(ArtistSet artistsSet) {
      artistSet.complement();
    }
}

通过抛出异常,您有机会获得堆栈跟踪,从而为您提供关于调用者期望的两种行为中的哪一种的线索。 幸运的是,没有人会调用该功能,这就是为什么供应商在运输代码方面遇到了这个问题。 虽然运气较少但仍有一些,他们处理异常。 如果不是这样,那么,至少现在你将有一个堆栈跟踪,你可以查看以确定调用者真正期待的内容并可能实现它(虽然我不情愿地想到这种方式永久存在错误,但我已经解释了我是怎么回事会在另一个答案中这样做)。

顺便说一句,对于其余的实现,我会将所有内容委托给通过构造函数传入的实际ArtistSet对象,以便以后可以轻松拆分。

如何实现一个具有两个具有单应方法的超接口的类?

在Java中,具有两个具有单应方法的超接口的类被认为仅具有该方法的一个实现。 (请参阅Java语言规范部分8.4.8 )。 这允许类方便地从多个接口继承,这些接口都实现相同的其他接口,并且只实现一次该功能。 这也简化了语言,因为这样就不需要语法和方法调度支持来区分基于它们来自哪个接口的单应方法。

因此,实现具有两个具有单应方法的超接口的类的正确方法是提供满足两个超接口的契约的单个方法。

C#有办法做到这一点。 如何在Java中完成? 这个没有构造吗?

C#定义的接口与Java不同,因此具有Java不具备的功能。

在Java中,语言构造被定义为意味着所有接口都获得相同方法的相同单个实现。 没有Java语言构造用于基于对象的编译时类创建多重继承的接口函数的替代行为。 这是Java语言设计者的有意识选择。

如果没有,如何最可靠地使用反射/字节码技巧/等?

“它”不能用反射/字节码技巧完成,因为决定选择哪个接口版本的单应方法所需的信息不一定存在于Java源代码中。 鉴于:

interface I1 { 
    // return ASCII character code of first character of String s 
    int f(String s); // f("Hello") returns 72
}
interface I2 {
    // return number of characters in String s 
    int f(String s);  // f("Hello") returns 5
}

interface I12 extends I1, I2 {}

public class C {
  public static int f1(I1 i, String s) { return i.f(s); }  // f1( i, "Hi") == 72
  public static int f2(I2 i, String s) { return i.f(s); }  // f2( i, "Hi") == 2
  public static int f12(I12 i, String s) { return i.f(s);} // f12(i, "Hi") == ???
}

根据Java语言规范,实现I12的类必须以这样的方式这样做: C.f1()C.f2()C.f12()在使用相同的参数调用时返回完全相同的结果。 如果C.f12(i, "Hello")有时返回72并且有时根据C.f12()的调用方式返回5,那将是程序中的一个严重错误并且违反了语言规范。

此外,如果C类的作者期望f12()中存在某种一致的行为,则C类中没有字节码或其他信息表明它是否应该是I1.f(s)I2.f(s)的行为I2.f(s) 如果C.f12()的作者想到Cf("Hello")应该返回5或72,那么就无法通过查看代码来判断。

很好,所以我通常不能使用字节码技巧为同形函数提供不同的行为,但我真的有一个像我的示例类TheaterManager类。 我该怎么做才能实现ArtistSet.complement()

您提出的实际问题实际答案是创建自己的TheaterManager替代实现,不需要ArtistSet 您不需要更改库的实现,您需要自己编写。

您引用的另一个示例问题实际答案基本上是“将I12.f()委托给I2.f() ”,因为没有接收I12对象的函数继续将该对象传递给期望I1对象的函数。

Stack Overflow仅用于普遍感兴趣的问题和答案

这里拒绝一个问题的原因之一是“它只与一个非常狭窄的情况有关,而这种情况通常不适用于全球互联网用户。” 因为我们希望提供帮助,处理这些狭隘问题的首选方法是修改问题,以便更广泛地适用。 对于这个问题,我采取的方法是回答广泛适用的问题版本,而不是实际编辑问题,以删除使其独特的问题。

在商业编程的现实世界中,任何像I12这样具有破坏接口的Java库都不会累积甚至数十个商业客户端,除非可以通过以下方式之一实现I12.f()来使用它们:

  • 委托给I1.f()
  • 委托给I2.f()
  • 没做什么
  • 抛出一个例外
  • 根据I12对象的某些成员的值,在每次调用的基础上选择上述策略之一

如果成千上万甚至只有少数几家公司在Java中使用这个库的这一部分,那么可以放心,他们已经使用了其中一种解决方案。 如果即使是少数公司也没有使用该库,那么Stack Overflow的问题就太窄了。

好吧, TheaterManager过于简单了。 在实际情况下,我很难替换那个类,我不喜欢你概述的任何实际解决方案。 我不能用花哨的JVM技巧解决这个问题吗?

这取决于你想要修复的内容。 如果要通过将所有调用映射到I12.f()然后解析堆栈以确定调用者并基于此选择行为来修复特定库。 您可以通过Thread.currentThread().getStackTrace()访问堆栈。

如果您遇到呼叫者,则无法识别您可能很难确定他们想要的版本。 例如,您可以从泛型调用(就像您给出的其他特定示例中的实际情况一样),例如:

public class TalentAgent<T extends Artist> {
  public static void butterUp(List<T> people) {
    for (T a: people) {
      a.complement()
    }
  }
}

在Java中, 泛型被实现为擦除 ,这意味着在编译时抛弃所有类型信息。 TalentAgent<Artist>TalentAgent<Set>之间没有类或方法签名差异, people参数的正式类型只是List 调用者的类接口或方法签名中没有任何内容可以通过查看堆栈来告诉您该做什么。

所以你需要实现多个策略,其中一个策略是反编译调用方法的代码,寻找调用者期望一个或另一个类的线索。 它必须非常复杂,以涵盖所有可能发生的方式,因为除了其他事项之外,你无法事先知道它实际期望的类,只是它期望一个实现其中一个接口的类。

有成熟且极其复杂的开源字节码实用程序,包括在运行时自动为给定类生成代理的实用程序(在Java语言支持之前很久就已编写),因此没有开源的事实处理这种情况的实用程序说明了在采用这种方法时努力与有用的比率。

好的,经过大量的研究,我有另一个想法来完全适应这种情况。 由于您无法直接修改其代码......您可以自行强制进行修改。

免责声明:以下示例代码非常简化。 我的目的是展示如何做到这一点的一般方法,而不是生成有效的源代码(因为这本身就是一个项目)。

问题是这些方法是单应性的。 所以要解决它,我们可以重命名方法。 简单吧? 我们可以使用Instrument包来实现这一目标。 正如您在链接文档中看到的那样,它允许您创建一个“代理”,可以在加载类时直接修改类,或者即使它们已经加载也可以重新修改它们。

从本质上讲,这需要您创建两个类:

  • 预处理和重新加载类的代理类; 和,
  • 一个ClassFileTransformer实现,它指定您要进行的更改。

代理类必须定义premain()agentmain()方法,具体取决于您是希望它在JVM启动时还是在它已经运行之后开始处理。 这方面的例子在上面的包文档中。 这些方法允许您访问Instrumenation实例,这将允许您注册ClassFileTransformer 所以它可能看起来像这样:

InterfaceFixAgent.java

public class InterfaceFixAgent {

    public static void premain(String agentArgs, Instrumentation inst) {

        //Register an ArtistTransformer
        inst.addTransformer(new ArtistTransformer());

        //In case the Artist interface or its subclasses 
        //have already been loaded by the JVM
        try {
            for(Class<?> clazz : inst.getAllLoadedClasses()) {
                if(Artist.class.isAssignableFrom(clazz)) {
                    inst.retransformClasses(clazz);
                }
            }
        }
        catch(UnmodifiableClassException e) {
            //TODO logging
            e.printStackTrace();
        }
    }
}

ArtistTransformer.java

public class ArtistTransformer implements ClassFileTransformer {

    private static final byte[] BYTES_TO_REPLACE = "complement".getBytes();
    private static final byte[] BYTES_TO_INSERT = "compliment".getBytes();

    @Override
    public byte[] transform(ClassLoader loader, String className,
                            Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) throws IllegalClassFormatException {

        if(Artist.class.isAssignableFrom(classBeingRedefined)) {
            //Loop through the classfileBuffer, find sequences of bytes
            //which match BYTES_TO_REPLACE, replace with BYTES_TO_INSERT
        }
        else return classfileBuffer;
    }

当然,这是简化的。 它将在extendsimplements Artist任何类中用“compliment”替换“补充”一词,因此您很可能需要进一步对其进行条件化(例如,如果Artist.class.isAssignableFrom(classBeingRedefined) && Set.class.isAssignableFrom(classBeingRedefined) ,你显然不希望用“compliment”替换“补充”的每个实例,因为Set的“补充”是完全合法的)。

所以,现在我们已经纠正了Artist接口及其实现。 错字消失了,方法有两个不同的名字,所以没有单应性。 这允许我们现在在CommunityTheatre类中有两个不同的实现,每个实现都将正确实现/覆盖ArtistSet的方法。

不幸的是,我们现在已经创建了另一个(可能更大)的问题。 我们刚刚从实现Artist类中删除了所有以前合法的对complement()引用。 要解决这个问题,我们需要创建另一个ClassFileTransformer ,用我们新的方法名替换这些调用。

这有点困难,但并非不可能。 基本上,新的ClassFileTransformer (假设我们称之为OldComplementTransformer )必须执行以下步骤:

  1. 找到与以前相同的字节串(表示旧方法名称的字符串,“补码”);
  2. 获取之前的字节,表示调用方法的对象引用;
  3. 将这些字节转换为Object ;
  4. 检查该Object是否是Artist ; 和,
  5. 如果是,请使用新方法名称替换这些字节。

完成第二个变换器后,可以修改InterfaceFixAgent以适应它。 (我还简化了retransformClasses()调用,因为在上面的例子中,我们在变换器本身内执行所需的检查。)

InterfaceFixAgent.java (已修改

public class InterfaceFixAgent {

    public static void premain(String agentArgs, Instrumentation inst) {

        //Register our transformers
        inst.addTransformer(new ArtistTransformer());
        inst.addTransformer(new OldComplementTransformer());

        //Retransform the classes that have already been loaded
        try {
            inst.retransformClasses(inst.getAllLoadedClasses());
        }
        catch(UnmodifiableClassException e) {
            //TODO logging
            e.printStackTrace();
        }
    }
}

现在......我们的计划很好。 编码肯定不容易,而且QA和测试将是彻头彻尾的地狱。 但它确实很强大,它解决了这个问题。 (从技术上讲,我认为通过删除它可以避免这个问题,但是......我将采取我能得到的东西。)

我们可能解决问题的其他方法:

这两个都允许您直接操作内存中的字节。 当然可以围绕这些解决方案来设计解决方案,但我相信它会更加困难并且更不安全。 所以我选择了上面的路线。

我认为这个解决方案甚至可以更加通用,成为一个非常有用的库,用于集成代码库。 指定在变量,命令行参数或配置文件中需要重构的接口和方法,让她松散。 在运行时协调Java中冲突接口的库。 (当然,我认为如果他们只修复Java 8中的错误,对每个人来说仍然会更好。)

这是我要做的消除歧义的方法:

interface Artist {
    void complement(); // [SIC] from OP, really "compliment"
    int getEgo();
}

interface Set {
    void complement(); // as in Set Theory
    void remove();
    boolean empty(); // [SIC] from OP, I prefer: isEmpty()
}

/**
 * This class is to represent a Set of Artists (as a group) -OR-
 * act like a single Artist (with some aggregate behavior).  I
 * choose to implement NEITHER interface so that a caller is
 * forced to designate, for any given operation, which type's
 * behavior is desired.
 */
class GroupOfArtists { // does NOT implement either

    private final Set setBehavior = new Set() {
        @Override public void remove() { /*...*/ }
        @Override public boolean empty() { return true; /* TODO */ }            
        @Override public void complement() {
            // implement Set-specific behavior
        }
    };

    private final Artist artistBehavior = new Artist() {
        @Override public int getEgo() { return Integer.MAX_VALUE; /* TODO */ }            
        @Override public void complement() {
            // implement Artist-specific behavior
        }
    };

    Set asSet() {
        return setBehavior;
    }

    Artist asArtist() {
        return artistBehavior;
    }
}

如果我将此对象传递给人力资源部门,我实际上会给它从asSet()返回的值来雇用/解雇整个组。

如果我将这个对象传递给剧院进行表演,我实际上会给它从asArtist()返回的值作为天赋。

只要您控制直接与不同组件交谈,这就有效...

但我意识到你的问题是单个第三方供应商创建了一个组件, TheaterManager ,它需要这两个函数的一个对象,它不会知道asSetasArtist方法。 问题不在于创建SetArtist的供应商,而是供应商将它们组合在一起而不是使用访问者模式,或仅指定一个将镜像我上面提到的asSetasArtist方法的接口。 如果你可以说服你的一个供应商“C”来修复这个界面,你的世界将会更加快乐。

祝好运!

狗,我有一种强烈的感觉,你遗漏了一些对解决方案至关重要的细节。 这通常发生在SO上,因为

  • 人们需要遗漏很多细节才能将问题调到合理的范围和范围,
  • 人们不完全理解问题和解决方案(这就是他们寻求帮助的原因)所以他们无法确定哪些细节是重要的,哪些不是,以及
  • 这个人不能自己解决问题的原因是因为他们不了解这个细节的重要性,这就是他们遗漏的原因。

我在另一个回答中说过我会对ArtistSet做些什么。 但是记住上面的内容我会给你一个稍微不同的问题的另一个解决方案。 可以说我有来自坏供应商的代码:

package com.bad;

public interface IAlpha {
    public String getName();
    // Sort Alphabetically by Name
    public int compareTo(IAlpha other);
}

这很糟糕,因为你应该声明一个返回Comparator<IAlpha>的函数来实现排序策略,但无论如何。 现在我从更糟糕的公司获得代码:

package com.worse;
import com.bad.IAlpha;

// an Alpha ordered by name length
public interface ISybil extends IAlpha, Comparable<IAlpha> {}

这更糟糕,因为它完全错误,因为它不相容地覆盖行为。 ISybil按名称长度命令自己,但是IAlpha按字母顺序排序,除了ISybil IAlpha 当他们可以而且应该做的事情时,他们被IAlpha的反模式误导了:

public interface ISybil extends IAlpha {
  public Comparator<IAlpha> getLengthComparator();
}

但是 ,这种情况仍然比ArtistSet好得多,因为这里记录了预期的行为。 关于ISybil.compareTo()应该做什么没有混淆。 所以我会按如下方式创建类。 实现compareTo()为com.worse的Sybil类需要并委托其他所有内容:

package com.hack;

import com.bad.IAlpha;
import com.worse.ISybil;

public class Sybil implements ISybil {

    private final Alpha delegate;

    public Sybil(Alpha delegate) { this.delegate = delegate; }
    public Alpha getAlpha() {   return delegate; }
    public String getName() { return delegate.getName(); }
    public int compareTo(IAlpha other) {
        return delegate.getName().length() - other.getName().length();
    }

}

和一个像com.bad一样工作的Alpha类应该说:

package com.hack;
import com.bad.IAlpha;

public class Alpha implements IAlpha {
    private String name;
    private final Sybil sybil;
    public Alpha(String name) { 
        this.name = name;
        this.sybil = new Sybil(this);
    }

    // Sort Alphabetically
    public int compareTo(IAlpha other) {
        return name.compareTo(other.getName());
    }

    public String getName() { return name; }
    public Sybil getSybil() { return sybil; }
}

请注意,我包含了类型转换方法:Alpha.getSybil()和Sybil.getAlpha()。 这样我就可以围绕任何com.worse供应商的方法创建自己的包装器,这些方法可以使用或返回Sybils,因此我可以避免使用com.worse的破坏来污染我的代码或任何其他供应商的代码。 所以如果com.worse有:

public ISybil breakage(ISybil broken);

我可以写一个函数

public Alpha safeDelegateBreakage(Alpha alpha) {
  return breakage(alpha.getSybil).getAlpha();
}

并且完成它,除了我仍然会大声抱怨com.wad和礼貌地com.bad。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM