01-Springboot博客项目

本系列是对李仁密老师的视频的学习记录
前端页面设计与实现部分不会在此展示,直接上后端部分。
项目源码和教程可以点击上面链接进行学习

码云地址:项目源码


1. 创建项目

IDEA利用Spring初始化工具创建

依赖选择如下

在这里插入图片描述

2. 配置项目

更改thymeleaf版本
pom文件中

<properties>
    <thymeleaf.version>3.0.11.RELEASE</thymeleaf.version>
    <thymeleaf-layout-dialect.version>2.1.1</thymeleaf-layout-dialect.version>
</properties>

更改thymeleaf解析模式 重要!
thymeleaf对html的检查非常严格,容易出现无法解析的情况,而且不会告诉你具体是哪里无法解析,这就很头疼。不如降低检查水平。
导入依赖

<dependency>
      <groupId>net.sourceforge.nekohtml</groupId>
      <artifactId>nekohtml</artifactId>
      <version>1.9.22</version>
</dependency>

application.yml中更改模式

  thymeleaf:
    cache: false  #关闭缓存方便调试
    mode: LEGACYHTML5   #更改解析检查模式

网上说改为thymeleaf3之后就能降低解析难度,但是我发现有些情况还是会出错,不如改成LEGACYHTML5 模式

连接数据库

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/blog?serverTimezone=UTC
    username: root
    password: 123456
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

配置日志

logging:
  level: 
    root: info
    com.ddw: debug  #指定com.ddw下进行debug级别的记录
  file:
    name: log/blog.log  #指定日志存放目录    

异常处理
创建全局异常配置类

@ControllerAdvice // 作为一个控制层的切面处理
public class GlobalException{
    @ExceptionHandler(value = Exception.class) // 所有的异常都是Exception子类
    public ModelAndView defaultErrorHandler(HttpServletRequest request, Exception e) {
        ModelAndView mav = new ModelAndView();
        mav.addObject("url", request.getRequestURL());
        mav.addObject("exception", e);
        mav.setViewName("error/5xx");
        return mav;
    }
}

要点
1.@ControllerAdvice 声明切面类
2.@ExceptionHandler 声明处理方法以及处理类型
3.setViewName(“error/5xx”); 返回到对应页面

编写5xx.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>5xx</title>
</head>
<body>
<h1>系统出现未知错误</h1>
<div>
    <div th:utext="'&lt;!--'" th:remove="tag"></div>
    <div th:utext="'Failed Request URL : ' + ${url}" th:remove="tag"> </div>
    <div th:utext="'Exception message : ' + ${exception.message}" th:remove="tag"></div>
    <ul th:remove="tag">
        <li th:each="st : ${exception.stackTrace}" th:remove="tag"><span th:utext="${st}" th:remove="tag"></span></li>
    </ul>
    <div th:utext="'--&gt;'" th:remove="tag"></div>
</div>
</body>
</html>

编写4xx.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>4xx</title>
</head>
<body>
<h1>页面不存在</h1>
</body>
</html>


创建目录如下,springboot会自动将4xx和5xx类型的错误对应跳转到相应页面

3. 定制日志

导入AOP

<!--Aop依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

编写日志切面

package com.ddw.blog.aspect;

import org.apache.tomcat.util.http.fileupload.RequestContext;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Enumeration;


@Aspect
@Component
public class Log {
    private final Logger logger = LoggerFactory.getLogger(Log.class);

    @Pointcut("execution(* com.ddw.blog.controller..*.*(..))")
    public void weblog(){}

    @Before("weblog()")
    public void doBefore(JoinPoint joinPoint){
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        assert attributes != null;
        HttpServletRequest request = attributes.getRequest();

        logger.info("---------------request----------------");
        logger.info("URL : " + request.getRequestURL().toString());
        logger.info("HTTP_Method : "+request.getMethod());
        logger.info("IP : "+request.getRemoteAddr());
        logger.info("Class_Method : "+joinPoint.getSignature().getDeclaringTypeName()+"."+joinPoint.getSignature().getName());
        logger.info("ARGS : " + Arrays.toString(joinPoint.getArgs()));
        Enumeration<String> enu = request.getParameterNames();
        while (enu.hasMoreElements()) {
            String name = enu.nextElement();
            logger.info("name:" + name + "value" + request.getParameter(name));
        }
    }

    @AfterReturning(returning = "ret",pointcut = "weblog()")
    public void doAfterReturning(Object ret){
        logger.info("---------------response----------------");
        logger.info("ResponseData : " +ret);
    }

}

4. 关联静态页面

直接将前端写好的页面按目录对应放入springboot项目中之后,也会出现静态资源无法找到的情况
在这里插入图片描述这是因为使用了thymeleaf模板。
在这里插入图片描述只需要将无法找到的静态资源用thymeleaf语法引入即可。
(也可以使用warjar引入方式)
但是,几乎所有本地外部引用的资源都找不到,如果一个一个增加thymeleaf引入会非常麻烦。
因此,可以使用fragments替换

编写fragments,包含“大家共用的片段”

<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head th:fragment="head(title)">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title th:replace="${title}">title</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/semantic-ui/2.2.4/semantic.min.css">
  <link rel="stylesheet" href="../static/css/typo.css" th:href="@{/css/typo.css}">
  <link rel="stylesheet" href="../static/css/animate.css" th:href="@{css/animate.css}">
  <link rel="stylesheet" href="../static/lib/prism/prism.css" th:href="@{/lib/prism/prism.css}">
  <link rel="stylesheet" href="../static/lib/tocbot/tocbot.css" th:href="@{/lib/tocbot/tocbot.css}">
  <link rel="stylesheet" href="../static/css/me.css" th:href="@{/css/me.css}">
</head>

无论是thymeleaf的普通th语法替换,还是fragments替换,都能够保持原有html,不需要对前端给的静态页面进行删减,只需要增加一些thymeleaf语法实现动态数据替换,因此,thymeleaf也能实现前后端分离开发。

然后在其他head页面中的head标签内增加引用即可,不需要一一更改原有html引用

<head th:replace="_fragments :: head(~{::title})">  
<!--这里是不同页面head-->
</head>

th:replace=“fragments文件名 :: 替换fragment名(~{::替换标签内容名})”

通过参数title,更改不同页面的title

因此,所有公共部分,都做成fragment引入

导航栏

通过参数n,更改不同页面的选中状态,th:classappend="${n==1} ? ‘active’",如果n==1,则增加一个class名“active”使之高亮。

<!--导航-->
<nav th:fragment="menu(n)" class="ui inverted attached segment m-padded-tb-mini m-shadow-small" >
  <div class="ui container">
    <div class="ui inverted secondary stackable menu">
      <h2 class="ui teal header item">Blog</h2>
      <a href="#" class="m-item item m-mobile-hide " th:classappend="${n==1} ? 'active'"><i class="mini home icon"></i>首页</a>
      <a href="#" class="m-item item m-mobile-hide" th:classappend="${n==2} ? 'active'"><i class="mini idea icon"></i>分类</a>
      <a href="#" class="m-item item m-mobile-hide" th:classappend="${n==3} ? 'active'"><i class="mini tags icon"></i>标签</a>
      <a href="#" class="m-item item m-mobile-hide" th:classappend="${n==4} ? 'active'"><i class="mini clone icon"></i>归档</a>
      <a href="#" class="m-item item m-mobile-hide" th:classappend="${n==5} ? 'active'"><i class="mini info icon"></i>关于我</a>
      <div class="right m-item item m-mobile-hide">
        <div class="ui icon inverted transparent input m-margin-tb-tiny">
          <input type="text" placeholder="Search....">
          <i class="search link icon"></i>
        </div>
      </div>
    </div>
  </div>
  <a href="#" class="ui menu toggle black icon button m-right-top m-mobile-show">
    <i class="sidebar icon"></i>
  </a>
</nav>

底部

