MVC高级开发

通过结合Servlet和JSP的MVC模式,我们可以发挥二者各自的优点:

  • Servlet实现业务逻辑;
  • JSP实现展示逻辑。

但是,直接把MVC搭在Servlet和JSP之上还是不太好,原因如下:

  • Servlet提供的接口仍然偏底层,需要实现Servlet调用相关接口;
  • JSP对页面开发不友好,更好的替代品是模板引擎;
  • 业务逻辑最好由纯粹的Java类实现,而不是强迫继承自Servlet。

能不能通过普通的Java类实现MVC的Controller?类似下面的代码:

  1. public class UserController {
  2. @GetMapping("/signin")
  3. public ModelAndView signin() {
  4. ...
  5. }
  6. @PostMapping("/signin")
  7. public ModelAndView doSignin(SignInBean bean) {
  8. ...
  9. }
  10. @GetMapping("/signout")
  11. public ModelAndView signout(HttpSession session) {
  12. ...
  13. }
  14. }

上面的这个Java类每个方法都对应一个GET或POST请求,方法返回值是ModelAndView,它包含一个View的路径以及一个Model,这样,再由MVC框架处理后返回给浏览器。

如果是GET请求,我们希望MVC框架能直接把URL参数按方法参数对应起来然后传入:

  1. @GetMapping("/hello")
  2. public ModelAndView hello(String name) {
  3. ...
  4. }

如果是POST请求,我们希望MVC框架能直接把Post参数变成一个JavaBean后通过方法参数传入:

  1. @PostMapping("/signin")
  2. public ModelAndView doSignin(SignInBean bean) {
  3. ...
  4. }

为了增加灵活性,如果Controller的方法在处理请求时需要访问HttpServletRequestHttpServletResponseHttpSession这些实例时,只要方法参数有定义,就可以自动传入:

  1. @GetMapping("/signout")
  2. public ModelAndView signout(HttpSession session) {
  3. ...
  4. }

以上就是我们在设计MVC框架时,上层代码所需要的一切信息。

设计MVC框架

如何设计一个MVC框架?在上文中,我们已经定义了上层代码编写Controller的一切接口信息,并且并不要求实现特定接口,只需返回ModelAndView对象,该对象包含一个View和一个Model。实际上View就是模板的路径,而Model可以用一个Map<String, Object>表示,因此,ModelAndView定义非常简单:

  1. public class ModelAndView {
  2. Map<String, Object> model;
  3. String view;
  4. }

比较复杂的是我们需要在MVC框架中创建一个接收所有请求的Servlet,通常我们把它命名为DispatcherServlet,它总是映射到/,然后,根据不同的Controller的方法定义的@Get@Post的Path决定调用哪个方法,最后,获得方法返回的ModelAndView后,渲染模板,写入HttpServletResponse,即完成了整个MVC的处理。

这个MVC的架构如下:

  1. HTTP Request ┌─────────────────┐
  2. ──────────────────>│DispatcherServlet
  3. └─────────────────┘
  4. ┌────────────┼────────────┐
  5. ┌───────────┐┌───────────┐┌───────────┐
  6. Controller1││Controller2││Controller3
  7. └───────────┘└───────────┘└───────────┘
  8. └────────────┼────────────┘
  9. HTTP Response ┌────────────────────┐
  10. <────────────────│render(ModelAndView)│
  11. └────────────────────┘

其中,DispatcherServlet以及如何渲染均由MVC框架实现,在MVC框架之上只需要编写每一个Controller。

我们来看看如何编写最复杂的DispatcherServlet。首先,我们需要存储请求路径到某个具体方法的映射:

  1. @WebServlet(urlPatterns = "/")
  2. public class DispatcherServlet extends HttpServlet {
  3. private Map<String, GetDispatcher> getMappings = new HashMap<>();
  4. private Map<String, PostDispatcher> postMappings = new HashMap<>();
  5. }

处理一个GET请求是通过GetDispatcher对象完成的,它需要如下信息:

  1. class GetDispatcher {
  2. Object instance; // Controller实例
  3. Method method; // Controller方法
  4. String[] parameterNames; // 方法参数名称
  5. Class<?>[] parameterClasses; // 方法参数类型
  6. }

