SpringMVC源码:DispatcherServlet初始化流程

23 篇文章 0 订阅

参考资料:

《SpringMVC源码解析系列》

《SpringMVC源码分析》

《Spring MVC源码》​​​​​​​

写在开头:本文为个人学习笔记,内容比较随意,夹杂个人理解,如有错误,欢迎指正。

目录

前文

        1、简介

        2、SpringMVC的配置

源码分析

一、Servlet

        1、FrameworkServlet.initWebApplicationContext

         2、FrameworkServlet.createWebApplicationContext

二、DispatcherServlet

         1、DispatcherServlet.onRefresh

        2、DispatcherServlet.initHandlerMappings

        3、DispatcherServlet.initHandlerAdapters

补充

        Spring与SpringMVC的父子容器

        默认HandlerMapping的初始化


前文

        1、简介

        MVC结构我们都知道,将模型、视图与控制器拆分实现分层。SrpingMVC采用类似的结构。

        不同的地方在于,SpringMVC的控制器多了一个,即前端控制器。前端控制器的作用在于将不同的请求根据地址转给不同的控制器进行处理,并对返回的模型选择相应的视图进行渲染。

        2、SpringMVC的配置

        SpringMVC通过servlet实现注册,通过配置load-on-startup为1使其在tomcat启动时执行。

  <servlet>
      <servlet-name>dispatcher</servlet-name>
      <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
          <init-param>
             <param-name>contextConfigLocation</param-name>
          <param-value>classpath:springContext.xml</param-value>
      </init-param>
      <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
      <servlet-name>dispatcher</servlet-name>
      <url-pattern>/</url-pattern>
  </servlet-mapping>
  <context-param>

源码分析

        SpringMVC的分发是由DispatcherServlet这个类实现的,根据类名我们可以确定这是个Servlet的实现类,下面是它的继承关系图。

         对于servlet我们知道会在首次调用时调用init方法初始化(这里我们配置了启动时初始化),并使用service方法为request请求服务。

        我们将DispatcherServlet的初始化过程整理如下:

         initServletBean没什么具体操作,因此这里从initServletBean开始看起。