<!--底部footer-->
<footer th:fragment="footer" class="ui inverted vertical segment m-padded-tb-massive">
  <div class="ui center aligned container">
    <div class="ui inverted divided stackable grid">
      <div class="three wide column">
        <div class="ui inverted link list">
          <div class="item">
            <img src="../static/images/wechat.jpg" th:src="@{/images/wechat.jpg}"  class="ui rounded image" alt="" style="width: 110px">
          </div>
        </div>
      </div>
      <div class="three wide column">
        <h4 class="ui inverted header m-text-thin m-text-spaced " >最新博客</h4>
        <div class="ui inverted link list">
          <a href="#" class="item m-text-thin">用户故事(User Story)</a>
          <a href="#" class="item m-text-thin">用户故事(User Story)</a>
          <a href="#" class="item m-text-thin">用户故事(User Story)</a>
        </div>
      </div>
      <div class="three wide column">
        <h4 class="ui inverted header m-text-thin m-text-spaced ">联系我</h4>
        <div class="ui inverted link list">
          <a href="#" class="item m-text-thin">Email:lirenmi@163.com</a>
          <a href="#" class="item m-text-thin">QQ:865729312</a>
        </div>
      </div>
      <div class="seven wide column">
        <h4 class="ui inverted header m-text-thin m-text-spaced ">Blog</h4>
        <p class="m-text-thin m-text-spaced m-opacity-mini">这是我的个人博客、会分享关于编程、写作、思考相关的任何内容,希望可以给来到这儿的人有所帮助...</p>
      </div>
    </div>
    <div class="ui inverted section divider"></div>
    <p class="m-text-thin m-text-spaced m-opacity-tiny">Copyright © 2016 - 2017 Lirenmi Designed by Lirenmi</p>
  </div>

</footer>

script

其实这个还是有些非公共部分,不过这里用到的资源不是很多,统一导入影响不大,而且方便

<!--可以将所有的script放进一个div中,方便使用fragment功能。不过更推荐使用th自带的bolck-->
<th:block th:fragment="script">
  <script src="https://cdn.jsdelivr.net/npm/jquery@3.2/dist/jquery.min.js"></script>
  <script src="https://cdn.jsdelivr.net/semantic-ui/2.2.4/semantic.min.js"></script>
  <script src="//cdn.jsdelivr.net/npm/jquery.scrollto@2.1.2/jquery.scrollTo.min.js"></script>
  <script src="../static/lib/prism/prism.js" th:src="@{/lib/prism/prism.js}"></script>
  <script src="../static/lib/tocbot/tocbot.min.js" th:src="@{/lib/tocbot/tocbot.min.js}"></script>
  <script src="../static/lib/qrcode/qrcode.min.js" th:src="@{/lib/qrcode/qrcode.min.js}"></script>
  <script src="../static/lib/waypoints/jquery.waypoints.min.js" th:src="@{/lib/waypoints/jquery.waypoints.min.js}"></script>
</th:block>

注意,在原生html中,script使用bolck包裹起来的时候,最好使用特殊方法将其注释掉,这样不影响原生html代码,也能使th代码生效

