springboot+vue+elementUI前后端分离项目练习—小试牛刀

确定技术栈

后端技术栈:

  • springboot
  • mybatis plus
  • shiro
  • lombok
  • redis
  • hibernate validatior(springboot内置)
  • jwt

后端开发步骤:

1、建好数据库

2、springboot整合mybatis plus(整合之后别忘了先测试一下)

3、前后端统一结果封装

这里我们用到了一个Result的类,这个用于我们的异步统一返回的结果封装。一般来说,结果里面有几个要素必要的

  • 是否成功,可用code表示(如200表示成功,400表示异常)
  • 结果消息
  • 结果数据
package com.ryan.common.lang;

import lombok.Data;
import org.springframework.stereotype.Component;

import java.io.Serializable;

@Data
public class Result implements Serializable {
    //状态码,200一般表示正常,非200表示异常
    private int code;
    //信息
    private String msg;
    //数据
    private Object data;

    //操作成功的结果封装
    //对于前端来说,操作成功一般都是200,他们一般比较注重我data,所以我们需要进一步封装
    public static Result success(Object data){
        return success(200,"操作成功",data);
    }

    //方法封装,方便别处调用
    public static Result success(int code,String msg,Object data){
        Result result = new Result();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(data);
        return result;
    }

    //往往操作失败之后,没有什么数据返回的,看中的是msg,所以我们可以再进一层封装
    public static Result fail(String msg){
        return success(400, msg,null);
    }

    //操作失败的状态码一般是400,msg一般是错误原因什么的,所以这里最好也加上
    public static Result fail(String msg, Object data){
        return success(400,msg,data);
    }

    //操作失败的结果封装
    public static Result fail(int code,String msg,Object data){
        Result result = new Result();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(data);
        return result;
    }


}

4、shiro整合jwt逻辑分析

  • 登陆逻辑
    springboot+vue+elementUI前后端分离项目练习—小试牛刀_第1张图片
  • 访问资源(接口)时的shiro&jwt逻辑
    springboot+vue+elementUI前后端分离项目练习—小试牛刀_第2张图片

shiro代码编写

考虑到后面可能需要做集群、负载均衡等,所以就需要会话共享,而shiro的缓存和会话信息,我们一般考虑使用redis来存储这些数据,所以,我们不仅仅需要整合shiro,同时也需要整合redis。在开源的项目中,我们找到了一个starter可以快速整合shiro-redis,配置简单,这里也推荐大家使用。

而因为我们需要做的是前后端分离项目的骨架,所以一般我们会采用token或者jwt作为跨域身份验证解决方案。所以整合shiro的过程中,我们需要引入jwt的身份验证过程。

第一步:导入shiro-redis的starter包:还有jwt的工具包,以及为了简化开发,我引入了hutool工具包。

<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

package com.ryan.config;

import com.ryan.shiro.AccountRealm;
import com.ryan.shiro.JwtFilter;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
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.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
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;

/**
 * shiro启用注解拦截控制器
 */
@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);
        securityManager.setSessionManager(sessionManager);
        securityManager.setCacheManager(redisCacheManager);
        /*
         * 关闭shiro自带的session,详情见文档
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        return securityManager;
    }
    //shiro过滤器链的定义
    @Bean
    public ShiroFilterChainDefinition shiroFilterChainDefinition() {
        DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
        Map<String, String> filterMap = new LinkedHashMap<>();
        //所有的链接都会经过我们jwt
        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;
    }

    // 开启注解代理(默认好像已经开启,可以不要)
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }
    @Bean
    public static DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator();
        return creator;
    }
}

第三步:导入jwt工具类

package com.ryan.utils;

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;

/**
 * jwt工具类
 */
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "ryan.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());
    }
}

第四步:编写jwtFilter类

  • 这个过滤器是我们的重点,这里我们继承的是Shiro内置的AuthenticatingFilter,一个可以内置了可以自动登录方法的的过滤器,有些同学继承BasicHttpAuthenticationFilter也是可以的。

    我们需要重写几个方法:

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

import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.ryan.common.lang.Result;
import com.ryan.utils.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.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

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