一、Servlet

        1、FrameworkServlet.initWebApplicationContext

          这个方法主要做了以下几步:

  1. 从ServletContext中获取第一步中创建的SpringMVC根上下文,为下面做准备
  2. 根据init-param中的contextAttribute属性值从ServletContext查找是否存在上下文对象
  3. 以XmlWebApplicationContext作为Class类型创建上下文对象,设置父类上下文,并完成刷新
  4. 执行子类扩展方法onRefresh,在DispatcherServlet内初始化所有web相关组件
  5. 将servlet子上下文对象发布到ServletContext
	protected WebApplicationContext initWebApplicationContext() {
        // 获取ServletContext(全局唯一)获取与之关联的WebApplicationContext
		WebApplicationContext rootContext =
				WebApplicationContextUtils.getWebApplicationContext(getServletContext());
		WebApplicationContext wac = null;
        // 如果SpringMVC的servlet子上下文对象不为空,则设置根上下文为其父类上下文,然后刷新
		if (this.webApplicationContext != null) {
			wac = this.webApplicationContext;
			if (wac instanceof ConfigurableWebApplicationContext) {
				ConfigurableWebApplicationContext cwac = (ConfigurableWebApplicationContext) wac;
				if (!cwac.isActive()) {
					if (cwac.getParent() == null) {
						cwac.setParent(rootContext);
					}
					configureAndRefreshWebApplicationContext(cwac);
				}
			}
		}
		if (wac == null) {
            // 根据init-param配置的属性名称从ServletContext查找SpringMVC的servlet子上下文
			wac = findWebApplicationContext();
		}
		if (wac == null) {
            // 若还为空,则创建一个新的上下文对象并刷新
			wac = createWebApplicationContext(rootContext);
		}

		if (!this.refreshEventReceived) {
            // 子类自定义对servlet子上下文后续操作,在DispatcherServlet中实现
			onRefresh(wac);
		}
        // 将新创建的上下文注册到ServletContext
		if (this.publishContext) {
			String attrName = getServletContextAttributeName();
			getServletContext().setAttribute(attrName, wac);
			if (this.logger.isDebugEnabled()) {
				this.logger.debug("Published WebApplicationContext of servlet '" + getServletName() +
						"' as ServletContext attribute with name [" + attrName + "]");
			}
		}

		return wac;
	}

         在findWebApplicationContext方法中会根据web.xml中的配置判断是否从ServletContext中查找上下文对象,配置了的话就直接获取。

	protected WebApplicationContext findWebApplicationContext() {
        // 根据init-param中的contextAttribute属性值从ServletContext查找是否存在上下文对象
		String attrName = getContextAttribute();
		if (attrName == null) {
			return null;
		}
        // 通过现有的WebApplicationContext获取已存在的上下文对象
		WebApplicationContext wac =
				WebApplicationContextUtils.getWebApplicationContext(getServletContext(), attrName);
		if (wac == null) {
			throw new IllegalStateException("No WebApplicationContext found: initializer not registered?");
		}
		return wac;
	}

         2、FrameworkServlet.createWebApplicationContext

        createWebApplicationContext方法创建SpringMVC的应用上下文,并调用configureAndRefreshWebApplicationContext方法进行上下文的刷新。

	protected WebApplicationContext createWebApplicationContext(ApplicationContext parent) {
        // 默认XmlWebApplicationContext
		Class<?> contextClass = getContextClass();
		if (this.logger.isDebugEnabled()) {
			this.logger.debug("Servlet with name '" + getServletName() +
					"' will try to create custom WebApplicationContext context of class '" +
					contextClass.getName() + "'" + ", using parent context [" + parent + "]");
		}
		if (!ConfigurableWebApplicationContext.class.isAssignableFrom(contextClass)) {
			throw new ApplicationContextException(
					"Fatal initialization error in servlet with name '" + getServletName() +
					"': custom WebApplicationContext class [" + contextClass.getName() +
					"] is not of type ConfigurableWebApplicationContext");
		}
        // 反射创建applicationContext
		ConfigurableWebApplicationContext wac =
				(ConfigurableWebApplicationContext) BeanUtils.instantiateClass(contextClass);

		wac.setEnvironment(getEnvironment());
        // 设置parent(ContextLoadListener中创建的applicationContext)
		wac.setParent(parent);
		wac.setConfigLocation(getContextConfigLocation());
        //refresh()
		configureAndRefreshWebApplicationContext(wac);

		return wac;
	}

        configureAndRefreshWebApplicationContext会先将新创建的这个上下文与servletcontext绑定,然后进行刷新操作,这个刷新操作就和我们之前介绍的IOC的执行过程一样,这部分内容可以看这里《Spring IOC:从ContextLoaderListener到AbstractApplicationContext》

	protected void configureAndRefreshWebApplicationContext(ConfigurableWebApplicationContext wac) {
		if (ObjectUtils.identityToString(wac).equals(wac.getId())) {
			if (this.contextId != null) {
				wac.setId(this.contextId);
			}
			else {

				wac.setId(ConfigurableWebApplicationContext.APPLICATION_CONTEXT_ID_PREFIX +
						ObjectUtils.getDisplayString(getServletContext().getContextPath()) + '/' + getServletName());
			}
		}
        //将ServletContext和ServletConfig都绑定到servlet子上下文对象中
		wac.setServletContext(getServletContext());
		wac.setServletConfig(getServletConfig());
		wac.setNamespace(getNamespace());
		wac.addApplicationListener(new SourceFilteringListener(wac, new ContextRefreshListener()));


		ConfigurableEnvironment env = wac.getEnvironment();
		if (env instanceof ConfigurableWebEnvironment) {
			((ConfigurableWebEnvironment) env).initPropertySources(getServletContext(), getServletConfig());
		}

		postProcessWebApplicationContext(wac);
		applyInitializers(wac);
        //最后初始化子容器,和上面根容器初始化一样
		wac.refresh();
	}

        到这一步为止,我们一共创建了3个context,分别为:

  • ServletContext:全局唯一,tomcat启动该web项目了创建
  • Spring的context:由servletcontext的监听器contextloaderlistener创建,默认读取servletcontext.xml配置,具体内容参考我们之前介绍的IOC的源码实现。同时该cntext与servletcontext互相关联,该context被注册到servletcontext中以webapplicationcontext属性存在。
  • SpringMVC的context:SpringMVC初始化DispacherServlet时创建的上下文,该context为Spring的context的子容器,可以访问Spring容器中的内容(子容器可以访问父容器中的内容,但是反过来不行)。

