news 2026/8/15 22:19:30

SpringMVC视图渲染原理深度解析:从DispatcherServlet到模板引擎的完整流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringMVC视图渲染原理深度解析:从DispatcherServlet到模板引擎的完整流程

1. 项目概述:从请求到页面的“最后一公里”

做Web开发,尤其是基于SpringMVC框架,我们每天都在写Controller,返回一个字符串,比如return “user/list”;,然后浏览器就神奇地渲染出了一个页面。这个看似简单的过程,背后是SpringMVC视图渲染机制在默默工作。这就像是快递的“最后一公里”,Controller处理完业务逻辑,生成了数据(模型),而视图渲染就是负责把这个“包裹”(数据)按照指定的“包装”(视图模板)送到用户面前(浏览器)。理解这个过程,不仅能让你在页面出问题时快速定位,更能让你在需要定制化视图行为时游刃有余,比如实现多主题切换、特定格式的数据导出,或者集成非主流的模板引擎。

很多开发者对SpringMVC的理解停留在Controller和RequestMapping的层面,对视图渲染这一块往往“黑盒”使用。但当你遇到视图解析失败、静态资源被拦截、或者想自定义一个视图解析器来适配老项目时,不了解其原理就会寸步难行。今天,我们就来彻底拆解SpringMVC的视图渲染原理,看看一个简单的返回字符串,是如何一步步变成用户看到的HTML的。

2. 核心流程总览:DispatcherServlet的调度艺术

整个SpringMVC请求处理的核心是DispatcherServlet,它是一个前端控制器,负责协调各个组件。视图渲染是请求处理链的最后一个环节。我们可以把整个流程想象成一条精密的流水线:

  1. 请求入口DispatcherServlet接收到HTTP请求。
  2. 处理器映射:通过HandlerMapping找到处理该请求的控制器方法(HandlerMethod)。
  3. 处理器适配HandlerAdapter调用该控制器方法执行业务逻辑。
  4. 返回处理:控制器方法执行完毕,返回一个结果。这个结果可能是String(视图名)、ModelAndViewView对象,甚至是@ResponseBody注解标注的任意对象。
  5. 视图解析(关键阶段):如果返回的不是@ResponseBodyDispatcherServlet会调用ViewResolver(视图解析器)来根据控制器返回的视图名(逻辑视图名)解析得到一个具体的View(视图)对象。
  6. 视图渲染DispatcherServlet调用解析得到的View对象的render()方法,将模型数据(Model)与视图模板结合,生成最终的响应内容(如HTML),并写入HTTP响应流。

其中,第5和第6步就是“视图渲染原理”的核心。DispatcherServlet并不关心具体怎么渲染,它只负责调用标准接口。这种设计完美体现了Spring的“依赖倒置”原则,使得视图技术可以自由替换,无论是JSP、Thymeleaf、FreeMarker还是Velocity。

注意@ResponseBody@RestController注解会触发完全不同的处理路径(消息转换器HttpMessageConverter),视图解析流程会被短路,这一点必须明确区分。

2.1 核心接口与协作关系

理解三个核心接口是掌握原理的基础:

  • ViewResolver:策略接口。职责是根据视图名和Locale解析出View对象。它的核心方法是View resolveViewName(String viewName, Locale locale)。Spring内置了多种实现,如InternalResourceViewResolver(用于JSP)、ThymeleafViewResolver等。
  • View:视图接口。职责是准备数据并执行渲染。它的核心方法是void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)。不同的视图技术对应不同的实现,如JstlViewThymeleafView
  • HandlerExceptionResolver:虽然不直接参与正常渲染,但在处理异常时,它也可以返回一个ModelAndView,从而进入视图渲染流程。这是实现统一异常页面的基础。

DispatcherServlet持有一个ViewResolver列表。在需要解析视图时,它会按顺序遍历这个列表,直到某个ViewResolver返回一个非空的View对象为止。这种链式解析提供了极大的灵活性。

3. 视图解析器链的深度解析

DispatcherServlet中有一个List<ViewResolver> viewResolvers。当控制器方法返回一个视图名后,渲染流程就正式开始了。