//定义jwt的过滤器JwtFilter
/*
这个过滤器是我们的重点,这里我们继承的是Shiro内置的AuthenticatingFilter,
一个可以内置了可以自动登录方法的的过滤器
 */
@Component
public class JwtFilter extends AuthenticatingFilter {

    @Autowired
    JwtUtils jwtUtils;

    //实现登录,我们需要生成我们自定义支持的JwtToken
    @Override
    protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        //获取Token
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String jwt = request.getHeader("Authorization");//获取jwt,,jwt是放在header里面的
        if (StringUtils.isEmpty(jwt)){
            return null;//jwt为空的话就没必要登陆,直接跳过
        }
        //有的话,封装成token
        return new JwtToken(jwt);
        /*
        思考:这里的createToken有什么用呢?
            底层就是调用了subject.login(token)
            然后再交给realm进行处理
         */
    }

    //拦截校验,当头部没有Authorization时候,我们直接通过,不需要自动登录;
    // 当带有的时候,首先我们校验jwt的有效性,没问题我们就直接执行executeLogin方法实现自动登录
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        String jwt = request.getHeader("Authorization");//获取jwt,,jwt是放在header里面的
        if (StringUtils.isEmpty(jwt)){
            return true;//没jwt,直接跳过,进入接口注解过滤
        }else {
            //如果有,则需要校验jwt
            Claims claim = jwtUtils.getClaimByToken(jwt);
            //如果claim为空或者token过期了,则抛出异常
            if (claim == null || jwtUtils.isTokenExpired(claim.getExpiration())){
                throw new ExpiredCredentialsException("token已过期,请重新登录");
            }
            //执行登陆,接下来就是交给realm处理的
            return executeLogin(servletRequest,servletResponse);
        }
    }

    //从executeLogin方法原码可以看到,登录异常时候进入onLoginFailure方法,我们直接把异常信息封装然后抛出
    //因为是前后端分离,当出现登陆异常时,我们应该封装结果集
    @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 ex) {
            ex.printStackTrace();
        }
        return false;
    }
}

第四步:新建一个jwtToken类

  • shiro默认supports的是UsernamePasswordToken,而我们现在采用了jwt的方式,所以这里我们自定义一个JwtToken,来完成shiro的supports方法。
package com.ryan.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;
    }
}

第五步:新建一个AccountProfile类

  • 而在AccountRealm我们还用到了AccountProfile,这是为了登录成功之后返回的一个用户信息的载体
package com.ryan.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;

}

第六步:新建AccountRealm类

  • AccountRealm是shiro进行登录或者权限校验的逻辑所在,算是核心了,我们需要重写3个方法,分别是

    • supports:为了让realm支持jwt的凭证校验
    • doGetAuthorizationInfo:权限校验
    • doGetAuthenticationInfo:登录认证校验
  • 其实主要就是doGetAuthenticationInfo登录认证这个方法,可以看到我们通过jwt获取到用户信息,判断用户的状态,最后异常就抛出对应的异常信息,否者封装成SimpleAuthenticationInfo返回给shiro。 接下来我们逐步分析里面出现的新类:

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

package com.ryan.shiro;

import cn.hutool.core.bean.BeanUtil;
import com.ryan.entity.User;
import com.ryan.service.UserService;
import com.ryan.utils.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;

    //需要声明支持什么token
    @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);

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

基本的校验的路线完成之后,我们需要少量的基本信息配置:

shiro-redis:
  enabled: true
  redis-manager:
    host: 127.0.0.1:6379
ryan:
  jwt:
    # 加密秘钥
    secret: f4e2e52034348f86b67cde581c0f9eb5
    # token有效时长,7天,单位秒
    expire: 604800
    header: token

另外,如果你项目有使用spring-boot-devtools,需要添加一个配置文件,在resources目录下新建文件夹META-INF,然后新建文件spring-devtools.properties,这样热重启时候才不会报错。

restart.include.shiro-redis=/shiro-[\\w-\\.]+jar

6、全局异常处理

新建一个exception包,然后新建一个全局异常处理的类GlobalExceptionHandler

package com.ryan.exception;

