从Spring源码探究SpringMVC运行流程

2022-12-02 10:55:01 浏览数 (1)

随着不断地使用Spring,以及后续的Boot、cloud,不断的体会到这个拯救Java的生态体系的强大,也使我对于这个框架有了极大的好奇心,以至于产生了我为什么不能写一个这样的框架的思考。 通过自学及参考谭勇德(Tom)老师的《Spring 5核心原理于30个类手写实战》这本书,记录此系列博客 。 从Spring源码探究IOC初始化流程 从Spring源码探究DI属性注入流程 从Spring源码探究AOP代码织入的过程 愿每个想探究Spring原理的人,学习道路一帆风顺

Spring MVC九大组件

  1. HandlerMappings HandlerMapping是用来查找Handler的,也就是处理器,具体的表现形式可以是类也可以是方法。比如,标注了@RequestMapping 的每个method都可以看成是一个Handler,由Handler来负责实际的请求处理.HandlerMapping 在请求到达之后,它的作用便是找到请求相应的处理器Handler和Interceptors。
  2. HandlerAdapters 从名字上看,这是一个适配器。因为Spring MVC中Handler可以是任意形式的,只要能够处理请求便行,但是把请求交给Servlet的时候,由于Servlet的方法结构都是如doService(HttpServletRequest req, HttpServletResponse resp)这样的形式,让固定的Servlet处理方法调用Handler来进行处理,这一步工作便是 HandlerAdapter要做的事。
  3. HandlerExceptionResolvers 从这个组件的名字上看,这个就是用来处理Handler过程中产生的异常情况的组件。具体来说,此组件的作用是根据异常设置ModelAndView,之后再交给render方法进行渲染,而render便将ModelAndView渲染成页面。不过有一点,HandlerExceptionResolver 只是用于解析对请求做处理阶段产生的异常而渲染阶段的异常则不归他管了,这也是Spring MVC组件设计的一大原则分工明确互不干涉。
  4. ViewResolvers 视图解析器,相信大家对这个应该都很熟悉了。因为通常在SpringMVC的配置文件中,都会配上一个该接口的实现类来进行视图的解析。这个组件的主要作用,便是将String类型的视图名和Locale解析为View类型的视图。这个接口只有一个resolveViewName()方法。从方法的定义就可以看出,Controller层返回的String类型的视图名viewName ,最终会在这里被解析成为View.View是用来渲染页面的,也就是说,它会将程序返回的参数和数据填入模板中,最终生成html文件。ViewResolver在这个过程中,主要做两件大事,即,ViewResolver 会找到渲染所用的模板(使用什么模板来渲染?)和所用的技术(其实也就是视图的类型,如JSP啊还是其他什么Blabla的)填入参数。默认情况下,Spring MVC会为我们自动配置一个InternalResourceViewResolver,这个是针对JSP类型视图的。
  5. RequestToViewNameTranslator 这个组件的作用,在于从Request中获取viewName.因为 ViewResolver是根据ViewName查找View,但有的 Handler处理完成之后,没有设置View也没有设置ViewName,便要通过这个组件来从Request中查找viewName。
  6. LocaleResolver 在上面我们有看到ViewResolver的 resolveViewName()方法,需要两个参数。那么第二个参数Locale是从哪来的呢,这就是LocaleResolver要做的事了。LocaleResolver用于从request 中解析出Locale,在中国大陆地区,Locale当然就会是zh-CN之类,用来表示一个区域。这个类也是i18n的基础。
  7. ThemeResolver 从名字便可看出,这个类是用来解析主题的。主题,就是样式,图片以及它们所形成的显示效果的集合。Spring MVC中一套主题对应一个properties文件,里面存放着跟当前主题相关的所有资源,如图片,css样式等。创建主题非常简单,只需准备好资源,然后新建一个“主题名.properties”并将资源设置进去,放在classpath下,便可以在页面中使用了。Spring MVC中跟主题有关的类有ThemeResolver, ThemeSource和Theme。ThemeResolver负责从request中解析出主题名,ThemeSource则根据主题名找到具体的主题,其抽象也就是 Theme,通过Theme来获取主题和具体的资源。
  8. MultipartResolver 其实这是一个大家很熟悉的组件,MultipartResolver用于处理上传请求,通过将普通的Request包装成MultipartHttpServletRequest来实现。MultipartHttpServletRequest可以通过getFile(直接获得文件,如果是多个文件上传,还可以通过调用getFileMap得到Map<FileName, File>这样的结构。MultipartResolver的作用就是用来封装普通的request,使其拥有处理文件上传的功能。
  9. FlashMapManager 说到FlashMapManager,就得先提一下FlashMap。 FlashMap用于重定向Redirect时的参数数据传递,比如,在处理用户订单提交时,为了避免重复提交,可以处理完post请求后redirect到一个get请求,这个get请求可以用来显示订单详情之类的信息。这样做虽然可以规避用户刷新重新提交表单的问题,但是在这个页面上要显示订单的信息,那这些数据从哪里去获取呢,因为redirect重定向是没有传递参数这一功能的,如果不想把参数写进url(其实也不推荐这么做,url有长度限制不说,把参数都直接暴露,感觉也不安全),那么就可以通过flashMap来传递。只需要在redirect 之前,将要传递的数据写入request( 可以通过ServletRequestAttributes.getRequest()获得)的属性oUTPUT_FLASH_MAP_ATTRIBUTE中,这样在redirect之后的handler 中 spring 就会自动将其设置到 Model中,在显示订单信息的页面上,就可以直接从 Model中取得数据了。而FlashMapManager就是用来管理FlashMap的。

其实SpringMVC相比较之前分析的IOC、DI、AOP来说,源码是远远不如他们复杂,我们把流程简单归为两步:

初始化和调用

1初始化

我们还是首先找到DispatcherServlet这个类,必然是寻找init()方法。然后,我们发现其init方法其实在父类HttpServletBean中,其源码如下︰

代码语言:javascript复制
	@Override
	public final void init() throws ServletException {
		if (logger.isDebugEnabled()) {
			logger.debug("Initializing servlet '"   getServletName()   "'");
		}

		// Set bean properties from init parameters.
		PropertyValues pvs = new ServletConfigPropertyValues(getServletConfig(), this.requiredProperties);
		if (!pvs.isEmpty()) {
			try {
				//定位资源
				BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(this);
				//加载配置信息
				ResourceLoader resourceLoader = new ServletContextResourceLoader(getServletContext());
				bw.registerCustomEditor(Resource.class, new ResourceEditor(resourceLoader, getEnvironment()));
				initBeanWrapper(bw);
				bw.setPropertyValues(pvs, true);
			}
			catch (BeansException ex) {
				if (logger.isErrorEnabled()) {
					logger.error("Failed to set bean properties on servlet '"   getServletName()   "'", ex);
				}
				throw ex;
			}
		}

		// Let subclasses do whatever initialization they like.
		initServletBean();

		if (logger.isDebugEnabled()) {
			logger.debug("Servlet '"   getServletName()   "' configured successfully");
		}
	}

我们看到在这段代码中,又调用了一个重要的initServletBean()方法。进入initServletBean()方法看到以下源码∶

代码语言:javascript复制
	@Override
	protected final void initServletBean() throws ServletException {
		getServletContext().log("Initializing Spring FrameworkServlet '"   getServletName()   "'");
		if (this.logger.isInfoEnabled()) {
			this.logger.info("FrameworkServlet '"   getServletName()   "': initialization started");
		}
		long startTime = System.currentTimeMillis();

		try {

			this.webApplicationContext = initWebApplicationContext();
			initFrameworkServlet();
		}
		catch (ServletException ex) {
			this.logger.error("Context initialization failed", ex);
			throw ex;
		}
		catch (RuntimeException ex) {
			this.logger.error("Context initialization failed", ex);
			throw ex;
		}

		if (this.logger.isInfoEnabled()) {
			long elapsedTime = System.currentTimeMillis() - startTime;
			this.logger.info("FrameworkServlet '"   getServletName()   "': initialization completed in "  
					elapsedTime   " ms");
		}
	}

这段代码中最主要的逻辑就是初始化IOC容器,最终会调用refresh()方法,前面的章节中对IOC容器的初始化细节我们已经详细掌握(没掌握的赶紧回去补课,上面有文章链接),在此我们不再赘述。我们看到上面的代码中,IOC容器初始化之后,最后又调用了onRefresh()方法。这个方法最终是在DisptcherServlet 中实现,来看源码︰

代码语言:javascript复制
//在FrameworkServlet类中initWebApplicationContext()方法末尾
		//触发onRefresh方法
		if (!this.refreshEventReceived) {
			// Either the context is not a ConfigurableApplicationContext with refresh
			// support or the context injected at construction time had already been
			// refreshed -> trigger initial onRefresh manually here.
			onRefresh(wac);
		}
	@Override
	protected void onRefresh(ApplicationContext context) {
		initStrategies(context);
	}
	//初始化策略
	protected void initStrategies(ApplicationContext context) {
		//多文件上传的组件
		initMultipartResolver(context);
		//初始化本地语言环境
		initLocaleResolver(context);
		//初始化模板处理器
		initThemeResolver(context);
		//handlerMapping
		initHandlerMappings(context);
		//初始化参数适配器
		initHandlerAdapters(context);
		//初始化异常拦截器
		initHandlerExceptionResolvers(context);
		//初始化视图预处理器
		initRequestToViewNameTranslator(context);
		//初始化视图转换器
		initViewResolvers(context);
		//FlashMap管理器
		initFlashMapManager(context);
	}

到这一步就完成了Spring MVC的九大组件的初始化。接下来 我们来看url和Controller的关系是如﹑何建立的呢?HandlerMapping的 子类AbstractDetectingUrlHandlerMapping实现了initApplicationContext()方法,所以我们直接看子类中的初始化容器方法。

代码语言:javascript复制
	@Override
	public void initApplicationContext() throws ApplicationContextException {
		super.initApplicationContext();
		detectHandlers();
	}
	/**
	 * 建立当前ApplicationContext中的所有controller和url的对应关系
	 */
	protected void detectHandlers() throws BeansException {
		ApplicationContext applicationContext = obtainApplicationContext();
		if (logger.isDebugEnabled()) {
			logger.debug("Looking for URL mappings in application context: "   applicationContext);
		}
		// 获取ApplicationContext容器中所有bean的Name
		String[] beanNames = (this.detectHandlersInAncestorContexts ?
				BeanFactoryUtils.beanNamesForTypeIncludingAncestors(applicationContext, Object.class) :
				applicationContext.getBeanNamesForType(Object.class));

		// Take any bean name that we can determine URLs for.
		// 遍历beanNames,并找到这些bean对应的url
		for (String beanName : beanNames) {
			// 找bean上的所有url(controller上的url 方法上的url),该方法由对应的子类实现
			String[] urls = determineUrlsForHandler(beanName);
			if (!ObjectUtils.isEmpty(urls)) {
				// URL paths found: Let's consider it a handler.
				// 保存urls和beanName的对应关系,put it to Map<urls,beanName>,该方法在父类AbstractUrlHandlerMapping中实现
				registerHandler(urls, beanName);
			}
			else {
				if (logger.isDebugEnabled()) {
					logger.debug("Rejected bean name '"   beanName   "': no URL paths identified");
				}
			}
		}
	}
	/** 获取controller中所有方法的url,由子类实现,典型的模板模式 **/
	protected abstract String[] determineUrlsForHandler(String beanName);

determineUrlsForHandler(String beanName)方法的作用是获取每个Controller中的url,不同的子类有不同的实现,这是一个典型的模板设计模式。因为开发中我们用的最多的就是用注解来配置Controller中的url,BeanNameUrlHandlerMapping是AbstractDetectingUrlHandlerMapping的子类,处理注解形式的url映射.所以我们这里以BeanNameUrlHandlerMapping来进行分析。我们﹐看BeanNameUrlHandlerMapping是如何查beanName 上所有映射的url。

代码语言:javascript复制
	//获取Controller中的所有URL
	@Override
	protected String[] determineUrlsForHandler(String beanName) {
		List<String> urls = new ArrayList<>();
		if (beanName.startsWith("/")) {
			urls.add(beanName);
		}
		String[] aliases = obtainApplicationContext().getAliases(beanName);
		for (String alias : aliases) {
			if (alias.startsWith("/")) {
				urls.add(alias);
			}
		}
		return StringUtils.toStringArray(urls);
	}

到这里HandlerMapping 组件就已经建立所有url和Controller的对应关系。

运行调用

这一步步是由请求触发的,所以入口为 DispatcherServlet的核心方法为doService() ,doService()中的核心逻辑由doDispatch()实现,源代码如下:

代码语言:javascript复制
	/** 中央控制器,控制请求的转发 **/
	protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
		HttpServletRequest processedRequest = request;
		HandlerExecutionChain mappedHandler = null;
		boolean multipartRequestParsed = false;

		WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

		try {
			ModelAndView mv = null;
			Exception dispatchException = null;

			try {
				// 1.检查是否是文件上传的请求
				processedRequest = checkMultipart(request);
				multipartRequestParsed = (processedRequest != request);

				// Determine handler for the current request.
				// 2.取得处理当前请求的controller,这里也称为hanlder,处理器,
				// 	 第一个步骤的意义就在这里体现了.这里并不是直接返回controller,
				//	 而是返回的HandlerExecutionChain请求处理器链对象,
				//	 该对象封装了handler和interceptors.
				mappedHandler = getHandler(processedRequest);
				// 如果handler为空,则返回404
				if (mappedHandler == null) {
					noHandlerFound(processedRequest, response);
					return;
				}

				// Determine handler adapter for the current request.
				//3. 获取处理request的处理器适配器handler adapter
				HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

				// Process last-modified header, if supported by the handler.
				// 处理 last-modified 请求头
				String method = request.getMethod();
				boolean isGet = "GET".equals(method);
				if (isGet || "HEAD".equals(method)) {
					long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
					if (logger.isDebugEnabled()) {
						logger.debug("Last-Modified value for ["   getRequestUri(request)   "] is: "   lastModified);
					}
					if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) {
						return;
					}
				}

				if (!mappedHandler.applyPreHandle(processedRequest, response)) {
					return;
				}

				// Actually invoke the handler.
				// 4.实际的处理器处理请求,返回结果视图对象
				mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

				if (asyncManager.isConcurrentHandlingStarted()) {
					return;
				}

				// 结果视图对象的处理
				applyDefaultViewName(processedRequest, mv);
				mappedHandler.applyPostHandle(processedRequest, response, mv);
			}
			catch (Exception ex) {
				dispatchException = ex;
			}
			catch (Throwable err) {
				// As of 4.3, we're processing Errors thrown from handler methods as well,
				// making them available for @ExceptionHandler methods and other scenarios.
				dispatchException = new NestedServletException("Handler dispatch failed", err);
			}
			processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
		}
		catch (Exception ex) {
			triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
		}
		catch (Throwable err) {
			triggerAfterCompletion(processedRequest, response, mappedHandler,
					new NestedServletException("Handler processing failed", err));
		}
		finally {
			if (asyncManager.isConcurrentHandlingStarted()) {
				// Instead of postHandle and afterCompletion
				if (mappedHandler != null) {
					// 请求成功响应之后的方法
					mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
				}
			}
			else {
				// Clean up any resources used by a multipart request.
				if (multipartRequestParsed) {
					cleanupMultipart(processedRequest);
				}
			}
		}
	}

