SpingMVC组件之ViewResolver

目录

1. ViewResolver概述
2. BeanNameViewResolver
3. ContentNegotiatingViewResolver
4. ViewResolverComposite
5. AbstractCachingViewResolver
6. 总结

1. ViewResolver概述

ViewResolver作为SpringMVC的组件之一,当然还是要从DispatcherServlet#doDispatch来看这个组件作用是什么及组件是如何被调用的。在doDispatch方法里面执行Handler里的逻辑后会执行processDispatchResult对Handler处理返回的ModelAndView进行视图渲染,这个方法主要是会去调用render方法,这个方法的实现为:

	protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
		// Determine locale for request and apply it to the response.
		Locale locale =
				(this.localeResolver != null ? this.localeResolver.resolveLocale(request) : request.getLocale());
		response.setLocale(locale);

		View view;
		//这一步是关键,从ModelAndView中拿到viewName
		String viewName = mv.getViewName();
		if (viewName != null) { //返回了逻辑视图
			// We need to resolve the view name.
			//从这里调用了ViewResolver
			view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
			if (view == null) {
				throw new ServletException("Could not resolve view with name '" + mv.getViewName() +
						"' in servlet with name '" + getServletName() + "'");
			}
		}
		else {//非逻辑视图
			// No need to lookup: the ModelAndView object contains the actual View object.
			view = mv.getView();
			if (view == null) {
				throw new ServletException("ModelAndView [" + mv + "] neither contains a view name nor a " +
						"View object in servlet with name '" + getServletName() + "'");
			}
		}

		// Delegate to the View object for rendering.
		if (logger.isDebugEnabled()) {
			logger.debug("Rendering view [" + view + "] in DispatcherServlet with name '" + getServletName() + "'");
		}
		try {
			if (mv.getStatus() != null) {
				response.setStatus(mv.getStatus().value());
			}
			//调用真实的视图来渲染视图
			view.render(mv.getModelInternal(), request, response);
		}
		catch (Exception ex) {
			if (logger.isDebugEnabled()) {
				logger.debug("Error rendering view [" + view + "] in DispatcherServlet with name '" +
						getServletName() + "'", ex);
			}
			throw ex;
		}
	}

需要理解的是视图View及视图解析器ViewResolver是两个不同的概念,ViewResolver是根据ViewName和Local去找到真正需要的View。DispatcherServlet会将Model里面的数据渲染到View中,这个View就是返回给用户的数据的展现形式。展现形式可以是多种多样的,可以是json格式,也可以是xml方式,也可以是jsp页面等。可以看到render会对modelAndView调用getViewName方法,如果得到viewName,说明需要将逻辑视图转换为真实的视图,这就是ViewResolver的作用。它的具体实现为:

	protected View resolveViewName(String viewName, @Nullable Map<String, Object> model,
			Locale locale, HttpServletRequest request) throws Exception {

		if (this.viewResolvers != null) {
			for (ViewResolver viewResolver : this.viewResolvers) {
				View view = viewResolver.resolveViewName(viewName, locale);
				if (view != null) {
					return view;
				}
			}
		}
		return null;
	}

可以看到DispatcherServlet的resolveViewName其实就是遍历自己的viewResolvers属性,这个viewResolvers就是个泛型为ViewResolver的list,然后看其是否能够通过viewName和Locale来解析视图,可以的话就直接返回解析出的View。而这个变量的初始化其实也是在onRefresh方法中完成的,这个在之前也已经分析过了,这里就不再次展开了。而ViewResolver的定义为:

public interface ViewResolver {
	View resolveViewName(String viewName, Locale locale) throws Exception;
}

它的继承体系为:

可以看出ViewResolver有四大继承类,
BeanNameViewResolver,将viewName当做beanName从容器中查找View.
ContentNegotiatingViewResolver,这个viewResolver根据request请求头的accept来选择合适的视图返回,可以用来实现RestFul。
ViewResolverComposite,这种XXXComposite的类就是XXX的一个容器,在处理请求是遍历自身存储的ViewResolver进行解析的。
AbstractCachingViewResolver,这个是所有可以缓存解析过的视图的基类,逻辑视图和视图的关系一般是不变的,所以不需要每次都重新解析,最好解析过一次就缓存起来。它的继承结构是:

2. BeanNameViewResolver
public class BeanNameViewResolver extends WebApplicationObjectSupport implements ViewResolver, Ordered {

	private int order = Ordered.LOWEST_PRECEDENCE;  // default: same as non-Ordered

	public void setOrder(int order) {
		this.order = order;
	}

	@Override
	public int getOrder() {
		return this.order;
	}


	@Override
	@Nullable
	public View resolveViewName(String viewName, Locale locale) throws BeansException {
		ApplicationContext context = obtainApplicationContext();
		if (!context.containsBean(viewName)) {
			if (logger.isDebugEnabled()) {
				logger.debug("No matching bean found for view name '" + viewName + "'");
			}
			// Allow for ViewResolver chaining...
			return null;
		}
		if (!context.isTypeMatch(viewName, View.class)) {
			if (logger.isDebugEnabled()) {
				logger.debug("Found matching bean for view name '" + viewName +
						"' - to be ignored since it does not implement View");
			}
			// Since we're looking into the general ApplicationContext here,
			// let's accept this as a non-match and allow for chaining as well...
			return null;
		}
		return context.getBean(viewName, View.class);
	}
}

可以看出BeanNameViewResolver是非常简单的,它的resolverViewName就是将参数viewName作beanName在容器中去查找,找不到或这找到的bean类型不对就直接返回了,否则就返回视图。

3. ContentNegotiatingViewResolver

ContentNegotiatingViewResolver可以用于实现RestFul,即选择最合适的方式表述资源。它的实现原理是将视图解析的任务分派给它自己的成员变量viewResolvers,这可能会解析出多个视图,然后再获取request的Accept请求头,这也可能会有多个值,将这两个结构进行匹配,返回最优的视图给请求端,决定是否是匹配是交给ContentNegotiationManager去完成的。
刚才有谈到ContentNegotiatingViewResolver它自身是不会解析视图的,是分派给他的成员变量viewResolvers的,那viewResolvers是怎么初试化的呢?

	protected void initServletContext(ServletContext servletContext) {
		Collection<ViewResolver> matchingBeans =
				BeanFactoryUtils.beansOfTypeIncludingAncestors(obtainApplicationContext(), ViewResolver.class).values();
		if (this.viewResolvers == null) {
			this.viewResolvers = new ArrayList<>(matchingBeans.size());
			for (ViewResolver viewResolver : matchingBeans) {
				if (this != viewResolver) {
					this.viewResolvers.add(viewResolver);
				}
			}
		}
		else {
			for (int i = 0; i < this.viewResolvers.size(); i++) {
				ViewResolver vr = this.viewResolvers.get(i);
				if (matchingBeans.contains(vr)) {
					continue;
				}
				//配置的viewResolver没有在容器中,对它进行初始化,其实就是执行Bean的生命周期函数
				String name = vr.getClass().getName() + i;
				obtainApplicationContext().getAutowireCapableBeanFactory().initializeBean(vr, name);
			}

		}
		if (this.viewResolvers.isEmpty()) {
			logger.warn("Did not find any ViewResolvers to delegate to; please configure them using the " +
					"'viewResolvers' property on the ContentNegotiatingViewResolver");
		}
		AnnotationAwareOrderComparator.sort(this.viewResolvers);
		this.cnmFactoryBean.setServletContext(servletContext);
	}

viewResolvers的初始化逻辑是:先从容器中获取所有类型为ViewResolver的bean,然后根据viewResolvers是否已经被setViewResolvers方法设置过。如果没有设置过的话,则将所有查找到的VireResolver添加到viewResolvers中,当然还排除了它自身。如果已经设置过的话,则判断已经设置过的viewResolver是否在容器中,如果不在Spring容器中的话,就对其进行初始化。
现在关于ContentNegotiatingViewResolver的成员变量viewResolvers的初试化已经完成了,在去看它是如何让利用这个成员变量进行视图解析的:

	public View resolveViewName(String viewName, Locale locale) throws Exception {
		//这几步适用于获取request的media-type,也就是accpet表示的能够接受的数据表现方式
		RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
		Assert.state(attrs instanceof ServletRequestAttributes, "No current ServletRequestAttributes");
		List<MediaType> requestedMediaTypes = getMediaTypes(((ServletRequestAttributes) attrs).getRequest());
		if (requestedMediaTypes != null) {
			//根据requestedMediaTypes获取到所有的候选view
			List<View> candidateViews = getCandidateViews(viewName, locale, requestedMediaTypes);
			//从候选view中获取最合适的view
			View bestView = getBestView(candidateViews, requestedMediaTypes, attrs);
			if (bestView != null) {
				return bestView;
			}
		}
		//不知道客户端能够接受哪种视图
		if (this.useNotAcceptableStatusCode) {
			if (logger.isDebugEnabled()) {
				logger.debug("No acceptable view found; returning 406 (Not Acceptable) status code");
			}
			return NOT_ACCEPTABLE_VIEW;
		}
		else {
			logger.debug("No acceptable view found; returning null");
			return null;
		}
	}