有了以上信息,就可以定义invoke()来处理真正的请求:

  1. class GetDispatcher {
  2. ...
  3. public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) {
  4. Object[] arguments = new Object[parameterClasses.length];
  5. for (int i = 0; i < parameterClasses.length; i++) {
  6. String parameterName = parameterNames[i];
  7. Class<?> parameterClass = parameterClasses[i];
  8. if (parameterClass == HttpServletRequest.class) {
  9. arguments[i] = request;
  10. } else if (parameterClass == HttpServletResponse.class) {
  11. arguments[i] = response;
  12. } else if (parameterClass == HttpSession.class) {
  13. arguments[i] = request.getSession();
  14. } else if (parameterClass == int.class) {
  15. arguments[i] = Integer.valueOf(getOrDefault(request, parameterName, "0"));
  16. } else if (parameterClass == long.class) {
  17. arguments[i] = Long.valueOf(getOrDefault(request, parameterName, "0"));
  18. } else if (parameterClass == boolean.class) {
  19. arguments[i] = Boolean.valueOf(getOrDefault(request, parameterName, "false"));
  20. } else if (parameterClass == String.class) {
  21. arguments[i] = getOrDefault(request, parameterName, "");
  22. } else {
  23. throw new RuntimeException("Missing handler for type: " + parameterClass);
  24. }
  25. }
  26. return (ModelAndView) this.method.invoke(this.instance, arguments);
  27. }
  28. private String getOrDefault(HttpServletRequest request, String name, String defaultValue) {
  29. String s = request.getParameter(name);
  30. return s == null ? defaultValue : s;
  31. }
  32. }

上述代码比较繁琐,但逻辑非常简单,即通过构造某个方法需要的所有参数列表,使用反射调用该方法后返回结果。

类似的,PostDispatcher需要如下信息:

  1. class PostDispatcher {
  2. Object instance; // Controller实例
  3. Method method; // Controller方法
  4. Class<?>[] parameterClasses; // 方法参数类型
  5. ObjectMapper objectMapper; // JSON映射
  6. }

和GET请求不同,POST请求严格地来说不能有URL参数,所有数据都应当从Post Body中读取。这里我们为了简化处理,_只支持_JSON格式的POST请求,这样,把Post数据转化为JavaBean就非常容易。

  1. class PostDispatcher {
  2. ...
  3. public ModelAndView invoke(HttpServletRequest request, HttpServletResponse response) {
  4. Object[] arguments = new Object[parameterClasses.length];
  5. for (int i = 0; i < parameterClasses.length; i++) {
  6. Class<?> parameterClass = parameterClasses[i];
  7. if (parameterClass == HttpServletRequest.class) {
  8. arguments[i] = request;
  9. } else if (parameterClass == HttpServletResponse.class) {
  10. arguments[i] = response;
  11. } else if (parameterClass == HttpSession.class) {
  12. arguments[i] = request.getSession();
  13. } else {
  14. // 读取JSON并解析为JavaBean:
  15. BufferedReader reader = request.getReader();
  16. arguments[i] = this.objectMapper.readValue(reader, parameterClass);
  17. }
  18. }
  19. return (ModelAndView) this.method.invoke(instance, arguments);
  20. }
  21. }

最后,我们来实现整个DispatcherServlet的处理流程,以doGet()为例:

  1. public class DispatcherServlet extends HttpServlet {
  2. ...
  3. @Override
  4. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  5. resp.setContentType("text/html");
  6. resp.setCharacterEncoding("UTF-8");
  7. String path = req.getRequestURI().substring(req.getContextPath().length());
  8. // 根据路径查找GetDispatcher:
  9. GetDispatcher dispatcher = this.getMappings.get(path);
  10. if (dispatcher == null) {
  11. // 未找到返回404:
  12. resp.sendError(404);
  13. return;
  14. }
  15. // 调用Controller方法获得返回值:
  16. ModelAndView mv = dispatcher.invoke(req, resp);
  17. // 允许返回null:
  18. if (mv == null) {
  19. return;
  20. }
  21. // 允许返回`redirect:`开头的view表示重定向:
  22. if (mv.view.startsWith("redirect:")) {
  23. resp.sendRedirect(mv.view.substring(9));
  24. return;
  25. }
  26. // 将模板引擎渲染的内容写入响应:
  27. PrintWriter pw = resp.getWriter();
  28. this.viewEngine.render(mv, pw);
  29. pw.flush();
  30. }
  31. }

