Mockito简单使用及原理分析

1 使用场景

在项目开发中,经常要依赖外部资源来进行,如数据库查询,请求第三方接口等等,如果数据库或者第三方接口挂了,就能等他们恢复才能进行单元测试;又如果想验证某些特定的场景,就需要通过造数据来达到某些特定的条件来,才能测试到特定的场景。

举个例子:当前有个拉取第三方商铺(如抖店、淘宝)的订单到本地订单服务入库的功能,现在想验证第三方商铺某个商品特定地址的订单能否正常入库,这时候就需要在第三方商铺下单相应的商品并填写相应的地址,然后才能跑单元测试验证。

这时候,我们是可以通过mock的方式,让查询外部资源的方法不用真正请求外部资源也能返回我们想要的结果。而mockito就是一个提供了这样的功能的框架。

2 mockito简单使用

mockito提供了很多功能,可以浏览官网进行学习,这里就从简单的hello world学起

Mockito官网

平时我们一般使用springboot框架,而spring-boot-starter-test模块就是集成了mockito。

注:本文使用的springboot版本是2.1.6.RELEASE,其集成mockito的版本是2.23.4

 <dependency>
   <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <!-- <scope>test</scope> -->
 </dependency>

下面是代码例子

public class MockTest {

    public static void main(String... args) throws IOException {
        MockTest origin = new MockTest();
        System.out.println(origin.hello());
        System.out.println("------ 分割线 -------");
        MockTest mock = Mockito.mock(MockTest.class);
        when(mock.hello()).thenReturn(1, 2);
        System.out.println(mock.hello());
        System.out.println(mock.hello());
        System.out.println(mock.hello());
    }

    public int hello(){
        System.out.println("hello world");
        return 0;
    }
}

最终输出:

hello world
0
------ 分割线 -------
1
2
2

  • 可以看到分割线前面是通过new的方式创建了对象,然后调用hello()方法输出了"hello world"并且返回了0;
  • 接着分割线后面是通过mockito来mock了一个对象,并且通过when方法指定hello()方法依次返回"1,2",然后调用了3次hello()方法,依次返回了"1,2,2",可以看到并没有打印"hello world"字符串了

3 mockito原理

通过上面的例子,可以了解到mockito的一些功能,那它到底是怎样实现的呢,现在还是以上面的例子通过debug来一探它的源码。(注:本文mockito版本是2.23.4)

3.1 mock()方法原理

以Mockito.mock()方法为入口

   public static <T> T mock(Class<T> classToMock) {
        return mock(classToMock, withSettings());
    }
    
   public static <T> T mock(Class<T> classToMock, MockSettings mockSettings) {
        return MOCKITO_CORE.mock(classToMock, mockSettings);
    }

可以看到实际是通过MOCKITO_CORE.mock()来生成mock对象,MOCKITO_CORE是MockitoCore对象,继续进入其mock()方法查看

public <T> T mock(Class<T> typeToMock, MockSettings settings) {
    if (!MockSettingsImpl.class.isInstance(settings)) {
        throw new IllegalArgumentException("Unexpected implementation of '" + settings.getClass().getCanonicalName() + "'\n" + "At the moment, you cannot provide your own implementations of that class.");
    }
    MockSettingsImpl impl = MockSettingsImpl.class.cast(settings);
    MockCreationSettings<T> creationSettings = impl.build(typeToMock);
    T mock = createMock(creationSettings);
    mockingProgress().mockingStarted(mock, creationSettings);
    return mock;
}

可以看到先是构建好配置类对象MockCreationSetting,里面存放了一些实例化mock对象时需要用到的信息,然后调用createMock()方法生成mock对象


//类似spi机制,mockito会到resource文件夹下的mockito-extensions文件夹
//去寻找是否有指定的MockMaker,没有的话,默认就是ByteBuddyMockMaker
private static final MockMaker mockMaker = Plugins.getMockMaker();
    
public static <T> T createMock(MockCreationSettings<T> settings) {
    //MockHandler是负责mock对象在方法执行时,进行相应的拦截处理
    MockHandler mockHandler =  createMockHandler(settings);
    //默认是通过ByteBuddyMockMaker来创建mock对象
    T mock = mockMaker.createMock(settings, mockHandler);
    //这里判断如果是spy模式,且原对象实例不为空,就把原对象实例的成员变量值复制到mock对象
    Object spiedInstance = settings.getSpiedInstance();
    if (spiedInstance != null) {
        new LenientCopyTool().copyToMock(spiedInstance, mock);
    }
    return mock;
}

