前后端分离项目----BlogSystem

前后端分离项目----BlogSystem

1、项目概述

1.1、BlogSystem

  1. 主要是一个前后端分离的基础项目,把后端框架和前端搭建起来,实现登录、编辑、增加、删除、退出等简单功能。主要是展示前后端分离项目的框架如何搭建成,数据库和功能都很简单,当做初学前后端分离项目的一个练手。
  2. 项目中的代码,我尽量会做到全部粘贴上来,方便我自己来理解,步骤也是一步一步的往下,做到可以自己拿着项目讲解的地步。不过很多源码的部分我肯定自己也一知半解甚至完全不知道,但是我尽量会把这些代码搞清楚干什么用的。

1.2、技术栈

  1. 后端:
  • springboot项目架构
  • mybatis-plus 数据库 ,generator代码生成器
  • shiro做安全权限
  • lombok 封装getset方法
  • redis 缓存,会话共享
  • hibernate validatior 实体校验
  • jwt 用户身份凭证
  1. 前端:
  • vue框架
  • Element ui 组件
  • axios 请求服务端接口(前后端交互)
  • mavon-editor — Markdown编辑器

2、项目搭建—后端

2.1、建立项目

  1. 新建一个springboot项目 2.2.6.RELEASE
  2. 导入依赖
 <dependencies>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>

        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-devtoolsartifactId>
            <scope>runtimescope>
            <optional>trueoptional>
        dependency>
     <dependency>
            <groupId>org.projectlombokgroupId>
            <artifactId>lombokartifactId>
            <optional>trueoptional>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-testartifactId>
            <scope>testscope>
        dependency>
   <dependency>
            <groupId>mysqlgroupId>
            <artifactId>mysql-connector-javaartifactId>
            <scope>runtimescope>
        dependency>
  • spring-boot-devtools :热部署依赖,使项目启动速度加快,节省开发时间

需要添加一个配置文件,这样热重启时才不会报错

resource/META-INF/spring-devtools.properties

restart.include.shiro-redis=/shiro-[\\w-\\.]+jar
  1. 建立数据库
CREATE DATABASE`vueblog`;
USE `vueblog`;
DROP TABLE IF EXISTS `m_blog`;
CREATE TABLE `m_blog` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `user_id` bigint(20) NOT NULL,
  `title` varchar(255) NOT NULL,
  `description` varchar(255) NOT NULL,
  `content` longtext,
  `created` datetime NOT NULL ON UPDATE CURRENT_TIMESTAMP,
  `status` tinyint(4) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb4;

insert  into `m_blog`(`id`,`user_id`,`title`,`description`,`content`,`created`,`status`) values 

(1,1,'生活就像海洋,只有意志坚强的人才能到达彼岸','这里是摘要哈哈哈','内容???','2020-05-21 22:08:42',0),

(2,1,'尊重','我以为别人尊重我是因为我很优秀,后来慢慢才明白,别人尊重我,是因为别人很优秀。','内容:我以为别人尊重我是因为我很优秀,后来慢慢才明白,别人尊重我,是因为别人很优秀。','2022-08-22 22:08:42',0);

DROP TABLE IF EXISTS `m_user`;