import com.ryan.common.lang.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.ShiroException;
import org.springframework.http.HttpStatus;
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 {

    //除了runtime还有shiro
    @ResponseStatus(HttpStatus.UNAUTHORIZED)//未授权
    @ExceptionHandler(value = ShiroException.class)
    public Result handler(ShiroException e){
        log.error("运行时异常:----------------{}",e);
        return Result.fail(401,e.getMessage(),null);

    }

    //大异常RuntimeException
    @ResponseStatus(HttpStatus.BAD_REQUEST)//返回状态码,让前端知道
    @ExceptionHandler(value = RuntimeException.class)
    public Result handler(RuntimeException e){
        log.error("运行时异常:----------------{}",e);
        return Result.fail(e.getMessage());
    }

}

接口添加一个要求认证后才能访问的注解

@RequiresAuthentication

测试:
springboot+vue+elementUI前后端分离项目练习—小试牛刀_第3张图片

7、实体校验

当我们表单数据提交的时候,前端的校验我们可以使用一些类似于jQuery Validate等js插件实现,而后端我们可以使用Hibernate validatior来做校验。

我们使用springboot框架作为基础,那么就已经自动集成了Hibernate validatior。

那么用起来啥样子的呢?

第一步:首先在实体的属性上添加对应的校验规则,比如:

private static final long serialVersionUID = 1L;

@TableId(value = "id", type = IdType.AUTO)
private Long id;

@NotBlank(message = "昵称不能为空")
private String username;

private String avatar;

@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;

private String password;

private Integer status;

private LocalDateTime created;

private LocalDateTime lastLogin;

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

@PostMapping("/save")
public Result save(@Validated @RequestBody User user){
    //@Validated注解方式,如果实体不符合要求,系统会抛出异常MethodArgumentNotValidException
    //这就要求我们需要在全局异常处理中捕捉他
    return Result.success(user);

第三步:全局异常中处理实体校验异常

@ResponseStatus(HttpStatus.UNAUTHORIZED)//未授权
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result handler(MethodArgumentNotValidException e){
    log.error("实体校验异常:----------------{}",e);
    ObjectError objectError = e.getBindingResult().getAllErrors().stream().findFirst().get();
    return Result.fail(objectError.getDefaultMessage());
}

第四步:使用postman测试下

实体校验出现异常时:
springboot+vue+elementUI前后端分离项目练习—小试牛刀_第4张图片
实体校验成功时:
springboot+vue+elementUI前后端分离项目练习—小试牛刀_第5张图片

8、解决跨域问题

因为是前后端分析,所以跨域问题是避免不了的,我们直接在后台进行全局跨域处理:

第一步:跨域配置CorsConfig

package com.ryan.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("*");
    }

}

第二步:在jwtfilter中提供跨域的支持

/**
 * 对跨域提供支持
 */
@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);
}

9、登陆接口开发

登录的逻辑其实很简答,只需要接受账号密码,然后把用户的id生成jwt,返回给前段,为了后续的jwt的延期,所以我们把jwt放在header上。具体代码如下:

package com.ryan.controller;

import cn.hutool.core.map.MapUtil;
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.ryan.common.dto.LoginDto;
import com.ryan.common.lang.Result;
import com.ryan.entity.User;
import com.ryan.service.UserService;
import com.ryan.utils.JwtUtils;
import org.apache.catalina.security.SecurityUtil;
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;
/**
 * 默认账号密码:ryan / 111111
 *
 */
@RestController
public class AccountController {

    @Autowired
    JwtUtils jwtUtils;

    @Autowired
    UserService userService;

    @PostMapping("/login")
    public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response){
        //1.用户名验证
        User user = userService.getOne(new QueryWrapper<User>().eq("username", loginDto.getUsername()));
        //断言username不为空
        Assert.notNull(user, "用户不存在");
        //2.密码验证
        if(!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))){
            return Result.fail("密码不正确");
        }
        //3.生成jwt并放到header当中
        String jwt = jwtUtils.generateToken(user.getId());
        response.setHeader("Authorization",jwt);
        response.setHeader("Access-control-Expose-Header","Authorization");
        //4.返回用户信息
        return Result.success(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.success(null);
    }

}

Assert.notNull(user, “用户不存在”);执行可能会抛出异常,需要在全局异常中处理