<!--/*/<th:block th:replace="">/*/-->
只要在注释内的标签前后加上/*/即可

在这里插入图片描述
同理,给之前的错误页面,也引入head和footer片段,实现美化。

5. 实体设计

关系抽象

实体类:

  • 博客 Blog
  • 博客分类 Type
  • 博客标签 Tag
  • 博客评论 Comment
  • 用户 User

以Blog为纽带。

  • type–blog,一对多(一种类型有多篇文章)
  • tag–blog,多对多(多个标签对应多篇文章)
  • User–blog,一对多(一个用户写了多篇文章)
  • Comment–bolg,多对一,(一篇文章被多人评论)
    在这里插入图片描述
    评论类的自关联关系:
    一条(父)评论可以被人多次回复,一对多
    在这里插入图片描述

属性设计
在这里插入图片描述

双环表明该属性为对象

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

6. 创建实体,生成数据库表

JAP相关知识

  1. @Entity 标注为实体,实现对象到表的映射

  2. @Table(name = “t_blog”) 设置表名

  3. @Id
    @GeneratedValue 设置id,自增

  4. @Column(columnDefinition=“text”) 指定列属性

  5. @Temporal(TemporalType.TIMESTAMP)
    Temporal注解用于帮java Data类型进行格式化,因为java Data是util下的类

    • 第一种:@Temporal(TemporalType.DATE)——>实体类会封装成日期“yyyy-MM-dd”的 Date类型。
    • 第二种:@Temporal(TemporalType.TIME)——>实体类会封装成时间“hh-MM-ss”的 Date类型。
    • 第三种:@Temporal(TemporalType.TIMESTAMP)——>实体类会封装成完整的时间“yyyy-MM-dd hh:MM:ss”的 Date类型。
  6. mapperBy
    1>只有OneToOne,OneToMany,ManyToMany上才有mappedBy属性,ManyToOne不存在该属性;
    2>mappedBy标签一定是定义在被拥有方的,他指向拥有方;
    3>mappedBy的含义,应该理解为,拥有方能够自动维护跟被拥有方的关系
    4>mappedBy跟joinColumn/JoinTable总是处于互斥的一方,mappedBy这方定义JoinColumn/JoinTable总是失效的,不会建立对应的字段或者表。

  7. @ManyToMany(cascade = {CascadeType.PERSIST})
    private List<Tag> tags = new ArrayList<>();
    上述两行在Blog中,设置级联新增。当新增文章的同时新增了标签,则该标签也会被增加到标签表中

  8. 级联CascadeType所有状态

    • ALL
      级联所有实体状态转换

    • PERSIST
      级联实体持久化操作。

    • MERGE
      级联实体合并操作。

    • REMOVE
      级联实体删除操作。

    • REFRESH
      级联实体刷新操作。

    • DETACH
      级联实体分离操作。

代码如下(此处省略了set、get、空构造以及tostring方法)


@Entity
@Table(name = "t_blog")
public class Blog {
    @Id
    @GeneratedValue
    private Long id;

    private String title;
    @Basic(fetch = FetchType.LAZY)
    @Lob
    private String content;
    private String firstPicture;
    private String flag;
    private Integer views;
    private boolean appreciation;
    private boolean shareStatement;
    private boolean commentabled;
    private boolean published;
    private boolean recommend;
    @Temporal(TemporalType.TIMESTAMP)
    private Date createTime;
    @Temporal(TemporalType.TIMESTAMP)
    private Date updateTime;

    @ManyToOne
    private Type type;

    @ManyToMany(cascade = {CascadeType.PERSIST})
    private List<Tag> tags = new ArrayList<>();

    @ManyToOne
    private User user;

    @OneToMany(mappedBy = "blog")
    private List<Comment> comments = new ArrayList<>();

@Basic(fetch = FetchType.LAZY)
@Lob
这两个注解会将属性映射为LongText字段。太大所以进行懒加载

@Entity
@Table(name = "t_comment")
public class Comment {

    @Id
    @GeneratedValue
    private Long id;
    private String nickname;
    private String email;
    private String content;
    private String avatar;
    @Temporal(TemporalType.TIMESTAMP)
    private Date createTime;

    @ManyToOne
    private Blog blog;

    @OneToMany(mappedBy = "parentComment")
    private List<Comment> replyComments = new ArrayList<>();

    @ManyToOne
    private Comment parentComment;
@Entity
@Table(name = "t_tag")
public class Tag {

    @Id
    @GeneratedValue
    private Long id;
    @NotBlank(message = "标签名称不能为空")
    private String name;

    @ManyToMany(mappedBy = "tags")
    private List<Blog> blogs = new ArrayList<>();
@Entity
@Table(name = "t_type")
public class Type {

    @Id
    @GeneratedValue
    private Long id;
    @NotBlank(message = "分类名称不能为空")
    private String name;

    @OneToMany(mappedBy = "type")   //type是被拥有端,因此声明mappedBy,对应字段是拥有端Blog中的外键名
    private List<Blog> blogs = new ArrayList<>();
@Entity
@Table(name = "t_user")
public class User {

    @Id
    @GeneratedValue
    private Long id;
    private String nickname;
    private String username;
    private String password;
    private String email;
    private String avatar;
    private Integer type;
    @Temporal(TemporalType.TIMESTAMP)
    private Date createTime;
    @Temporal(TemporalType.TIMESTAMP)
    private Date updateTime;

    @OneToMany(mappedBy = "user")
    private List<Blog> blogs = new ArrayList<>();

注意评论自关联的部分,同一个实体中

//当前实体作为parentComment时,包含多个子类对象,mappedBy 写在子类对象上
@OneToMany(mappedBy = "parentComment")
private List<Comment> replyComments = new ArrayList<>(); 

//当前实体作为replyComments时,多个replyComments对应一个父类对象
@ManyToOne
private Comment parentComment;  //

备注,我没看懂,我感觉这两个注解应该交换一下(mapperdBy的位置不换)

运行项目,生成数据表
在这里插入图片描述

hibernate_sequence用于记录表的主键
t_blog_tags是多对多关系的中间表
其他都是意料之中的表

拥有mapperBy的一方被称为“被拥有方”,该方不会生成xxx_id字段,而是拥有方才会生成xxx_id字段
如t_blog表中有user_id,但是t_user表中没有blog_id

多对多关系也不会在各表中生成xxx_id,而是生成中间表

@NotBlank(message = “分类名称不能为空”)是后端数据校验功能

7. admin后台

7.1 登陆管理页面

dao层

public interface UserRepository extends JpaRepository<User,Long> {
    User findByUsernameAndPassword(String username,String password);
}

JpaRepository<操作对象,主键类型>
里面的查询方法符合jpa命名规范即可。

service层
接口

public interface UserService {
    public User checkUser(String username,String password);
}

实现

@Service
public class UserServiceImpl implements UserService {

    @Autowired
    UserRepository userRepository;

    @Override
    public User checkUser(String username, String password) {
        return userRepository.findByUsernameAndPassword(username,password);
    }
}

控制器

@Controller
@RequestMapping("/admin")
public class LoginController {

    @Autowired
    UserServiceImpl userService;

    @GetMapping
    String loginPage(){
        return "admin/login";
    }

    @PostMapping("/login")
    String login(@RequestParam String username,
                 @RequestParam String password,
                 HttpSession session,
                 RedirectAttributes attributes){
        User user = userService.checkUser(username,password);
        if (user!=null){
            user.setPassword(null);  //保存session,进行安全处理
            session.setAttribute("user",user);
            return "admin/index";
        }
        attributes.addFlashAttribute("message", "用户名或密码错误");
        return "redirect:/admin";  //重定向到admin地址
    }

    @GetMapping("logout")
    String loginout(HttpSession session){
        session.removeAttribute("user");
        return "redirect:/admin";  //重定向到admin地址
    }
}

登陆成功使用转发:转发能够保存一次会话的数据。
登录失败使用重定向:重定向会清空数据。

补充笔记(重点)

1. 控制器之间的数据交互
  • (1)Request.getServletContext() 单例,一个应用在运行期间共享一个servletContext
  • (2)通过转发传递request (浏览器url不变,只显示转发前那个请求,请求一次)
    • ----return “forward:/admin” (转发到不同控制器补全相对路径即可)
  • (3)通过重定向
    • ----return “redirect:/admin”
2. 转发和重定向的区别
  • (1)涉及到数据操作(数据提交,增删改)时,使用重定向。若使用转发,页面重载时会重新加载数据操作。转发仅用于后台逻辑操作,保证页面不变
  • (2)转发之后,浏览器中URL仍然指向开始页面,此时如果重载当前页面,开始页面将会被重新调用。
  • (3)转发为同一个请求,重定向为新的请求
    • ①forword:直接到目标页面,本页面的所有响应都无效
    • ②include:顺序进行响应,进入include的页面执行完后再返回本页面继续响应

转发和重定向都是面向控制器路由的(即action路径),而非模板映射路径;
由于本节控制器中没有专用于登陆成功的控制器,因此此处没有使用转发,而是通过模板映射。

3. 前后端的数据交互
  • (1)控制器的参数对应表单提交的参数即可自动实现注入;若为model,也能实现自动注入
    • ①使用总结:控制器中形参的类型,在表单中直接提交形参类型的属性即可。比如:
      • 1)形参为User,表单直接提交User中的username、
      • 2)形参为UserExt,表单直接提交UserExt中的map(infos[‘key’])、list(userList[0])
    • ②若用axios传输,则只有js使用data传输数据(在uri中),控制器能用对应参数自动注入。否则只能用@RequestBody Map接收
  • (2)@RequestParam:接收请求头(少量数据)
    • ①Content-Type为application/x-www-form-urlencoded编码的内容,或者其他类型的请求
  • (3)@RequestBody:大量数据,json,xml等非application/x-www-form-urlencodeed
    • ①作用在形参列表上,用于将前台发送过来固定格式的数据【xml 格式或者 json等】封装为对应的 JavaBean 对象,封装时使用到的一个对象是系统默认配置的 HttpMessageConverter进行解析,然后封装到形参上
    • 由于RequestBody实际从HttpEntity中获取数据,而Get请求没有HttpEntity,因此不适用。
    • ③并非验证是否为请求体,而是接收application/合适的请求
    • ④(model接收)在这里插入图片描述
      • 1)可以在model中的属性上增加@JsonAlias实现别名
      • 2)在model属性上增加@JsonProperty实现唯一标准名(与前端提交的相比较)
    • ⑤如果前端传递的不是json,又需要将其封装为model,使用反射或者
  • (4)@Controller@ResponseBody / @RestController
    • ①被此注解修饰的方法的return会把数据直接发送到请求体中,而不会被解析为路径(常用于发送json数据)
    • ②如果返回的是字符串,则前端直接收到html原生代码串

相关注意点

request.getAttribute 在一个request中传递对象
request.getSession().getAttribute 在一个session中传递对象

public String userLogin(@RequestParam("username")String username,@RequestParam("password")String password){

等价于

public String userLogin(HttpServletRequest request){
    String username = request.getParameter("username");
String password = request.getParameter("password");

@RestController注解下不能访问静态资源(如图标)

千万千万!!不要将HttpServletRequest传递到任何异步方法中!比如axios传递参数到@requestParams中(改成request.getParam可以)
查看原因

MD5加密

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class MD5Utils {

    /**
     * MD5加密类
     * @param str 要加密的字符串
     * @return    加密后的字符串
     */
    public static String code(String str){
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.update(str.getBytes());
            byte[]byteDigest = md.digest();
            int i;
            StringBuilder buf = new StringBuilder();
            for (byte b : byteDigest) {
                i = b;
                if (i < 0)
                    i += 256;
                if (i < 16)
                    buf.append("0");
                buf.append(Integer.toHexString(i));
            }
            //32位加密
            return buf.toString();
            // 16位的加密
            //return buf.toString().substring(8, 24);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            return null;
        }

    }

}

UserServiceImpl中

public User checkUser(String username, String password) {
        return userRepository.findByUsernameAndPassword(username, MD5Utils.code(password));
    }

登录拦截器

拦截器

package com.ddw.blog.interceptor;

import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class AdminInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (request.getSession().getAttribute("user")==null){
            response.sendRedirect("/admin");
            return false;
        }
        return true;
    }
}

注册拦截器

package com.ddw.blog.interceptor;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AdminInterceptor())
                .addPathPatterns("/admin/**")
                .excludePathPatterns("/admin") //防止无限循环重定向进入admin
                .excludePathPatterns("/admin/login"); //表单提交不能被拦截
    }
}

7.2 分类、标签管理

dao层
tag

public interface TagRepository extends JpaRepository<Tag,Long> {
    Tag findByName(String name);
}

type

public interface TypeRepository extends JpaRepository<Type,Long> {
    Type findByName(String name);
}

service层
tag
接口

public interface TagService {
    Tag saveTag(Tag tag); 
    Tag getTag(Long id);
    Tag getTagByName(String name);
    Page<Tag> listTag(Pageable pageable);
    Tag updateTag(Long id, Tag tag);
    void deleteTag(Long id);
}

实现

@Service
public class TagServiceImpl implements TagService {

    @Autowired
    TagRepository tagRepository;

    @Transactional
    @Override
    public Tag saveTag(Tag tag) {
        return tagRepository.save(tag);
    }

    @Override
    public Tag getTag(Long id) {
        return tagRepository.getOne(id);
    }

    @Override
    public Tag getTagByName(String name) {
        return tagRepository.findByName(name);
    }

    @Override
    public Page<Tag> listTag(Pageable pageable) {
        return tagRepository.findAll(pageable);
    }

    @Transactional
    @Override
    public Tag updateTag(Long id, Tag tag) {
        Tag tmp = getTag(id);
        if (tmp==null){
            throw new NotFoundException("标签不存在");
        }
        BeanUtils.copyProperties(tag,tmp);
        return tagRepository.save(tmp);
    }

    @Transactional
    @Override
    public void deleteTag(Long id) {
        tagRepository.deleteById(id);
    }
}

