Springboot 搭建博客后台脚手架流程梳理(前后端分离)

Yunxiao 2020年10月21日 784次浏览

前言

学了springboot一直没做过什么demo,正好看到了vue-springboot前后端分离的博客系统。就跟着学习了一遍

参考博客

文档:https://juejin.im/post/6844903823966732302#heading-7
配套视频:https://www.bilibili.com/video/BV1PQ4y1P7hZ
代码仓库:https://github.com/MarkerHub/vueblog
需要注意的是视频和文档有一点不一样,文档更全一点,比如JwtFilter那里视频中就没有关闭shior自带的Seesion,我也不知道他怎么就测试成功了。

数据准备

开发脚手架嘛,数据很简单,就两个表,一个m_blog,一个m_user.用文档中带的建表语句就可以了。mysql 5.6之前的版本datetime不支持默认时间,需要注意

包结构总览

image.png
image.png

集成Mybatis-plus

这也是我第一次使用Mybatis-plus,之前稍微学过一点Mybatis,不过Mybatis-plus声称是无侵入的附加框架,使用下来感受确实很好。简化了很多工作,不得不感叹在这些自动化框架的加持下做的确实更少了,但也更简单和单一了,更像无脑打工人了哈哈。

首先导入相关的依赖:

        <!--mp-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>

        <!--mp代码生成器-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.1.2</version>
        </dependency>

第二个是用来自动生成代码时的代码模板,还有其他的模板,没有详细了解过,后面有空了解了再不,不是很重要,这里就采用了和原文档一致的模板。

然后还需要再配置文件中开启mybatis的包扫描

mybatis-plus:
    mapper-locations: classpath*:/mapper/**Mapper.xml

自动生成代码

首先需要一个CodeGenerator的自动生成代码类,再修改里面的一些配置即可

// 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
public class CodeGenerator {

    /**
     * <p>
     * 读取控制台内容
     * </p>
     */
    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.setOutputDir("D:\\test");
        gc.setAuthor("");
        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=UTC");
        // dsc.setSchemaName("public");
        dsc.setDriverName("com.mysql.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("123456");
        mpg.setDataSource(dsc);

        // 包配置
        PackageConfig pc = new PackageConfig();
        pc.setModuleName(null);
        pc.setParent("com.yunxiao");
        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();
    }
}

主要需要配置的就是数据源那一部分,还有包配置修改成和自己的包结构一样即可。
执行这个main方法,输入需要自动生成的表名,就会按照模板自动生成实体类,mapper,service等类。

统一结果封装

由于是前后端分离的项目,所以需要对调用接口的结果进行一个统一封装,便于前端根据返回的结果进行相应的处理。
新建common.lang.Result这个类

@Data
public class Result {
    private String code;//200 正常 400 异常
    private String msg;
    private Object data;

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

    public static Result succ(Object data){
        Result result = new Result();
        result.setCode("200");
        result.setMsg("操作成功");
        result.setData(data);
        return result;
    }

    public static Result fail(String code,String msg, Object data){
        Result result = new Result();
        result.setCode(code);
        result.setMsg(msg);
        result.setData(data);
        return result;
    }

    public static Result fail(String msg, Object data){
        Result result = new Result();
        result.setCode("400");
        result.setMsg(msg);
        result.setData(data);
        return result;
    }

    public static Result fail(String msg){
        Result result = new Result();
        result.setCode("400");
        result.setMsg(msg);
        result.setData(null);
        return result;
    }
}

就三个类成员,code表示需要的返回的状态码,msg表示返回信息,Data表示返回数据。这里定义200正常,400异常。然后根据需要定义静态方法succ和fail分别表示成功的结果封装和失败的结果封装,可以组合着适当重载。比如参数只要msg,只要Data等...

集成Shior安全框架

这应该是这个脚手架中代码最多的部分了,也是对我来说最难的一部分,毕竟第一次接触,我尽量梳理清楚。

shiro相关的包

<!--shiro redis hutool jwt工具类-->
        <dependency>
            <groupId>org.crazycake</groupId>
            <artifactId>shiro-redis-spring-boot-starter</artifactId>
            <version>3.2.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-web-starter</artifactId>
            <version>1.4.0-RC2</version>
       <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.4.3</version>
        </dependency>
       <!-- jwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

jwt校验流程图分析

image.png
核心就是围绕jwt来做权限验证

JwtUtils工具类

这个工具类主要用来生成jwt,验证jwt是否过期,返回jwt的信息。

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

需要添加配置文件,用来设置Jwt过期时间,secrt密钥信息,同时需要开启redis。

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

AccountRealm自定义Realm

主要就是doGetAuthenticationInfo登录认证这个方法,可以看到我们通过jwt获取到用户信息,判断用户的状态,最后异常就抛出对应的异常信息,否者封装成SimpleAuthenticationInfo返回给shiro

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

    @Autowired
    UserService userService;

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        JwtToken jwtToken = (JwtToken)authenticationToken;

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

        User user = userService.getById(Long.valueOf(userID));

        if(user == null){
            System.out.println("用户不存在");
        }

        if(user.getStatus()==-1){
            System.out.println("账户被锁定");
        }

        AccountProfile profile = new AccountProfile();
        BeanUtil.copyProperties(user,profile);

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

AccountProfile