可以看出,先获取request表明的客户端能够接收的MediaType,将其作为条件之一获取所有可以满足客户端需求的视图,然后再这些都能满足客户端需求的视图中选择最合适的一个,这个过程中调用了两个方法;

	private List<View> getCandidateViews(String viewName, Locale locale, List<MediaType> requestedMediaTypes)
			throws Exception {
		//临时变量,用于存储所有备选的视图
		List<View> candidateViews = new ArrayList<>();
		if (this.viewResolvers != null) {
			Assert.state(this.contentNegotiationManager != null, "No ContentNegotiationManager set");
			//遍历成员变量viewResolvers,看其是否能够解析当前的viewName
			for (ViewResolver viewResolver : this.viewResolvers) {
				View view = viewResolver.resolveViewName(viewName, locale);
				//如果能够解析出来
				if (view != null) {
					//加入到备选视图中
					candidateViews.add(view);
				}
				//将ViewName加上MediaType的后缀,看当前遍历到的视图解析器是否能够解析这个生成的新的viewName,如果可以的话将其加入到候选视图中
				for (MediaType requestedMediaType : requestedMediaTypes) {
					List<String> extensions = this.contentNegotiationManager.resolveFileExtensions(requestedMediaType);
					for (String extension : extensions) {
						String viewNameWithExtension = viewName + '.' + extension;
						view = viewResolver.resolveViewName(viewNameWithExtension, locale);
						if (view != null) {
							candidateViews.add(view);
						}
					}
				}
			}
		}
		//将所有的默认视图放在备选视图中
		if (!CollectionUtils.isEmpty(this.defaultViews)) {
			candidateViews.addAll(this.defaultViews);
		}
		return candidateViews;
	}

getCandidateViews用于找到所有适合的视图,它的处理逻辑是遍历viewResolvers变量,看其是否能够解析传入的viewName或者viewName加上mediaType对应的后缀名,如果可以的话就将其加入候选视图中,当遍历完viewResolvers后,再将设置的默认视图一起放入到候选视图中,再返回给调用者。

	private View getBestView(List<View> candidateViews, List<MediaType> requestedMediaTypes, RequestAttributes attrs) {
		//用于判断候选的视图中有没有重定向视图,如果有的话就直接返回
		for (View candidateView : candidateViews) {
			//smartView是View的子类,用于判断这个视图是否会重定向
			if (candidateView instanceof SmartView) {
				SmartView smartView = (SmartView) candidateView;
				if (smartView.isRedirectView()) {
					if (logger.isDebugEnabled()) {
						logger.debug("Returning redirect view [" + candidateView + "]");
					}
					return candidateView;
				}
			}
		}
		//可以看到查找过程是MediaType是优于View的,
		for (MediaType mediaType : requestedMediaTypes) {
			for (View candidateView : candidateViews) {
				if (StringUtils.hasText(candidateView.getContentType())) {
					MediaType candidateContentType = MediaType.parseMediaType(candidateView.getContentType());
					if (mediaType.isCompatibleWith(candidateContentType)) {
						if (logger.isDebugEnabled()) {
							logger.debug("Returning [" + candidateView + "] based on requested media type '" +
									mediaType + "'");
						}
						attrs.setAttribute(View.SELECTED_CONTENT_TYPE, mediaType, RequestAttributes.SCOPE_REQUEST);
						return candidateView;
					}
				}
			}
		}
		return null;
	}