getOne返回一个实体的引用,无结果会抛出异常;
findById返回一个Optional对象;
findOne返回一个Optional对象,可以实现动态查询;
Optional代表一个可能存在也可能不存在的值。

Page<分页实体> list(Pageable pageable); springboot会自动将数据封装为一页
当前端(更改)传输page的属性时,控制器会接收到,比如前端点击上一页时,设置(page=${page.number}-1),则前端也会根据更改后的页码进行分页查询(比如本项目中的tag和type分页)
如果是复杂分页,则不能通过前端更改page页码实现动态查询(比如本项目中的bolgs页面)

type
接口

public interface TypeService {
    Type saveType(Type type);
    Type getType(Type type);
    Type getTypeByName(String name);
    Page<Type> listType(Pageable pageable);
    Type updateType(Long id,Type type);
    void deleteType(Long id);
}

实现

把上面tag的实现类中的“Tag”换成“Type”即可

自定义一个未找到异常

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class NotFoundException extends RuntimeException{
    public NotFoundException() {
    }

    public NotFoundException(String message) {
        super(message);
    }

    public NotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
}

控制器
type

package com.ddw.blog.controller.admin;

import com.ddw.blog.model.Type;
import com.ddw.blog.service.TypeServiceImpl;
import com.sun.org.apache.xpath.internal.operations.Mod;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import javax.validation.Valid;

@Controller
@RequestMapping("/admin")
public class TypeController {
    /**
     * 涉及到的操作有:         方式     路由                  页面               描述
     * 1. 访问所有类型的页面     get    /types              admin/types         通过分页查询展示所有类型
     * 2. 访问新增类型的页面     get    /types/input        admin/types-input   在/types页面单击“新增”跳转到本页面
     * 3. 编辑标签请求          get    /tags/{id}/input    admin/tags-input    在/types页面单击“编辑”跳转到本页面,并进行数据回显
     * 4. 更新(添加)标签请求   post   /tags{id}           admin/tags          在页面获得id,通过id进行更新
     * 5. 删除标签请求         delete  /tags/{id}          admin/tags
     */

    @Autowired
    TypeServiceImpl typeService;

    @GetMapping("/types")
    public String types(@PageableDefault(size = 3,sort = {"id"},direction = Sort.Direction.DESC)
                        Pageable pageable, Model model){
        // 按3条一页的形式分写,排序方式按id降序
        // springboot会根据前端的参数封装好pageable
        model.addAttribute("page",typeService.listType(pageable));
        return "admin/types";
    }

    @GetMapping("types/input")
    public String input(Model model){
        //这里的model添加type,是为了让前端页面能够拿到一个type对象,然后进行数据校验
        model.addAttribute("type",new Type());
        return "admin/types-input";
    }

    @GetMapping("/types/{id}/input")
    public String editInput(@PathVariable Long id, Model model){
        //数据回显
        model.addAttribute("type",typeService.getType(id));
        return "admin/types-input";
    }

    @PostMapping("/types")
    public String post(@Valid Type type, BindingResult result, RedirectAttributes attributes){
        //进行重复校验
        Type tmp = typeService.getTypeByName(type.getName());
        if (tmp!=null){
            //这句话会将nameError加入到result中,因此,下面result.hasErrors()为true,这里不用return,
            result.rejectValue("name","nameError","重复添加!");
        }
        //校验,结合实体注解校验
        if(result.hasErrors()){
            return "admin/types-input";
        }
        Type tmp2 = typeService.saveType(type);
        if (tmp2==null){
            attributes.addFlashAttribute("message","新增失败");
        }else{
            attributes.addFlashAttribute("message","新增成功");
        }
        return "redirect:/admin/types";
    }

    @PostMapping("/types/{id}")
    public String editPost(@Valid Type type, BindingResult result, @PathVariable Long id,RedirectAttributes attributes){
        Type tmp = typeService.getTypeByName(type.getName());
        if(tmp!=null){
            result.rejectValue("name","nameError","重复添加!");
        }
        if(result.hasErrors()){
            return "admin/types-input";
        }
        Type tmp2 = typeService.updateType(id,type);
        if (tmp2==null){
            attributes.addFlashAttribute("message","新增失败");
        }else{
            attributes.addFlashAttribute("message","新增成功");
        }
        return "redirect:/admin/types";
    }

    @GetMapping("/types/{id}/delete")
    public String delete(@PathVariable Long id,RedirectAttributes attributes){
        typeService.deleteType(id);
        attributes.addFlashAttribute("message","删除成功");
        return "redirect:/admin/types";
    }
}

tag的跟这个几乎完全一样,就不贴了

注意
在这里插入图片描述
为什么要用重定向:admin/types中使用了分页查询,如果直接跳转,会导致无法看到最新数据

JPA封装的page数据格式
content中的内容是实体的属性键值对,其他都是固定的

page
{
	"content":[    
		{"id":123,"title":"blog122","content":"this is blog content"},    
		{"id":122,"title":"blog121","content":"this is blog content"}, 
	],
	
	"last":false,  //是否是最后一页
	"totalPages":9,  
	"totalElements":123,  
	"size":15,  //每页数据条数
	"number":0,   //当前页 
	"first":true,  
	"sort":[{    
		"direction":"DESC",    
		"property":"id",    
		"ignoreCase":false,    
		"nullHandling":"NATIVE",    
		"ascending":false
	}],  
	"numberOfElements":15  //当前页的数据有多少条
}

后端校验

假设运行流程:
首页单击链接,通过A控制器,到达目标页面
目标页面输入信息,提交请求到B控制器
实体类为Type

  1. 实体类中增加校验注解(以name上面校验为例)
    @NotBlank(message = “不能为空”)是后端数据校验功能
    String name;

  2. A控制器中放入一个空的实体
    model.addAttribute(“type”,new Type());

  3. B控制器中进行校验(这里只保留了校验错误发生时所需的代码)

public String post(@Valid Type type,BindingResult result) {
//如果result中存在校验错误,则返回到输入页面
	if (result.hasErrors()) {
	    return "admin/types-input";
	}
}

@Valid Type type表示对type进行校验,校验方式就是我们在该实体类中所标注的校验注解
BindingResult result 接收校验之后的结果

  1. 前端页面显示校验结果(message)
    • 前端页面的form标签上声明这个实体
      <form action="#" method="post"  th:object="${type}">
      
    • 在该form中准备输入被校验的值(name)上声明校验
      <input type="text" name="name" placeholder="分类名称" th:value="*{name}" >
      
    • 显示校验结果
      <!--/*/
      <div th:if="${#fields.hasErrors('name')}"  >
      	<div class="header">验证失败</div>
      	<p th:errors="*{name}">提交信息不符合规则</p>
      </div>
      /*/-->
      
      fields.hasErrors(‘name’) 中的name是指被校验注解标注的字段名
      *{name}中的星号可以理解为object

此外,通过BindingResult 还可以自定义错误校验,绕过注解校验
如:如果用户输入的名字重复了,可以通过result进行返回错误,显示方法跟上述第4步一致。

Type type = typeService.getTypeByName(type.getName());
if (type != null) {
    result.rejectValue("name","nameError","不能添加重复的分类");
}

result.rejectValue(“校验字段名”,“自定义错误名”,“前端返回错误信息”);

作用机制流程
首先在实体类上标注校验
然后将用户输入的信息放入控制器准备的空实体
该实体会被传输到后台,后台进行校验,并返回校验结果

注意,@Valid 实体类和BindingResult必须挨着,不然无效

7.3 博客管理(含重要注释)

Dao

public interface BlogRepository extends JpaRepository<Blog,Long>, JpaSpecificationExecutor<Blog> {
}

Service层
接口

public interface BlogService {
    Blog saveBlog(Blog blog);
    Blog getBlog(Long id);
    Page<Blog> listBlog(Pageable pageable, BlogQuery blog); //这个用于复杂分页查询
    Page<Blog> listBlog(Pageable pageable);  //这个用于刚进入博客时展示所有博客
    Blog updateBlog(Long id, Blog blog);
    void deleteBlog(Long id);
}

实现

package com.ddw.blog.service;


import com.ddw.blog.dao.BlogRepository;
import com.ddw.blog.exception.NotFoundException;
import com.ddw.blog.po.Blog;
import com.ddw.blog.po.Type;
import com.ddw.blog.utils.MyBeanUtils;
import com.ddw.blog.vo.BlogQuery;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.xml.crypto.Data;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@Service
public class BlogServiceImpl implements BlogService {

