使用 Spring MVC 构建 Web 应用
1. Spring MVC 请求处理流程
在 Spring MVC 中,一个完整的请求处理流程包含以下步骤:
1. 视图解析器映射 :视图解析器执行逻辑,将逻辑视图名映射到物理视图名。例如, welcome-model-view 会被转换为 /WEB-INF/views/welcome-model-view.jsp 。
2. DispatcherServlet 执行视图 : DispatcherServlet 执行视图,并将模型提供给视图。
3. 视图返回内容 :视图将需要返回的内容返回给 DispatcherServlet 。
4. DispatcherServlet 返回响应 : DispatcherServlet 将响应发送回浏览器。
下面是这个流程的 mermaid 流程图:
graph LR
A[客户端请求] --> B[DispatcherServlet]
B --> C[视图解析器]
C --> D[物理视图]
D --> B
B --> A
2. RequestMapping 详解
2.1 RequestMapping 概述
RequestMapping 用于将 URI 映射到控制器或控制器方法。它可以在类级别和/或方法级别使用,并且可以通过可选的 method 参数将方法映射到特定的请求方法(如 GET、POST 等)。
2.2 RequestMapping 示例
以下是几个 RequestMapping 的示例:
示例 1
@Controller
public class UserController {
@RequestMapping(value = "/show-page")
public String showPage() {
/* Some code */
}
}
在这个示例中, showPage 方法将被映射到 /show-page 的 GET、POST 和任何其他请求类型。
示例 2
@Controller
public class UserController {
@RequestMapping(value = "/show-page", method = RequestMethod.GET)
public String showPage() {
/* Some code */
}
}
在这个示例中, showPage 方法仅映射到 /show-page 的 GET 请求,其他请求方法将抛出“方法不支持异常”。
示例 3
@Controller
@RequestMapping("/user")
public class UserController {
@RequestMapping(value = "/show-page", method = RequestMethod.GET)
public String showPage() {
/* Some code */
}
}
在这个示例中, showPage 方法仅映射到 /user/show-page 的 GET 请求。
3. RequestMapping 方法支持的参数和返回类型
3.1 支持的方法参数
以下是 RequestMapping 方法支持的一些参数类型和注解:
| 参数类型/注解 | 用途 |
| — | — |
| java.util.Map / org.springframework.ui.Model / org.springframework.ui.ModelMap | 作为 MVC 中的模型,用于存储要暴露给视图的值。 |
| 命令或表单对象 | 用于将请求参数绑定到 Bean,并支持验证。 |
| org.springframework.validation.Errors / org.springframework.validation.BindingResult | 命令或表单对象的验证结果(表单对象应是紧接在前的方法参数)。 |
| @PreDestroy | 在任何 Spring Bean 上,可以使用该注解提供一个预销毁方法,在 Bean 从容器中移除之前调用,用于释放 Bean 持有的资源。 |
| @RequestParam | 用于访问特定的 HTTP 请求参数。 |
| @RequestHeader | 用于访问特定的 HTTP 请求头。 |
| @SessionAttribute | 用于访问 HTTP 会话中的属性。 |
| @RequestAttribute | 用于访问特定的 HTTP 请求属性。 |
| @PathVariable | 允许从 URI 模板中访问变量,如 /owner/{ownerId} 。 |
3.2 支持的返回类型
RequestMapping 方法支持多种返回类型,概念上,请求映射方法应回答两个问题:视图是什么?视图需要的模型是什么?但在 Spring MVC 中,视图和模型不必总是显式声明:
- 隐式视图定义 :如果返回类型中未显式定义视图,则会隐式定义。
- 模型自动丰富 :任何模型对象都会根据以下规则自动丰富。
以下是一些重要规则:
- 模型隐式丰富 :如果模型是返回类型的一部分,它会用命令对象(包括命令对象的验证结果)进行丰富,并且带有 @ModelAttribute 注解的方法的结果也会添加到模型中。
- 视图隐式确定 :如果返回类型中没有视图名,则使用 DefaultRequestToViewNameTranslator 确定视图名。默认情况下, DefaultRequestToViewNameTranslator 会从 URI 中移除前导和尾随斜杠以及文件扩展名,例如 display.html 会变成 display 。
以下是 RequestMapping 方法支持的一些返回类型:
| 返回类型 | 说明 |
| — | — |
| ModelAndView | 包含对模型和视图名的引用。 |
| Model | 仅返回模型,视图名使用 DefaultRequestToViewNameTranslator 确定。 |
| Map | 用于暴露模型的简单映射。 |
| View | 隐式定义了模型的视图。 |
| String | 对视图名的引用。 |
4. 视图解析
4.1 视图解析概述
Spring MVC 提供了非常灵活的视图解析功能,支持与 JSP、Freemarker 等集成,并提供了多种视图解析策略:
- XmlViewResolver :基于外部 XML 配置进行视图解析。
- ResourceBundleViewResolver :基于属性文件进行视图解析。
- UrlBasedViewResolver :将逻辑视图名直接映射到 URL。
- ContentNegotiatingViewResolver :根据 Accept 请求头委托给其他视图解析器。
同时,Spring MVC 支持按显式定义的优先级对视图解析器进行链式调用,并支持使用内容协商直接生成 XML、JSON 和 Atom。
4.2 配置 JSP 视图解析器
以下是使用 InternalResourceViewResolver 配置 JSP 视图解析器的常见方法:
<bean id="jspViewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="viewClass" value="org.springframework.web.servlet.view.JstlView"/>
<property name="prefix" value="/WEB-INF/jsp/"/>
<property name="suffix" value=".jsp"/>
</bean>
此外,还可以使用属性文件和 XML 文件进行映射。
4.3 配置 Freemarker 视图解析器
配置 Freemarker 视图解析器通常分为两步:
1. 使用 freemarkerConfig Bean 加载 Freemarker 模板:
<bean id="freemarkerConfig" class="org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer">
<property name="templateLoaderPath" value="/WEB-INF/freemarker/"/>
</bean>
- 配置 Freemarker 视图解析器:
<bean id="freemarkerViewResolver" class="org.springframework.web.servlet.view.freemarker.FreeMarkerViewResolver">
<property name="cache" value="true"/>
<property name="prefix" value=""/>
<property name="suffix" value=".ftl"/>
</bean>
与 JSP 类似,视图解析也可以使用属性文件或 XML 文件进行定义。
5. 处理器映射和拦截器
5.1 处理器映射
在 Spring 2.5 之前,URL 和控制器(也称为处理器)之间的映射是通过处理器映射来实现的。如今,注解的使用消除了显式处理器映射的需求。
5.2 处理器拦截器
HandlerInterceptors 可用于拦截对处理器(或控制器)的请求。创建 HandlerInterceptor 有两个步骤:
1. 定义 HandlerInterceptor :可以重写 HandlerInterceptorAdapter 中的以下方法:
- preHandle :在处理器方法调用之前调用。
- postHandle :在处理器方法调用之后调用。
- afterCompletion :在请求处理完成后调用。
以下是一个实现 HandlerInterceptor 的示例:
public class HandlerTimeLoggingInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
request.setAttribute("startTime", System.currentTimeMillis());
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
request.setAttribute("endTime", System.currentTimeMillis());
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
long startTime = (Long) request.getAttribute("startTime");
long endTime = (Long) request.getAttribute("endTime");
logger.info("Time Spent in Handler in ms : " + (endTime - startTime));
}
}
5.3 映射处理器拦截器
HandlerInterceptors 可以映射到特定的 URL。以下是一个 XML 上下文配置示例:
<mvc:interceptors>
<bean class="com.mastering.spring.springmvc.controller.interceptor.HandlerTimeLoggingInterceptor" />
</mvc:interceptors>
默认情况下,拦截器将拦截所有处理器(控制器)。也可以配置精确的 URI 进行拦截,例如:
<mvc:interceptors>
<mapping path="/**"/>
<exclude-mapping path="/secure/**"/>
<bean class="com.mastering.spring.springmvc.controller.interceptor.HandlerTimeLoggingInterceptor" />
</mvc:interceptors>
在这个示例中,除了 URI 映射以 /secure/ 开头的处理器外,所有处理器都会被拦截。
6. 模型属性
6.1 模型属性概述
常见的 Web 表单包含许多下拉值,如州列表、国家列表等。这些值列表需要在模型中可用,以便视图可以显示它们。通常使用带有 @ModelAttribute 注解的方法将这些常见值填充到模型中。
6.2 模型属性示例
以下是两种不同的示例:
示例 1
@ModelAttribute
public List<State> populateStateList() {
return stateService.findStates();
}
在这个示例中,方法返回需要放入模型的对象。
示例 2
@ModelAttribute
public void populateStateAndCountryList() {
model.addAttribute(stateService.findStates());
model.addAttribute(countryService.findCountries());
}
在这个示例中,方法用于向模型中添加多个属性。
需要注意的是,带有 @ModelAttribute 注解的方法数量没有限制。同时,可以使用 Controller Advice 使模型属性在多个控制器中通用。
7. 会话属性
7.1 会话属性概述
前面讨论的所有属性和值都在单个请求中使用,但有些值(如特定的 Web 用户配置)在不同请求之间可能不会改变,这些值通常存储在 HTTP 会话中。Spring MVC 提供了简单的类级别注解 @SessionAttributes 来指定要存储在会话中的属性。
7.2 会话属性操作
放入会话属性
@Controller
@SessionAttributes("exampleSessionAttribute")
public class LoginController {
// ...
model.put("exampleSessionAttribute", sessionValue);
// ...
}
在这个示例中,当在模型中添加 exampleSessionAttribute 属性时,它会自动存储到会话中。
读取会话属性
@Controller
@SessionAttributes("exampleSessionAttribute")
public class SomeOtherController {
// ...
Value sessionValue = (Value) model.get("exampleSessionAttribute");
// ...
}
在这个示例中,可以从模型中直接访问会话属性的值。
移除会话属性
有两种方法可以从会话中移除属性:
方法一
@RequestMapping(value = "/some-method", method = RequestMethod.GET)
public String someMethod(/*Other Parameters*/ WebRequest request, SessionStatus status) {
status.setComplete();
request.removeAttribute("exampleSessionAttribute", WebRequest.SCOPE_SESSION);
// Other Logic
}
方法二
@RequestMapping(value = "/some-other-method", method = RequestMethod.GET)
public String someOtherMethod(/*Other Parameters*/ SessionAttributeStore store, SessionStatus status) {
status.setComplete();
store.cleanupAttribute(request, "exampleSessionAttribute");
// Other Logic
}
8. InitBinders
8.1 InitBinders 概述
典型的 Web 表单包含日期、货币和金额等数据,表单中的值需要绑定到表单支持对象。可以使用 @InitBinder 注解来定制绑定过程。
8.2 InitBinders 示例
以下是设置表单绑定默认日期格式的示例:
@InitBinder
protected void initBinder(WebDataBinder binder) {
SimpleDateFormat dateFormat = new SimpleDateFormat("dd/MM/yyyy");
binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, false));
}
可以在特定控制器或一组控制器中使用 Handler Advice 进行定制。
9. @ControllerAdvice 注解
9.1 @ControllerAdvice 概述
@ControllerAdvice 可以使在控制器级别定义的某些功能在整个应用中通用。例如,希望在整个应用中使用相同的日期格式,就可以使用 @ControllerAdvice 。
9.2 @ControllerAdvice 示例
@ControllerAdvice
public class DateBindingControllerAdvice {
@InitBinder
protected void initBinder(WebDataBinder binder) {
SimpleDateFormat dateFormat = new SimpleDateFormat("dd/MM/yyyy");
binder.registerCustomEditor(Date.class, new CustomDateEditor(dateFormat, false));
}
}
在这个示例中, @ControllerAdvice 注解的类中的 @InitBinder 方法定义的绑定默认适用于所有请求映射。同时, Controller advice 还可以用于定义通用的模型属性( @ModelAttribute )和通用的异常处理( @ExceptionHandler )。
10. Spring MVC 高级特性
10.1 异常处理
10.1.1 异常处理概述
异常处理是任何应用的关键部分,在应用中拥有一致的异常处理策略非常重要。在 Spring 框架出现之前,由于广泛使用检查异常,应用代码中需要大量的异常处理代码。而 Spring 框架将大多数异常变为非检查异常,使得在不需要特定异常处理时,可以在整个应用中进行通用的异常处理。
10.1.2 通用异常处理
可以使用 Controller advice 实现跨控制器的通用异常处理:
@ControllerAdvice
public class ExceptionController {
private Log logger = LogFactory.getLog(ExceptionController.class);
@ExceptionHandler(value = Exception.class)
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
logger.error("Request " + request.getRequestURL() + " Threw an Exception", ex);
ModelAndView mav = new ModelAndView();
mav.addObject("exception", ex);
mav.addObject("url", request.getRequestURL());
mav.setViewName("common/spring-mvc-error");
return mav;
}
}
在这个示例中, @ControllerAdvice 注解的类中的 @ExceptionHandler 方法会在控制器中抛出指定类型或其子类型的异常时被调用。
10.1.3 错误视图
当发生异常时, ExceptionController 会将用户重定向到 spring-mvc-error 视图,并在模型中填充异常详细信息:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@page isErrorPage="true"%>
<h1>Error Page</h1>
URL: ${url}
<br />
Exception: ${exception.message}
<c:forEach items="${exception.stackTrace}" var="exceptionStackTrace">
${exceptionStackTrace}
</c:forEach>
10.1.4 特定控制器异常处理
在某些情况下,需要在控制器中进行特定的异常处理,可以实现带有 @ExceptionHandler 注解的方法:
@Controller
public class SomeController {
@ExceptionHandler(value = SomeSpecificException.class)
public ModelAndView handleSpecificException(HttpServletRequest request, SomeSpecificException ex) {
// 处理特定异常的逻辑
}
}
10.2 国际化
10.2.1 国际化概述
在开发应用时,希望应用能在多个地区使用,根据用户的位置和语言定制显示给用户的文本,这就是国际化(也称为本地化,i18n)。
10.2.2 实现方式
国际化可以通过两种方式实现:
- SessionLocaleResolver :用户选择的区域设置存储在用户会话中,仅在用户会话期间有效。
- CookieLocaleResolver :用户选择的区域设置存储为 cookie。
通过以上介绍,我们详细了解了使用 Spring MVC 构建 Web 应用的各个方面,包括请求处理流程、 RequestMapping 、视图解析、拦截器、模型属性、会话属性、异常处理和国际化等。这些知识可以帮助我们更好地开发和优化基于 Spring MVC 的 Web 应用。
11. 测试 Spring MVC 应用
11.1 测试的重要性
在开发 Spring MVC 应用时,测试是确保代码质量和功能正确性的关键环节。通过测试,可以及时发现并修复潜在的问题,提高应用的稳定性和可靠性。
11.2 单元测试
单元测试主要针对控制器方法进行,确保每个方法在给定输入时能产生预期的输出。可以使用 JUnit 和 MockMvc 来进行单元测试。以下是一个简单的单元测试示例:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;
@WebMvcTest(YourController.class)
public class YourControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testShowPage() throws Exception {
mockMvc.perform(get("/show-page"))
.andExpect(status().isOk())
.andExpect(view().name("expected-view-name"));
}
}
在这个示例中,我们使用 @WebMvcTest 注解来测试 YourController 类的 showPage 方法。通过 MockMvc 模拟 HTTP 请求,并验证响应状态和视图名称。
11.3 集成测试
集成测试用于测试多个组件之间的交互,确保它们能协同工作。可以使用 Spring Boot 的 @SpringBootTest 注解来进行集成测试。以下是一个简单的集成测试示例:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
public class IntegrationTest {
@Autowired
private WebApplicationContext webApplicationContext;
@Test
public void testIntegration() throws Exception {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
mockMvc.perform(get("/some-url"))
.andExpect(status().isOk());
}
}
在这个示例中,我们使用 @SpringBootTest 注解来加载整个应用上下文,并通过 MockMvc 模拟 HTTP 请求,验证响应状态。
12. 安全与 Spring Security
12.1 Spring Security 概述
Spring Security 是一个强大的安全框架,用于保护 Spring 应用。它提供了身份验证、授权、密码加密等功能,可以帮助我们轻松实现 Web 应用的安全控制。
12.2 配置 Spring Security
以下是一个简单的 Spring Security 配置示例:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
return http.build();
}
}
在这个示例中,我们配置了 Spring Security,允许 /public/** 路径下的请求无需认证即可访问,其他请求需要进行身份验证。同时,我们指定了登录页面为 /login ,并允许用户注销。
12.3 身份验证和授权
Spring Security 支持多种身份验证方式,如表单登录、HTTP 基本认证等。授权可以基于角色或权限进行控制。以下是一个基于角色的授权示例:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
return http.build();
}
}
在这个示例中,我们配置了只有具有 ADMIN 角色的用户才能访问 /admin/** 路径下的资源。
13. 性能优化
13.1 缓存
缓存是提高应用性能的有效方法。Spring MVC 支持多种缓存策略,如内存缓存、分布式缓存等。可以使用 @Cacheable 、 @CachePut 和 @CacheEvict 注解来实现缓存功能。以下是一个简单的缓存示例:
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class YourService {
@Cacheable("yourCache")
public String getData() {
// 模拟耗时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Data";
}
}
在这个示例中, getData 方法的结果会被缓存到名为 yourCache 的缓存中,下次调用该方法时,如果缓存中存在结果,则直接返回缓存中的数据,避免了重复的耗时操作。
13.2 异步处理
Spring MVC 支持异步处理,可以提高应用的并发性能。可以使用 @Async 注解来实现异步方法。以下是一个简单的异步处理示例:
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
@Service
public class YourService {
@Async
public CompletableFuture<String> asyncMethod() {
// 模拟耗时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return CompletableFuture.completedFuture("Async Data");
}
}
在这个示例中, asyncMethod 方法会在一个单独的线程中执行,主线程可以继续处理其他任务,提高了应用的并发性能。
14. 与其他技术集成
14.1 与数据库集成
Spring MVC 可以与多种数据库集成,如 MySQL、Oracle、MongoDB 等。可以使用 Spring Data JPA 或 MyBatis 来简化数据库操作。以下是一个使用 Spring Data JPA 的示例:
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
// 可以定义自定义查询方法
}
在这个示例中,我们定义了一个 UserRepository 接口,继承自 JpaRepository ,可以使用 Spring Data JPA 提供的基本 CRUD 操作。
14.2 与消息队列集成
Spring MVC 可以与消息队列集成,如 RabbitMQ、Kafka 等。可以使用 Spring AMQP 或 Spring Kafka 来实现消息的发送和接收。以下是一个使用 Spring AMQP 发送消息的示例:
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class MessageSender {
@Autowired
private RabbitTemplate rabbitTemplate;
public void sendMessage(String message) {
rabbitTemplate.convertAndSend("yourExchange", "yourRoutingKey", message);
}
}
在这个示例中,我们使用 RabbitTemplate 向指定的交换器和路由键发送消息。
15. 总结
通过本文的介绍,我们全面了解了使用 Spring MVC 构建 Web 应用的各个方面,包括请求处理流程、 RequestMapping 、视图解析、拦截器、模型属性、会话属性、异常处理、国际化、测试、安全、性能优化以及与其他技术的集成等。这些知识可以帮助我们开发出功能强大、稳定可靠、性能优良的 Web 应用。在实际开发中,我们可以根据具体需求选择合适的技术和方法,不断优化和完善应用。
以下是 Spring MVC 应用开发的关键技术点总结表格:
| 技术点 | 描述 |
| — | — |
| RequestMapping | 用于将 URI 映射到控制器或控制器方法 |
| 视图解析 | 支持多种视图解析策略,如 JSP、Freemarker |
| 拦截器 | 用于拦截请求,进行预处理和后处理 |
| 模型属性 | 使用 @ModelAttribute 注解填充模型 |
| 会话属性 | 使用 @SessionAttributes 注解管理会话属性 |
| 异常处理 | 支持通用和特定的异常处理 |
| 国际化 | 可以通过 SessionLocaleResolver 或 CookieLocaleResolver 实现 |
| 测试 | 包括单元测试和集成测试 |
| 安全 | 使用 Spring Security 实现身份验证和授权 |
| 性能优化 | 如缓存和异步处理 |
| 集成 | 可以与数据库、消息队列等集成 |
同时,我们可以用 mermaid 流程图来展示 Spring MVC 应用的整体架构:
graph LR
A[客户端请求] --> B[DispatcherServlet]
B --> C[控制器]
C --> D[模型]
D --> E[视图解析器]
E --> F[视图]
F --> B
B --> A
G[拦截器] --> B
H[Spring Security] --> B
I[缓存] --> C
J[数据库] --> C
K[消息队列] --> C
这个流程图展示了 Spring MVC 应用的主要组件和它们之间的交互关系,包括客户端请求、 DispatcherServlet 、控制器、模型、视图解析器、视图、拦截器、Spring Security、缓存、数据库和消息队列等。通过这些组件的协同工作,我们可以构建出一个完整的 Web 应用。

1351

被折叠的 条评论
为什么被折叠?