getHandler(processedRequest)方法实际上就是从HandlerMapping中找到url和Controller的对应关系。也就是Map<url,Controller>。我们知道,最终处理Request的是Controller中的方法,我们现在只是知道了Controller,我们如何确认Controller中处理Request的方法呢?继续往下看。

从Map<urls,beanName>中取得Controller后,经过拦截器的预处理方法,再通过反射获取该方法上的注解和参数,解析方法和参数上的注解,然后反射调用方法获取ModelAndView结果视图。最后,调用的就是RequestMappingHandlerAdapter的handle()中的核心逻辑由handleInternal(request, response, handler)实现。

代码语言:javascript复制
	@Override            
	protected ModelAndView handleInternal(HttpServletRequest request,
			HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {

		ModelAndView mav;
		checkRequest(request);

		// Execute invokeHandlerMethod in synchronized block if required.
		if (this.synchronizeOnSession) {
			HttpSession session = request.getSession(false);
			if (session != null) {
				Object mutex = WebUtils.getSessionMutex(session);
				synchronized (mutex) {
					mav = invokeHandlerMethod(request, response, handlerMethod);
				}
			}
			else {
				// No HttpSession available -> no mutex necessary
				mav = invokeHandlerMethod(request, response, handlerMethod);
			}
		}
		else {
			// No synchronization on session demanded at all...
			mav = invokeHandlerMethod(request, response, handlerMethod);
		}

		if (!response.containsHeader(HEADER_CACHE_CONTROL)) {
			if (getSessionAttributesHandler(handlerMethod).hasSessionAttributes()) {
				applyCacheSeconds(response, this.cacheSecondsForSessionAttributeHandlers);
			}
			else {
				prepareResponse(response);
			}
		}

		return mav;
	}

整个处理过程中最核心的逻辑其实就是拼接Controller的url和方法的url,与Request的url进行匹配,找到匹配的方法。

代码语言:javascript复制
	@Override
	protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
		String lookupPath = getUrlPathHelper().getLookupPathForRequest(request);
		if (logger.isDebugEnabled()) {
			logger.debug("Looking up handler method for path "   lookupPath);
		}
		this.mappingRegistry.acquireReadLock();
		try {
			//遍历controller上的所有方法,获取url匹配的方法
			HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
			if (logger.isDebugEnabled()) {
				if (handlerMethod != null) {
					logger.debug("Returning handler method ["   handlerMethod   "]");
				}
				else {
					logger.debug("Did not find handler method for ["   lookupPath   "]");
				}
			}
			return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
		}
		finally {
			this.mappingRegistry.releaseReadLock();
		}
	}