    @Autowired
    BlogRepository BlogRepository;


    @Override
    public Blog getBlog(Long id) {
        return BlogRepository.getOne(id);
    }

    @Override
    public Page<Blog> listBlog(Pageable pageable, BlogQuery blog) {
        return BlogRepository.findAll(new Specification<Blog>() {
            @Override
            public Predicate toPredicate(Root<Blog> root, CriteriaQuery<?> cq, CriteriaBuilder cb) {
                List<Predicate> predicates = new ArrayList<>(); //存放查询条件
                if(!"".equals(blog.getTitle()) && blog.getTitle()!=null) //如果标题不为空
                    predicates.add(cb.like(root.<String>get("title"),"%"+blog.getTitle()+"%"));
                if(blog.getTypeId()!=null) //如果标签不为空
                    predicates.add(cb.equal(root.<Type>get("type").get("id"),blog.getTypeId()));
                if(blog.isRecommend())
                    predicates.add(cb.equal(root.<Boolean>get("recommend"),blog.isRecommend()));
                cq.where(predicates.toArray(new Predicate[predicates.size()]));  //cq.where必须传一个数组
                return null;
            }
        },pageable);
    }

    @Transactional
    @Override
    public Blog saveBlog(Blog Blog) {
        //初始化文章,传过来的文章并没有对时间进行处理
        Blog.setCreateTime(new Date());
        Blog.setUpdateTime(new Date());
        Blog.setViews(0);
        //如果将更新和插入方法公用,会出现错误:A数据原来有abc字段,当更新时,更新了ab,如果传过来的数据不包含c,那c会被置为null
        return BlogRepository.save(Blog);
    }


    @Override
    public Page<Blog> listBlog(Pageable pageable) {
        return BlogRepository.findAll(pageable);
    }

    @Transactional
    @Override
    public Blog updateBlog(Long id, Blog Blog) {
        Blog tmp = getBlog(id);
        if (tmp==null){
            throw new NotFoundException("文章不存在");
        }
        //如果直接将前端传来的blog copy 给数据库查到的tmp,则blog中的null会覆盖tmp原来有数据的字段
        //因此,要忽略掉blog中属性值为空的字段
        BeanUtils.copyProperties(Blog,tmp, MyBeanUtils.getNullPropertyNames(Blog));
        tmp.setUpdateTime(new Date());
        return BlogRepository.save(tmp);
    }

    @Transactional
    @Override
    public void deleteBlog(Long id) {
        BlogRepository.deleteById(id);
    }
}

控制器

package com.ddw.blog.controller.admin;

import com.ddw.blog.po.Blog;
import com.ddw.blog.po.User;
import com.ddw.blog.service.BlogService;
import com.ddw.blog.service.TagService;
import com.ddw.blog.service.TypeService;
import com.ddw.blog.vo.BlogQuery;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import javax.servlet.http.HttpSession;

@Controller
@RequestMapping("/admin")
public class BlogController {

    @Autowired
    BlogService blogService;
    @Autowired
    TypeService typeService;
    @Autowired
    TagService tagService;

    @GetMapping("/blogs") //进入文章管理页面
    public String blogs(@PageableDefault(size = 2,sort = {"updateTime"},direction = Sort.Direction.DESC)Pageable pageable,
                        Model model) {
        model.addAttribute("page",blogService.listBlog(pageable));
        model.addAttribute("types",typeService.listType());
        return "/admin/blogs";
    }

    @PostMapping("/blogs/search") //单击查询
    public String search(@PageableDefault(size = 2, sort = {"updateTime"}, direction = Sort.Direction.DESC) Pageable pageable,
                         BlogQuery blog, Model model) {
        //直接翻页的时候,调用的也是这个,此时BlogQuery为空,直接食用pageable进行查询
        model.addAttribute("page", blogService.listBlog(pageable, blog));
        return "/admin/blogs :: blogList"; //部分刷新
    }

    //公用方法,拿到所有的type和tag保存在模板引擎
    //用于给用户从所有type和tag中进行选择
    private void setTypeAndTag(Model model){
        model.addAttribute("types",typeService.listType());
        model.addAttribute("tags",tagService.listTag());

    }

    //编辑文章页面
    @GetMapping("/blogs/{id}/input")
    public String editInput(@PathVariable Long id,Model model){
        //数据回显
        setTypeAndTag(model);
        Blog blog = blogService.getBlog(id);
        //由于前端标签选择栏的多个tags形式为“1,2,3”,因此需要额外给blog实体增加一个tagIds保存字符串
        // 并提供一个方法将list<tag>转化为String tagIds
        blog.init();
        model.addAttribute("blog",blog);
        return "/admin/blogs-input";
    }

    //进入新增页面
    @GetMapping("/blogs/input")
    public String input(Model model){
        setTypeAndTag(model);
        //由于新增页面和编辑页面共用了一个页面,因此为了保证页面解析正确,这里加一个空对象
        model.addAttribute("blog",new Blog());
        return "admin/blogs-input";
    }
    //保存/发布文章请求进入这里
    @PostMapping("/blogs")
    public String post(Blog blog, RedirectAttributes attributes, HttpSession session){
        //传递过来的blog只包含title,type,tagIds,图片,content等;这里进行初始化
        //这句话是为了设置博客的作者,如果不加也没关系,不过数据库中blog对应的user_id为null
        blog.setUser((User) session.getAttribute("user"));
        blog.setType(typeService.getType(blog.getType().getId()));
        blog.setTags(tagService.listTag(blog.getTagIds())); //按照前端传过来的tag“1,2,3”查询标签

        Blog b;
        if (blog.getId()==null)
            b = blogService.saveBlog(blog);
        else
            b = blogService.updateBlog(blog.getId(),blog);
        if (b ==null){
            attributes.addFlashAttribute("message","操作失败");
        }else {
            attributes.addFlashAttribute("message","操作成功");
        }
        return "redirect:/admin/blogs";
    }

    @GetMapping("/blogs/{id}/delete")
    public String delete(@PathVariable Long id, RedirectAttributes attributes){
        blogService.deleteBlog(id);
        attributes.addFlashAttribute("message","删除成功");
        return "redirect:/admin/blogs";
    }
}


简单分页查询

  1. Dao—提供继承JpaRepository的接口
    public interface BlogRepository extends JpaRepository<Blog,Long> {
    }
    
  2. Service—提供分页查询方法,使用findAll(Pageable)方法
    public Page<Blog> listBlog(Pageable pageable, BlogQuery blog) {
        return BlogRepository.findAll(new Specification<Blog>() {
            @Override
            public Predicate toPredicate(Root<Blog> root, CriteriaQuery<?> cq, CriteriaBuilder cb) {
                List<Predicate> predicates = new ArrayList<>(); //存放查询条件
                if(!"".equals(blog.getTitle()) && blog.getTitle()!=null) //如果标题不为空
                    predicates.add(cb.like(root.<String>get("title"),"%"+blog.getTitle()+"%"));
                if(blog.getTypeId()!=null) //如果标签不为空
                    predicates.add(cb.equal(root.<Type>get("type").get("id"),blog.getTypeId()));
                if(blog.isRecommend())
                    predicates.add(cb.equal(root.<Boolean>get("recommend"),blog.isRecommend()));
                cq.where(predicates.toArray(new Predicate[predicates.size()]));  //cq.where必须传一个数组
                return null;
            }
        },pageable);
    }
    
  3. Controller—接收Pageable对象,并利用Service中的分页查询方法查询page,保存到视图中
    @GetMapping("/tags")
    public String tags(@PageableDefault(size = 3,sort = {"id"},direction = Sort.Direction.DESC) Pageable pageable, Model model) {
    	model.addAttribute("page",tagService.listTag(pageable));
    	return "admin/tags";
    }
    

机制:

  • 1.(第一次)前端访问控制器,控制器初始化Pageable对象,初始化相应的size、sort等page信息
  • 2.控制器中将Pageable中的信息传递给Service中的分页查询方法,查询返回一个Page
  • 3.控制器将该Page放入视图中,传递到模板引擎,模板引擎渲染数据到视图,返回给前端。
  • 4.(第一次之后)前端进行翻页(${page.number}+1),控制器利用前端传递过来的翻页信息和控制器声明的信息对Pageable对象进行初始化
  • 5.重复2~3