这里有几个小改进:

  • 允许Controller方法返回null,表示内部已自行处理完毕;
  • 允许Controller方法返回以redirect:开头的view名称,表示一个重定向。

这样使得上层代码编写更灵活。例如,一个显示用户资料的请求可以这样写:

  1. @GetMapping("/user/profile")
  2. public ModelAndView profile(HttpServletResponse response, HttpSession session) {
  3. User user = (User) session.getAttribute("user");
  4. if (user == null) {
  5. // 未登录,跳转到登录页:
  6. return new ModelAndView("redirect:/signin");
  7. }
  8. if (!user.isManager()) {
  9. // 权限不够,返回403:
  10. response.sendError(403);
  11. return null;
  12. }
  13. return new ModelAndView("/profile.html", Map.of("user", user));
  14. }

最后一步是在DispatcherServletinit()方法中初始化所有Get和Post的映射,以及用于渲染的模板引擎:

  1. public class DispatcherServlet extends HttpServlet {
  2. private Map<String, GetDispatcher> getMappings = new HashMap<>();
  3. private Map<String, PostDispatcher> postMappings = new HashMap<>();
  4. private ViewEngine viewEngine;
  5. @Override
  6. public void init() throws ServletException {
  7. this.getMappings = scanGetInControllers();
  8. this.postMappings = scanPostInControllers();
  9. this.viewEngine = new ViewEngine(getServletContext());
  10. }
  11. ...
  12. }

如何扫描所有Controller以获取所有标记有@GetMapping@PostMapping的方法?当然是使用反射了。虽然代码比较繁琐,但我们相信各位童鞋可以轻松实现。

这样,整个MVC框架就搭建完毕。

实现渲染

有的童鞋对如何使用模板引擎进行渲染有疑问,即如何实现上述的ViewEngine?其实ViewEngine非常简单,只需要实现一个简单的render()方法:

  1. public class ViewEngine {
  2. public void render(ModelAndView mv, Writer writer) throws IOException {
  3. String view = mv.view;
  4. Map<String, Object> model = mv.model;
  5. // 根据view找到模板文件:
  6. Template template = getTemplateByPath(view);
  7. // 渲染并写入Writer:
  8. template.write(writer, model);
  9. }
  10. }

Java有很多开源的模板引擎,常用的有:

他们的用法都大同小异。这里我们推荐一个使用Jinja语法的模板引擎Pebble,它的特点是语法简单,支持模板继承,编写出来的模板类似:

  1. <html>
  2. <body>
  3. <ul>
  4. {% for user in users %}
  5. <li><a href="{{ user.url }}">{{ user.username }}</a></li>
  6. {% endfor %}
  7. </ul>
  8. </body>
  9. </html>

即变量用{{ xxx }}表示,控制语句用{% xxx %}表示。

使用Pebble渲染只需要如下几行代码:

  1. public class ViewEngine {
  2. private final PebbleEngine engine;
  3. public ViewEngine(ServletContext servletContext) {
  4. // 定义一个ServletLoader用于加载模板:
  5. ServletLoader loader = new ServletLoader(servletContext);
  6. // 模板编码:
  7. loader.setCharset("UTF-8");
  8. // 模板前缀,这里默认模板必须放在`/WEB-INF/templates`目录:
  9. loader.setPrefix("/WEB-INF/templates");
  10. // 模板后缀:
  11. loader.setSuffix("");
  12. // 创建Pebble实例:
  13. this.engine = new PebbleEngine.Builder()
  14. .autoEscaping(true) // 默认打开HTML字符转义,防止XSS攻击
  15. .cacheActive(false) // 禁用缓存使得每次修改模板可以立刻看到效果
  16. .loader(loader).build();
  17. }
  18. public void render(ModelAndView mv, Writer writer) throws IOException {
  19. // 查找模板:
  20. PebbleTemplate template = this.engine.getTemplate(mv.view);
  21. // 渲染:
  22. template.evaluate(writer, mv.model);
  23. }
  24. }