上面可以看到,mock对象是通过MockMaker来创建了,由于默认是使用ByteBuddyMockMaker这个实现类,所以接下来看这个类的createMock()做了什么


    private ClassCreatingMockMaker defaultByteBuddyMockMaker = new SubclassByteBuddyMockMaker();

    @Override
    public <T> T createMock(MockCreationSettings<T> settings, MockHandler handler) {
        return defaultByteBuddyMockMaker.createMock(settings, handler);
    }

可以看到,实际是调用SubclassByteBuddyMockMaker的createMock()来创创建mock对象,继续跟踪

public <T> T createMock(MockCreationSettings<T> settings, MockHandler handler) {
   //创建mock对象的class对象,默认使用byteBuddy来生成
   Class<? extends T> mockedProxyType = createMockType(settings);
   
   //找到一个实例初始化器,如果对象构造方法是无参的就是用objenesis库,如果是有参的就是用ConstructorInstantiator(通过筛选出确定的构造方法然后反射实例化)
    Instantiator instantiator = Plugins.getInstantiatorProvider().getInstantiator(settings);
    T mockInstance = null;
    try {
        //创建mock对象的class对象
        mockInstance = instantiator.newInstance(mockedProxyType);
        //因为mock对象会实现MockAccess接口,可以强转
        MockAccess mockAccess = (MockAccess) mockInstance;
        //设置了拦截器,其实mock对象每个方法都是经过这个拦截器(aop的味道)
        mockAccess.setMockitoInterceptor(new MockMethodInterceptor(handler, settings));

        return ensureMockIsAssignableToMockedType(settings, mockInstance);
    } catch (ClassCastException cce) {
        ...省略
    } catch (org.mockito.creation.instance.InstantiationException e) {
        throw new MockitoException("Unable to create mock instance of type '" + mockedProxyType.getSuperclass().getSimpleName() + "'", e);
    }
}

看到这里基本知道了mockito创建mock对象的原理,就是在运行时,通过动态代理的形式,创建一个代理对象实例并返回。它是使用byteBuddy库生成子类的方式来实现的。

bytebuddy官网

3.2 when().thenReturn()原理

上面已经知道mock对象是通过byteBuddy库在运行时生成的class对象实例化的,那有没有办法看到mock对象的class源码呢?

arthas文档

这时候阿里的arthas就派上用场了,首先要保证程序保持运行(如加行代码:System.in.read();),然后启动arthas,选择对应的java程序,因为mock对象的class名称是带随机数的,需要先找出来是什么名称,可以使用sc命令搜索一下mock对象的class名称,最后在使用jad命令进行反编译即可;

[arthas@1656]$ sc com.test.example.* -E
com.test.example.MockTest
com.test.example.MockTest$MockitoMock$1912680993
com.test.example.MockTest$MockitoMock$1912680993$auxiliary$g1Dqkwtz
com.test.example.MockTest$MockitoMock$1912680993$auxiliary$lRJxdBSA
com.test.example.MockTest$MockitoMock$1912680993$auxiliary$v9vJMsdZ
Affect(row-cnt:5) cost in 20 ms.
[arthas@1656]$ jad com.test.example.MockTest$MockitoMock$1912680993

可以看到多出了4个跟mock对象有关的4个class,其中MockTest$MockitoMock$1912680993就是mock对象的class

public class MockTest$MockitoMock$1912680993 extends MockTest implements MockAccess {
...省略
public int hello() {
	return (Integer)MockMethodInterceptor.DispatcherDefaultingToRealMethod.interceptSuperCallable(this, this.mockitoInterceptor, cachedValue$sDcnG1W8$4t661m1, new Object[0], new MockTest$MockitoMock$1912680993$auxiliary$v9vJMsdZ(this));
}
static {
        cachedValue$sDcnG1W8$4t661m1 = MockTest.class.getMethod("hello", new Class[0]);
    }

    final /* synthetic */ int hello$accessor$sDcnG1W8() {
        return super.hello();
    }
    ...省略
}