复杂分页查询

机制:

  • 1.(第一次)前端访问控制器,控制器初始化Pageable对象,初始化相应的size、sort等page信息,初始化查询vo,此时vo为空,查询结构为空
  • 2.前端进行条件搜索,搜索条件作为vo发送给控制器,同时携带了Pageable信息
  • 3.控制器中将Pageable中的信息和vo传递给Service中的分页查询方法,查询返回一个Page
  • 4.控制器将该Page放入视图中,传递到模板引擎,模板引擎渲染数据到视图,返回给前端。

注意:分页结果是一个完整的po,分页查询条件是一个vo。因此前端进行翻页的时候,除了将page的页码信息(${page.number}+1)传递给控制器,还得将vo传递给控制器

  1. Dao—提供继承JpaRepository和JpaSpecificationExecutor接口
    public interface BlogRepository extends JpaRepository<Blog,Long>, 	JpaSpecificationExecutor<Blog> {
    }
    
  2. Service—提供分页查询方法,加上复杂分页查询vo,使用findAll(Specification,Pageable)方法
    Page<Blog> listBlog(Pageable pageable,BlogQuery blog){
    	blogRepository.findAll(pageable);
    };
    
  3. Controller—接收Pageable对象,并利用Service中的分页查询方法查询page,保存到视图中
    @PostMapping("/blogs/search") //单击查询
    public String search(@PageableDefault(size = 8, sort = {"updateTime"}, direction = Sort.Direction.DESC) Pageable pageable,
                         BlogQuery blog, Model model) {
        model.addAttribute("page", blogService.listBlog(pageable, blog));
        return "/admin/blogs :: blogList"; //部分刷新
    }
    

Predicate:动态查询条件的容器
Root:查询对象,可以从中获取到表的字段
CriteriaBuilder:设置条件表达式
CriteriaQuery:进行查询


8. 前端展示

8.1 首页展示

控制器

package com.ddw.blog.controller;

import com.ddw.blog.po.Blog;
import com.ddw.blog.po.Tag;
import com.ddw.blog.po.Type;
import com.ddw.blog.service.BlogService;
import com.ddw.blog.service.TagService;
import com.ddw.blog.service.TypeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import java.util.List;

@Controller
public class IndexController {

    @Autowired
    BlogService blogService;
    @Autowired
    TagService tagService;
    @Autowired
    TypeService typeService;

    @GetMapping("/")
    public String index(@PageableDefault(size = 8,sort = {"updateTime"},direction = Sort.Direction.DESC) Pageable pageable,
                        Model model ){
        //数据回显
        Page<Blog> blogs  = blogService.listBlog(pageable);
        model.addAttribute("page",blogs);
        List<Type> types = typeService.listTypeTop(6);
        model.addAttribute("types",types);
        List<Tag> tags = tagService.listTagTop(10);
        model.addAttribute("tags",tags);
        //如果是templates下的文件夹下的html,则文件夹需要加/,如果直接是templates下的html,则不用加/
        return "index";
    }

    @GetMapping("/blog/{id}")
    public String blog(@PathVariable Long id,Model model){
        Blog blog = blogService.getBlog(id);
        model.addAttribute("blog",blog);
        return "blog";
    }
}

需要增加“按博客数量排序返回前n个type\tag对象”的方法。
这里以返回type为例。

Dao

public interface TypeRepository extends JpaRepository<Type,Long> {
    Type findByName(String name);

//传入pageable对象,通过自定义查询,查找到所有的type,放入List<Type>中
    @Query("select t from Type t")
    List<Type> findTop(Pageable pageable);
}

service实现

视频给的方法已经过期了,这个是查看文档更改的方法

@Override
public List<Type> listTypeTop(Integer size) {
    //按type中的<List>blogs.size 降序排序
    Sort sort = Sort.by(Sort.Direction.DESC,"blogs.size");
    //从0~size,按sort方法生成分页
    Pageable pageable = PageRequest.of(0,size,sort);
    return TypeRepository.findTop(pageable);
}

8.2 全局search

Dao

public interface BlogRepository extends JpaRepository<Blog,Long>, JpaSpecificationExecutor<Blog> {
    //从Blog中查询,按blog的title或者content与参数1进行相似比较
    @Query("select b from Blog b where b.title like ?1 or b.content like ?1")
    Page<Blog> findByQuery(String query,Pageable pageable);
}

service

@Override
public Page<Blog> listBlog(String query, Pageable pageable) {
    return BlogRepository.findByQuery(query,pageable);
}

控制器

//全局搜索
@PostMapping("/search")
public String search(@PageableDefault(size = 8,sort = {"updateTime"},direction = Sort.Direction.DESC)Pageable pageable,
                     @RequestParam String query,Model model){
    //jpa的Query不会自动处理like查询所需的百分号,这里手动加上
    model.addAttribute("page",blogService.listBlog("%"+query+"%",pageable));
    model.addAttribute("query",query);
    return "search";
}

搜索按钮是一个i标签,因此需要绑定submit方法
在这里插入图片描述

8.3 文章详情页

虽然提供了markdown文本编辑器,但是提交到数据的内容还是markdown文本,而实际展示页面要有markdown的样式的化是需要转为html文本的。
因此,这里增加markdown转html的功能

导入依赖

<!--        markdown转html-->
		<!--基本包-->
        <dependency>
            <groupId>com.atlassian.commonmark</groupId>
            <artifactId>commonmark</artifactId>
            <version>0.10.0</version>
        </dependency>
        
        <!--处理head,使其生成id实现页内跳转和页内目录-->
        <dependency>
            <groupId>com.atlassian.commonmark</groupId>
            <artifactId>commonmark-ext-heading-anchor</artifactId>
            <version>0.10.0</version>
        </dependency>

		<!--处理table-->
        <dependency>
            <groupId>com.atlassian.commonmark</groupId>
            <artifactId>commonmark-ext-gfm-tables</artifactId>
            <version>0.10.0</version>
        </dependency>
<!--        markdown转html end-->

Utils
生成一个markdown转html的Utils工具

package com.ddw.blog.utils;

import org.commonmark.Extension;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.ext.heading.anchor.HeadingAnchorExtension;
import org.commonmark.node.Link;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.AttributeProvider;
import org.commonmark.renderer.html.AttributeProviderContext;
import org.commonmark.renderer.html.AttributeProviderFactory;
import org.commonmark.renderer.html.HtmlRenderer;

import java.util.*;
public class MarkdownUtils {

    /**
     * markdown格式转换成HTML格式的基本语法
     * @param markdown
     * @return
     */
    public static String markdownToHtml(String markdown) {
        Parser parser = Parser.builder().build();
        Node document = parser.parse(markdown);
        HtmlRenderer renderer = HtmlRenderer.builder().build();
        return renderer.render(document);
    }

    /**
     * 增加扩展[标题锚点,表格生成]
     * Markdown转换成HTML
     * @param markdown
     * @return
     */
    public static String markdownToHtmlExtensions(String markdown) {
        //h标题生成id
        Set<Extension> headingAnchorExtensions = Collections.singleton(HeadingAnchorExtension.create());
        //转换table的HTML
        List<Extension> tableExtension = Arrays.asList(TablesExtension.create());
        Parser parser = Parser.builder()
                .extensions(tableExtension)
                .build();
        Node document = parser.parse(markdown);
        HtmlRenderer renderer = HtmlRenderer.builder()
                .extensions(headingAnchorExtensions)
                .extensions(tableExtension)
                .attributeProviderFactory(new AttributeProviderFactory() {
                    public AttributeProvider create(AttributeProviderContext context) {
                        return new CustomAttributeProvider();
                    }
                })
                .build();
        return renderer.render(document);
    }

    /**
     * 处理标签的属性
     */
    static class CustomAttributeProvider implements AttributeProvider {
        @Override
        public void setAttributes(Node node, String tagName, Map<String, String> attributes) {
            //改变a标签的target属性为_blank
            if (node instanceof Link) {
                attributes.put("target", "_blank");
            }
            if (node instanceof TableBlock) {
                attributes.put("class", "ui celled table");
            }
        }
    }