@ResponseStatus(HttpStatus.UNAUTHORIZED)//未授权
@ExceptionHandler(value = IllegalArgumentException.class)
public Result handler(IllegalArgumentException e){
    log.error("Assert异常:----------------{}",e);
    return Result.fail(e.getMessage());
}

10、博客接口开发

我们的骨架已经完成,接下来,我们就可以添加我们的业务接口了,下面我以一个简单的博客列表、博客详情页、博客编辑/新增、博客删除为例子开发:

第一步:写一个工具类ShiroUtil

package com.ryan.utils;

import com.ryan.shiro.AccountProfile;
import org.apache.shiro.SecurityUtils;

public class ShiroUtil {
    public static AccountProfile getProfile() {
        return (AccountProfile) SecurityUtils.getSubject().getPrincipal();
    }
}

第二步:接口开发

package com.ryan.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.ryan.common.lang.Result;
import com.ryan.entity.Blog;
import com.ryan.service.BlogService;
import com.ryan.utils.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;

/**
 * 

* 前端控制器 *

* * @author Ryan * @since 2020-06-11 */
@RestController public class BlogController { //详情(list)、detail(通过id)、编辑/新建、删除 @Autowired BlogService blogService; @GetMapping("/blogs") public Result list(@RequestParam(defaultValue = "1") Integer currentPage){//默认是第一页,确保不是空 Page page = new Page(currentPage, 5);//每页5条记录 IPage pageData = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created")); return Result.success(pageData); } @GetMapping("/blog/{id}") public Result detail(@PathVariable(value = "id")Long id){ Blog blog = blogService.getById(id); //blog不应该为空 Assert.notNull(blog, "该博客不存在或已被删除"); return Result.success(blog); } @RequiresAuthentication//需要认证才能编辑 @PostMapping("/blog/edit") public Result edit(@Validated @RequestBody Blog blog){ //分为两种情况,编辑已有博客和新建博客 Blog temp = null; if(blog.getId() != null){//表示编辑已有博客 temp = blogService.getById(blog.getId()); //注意,只能编辑自己的博客 Assert.isTrue(temp.getUserId().longValue() == ShiroUtil.getProfile().getId().longValue(), "抱歉,没有权限编辑他人的博客"); }else{//表示新建博客 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.success(null); } @GetMapping("/blog/remove/{id}") public Result delete(@PathVariable(value = "id")Long id){ blogService.removeById(id); return Result.success(null); } }

测试:略

前端技术栈:

  • vue
  • element ui
  • axios
  • mavon-editor
  • markdown-it
  • github-markdown-css

前端开发步骤:

1、环境搭建

1.1、node.js:之前都安装过了,这里就不演示了

1.2、vue cli:为了vue可视化操作,我这里升级了vue3.6.3的版本,注意是3.6+的版本,不然会踩到一些坑,然后控制台输入vue cli即可弹出vue的可视化界面,在工程根目录下新建一个vueblog-vue项目

1.3、下一步中,项目文件夹中输入项目名称“vueblog-vue”,其他不用改,点击下一步,选择【手动】,再点击下一步,如图点击按钮,勾选上路由Router、状态管理Vuex,去掉js的校验。
springboot+vue+elementUI前后端分离项目练习—小试牛刀_第6张图片
下一步中,也选上【Use history mode for router】,点击创建项目,然后弹窗中选择按钮【创建项目,不保存预设】,就进入项目创建啦。

稍等片刻之后,项目就初始化完成了。上面的步骤中,我们创建了一个vue项目,并且安装了Router、Vuex。这样我们后面就可以直接使用。

1.4、安装element-ui:略

1.5、安装axios:略

2、页面路由

接下来,我们先定义好路由和页面,因为我们只是做一个简单的博客项目,页面比较少,所以我们可以直接先定义好,然后在慢慢开发,这样需要用到链接的地方我们就可以直接可以使用:

我们在views文件夹下定义几个页面:

  • BlogDetail.vue(博客详情页)
  • BlogEdit.vue(编辑博客)
  • Blogs.vue(博客列表)
  • Login.vue(登录页面)

然后在路由中心配置:

import Vue from 'vue'
import VueRouter from 'vue-router'
import Login from "../views/Login";
import Blogs from "../views/Blogs";
import Detail from "../views/Detail";
import Edit from "../views/Edit";


Vue.use(VueRouter)