在这里插入图片描述

        经过上面两个步骤,DispatcherServlet父类中的流程已经全部走完,这几个步骤主要的功能就是生成了SpringMVC容器,并将其与ServletContext、Spring容器相关联。

        这个SpringMVC容器会和Spring容器一样扫描web.xml中配置文件,例如如下配置就会扫描spring-mvc.xml并生成该容器的beanFactory,生成注册在其中的bean。

	<servlet>
		<servlet-name>SpringMvcDispatcher</servlet-name>
		<servlet-class>org.springframework.web.servlet.org.springframework.web.servlet</servlet-class>
		<async-supported>true</async-supported>
		<init-param>
			<param-name>contextConfigLocation</param-name>
			<param-value>classpath*:spring-mvc.xml</param-value>
		</init-param>
		<load-on-startup>1</load-on-startup>
	</servlet>
	<servlet-mapping>
		<servlet-name>SpringMvcDispatcher</servlet-name>
		<url-pattern>/rest/*</url-pattern>
	</servlet-mapping>

二、DispatcherServlet

         1、DispatcherServlet.onRefresh

        onRefresh将会执行initStrategies方法初始化九大组件。这些组件都是整个SpringMVC运行的基础。其中最重要的就是initHandlerMappings、initHandlerAdapters,下面我们会着重进行介绍。

	@Override
	protected void onRefresh(ApplicationContext context) {
		initStrategies(context);
	}

        // 初始化九大组件
    protected void initStrategies(ApplicationContext context) {
        //初始化文件上传解析对象组件MultipartResolver
        //该组件主要用于支持springMVC的文件上传
        initMultipartResolver(context);
        //初始化国际化资源解析器LocaleResolver
        initLocaleResolver(context);
        //初始化主题资源解析器 一个主题就是一组静态资源  其包含主题资源和主题解析器
        initThemeResolver(context);
        //初始化处理器映射器 该组件主要是根据对应的请求 获取对应的处理逻辑组件Handler
        //(说白了就是我们编写的Controller的方法)
        initHandlerMappings(context);
        //初始化处理器适配器,因为我们编写的Handler 可能是不同的了类型,比如简单类型,http类型等
        //为了使请求可能被不同的handler处理统一起来 这里会使用适配模式提供统一的处理请求的返回结果的接口
        //所有业务逻辑执行是在该处理器适配器中执行的
        //比如:我们国家的电压220V 但是和手机,洗衣机需要的电压,这里充电的时候会有不同的适配器将其电压转换为
        //合适终端的电压,此即为处理器适配器
        initHandlerAdapters(context);
        //初始化 处理异常的组件,该组件有一个方法resolveException() 该方法会对请求处理过程中出现异常的情况
        //进行处理 根据不同的异常返回不同的异常页面
        initHandlerExceptionResolvers(context);
        //当Controller处理方法并没有返回视图的时候,且没有在reponse存放数据(往reponse中存放数据大多数是下载功能)
        //该组件按照其getViewName()设置视图 从而返回
        initRequestToViewNameTranslator(context);
        //初始化视图解析器,当请求被处理放入ModelAndView.该组件会选择合适的视图去进行渲染
        initViewResolvers(context);
        //初始化FlashMapManager 用于在重定向的时候 还能继续使用数据(一般情况重定向请求后请求参数会丢失)
        initFlashMapManager(context);
    }

        2、DispatcherServlet.initHandlerMappings

        该方法负责进行HandlerMapping接口实现类的加载。HandlerMapping接口主要用来提供request 请求对象和​​​​​​​Handler对象 映射关系的接口。所谓request对象比如我们web应用中的http 请求,Handler对象则指的是对应rquest请求的相关处理逻辑。

        首先判断是否查找所有HandlerMapping(默认为true)。如果为是,则从上下文(包括所有父上下文)中查询类型为HandlerMapping的Bean,并进行排序。如果为否,则从上下文中按指定名称去寻找。如果都没有找到,提供一个默认的实现。这个默认实现从DispatcherServlet同级目录的DispatcherServlet.properties中加载得。

	/** Detect all HandlerMappings or just expect "handlerMapping" bean? */
	private boolean detectAllHandlerMappings = true;

	private void initHandlerMappings(ApplicationContext context) {
		this.handlerMappings = null;
        // 是否查找所有HandlerMapping标识
		if (this.detectAllHandlerMappings) {
            // 从上下文(包含所有父上下文)中查找HandlerMapping类型的Bean,是下文中的RequestMappingHandlerMapping,其中包含URL和Mapping的映射Map
			Map<String, HandlerMapping> matchingBeans =
					BeanFactoryUtils.beansOfTypeIncludingAncestors(context, HandlerMapping.class, true, false);
			if (!matchingBeans.isEmpty()) {
                //将从容器中查找出来的HandlerMapping加入到DispatcherServlet的handlerMappings属性中
				this.handlerMappings = new ArrayList<HandlerMapping>(matchingBeans.values());
				AnnotationAwareOrderComparator.sort(this.handlerMappings);
			}
		}
		else {
			try {
                // 根据指定名称获取HandlerMapping对象
				HandlerMapping hm = context.getBean(HANDLER_MAPPING_BEAN_NAME, HandlerMapping.class);
				this.handlerMappings = Collections.singletonList(hm);
			}
			catch (NoSuchBeanDefinitionException ex) {
			}
		}
        // 如果容器中没有实现了handlerMapping接口的类,则创建默认的handlerMapping实现类
		if (this.handlerMappings == null) {
			this.handlerMappings = getDefaultStrategies(context, HandlerMapping.class);
			if (logger.isDebugEnabled()) {
				logger.debug("No HandlerMappings found in servlet '" + getServletName() + "': using default");
			}
		}
	}

        3、DispatcherServlet.initHandlerAdapters

        上一节中通过initHandlerMappings已经将request通过HandlerMapping(处理器映射器)将请求映射到了对应的Handler上,这一步就需要考虑如何解析并执行该handler对象。

        这里Spring使用了适配器模式,主要是因为handler对象有两种不同的类型。

        (1)以实现了Controller接口的Handler类

