MVC高级开发

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

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

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

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

    上面的这个Java类每个方法都对应一个GET或POST请求,方法返回值是,它包含一个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框架?在上文中,我们已经定义了上层代码编写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. 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. }

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

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

    最后,我们来实现整个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名称,表示一个重定向。

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

    最后一步是在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有很多开源的模板引擎,常用的有:

    1. <html>
    2. <body>
    3. <ul>
    4. <li><a href="{{ user.url }}">{{ user.username }}</a></li>
    5. {% endfor %}
    6. </ul>
    7. </body>
    8. </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来处理静态文件:

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

    为了把方法参数的名称编译到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>

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

    从下载练习:实现一个MVC框架 (推荐使用快速下载)

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

    MVC高级开发 - 图1