可以看到mock对象的方法,其实调用MockMethodInterceptor.DispatcherDefaultingToRealMethod.interceptSuperCallable()方法


public static class DispatcherDefaultingToRealMethod {

        @SuppressWarnings("unused")
        @RuntimeType
        @BindingPriority(BindingPriority.DEFAULT * 2)
        public static Object interceptSuperCallable(@This Object mock,
                                                    @FieldValue("mockitoInterceptor") MockMethodInterceptor interceptor,
                                                    @Origin Method invokedMethod,
                                                    @AllArguments Object[] arguments,
                                                    @SuperCall(serializableProxy = true) Callable<?> superCall) throws Throwable {
            if (interceptor == null) {
                return superCall.call();
            }
            return interceptor.doIntercept(
                    mock,
                    invokedMethod,
                    arguments,
                    new RealMethod.FromCallable(superCall)
            );
        }
}

该方法中interceptor,其实是前文出现过的MockMethodInterceptor

mockAccess.setMockitoInterceptor(new MockMethodInterceptor(handler, settings));

Object doIntercept(Object mock,
                       Method invokedMethod,
                       Object[] arguments,
                       RealMethod realMethod) throws Throwable {
        return doIntercept(
                mock,
                invokedMethod,
                arguments,
            realMethod,
                new LocationImpl()
        );
    }

    Object doIntercept(Object mock,
                       Method invokedMethod,
                       Object[] arguments,
                       RealMethod realMethod,
                       Location location) throws Throwable {
        return handler.handle(createInvocation(mock, invokedMethod, arguments, realMethod, mockCreationSettings, location));
    }

可以看到,最终是调用了handler的handle方法返回最终结果,handler再创建时用了装饰者模式,真正干活的是MockHandlerImpl


public class MockHandlerImpl<T> implements MockHandler<T> {
    ...省略
	public Object handle(Invocation invocation) throws Throwable {
	        ...省略
	        InvocationMatcher invocationMatcher = matchersBinder.bindMatchers(
	                mockingProgress().getArgumentMatcherStorage(),
	                invocation
	        );
	
	       ...省略
	
	        // prepare invocation for stubbing
	        invocationContainer.setInvocationForPotentialStubbing(invocationMatcher);
	        OngoingStubbingImpl<T> ongoingStubbing = new OngoingStubbingImpl<T>(invocationContainer);
	        //保存存根
	        mockingProgress().reportOngoingStubbing(ongoingStubbing);
	
	        // look for existing answer for this invocation
	        StubbedInvocationMatcher stubbing = invocationContainer.findAnswerFor(invocation);
	        ...
	        if (stubbing != null) {
	            stubbing.captureArgumentsFrom(invocation);
	        //如果用了when方法,就可能匹配上对应的answer,从而返回我们想要的结果
				return stubbing.answer(invocation);
	        } else {
	        //使用默认的answer返回结果
	            Object ret = mockSettings.getDefaultAnswer().answer(invocation);
	            ...省略
	            //返回结果
	            return ret;
	        }
	    }
}

可以看到,主要是在invocationContainer里面找出当前调用方法匹配到的answer实例(存根),如果匹配不到就用默认的answer实例,然后调用answer实例的answer方法获得返回值。
通过一步一步debug可以知道,when().thenReturn()就是往invocationContainer里面添加了一个StubbedInvocationMatcher,所以在MockHandlerImpl的handle方法里会被匹配上,返回我们指定的值。

StubbedInvocationMatcher

4 sprinboot结合junit使用mockito进单元测试

4.1 单元测试例子

先简单写个bean

@Component
public class TestBean {
    public int sayHello(){
        System.out.println("hello world");
        return 0;
    }
}

再简单写个单元测试

@SpringBootTest
@RunWith(SpringRunner.class)
public class MockTest {
    @MockBean
    TestBean testBean;

    @Test
    public void test() {
        when(testBean.sayHello()).thenReturn(1);
        System.out.println(testBean.sayHello());
    }
}

其中@MockBean注解是springboot提供的,加上这个注解,spring容器中该对象就是一个mock对象了,所以注入的对象也是一个mock对象。

4.2 @MockBean注解的原理