3.1 解析流程的详细步骤

  1. 构建ModelAndViewDispatcherServletHandlerAdapter处获得一个ModelAndView容器。这个容器里包含了视图名(或View对象)和模型数据。
  2. 判断是否需要渲染视图:检查ModelAndView对象。如果它为null,或者其view属性为null,或者请求是否已经通过RequestDispatcher进行了转发/包含(通过检查ServletRequest属性"javax.servlet.include.request_uri"),则直接返回,不进行视图渲染。这对应了控制器方法返回void或通过HttpServletResponse直接输出等情况。
  3. 遍历ViewResolver链:如果需要渲染,DispatcherServlet会遍历所有注册的ViewResolver,调用其resolveViewName()方法。
  4. 解析成功:一旦某个ViewResolver返回了一个非空的View对象,遍历立即停止,并使用这个View对象。
  5. 解析失败:如果所有ViewResolver都返回nullDispatcherServlet会抛出一个ServletException,提示找不到视图。

3.2 常用ViewResolver实现剖析

  • InternalResourceViewResolver:这是用于JSP和Servlet容器内静态资源的最经典解析器。

    • 原理:它简单地为视图名加上前缀(prefix)和后缀(suffix)。例如,前缀="/WEB-INF/views/",后缀=".jsp",视图名="home",则解析出的视图路径为"/WEB-INF/views/home.jsp"
    • 内部处理:它返回的通常是InternalResourceView或其子类JstlView。这个View对象在渲染时,并不会立即编译JSP,而是通过RequestDispatcher.forward(request, response)将请求转发到该JSP路径。后续的JSP编译、执行是由Servlet容器(如Tomcat)完成的。这就是为什么JSP中可以使用${}访问模型数据的原因,因为转发(forward)保持了同一个request作用域。
    • 配置示例
      @Bean public ViewResolver internalResourceViewResolver() { InternalResourceViewResolver resolver = new InternalResourceViewResolver(); resolver.setPrefix("/WEB-INF/views/"); resolver.setSuffix(".jsp"); // 启用JSTL支持 resolver.setViewClass(JstlView.class); return resolver; }
  • ThymeleafViewResolver:用于集成Thymeleaf模板引擎。

    • 原理:它持有TemplateEngine的引用。resolveViewName方法会根据视图名和Locale,通过TemplateEngine解析出对应的模板(ITemplateResource),并包装成一个ThymeleafView对象。
    • 渲染ThymeleafView.render()方法会调用TemplateEngine.process(),将模型数据与模板合并,直接生成HTML字符串,然后通过response.getWriter()写入输出流。这是一个纯Java的渲染过程,不依赖Servlet容器的转发。
    • 特点:支持完整的SpringEL表达式,与Spring生态集成极深。
  • ContentNegotiatingViewResolver:这是一个特殊的“代理”解析器,它本身不直接解析,而是委托给其他解析器,并根据客户端请求的Accept头或文件扩展名等因素,选择最合适的视图进行渲染。这是实现同一接口返回JSON或XML(通过@ResponseBody)或HTML(通过视图)的关键组件之一,虽然现在RESTful场景下更多直接使用HttpMessageConverter

3.3 自定义ViewResolver实战

假设我们有一个老旧系统,视图文件存放在数据库里,视图名对应数据库中的一条模板记录。我们可以通过自定义ViewResolver来支持。

@Component public class DatabaseViewResolver implements ViewResolver, Ordered { // 实现Ordered接口控制解析顺序 private int order = Integer.MAX_VALUE; // 默认最低优先级 @Autowired private TemplateService templateService; // 假设这个服务能从数据库获取模板内容 @Override public View resolveViewName(String viewName, Locale locale) throws Exception { // 1. 根据viewName和locale,从数据库查询模板内容 String templateContent = templateService.getTemplate(viewName, locale.toString()); if (templateContent == null) { // 返回null,让链上的下一个ViewResolver尝试 return null; } // 2. 返回一个自定义的View对象 return new DatabaseView(templateContent); } @Override public int getOrder() { return this.order; } public void setOrder(int order) { this.order = order; } // 自定义View实现 private static class DatabaseView implements View { private final String templateContent; public DatabaseView(String templateContent) { this.templateContent = templateContent; } @Override public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception { response.setContentType("text/html;charset=UTF-8"); // 3. 极简的模板渲染:替换占位符。实际中可用真正的模板引擎 String result = templateContent; for (Map.Entry<String, ?> entry : model.entrySet()) { result = result.replace("${" + entry.getKey() + "}", String.valueOf(entry.getValue())); } response.getWriter().write(result); } @Override public String getContentType() { return "text/html;charset=UTF-8"; } } }

实操心得:自定义ViewResolver时,一定要在resolveViewName中做好判断,如果该解析器“不认识”这个视图名,务必返回null,这样才能保证解析器链正常工作。另外,通过实现Ordered接口或使用@Order注解来精确控制它在链中的位置非常重要,通常自定义的、范围特定的解析器应放在通用解析器(如InternalResourceViewResolver)之前。

4. 视图渲染过程的微观实现

DispatcherServlet获得了具体的View对象后,就进入了渲染阶段。核心是调用view.render(model, request, response)。我们以最常用的InternalResourceView(JSP)和ThymeleafView为例,看看里面发生了什么。

4.1 JSP视图的渲染:容器的接力

InternalResourceView的渲染本质是请求转发

// InternalResourceView.render() 方法简化逻辑 public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception { // 1. 将Model中的数据全部暴露到HttpServletRequest的属性中 exposeModelAsRequestAttributes(model, request); // 2. 获取到目标JSP路径(如/WEB-INF/views/home.jsp) String dispatcherPath = prepareForRendering(request, response); // 3. 获取RequestDispatcher并进行转发 RequestDispatcher rd = request.getRequestDispatcher(dispatcherPath); // 4. 如果使用include模式(view.setExposePathVariables(false)),否则默认forward if (useInclude(request, response)) { rd.include(request, response); } else { rd.forward(request, response); } }

关键点

  • exposeModelAsRequestAttributes方法把Model里的所有键值对,都通过request.setAttribute(name, value)设置到了ServletRequest的属性域中。这就是为什么在JSP里可以用request.getAttribute(“key”)或EL表达式${key}访问控制器传过来的数据。
  • 转发(forward)后,当前Servlet的执行就暂停了,控制权交给了容器为那个JSP路径分配的Servlet(通常是JspServlet)。JspServlet负责将JSP文件编译成Servlet类、实例化、执行,生成HTML。
  • 整个渲染过程是阻塞的,直到JSP页面执行完毕,响应才会返回给客户端。

4.2 Thymeleaf视图的渲染:引擎的直接处理

ThymeleafView的渲染则是直接生成字符串并输出

// ThymeleafView.render() 方法简化逻辑 public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response) throws Exception { // 1. 创建WebContext,将Request、Response、Session、ServletContext及Model数据合并 IWebContext context = new WebExchangeContext(request, response, request.getServletContext(), locale); context.setVariables(model); // 合并Model数据 // 2. 获取模板名称(通常由ThymeleafViewResolver根据视图名确定) String templateName = getTemplateName(); // 3. 调用TemplateEngine进行渲染,结果直接写入Response的Writer this.templateEngine.process(templateName, context, response.getWriter()); }

关键点

  • Thymeleaf自己管理了一个Context对象来持有所有数据,包括模型数据和Web上下文(request, session等)。
  • templateEngine.process()方法完成了模板的查找、解析、渲染全过程,生成的是最终的HTML字符串。
  • 渲染结果通过response.getWriter()直接写入输出流,不经过Servlet容器的转发。这意味着Thymeleaf渲染可以更早地控制响应头,但也意味着它无法享受forward的某些特性(如容器级别的错误页面处理)。

4.3 模型数据的合并与暴露

无论哪种视图技术,模型数据的传递都是关键。SpringMVC的Model是一个接口,在处理器执行完毕后,其中包含的数据需要被有效地传递给视图层。

  • Model接口:通常由ExtendedModelMapBindingAwareModelMap实现,它本质上是一个Map的增强,提供了链式调用等方法。
  • 数据合并:控制器方法可以通过@ModelAttribute注解的方法、方法参数中的ModelModelMap类型,以及返回ModelAndView等方式向模型中添加属性。
  • 数据暴露
    • 对于JSP(InternalResourceView),数据被暴露为HttpServletRequest的属性。
    • 对于Thymeleaf、FreeMarker等,数据被放入它们各自的上下文对象(如Thymeleaf的IWebContext)中。
  • 特殊属性:Spring还会自动暴露一些隐含对象到视图,例如:
    • HttpServletRequest/response/session
    • WebRequest/NativeWebRequest
    • @RequestParam注解的参数(如果模型中没有同名属性)
    • Command Object(表单绑定对象)通常以类名的小写开头(如user)作为属性名。

重要提示:模型中的数据如果与Request中已有的属性同名,默认会被覆盖。理解数据暴露的层次和顺序,对于调试视图数据显示问题至关重要。

5. 高级主题与性能调优

理解了基本流程,我们再看几个深入且实用的主题。

5.1 静态资源处理与视图解析的冲突

这是新手常踩的坑。配置了<mvc:default-servlet-handler/>WebMvcConfigurer.addResourceHandlers后,访问静态资源(如/css/style.css)有时会报404,错误日志显示被DispatcherServlet处理了,并且InternalResourceViewResolver尝试把“css/style.css”当作视图名去解析,自然找不到对应的JSP。

根源DispatcherServlet的URL映射通常是/,它会拦截所有请求。虽然DefaultServletHttpRequestHandlerResourceHttpRequestHandler会处理静态资源,但这是在DispatcherServlet内部,经过处理器映射查找之后。如果HandlerMapping(如RequestMappingHandlerMapping)没有找到处理器,才会走默认Servlet或资源处理器。但InternalResourceViewResolver在某些配置下(特别是alwaysUseFullPath等属性)可能会干扰这个判断。

解决方案

  1. 推荐:明确区分API和资源路径。将DispatcherServlet映射到/app/*,静态资源放在/resources/*,并通过addResourceHandlers明确配置。
  2. 确保配置顺序:在XML配置中,确保<mvc:default-servlet-handler/>的声明在<mvc:annotation-driven/>之前。
  3. 检查ViewResolver配置:避免为InternalResourceViewResolver设置setOrder(Ordered.HIGHEST_PRECEDENCE),让它保持较低的优先级。
  4. 使用ResourceUrlProvider(与Thymeleaf等集成时):让视图模板中生成的资源URL自动添加版本号,并确保能被正确解析。

5.2 视图解析策略与缓存

视图解析和渲染可能是性能瓶颈,尤其是JSP(需要编译)和复杂的模板引擎。

  • JSP编译缓存:这是Servlet容器(如Tomcat)级别的。Tomcat会将编译后的JSP Servlet类缓存起来。生产环境应确保预编译JSP(jsp-precompiled)或确保第一次访问后的缓存生效。
  • Thymeleaf/FreeMarker模板缓存这是最重要的性能优化点。在开发环境,需要关闭缓存以便实时看到修改。
    # application.properties (Spring Boot) spring.thymeleaf.cache=false # 开发环境关闭 spring.freemarker.cache=false
    在生产环境,务必开启缓存,否则每次请求都会重新解析模板,CPU和IO压力巨大。
    spring.thymeleaf.cache=true # 生产环境开启 spring.thymeleaf.cache.ttlm=3600 # 可以设置TTL
  • ViewResolver缓存:一些ViewResolver实现(如ResourceBundleViewResolver)内部有缓存机制。InternalResourceViewResolver解析视图名得到View对象的过程很快,通常不是瓶颈。

5.3 异步请求与视图渲染

SpringMVC支持异步处理(DeferredResult,Callable)。在异步场景下,视图渲染的时机有所不同。

  1. 控制器返回Callable:当Callable返回时,SpringMVC会使用返回的对象(可能是视图名,也可能是@ResponseBody)重新派发一次请求(ASYNC派发),再次走完整的处理流程,包括视图解析和渲染。
  2. 控制器返回DeferredResult:当在其他线程中调用deferredResult.setResult()时,情况类似,也会重新派发请求。

关键点:异步处理下的视图渲染,发生在异步任务完成之后的第二次“模拟”请求中。这意味着RequestDispatcher的forward在异步上下文中仍然是有效的。但开发者需要特别注意,在异步线程中设置的请求属性,在第二次派发时是否仍然可用(通常是通过Request#startAsync得到的AsyncContext来传递数据)。

5.4 国际化与视图解析

ViewResolver.resolveViewName(String viewName, Locale locale)方法的第二个参数就是Locale。这为国际化视图提供了支持。

  • ResourceBundleViewResolver:可以根据Locale加载不同的属性文件,从而解析出不同的视图定义。
  • XmlViewResolver:类似,但使用XML配置。
  • 自定义实现:你可以实现一个根据Locale选择不同模板路径的解析器。例如,视图名“home”,对于Locale.US解析到/WEB-INF/views/en/home.jsp,对于Locale.CHINA解析到/WEB-INF/views/zh/home.jsp
  • 常用模式:更常见的做法是使用一个通用的视图解析器(如InternalResourceViewResolver),但配合Spring的国际化消息源(MessageSource)和JSP标签库或Thymeleaf的#{}表达式,在同一个模板文件中根据Locale显示不同的文本内容,而不是使用完全不同的模板文件。

6. 常见问题排查与调试技巧

在实际开发中,视图渲染相关的问题五花八门,这里总结几个典型场景和排查思路。

6.1 视图解析失败:404或500错误

现象可能原因排查步骤
返回视图名,但报404,控制台无错误ViewResolver未正确配置或未匹配1. 检查ViewResolverBean是否被创建。2. 在DispatcherServlet初始化日志中查看注册的ViewResolver列表。3. 调试DispatcherServlet.render()方法,看viewResolvers列表是否为空,以及遍历解析时每个解析器的返回结果。
报404,控制台有ServletException: Could not resolve view with name ‘xxx’所有ViewResolver都返回null1. 检查视图名拼写。2. 检查InternalResourceViewResolver的前缀/后缀配置。3. 检查自定义ViewResolverOrder,是否优先级过高且错误地返回了null,导致链中断。4. 检查视图文件是否真的存在于配置的路径下。
报500,错误信息包含JasperExceptionJSP文件本身有语法错误或编译错误1. 查看Tomcat日志中的完整堆栈,定位到JSP文件行号。2. 检查JSP中的Java代码、标签库引用、EL表达式。3. 检查JSP依赖的Java类是否在类路径中。
静态资源(CSS/JS)访问变成下载或404视图解析器拦截了静态资源请求参见5.1节。检查HandlerMapping顺序和静态资源处理器配置。

调试技巧:在开发环境中,可以在DispatcherServlet.doDispatch()方法的processDispatchResult()调用处(即调用render()方法前)打一个条件断点,观察mv(ModelAndView)对象中的视图名和模型数据是否正确。也可以在每个ViewResolverresolveViewName方法内打上断点。

6.2 模型数据在视图中无法显示

  • JSP中EL表达式不显示
    • 检查1:确保JSP页面开头有<%@ page isELIgnored=“false” %>,或者web.xml中配置了<el-ignored>false</el-ignored>(Servlet 2.4+默认启用EL)。
    • 检查2:检查属性名是否正确。在控制器中model.addAttribute(“user”, userObj),在JSP中应使用${user}
    • 检查3:使用${requestScope.user}显式指定作用域,看是否能取出,以判断数据是否被放入了request。
  • Thymeleaf中变量无法访问
    • 检查1:确保模板语法正确,如th:text=“${user.name}”
    • 检查2:在控制器中调试,确认model里确实有“user”这个key。
    • 检查3:检查Thymeleaf的Spring集成配置是否正确,TemplateEngine是否使用了SpringTemplateEngine

6.3 性能问题分析与优化

  1. 首次访问极慢:对于JSP,这是编译耗时。考虑在生产环境预编译JSP。对于Thymeleaf/FreeMarker,检查模板缓存是否开启(生产环境必须开启)。
  2. 每次请求都慢:使用性能分析工具(如Arthas的trace命令,或APM工具)跟踪View.render()方法的执行时间。如果时间花在模板引擎的process方法上,几乎可以断定是缓存未开启。如果时间花在IO(如从数据库读取模板),则需要优化模板获取逻辑。
  3. 内存占用高:检查是否缓存了过多的View对象。标准的ViewResolver通常不会缓存大量View实例,它们通常是轻量级且可重用的。但如果自定义的View在渲染时加载了大量数据到内存且未释放,则可能导致内存泄漏。

6.4 自定义视图与内容协商

有时我们需要根据请求动态返回不同的视图类型,比如同一个/report接口,希望.pdf后缀返回PDF,.xls后缀返回Excel。这通常通过内容协商实现,有两种主流方式:

  1. 使用ContentNegotiatingViewResolver:配置多个ViewResolver和对应的View(如PdfView,XlsView),并让ContentNegotiatingViewResolver根据请求的扩展名或Accept头选择最合适的View
  2. 更现代的方式:使用HttpMessageConverter:在RESTful控制器中,使用@RequestMappingproduces属性,或直接让方法返回ResponseEntity,并依靠Spring的HttpMessageConverter(如MappingJackson2HttpMessageConverter用于JSON,需要自定义Converter用于PDF)来转换响应体。这种方式更常用于API接口,而非页面渲染。

选择建议:如果是传统的服务端渲染页面,且格式差异很大(如HTML vs PDF),可以用方案1。如果是前后端分离的API,统一用方案2。

理解SpringMVC的视图渲染原理,就像掌握了Web请求生命周期的完整地图。从控制器返回一个字符串开始,到用户看到最终的页面,中间每一个环节的组件如何协作、数据如何流动,都变得清晰可见。这份理解不仅能帮你高效地解决日常开发中的视图层bug,更能让你在架构设计时,从容地选择或定制适合的视图技术方案。下次当你的页面没有按预期显示时,不妨沿着这条“渲染流水线”从头到尾梳理一遍,相信你很快就能找到问题的关键。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/15 22:18:55

从人肉评测到智能评测:得物自动化评测平台架构与工程实践

1. 从“人肉评测”到“智能评测”&#xff1a;得物自动化评测平台的诞生背景 在电商和内容社区领域&#xff0c;推荐系统的质量直接决定了用户体验和平台的核心竞争力。一个精准、个性化的推荐&#xff0c;能让用户快速找到心仪的商品或内容&#xff0c;从而提升转化率和用户粘…

作者头像 李华
网站建设 2026/8/15 22:18:21

虚拟知识图谱实战:用Ontop实现数据语义集成与逻辑统一访问

1. 从数据孤岛到语义互联&#xff1a;为什么我们需要虚拟知识图谱如果你在数据领域工作过几年&#xff0c;大概率会碰到这样的场景&#xff1a;业务部门提了个需求&#xff0c;需要整合销售、供应链和客户反馈数据&#xff0c;做一个全面的产品分析。你打开数据库&#xff0c;发…

作者头像 李华
网站建设 2026/8/15 22:17:13

线程池如何调优?

回答“线程池调优”这道题&#xff0c;最忌讳直接背诵《Java并发编程实战》里的书本公式&#xff08;比如“CPU密集型 N1N1N1&#xff0c;IO密集型 2N2N2N”&#xff09;。面试官听到这种标准答案通常会扣分&#xff0c;因为真实的生产环境远比公式复杂。 高分回答的底层逻辑是…

作者头像 李华
网站建设 2026/8/15 22:12:35

哲学与编程的认知革命:从维特根斯坦到Python实践

1. 哲学与代码的跨界碰撞&#xff1a;一场正在发生的认知革命当我在深夜调试一段递归算法时&#xff0c;突然意识到&#xff1a;这段代码和黑格尔的辩证法竟有惊人的相似性——正题与反题的冲突在递归调用中不断演进&#xff0c;最终在基线条件达成时实现某种"合题"。…

作者头像 李华
网站建设 2026/8/15 22:09:57

JavaSE 基础语法 - 继承 - ②

接上节 JavaSE 基础语法 - 继承 - ① 的内容。 子类不仅可以拥有父类的成员变量&#xff0c;也可以拥有父类的方法。 那么新的问题来了。 如果&#xff1a;子类自己也有&#xff1a;String name &#xff0c;这时候怎么办&#xff1f;如果子类自己也有&#xff1a;eat()&#x…

作者头像 李华