后端使用Spring Boot搭建的一个博客系统,前端使用的是thymeleaf + bootstrap,集成了editormd的markdown编辑器。
本项目适合Spring初学者作为练手项目,包含的主要技术点: 从开始到项目完成的全过程,篇幅可能较长
spring data jpa的基本使用数据绑定拦截器spring boot 注解配置项目的思路以及前端代码来自他的博客: 一个JavaWeb搭建的开源Blog系统,整合SSM框架
项目GitHub地址: https://github.com/wchstrife/blog
把工程导入本地后,在mysql中添加一个叫做blog的database,然后在配置文件中把数据库的账号密码地址修改成自己的即可运行 主界面:
详情
登录
后台管理
写博客
下面我们的正式开始
我使用的是IDEA使用Maven构建一个Spring Boot工程,搭建过程请自行百度 建好工程之后手动添加一些目录,下面展示一下详细的目录
这里主要介绍一下resources目录
static目录是spring boot 默认的一个扫描的路径,所以我们要把引用的资源放在这里。
bootstrap是我们引进的一个样式控制的组件 editormd是引入的支持MarkDown语法的编辑器 css是一些全局的样式控制 jquery是bootstrap必要的
templates目录下放的是前端的HTML页面
admin是后台的管理 common是所有页面公用的部分 front是前台的展示界面
展示一下我们项目完整的依赖:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.wchstrife</groupId> <artifactId>demo</artifactId> <version>0.0.1-SNAPSHOT</version> <packaging>jar</packaging> <name>demo</name> <description>Demo project for Spring Boot</description> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>1.5.6.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-thymeleaf --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> <version>1.5.4.RELEASE</version> </dependency> <!--不严格检查--> <dependency> <groupId>net.sourceforge.nekohtml</groupId> <artifactId>nekohtml</artifactId> <version>1.9.22</version> </dependency> <!--热部署--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <optional>true</optional><!-- optional=true,依赖不会传递,该项目依赖devtools;之后依赖myboot项目的项目如果想要使用devtools,需要重新引入 --> </dependency> <!--markdown--> <dependency> <groupId>org.tautua.markdownpapers</groupId> <artifactId>markdownpapers-core</artifactId> <version>1.4.1</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>实体的映射是在entity包下 下面我们使用Spring data jpa 对我们项目需要的实体进行数据库的映射 通过这种方式,我们可以避免直接操作数据库,而是通过Spring data jpa来进行增删改查的操作 这里我们一共用到三张表:Aritcle Category User 分别对应博客,分类,用户 主键生成策略: 这里我采用的是UUID的生成方式,会生成一串字符串作为主键
@Id @GeneratedValue(generator = "uuid") @GenericGenerator(name = "uuid", strategy = "uuid") @Column(name = "id", columnDefinition = "varchar(64) binary") private String id;外键约束 在Article表中,我们使用了ManyToOne的外键约束 在Article中有一个Categoriy的引用,代表一个博客有对应的一个分类,而一个分类下应该有多个博客
@ManyToOne private Category category;在dao包下封装了一系列的对数据库增删改查的操作 Spring data jpa强大之处就是可以根据方法名进行解析。所以在dao层下的接口,大部分只有方法名和接收的参数。 如果需要自定义sql语句只需要加注解即可 这里演示一个自定义的模糊查询
@Query("from Article where title like %:title%") public List<Article> findByTitleLike(@Param("title") String title);划分这两层体现了很重要的分层的思想,即每一层只针对一层提供方法,不去了解其他层的方法,这样方便维护。 所以为了体现这种分层的思想,所以针对数据库的操作都放在service层进行封装 在controller层主要负责url地址的分配,对外提供的接口,部分简单的逻辑写在这里
在前端我们使用了Thymeleaf前端模板,里面有很多类似JSP标签的写法,进行对数据的遍历操作 在后端Control里面返回页面的时候,使用Model向其中添加属性,在前端页面上可以通过${}来获取这个属性值,并且用th:each来遍历 注意带th的语法表示交给thyme leaf模板解析的语法
例如前台index界面: controller返回所有的博客列表
@RequestMapping("") public String list(Model model){ List<Article> articles = articleService.list(); model.addAttribute("articles", articles); return "front/index"; }在前台使用ty的语法进行遍历显示
<div th:each="articles:${articles}"> <h3 th:text="${articles.title}"></h3> <span class="summary" th:text="${articles.summary}"></span><br/><br/> </div>所以在前台展示只有三个页面,分别是列表显示所有博客,按类型显示博客,某个博客的全文 所以对应在controller里面只需要从数据库筛选全部的博客、某个类型的博客、取出某个博客(通过ID)的全文在页面上展示即可
在管理员界面要实现的功能比较多,最重要的是对博客的增删改 同时这里有一个登录的功能,只有在User表中有对应的账号密码才能登录,所以这里需要一层登陆拦截,这个稍后介绍。
在编辑博客的时候我们支持使用markdown语法,我在网上找了一款叫做editormd的开源项目,放到static目录下 在我们write.html用如下的语法引入编辑器
<script th:src="@{/jquery-3.2.1.min.js}"></script> <script th:src="@{/editormd/editormd.js}"></script> <script th:src="@{/bootstrap/js/bootstrap.js}"></script> <script type="text/javascript" th:inline="javascript"> // 调用编辑器 var testEditor; $(function() { testEditor = editormd("test-editormd", { width : "1000px", height : 640, syncScrolling : "single", path : [[@{/editormd/lib/}]] }); }); </script> <script th:inline="javascript"> function selectCategory(obj) { var name = $(obj).attr("name"); var displayName = $(obj).attr("abbr"); console.log(name + " " + displayName); $("#categoryBtn").html(displayName); $("#cateoryInput").val(name); } </script>在需要编辑器的地方输入一个textarea即可
<div id="test-editormd"> <textarea style="display:none;" name="content" th:field="*{content}" th:text="${target.content}"></textarea> </div>深坑: 如果在js中要使用thymeleaf的语法,比如@{} ${}这种语法,一定要加上这句话th:inline="javascript 这样来使用该值[[@{/editormd/lib/}]]
在写文章按钮上绑定好要提交的action,在controller里面对这个action进行处理,这里我们重点是要返回一个new出来的Article对象,因为要对对象进行数据的绑定,所以如果不传这个参数的话会报错
@RequestMapping("/write") public String write(Model model){ List<Category> categories = categoryService.list(); model.addAttribute("categories", categories); model.addAttribute("article", new Article()); return "admin/write"; }在write.html中引入相关的编辑器组件以后,通过th:object绑定到Article对象上,然后Spring Boot会自动的帮我们把表单中的数据组合成一个ArtIicle对象,是不是很方便
<form method="post" th:action="@{/admin/save}" th:object="${article}"> <input name="category" id="cateoryInput" type="hidden" th:field="*{category.name}"/> <input type="text" class="form-contorl" palceholder="标题" name="title" th:field="*{title}"/> <textarea style="display:none;" name="content" th:field="*{content}"></textarea> </form>前面html的表达提交之后,提交到save这个action中,在这里我们对提交的数据进行一个简单的处理,然后调用service里面封装的dao层的save方法即可 这里主要是对博客的日期,简介进行一个处理
@RequestMapping(value = "/save", method = RequestMethod.POST) public String save(Article article){ //设置种类 String name = article.getCategory().getName(); Category category = categoryService.fingdByName(name); article.setCategory(category); //设置摘要,取前40个字 if(article.getContent().length() > 40){ article.setSummary(article.getContent().substring(0, 40)); }else { article.setSummary(article.getContent().substring(0, article.getContent().length())); } article.setDate(sdf.format(new Date())); articleService.save(article); return "redirect:/admin"; }更新博客其实和写博客是一个道理,不过在更新的时候需要把id传给controller,然后根据id找到这个文章 把这个博客的内容、标题渲染在update.html中 然后在表单提交的字段中加一个隐藏表单,把博客的id传进去 调用save方法即可完成更新(根据id进行save,所以这时候会执行更新操作)
@RequestMapping("/update/{id}") public String update(@PathVariable("id") String id, Model model){ Article article = articleService.getById(id); model.addAttribute("target", article); List<Category> categories = categoryService.list(); model.addAttribute("categories", categories); model.addAttribute("article", new Article()); return "admin/update"; }对于后台的增删改操作,我们只对管理员开放,虽然我们增加了一个登录界面,但是别人还是可以通过直接输入对应url进行访问,所以我们要在这里增加一层登陆拦截,让没有登录的人不允许访问到我们后天的界面
在登录处理登录的doLogin方法中,我们在登录成功之后在cookie中加一个标志
Cookie cookie = new Cookie(WebSecurityConfig.SESSION_KEY, user.toString()); response.addCookie(cookie);在aspect包中建立一个拦截器
在WebSecurityConfig类中继承WebMvcConfigurerAdapter 重写addInterceptors方法,在里面配置要拦截的路径
在里面建一个内部类
SecurityInterceptor继承HandlerInterceptorAdapter 重写preHandle方法,表明在方法执行前执行拦截的动作 我们在这里对cookie的内容进行判断,如果有登录成功的标志,就进入后台管理界面,否则跳转到登录界面注意:使用session的方法是不可以的,因为我们在登录的controller当中使用的是重定向(redirect),所以会导致session里面的值取不到