那@MockBean是如何把spring容器的对象,修改成mock对象的呢?
这时候可以在idea点击进入@MockBean的源码,然后再查看@MockBean在哪些类中被使用了,这时候会找到一个MockitoPostProcessor 类

public class MockitoPostProcessor extends InstantiationAwareBeanPostProcessorAdapter
		implements BeanClassLoaderAware, BeanFactoryAware, BeanFactoryPostProcessor, Ordered {
...省略

    @Override
	public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
		Assert.isInstanceOf(BeanDefinitionRegistry.class, beanFactory,
				"@MockBean can only be used on bean factories that " + "implement BeanDefinitionRegistry");
		postProcessBeanFactory(beanFactory, (BeanDefinitionRegistry) beanFactory);
	}

	private void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory, BeanDefinitionRegistry registry) {
		beanFactory.registerSingleton(MockitoBeans.class.getName(), this.mockitoBeans);
		DefinitionsParser parser = new DefinitionsParser(this.definitions);
		for (Class<?> configurationClass : getConfigurationClasses(beanFactory)) {
			parser.parse(configurationClass);
		}
		//拿出所有需要替换成mock对象的类
		Set<Definition> definitions = parser.getDefinitions();
		for (Definition definition : definitions) {
			Field field = parser.getField(definition);
			//开始修改成mock对象,并注册到bean工厂
			register(beanFactory, registry, definition, field);
		}
	}
	
	private void register(ConfigurableListableBeanFactory beanFactory, BeanDefinitionRegistry registry,
			Definition definition, Field field) {
		if (definition instanceof MockDefinition) {
			//是@MockBean注解就会进到这里
			registerMock(beanFactory, registry, (MockDefinition) definition, field);
		}
		else if (definition instanceof SpyDefinition) {
			//是@SpyBean注解就会进到这里
			registerSpy(beanFactory, registry, (SpyDefinition) definition, field);
		}
	}

	private void registerMock(ConfigurableListableBeanFactory beanFactory, BeanDefinitionRegistry registry,
			MockDefinition definition, Field field) {
			//创建bean工厂接受的RootBeanDefinition 
		RootBeanDefinition beanDefinition = createBeanDefinition(definition);
		String beanName = getBeanName(beanFactory, registry, definition, beanDefinition);
		String transformedBeanName = BeanFactoryUtils.transformedBeanName(beanName);
		if (registry.containsBeanDefinition(transformedBeanName)) {
		//拿到原先的BeanDefinition
			BeanDefinition existing = registry.getBeanDefinition(transformedBeanName);
			copyBeanDefinitionDetails(existing, beanDefinition);
			//把它从bean工厂中删除
			registry.removeBeanDefinition(transformedBeanName);
		}
		//替换成mock对象的BeanDefinition
		registry.registerBeanDefinition(transformedBeanName, beanDefinition);
		//创建mock对象
		Object mock = definition.createMock(beanName + " bean");
		//注册mock对象实例到bean工厂,这样bean工厂在获取的时候就不用再走bean的创建流程
		beanFactory.registerSingleton(transformedBeanName, mock);
		this.mockitoBeans.add(mock);
		this.beanNameRegistry.put(definition, beanName);
		if (field != null) {
			this.fieldRegistry.put(field, beanName);
		}
	}
}

可以看到它实现了BeanFactoryPostProcessor,所以在bean实例化之前可以对bean工厂进行加工(spring容器启动流程可参考AbstractApplicationContext类的refresh方法),从而就有机会修改bean工厂里面的bean了。

从上面的代码也可以看到,它的做法就是把bean工厂里旧的对象的BeanDefinition删掉,然后替换成mock对象的BeanDefinition,接着调用definition.createMock(beanName + " bean")来实例化一个mock对象,再注册到bean工厂里,这样bean工厂在获取的时候就不用再走bean的创建流程,所以我们拿到的就是一个mock对象。

接下来看下definition.createMock()方法是如何创建一个mock对象实例的

public <T> T createMock(String name) {
		MockSettings settings = MockReset.withSettings(getReset());
		if (StringUtils.hasLength(name)) {
			settings.name(name);
		}
		if (!this.extraInterfaces.isEmpty()) {
			settings.extraInterfaces(ClassUtils.toClassArray(this.extraInterfaces));
		}
		settings.defaultAnswer(this.answer);
		if (this.serializable) {
			settings.serializable();
		}
		return (T) Mockito.mock(this.typeToMock.resolve(), settings);
	}

