escapar CodingDaydreaming
Web应用一把梭:结构(实例)
MVC, Web, Java, Spring Boot
根据上篇提出的结构,本篇用框架写了一套可以运行的Web App。由于数据库相关的操作会在后文详述,所以**例子里的数据是Mock的**。 这章实践的成分比较大,动手能力强的同学可以TL;DR并直接下载源码。 本文以文章列表和文章详情功能为例,源码[点此获取](https://github.com/POJOa/demo-app-one)。 用这个思路写出的App效果可以参考[Blog的极简版](https://m.k41d.com/)。 ## 准备工作 首先,准备一个Java IDE,**推荐IntelliJ IDEA**。 前往[Spring Initializer页面](https://start.spring.io/),钩选**Thymeleaf和Web**依赖,点击Generate Project,如下图。 ![](https://wx3.sinaimg.cn/mw690/6849f9a1ly1forqkyko45j21kw12s45c.jpg) 解压下载的压缩包,在IntelliJ IDEA菜单的File - New 中 选择Project From Existing Sources,打开解压的文件。在弹出的对话框中选择选择Import from external model,然后一路下一步。如下图。 ![](https://wx1.sinaimg.cn/mw690/6849f9a1ly1forqkyg56zj215a0hk401.jpg) 至此就获得了一个非常干净的Spring Boot项目,程序入口在`src/main/java/com/example/demo/DemoApplication.java`,可以看到在IDE的右上角已经有了既定的运行和调试设定,无须作更多配置,今后可以点击**绿色的瓢虫图标**来调试。 ## 目录结构 ![](https://wx3.sinaimg.cn/mw690/6849f9a1ly1forrbox56rj20ps0xgtcm.jpg) 请参考github的源码。 ## Domain ### Model 我们假定文章(Topic)有标题和内容属性,那么根据Java Bean的方式,我们的Domain Model对象定义就要这么写。 ``` java public class Topic { private String title; private String content; public Topic(String title, String content){ this.content = content; this.title = title; } public String getContent() { return content; } public Topic setContent(final String content) { this.content = content; return this; } public String getTitle() { return title; } public Topic setTitle(final String title) { this.title = title; return this; } } ``` 当然,构造器和样板getter、setter都可以从右键的generate里自动生成。 ![](https://wx3.sinaimg.cn/mw690/6849f9a1ly1forqyaqvo5j20om0oqjvn.jpg) ### Repository Repository太长了,类名里我习惯简称Repo。 这篇文章里暂不涉及和数据库交互的部分,所以数据直接在Repo中定义~~,然后我们假装它是从数据库取的~~。 ``` java @Repository public class TopicRepo { private final List<Topic> existingTopics = Arrays.asList( new Topic("topicA",LOREM.content),new Topic("topicB","# titleB\ncontent\n## lorem")); public Topic get(int id){ return existingTopics.get(id); } public List<Topic> findAll(){ return existingTopics; } } ``` 很多刚接触Spring不久的同学都会习惯漏写@Repository之类的Annotation(注解),**千万不要忘记**。因为这样,这个Repository的生命周期就不会被框架接管,会产生依赖注入失败的问题。 依赖注入的用法会在后文看到,依赖注入和注解的概念不在这个系列文章的讨论范围内。 这边实现了两个非常简单的方法,一个是获取特定id的文章,一个是获取全部文章。 ## Service 这里的Service并没有做什么,因为例子很简单,没有什么特别的业务逻辑。看起来它只是把Repo注入了,并将它的方法转发了出去。 但这**并不意味着应该省略它**,因为程序都是会变复杂的。尽管把所有逻辑都并在一层写是很流行的做法,我还是坚持我的看法。 ``` java @Service public class TopicService { @Autowired TopicRepo topicRepo; public Topic get(int id){ return topicRepo.get(id); } public List<Topic> findAll(){ return topicRepo.findAll(); } } ``` 这里的@Autowired就是标示了需要依赖注入(DI)的对象,就像字面意义说的一样,你无需手动初始化被注入的对象,框架会自动帮你做这件事。如果你手动new了那些实例,会发现其中的方法都不可用,可以做个试验。 **就结果来说,注入后直接用就得了。但依赖注入是面试常考的概念,务必连同单例模式一起熟悉**。 ## Interface 我的包名写成了interfaces,因为interface是Java的保留关键字。 ### RESTful API 什么是RESTful?我不打算在这个系列里讨论这个争议很大的问题。 不过有几点是公认的: * RESTful API可以运用流行的JSON格式进行数据交互; * RESTful API可以包含多种请求的方式(除了常见的GET和POST外,还有PATCH、DELETE等); * RESTful API追求直观的URL和参数设定(有人认为把id放在URL里也是不直观的); * RESTful API的一般不直接面向最终用户,而是需要经过另一个应用程序的处理。 具体的内容和含义**务必**了解清楚,这也是面试常用的。应用RESTful API的地方有很多,就举移动端的例子,最接地气的应该就是移动端HTML5页面领红包用的API了,如果页面没有刷新或跳转,或者只显示一个弹层“领取成功”,很可能用的就是RESTful API。移动端原生应用和服务端的数据交互,大部分是用RESTful API完成的。 ~~Java Web的经典开发模式是曾经风靡的Struts式Action + JSP,从纯Servlet和J2EE时代走来的老程序员对此情有独钟。我曾经改造过一个Struts项目,它仍然在用这种方式输出JSON。 与此相对,基于Ruby的Sinatra提倡了一种面向API的新的代码组织风格,目前,它已经成为了现代框架的标配,可以在Flask、Play等知名框架中看到类似的方式。~~ ``` java @RestController @RequestMapping(value = "api/topic/") public class TopicAPI { @Autowired TopicService topicService; @RequestMapping(value = "/{id}", method = RequestMethod.GET, produces = "application/json") public Topic get(@PathVariable int id) { return topicService.get(id); } @RequestMapping(value = "/", method = RequestMethod.GET, produces = "application/json") public List<Topic> list() { return topicService.findAll(); } } ``` 以上代码生成了两个API入口,`api/topic/id`和`api/topic/`,这两个入口的功能分别为获取单个文章和获取全部文章,我们可以访问`http://localhost:8080/api/topic/`(相当于发出了GET请求)来查看结果。 ![](https://wx3.sinaimg.cn/mw1024/6849f9a1ly1forsc7y2e1j20r604igm2.jpg) 易见@RequestMapping是可以级联生成API入口的。method中可以指定请求的方式,produces的默认值就是application/json,可以省略。方法返回的类型可以直接写对象类型,会有默认的序列化(将对象转化为JSON)工具来进行进一步处理。 URL中的参数,我们可以用{名称}的方式在入口中表示,并在方法的参数中读取。注意,如果方法的参数名字和花括号中的名称不同,必须对@PathVariable annotion指定name值。 ### 服务端渲染HTML #### Model 和 Controller 这个模式是典型的MVC。 ``` java @Controller public class TopicController { @Autowired TopicService topicService; @RequestMapping("/") public String list(Model model){ model.addAttribute("topics", topicService.findAll()); return "topic/list"; } @RequestMapping("/{id}") public String list(Model model,@PathVariable final int id){ model.addAttribute("t", topicService.get(id)); return "topic/detail"; } } ``` Controller的定义方式和之前提到的RestController类似。 值得一提的有两点。 其一,方法中的 Model 类型参数,面向的只是这个后端渲染出的前端,**用法和Map有些相似**。它并不是凭空跑出来的,框架会处理具体传入的model,无需另外初始化。这里的Model相当于前端应用里的Viewmodel,在Controller中组装,可以稍后在View中直接访问。**简而言之,拿来model直接用,调用addAttribute往里放页面需要的内容就好了** 其二,Controller通过返回值指定View,其中最方便的是返回View的路径。根据上图返回的路径,框架会分别去/src/main/resources/templates/中寻找topic/list.html和topic/detail.html。 #### View Thymeleaf定义了很多模板的语法,可以通过[官方文档](https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html)查到。你需要做好心理准备,无论怎么简化,哪怕是用了Vaadin这样的组件库,用Java Web写View仍然是一件很痛苦的事情,尽管这是主流的方式。在这里举几个最基本的例子。 ``` html <!DOCTYPE HTML> <html xmlns:th="http://www.thymeleaf.org" view="cat"> <head> <title>文章列表</title> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <link rel="stylesheet" th:href="@{/css/sakura.min.css}" type="text/css"> </head> <body> <div th:each="t,iter : ${topics}"> <a th:text="${t.title}" th:href="${'/'+iter.index}">文章名</a> </div> </body> </html> ``` 有以下几点需要注意: * thymeleaf是一个严格的XML parser,务必保证每个HTML标签闭合。 * head标签前的内容,请务必不要漏掉,尽管目前没有什么实际用处,但在后期,可以在这里实现一些常用的功能,例如指定layout布局。 * js、css等静态资源,请放在/src/main/resources/static/中,并用上文类似的方式指定css的路径。 * th:each是生成列表的常用模板指令,在t,iter : ${topics}中,${topics}表示是odel中类型为List<Topic>的topics对象,t每次被迭代出的对象,iter是每次迭代的状态信息,这里用iter.index替代了id的功能。 * 我们可以在标签中填入任意内容替代注释的功能(例如上文的“文章名”),也可以直接打开HTML预览(可预览的模板文件也是Thymeleaf的设计初衷之一),在生成的页面中它将会被th:text指定的值替换。 ~~接下来是一个我认为服务端渲染最恶心的地方,I nline Javscript,非常容易产生混淆,但在ExtJS + Spring的开发里也很常用。~~ 假设我的文章内容是用Markdown表示的,但我不希望用服务器的算力帮用户预处理,我就需要: 1. 在页面上嵌入一个JS的Markdown Parser; 2. 把Markdown内容传递给Parser; 3. 将Parser的处理结果append到指定的div里。 在Thymeleaf中是这么实现的,看以下详情页的View。 ``` html <!DOCTYPE HTML> <html xmlns:th="http://www.thymeleaf.org" view="topics"> <head> <title th:text="${t.title}"></title> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <link rel="stylesheet" th:href="@{/css/sakura.min.css}" type="text/css"> <script th:src="@{/js/marked.min.js}"></script> </head> <body> <h2 th:text="${t.title}">标题</h2> <div id="content"></div> <script th:inline="javascript"> var c = /*[[${t.content}]]*/ '#mark'; document.getElementById('content').innerHTML = marked(c); </script> </body> </html> ``` 对应上文的三步: 1. 这里使用的Parser是marked.js。 2. 对变量c的赋值语句中,在浏览器接收到后端的HTML数据时,#mark已经被t.content的内容替换,这里的语法是十分晦涩的。 3. 通过`document.getElementById('content').innerHTML`来访问和编辑元素的内容。 至此,最基本的程序结构实现就完成了,可以debug运行,并在访问http://localhost:8080 查看效果,这里贴出列表页和详细页的效果图。 下一篇是关于数据库和Spring Data JPA的操作,回见。 ![](https://wx3.sinaimg.cn/mw690/6849f9a1ly1forsh3g5m6j20mi08kglw.jpg) ![](https://wx1.sinaimg.cn/mw690/6849f9a1ly1forsh3l6d6j218e1p8tip.jpg)