SpringBoot练手项目总结

Posted 早上真起不来!

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了SpringBoot练手项目总结相关的知识,希望对你有一定的参考价值。

项目总结

SpringBoot+MybatisPlus+Redis+Vue+SpringSecurity 前后端分离个人博客

  • 采用前后端分离,前端提供接口,后端根据接口开发,加上后台管理系统
  • 采用MybatisPlus优化简化sql,简化代码
  • 采用JWT来存储用户信息,将token放到Redis中,防止过多session对服务端造成性能问题,将token放到请求头中,下次请求需要用户信息的接口直接访问redis中,避免与数据库过多交互。并且采用ThreadLocal保存用户信息,在登陆成功后存入用户,使线程全局私有用户信息,比如写文章的时候直接从ThreadLocal中拿取用户信息,并且要及时移除用户信息,避免内存泄露。
  • 采用拦截器,拦截需要登陆访问的接口
  • 采用线程池,更新阅读次数,主要是防止更新阻塞其他的读操作,因为更新操作有锁,性能就会比较低,所以更新阅读数扔到线程池中去执行,这样不会影响主线程的操作了。
  • 采用AOP实现缓存和日志功能,在接口上加上缓存减少与数据库的交互,在接口上加上日志,在我们排错的时候可以快速定位
  • 采用七牛云来存放我们的静态资源,加快博客的访问速度,降低我们自身应用服务器的带宽消耗
  • 采用SpringSecurity 来实现后台管理系统的认证和授权,来对用户的统一管理

后端基础配置

1、MybatisPlus

配置

spring.datasource.url=jdbc:mysql://localhost:3306/blog?useUnicode=true&characterEncoding=UTF-8&serverTimeZone=UTC
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
# 与数据库操作的时候自动在表前面加上ms_
mybatis-plus.global-config.db-config.table-prefix=ms_
@Configuration
//扫包,将此包下的接口生成代理实现类,并且注册到spring容器中
@MapperScan("com.liu.blog.dao.mapper")
public class MybatisPlusConfig 
	//分页插件
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor()
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return interceptor;
    

  • 当我们使用MybatisPlus的插入方法,如果数据库采用的自增id,那么需要在实体id加上@TableId(value ="id", type = IdType.AUTO),不然他会按照mybatis-plus方式设置的主键

  • 可以使用LambdaQueryWrapper条件构造器来替换QueryWrapper,前者可以跟好的使用lambda方法传参

  • LambdaQueryWrapper<Tag> tagLambdaQueryWrapper = new LambdaQueryWrapper<>();
    tagLambdaQueryWrapper.eq(Tag::getId,id);
    tagLambdaQueryWrapper.last("limit "+1); // 表示在最后加上 limit 1;
    ======================================================
    QueryWrapper<Tag> tagQueryWrapper = new QueryWrapper<>();
    tagQueryWrapper.eq("id",id);
    tagQueryWrapper.last("limit "+1);
    
    
  • 多表查询需要自定义sql

select id ,tag_name from ms_tag where id in
                                     (select tag_id from
 (select tag_id  from ms_article_tag group by tag_id order by count(tag_id)  desc limit 2) as aliasA
                                     )
# 这里as aliasA 和select tag_id from 是关键,相当于把查询结果当作一个新表去查询,
# 当然多表查询也可以采用map映射
  • 按多个属性递减
select FROM_UNIXTIME(create_date/1000,'%Y') as year,FROM_UNIXTIME(create_date/1000,'%m') as month,count(*) as count from ms_article group by year,month order by year desc,month desc
# order by year desc,month desc
  • 赋值两个对象,BeanUtils.copyProperties(article,articleVo),将article与articleVo属性类型相同的复制给articleVo

    不同的属性类型,比如vo中id是String,则要手动articleVo.setId(String.valueOf(article.getId()))

    String.valueOf(article.getId()) // 可以避免空指针异常。article.getId().toString不能
    
  • 当参数时集合的时候

    # 根据tagIds数组中的id返回其id对应的List<Tag>
    <select id="findTagsByTagIds" parameterType="list" resultType="com.liu.blog.dao.pojo.Tag">
        select id,tag_name as tagName from ms_tag
        where id in
        <foreach collection="tagIds" item="tagId" separator="," open="(" close=")">
            #tagId
        </foreach>
    </select>
    

2、JWT

登陆成功创建token,存放我们的用户信息,替换session,避免过多session对服务器造成压力