  const routes = [
  {
    path: '/',
    name: 'index',
    component: Blogs
  },{
    path: '/login',
    name: 'Login',
    component: Login
  },{
    path: '/blogs',
    name: 'Blogs',
    component: Blogs
  },{
    path: '/blog/add',
    name: 'Add',
    meta: {
      requireAuth: true
    },
    component: Edit
  },{
  path: '/blog/:blogId/detail',
  name: 'Detail',
  component: Detail
  },{
  path: '/blog/:blogId/edit',
  name: 'Edit',
    meta: {
      requireAuth: true
    },
  component: Edit
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

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

3、登陆页面

Login.vue

接下来,我们来搞一个登陆页面,表单组件我们直接在element-ui的官网上找就行了,登陆页面就两个输入框和一个提交按钮,相对简单,然后我们最好带页面的js校验。






上面的代码主要做了两件事情:

  • 表单验证
  • 登陆按钮点击登陆时间

表单校验规则还好,比较固定写法,查一下element-ui的组件就知道了,我们来分析一下发起登录之后的代码:

_this.$store.commit("SET_TOKEN", jwt)
_this.$store.commit("SET_USERINFO", userInfo)

_this.$router.push("/blogs")

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

3.1、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: {
    //相当于setter
    SET_TOKEN: (state, token) => {
      state.token = token
      localStorage.setItem("token", token)
    },
    SET_USERINFO: (state, userInfo) => {
      state.userInfo = userInfo
      sessionStorage.setItem("userInfo", JSON.stringify(userInfo))
      //说明:JSON.stringify()的作用是将 JavaScript 对象转换为 JSON 字符串,而JSON.parse()可以将JSON字符串转为一个对象。
    },
    REMOVE_INFO: (state) => {
      state.token = ""
      state.userInfo = {}
      localStorage.setItem("token","")
      sessionStorage.setItem("userInfo", JSON.stringify(""))
    }
  },
  getters: {
    //getter
    getUser: state => {
      return state.userInfo
    }
  },
  actions: {
  },
  modules: {
  }
})

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

3.2、定义全局axios拦截器

点击登录按钮发起登录请求,成功时候返回了数据,如果是密码错误,我们是不是也应该弹窗消息提示。为了让这个错误弹窗能运用到所有的地方,所以我对axios做了个后置拦截器,就是返回数据时候,如果结果的code或者status不正常,那么我对应弹窗提示。

src目录下新建一个axios.js文件(与main.js同级)

import axios from 'axios'
import ElementUI from 'element-ui';
import store from "./store";
import router from "./router";
//url前缀
axios.defaults.baseURL = "http://localhost:8001"

//前置拦截
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 {//这是针对shiroException的情况,其他的属于报错了
        //因为此axios是要挂载到main.js中的,所以这里不能使用this.$message
        ElementUI.Message.error('密码错误,请重新输入', {duration: 3*1000});//弹窗3秒后消失
        //同时我们要拦截Login.vue中的正确逻辑,并弹窗
        return Promise.reject(response.data.msg)
    }
},
    error => {
    console.log(error);
    if(error.response.data){//当返回的数据不为空的时候,就将msg赋值给error的message,给下面的弹窗使用
    error.message = error.response.data.msg
    }
    //报错的话我们应该重新跳转到登陆界面
    if(error.response.status === 401){
    //先清楚token和userInfo
    store.commit('REMOVE_INFO');//注意store不要导错包了
    router.push("/login")
    }
    //除了401,我们直接弹窗即可
    ElementUI.Message.error(error.message, {duration: 3*1000});
    return Promise.reject(error)
    }
)

前置拦截,其实可以统一为所有需要权限的请求装配上header的token信息,这样不需要在使用是再配置,我的小项目比较小,所以,还是免了吧~

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

import './axios.js' // 请求拦截

后端因为返回的实体是Result,success时候code为200,fail时候返回的是400,所以可以根据这里判断结果是否是正常的。另外权限不足时候可以通过请求结果的状态码来判断结果是否正常。这里都做了简单的处理。