可以看到最终是调用Mockito.mock()方法生成的一个实例,它的源码在文章前面已分析过了。

4.3 MockitoPostProcessor是如何注册到spring容器的

到这里,笔者还有个疑问,BeanFactoryPostProcessor是需要注册到spring容器才能被触发的,然而MockitoPostProcessor类上并没有类似@Component等注解,那spring是如何扫描到它,并注册到容器的呢?

这时候,还是可以通过idea点击MockitoPostProcessor类,查看其被引用的地方,这时可以找到MockitoContextCustomizer类

class MockitoContextCustomizer implements ContextCustomizer {

	private final Set<Definition> definitions;

	MockitoContextCustomizer(Set<? extends Definition> definitions) {
		this.definitions = new LinkedHashSet<>(definitions);
	}

	@Override
	public void customizeContext(ConfigurableApplicationContext context,
			MergedContextConfiguration mergedContextConfiguration) {
		if (context instanceof BeanDefinitionRegistry) {
		//把MockitoPostProcessor的BeanDefinition注册到bean工厂
			MockitoPostProcessor.register((BeanDefinitionRegistry) context, this.definitions);
		}
	}
	...省略
}

可以看到时在MockitoContextCustomizer类的customizeContext方法,MockitoPostProcessor的BeanDefinition注册到bean工厂。而再跟踪customizeContext方法发现是在ContextCustomizerAdapter类调用的。

private static class ContextCustomizerAdapter
			implements ApplicationContextInitializer<ConfigurableApplicationContext> {

		private final ContextCustomizer contextCustomizer;
		private final MergedContextConfiguration config;

		ContextCustomizerAdapter(ContextCustomizer contextCustomizer, MergedContextConfiguration config) {
			this.contextCustomizer = contextCustomizer;
			this.config = config;
		}

		@Override
		public void initialize(ConfigurableApplicationContext applicationContext) {
			this.contextCustomizer.customizeContext(applicationContext, this.config);
		}
	}

可以看到它实现了ApplicationContextInitializer接口,实现这个接口,就可以在springboot刷新容器之前,有机会对容器做加工处理(具体流程可以看SpringApplication类的run方法,其中prepareContext步骤就会触发所有ApplicationContextInitializer)
通过debug可以看到ContextCustomizerAdapter在什么时候添加到springboot的,限于篇幅,这里直接说结论。

结论:springboot的test模块在启动时,会创建一个springboot的上下文,会去加载META-INF/spring.factories文件中的实现了ContextCustomizerFactory接口的类,并调用这个工厂的接口创建ContextCustomizer ,然后加入到springboot的上下文中,从而springboot启动的时候,就会触发到。其中MockitoContextCustomizerFactory就是用来创建MockitoContextCustomizer的

class MockitoContextCustomizerFactory implements ContextCustomizerFactory {

	@Override
	public ContextCustomizer createContextCustomizer(Class<?> testClass,
			List<ContextConfigurationAttributes> configAttributes) {
		// We gather the explicit mock definitions here since they form part of the
		// MergedContextConfiguration key. Different mocks need to have a different key.
		DefinitionsParser parser = new DefinitionsParser();
		parser.parse(testClass);
		//创建
		return new MockitoContextCustomizer(parser.getDefinitions());
	}

}

4.4 带@MockBean注解的成员变量如何注入

我们常用的@Autowired、@Resource注解来注入成员变量,是通过bean后置处理器来解析并注入。那带@MockBean注解的成员变量是如何注入的呢?
还是在idea通过crtl+鼠标左键点击@MockBean注解,发现其被引用的地方有个类叫MockitoTestExecutionListener,就是由它来解析注入的。
MockitoTestExecutionListener
可以看到,它实现了TestExecutionListener接口,其中prepareTestInstance()方法在测试用例执行之前会被调用(这里涉及到juit框架和SpringJUnit4ClassRunner,限于篇幅不展开讲)。

public class MockitoTestExecutionListener extends AbstractTestExecutionListener {
	@Override
	public void prepareTestInstance(TestContext testContext) throws Exception {
	    //解析带有mockito框架提供的注解的成员变量
		initMocks(testContext);
		//解析带有spring框架提供的@MockBean注解的成员变量
		injectFields(testContext);
	}