public class JWTUtils 

    // 设置秘钥,用于加密解密
    private static final String jwtToken = "123456liu!@#$$";

    // 根据用户id创建token
    public static String createToken(Long userId)
        // 将用户id以map的形式封装,取的时候好取
        Map<String,Object> claims = new HashMap<>();
        claims.put("userId",userId);
        JwtBuilder jwtBuilder = Jwts.builder()
                .signWith(SignatureAlgorithm.HS256, jwtToken) // 签发算法,秘钥为jwtToken
                .setClaims(claims) // body数据,要唯一,自行设置
                .setIssuedAt(new Date()) // 设置签发时间
                .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 60 * 1000));// 一天的有效时间
        String token = jwtBuilder.compact();// 将所有的拼接成最终的token
        return token;
    

    public static Map<String, Object> checkToken(String token)
        try 
            Jwt parse = Jwts.parser().setSigningKey(jwtToken).parse(token);
            return (Map<String, Object>) parse.getBody();
        catch (Exception e)
            e.printStackTrace();
        
        return null;
    

    // 检测token是否好使
   // @Test
    public void parse()
        String token = createToken(12l);
        Jwt parse = Jwts.parser().setSigningKey(jwtToken).parse(token);
        Map<String, Object> body = (Map<String, Object>) parse.getBody();
        System.out.println(body.get("userId"));
        System.out.println(parse);
        /**
         * 12
         * header=alg=HS256,body=exp=1639296423, userId=12, iat=1638407390,
         * signature=MYtDVcNccgWgEHROEG3nLW1jLfQzyWjCdiLtW4UnDs4      
         */
    


使用

String token = JWTUtils.createToken(sysUser.getId());

3、Redis

登陆或注册成功,存放我们的token和对应的用户到redis中,这样下次请求获取当前用户的接口的时候传入token,根据token去redis中查询,减少与数据库的交互,比如拦截器中传入token判断是否有用户信息,这时候就直接去redis中判断,并且创建用户、创建token、存放redis时原子操作,所以可以加事务在类上加:@Transactional 注解即可

配置

# redis的接口配置
spring.redis.host=localhost
spring.redis.port=6379
// 这里我们可以将token和user存到redis中,下次再请求token的时候,可以直接从redis中获取user,不用再解析token然后再去数据库中取
redisTemplate.opsForValue().set("TOKEN_"+token, JSON.toJSONString(sysUser),1, TimeUnit.DAYS);

退出登陆的时候移除即可

redisTemplate.delete("TOKEN_"+token);

4、ThreadLocal

存放我们的用户,线程隔离,比如当我们写文章,先要被拦截,然后登陆后就会将user放到ThreadLocal中,使此次线程全局共享这个user,比如写好文章将文章参数传到后端此时就可以直接去ThreadLocal中拿到user来创建文章。

/**
 * @author ljy
 * @version 1.0.0
 * @ClassName 全局的user信息
 * @Description TODO
 * @createTime 2021年12月02日 13:46:00
 */
public class UserThreadLocal 

    private UserThreadLocal()
    //线程变量隔离
    private static final ThreadLocal<SysUser> LOCAL = new ThreadLocal<>();

    public static void put(SysUser sysUser)
        LOCAL.set(sysUser);
    

    public static SysUser get()
        return LOCAL.get();
    

    public static void remove()
        LOCAL.remove();
    

UserThreadLocal.put(sysUser);

5、拦截器

主要是为了拦截一些需要登陆过后才能操作的接口,比如写文章。评论等

配置

@Configuration
public class WebMVCConfig implements WebMvcConfigurer 
    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addCorsMappings(CorsRegistry registry) 
        //跨域配置,前后端分离端口不同,所以要配置
        registry.addMapping("/**").allowedOrigins("http://localhost:8080");

//        registry.addMapping("/**")
//                .allowedOriginPatterns("*")
//                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
//                .allowCredentials(true)
//                .maxAge(3600)
//                .allowedHeaders("*");
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) 
        //拦截test接口,后续实际遇到需要拦截的接口时,再配置为真正的拦截接口
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/test")
                .addPathPatterns("/comments/create/change") //评论前要先登录
                .addPathPatterns("/articles/publish"); // 写文章前也要登录
    


使用:自动执行,当访问我们拦截的接口

@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor 
    @Autowired
    private LoginService loginService;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception 
        //在执行controller方法(Handler)之前进行执行
        /**
         * 1. 需要判断 请求的接口路径 是否为 HandlerMethod (controller方法)
         * 2. 判断 token是否为空,如果为空 未登录
         * 3. 如果token 不为空,登录验证 loginService checkToken
         * 4. 如果认证成功 放行即可
         */
        if (!(handler instanceof HandlerMethod))
            //handler 可能是 RequestResourceHandler springboot 程序 访问静态资源 默认去classpath下的static目录去查询
            return true;
        
        String token = request.getHeader("Authorization");

        log.info("=================request start===========================");
        String requestURI = request.getRequestURI();
        log.info("request uri:",requestURI);
        log.info("request method:",request.getMethod());
        log.info("token:", token);
        log.info("=================request end===========================");


        if (StringUtils.isBlank(token))
            Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(), "未登录");
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().print(JSON.toJSONString(result));
            return false;
        
        SysUser sysUser = loginService.checkToken(token);
        if (sysUser == null)
            Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(), "未登录");
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().print(JSON.toJSONString(result));
            return false;
        
        //登录验证成功,放行
        //我希望在controller中 直接获取用户的信息 怎么获取?
        UserThreadLocal.put(sysUser);
        return true;
    

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception 
        //如果不删除 ThreadLocal中用完的信息 会有内存泄漏的风险
        UserThreadLocal.remove();
    