上面的AccountRealm中用到了这个类,这个类主要是用来封装返回信息的一个类,需要序列化,可以根据需要增删字段

@Data
public class AccountProfile implements Serializable {
    private Long id;
    private String username;
    private String avatar;
}

JwtToken

这个类用来把jwt封装成token。

public class JwtToken implements AuthenticationToken {

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

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

JwtFilter过滤器

这是这个项目中的shior重点
我们需要重写几个方法:

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

@Component
public class JwtFilter extends BasicHttpAuthenticationFilter {

    @Autowired
    JwtUtils jwtUtils;
    @Override
    protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) {
        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{
            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 httpResponse = (HttpServletResponse) response;
        try {
            //处理登录失败的异常
            Throwable throwable = e.getCause() == null ? e : e.getCause();
            Result r = Result.fail(throwable.getMessage());
            String json = JSONUtil.toJsonStr(r);
            httpResponse.getWriter().print(json);
        } catch (IOException e1) {
        }
        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);

    }
}

shiro总结

通过以上几个类就通过jwt来实现了登陆权限控制,这里还没有实现角色控制,后续学习了shiro后再补。

全局异常处理

我们在前面的代码中抛出了很多异常,我们用一个全局异常来统一处理。新建excption.GlobaExceptionHandler

@Slf4j
@RestControllerAdvice
public class GlobaExceptionHandler {

    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ExceptionHandler(ShiroException.class)
    public Result handle401(ShiroException e){
        return Result.fail("401",e.getMessage(),null);
    }


    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(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(IllegalArgumentException.class)
    public Result handler(IllegalArgumentException e){
        log.error("断言异常------------->",e);
        return Result.fail(e.getMessage());
    }



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

@ExceptionHandler这个注解用于捕获异常,@ResponseStatus用户返回错误状态码
后续如果有其它异常抛出,直接在这个全局异常类捕获处理,非常的鲁棒。

后台接口开发

AccountController

@RestController
public class AccountController {

    @Autowired
    JwtUtils jwtUtils;
    @Autowired
    UserService userService;

    @CrossOrigin
    @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-Header","Authorization");
        return Result.succ(MapUtil.builder()
                .put("id",user.getId())
                .put("userName",user.getUsername())
                .put("avatar",user.getAvatar())
                .put("email",user.getEmail())
                .map()
        );
    }


    @PostMapping("/logout")
    @RequiresAuthentication
    public Result logout(){
//        System.out.println("test:"+SecurityUtils.getSubject().isAuthenticated());
        SecurityUtils.getSubject().logout();
        return Result.succ(null);
    }
}

BlogController

@RestController
public class BlogController {

    @Autowired
    BlogService blogService;


    @GetMapping("/blogs")
    public Result blogs(Integer currentPage) {
        if(currentPage == null || currentPage<1) currentPage=1;
        Page page = new Page(currentPage, 5);
        IPage pageData = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created"));
        return Result.succ(pageData);
    }

    @GetMapping("/blog/{id}")
    public Result blog(@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){
        System.out.println(blog.toString());
        Blog temp = null;
        if(blog.getId()!=null){
            temp = blogService.getById(blog.getId());
            Assert.isTrue(temp.getUserId().equals(ShiroUtil.getProfile().getId()) ,"没有权限编辑");

        }else {
            temp = new Blog();
            temp.setUserId(ShiroUtil.getProfile().getId());
            temp.setCreated(LocalDateTime.now());
            temp.setStatus(0);
        }
        BeanUtil.copyProperties(blog,temp,"userId","created","status");
        blogService.saveOrUpdate(temp);
        return Result.succ("200","编辑操作成功",null);
    }

    @PostMapping("blog/delete/{id}")
    public Result delete(@PathVariable(value = "id") Long id){
        if (blogService.removeById(id)){
            return Result.succ("200","删除文章成功",null);
        }else {
            return Result.fail("400","文章不存在",null);
        }

    }
}

至此,登陆和博客增删查改的接口开发完成,用postman测试结果正常
image.png
测试需要权限的接口需要再headers中添加上jwt(通过登陆获取)
image.png

问题汇总

1. datetime格式的数据无法映射

参考官网mp版本从3.1.0及以下版本升级到高版本,JDK8日期新类型LocalDateTime等无法映射(报错)。
image.png
由于我是mysql5所以最多JDBC也就升级到5.9,然后问题就解决了。

2. jwtFilter不起作用

测试接口的发现jwtFilter没起作用,本来该自动拿到jwt执行自动登陆的动作,但是却没反应,导致访问不了需要权限的接口如编辑和删除。后来发现时JwtConfig这个配合文件中没有关闭shior自带的Seesion。

  
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        return securityManager;

加入这段代码后正常,这里要卡了好久,主要是还没有系统的学习过shiro这个框架就学习这个脚手架了,这个经验要引以为戒。

2. maven导入不了相关jar包

这个是经常遇到的问题,跟maven版本,网络条件,jar包版本很多都有关系。需要注意的是不能直接把jar包下载下来在idea中导入,是没用的,因为这个项目的jar包都被maven托管了。手动导入的流程应该是
mvn install:install-file -Dfile=jar包的路径 -DgroupId=gruopId中的内容 -DartifactId=actifactId的内容 -Dversion=version的内容 -Dpackaging=jar