	private void injectFields(TestContext testContext) {
		//实际是通过MockitoPostProcessor的inject方法来注入
		postProcessFields(testContext, (mockitoField, postProcessor) -> postProcessor.inject(mockitoField.field,
				mockitoField.target, mockitoField.definition));
	}

	private void postProcessFields(TestContext testContext, BiConsumer<MockitoField, MockitoPostProcessor> consumer) {
		DefinitionsParser parser = new DefinitionsParser();
		//扫描出带有@MockBean等注解的成员变量
		parser.parse(testContext.getTestClass());
		if (!parser.getDefinitions().isEmpty()) {
		    //从spring容器中拿到MockitoPostProcessor 
			MockitoPostProcessor postProcessor = testContext.getApplicationContext()
					.getBean(MockitoPostProcessor.class);
			for (Definition definition : parser.getDefinitions()) {
				Field field = parser.getField(definition);
				if (field != null) {
					consumer.accept(new MockitoField(field, testContext.getTestInstance(), definition), postProcessor);
				}
			}
		}
	}
}

可以看到实际是通过MockitoPostProcessor的inject方法来注入的

	void inject(Field field, Object target, Definition definition) {
			String beanName = this.beanNameRegistry.get(definition);
			Assert.state(StringUtils.hasLength(beanName), () -> "No bean found for definition " + definition);
			inject(field, target, beanName);
		}
		
	private void inject(Field field, Object target, String beanName) {
		try {
			field.setAccessible(true);
			Assert.state(ReflectionUtils.getField(field, target) == null,
					() -> "The field " + field + " cannot have an existing value");
			//从spring容器中拿出实例
			Object bean = this.beanFactory.getBean(beanName, field.getType());
			//反射赋值
			ReflectionUtils.setField(field, target, bean);
		}
		catch (Throwable ex) {
			throw new BeanCreationException("Could not inject field: " + field, ex);
		}
	}

可以看到,最终是从spring容器中拿出mock实例,然后通过反射赋值

5 总结

  • mockito能解决对复杂的类依赖和调用关系的场景单元测试的困难
  • mockito的主要原理是动态代理,在运行时生成子类
  • springboot的@MockBean注解原理是实现BeanFactoryPostProcessor接口,在bean实例化之前,替换成mock对象beanDefinition,并通过mockito创建mock对象实例注册到BeanFactory
  • 16
    点赞
  • 67
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
注:下文中的 *** 代表文件名中的组件名称。 # 包含: 中文-英文对照文档:【***-javadoc-API文档-中文(简体)-英语-对照版.zip】 jar包下载地址:【***.jar下载地址(官方地址+国内镜像地址).txt】 Maven依赖:【***.jar Maven依赖信息(可用于项目pom.xml).txt】 Gradle依赖:【***.jar Gradle依赖信息(可用于项目build.gradle).txt】 源代码下载地址:【***-sources.jar下载地址(官方地址+国内镜像地址).txt】 # 本文件关键字: 中文-英文对照文档,中英对照文档,java,jar包,Maven,第三方jar包,组件,开源组件,第三方组件,Gradle,中文API文档,手册,开发手册,使用手册,参考手册 # 使用方法: 解压 【***.jar中文文档.zip】,再解压其中的 【***-javadoc-API文档-中文(简体)版.zip】,双击 【index.html】 文件,即可用浏览器打开、进行查看。 # 特殊说明: ·本文档为人性化翻译,精心制作,请放心使用。 ·本文档为双语同时展示,一行原文、一行译文,可逐行对照,避免了原文/译文来回切换的麻烦; ·有原文可参照,不再担心翻译偏差误导; ·边学技术、边学英语。 ·只翻译了该翻译的内容,如:注释、说明、描述、用法讲解 等; ·不该翻译的内容保持原样,如:类名、方法名、包名、类型、关键字、代码 等。 # 温馨提示: (1)为了防止解压后路径太长导致浏览器无法打开,推荐在解压时选择“解压到当前文件夹”(放心,自带文件夹,文件不会散落一地); (2)有时,一套Java组件会有多个jar,所以在下载前,请仔细阅读本篇描述,以确保这就是你需要的文件;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值