4、博客列表

登录完成之后直接进入博客列表页面,然后加载博客列表的数据渲染出来。同时页面头部我们需要把用户的信息展示出来,因为很多地方都用到这个模块,所以我们把页面头部的用户信息单独抽取出来作为一个组件。

4.1、头部用户信息

那么,我们先来完成头部的用户信息,应该包含三部分信息:id,头像、用户名,而这些信息我们是在登录之后就已经存在了sessionStorage。因此,我们可以通过store的getters获取到用户信息。

在component文件夹中新建Header.vue






然后需要头部用户信息的页面只需要几个步骤:

import Header from "@/components/Header";
data() {
  components: {Header}
}
# 然后模板中调用组件
4.2、博客分页

接下来就是列表页面,需要做分页,列表我们在element-ui中直接使用时间线组件来作为我们的列表样式,还是挺好看的。还有我们的分页组件。

需要几部分信息:

  • 分页信息
  • 博客列表内容,包括id、标题、摘要、创建时间

Blogs.vue






data()中直接定义博客列表blogs、以及一些分页信息。methods()中定义分页的调用接口page(currentPage),参数是需要调整的页码currentPage,得到结果之后直接赋值即可。然后初始化时候,直接在mounted()方法中调用第一页this.page(1)。

5、博客编辑

我们点击发表博客链接调整到/blog/add页面,这里我们需要用到一个markdown编辑器,在vue组件中,比较好用的是mavon-editor,那么我们直接使用哈。先来安装mavon-editor相关组件:

安装mavon-editor

基于Vue的markdown编辑器mavon-editor

npm install mavon-editor --save

然后在main.js中全局注册:

import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'
Vue.use(mavonEditor);

ok,那么我们去定义我们的博客表单:

Edit.vue






逻辑依然简单,校验表单,然后点击按钮提交表单,注意头部加上Authorization信息,返回结果弹窗提示操作成功,然后跳转到博客列表页面。

然后因为编辑和添加是同一个页面,所以有了create()方法,比如从编辑连接/blog/7/edit中获取blogId为7的这个id。然后回显博客信息。获取方式是const blogId = this.$route.params.blogId。

对了,mavon-editor因为已经全局注册,所以我们直接使用组件即可:

6、博客详情

博客详情中需要回显博客信息,然后有个问题就是,后端传过来的是博客内容是markdown格式的内容,我们需要进行渲染然后显示出来,这里我们使用一个插件markdown-it,用于解析md文档,然后导入github-markdown-c,所谓md的样式。

方法如下:

# 用于解析md文档
npm install markdown-it --save
# md样式
npm install github-markdown-css

然后就可以在需要渲染的地方使用:

Detail.vue






具体逻辑还是挺简单,初始化create()方法中调用getBlog()方法,请求博客详情接口,返回的博客详情content通过markdown-it工具进行渲染。同时还添加了“编辑”和“删除”两个按钮

别忘了导入github的样式哦

import "github-markdown-css/github-markdown.css"

另外标题下添加了个小小的编辑按钮,通过isOwnBlog (判断博文作者与登录用户是否同一人)来判断按钮是否显示出来。

7、路由权限拦截

页面已经开发完毕之后,我们来控制一下哪些页面是需要登录之后才能跳转的,如果未登录访问就直接重定向到登录页面,因此我们在src目录下定义一个js文件:

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()
    }
})

通过之前我们再定义页面路由时候的的meta信息,指定requireAuth: true,需要登录才能访问,因此这里我们在每次路由之前(router.beforeEach)判断token的状态,觉得是否需要跳转到登录页面。

然后我们再main.js中import我们的permission.js

import './permission.js' // 路由拦截

最终的效果:
springboot+vue+elementUI前后端分离项目练习—小试牛刀_第7张图片
springboot+vue+elementUI前后端分离项目练习—小试牛刀_第8张图片
springboot+vue+elementUI前后端分离项目练习—小试牛刀_第9张图片

注:本项目来自B站up注MarkerHub教程,感兴趣的同学可以去看看练手

你可能感兴趣的:(springboot+vue+elementUI前后端分离项目练习—小试牛刀)