最后我们来看看整个工程的结构:

  1. web-mvc
  2. ├── pom.xml
  3. └── src
  4. └── main
  5. ├── java
  6. └── com
  7. └── itranswarp
  8. └── learnjava
  9. ├── Main.java
  10. ├── bean
  11. ├── SignInBean.java
  12. └── User.java
  13. ├── controller
  14. ├── IndexController.java
  15. └── UserController.java
  16. └── framework
  17. ├── DispatcherServlet.java
  18. ├── FileServlet.java
  19. ├── GetMapping.java
  20. ├── ModelAndView.java
  21. ├── PostMapping.java
  22. └── ViewEngine.java
  23. └── webapp
  24. ├── WEB-INF
  25. ├── templates
  26. ├── _base.html
  27. ├── hello.html
  28. ├── index.html
  29. ├── profile.html
  30. └── signin.html
  31. └── web.xml
  32. └── static
  33. ├── css
  34. └── bootstrap.css
  35. └── js
  36. ├── bootstrap.js
  37. └── jquery.js

其中,framework包是MVC的框架,完全可以单独编译后作为一个Maven依赖引入,controller包才是我们需要编写的业务逻辑。

我们还硬性规定模板必须放在webapp/WEB-INF/templates目录下,静态文件必须放在webapp/static目录下,因此,为了便于开发,我们还顺带实现一个FileServlet来处理静态文件:

  1. @WebServlet(urlPatterns = { "/favicon.ico", "/static/*" })
  2. public class FileServlet extends HttpServlet {
  3. protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4. // 读取当前请求路径:
  5. ServletContext ctx = req.getServletContext();
  6. // RequestURI包含ContextPath,需要去掉:
  7. String urlPath = req.getRequestURI().substring(ctx.getContextPath().length());
  8. // 获取真实文件路径:
  9. String filepath = ctx.getRealPath(urlPath);
  10. if (filepath == null) {
  11. // 无法获取到路径:
  12. resp.sendError(HttpServletResponse.SC_NOT_FOUND);
  13. return;
  14. }
  15. Path path = Paths.get(filepath);
  16. if (!path.toFile().isFile()) {
  17. // 文件不存在:
  18. resp.sendError(HttpServletResponse.SC_NOT_FOUND);
  19. return;
  20. }
  21. // 根据文件名猜测Content-Type:
  22. String mime = Files.probeContentType(path);
  23. if (mime == null) {
  24. mime = "application/octet-stream";
  25. }
  26. resp.setContentType(mime);
  27. // 读取文件并写入Response:
  28. OutputStream output = resp.getOutputStream();
  29. try (InputStream input = new BufferedInputStream(new FileInputStream(filepath))) {
  30. input.transferTo(output);
  31. }
  32. output.flush();
  33. }
  34. }

运行代码,在浏览器中输入URLhttp://localhost:8080/hello?name=Bob可以看到如下页面:

mvc

为了把方法参数的名称编译到class文件中,以便处理@GetMapping时使用,我们需要打开编译器的一个参数,在Eclipse中勾选Preferences-Java-Compiler-Store information about method parameters (usable via reflection);在Idea中选择Preferences-Build, Execution, Deployment-Compiler-Java Compiler-Additional command line parameters,填入-parameters;在Maven的pom.xml添加一段配置如下:

  1. <project ...>
  2. <modelVersion>4.0.0</modelVersion>
  3. ...
  4. <build>
  5. <plugins>
  6. <plugin>
  7. <groupId>org.apache.maven.plugins</groupId>
  8. <artifactId>maven-compiler-plugin</artifactId>
  9. <configuration>
  10. <compilerArgs>
  11. <arg>-parameters</arg>
  12. </compilerArgs>
  13. </configuration>
  14. </plugin>
  15. </plugins>
  16. </build>
  17. </project>

有些用过Spring MVC的童鞋会发现,本节实现的这个MVC框架,上层代码使用的公共类如GetMappingPostMappingModelAndView都和Spring MVC非常类似。实际上,我们这个MVC框架主要参考就是Spring MVC,通过实现一个“简化版”MVC,可以掌握Java Web MVC开发的核心思想与原理,对将来直接使用Spring MVC是非常有帮助的。

练习

MVC高级开发 - 图2下载练习:实现一个MVC框架 (推荐使用IDE练习插件快速下载)

小结

一个MVC框架是基于Servlet基础抽象出更高级的接口,使得上层基于MVC框架的开发可以不涉及Servlet相关的HttpServletRequest等接口,处理多个请求更加灵活,并且可以使用任意模板引擎,不必使用JSP。

读后有收获可以支付宝请作者喝咖啡,读后有疑问请加微信群讨论:

MVC高级开发 - 图3MVC高级开发 - 图4