    public static void main(String[] args) {
        String table = "| hello | hi   | 哈哈哈   |\n" +
                "| ----- | ---- | ----- |\n" +
                "| 斯维尔多  | 士大夫  | f啊    |\n" +
                "| 阿什顿发  | 非固定杆 | 撒阿什顿发 |\n" +
                "\n";
        String a = "[imCoding 爱编程](http://www.lirenmi.cn)";
        System.out.println(markdownToHtmlExtensions(a));
    }
}

service

@Override
public Blog getAndConvert(Long id) {
    Blog blog = BlogRepository.getOne(id);
    if (blog==null)
        throw new NotFoundException("该博客不存在");
    //为了避免将数据库中的markdown文本也转换成html,这里用一个临时的blog接收并转换
    Blog tmp = new Blog();
    BeanUtils.copyProperties(blog,tmp);
    tmp.setContent(MarkdownUtils.markdownToHtmlExtensions(tmp.getContent()));
    return tmp;
}

控制器

@GetMapping("/blog/{id}")
    public String blog(@PathVariable Long id,Model model){
    	//放入markdown被转换为html的blog
        model.addAttribute("blog",blogService.getAndConvert(id));
        return "blog";
    }

8.4 详情评论

controller

package com.ddw.blog.controller;

import com.ddw.blog.po.Comment;
import com.ddw.blog.po.User;
import com.ddw.blog.service.BlogService;
import com.ddw.blog.service.CommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;

import javax.servlet.http.HttpSession;

@Controller
public class CommentController {

    /*
    实现功能:
    1.当用户访问blog/{id}时,载入时页面获取id,发起ajax请求到comments/{id},实现刷新评论区
    2.comment-container加载时,发起ajax请求,查看是否时博主登陆,进行信息回显(不知道为什么,这个没生效)
    3.用户发布评论之后,局部重定向,刷新评论区

     */
    @Autowired
    private CommentService commentService;

    @Autowired
    private BlogService blogService;

    //这里为所有用户配置一个头像,读取配置文件,写死一个
    @Value("${comment.avatar}")
    private String avatar;



    //刷新评论区
    @GetMapping("/comments/{blogId}")
    public String comments(@PathVariable Long blogId, Model model){
        model.addAttribute("comments",commentService.listCommentByBlogId(blogId));
        return "blog::commentList";
    }

    //用户评论是进入这里
    @PostMapping("/comments")
    public String post(Comment comment, HttpSession session){
        Long blogId = comment.getBlog().getId();
        comment.setBlog(blogService.getBlog(blogId));
//        comment.setBlog(comment.getBlog());
        User user = (User) session.getAttribute("user");
        //如果当前是博主在访问,那就设置博主访问信息
        if (user!=null){
            comment.setAvatar(user.getAvatar());
            comment.setAdminComment(true);
        }else {
            comment.setAvatar(avatar);
        }
        commentService.saveComment(comment);
        return "redirect:/comments/"+blogId;
    }
}

ServiceImpl

这个涉及到稍微复杂的逻辑,我改写了视频给的方法。
此外,视频说要操作通过findByBlogIdAndParentCommentNull查出来的数据的拷贝,不然会影响数据库的数据,这个逻辑可能是错的。
findByBlogIdAndParentCommentNull查出来的数据已经放在内存当中了,对数据库应该不会造成影响。
我猜测是不是缓存刷新会导致数据库的数据被刷新?如果有人知道,敬请留言。
此外,作者多次使用BeanUtils的copy功能,操作数据备份,我回去检查了下,有一些跟这里的逻辑一样,似乎也不需要。

package com.ddw.blog.service;

import com.ddw.blog.dao.CommentRepository;
import com.ddw.blog.po.Comment;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@Service
public class CommentServiceImpl implements CommentService {


    @Autowired
    CommentRepository commentRepository;

    @Override
    public Comment saveComment(Comment comment) {
        // 当前端传来一个评论时,判断它是否时顶级回复,如果是顶级回复,则设置parentComment为null
        // 否则通过它的parentCommentId查询它的父级评论,初始化它的相关信息
        Long parentCommentId = comment.getParentComment().getId();
        if (parentCommentId!=-1)
            comment.setParentComment(commentRepository.getOne(parentCommentId));
        else
            comment.setParentComment(null);
        comment.setCreateTime(new Date());
        return commentRepository.save(comment);
    }


    /**
     * 结构闭包分析:
     * 根节点评论A,拥有子节点评论B;子节点评论B,拥有子节点评论C。
     *
     * 结构层次:
     * A(B1,B2,...Bn),B(C1,C2,...Cn)...
     *
     * 算法目标:
     * A(B1,...Bn,C1,...Cn,D1,...Dn,...)
     *
     * 处理逻辑:
     * 0. 创建子节点容器(存放迭代找出的所有子代的集合)
     * 1. 拿到所有根节点As
     * 2. 遍历As,拿到A;通过A,拿到它的子节点Bs,
     * 3. 遍历Bs,拿到B,将B放入子节点容器;通过B,拿到它的子节点Cs
     * 4. 遍历Cs,拿到C,将C放入子节点容器;通过C,拿到它的子节点Ds
     * 5. ......
     * 6. 当Ns为空时结束
     * 7. 将As的所有A的子节点改成子节点容器,清空子节点容器
     * 8. 返回As
     *
     * 上述算法可以通过递归实现
     * 0. 创建子节点容器(存放迭代找出的所有子代的集合)
     * 1. 拿到所有根节点As
     * 2. 遍历As,拿到A;通过A,拿到它的子节点Bs;
     * 3. 遍历Bs,拿到B,如果B不为空,将B放入子节点容器中,并拿到他们的子节点Cs,
     * 4. 递归调用第三步(此时传入的参数Bs=Ns,N=(C,D,E...))
     * 5. 将As的所有A的子节点改成子节点容器,清空子节点容器
     * 6. 返回As
     */

    //0. 创建子节点容器(存放迭代找出的所有子代的集合)
    private List<Comment> tempReplys = new ArrayList<>();

    //1. 拿到所有根节点As
    @Override
    public List<Comment> listCommentByBlogId(Long blogId) {
        Sort sort = Sort.by("createTime");
        //按创建时间,拿到顶级评论(ParentComment为null的字段)
        List<Comment> As = commentRepository.findByBlogIdAndParentCommentNull(blogId,sort);
        return combineChildren(As);
    }

    // 3. 如果Bs.size > 0,遍历Bs,拿到B,将B放入子节点容器中;通过B,拿到他的子节点Cs,
    private void Dep(List<Comment> Bs){
        if (Bs.size()>0){
            for (Comment B : Bs){
                tempReplys.add(B);
                List<Comment> Cs = B.getReplyComments();
                //4. 递归调用
                Dep(Cs);
            }
        }
    }

    //2. 遍历As-Copy,拿到A;通过A,拿到它的子节点Bs;
    private List<Comment> combineChildren(List<Comment> AsCopy) {
        //传入的是顶级节点
        for (Comment A : AsCopy) {
            //通过A,拿到Bs
            List<Comment> Bs = A.getReplyComments();

            // 调用第3步方法
            Dep(Bs);

            //修改顶级节点的reply集合为迭代处理后的集合
            A.setReplyComments(tempReplys);
            //清除临时存放区
            tempReplys = new ArrayList<>();
        }
        return AsCopy;
    }

}

Dao

package com.ddw.blog.dao;

import com.ddw.blog.po.Comment;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface CommentRepository extends JpaRepository<Comment,Long> {
    //通过blogId找到comment,而且ParentComment为Null的记录,并按sort排序
    List<Comment> findByBlogIdAndParentCommentNull(Long blogId, Sort sort);
}

注意
自定义data-commentnickname不能使用驼峰形式,因为$(obj).data(’’)只能识别小写
在这里插入图片描述

8.5 按分类/标签展示

按分类展示

注意springboot的controller即便不同包,也不允许同名

控制器

package com.ddw.blog.controller;

import com.ddw.blog.po.Type;
import com.ddw.blog.service.BlogService;
import com.ddw.blog.service.TypeService;
import com.ddw.blog.vo.BlogQuery;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import java.util.List;

@Controller
public class TypeShowController {

    @Autowired
    private TypeService typeService;

    @Autowired
    private BlogService blogService;