6、线程池

当我们查询某一篇文章时,阅读数要相应增加,但是更新操作会加锁阻塞读操作,这样就会影响文章详情的响应,所以把更新操作交给线程池来做,这样就不会影响主线程的查询文章详情

/**
 * @author ljy1999
 * @version 1.0.0
 * @ClassName 线程池来更新阅读次数
 * @Description TODO
 * @createTime 2021年12月02日 14:50:00
 */
@Configuration
@EnableAsync
public class ThreadPoolConfig 

    @Bean("taskExecutor")
    public Executor asyncServiceExecutor() 
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 设置核心线程数
        executor.setCorePoolSize(5);
        // 设置最大线程数
        executor.setMaxPoolSize(20);
        //配置队列大小
        executor.setQueueCapacity(Integer.MAX_VALUE);
        // 设置线程活跃时间(秒)
        executor.setKeepAliveSeconds(60);
        // 设置默认线程名称
        executor.setThreadNamePrefix("小刘博客项目");
        // 等待所有任务结束后再关闭线程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        //执行初始化
        executor.initialize();
        return executor;
    

/**
 * @author ljy1999
 * @version 1.0.0
 * @ClassName ThreadService.java
 * @Description TODO
 * @createTime 2021年12月02日 14:51:00
 */
@Service
public class ThreadService 
    //期望此操作在线程池 执行 不会影响原有的主线程
    @Async("taskExecutor")
    public void updateArticleViewCount(ArticleMapper articleMapper, Article article)
        // 修改文章
        Article articleUpdate = new Article();
        // 阅读加一
        articleUpdate.setViewCounts(article.getViewCounts() + 1);
        // 使用LambdaQueryWrapper,在eq中就可以直接用Article::getId形式,而不用去知道数据库中的字段是什么
        LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Article::getId,article.getId());
        //设置一个ViewCounts 为了在多线程的环境下 线程安全
        queryWrapper.eq(Article::getViewCounts,article.getViewCounts());
        // 这个时候文章阅读已经被修改  update article set view_count=100 where view_count=99 and id=11
        articleMapper.update(articleUpdate,queryWrapper);
/*        try 
            //睡眠5秒 证明不会影响主线程的使用
            Thread.sleep(5000);
         catch (InterruptedException e) 
            e.printStackTrace();
        */
    

//查看完文章了,新增阅读数,有没有问题呢?
//查看完文章之后,本应该直接返回数据了,这时候做了一个更新操作,更新时加写锁,阻塞其他的读操作,性能就会比较低
// 更新 增加了此次接口的 耗时 如果一旦更新出问题,不能影响 查看文章的操作
//线程池  可以把更新操作 扔到线程池中去执行,和主线程就不相关了
threadService.updateArticleViewCount(articleMapper,article);

7、AOP

日志

创建注解

/**
 * @author ljy
 * @version 1.0.0
 * @Description 日志注解
 * @createTime 2021年12月02日 22:39:00
 */
//Type 代表可以放在类上面 Method 代表可以放在方法上
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented  // 这三个注解是固定的
public @interface LogAnnotation 

    // 模块名称 默认为空
    String module() default "";
    // 操作名称
    String operator() default "";

配置注解

/**
 * @author ljy
 * @version 1.0.0
 * @Description 日志切面
 * @createTime 2021年12月02日 22:40:00
 */
@Aspect //切面 定义了通知和切点的关系
@Component
@Slf4j // 记录日志
public class LogAspect 

    // 定义切点 :com.liu.blog.common.aop.LogAnnotation
    // 切点是这个注解 就表示这个注解加到哪 哪就是切点
    @Pointcut("@annotation(com.liu.blog.common.aop.LogAnnotation)")
    public void logPointCut() 
    

    // 通知类 标识切点logPointCut
    // 环绕通知
    @Around("logPointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable 
        long beginTime = System.currentTimeMillis(); // 记录开始时间
        Object result = point.proceed();        //执行原有方法
        //执行时长(毫秒)
        long time 以上是关于SpringBoot练手项目总结的主要内容,如果未能解决你的问题,请参考以下文章

springboot+vue练手级项目,真实的在线博客系统

Activiti工作流在SpringBoot练手

Activiti工作流在SpringBoot练手

Activiti工作流在SpringBoot练手

史上最经典的几个Java练手项目!

SpringBoot-JdbcTemplate-Demo练手小案例(Eclipse)