通过上面的代码分析,已经可以找到处理Request的Controller 中的方法了,现在看如何解析该方法上的参数,并反射调用该方法。

代码语言:javascript复制
	/** 获取处理请求的方法,执行并返回结果视图 **/
	@Nullable
	protected ModelAndView invokeHandlerMethod(HttpServletRequest request,
			HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {

		ServletWebRequest webRequest = new ServletWebRequest(request, response);
		try {
			WebDataBinderFactory binderFactory = getDataBinderFactory(handlerMethod);
			ModelFactory modelFactory = getModelFactory(handlerMethod, binderFactory);

			ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
			if (this.argumentResolvers != null) {
				invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
			}
			if (this.returnValueHandlers != null) {
				invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
			}
			invocableMethod.setDataBinderFactory(binderFactory);
			invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer);

			ModelAndViewContainer mavContainer = new ModelAndViewContainer();
			mavContainer.addAllAttributes(RequestContextUtils.getInputFlashMap(request));
			modelFactory.initModel(webRequest, mavContainer, invocableMethod);
			mavContainer.setIgnoreDefaultModelOnRedirect(this.ignoreDefaultModelOnRedirect);

			AsyncWebRequest asyncWebRequest = WebAsyncUtils.createAsyncWebRequest(request, response);
			asyncWebRequest.setTimeout(this.asyncRequestTimeout);

			WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
			asyncManager.setTaskExecutor(this.taskExecutor);
			asyncManager.setAsyncWebRequest(asyncWebRequest);
			asyncManager.registerCallableInterceptors(this.callableInterceptors);
			asyncManager.registerDeferredResultInterceptors(this.deferredResultInterceptors);

			if (asyncManager.hasConcurrentResult()) {
				Object result = asyncManager.getConcurrentResult();
				mavContainer = (ModelAndViewContainer) asyncManager.getConcurrentResultContext()[0];
				asyncManager.clearConcurrentResult();
				if (logger.isDebugEnabled()) {
					logger.debug("Found concurrent result value ["   result   "]");
				}
				invocableMethod = invocableMethod.wrapConcurrentResult(result);
			}

			invocableMethod.invokeAndHandle(webRequest, mavContainer);
			if (asyncManager.isConcurrentHandlingStarted()) {
				return null;
			}

			return getModelAndView(mavContainer, modelFactory, webRequest);
		}
		finally {
			webRequest.requestCompleted();
		}
	}