public class DemoController implements Controller {
    @Override
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
        System.out.println("进入DemoController方法执行处理逻辑");
        return new ModelAndView("demo");
    }
}

        (2)以@RequestMapping注解修饰的HandlerMethod对象

@RequestMapping(value = "/book/ListPage",method = RequestMethod.POST)
@ResponseBody
public String getBookPage(@RequestBody BookQuery bookQuery){
       return success(pageTotal(pageInfo));
}

        其他还有实现Servlet的实例,HandlerFunction实例、HttpRequestHandler实例等,不同的实例对象调用时走不同的方法,为了能将不同的方法转换成统一的调用形式,这里使用了适配器模式,将各个实例的方法调用包装到HandlerAdapter统一调用。

        initHandlerAdapters的过程和initHandlerMappings类似,也是先从上下文(包括所有父上下文)中查询类型为HandlerAdapter的Bean,并进行排序。如果为否,则从上下文中按指定名称去寻找。如果都没有找到,提供一个默认的实现。

private void initHandlerAdapters(ApplicationContext context) {
    this.handlerAdapters = null;
    if (this.detectAllHandlerAdapters) {
        Map<String, HandlerAdapter> matchingBeans = BeanFactoryUtils.beansOfTypeIncludingAncestors(context, HandlerAdapter.class, true, false);
        if (!matchingBeans.isEmpty()) {
            this.handlerAdapters = new ArrayList(matchingBeans.values());
            AnnotationAwareOrderComparator.sort(this.handlerAdapters);
        }
    } else {
        try {
            HandlerAdapter ha = (HandlerAdapter)context.getBean("handlerAdapter", HandlerAdapter.class);
            this.handlerAdapters = Collections.singletonList(ha);
        } catch (NoSuchBeanDefinitionException var3) {
            ;
        }
    }

    if (this.handlerAdapters == null) {
        this.handlerAdapters = this.getDefaultStrategies(context, HandlerAdapter.class);
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("No HandlerAdapters found in servlet '" + this.getServletName() + "': using default");
        }
    }

}

               

        整个SpringMVC的初始化流程就大致介绍了一遍,主要完成了SpringMVC容器的构建并加载了初始化相关的组件如HandlerMapping、HandlerAdapter等,下篇文章我们会介绍下HandlerMapping与HandlerAdapter的实现类。