在刚才的步骤中已经得到了可以返回给用户的所有视图,现在就需要挑一个最合适的视图进行返回了,具体逻辑是:看候选View中是否有redirectView,如果有的话就直接返回,否者遍历从request中获取的media-type,对于客户端支持的每一个media-type都去遍历候选view,看view能支持的media-type,如果view支持的media-type和当前遍历到的media可以兼容的话,就返回当前view,并且将该media-type设置到request域中。到此,我们对ContentNegotiatingViewResolver的分析就完毕了,其实真要用RestFul的话更合适的方式是使用ResponseEntity。

4. ViewResolverComposite

几乎所有的XXXComposite类的作用就是存储一系列的XXX,然后遍历这些XXX来完成接口定义的功能,ViewResolverComposite也是样的。我们看看其源码

	public View resolveViewName(String viewName, Locale locale) throws Exception {
		for (ViewResolver viewResolver : this.viewResolvers) {
			View view = viewResolver.resolveViewName(viewName, locale);
			if (view != null) {
				return view;
			}
		}
		return null;
	}

可以看到其过程就是简单的调用其viewResolvers属性,返回第一个找到的view,如果找不到的话就返回一个null。至于这个属性就是通过调用Setter方法设置的。

5. AbstractCachingViewResolver

AbstarctCachingViewResolver是ViewResolver里面最丰富的一个体系了,它提供了统一的缓存功能,使得视图被解析过一次就缓存起来(视图解析就是视图调用其render方法,将model,request,response里面的数据设置到View里合适的地方的一个过程),当视图被缓存起来后,就可以直接从缓存里面取视图了,不过既然是缓存,那必定会有设置缓存失效的方法了,稍后在看其是如何实现缓存的。AbstarctCachingViewResolver的直接子类有三个ResourceBundleViewResolver,XmlViewResolver和UrlBasedViewResolver。前两个分别通过配置properties和xml文件来解析视图的,至于UrlbasedViewResolver则是所有将逻辑视图(viewName)当做url去查找模版文件的ViewResolver的基类,最常见的就是InternalResourceViewResolver了。现在来看看AbstractCachingViewResolver是如何解析视图的:

	public View resolveViewName(String viewName, Locale locale) throws Exception {
		//判断是否开启了缓存功能,默认是开启了的,其容量为1024个视图
		if (!isCache()) {
			//如果没有开启的话,实际创建视图
			return createView(viewName, locale);
		}
		else {
			//获得key 这个值就是:viewName + '_' + locale;
			Object cacheKey = getCacheKey(viewName, locale);
			//从viewAccessCache中获取视图,这个属性是ConcurrentHashMap类型的
			View view = this.viewAccessCache.get(cacheKey);
			if (view == null) {
				//viewCreationCache是LinkedHashMap类型的
				synchronized (this.viewCreationCache) {
					//获取到视图
					view = this.viewCreationCache.get(cacheKey);
					if (view == null) {
						// Ask the subclass to create the View object.
						//实际创建视图
						view = createView(viewName, locale);
						if (view == null && this.cacheUnresolved) {
							//UNRESOLVED_VIEW 定义的一个Content-Type为空的view
							view = UNRESOLVED_VIEW;
						}
						if (view != null) {
							this.viewAccessCache.put(cacheKey, view);
							this.viewCreationCache.put(cacheKey, view);
							if (logger.isTraceEnabled()) {
								logger.trace("Cached view [" + cacheKey + "]");
							}
						}
					}
				}
			}
			return (view != UNRESOLVED_VIEW ? view : null);
		}
	}

先判断是否开启了缓存功能,如果没有开启的话就创建一个视图,否则就从缓存中获取,但是从缓存中获取数据又分为命中和没有命中两种情况。命中的话就直接返回,没有命中的话就创建视图,如果创建成功返回并放到缓存中。可以看到其缓存实现是通过了两个Map,ConcurrentHashMap与LinkedHashMap,这里结合了这两种Map的优势,及ConcurretHashMap在并发上的优势与LinkedHashMap在实现LRU算法上的优势。创建View的代码为:

	protected View createView(String viewName, Locale locale) throws Exception {
		//loadView是一个模版方法
		return loadView(viewName, locale);
}