CREATE TABLE `m_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `username` varchar(64) DEFAULT NULL,
  `avatar` varchar(255) DEFAULT NULL,
  `email` varchar(64) DEFAULT NULL,
  `password` varchar(64) DEFAULT NULL,
  `status` int(5) NOT NULL,
  `created` datetime DEFAULT NULL,
  `last_login` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `UK_USERNAME` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

insert  into `m_user`(`id`,`username`,`avatar`,`email`,`password`,`status`,`created`,`last_login`) values 
/*密码是MD5加密后的*/
(1,'root','https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg',NULL,'96e79218965eb72c92a549dd5a330112',0,'2020-04-20 10:44:01',NULL);

  1. 编写配置文件

application.yml

server:
  port: 8081

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: 123456
    url: jdbc:mysql://localhost:3306/vueblog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai

2.2、整合Mybatis-plus

  1. 导入依赖
  <dependency>
            <groupId>com.baomidougroupId>
            <artifactId>mybatis-plus-boot-starterartifactId>
            <version>3.2.0version>
        dependency>
        
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-freemarkerartifactId>
        dependency>

        <dependency>
            <groupId>com.baomidougroupId>
            <artifactId>mybatis-plus-generatorartifactId>
            <version>3.2.0version>
        dependency>

  • spring-boot-starter-freemarker:页面模板引擎
  • mybatis-plus-generator :代码生成器
  1. 写配置文件

application.yml

mybatis-plus:
  mapper-locations:classpath*:/mapper/**Mapper.xml
  1. 开启 mapper 接口扫描,添加分页插件

com.example.config.MybatisPlusConfig

package com.example.config;

import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration   //组件,添加到容器
@EnableTransactionManagement  //开启事务管理
@MapperScan("com.example.mapper")  //开启mapper接口扫描
public class MybatisPlusConfig {
    //添加分页插件
    @Bean
    public PaginationInterceptor paginationInterceptor(){
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();
        return paginationInterceptor;
    }

}

  1. 代码生成(固定代码)

com.example.CodeGenerator.java

package com.example;
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;


public class CodeGenerator {
    public static String scanner(String tip) {
        Scanner scanner = new Scanner(System.in);
        StringBuilder help = new StringBuilder();
        help.append("请输入" + tip + ":");
        System.out.println(help.toString());
        if (scanner.hasNext()) {
            String ipt = scanner.next();
            if (StringUtils.isNotEmpty(ipt)) {
                return ipt;
            }
        }
        throw new MybatisPlusException("请输入正确的" + tip + "!");
    }

    public static void main(String[] args) {
        // 代码生成器
        AutoGenerator mpg = new AutoGenerator();

        // 全局配置
        GlobalConfig gc = new GlobalConfig();
        String projectPath = System.getProperty("user.dir");
        gc.setOutputDir(projectPath + "/src/main/java");
        gc.setAuthor("xqh");
        gc.setOpen(false);
        // gc.setSwagger2(true); 实体属性 Swagger2 注解
        gc.setServiceName("%sService");
        mpg.setGlobalConfig(gc);

        // 数据源配置
        DataSourceConfig dsc = new DataSourceConfig();
        dsc.setUrl("jdbc:mysql://localhost:3306/vueblog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai");
        // dsc.setSchemaName("public");
        dsc.setDriverName("com.mysql.cj.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("123456");
        mpg.setDataSource(dsc);

        // 包配置
        PackageConfig pc = new PackageConfig();
        pc.setModuleName(null);
        pc.setParent("com.example");
        mpg.setPackageInfo(pc);

        // 自定义配置
        InjectionConfig cfg = new InjectionConfig() {
            @Override
            public void initMap() {
                // to do nothing
            }
        };

        // 如果模板引擎是 freemarker
        String templatePath = "/templates/mapper.xml.ftl";
        // 如果模板引擎是 velocity
        // String templatePath = "/templates/mapper.xml.vm";

        // 自定义输出配置
        List<FileOutConfig> focList = new ArrayList<>();
        // 自定义配置会被优先输出
        focList.add(new FileOutConfig(templatePath) {
            @Override
            public String outputFile(TableInfo tableInfo) {
                // 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
                return projectPath + "/src/main/resources/mapper/"
                        + "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
            }
        });

        cfg.setFileOutConfigList(focList);
        mpg.setCfg(cfg);

        // 配置模板
        TemplateConfig templateConfig = new TemplateConfig();

        templateConfig.setXml(null);
        mpg.setTemplate(templateConfig);

        // 策略配置
        StrategyConfig strategy = new StrategyConfig();
        strategy.setNaming(NamingStrategy.underline_to_camel);
        strategy.setColumnNaming(NamingStrategy.underline_to_camel);
        strategy.setEntityLombokModel(true);
        strategy.setRestControllerStyle(true);
        strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
        strategy.setControllerMappingHyphenStyle(true);
        strategy.setTablePrefix("m_");
        mpg.setStrategy(strategy);
        mpg.setTemplateEngine(new FreemarkerTemplateEngine());
        mpg.execute();
    }
}
  1. 运行CodeGenerator
  • 输入表名 : m_user , m_blog
  • 一键生成代码(mapper、service、controller)
  1. 整合MybatisPlus完毕,写一个controller测试一下能否拿到数据库

com.example.controller.UserController.java

@RestController   //返回的是json
@RequestMapping("/user")
public class UserController {
    @Autowired
    UserService userService;
    @GetMapping("/{id}")
    public Object test(@PathVariable("id") Long id) {
        return userService.getById(id);
    }
}
  • 运行项目主启动项
  • 访问:http://localhost:8080/user/1 ,拿到user表中的第一条数据 测试成功

2.3、统一结果封装

  1. 这里我们用到了一个 Result 的类,这个用于我们的异步统一返回的结果封装。方便测试接口时返回结果。对结果进行封装,一般来说,结果里面有几个要素必要的
  • 是否成功,可用 code 表示(如 200 表示成功,400 表示异常)
  • 结果消息
  • 结果数据
  1. com.example.common.lang.Result.java
package com.example.common.lang;
import lombok.Data;

import java.io.Serializable;
@Data
public class Result implements Serializable {
    private int code;
    private String msg;
    private Object data;

//封装succ方法,因为一般情况下操作成功code,msg都是固定,只有返回对象不同
    public static Result succ(Object data){
        return succ(200,"操作成功",data);
    }

    public static Result succ(int code,String msg,Object data){
        Result r = new Result();
        r.setCode(code);
        r.setMsg(msg);
        r.setData(data);
        return r;

    }
//封装fail方法,返回400固定,给的错误信息不同
    public static Result fail(String msg){
        return succ(400,msg,null);
    }

//封装fail方法,返回400固定,给的错误信息和返回的对象不同
    public static Result fail(String msg,Object data){
        return fail(400,msg,data);
    }
    public static Result fail(int code,String msg,Object data){
        Result r = new Result();
        r.setCode(code);
        r.setMsg(msg);
        r.setData(data);
        return r;

    }
}

  • 将返回结果封装,在Result类中,又对succ、fail方法进行封装,分情况简化代码
  1. 测试

在UserController中

@GetMapping("/index")
    public Result getById(){
        User user = userService.getById(1L);
        return Result.succ(user);
    }
  • 运行后看返回结果

2.4、整合Shiro+Jwt,并会话共享

  • 考虑到后面可能需要做集群、负载均衡等,所以就需要会话共享,而 shiro 的缓存和会话信息,我们一般考虑使用 redis 来存储这些数据,所以,我们不仅仅需要整合 shiro,同时也需要整合 redis。而因为我们需要做的是前后端分离项目的骨架,所以一般我们会采用 token 或者 jwt 作为跨域身份验证解决方案。所以整合 shiro 的过程中,我们需要引入 jwt 的身份验证过程。
  1. 导入依赖
 <dependency>
            <groupId>org.crazycakegroupId>
            <artifactId>shiro-redis-spring-boot-starterartifactId>
            <version>3.2.1version>
        dependency>
        
        <dependency>
            <groupId>cn.hutoolgroupId>
            <artifactId>hutool-allartifactId>
            <version>5.3.3version>
        dependency>
        
        <dependency>
            <groupId>io.jsonwebtokengroupId>
            <artifactId>jjwtartifactId>
            <version>0.9.1version>
        dependency>
  • shiro-redis-spring-boot-starter :快速整合 shiro-redis

  • hutool-all : Hutool 是一个小而全的 Java 工具类库,通过静态方法封装,降低相关API的学习成本,提高工作效率,使 Java 拥有函数式语言般的优雅,让 Java 语言也可以“甜甜的”。

    Hutool 中的工具方法来自每个用户的精雕细琢,它涵盖了 Java 开发底层代码中的方方面面,它既是大型项目开发中解决小问题的利器,也是小型项目中的效率担当;一个 Java 基础工具类,对文件、流、加密、解密、转码、正则、线程、XML等JDK方法进行封装,组成各种Util工具类

  • jjwt :通俗地说,JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个JWT token并且这个JWT token带有签名信息,接收后可以校验是否被篡改,所以可以用于在各方之间安全地将信息作为Json对象传输。JWT的认证流程如下:

    1. 首先,前端通过Web表单将自己的用户名和密码发送到后端的接口,这个过程一般是一个POST请求。建议的方式是通过SSL加密的传输(HTTPS),从而避免敏感信息被嗅探
    2. 后端核对用户名和密码成功后,将包含用户信息的数据作为JWT的Payload,将其与JWT Header分别进行Base64编码拼接后签名,形成一个JWT Token,形成的JWT Token就是一个如同lll.zzz.xxx的字符串
    3. 后端将JWT Token字符串作为登录成功的结果返回给前端。前端可以将返回的结果保存在浏览器中,退出登录时删除保存的JWT Token即可
    4. 前端在每次请求时将JWT Token放入HTTP请求头中的Authorization属性中(解决XSS和XSRF问题)
    5. 后端检查前端传过来的JWT Token,验证其有效性,比如检查签名是否正确、是否过期、token的接收方是否是自己等等
    6. 验证通过后,后端解析出JWT Token中包含的用户信息,进行其他逻辑操作(一般是根据用户信息得到权限等),返回结果

jwt就是利用token进行用户身份验证时token的一种具体实现方式。 客户端使用用户名和密码登录,服务端收到请求,验证用户名和密码,验证成功后经过一系列操作(将包含用户信息的数据作为JWT的Payload,将其与JWT Header分别进行Base64编码拼接后签名)形成一个JwtToken 返回到客户端 。客户端每次请求资源时都需要袖带这个jwtToken ,服务端收到请求再去验证JwtToken的有效性等。

  1. 编写配置文件

application.yml

shiro-redis:
  enabled: true
  redis-manager:
    host: 127.0.0.1:6379


root:
  jwt:
    secret: f4e2e52034348f86b67cde581c0f9eb5
    expire: 604800
    header: Authorization
  1. 编写Shiro配置(代码比较固定,官方文档有)

com.example.config.ShrioConfig.java

package com.example.config;
import com.example.shiro.AccountRealm;
import com.example.shiro.JwtFilter;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.crazycake.shiro.RedisCacheManager;
import org.crazycake.shiro.RedisSessionDAO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

@Configuration
public class ShiroConfig {
    @Autowired
    JwtFilter jwtFilter;
    @Bean
    public SessionManager sessionManager(RedisSessionDAO redisSessionDAO) {
        DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
        sessionManager.setSessionDAO(redisSessionDAO);
        return sessionManager;
    }

    @Bean
    public DefaultWebSecurityManager securityManager(AccountRealm accountRealm,
                                                     SessionManager sessionManager,
                                                     RedisCacheManager redisCacheManager) {

        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(accountRealm);

        //inject sessionManager
        securityManager.setSessionManager(sessionManager);

        // inject redisCacheManager
        securityManager.setCacheManager(redisCacheManager);
        return securityManager;
    }
    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();

        Map<String, String> filterMap = new LinkedHashMap<>();

        filterMap.put("/**", "jwt");
        chainDefinition.addPathDefinitions(filterMap);
        return chainDefinition;
    }

    @Bean("shiroFilterFactoryBean")
    public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager,
                                                         ShiroFilterChainDefinition shiroFilterChainDefinition) {
        ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
        shiroFilter.setSecurityManager(securityManager);

        Map<String, Filter> filters = new HashMap<>();
        filters.put("jwt", jwtFilter);
        shiroFilter.setFilters(filters);

        Map<String, String> filterMap = shiroFilterChainDefinition.getFilterChainMap();

        shiroFilter.setFilterChainDefinitionMap(filterMap);
        return shiroFilter;
    }

}

上面 ShiroConfig,我们主要做了几件事情:

  • 引入 RedisSessionDAO 和 RedisCacheManager ,为了解决 shiro 的权限数据和会话信息能保存到 redis 中,实现会话共享。
  • 重写了 SessionManager 和 DefaultWebSecurityManager,同时在 DefaultWebSecurityManager 中为了关闭 shiro 自带的 session 方式,我们需要设置为 false,这样用户就不再能通过 session 方式登录 shiro。后面将采用 jwt 凭证登录。
  • 在 ShiroFilterChainDefinition 中,我们不再通过编码形式拦截 Controller 访问路径,而是所有的路由都需要经过 JwtFilter 这个过滤器,然后判断请求头中是否含有 jwt 的信息,有就登录,没有就跳过。跳过之后,有 Controller 中的 shiro 注解进行再次拦截,比如 @RequiresAuthentication,这样控制权限访问。
  1. AccountRealm
  • shiro进行登录或者权限校验的逻辑所在,需要重写三个方法

com.example.shiro.AccountRealm.java

package com.example.shiro;

import cn.hutool.core.bean.BeanUtil;
import com.example.entity.User;
import com.example.service.UserService;
import com.example.util.JwtUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class AccountRealm extends AuthorizingRealm {
    @Autowired
    JwtUtils jwtUtils;

    @Autowired
    UserService userService;
//为了让Realm支持jwt的凭证校验
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }
//权限校验
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }
//登录认证校验
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

        JwtToken jwtToken = (JwtToken) token;

        String userId = jwtUtils.getClaimByToken((String) jwtToken.getPrincipal()).getSubject();

        User user = userService.getById(Long.valueOf(userId));
        if (user == null) {
            throw new UnknownAccountException("账户不存在");
        }

        if (user.getStatus() == -1) {
            throw new LockedAccountException("账户已被锁定");
        }
//把一些可以公共的信息封装起来
        AccountProfile profile = new AccountProfile();
        BeanUtil.copyProperties(user, profile);  //copy,把user对象里的东西copy到profile里面

        return new SimpleAuthenticationInfo(profile, jwtToken.getCredentials(), getName());
    }
}

  1. 自定义JwtToken
  • shiro 默认 supports 的是 UsernamePasswordToken,而我们现在采用了 jwt 的方式,所以这里我们自定义一个 JwtToken,来完成 shiro 的 supports 方法。

com.example.shiro.JwtToken.java

package com.example.shiro;

import org.apache.shiro.authc.AuthenticationToken;

public class JwtToken implements AuthenticationToken {
    private String token;

    public JwtToken(String jwt) {
        this.token = jwt;
    }
    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}

  1. JwtUtils -生成和校验 jwt 的工具类

com.example.util.JwtUtils.java

package com.example.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Date;
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "markerhub.jwt")   //这个和配置文件一致
public class JwtUtils {

    private String secret;
    private long expire;
    private String header;

    /**
     * 生成jwt token
     */
    public String generateToken(long userId) {
        Date nowDate = new Date();
        //过期时间
        Date expireDate = new Date(nowDate.getTime() + expire * 1000);

        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject(userId+"")
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    public Claims getClaimByToken(String token) {
        try {
            return Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token)
                    .getBody();
        }catch (Exception e){
            log.debug("validate is token error ", e);
            return null;
        }
    }

    /**
     * token是否过期
     * @return  true:过期
     */
    public boolean isTokenExpired(Date expiration) {
        return expiration.before(new Date());
    }
}

  1. AccountProfile 把一些公共信息封装起来 这是为了登录成功之后返回的一个用户信息的载体

com.example.shiro.AccountProfile.java

package com.example.shiro;

import lombok.Data;

import java.io.Serializable;

//把一些可以公共的信息封装起来  ,除去密码
@Data
public class AccountProfile implements Serializable {

    private Long id;

    private String username;

    private String avatar;

    private String email;
}

  1. 定义 jwt 的过滤器 JwtFilter

com.example.shiro.JwtFilter.java

package com.example.shiro;
import cn.hutool.json.JSONUtil;
import com.example.common.lang.Result;
import com.example.util.JwtUtils;
import io.jsonwebtoken.Claims;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.ExpiredCredentialsException;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.apache.shiro.web.util.WebUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtFilter extends AuthenticatingFilter {
    @Autowired
    JwtUtils jwtUtils;
    @Override
    protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String jwt = request.getHeader("Authorization");
        if(StringUtils.isEmpty(jwt)) {
            return null;
        }

        return new JwtToken(jwt);
    }

    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String jwt = request.getHeader("Authorization");
        if(StringUtils.isEmpty(jwt)) {
            return true;
        } else {

            // 校验jwt
            Claims claim = jwtUtils.getClaimByToken(jwt);
            if(claim == null || jwtUtils.isTokenExpired(claim.getExpiration())) {
                throw new ExpiredCredentialsException("token已失效,请重新登录");
            }

            // 执行登录
            return executeLogin(servletRequest, servletResponse);
        }
    }

    //执行登录异常进行处理
    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {

        HttpServletResponse httpServletResponse = (HttpServletResponse) response;

        Throwable throwable = e.getCause() == null ? e : e.getCause();
        Result result = Result.fail(throwable.getMessage());
        String json = JSONUtil.toJsonStr(result);

        try {
            httpServletResponse.getWriter().print(json);
        } catch (IOException ioException) {

        }
        return false;
    }

    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {

        HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
        HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
            return false;
        }

        return super.preHandle(request, response);
    }
}

我们重写了几个方法:

  • createToken :实现登录,我们需要生成我们自定义支持的 JwtToken
  • onAccessDenied :拦截校验,当头部没有 Authorization 时候,我们直接通过,不需要自动登录;当带有的时候,首先我们校验 jwt 的有效性,没问题我们就直接执行 executeLogin 方法实现自动登录
  • onLoginFailure :登录异常时候进入的方法,我们直接把异常信息封装然后抛出
  • preHandle :拦截器的前置拦截,因为我们是前后端分析项目,项目中除了需要跨域全局配置之外,我们再拦截器中也需要提供跨域支持。这样,拦截器才不会在进入 Controller 之前就被限制了。

那么到这里,我们的 shiro 就已经完成整合进来了,并且使用了 jwt 进行身份校验!

2.5、全局异常处理

  1. 有时候不可避免服务器报错的情况,如果不配置异常处理机制,就会默认返回 tomcat 或者 nginx 的 5XX 页面,对普通用户来说,不太友好,用户也不懂什么情况。这时候需要我们程序员设计返回一个友好简单的格式给前端。处理办法如下:通过使用 @ControllerAdvice 来进行统一异常处理,使用**@ExceptionHandler (value = RuntimeException.class) 来指定捕获的 Exception 各个类型异常 ,这个异常的处理,是全局的,所有类似的异常,都会跑到这个地方处理。**
  2. GlobalExceptionHandler

com.example.exception.GlobalExceptionHandler.java

package com.example.common.exception;

import com.example.common.lang.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.ShiroException;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice   //捕获全局异常
public class GlobalExceptionHandler {

    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(value = ShiroException.class)
    public Result handler(ShiroException e) {
        log.error("运行时异常:----------------{}", e);
        return Result.fail(401, e.getMessage(), null);
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public Result handler(MethodArgumentNotValidException e) {
        log.error("实体校验异常:----------------{}", e);
        BindingResult bindingResult = e.getBindingResult();
        ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
        return Result.fail(objectError.getDefaultMessage());
    }
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = IllegalArgumentException.class)
    public Result handler(IllegalArgumentException e) {
        log.error("Assert异常:----------------{}", e);
        return Result.fail(e.getMessage());
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = RuntimeException.class)
    public Result handler(RuntimeException e) {
        log.error("运行时异常:----------------{}", e);
        return Result.fail(e.getMessage());
    }



}

上面我们捕捉了几个异常:

  • ShiroException:shiro 抛出的异常,比如没有权限,用户登录异常
  • IllegalArgumentException:处理 Assert 的异常
  • MethodArgumentNotValidException:处理实体校验的异常
  • RuntimeException:捕捉其他异常 运行时的异常

2.6、实体校验

表单数据提交时,后端可以用Hibernate validatior 来做校验。

我们使用 springboot 框架作为基础,那么就已经自动集成了 Hibernate validatior。(我用的springboot版本是2.2.6.RELEASE,高版本没有集成这个 Hibernate validatior,如果需要使用实体校验,需要自己导入 Hibernate validatior依赖,可以去maven仓库搜索导入依赖)

  1. 使用时,先在实体类上加注解
@TableName("m_user")
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    @NotBlank(message = "昵称不能为空")
    private String username;
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
    
    ...
}
  1. 在controller中进行校验测试
/**
 * 测试实体校验
 * @param user
 * @return
 */
@PostMapping("/save")
public Object testUser(@Validated @RequestBody User user) {
    return user.toString();
}

@Validated 是关键,只有加上这个注解,实体校验才会生效哦!

  • 这里我们使用 @Validated 注解方式,如果实体不符合要求,系统会抛出异常,那么我们的异常处理中就捕获到 MethodArgumentNotValidException。

2.7、跨域问题

  1. 前后端项目,跨域问题是无法避免的,可以使用全局配置跨域来解决
  2. CorsConfig

com.example.config.CorsConfig.java

package com.example.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * 解决跨域问题
 */
@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                .allowCredentials(true)
                .maxAge(3600)
                .allowedHeaders("*");
    }
}

至sringboot后端框架就已经搭建好了,下面就是开始编写业务接口了

2.8、登录接口

  1. 只需要接受账号密码,然后把用户的 id 生成 jwt,返回给前段,为了后续的 jwt 的延期,所以我们把 jwt 放在 header上
  2. AccountController

com.example.controller.AccountController.java

package com.example.controller;

import cn.hutool.core.map.MapUtil;
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.common.dto.LoginDto;
import com.example.common.lang.Result;
import com.example.entity.User;
import com.example.service.UserService;
import com.example.util.JwtUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletResponse;

@RestController
public class AccountController {
    @Autowired
    JwtUtils jwtUtils;
    @Autowired
    UserService userService;

    @PostMapping("/login")
    public Result login(@Validated @RequestBody  LoginDto loginDto, HttpServletResponse response){
        User user = userService.getOne(new QueryWrapper<User>().eq("username", loginDto.getUsername()));
        Assert.notNull(user, "用户不存在");
        if(!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))){
            return Result.fail("密码不正确");
        }
        String jwt = jwtUtils.generateToken(user.getId());

        response.setHeader("Authorization", jwt);
        response.setHeader("Access-control-Expose-Headers", "Authorization");

        return Result.succ(MapUtil.builder()
                .put("id", user.getId())
                .put("username", user.getUsername())
                .put("avatar", user.getAvatar())
                .put("email", user.getEmail())
                .map()
        );


    }
    @RequiresAuthentication
    @GetMapping("/logout")
    public Result logout() {
        SecurityUtils.getSubject().logout();
        return Result.succ(null);
    }
}

  1. LoginDto

封装一些属性,用户名和密码

package com.example.common.dto;

import lombok.Data;

import javax.validation.constraints.NotBlank;
import java.io.Serializable;
@Data
public class LoginDto implements Serializable {
    @NotBlank(message = "昵称不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    private String password;


}

  • Assert.notNull :断言判断是否为空
  • SecureUtil.md5 :这是Hutool 中封装的解密工具类(因为数据库中的密码是MD5加密后的,所以这里先要解密)
  1. 这样就完成了登录接口的编写

2.9、博客接口

  1. 博客接口的话,无非就是实现增删改查
  2. BlogController

com.example.controlelr.BlogController.java

package com.example.controller;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.common.lang.Result;
import com.example.entity.Blog;
import com.example.service.BlogService;
import com.example.util.ShiroUtil;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.Assert;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;

@RestController
public class BlogController {

    @Autowired
    BlogService blogService;

    //分页接口
    @GetMapping("/blogs")
    public Result list(@RequestParam(defaultValue = "1") Integer currentPage) {

        //分业  一页五条数据
        Page page = new Page(currentPage, 5);
        IPage pageData = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created"));

        return Result.succ(pageData);
    }

    //根据id查询博客内容
    @GetMapping("/blog/{id}")
    public Result detail(@PathVariable(name = "id") Long id) {
        Blog blog = blogService.getById(id);
        //判断为空
        Assert.notNull(blog, "该博客已被删除");

        return Result.succ(blog);
    }

    //博客编辑页面
    @RequiresAuthentication   //需要权限
    @PostMapping("/blog/edit")
    public Result edit(@Validated @RequestBody Blog blog) {
        Blog temp = null;
        //讨论是编辑自己原来的文章,还是新添加文章
        if(blog.getId() != null) {
            //不是空,就先获取原来的文章(update)
            temp = blogService.getById(blog.getId());
            // 只能编辑自己的文章
            System.out.println(ShiroUtil.getProfile().getId());
            //判断编辑的人是不是原来的用户,如果不是:没有权限编辑
            Assert.isTrue(temp.getUserId().longValue() == ShiroUtil.getProfile().getId().longValue(), "没有权限编辑");

        } else {

            //add添加方法
            temp = new Blog();
            temp.setUserId(ShiroUtil.getProfile().getId());
            temp.setCreated(LocalDateTime.now());
            temp.setStatus(0);
        }

        BeanUtil.copyProperties(blog, temp, "id", "userId", "created", "status");
        blogService.saveOrUpdate(temp);

        return Result.succ(null);
    }


}


至此后端部分就全部完成

3、项目搭建—前端

3.1、创建项目

1.该有的nodejs 、vue都要安装上,我用的是vuecli3,所以可以使用可视化界面来创建项目

2.cmd命令行输入: vue ui

3.开始创建一个vue项目脚手架,插件可以先不勾选,到时候用什么再下,这里我使用的是vue2.0,而不是新版的3,因为3是最新版的有很多功能还不太熟悉,很多组件下载也不一样

4.创建好项目后,用idea打开这个项目 vue-blog

3.2、下载插件

  1. 下载element ui
  • 在idea打开的这个vue项目中,终端输入cnpm install element-ui --save

  • 下载好后,在main.js中配置依赖

    import Element from 'element-ui'
    import "element-ui/lib/theme-chalk/index.css"
    Vue.use(Element)
    
  • 这样就可以使用elementui这个组件了

  1. 下载Axios
  • 输入cnpm install axios --save

  • 下载后在main.js中配置

    import axios from 'axios'
    Vue.prototype.$axios = axios 
    

  • 组件中,我们就可以通过 this.$axios.get () 来发起我们的请求了,和后端交互

3.3、页面路由

  1. 接下来,我们先定义好路由和页面,因为页面比较少所以我们先定义好,方便直接使用
  2. 在view文件夹下新建以下路由
  • BlogDetail.vue(博客详情页)
  • BlogEdit.vue(编辑博客)
  • Blogs.vue(博客列表)
  • Login.vue(登录页面)
  1. 去路由中心注册这些路由

router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'
import BlogDetail from "@/views/BlogDetail";
import BlogEdit from "@/views/BlogEdit";
import Login from "@/views/Login";
import Blogs from "@/views/Blogs";

Vue.use(VueRouter)
const routes = [
  {
    path: '/',
    name: 'Index',
    redirect: { name: 'Blogs' }
  },
  {
    path: '/blogs',
    name: 'Blogs',
    component: Blogs

  },
  {
    path: '/login',
    name: 'Login',
    component: Login
  },

{
  path: '/blog/add', // 注意放在 path: '/blog/:blogId'之前
      name: 'BlogAdd',
    meta: {
  requireAuth: true    //需要登录才能访问
},
  component: BlogEdit     //博客编辑页   添加博客add
},
{
  path: '/blog/:blogId',
      name: 'BlogDetail',
    component: BlogDetail   //博客详情页
},
{
  path: '/blog/:blogId/edit',
      name: 'BlogEdit',   
    meta: {
  requireAuth: true
},
  component: BlogEdit    //博客编辑页    这个是修改博客 update
}
];
const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})
export default router

其中,带有 meta:requireAuth: true 说明是需要登录字后才能访问的受限资源,后面我们路由权限拦截时候会用到。

3.4、登录页面

  1. 表单组件在elementui官网上组件里找然后复制就行了

views/Login.vue






从返回的结果请求头中获取到 token 的信息,然后使用 store 提交 token 和用户信息的状态。完成操作之后,我们跳转到了 /blogs 路由,即博客列表页面。

3.5、Token的状态同步

store/index.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    token: '',
    userInfo: JSON.parse(sessionStorage.getItem("userInfo"))
  },
  mutations: {
    // set
    SET_TOKEN: (state, token) => {
      state.token = token
      localStorage.setItem("token", token)
    },
    SET_USERINFO: (state, userInfo) => {
      state.userInfo = userInfo
      sessionStorage.setItem("userInfo", JSON.stringify(userInfo))
    },
    REMOVE_INFO: (state) => {
      state.token = ''
      state.userInfo = {}
      localStorage.setItem("token", '')
      sessionStorage.setItem("userInfo", JSON.stringify(''))
    }

  },
  getters: {
    // get
    getUser: state => {
      return state.userInfo
    }

  },
  actions: {
  },
  modules: {
  }
})

存储 token,我们用的是 localStorage,存储用户信息,我们用的是 sessionStorage。毕竟用户信息我们不需要长久保存,保存了 token 信息,我们随时都可以初始化用户信息。

3.6、定义全局Axios拦截器

  1. 点击登录按钮发起登录请求,成功时候返回了数据,如果是密码错误,我们也应该弹窗消息提示。为了让这个错误弹窗能运用到所有的地方,所以对 axios 做了个后置拦截器,就是返回数据时候,如果结果的 code 或者 status 不正常,那么就对应弹窗提示。
  2. 在 src 目录下创建一个文件 axios.js(与 main.js 同级),定义 axios 的拦截:

axios.js

import axios from 'axios'
import Element from 'element-ui'
import router from './router'
import store from './store'

//服务端的接口的链接
axios.defaults.baseURL = "http://localhost:8081"

// 前置拦截
axios.interceptors.request.use(config => {
    return config
})

axios.interceptors.response.use(response => {
        let res = response.data;

        console.log("=================")
        console.log(res)
        console.log("=================")

        if (res.code === 200) {
            return response
        } else {

            Element.Message.error('错了哦,这是一条错误消息', {duration: 3 * 1000})

            return Promise.reject(response.data.msg)
        }
    },
    error => {
        console.log(error)
        if(error.response.data) {
            error.message = error.response.data.msg
        }

        if(error.response.status === 401) {
            store.commit("REMOVE_INFO")
            router.push("/login")
        }

        Element.Message.error(error.message, {duration: 3 * 1000})
        return Promise.reject(error)
    }
)

因为后端返回的是状态码,200或400之类,这里做了配置,返回的是信息

  1. 然后再 main.js 中导入 axios.js

import ‘./axios.js’ // 请求拦截

3.7、博客列表 Blogs

  1. 头部信息
  • 页面头部我们需要把用户的信息展示出来,因为很多地方都用到这个模块,所以我们把页面头部的用户信息单独抽取出来作为一个组件,方便其他页面直接调用
  • 头部信息应该包含三部分信息:id,头像、用户名,而这些信息我们是在登录之后就已经存sessionStorage。因此,我们可以通过 store 的 getters 获取到用户信息。
  • cmponents/Header.vue





  1. 这样的话引用头部信息只需要:
import Header from "@/components/Header";
data() {
  components: {Header}
}
# 然后模板中调用组件
  1. 博客列表(分页)

views/Blogs.vue






3.8、博客发表(编辑)

  1. 我们点击发表博客链接跳转到 /blog/add 页面也就是BlogEdit,这里我们需要用到一个 markdown 编辑器,在 vue 组件中,比较好用的是 mavon-editor
  2. 安装 mavon-editor

cnpm install mavon-editor --save

  1. 在main.js中配置
// 全局注册
import Vue from 'vue'
import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'
// use
Vue.use(mavonEditor)
  1. BlogEdit

views/BlogEdit.vue






3.9、博客详情页

  1. 后端传过来的是博客内容是 markdown 格式的内容,我们需要进行渲染然后显示出来,这里我们使用一个插件 markdown-it,用于解析 md 文档,然后导入 github-markdown-c,所谓 md 的样式。
  2. 下载这两个插件
# 用于解析md文档
cnpm install markdown-it --save
# md样式
cnpm install github-markdown-css
  1. BlogDetail

views/BlogDetail.vue






3.10、路由权限拦截

  1. 页面已经开发完毕之后,我们来控制一下哪些页面是需要登录之后才能跳转的,如果未登录访问就直接重定向到登录页面
  2. src/permission.js
import router from "./router";

// 路由判断登录 根据路由配置文件的参数
router.beforeEach((to, from, next) => {

    if (to.matched.some(record => record.meta.requireAuth)) { // 判断该路由是否需要登录权限

        const token = localStorage.getItem("token")
        console.log("------------" + token)

        if (token) { // 判断当前的token是否存在 ; 登录存入的token
            if (to.path === '/login') {

            } else {
                next()
            }
        } else {
            next({
                path: '/login'
            })
        }
    } else {
        next()
    }
})

至此,前端页面也已经搭建完成,启动测试一下。

4、项目总结

  1. 这个项目只是一个练手前后端分离的小demo,并不算的上什么真正意义上的项目。找的是一位博主的项目练手,他的文档链接放在这里:https://my.oschina.net/u/3080373/blog/4294136?_from=gitee_search

  2. 因为是刚学完springboot、vue,已经可以开发一个ssm项目,然后来体验一下前后端分离项目,而这个项目可以体验到搭建前后端分离框架。后端主要是ssm架构,spring+springmvc+mybatis,在这里是用spirngboot项目框架整合起来,然后用的是mybatis-plus这个很方便的工具,安全权限用的是shiro,会话缓存在redis,用户认证用的jwt,大概的技术就是这些。

你可能感兴趣的:(项目,后端,vue)