    @GetMapping("/types/{id}")
    public String types(@PageableDefault(size = 8, sort = {"updateTime"}, direction = Sort.Direction.DESC) Pageable pageable,
                        @PathVariable Long id, Model model) {
        List<Type> types = typeService.listTypeTop(10000);
        if (id == -1) {
            //如果从首页进来,则id=-1,默认展示type的第一个
            id = types.get(0).getId();
        }
        //需求是通过id查询blog的分页,没有单独的方法,拿这个BlogQuery也可以
        BlogQuery blogQuery = new BlogQuery();
        blogQuery.setTypeId(id);
        model.addAttribute("types", types);
        model.addAttribute("page", blogService.listBlog(pageable, blogQuery));
        model.addAttribute("activeTypeId", id);
        return "types";
    }
}

按标签展示

控制器

@GetMapping("/tags/{id}")
public String tags(@PageableDefault(size = 8, sort = {"updateTime"}, direction = Sort.Direction.DESC) Pageable pageable,
                    @PathVariable Long id, Model model) {
    List<Tag> tags = tagService.listTagTop(10000);
    if (id == -1) {
       id = tags.get(0).getId();
    }
    model.addAttribute("tags", tags);
    model.addAttribute("page", blogService.listBlog(id,pageable));
    model.addAttribute("activeTagId", id);
    return "tags";
}

BlogServiceImpl
连接查询分页

@Override
public Page<Blog> listBlog(Long tagId, Pageable pageable) {
    return BlogRepository.findAll(new Specification<Blog>() {
        @Override
        public Predicate toPredicate(Root<Blog> root, CriteriaQuery<?> cq, CriteriaBuilder cb) {
        	//将<Blog>root 与 Blog.tags连接
            Join join = root.join("tags");
            //查询root.id =tagId的部分
            return cb.equal(join.get("id"),tagId);
        }
    },pageable);
}

8.6 博客归档

控制器

package com.ddw.blog.controller;

import com.ddw.blog.service.BlogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class ArchiveShowController {

    @Autowired
    private BlogService blogService;

    @GetMapping("/archives")
    public String archives(Model model) {
        model.addAttribute("archiveMap", blogService.archiveBlog());
        model.addAttribute("blogCount", blogService.countBlog());
        return "archives";
    }
}

BlogServiceImpl

@Override
public Map<String, List<Blog>> archiveBlog() {
    List<String> years = BlogRepository.findGroupYear();
    Map<String, List<Blog>> map = new HashMap<>();
    for (String year : years) {
        map.put(year, BlogRepository.findByYear(year));
    }
    return map;
}

@Override
public Long countBlog() {
    return BlogRepository.count();
}

Dao

//注意group by不能用别名
@Query("select function('date_format',b.updateTime,'%Y') as year from Blog b group by function('date_format',b.updateTime,'%Y') order by year desc ")
List<String> findGroupYear();

@Query("select b from Blog b where function('date_format',b.updateTime,'%Y') = ?1")
List<Blog> findByYear(String year);

8.7 关于我与功能完善

关于我
用一个静态页面即可

@Controller
public class AboutShowController {

    @GetMapping("/about")
    public String about() {
        return "about";
    }
}

功能完善

footer的最新文章列表

@GetMapping("/footer/newblog")
public String newblogs(Model model) {
    model.addAttribute("newblogs", blogService.listRecommendBlogTop(3));
    return "_fragments :: newblogList";
}

从配置文件中读值渲染模板
在这里插入图片描述
messages.properties是全局配置(与en-zh互补)

在这里插入图片描述

html模板取值
在这里插入图片描述

9. 打包运行

更改相关配置(比如端口号)
运行mavne命令
在这里插入图片描述
从target中拿到jar包,放到服务器上试试(提前设置好数据库)

完美运行。。。
就不演示了

10. 项目thymeleaf知识点

$取保存在model中的变量
#取配置文件中的值

错误信息在源代码中展示,页面不显示

<div>
    <div th:utext="'&lt;!--'" th:remove="tag"></div>
    <div th:utext="'Failed Request URL : ' + ${url}" th:remove="tag"> </div>
    <div th:utext="'Exception message : ' + ${exception.message}" th:remove="tag"></div>
    <ul th:remove="tag">
        <li th:each="st : ${exception.stackTrace}" th:remove="tag"><span th:utext="${st}" th:remove="tag"></span></li>
    </ul>
    <div th:utext="'--&gt;'" th:remove="tag"></div>
</div>

th:utext与th:text

  • th:text

    • 1.可以对表达式或变量进行求值
    • 2.用“+”符号可进行文本连接
    • 3.当获取后端传来的参数时,若后端有标签,则直接显示html代码(没有解析功能)
  • th:utext

    • 具有解析html字符串的功能

<head th:fragment="head(title)">
  <title th:replace="${title}">title</title>
  <link rel="stylesheet" href="../static/css/me.css" th:href="@{/css/me.css}">
</head>

<head th:fragment=“head(title)”>
声明此处为fragment对象,名字为head,包含参数为title

<title th:replace="${title}">title</title>
意思是将title标签内的内容动态的更改为传参过来的值title

<head th:replace="_fragments :: head(~{::title})">  
</head>

th:replace=“fragments文件名 :: 替换fragment对象名(~{::替换标签名})”
head(~{::title}) —>将一个片段作为参数传入,然后作为替换元素~{::title}表示片段的引用


<tbody>
  <tr th:each="type,iterStat : ${page.content}">
    <td th:text="${iterStat.count}">1</td>
    <td th:text="${type.name}">xx</td>
    <td>
      <a href="#" th:href="@{/admin/types/{id}/input(id=${type.id})}" class="ui mini teal basic button">编辑</a>
      <a href="#" th:href="@{/admin/types/{id}/delete(id=${type.id})}" class="ui mini red basic button">删除</a>
    </td>
  </tr>
</tbody>

each=“type,iterStat:${page.content}”
意思式遍历page.content放到type中,同时保存遍历状态iterStat
iterStat.count表示当前元素的序号
th:href 能够动态替换地址,…{id}…(id=${type.id})表示将将后端传过来的type.id放到id中


<div class="ui mini pagination menu" th:if="${page.totalPages}>1">
  <a th:href="@{/admin/types(page=${page.number}-1)}" class="  item" th:unless="${page.first}">上一页</a>
  <a th:href="@{/admin/types(page=${page.number}+1)}" class=" item" th:unless="${page.last}">下一页</a>
</div>

th:if 如果条件成立则当前标签可见
th:unless 如果条件成立则当前标签不可见


<form action="#" method="post"  th:object="${type}" th:action="*{id}==null ? @{/admin/types} : @{/admin/types/{id}(id=*{id})} "  class="ui form">
  <input type="hidden" name="id" th:value="*{id}">

th:object 拿到后端传递的对象
*{id} 意思式 object.id
之所以放一个hidden input标签,是为了将当前id传递给控制器(也可以不用)

通过:如果id为空,则选择不同的提交路径,实现代码复用。


javascript中含有th代码的时候,需要如下配置才有效。
在这里插入图片描述


archiveMap是Map对象,在th模板中用item接收,则item包含key和value
在这里插入图片描述

11. 相关UI技术

selection

在这里插入图片描述
本项目通过selection给某些字段进行赋值

<div class="ui type selection dropdown">
  <input type="hidden" name="typeId">
  <i class="dropdown icon"></i>
  <div class="default text">分类</div>
  <div class="menu">
    <div th:each="type : ${types}" class="item" data-value="" th:data-value="${type.id}" th:text="${type.name}">错误日志</div>
    <!--/*-->
    <div class="item" data-value="2">开发者手册</div>
    <!--*/-->
  </div>
</div>

此处会将data-value的值赋给input的value
如果这个input在form表单内,则提交表单后后台能够获取到typeId。
或者通过ajax的形式获取到该值进行请求

function loaddata() {
  $("#table-container").load(/*[[@{/admin/blogs/search}]]*/"/admin/blogs/search",{
    title : $("[name='title']").val(),
    typeId : $("[name='typeId']").val(),
    recommend : $("[name='recommend']").prop('checked'),
    page : $("[name='page']").val()
  });
}

下面这种方式是thymeleaf的注释方式,这样注释之后模板引擎渲染后会删除该行,如果打开原生页面,则能看见

<!--/*-->
<div class="item" data-value="2">开发者手册</div>
<!--*/-->

前端非空校验

$('.form').form({
      fields : {
        title : {
          identifier: 'title',
          rules: [{
            type : 'empty',
            prompt: '请输入博客标题'
          }]
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值