补充

        Spring与SpringMVC的父子容器

        1、方便划分框架边界

        在service层我们一般使用spring框架来管理, 而在web层则有多种选择,如spring mvc、struts等。因此,通常对于web层我们会使用单独的配置文件。

        如果现在我们想把web层从SpringMVC替换成Struts,那么只需要将SpringMVC替换成Struts的配置文件Struts.xml即可,而Spring的配置文件不需要改变。

        2、是否可以把所有类都通过Spring父容器来管理?

        这里以spring-context-4.3.29为例,initHandlerMethods的源码如下

    private boolean detectHandlerMethodsInAncestorContexts = false;
	protected void initHandlerMethods() { 
        // 获取所有的beanName
		String[] beanNames = (this.detectHandlerMethodsInAncestorContexts ?
				BeanFactoryUtils.beanNamesForTypeIncludingAncestors(getApplicationContext(), Object.class) :
				getApplicationContext().getBeanNamesForType(Object.class));
        // 其余省略
	}

        这里获取所有的beanName有2种方法,我们分别来看下:

    //BeanFactoryUtils.java
	public static String[] beanNamesForTypeIncludingAncestors(ListableBeanFactory lbf, Class<?> type) {
		Assert.notNull(lbf, "ListableBeanFactory must not be null");
		String[] result = lbf.getBeanNamesForType(type);
		if (lbf instanceof HierarchicalBeanFactory) {
			HierarchicalBeanFactory hbf = (HierarchicalBeanFactory) lbf;
			if (hbf.getParentBeanFactory() instanceof ListableBeanFactory) {
                // 获取父BeanFactory中结果
				String[] parentResult = beanNamesForTypeIncludingAncestors(
						(ListableBeanFactory) hbf.getParentBeanFactory(), type);
				result = mergeNamesWithParent(result, parentResult, hbf);
			}
		}
		return result;
	}


    //DefaultListableBeanFactory.java
	@Override
	public String[] getBeanNamesForType(Class<?> type) {
		return getBeanNamesForType(type, true, true);
	}

	@Override
	public String[] getBeanNamesForType(Class<?> type, boolean includeNonSingletons, boolean allowEagerInit) {
		if (!isConfigurationFrozen() || type == null || !allowEagerInit) {
			return doGetBeanNamesForType(ResolvableType.forRawClass(type), includeNonSingletons, allowEagerInit);
		}
        // 从当前容器中获取结果
		Map<Class<?>, String[]> cache =
				(includeNonSingletons ? this.allBeanNamesByType : this.singletonBeanNamesByType);
		String[] resolvedBeanNames = cache.get(type);
		if (resolvedBeanNames != null) {
			return resolvedBeanNames;
		}
		resolvedBeanNames = doGetBeanNamesForType(ResolvableType.forRawClass(type), includeNonSingletons, true);
		if (ClassUtils.isCacheSafe(type, getBeanClassLoader())) {
			cache.put(type, resolvedBeanNames);
		}
		return resolvedBeanNames;
	}

        从上面的结果可以看出,SpringMVC中的initHandlerMethods默认是从当前容器中查询beanName的,如果将所有的bean都交给Spring容器来管理,可能导致无法处理相应的请求。

        3、是否可以把我们所需的类都放入SpringMVC子容器里面来管理

        可以,但不推荐。如果项目里有用到事务或AOP则需要把这部分配置需要放到SpringMVC子容器的配置文件来,不然一部分内容在子容器和一部分内容在父容器,可能就会导致你的事物或者AOP不生效。

        4、是否可以同时通过两个容器同时来管理所有的类?

        首先两个容器里面都放一份一样的对象,造成了内存浪费。另外子容器会覆盖父容器加载,本来可能父容器配置了事物生成的是代理对象,但是被子容器一覆盖,又成了原生对象。这就导致了你的事物不起作用了。

        默认HandlerMapping的初始化

        在DispatcherServlet.initHandlerMappings中,如果容器中没有配置别的HandlerMapping的话则创建默认的实现类。方法是读取DispatcherServlet.properties中的配置,并调用createBean创建。

	private static final String DEFAULT_STRATEGIES_PATH = "DispatcherServlet.properties";
	private static final Properties defaultStrategies;

	static {

        // 获取默认配置DispatcherServlet.properties中的属性
		try {
			ClassPathResource resource = new ClassPathResource(DEFAULT_STRATEGIES_PATH, DispatcherServlet.class);
			defaultStrategies = PropertiesLoaderUtils.loadProperties(resource);
		}
		catch (IOException ex) {
			throw new IllegalStateException("Could not load '" + DEFAULT_STRATEGIES_PATH + "': " + ex.getMessage());
		}
	}

	@SuppressWarnings("unchecked")
	protected <T> List<T> getDefaultStrategies(ApplicationContext context, Class<T> strategyInterface) {
		String key = strategyInterface.getName();
        // 获取默认配置DispatcherServlet.properties中的属性
		String value = defaultStrategies.getProperty(key);
		if (value != null) {
			String[] classNames = StringUtils.commaDelimitedListToStringArray(value);
			List<T> strategies = new ArrayList<T>(classNames.length);
			for (String className : classNames) {
				try {
					Class<?> clazz = ClassUtils.forName(className, DispatcherServlet.class.getClassLoader());
					Object strategy = createDefaultStrategy(context, clazz);
					strategies.add((T) strategy);
				}
				catch (ClassNotFoundException ex) {
					throw new BeanInitializationException(
							"Could not find DispatcherServlet's default strategy class [" + className +
									"] for interface [" + key + "]", ex);
				}
				catch (LinkageError err) {
					throw new BeanInitializationException(
							"Error loading DispatcherServlet's default strategy class [" + className +
									"] for interface [" + key + "]: problem with class file or dependent class", err);
				}
			}
			return strategies;
		}
		else {
			return new LinkedList<T>();
		}
	}

    	protected Object createDefaultStrategy(ApplicationContext context, Class<?> clazz) {
        // 对传入进来的类调用createBean方法进行创建
		return context.getAutowireCapableBeanFactory().createBean(clazz);
	}

         DispatcherServlet.properties的内容如下,我们可以看到HandlerMapping有2个实现类,BeanNameUrlHandlerMapping与DefaultAnnotationHandlerMapping,分别对应xml注册方式与注解方式的分发模式。

org.springframework.web.servlet.LocaleResolver=org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver

org.springframework.web.servlet.ThemeResolver=org.springframework.web.servlet.theme.FixedThemeResolver

org.springframework.web.servlet.HandlerMapping=org.springframework.web.servlet.handler.BeanNameUrlHandlerMapping,\
	org.springframework.web.servlet.mvc.annotation.DefaultAnnotationHandlerMapping

org.springframework.web.servlet.HandlerAdapter=org.springframework.web.servlet.mvc.HttpRequestHandlerAdapter,\
	org.springframework.web.servlet.mvc.SimpleControllerHandlerAdapter,\
	org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter

org.springframework.web.servlet.HandlerExceptionResolver=org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerExceptionResolver,\
	org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver,\
	org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver

org.springframework.web.servlet.RequestToViewNameTranslator=org.springframework.web.servlet.view.DefaultRequestToViewNameTranslator

org.springframework.web.servlet.ViewResolver=org.springframework.web.servlet.view.InternalResourceViewResolver

org.springframework.web.servlet.FlashMapManager=org.springframework.web.servlet.support.SessionFlashMapManager

         再补充下DefaultAnnotationHandlerMapping的初始化。

​​

       

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值