可以看出AbstractCachingViewResolver的resolveViewName的做法概述为就是从缓存中获取,获取不到就创建视图,而创建视图的方式就交给了子类去实现,所以其子类的入口就是loadView方法了。

ResourceBundleViewResolver与XmlViewResolver

这两个类的loadView方法是一样的,

	protected View loadView(String viewName, Locale locale) throws Exception {
		BeanFactory factory = initFactory(locale);
		try {
			return factory.getBean(viewName, View.class);
		}
		catch (NoSuchBeanDefinitionException ex) {
			// Allow for ViewResolver chaining...
			return null;
		}
	}

可以看到这两个类都是先建立一个BeanFactory然后从这个factory里面去获取View的,他们的不同就在与这个BeanFactory的创建过程了,一个是读取properties文件,一个是读取XMl文件。
实现,所以其子类的入口就是loadView方法了。

UrlBasedViewResolver

UrlBasedViewResolver不仅重写了模版方法loadView,它还重写了父类的getCacheKey和createView方法。
父类的getCacheKey返回的是viewName+"_"+locale,而UrlBasedViewResolver重写后getCacheKey则直接返回viewName,因此可以知道UrlBasedViewResolver是不支持Locale的。
而它的createView方法是:

	protected View createView(String viewName, Locale locale) throws Exception {
		// If this resolver is not supposed to handle the given view,
		// return null to pass on to the next resolver in the chain.
		//判断是否能够解析当前传入的viewName
		//通过看viewName是否和属性viewNames里的某项匹配,如果viewNames没有配置,则说明支持所有的viewName
		if (!canHandle(viewName, locale)) {
			return null;
		}

		// Check for special "redirect:" prefix.
		//判断是不是以“redirect”开头
		if (viewName.startsWith(REDIRECT_URL_PREFIX)) {
			String redirectUrl = viewName.substring(REDIRECT_URL_PREFIX.length());
			RedirectView view = new RedirectView(redirectUrl,
					isRedirectContextRelative(), isRedirectHttp10Compatible());
			String[] hosts = getRedirectHosts();
			if (hosts != null) {
				view.setHosts(hosts);
			}
			return applyLifecycleMethods(REDIRECT_URL_PREFIX, view);
		}

		// Check for special "forward:" prefix.
		//判断是否是以“forward”开头
		if (viewName.startsWith(FORWARD_URL_PREFIX)) {
			String forwardUrl = viewName.substring(FORWARD_URL_PREFIX.length());
			return new InternalResourceView(forwardUrl);
		}

		// Else fall back to superclass implementation: calling loadView.
		//普通的逻辑视图名调用父类的createView方法,实际就是调用loadView方法
		return super.createView(viewName, locale);
	}

UrlBasedViewResolver的createView做的就是首先判断是否可以解析传入的逻辑视图,不可以的话返回null交由别的ViewResolver处理,可以的话就再检查逻辑视图名是不是以direct或者forward开头,如果是的话则返回相应的视图。如果不是的话就通过父类的createView去调用loadView方法。而loadView方法为:

	protected View loadView(String viewName, Locale locale) throws Exception {
		AbstractUrlBasedView view = buildView(viewName);
		View result = applyLifecycleMethods(viewName, view);
		return (view.checkResource(locale) ? result : null);
	}

UrlBasedViewResolver的loadView方法就是用viewName当参数调用buildView创建了一个类型为AbstractUrlBasedView的View,然后对这个View进行了初始化操作,然后看这个View的url对于的模版是否存在,如果存在就将初始化的视图返回,否则返回null交给下一个ViewResolver来处理。

	protected AbstractUrlBasedView buildView(String viewName) throws Exception {
		//viewClass是UrlBasedViewResolver的一个属性 private Class<?> viewClass;
		Class<?> viewClass = getViewClass();
		Assert.state(viewClass != null, "No view class");
		//通过反射的形式创建出一个View,类型为AbstractUrlBasedView的子类
		AbstractUrlBasedView view = (AbstractUrlBasedView) BeanUtils.instantiateClass(viewClass);
		//可以看到view的url被设置为前缀+name+后缀
		view.setUrl(getPrefix() + viewName + getSuffix());

		//接下来就是给view设置一系列的属性
		String contentType = getContentType();
		if (contentType != null) {
			view.setContentType(contentType);
		}

		view.setRequestContextAttribute(getRequestContextAttribute());
		view.setAttributesMap(getAttributesMap());

		Boolean exposePathVariables = getExposePathVariables();
		if (exposePathVariables != null) {
			view.setExposePathVariables(exposePathVariables);
		}
		Boolean exposeContextBeansAsAttributes = getExposeContextBeansAsAttributes();
		if (exposeContextBeansAsAttributes != null) {
			view.setExposeContextBeansAsAttributes(exposeContextBeansAsAttributes);
		}
		String[] exposedContextBeanNames = getExposedContextBeanNames();
		if (exposedContextBeanNames != null) {
			view.setExposedContextBeanNames(exposedContextBeanNames);
		}

		return view;
	}

