在Eclipse RCP中实现反转控制(IoC)

在Eclipse RCP中实现反转控制(IoC)

作者 Riccardo Govoni  2006年6月7日
翻译 土豆爸爸 2006年6月12日
原文 在这里

Eclipse富客户平台(RCP)是一个功能强大的软件平台,它基于插件间的互连与协作,允许开发人员构建通用的应用程序。RCP使开发人员可以集中精力进行应用程序业务代码的开发,而不需要花费时间重新发明轮子编写应用程序管理的逻辑。

反转控制(Inversion of Control, IoC)和依赖注入(Dependency Injection, DI)是两种编程模式,可用于减少程序间的耦合。它们遵循一个简单的原则:你不要创建你的对象;你描述它们应当如何被创建。你不要实例化你的部件所需要对象或直接定位你的部件所需要的服务;相反,你描述哪个部件需要哪些服务,其它人(通常是一个容器)负责将它们连接到一起。这也被认为是好莱坞法则:don't call us--we'll call you。

本文将描述一个简单的方式在Eclipse RCP应用程序中使用依赖注入。为了避免污染Eclipse 平台的基础结构以及透明地在RCP之上添加IoC框架,我们将结合使用运行时字节码操作技术(使用 ObjectWeb ASM库)、Java类加载代理(使用java.lang.instrument包)以及Java annotation。

什么是Eclipse富客户平台?

用一句话来讲,富客户平台是一个类库、软件框架的集合,它是一个用于构建单机和连网应用程序的运行时环境。

尽管Eclipse被认为是构建集成开发环境(IDE)的框架,从3.0开始,Eclipse整个产品进行了重构,分割成各种不同的部件,它些部件可以用于构建任意的应用程序。其中的一个子集构成了富客户平台,它包含以下元素:基本的运行时环境、用户界面组件(SWT和JFace)、插件以及 OSGI层。图1显示了Eclipse平台的主要部件。

Eclipse平台的主要部件
图1. Eclipse平台的主要部件

整个Eclipse平台是基于插件和扩展点。一个插件是一个可以独立开发和发布的最小的功能单元。它通常打包成一个jar文件,通过添加功能(例如,一个编辑器、一个工具栏按钮、或一个编译器)来扩展平台。整个平台是一个相互连接和通信的插件的集合。一个扩展点是一个互相连接的端点,其它插件可以用它提供额外的功能(在Eclipse中称为扩展)。扩展和扩展点定义在XML配置文件中,XML文件与插件捆绑在一起。

插件模式加强了关注分离的概念,插件间的强连接和通讯需要通过配线进行设置它们之间的依赖。典型的例子源自需要定位应用程序所需要的单子服务,例如数据库连接池、日志处理或用户保存的首选项。反转控制和依赖注入是消除这种依赖的可行解决方案。

反转控制和依赖注入

反转控制是一种编程模式,它关注服务(或应用程序部件)是如何定义的以及他们应该如何定位他们依赖的其它服务。通常,通过一个容器或定位框架来获得定义和定位的分离,容器或定位框架负责:

  • 保存可用服务的集合
  • 提供一种方式将各种部件与它们依赖的服务绑定在一起
  • 为应用程序代码提供一种方式来请求已配置的对象(例如,一个所有依赖都满足的对象), 这种方式可以确保该对象需要的所有相关的服务都可用。

现有的框架实际上使用以下三种基本技术的框架执行服务和部件间的绑定:

  • 类型1 (基于接口): 可服务的对象需要实现一个专门的接口,该接口提供了一个对象,可以从用这个对象查找依赖(其它服务)。早期的容器Excalibur使用这种模式。
  • 类型2 (基于setter): 通过JavaBean的属性(setter方法)为可服务对象指定服务。HiveMindSpring采用这种方式。
  • 类型3 (基于构造函数): 通过构造函数的参数为可服务对象指定服务。PicoContainer只使用这种方式。HiveMind和Spring也使用这种方式。

我们将采用第二种方式的一个变种,通过标记方式来提供服务(下面示例程序的源代码可以在资源部分得到)。 声明一个依赖可以表示为:

@Injected public void aServicingMethod(Service s1, AnotherService s2) {  // 将s1和s2保存到类变量,需要时可以使用}

反转控制容器将查找Injected注释,使用请求的参数调用该方法。我们想将IoC引入Eclipse平台,服务和可服务对象将打包放入Eclipse插件中。插件定义一个扩展点 (名称为com.onjava.servicelocator.servicefactory),它可以向程序提供服务工厂。当可服务对象需要配置时,插件向一个工厂请求一个服务实例。ServiceLocator类将完成所有的工作,下面的代码描述该类(我们省略了分析扩展点的部分,因为它比较直观):

   /**     * Injects the requested dependencies into the parameter object. It scans     * the serviceable object looking for methods tagged with the     * {@link Injected} annotation.Parameter types are extracted from the     * matching method. An instance of each type is created from the registered     * factories (see {@link IServiceFactory}). When instances for all the     * parameter types have been created the method is invoked and the next one     * is examined.     *      * @param serviceable     *            the object to be serviced     * @throws ServiceException     */    public static void service(Object serviceable) throws ServiceException {        ServiceLocator sl = getInstance();        if (sl.isAlreadyServiced(serviceable)) {            // prevent multiple initializations due to            // constructor hierarchies            System.out.println("Object " + serviceable                    + " has already been configured ");            return;        }        System.out.println("Configuring " + serviceable);        // Parse the class for the requested services        for (Method m : serviceable.getClass().getMethods()) {            boolean skip = false;            Injected ann = m.getAnnotation(Injected.class);            if (ann != null) {                Object[] services = new Object[m.getParameterTypes().length];                int i = 0;                for (Class<?> class : m.getParameterTypes()) {                    IServiceFactory factory = sl.getFactory(class, ann                            .optional());                    if (factory == null) {                        skip = true;                        break;                    }                    Object service = factory.getServiceInstance();                    // sanity check: verify that the returned                    // service's class is the expected one                    // from the method                    assert (service.getClass().equals(class) || class                            .isAssignableFrom(service.getClass()));                    services[i++] = service;                }                try {                    if (!skip)                        m.invoke(serviceable, services);                } catch (IllegalAccessException iae) {                    if (!ann.optional())                        throw new ServiceException(                                "Unable to initialize services on "                                        + serviceable + ": " + iae.getMessage(), iae);                } catch (InvocationTargetException ite) {                    if (!ann.optional())                        throw new ServiceException(                                "Unable to initialize services on "                                        + serviceable + ": " + ite.getMessage(), ite);                }            }        }        sl.setAsServiced(serviceable);    }

由于服务工厂返回的服务可能也是可服务对象,这种策略允许定义服务的层次结构(然而目前不支持循环依赖)。

ASM和java.lang.instrument代理

前节所述的各种注入策略通常依靠容器提供一个入口点,应用程序使用入口点请求已正确配置的对象。然而,我们希望当开发IoC插件时采用一种透明的方式,原因有二:

  • RCP采用了复杂的类加载器和实例化策略(想一下createExecutableExtension()) 来维护插件的隔离和强制可见性限制。我们不希望修改或替换这些策略而引入我们的基于容器的实例化规则。
  • 显式地引用这样一个入口点(Service Locator插件中定义的service()方法) 将强迫应用程序采用一种显式地模式和逻辑来获取已初始化的部件。这表示应用程序代码出现了library lock-in。我们希望定义可以协作的插件,但不需要显示地引用它的基代码。

出于这些原因,我将引入java转换代理,它定义在 java.lang.instrument 包中,J2SE 5.0及更高版本支持。一个转换代理是一个实现了 java.lang.instrument.ClassFileTransformer接口的对象,该接口只定义了一个 transform()方法。当一个转换实例注册到JVM时,每当JVM创建一个类的对象时都会调用它。这个转换器可以访问类的字节码,在它被JVM加载之前可以修改类的表示形式。

可以使用JVM命令行参数注册转换代理,形式为-javaagent:jarpath[=options],其中jarpath是包含代码类的JAR文件的路径, options是代理的参数字符串。代理JAR文件使用一个特殊的manifest属性指定实际的代理类,该类必须定义一个 public static void premain(String options, Instrumentation inst)方法。代理的premain()方法将在应用程序的main()执行之前被调用,并且可以通过传入的java.lang.instrument.Instrumentation对象实例注册一个转换器。

在我们的例子中,我们定义一个代理执行字节码操作,透明地添加对Ioc容器(Service Locator 插件)的调用。代理根据是否出现Serviceable注释来标识可服务的对象。接着它将修改所有的构造函数,添加对IoC容器的回调,这样就可以在实例化时配置和初始化对象。

假设我们有一个对象依赖于外部服务(Injected注释):

@Serviceablepublic class ServiceableObject {  public ServiceableObject() {    System.out.println("Initializing...");  }  @Injected public void aServicingMethod(Service s1, AnotherService s2) {    // ... omissis ...  }}

当代理修改之后,它的字节码与下面的类正常编译的结果一样:

@Serviceablepublic class ServiceableObject {  public ServiceableObject() {    ServiceLocator.service(this);    System.out.println("Initializing...");  }  @Injected public void aServicingMethod(Service s1, AnotherService s2) {    // ... omissis ...  }}

采用这种方式,我们就能够正确地配置可服务对象,并且不需要开发人员对依赖的容器进行硬编码。开发人员只需要用Serviceable注释标记可服务对象。代理的代码如下:

public class IOCTransformer implements ClassFileTransformer {    public byte[] transform(ClassLoader loader, String className,            Class<?> classBeingRedefined, ProtectionDomain protectionDomain,            byte[] classfileBuffer) throws IllegalClassFormatException {        System.out.println("Loading " + className);        ClassReader creader = new ClassReader(classfileBuffer);        // Parse the class file        ConstructorVisitor cv = new ConstructorVisitor();        ClassAnnotationVisitor cav = new ClassAnnotationVisitor(cv);        creader.accept(cav, true);        if (cv.getConstructors().size() > 0) {            System.out.println("Enhancing " + className);            // Generate the enhanced-constructor class            ClassWriter cw = new ClassWriter(false);            ClassConstructorWriter writer = new ClassConstructorWriter(cv                    .getConstructors(), cw);            creader.accept(writer, false);            return cw.toByteArray();        } else            return null;    }    public static void premain(String agentArgs, Instrumentation inst) {        inst.addTransformer(new IOCTransformer());    }}

ConstructorVisitorClassAnnotationVisitor ClassWriter以及ClassConstructorWriter使用ObjectWeb ASM库执行字节码操作。

ASM使用visitor模式以事件流的方式处理类数据(包括指令序列)。当解码一个已有的类时, ASM为我们生成一个事件流,调用我们的方法来处理这些事件。当生成一个新类时,过程相反:我们生成一个事件流,ASM库将其转换成一个类。注意,这里描述的方法不依赖于特定的字节码库(这里我们使用的是ASM);其它的解决方法,例如BCEL或Javassist也是这样工作的。

我们不再深入研究ASM的内部结构。知道ConstructorVisitorClassAnnotationVisitor对象用于查找标记为Serviceable类,并收集它们的构造函数已经足够了。他们的源代码如下:

        public class ClassAnnotationVisitor extends ClassAdapter {    private boolean matches = false;    public ClassAnnotationVisitor(ClassVisitor cv) {        super(cv);    }    @Override    public AnnotationVisitor visitAnnotation(String desc, boolean visible) {        if (visible && desc.equals("Lcom/onjava/servicelocator/annot/Serviceable;")) {            matches = true;        }        return super.visitAnnotation(desc, visible);    }    @Override    public MethodVisitor visitMethod(int access, String name, String desc,            String signature, String[] exceptions) {        if (matches)            return super.visitMethod(access, name, desc, signature, exceptions);        else {            return null;        }    }}public class ConstructorVisitor extends EmptyVisitor {    private Set<Method> constructors;    public ConstructorVisitor() {        constructors = new HashSet<Method>();    }    public Set<Method> getConstructors() {        return constructors;    }    @Override    public MethodVisitor visitMethod(int access, String name, String desc,            String signature, String[] exceptions) {        Type t = Type.getReturnType(desc);        if (name.indexOf("<init>") != -1 && t.equals(Type.VOID_TYPE)) {            constructors.add(new Method(name, desc));        }        return super.visitMethod(access, name, desc, signature, exceptions);    }}

一个ClassConstructorWriter的实例将修改收集的每个构造函数,注入对Service Locator插件的调用:

com.onjava.servicelocator.ServiceLocator.service(this);

ASM需要下面的指令以完成工作:

// mv is an ASM method visitor,// a class which allows method manipulationmv.visitVarInsn(ALOAD, 0);mv.visitMethodInsn(    INVOKESTATIC,     "com/onjava/servicelocator/ServiceLocator",     "service",     "(Ljava/lang/Object;)V");

第一个指令将this对象引用加载到栈,第二指令将使用它。它二个指令调用ServiceLocator的静态方法。

Eclipse RCP应用程序示例

现在我们具有了构建应用程序的所有元素。我们的例子可用于显示用户感兴趣的名言警句。它由四个插件组成:

  • Service Locator插件,提供IoC框架
  • FortuneService插件,提供服务管理fortune cookie
  • FortuneInterface插件,发布访问服务所需的公共接口
  • FortuneClient插件,提供Eclipse应用程序,以Eclipse视图中显示名言警句。

采用IoC设计,使服务的实现与客户分离;服务实例可以修改,对客户没有影响。图2显示了插件间的依赖关系。

插件间的依赖关系
图2. 插件间的依赖关系: ServiceLocator和接口定义使服务和客户分离。

如前面所述,Service Locator将客户和服务绑定到一起。FortuneInterface只定义了公共接口 IFortuneCookie,客户可以用它访问cookie消息:

public interface IFortuneCookie {        public String getMessage();}

FortuneService提供了一个简单的服务工厂,用于创建IFortuneCookie的实现:

public class FortuneServiceFactory implements IServiceFactory {    public Object getServiceInstance() throws ServiceException {        return new FortuneCookieImpl();    }    // ... omissis ...}

工厂注册到service locator插件的扩展点,在plugin.xml文件:

<?xml version="1.0" encoding="UTF-8"?><?eclipse version="3.0"?><plugin><extension  point="com.onjava.servicelocator.servicefactory">  <serviceFactory    class="com.onjava.fortuneservice.FortuneServiceFactory"    id="com.onjava.fortuneservice.FortuneServiceFactory"    name="Fortune Service Factory"    resourceClass="com.onjava.fortuneservice.IFortuneCookie"/></extension></plugin>

resourceClass属性定义了该工厂所提供的服务的类。在FortuneClient插件中, Eclipse视图使用该服务:

@Serviceablepublic class View extends ViewPart {    public static final String ID = "FortuneClient.view";    private IFortuneCookie cookie;    @Injected(optional = false)    public void setDate(IFortuneCookie cookie) {        this.cookie = cookie;    }    public void createPartControl(Composite parent) {        Label l = new Label(parent, SWT.WRAP);        l.setText("Your fortune cookie is:\n" + cookie.getMessage());    }    public void setFocus() {    }}

注意这里出现了ServiceableInjected注释,用于定义依赖的外部服务,并且没有引用任何服务代码。最终结果是,createPartControl() 可以自由地使用cookie对象,可以确保它被正确地初始化。示例程序如图3所示

示例程序
图3. 示例程序

结论

本文我讨论了如何结合使用一个强大的编程模式--它简化了代码依赖的处理(反转控制),与Java客户端程序(Eclipse RCP)。即使我没有处理影响这个问题的更多细节,我已经演示了一个简单的应用程序的服务和客户是如何解耦的。我还描述了当开发客户和服务时, Eclipse插件技术是如何实现关注分离的。然而,还有许多有趣的因素仍然需要去探究,例如,当服务不再需要时的清理策略,或使用mock-up服务对客户端插件进行单元测试,这些问题我将留给读者去思考。

参考

Riccardo Govoni has been working since 2003 as a J2EE developer for a financial services company in the northern part of Italy.


 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Go语言(也称为Golang)是由Google开发的一种静态强类型、编译型的编程语言。它旨在成为一门简单、高效、安全和并发的编程语言,特别适用于构建高性能的服务器和分布式系统。以下是Go语言的一些主要特点和优势: 简洁性:Go语言的语法简单直观,易于学习和使用。它避免了复杂的语法特性,如继承、重载等,转而采用组合和接口来实现代码的复用和扩展。 高性能:Go语言具有出色的性能,可以媲美C和C++。它使用静态类型系统和编译型语言的优势,能够生成高效的机器码。 并发性:Go语言内置了对并发的支持,通过轻量级的goroutine和channel机制,可以轻松实现并发编程。这使得Go语言在构建高性能的服务器和分布式系统时具有天然的优势。 安全性:Go语言具有强大的类型系统和内存管理机制,能够减少运行时错误和内存泄漏等问题。它还支持编译时检查,可以在编译阶段就发现潜在的问题。 标准库:Go语言的标准库非常丰富,包含了大量的实用功能和工具,如网络编程、文件操作、加密解密等。这使得开发者可以更加专注于业务逻辑的实现,而无需花费太多时间在底层功能的实现上。 跨平台:Go语言支持多种操作系统和平台,包括Windows、Linux、macOS等。它使用统一的构建系统(如Go Modules),可以轻松地跨平台编译和运行代码。 开源和社区支持:Go语言是开源的,具有庞大的社区支持和丰富的资源。开发者可以通过社区获取帮助、分享经验和学习资料。 总之,Go语言是一种简单、高效、安全、并发的编程语言,特别适用于构建高性能的服务器和分布式系统。如果你正在寻找一种易于学习和使用的编程语言,并且需要处理大量的并发请求和数据,那么Go语言可能是一个不错的选择。
Go语言(也称为Golang)是由Google开发的一种静态强类型、编译型的编程语言。它旨在成为一门简单、高效、安全和并发的编程语言,特别适用于构建高性能的服务器和分布式系统。以下是Go语言的一些主要特点和优势: 简洁性:Go语言的语法简单直观,易于学习和使用。它避免了复杂的语法特性,如继承、重载等,转而采用组合和接口来实现代码的复用和扩展。 高性能:Go语言具有出色的性能,可以媲美C和C++。它使用静态类型系统和编译型语言的优势,能够生成高效的机器码。 并发性:Go语言内置了对并发的支持,通过轻量级的goroutine和channel机制,可以轻松实现并发编程。这使得Go语言在构建高性能的服务器和分布式系统时具有天然的优势。 安全性:Go语言具有强大的类型系统和内存管理机制,能够减少运行时错误和内存泄漏等问题。它还支持编译时检查,可以在编译阶段就发现潜在的问题。 标准库:Go语言的标准库非常丰富,包含了大量的实用功能和工具,如网络编程、文件操作、加密解密等。这使得开发者可以更加专注于业务逻辑的实现,而无需花费太多时间在底层功能的实现上。 跨平台:Go语言支持多种操作系统和平台,包括Windows、Linux、macOS等。它使用统一的构建系统(如Go Modules),可以轻松地跨平台编译和运行代码。 开源和社区支持:Go语言是开源的,具有庞大的社区支持和丰富的资源。开发者可以通过社区获取帮助、分享经验和学习资料。 总之,Go语言是一种简单、高效、安全、并发的编程语言,特别适用于构建高性能的服务器和分布式系统。如果你正在寻找一种易于学习和使用的编程语言,并且需要处理大量的并发请求和数据,那么Go语言可能是一个不错的选择。
Go语言(也称为Golang)是由Google开发的一种静态强类型、编译型的编程语言。它旨在成为一门简单、高效、安全和并发的编程语言,特别适用于构建高性能的服务器和分布式系统。以下是Go语言的一些主要特点和优势: 简洁性:Go语言的语法简单直观,易于学习和使用。它避免了复杂的语法特性,如继承、重载等,转而采用组合和接口来实现代码的复用和扩展。 高性能:Go语言具有出色的性能,可以媲美C和C++。它使用静态类型系统和编译型语言的优势,能够生成高效的机器码。 并发性:Go语言内置了对并发的支持,通过轻量级的goroutine和channel机制,可以轻松实现并发编程。这使得Go语言在构建高性能的服务器和分布式系统时具有天然的优势。 安全性:Go语言具有强大的类型系统和内存管理机制,能够减少运行时错误和内存泄漏等问题。它还支持编译时检查,可以在编译阶段就发现潜在的问题。 标准库:Go语言的标准库非常丰富,包含了大量的实用功能和工具,如网络编程、文件操作、加密解密等。这使得开发者可以更加专注于业务逻辑的实现,而无需花费太多时间在底层功能的实现上。 跨平台:Go语言支持多种操作系统和平台,包括Windows、Linux、macOS等。它使用统一的构建系统(如Go Modules),可以轻松地跨平台编译和运行代码。 开源和社区支持:Go语言是开源的,具有庞大的社区支持和丰富的资源。开发者可以通过社区获取帮助、分享经验和学习资料。 总之,Go语言是一种简单、高效、安全、并发的编程语言,特别适用于构建高性能的服务器和分布式系统。如果你正在寻找一种易于学习和使用的编程语言,并且需要处理大量的并发请求和数据,那么Go语言可能是一个不错的选择。
Go语言(也称为Golang)是由Google开发的一种静态强类型、编译型的编程语言。它旨在成为一门简单、高效、安全和并发的编程语言,特别适用于构建高性能的服务器和分布式系统。以下是Go语言的一些主要特点和优势: 简洁性:Go语言的语法简单直观,易于学习和使用。它避免了复杂的语法特性,如继承、重载等,转而采用组合和接口来实现代码的复用和扩展。 高性能:Go语言具有出色的性能,可以媲美C和C++。它使用静态类型系统和编译型语言的优势,能够生成高效的机器码。 并发性:Go语言内置了对并发的支持,通过轻量级的goroutine和channel机制,可以轻松实现并发编程。这使得Go语言在构建高性能的服务器和分布式系统时具有天然的优势。 安全性:Go语言具有强大的类型系统和内存管理机制,能够减少运行时错误和内存泄漏等问题。它还支持编译时检查,可以在编译阶段就发现潜在的问题。 标准库:Go语言的标准库非常丰富,包含了大量的实用功能和工具,如网络编程、文件操作、加密解密等。这使得开发者可以更加专注于业务逻辑的实现,而无需花费太多时间在底层功能的实现上。 跨平台:Go语言支持多种操作系统和平台,包括Windows、Linux、macOS等。它使用统一的构建系统(如Go Modules),可以轻松地跨平台编译和运行代码。 开源和社区支持:Go语言是开源的,具有庞大的社区支持和丰富的资源。开发者可以通过社区获取帮助、分享经验和学习资料。 总之,Go语言是一种简单、高效、安全、并发的编程语言,特别适用于构建高性能的服务器和分布式系统。如果你正在寻找一种易于学习和使用的编程语言,并且需要处理大量的并发请求和数据,那么Go语言可能是一个不错的选择。
Go语言(也称为Golang)是由Google开发的一种静态强类型、编译型的编程语言。它旨在成为一门简单、高效、安全和并发的编程语言,特别适用于构建高性能的服务器和分布式系统。以下是Go语言的一些主要特点和优势: 简洁性:Go语言的语法简单直观,易于学习和使用。它避免了复杂的语法特性,如继承、重载等,转而采用组合和接口来实现代码的复用和扩展。 高性能:Go语言具有出色的性能,可以媲美C和C++。它使用静态类型系统和编译型语言的优势,能够生成高效的机器码。 并发性:Go语言内置了对并发的支持,通过轻量级的goroutine和channel机制,可以轻松实现并发编程。这使得Go语言在构建高性能的服务器和分布式系统时具有天然的优势。 安全性:Go语言具有强大的类型系统和内存管理机制,能够减少运行时错误和内存泄漏等问题。它还支持编译时检查,可以在编译阶段就发现潜在的问题。 标准库:Go语言的标准库非常丰富,包含了大量的实用功能和工具,如网络编程、文件操作、加密解密等。这使得开发者可以更加专注于业务逻辑的实现,而无需花费太多时间在底层功能的实现上。 跨平台:Go语言支持多种操作系统和平台,包括Windows、Linux、macOS等。它使用统一的构建系统(如Go Modules),可以轻松地跨平台编译和运行代码。 开源和社区支持:Go语言是开源的,具有庞大的社区支持和丰富的资源。开发者可以通过社区获取帮助、分享经验和学习资料。 总之,Go语言是一种简单、高效、安全、并发的编程语言,特别适用于构建高性能的服务器和分布式系统。如果你正在寻找一种易于学习和使用的编程语言,并且需要处理大量的并发请求和数据,那么Go语言可能是一个不错的选择。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值