invocableMethod.invokeAndHandle()最终要实现的目的就是︰完成 Request中的参数和方法参数上数据的绑定。Spring MVC中提供两种 Request参数到方法中参数的绑定方式:

1、通过注解进行绑定,@RequestParam。

2、通过参数名称进行绑定。

使用注解进行绑定,我们只要在方法参数前面声明@RequestParam(“name”),就可以将request中参数name的值绑定到方法的该参数上。使用参数名称进行绑定的前提是必须要获取方法中参数的名称,Java反射只提供了获取方法的参数的类型,并没有提供获取参数名称的方法。SpringMVC解决这个问题的方法是用asm框架读取字节码文件,来获取方法的参数名称。asm框架是一个字节码操作框架,关于 asm更多介绍可以参考其官网。个人建议,使用注解来完成参数绑定,这样就可以省去 asm框架的读取字节码的操作。

代码语言:javascript复制
	@Nullable
	public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
			Object... providedArgs) throws Exception {

		Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
		if (logger.isTraceEnabled()) {
			logger.trace("Invoking '"   ClassUtils.getQualifiedMethodName(getMethod(), getBeanType())  
					"' with arguments "   Arrays.toString(args));
		}
		Object returnValue = doInvoke(args);
		if (logger.isTraceEnabled()) {
			logger.trace("Method ["   ClassUtils.getQualifiedMethodName(getMethod(), getBeanType())  
					"] returned ["   returnValue   "]");
		}
		return returnValue;
	}
	private Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
			Object... providedArgs) throws Exception {

		MethodParameter[] parameters = getMethodParameters();
		Object[] args = new Object[parameters.length];
		for (int i = 0; i < parameters.length; i  ) {
			MethodParameter parameter = parameters[i];
			parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
			args[i] = resolveProvidedArgument(parameter, providedArgs);
			if (args[i] != null) {
				continue;
			}
			if (this.argumentResolvers.supportsParameter(parameter)) {
				try {
					args[i] = this.argumentResolvers.resolveArgument(
							parameter, mavContainer, request, this.dataBinderFactory);
					continue;
				}
				catch (Exception ex) {
					if (logger.isDebugEnabled()) {
						logger.debug(getArgumentResolutionErrorMessage("Failed to resolve", i), ex);
					}
					throw ex;
				}
			}
			if (args[i] == null) {
				throw new IllegalStateException("Could not resolve method parameter at index "  
						parameter.getParameterIndex()   " in "   parameter.getExecutable().toGenericString()  
						": "   getArgumentResolutionErrorMessage("No suitable resolver for", i));
			}
		}
		return args;
	}

关于asm框架获取方法参数的部分,这里就不再进行分析了。感兴趣的小伙伴可以继续深入了解这个处理过程。

到这里,方法的参数值列表也获取到了,就可以直接进行方法的调用了。整个请求过程中最复杂的一步就是在这里了。到这里整个请求处理过程的关键步骤都已了解。理解了Spring MVC中的请求处理流程,整个代码还是比较清晰的。最后我们再来梳理一下Spring MVC时序图:

0 人点赞