可以看到其创建过程就是根据属性viewClass利用反射的方式创建一个View,这个view是AbstarctUrlBasedView的子类,然后设置了view的url及其他属性。AbstractUrlBasedView的继承结构为:

可以看出UrlBasedViewResolver的功能已经很完善了,唯一需要做的就是配置其viewClasses属性。因此可以直接在容器中配置一个UrlBasedViewResolver,然后给他的viewClass配置为一个AbstractUrlBasedView的子类就可以使用了。但是实际一般也是直接使用UrlBasedViewResolver其子类,比如InternalResourceViewResolver,FreeMarkerViewResolver等。在此我们就看看用的最多的
InternalResourceViewResolver。刚才有说到UrlBasedViewResolver的关键在于配置其ViewClass属性,这是调用方法setViewClass来完成的:

	public void setViewClass(@Nullable Class<?> viewClass) {
		if (viewClass != null && !requiredViewClass().isAssignableFrom(viewClass)) {
			throw new IllegalArgumentException("Given view class [" + viewClass.getName() +
					"] is not of type [" + requiredViewClass().getName() + "]");
		}
		this.viewClass = viewClass;
	}

很简单的一个Setter方法,只不过还调用了哈requiredViewClass().isAssignableFrom(viewClass)方法。而在InternalResourceViewResolver中是通过其构造函数调用的这个方法的:

	public InternalResourceViewResolver() {
		Class<?> viewClass = requiredViewClass();
		if (InternalResourceView.class == viewClass && jstlPresent) {
			viewClass = JstlView.class;
		}
		setViewClass(viewClass);
	}

	protected Class<?> requiredViewClass() {
		return InternalResourceView.class;
	}

	protected AbstractUrlBasedView buildView(String viewName) throws Exception {
		InternalResourceView view = (InternalResourceView) super.buildView(viewName);
		if (this.alwaysInclude != null) {
			view.setAlwaysInclude(this.alwaysInclude);
		}
		view.setPreventDispatchLoop(true);
		return view;
	}

在这把InternalResourceViewResolver相关的代码贴出,可以看到其主要完成的工作是 1.重写requiredViewClass来将类型限制为自身能够解析的类型 2.通过setViewClass来将自身支持的view的类型设置到viewClass属性中,之后会根据这个类类型通过反射的方式生成一个view 3.在buildView中调用父类的buildView后再对view设置一些特有的属性。
基本所有UrlBasedViewResolver的子类都是这么一个过程,就不再展开了。

6. 总结

在这一篇笔记中记录了ViewResolver在DispatcherServlet中的初始化过程和调用流程,然后再细致的分析了ViewResolver类族。ViewResolver的作用就是根据逻辑视图找到视图,所谓的逻辑视图其实就是一个String类型的值,它代表的是视图的名字,也就是viewName,而这个viewName就是通过mv.getViewName()获得的,这里的mv就是HandlerAdapter执行handle函数后返回的处理结果。根据viewName查找view可以将viewName当做url去查找view,这是通过UrlBasedViewResolver去实现的,也可以将viewName当做beanName在容器中查找view,这是通过BeanNameViewResolver实现的。而ResourceBundleViewResolve和XmlViewResolver通过properties文件或xml文件来将viewName解析为view。而ContentNegotiatingViewResolver和ViewResolverComposite则将视图解析的任务交给其自身的属性viewResolvers来完成。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值