个人博客项目开发总结(一) 项目架构及后端开发

 目录

一. 项目架构

1. 技术栈介绍

(1)后端

(2)前端

2. 运行环境

3. 架构设计与分析 

二. 后端开发

1. 数据库设计

(1)user 用户表

(2)blog 博客表 

(3)resource 资源表

2. 统一结果封装

3. 全局异常处理

(1) 在Filter中直接使用response返回(项目使用)

(2)重定向到Controller

4. 整合Redis缓存

(1)RedisConfig配置 

(2)RedisUtils 工具类

5. 权限管理

(1)JWTUtils Token工具类 

(2)Realm 校验类

(3)JwtFilter 拦截器

(4)ShiroConfig 配置类

6. 分页查询处理

6.1 设计思路

6.2  关于 limit 查询优化的思考

6.3 第三方工具PageHelper的使用

7. 多表查询处理

7.1  SQL层面连接查询

7.2 业务层面多次单表查询

8. 文章浏览量统计

8.1 问题概述

8.2 解决思路 

8.3 定时机制实现-Quartz定时框架 

8.4 本项目代码实现逻辑(实时刷新版本)

9. 后端接口实现

(1)Controller层 

(2)Service层

10. 问题与总结

10.1 FastJson使用报错

10.2 静态常量类

10.3 Controller多@RequestBody参数接收

10.4 整合七牛云OSS对象存储 


一. 项目架构

1. 技术栈介绍

(1)后端

  • SpringBoot2:后端服务开发框架
  • MyBatis:数据库交互与管理
  • Redis:数据缓存
  • Shiro:身份与权限管理
  • JWT:前后端分离令牌
  • Quartz:定时任务调度
  • MD5:数据加密
  • Qiniu:七牛云做图床/对象存储
  • PageHelper:数据分页查询

(2)前端

  • Vue2:前端服务开发框架
  • VueX:数据持久化
  • Axios:异步通信
  • elementUI+Vuetify:前端样式组件
  • 其他第三方插件:mavon-editor、markdown-it、highlight.js等

2. 运行环境

  • 开发工具:IDEA(后端) + WebStorm(前端)
  • 服务器:
  • 对象存储/图床:七牛云
  • 服务开发框架版本:SpringBoot2.6.3 + Vue2.9.6 + mysql8.0

3. 架构设计与分析 

        整个项目采用主流的前后端分离项目架构,后端使用SpringBoot开发,前端使用Vue开发。项目的用例设计思路如下:

  • 基本用例:博客列表展示、博客详情展示、博客搜索、分类列表展示、资源列表展示、资源详情展示、友链展示、关于我展示、登陆/注册、展示/修改个人信息、我的博客列表管理、我的资源列表管理、分类管理、他人空间展示(信息、博客、资源)、博客留言模块、博客编辑/发表、资源编辑/发表、退出/注销。
  • 权限等级:admin>editor>user>游客
    • admin:拥有博客系统的所有权限,可以登陆后台管理系统,admin权限不存在注册渠道。
    • editor:拥有博客浏览、资源浏览、博客发布、资源发布、分类添加权限,可以编辑自己发布的博客、资源,可以留言、对自己发布博客下的留言进行管理。editor权限可以在注册时通过邀请码进行激活。
    • user:拥有博客浏览、资源浏览、留言权限,用户在注册时默认为user权限。
    • 游客:拥有博客浏览、资源浏览权限,无需注册。

二. 后端开发

        在后端开发中,我们使用SpringBoot2.6.3作为后端服务开发框架,用mysql8.0作为关系数据库,整合MyBatis作为数据库交互框架,并使用Redis作为数据缓存工具。在项目架构方面,我们使用MVC三层架构划分业务逻辑,其详细介绍如下:

  • Dao层:Dao层接口是数据库交互的直接层,该层只提供简单的数据库交互操作,包括增删改查,只返回基本的结果集封装。Dao层只与Service层交互,每一个Dao层方法是一个基本的数据单元操作。

  • Service层:Service层提供业务的逻辑处理封装,缓存@Cacheable和事务@Transactional管理集中在Service层处理,所以涉及缓存、业务逻辑封装、事务管理的所有操作集中在Service层,并且Service层也只处理返回中间结果形式!Service层向上为其他各层提供具体的逻辑处理方法,每一个Service层方法是一个基本的逻辑单元操作(可能包含多个数据单元操作)。

  • Controller层:Controller层主要对前端接收匹配Request请求,并交由Service处理。提供主要的业务流程控制,并不进行业务逻辑的具体实现,该层不涉及不体现缓存和事务相关操作,返回最终响应结果ResultVo。Controller与前端交互,控制处理流程。

        在权限管理方面,使用Shiro+JWT的方式(现在主流可能是SpringSecurity,但Shiro比较简单和通用),将项目的权限管理大部分集中到后端处理,并实现Token自动刷新+Token注销后失效机制。 

1. 数据库设计

        本项目中所设计的数据库表包括user用户表、blog博客表、resource资源表、comment评论表、type类型表、link友链信息表、siteinfo网站信息表。在数据库表之间并没有建立外键,所以涉及到数据表连接查询时,需要进行sql层面或业务逻辑层面的人为控制。其中一些主要的数据库表信息如下:

(1)user 用户表

(2)blog 博客表 

(3)resource 资源表

2. 统一结果封装

        在前后端数据交互过程中,我们使用一个ResultVo对象统一封装异步数据结果返回给前端,为了实现泛化性和可拓展性,我们将ResultVo内的属性设计如下:

  • int code:响应状态编码。RES_FAIL = 0,RES_SUCCESS = 1,RES_ERROR = 2
  • String message:响应结果提示消息。
  • HashMap<String,Object> data:响应结果携带数据(可多个)。key:value格式
public class ResultVo {

    private int code;
    private String message;
    private HashMap<String,Object> data;

    private ResultVo(int _code, String _message, HashMap<String, Object> _data) {
        this.code = _code;
        this.message = _message;
        this.data = _data;
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public HashMap<String, Object> getData() {
        return data;
    }

    public static ResultVo success(){
        return new ResultVo(ConstantUtils.RES_SUCCESS,null,null);
    }

    public static ResultVo success(String _message){
        return new ResultVo(ConstantUtils.RES_SUCCESS,_message,null);
    }

    public static ResultVo fail(){
        return new ResultVo(ConstantUtils.RES_FAIL,null,null);
    }

    public static ResultVo fail(String _message){
        return new ResultVo(ConstantUtils.RES_FAIL,_message,null);
    }

    public static ResultVo error(){
        return new ResultVo(ConstantUtils.RES_ERROR,null,null);
    }

    public static ResultVo error(String _message){
        return new ResultVo(ConstantUtils.RES_ERROR,_message,null);
    }

    public ResultVo setAttribute(String key, Object value){
        if(this.data==null)this.data = new HashMap<String,Object>();
        this.data.put(key,value);
        return this;
    }
}

3. 全局异常处理

        对于后端抛出的全局异常,如果不配置异常处理机制,就会默认返回tomcat或者nginx的5XX页面,对普通用户来说不太友好。所以我们需要进行一个全局异常捕获和统一处理,其常用方法是使用@ControllerAdvice@ExceptionHandler注解开启。

//全局异常处理类:处理被抛出但无人接收的异常
@RestControllerAdvice
public class ExceptionController {

    // 捕获Shiro异常
    @ExceptionHandler(ShiroException.class)
    public ResultVo handleShiroException() {
        return ResultVo.error("非法权限访问");
    }

    // 捕捉其他所有异常
    @ExceptionHandler(Exception.class)
    public ResultVo handleException(Exception e) {
        e.printStackTrace();
        return ResultVo.error("系统访问异常");
    }

}
  • 产生问题:权限管理中Filter抛出的全局异常ExceptionHandler无法捕获。
  • 原因分析:Filter 处理是在控制器Controller之前进行的, 所以由 @ControllerAdvice注解的全局异常处理器无法处理这里Filter抛出的异常(@ControllerAdvice是由spring 提供的增强控制器) ,只能处理SpringBoot本身组件所产生的全局异常。
  • 解决方法:在Filter中直接使用response返回重定向到Controller

(1) 在Filter中直接使用response返回(项目使用)

 private void responseError(ServletResponse response, String message) {
        try {
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            httpServletResponse.setContentType("application/json;charset=utf-8");
            httpServletResponse.getWriter().print(JSON.toJSONString(ResultVo.error(message)));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

(2)重定向到Controller

/**
* 将非法请求转到 /unauthorized/** 处理
*/
private void responseError(ServletResponse response, String message) {
    try {
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        //设置编码,否则中文字符在重定向时会变为空字符串
        message = URLEncoder.encode(message, "UTF-8");
        httpServletResponse.sendRedirect("/unauthorized/" + message);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

注意:

  • 在shiro的配置类中需要配置对重定向的路径访问无需授权,否侧重定向后会重新进入JWTFilter 中继续判断,形成死循环。  
  • 重定向时,如果message路径参数含有中文、特殊符号等,会导致路径解析异常,无法正确重定向,具体原因和解决方法未知。

4. 整合Redis缓存

        在项目开发中,缓存的引入是必须的,他可以加速数据响应,减少数据库的压力。在本项目中,使用缓存的地方主要有三个:一个是业务逻辑数据缓存(博客、资源、分类、留言等信息的查询数据缓存)、一个是认证授权中Token信息的缓存、一个是浏览量数据的缓存。对于这三部分数据可以分为两类:

  • 粗粒度缓存:业务逻辑数据缓存属于粗粒度缓存。这类数据缓存只需要缓存查询数据,在数据更新时清空对应缓存即可。这类缓存我们可以通过SpringBoot提供的简单的@Cacheable相关缓存注解实现即可。
  • 细粒度缓存:Token信息的缓存和浏览量数据的缓存属于细粒度缓存。这类缓存不仅需要缓存数据,还需要对具体的缓存数据进行相应的操作,比如刷新某个Token信息的某项(此处逻辑在权限管理处讲解)、某个浏览量缓存+-多少数字等等。这类缓存我们可以通过RedisTemplate来进行细粒度操作。

        经过上述分析,我们可以发现这两种粒度的缓存是最好分库处理的(互不影响),并且我们还需要两种不同的操作缓存的方式,因此在整合Redis缓存时,我们需要进行“SpringBoot 多Redis Index库操作解决方案 之 RedisTemplate+@Cache缓存注解分库操作 ”,详细解决方案分析可见我之前的博客 https://blog.csdn.net/qq_40772692/article/details/119875099?spm=1001.2014.3001.5501

(1)RedisConfig配置 

        这里主要通过配置 两个不同Redis Index的LettuceConnectionFactory连接工厂来实现操作不同的Redis库,这里要注意一个细节问题:当注入多个factory bean时,要指定@Primary,否则会报错

  • 原因:redis-data自动配置过程中,除了redis还会自动配置一个ReactiveRedisTemplate。ReactiveRedisTemplate与RedisTemplate使用类似,但它提供的是异步的,响应式的Redis交互方式。ReactiveRedisTemplate的自动注入也需要工厂factory,因为我们没有自己注入自定义的ReactiveRedisTemplate。所以它会自动配置生成,但是当发现我们有多个factory bean,它就无法选择注入哪个了(自定义factory bean后,springboot不再自动配置factory @ConditionalOnMissingBean注解的作用)。所以我们要指定主要的factory bean,即 @Primary (默认的、主要的、首选的)
  • 解决方法:使用@Qualifier 指定注入bean名称;或使用@Primary 指定多个同类型注入时默认的注入bean。
/**
 * 配置 Redis 多 dbIndex 操作
 *  1.RedisTemplate处理RefreshToken缓存,存储与缓存库 REDIS_INDEX_TOKEN(1)
 *  2.@Cache + chacheManager处理业务缓存,存储与缓存库 REDIS_INDEX_SERVICE(0)
 */

@Configuration
@EnableCaching //开启缓存注解支持
public class RedisConfig {

    @Resource
    private RedisProperties redisProperties;

    /**
     * redis 单机配置(默认)
     *  1.配置基本的redis连接属性(host,port等)
     *  1.哨兵模式和集群模式我们暂时用不到,不再配置(不需要数据备份和高并发)
     */
    private RedisStandaloneConfiguration redisConfiguration() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName(redisProperties.getHost());
        redisStandaloneConfiguration.setPort(redisProperties.getPort());
        //设置密码
        if (redisProperties.getPassword() != null) {
            redisStandaloneConfiguration.setPassword(RedisPassword.of(redisProperties.getPassword()));
        }
        return redisStandaloneConfiguration;
    }

    /**
     * redis Lettuce客户端配置 + 连接池
     */
    private LettuceClientConfiguration clientConfiguration() {
        //配置连接池
        GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
        poolConfig.setMaxIdle(redisProperties.getLettuce().getPool().getMaxIdle());
        poolConfig.setMinIdle(redisProperties.getLettuce().getPool().getMinIdle());
        poolConfig.setMaxTotal(redisProperties.getLettuce().getPool().getMaxActive());
        poolConfig.setMaxWait(redisProperties.getLettuce().getPool().getMaxWait());
        //配置客户端
        LettucePoolingClientConfiguration.LettucePoolingClientConfigurationBuilder builder = LettucePoolingClientConfiguration.builder();
        //设置关闭超时时间,原setTimeout已弃用
        builder.shutdownTimeout(redisProperties.getLettuce().getShutdownTimeout());
        builder.commandTimeout(redisProperties.getLettuce().getShutdownTimeout());
        return builder.poolConfig(poolConfig).build();
    }

    /**
     * 配置 业务逻辑缓存的redisConnectionFactory
     */
    @Primary
    @Bean("redisServiceFactory")
    public LettuceConnectionFactory redisServiceFactory(){
        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisConfiguration(),clientConfiguration());
        lettuceConnectionFactory.setDatabase(ConstantUtils.REDIS_INDEX_SERVICE);
        return lettuceConnectionFactory;
    }

    /**
     * 配置 Token缓存的redisConnectionFactory
     */
    @Bean("redisTokenFactory")
    public LettuceConnectionFactory redisTokenFactory(){
        LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisConfiguration(),clientConfiguration());
        lettuceConnectionFactory.setDatabase(ConstantUtils.REDIS_INDEX_UTILS);
        return lettuceConnectionFactory;
    }

    //RedisTemplate配置 RedisTemplate与@Cacheable独立,需要重新设置序列化方式
    @Bean
    public RedisTemplate<String,Object> redisTemplate(@Qualifier("redisTokenFactory") RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        // value值的序列化采用fastJsonRedisSerializer
        template.setValueSerializer(jsonRedisSerializer);
        template.setHashValueSerializer(jsonRedisSerializer);
        // key的序列化采用StringRedisSerializer
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        return template;
    }

    /**
     * 缓存注解@Cache 配置
     */
    @Bean
    public CacheManager cacheManager(@Qualifier("redisServiceFactory") RedisConnectionFactory factory) {
        GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        // 配置序列化
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        RedisCacheConfiguration redisCacheConfiguration = config
                // 键序列化方式 redis字符串序列化
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringRedisSerializer))
                // 值序列化方式 简单json序列化
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(genericJackson2JsonRedisSerializer))
                //不缓存Null值
                .disableCachingNullValues()
                //默认缓存失效 3天
                .entryTtl(Duration.ofDays(2));
        return RedisCacheManager.builder(factory).cacheDefaults(redisCacheConfiguration).build();
    }

    /**
     * 重写缓存key的生成方式: 类名.方法名字&[参数列表]
     */
    @Bean
    public KeyGenerator keyGenerator(){
        return new KeyGenerator() {
            @Override
            public Object generate(Object target, Method method, Object... params) {
                StringBuilder sb = new StringBuilder();
                sb.append(target.getClass().getName()).append(".");//执行类名
                sb.append(method.getName()).append("&");//方法名
                sb.append(Arrays.toString(params));//参数
                return sb.toString();
            }
        };
    }
}

(2)RedisUtils 工具类

        在封装RedisUtils工具类时,遇到一个小问题:我们需要RedisUtils类对外提供静态方法,这就要求RedisTemplate是静态变量。而RedisTemplate我们在RedisConfig中已经注册了,这里就需要注入RedisUtils。但是由于RedisTemplate是静态变量,其在程序编译时就已经赋值完成,传统的@Autowired在程序运行时以及无法注入了,所以这里就需要进行静态变量注入,其步骤如下:

  • 使用static声明静态变量,并设置其非 static 的 set方法
  • 使用@Autowired标注该set方法,解决静态变量自动注入问题
@Component
public class RedisUtils {

    /**
     * 注入静态 static 变量
     *  1.问题:直接 @Autowired注入静态变量,会导致空指针错误
     *  2.原因:static属于类的属性,在类初始化时就完成创建了。但是 @Autowired 在对象生成时才注入,因此空指针null
     *  3.解决办法:static声明变量,设置其非 static 的 set方法,并使用@Autowired/@Value标注,解决问题。
     */
    private static RedisTemplate<String,Object> redisTemplate;

    @Autowired
    public void setRedisTemplate(RedisTemplate<String, Object> redisTemplate) {
        RedisUtils.redisTemplate = redisTemplate;
    }

    /**
     * 指定目标缓存失效时间(秒),默认永久有效
     * @param key
     * @param time (time<=0不改变过期时间)
     * @return
     */
    public static boolean expire(String key,long time){
        try{
            if(time > 0){
                redisTemplate.expire(key,time, TimeUnit.SECONDS);
            }
            return true;
        }catch(Exception e){
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 根据key 获取过期时间(秒)
     * @param key
     * @return 时间(秒)
     *      1.The command returns -2 if the key does not exist.
     *      2.The command returns -1 if the key exists but has no associated expire.
     *      3.The command returns -3 if exception is occured
     */
    public static long getExpire(String key){
        try{
            return redisTemplate.getExpire(key,TimeUnit.SECONDS);
        }catch (Exception e){
            e.printStackTrace();
            return -3;
        }
    }

    /**
     * 判断key是否存在
     * @param key
     * @return
     */
    public static boolean hasKey(String key){
        try{
            return redisTemplate.hasKey(key);
        }catch (Exception e){
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 设置缓存数据
     * @param key
     * @param value
     */
    public static boolean put(String key,Object value){
        try{
            redisTemplate.opsForValue().set(key,value);
            return true;
        }catch (Exception e){
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 获取缓存数据
     * @param key
     * @return
     */
    public static Object get(String key){
        try{
            return redisTemplate.opsForValue().get(key);
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }


    /**
     * 设置缓存数据,并设置过期时间
     * @param key
     * @param value
     * @param time 时间(秒) 注意若time<=0,则设置无期限
     * @return
     */
    public static boolean put(String key,Object value,long time){
        try{
            if(time > 0){
                redisTemplate.opsForValue().set(key,value,time,TimeUnit.SECONDS);
            }else{
                redisTemplate.opsForValue().set(key,value);
            }
            return true;
        }catch (Exception e){
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 删除目标缓存
     * @param key
     * @return
     */
    public static boolean del(String key){
        try{
            return redisTemplate.delete(key);
        }catch (Exception e){
            e.printStackTrace();
            return false;
        }
    }

    /**
     * hashGet
     * @param key 键 mapName
     * @param item 项 mapItem
     * @return
     */
    public static Object hget(String key, String item) {
        return redisTemplate.opsForHash().get(key, item);
    }

    /**
     * 获取hashKey对应的所有键值
     *
     * @param key 键
     * @return 对应的多个键值
     */
    public static Map<Object, Object> hmget(String key) {
        return redisTemplate.opsForHash().entries(key);
    }

    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key   键
     * @param item  项
     * @param value 值
     * @return true 成功 false失败
     */
    public static boolean hset(String key, String item, Object value) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 向一张hash表中放入数据,如果不存在将创建
     *
     * @param key   键
     * @param item  项
     * @param value 值
     * @param time  时间(秒) 注意:如果已存在的hash表有时间,这里将会替换原有的时间
     * @return true 成功 false失败
     */
    public static boolean hset(String key, String item, Object value, long time) {
        try {
            redisTemplate.opsForHash().put(key, item, value);
            if (time > 0) {
                expire(key, time);
            }
            return true;
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 删除hash表中的值
     *
     * @param key  键 不能为null
     * @param item 项 可以使多个 不能为null
     */
    public static void hdel(String key, Object... item) {
        redisTemplate.opsForHash().delete(key, item);
    }

    /**
     * 判断hash表中是否有该项的值
     *
     * @param key  键 不能为null
     * @param item 项 不能为null
     * @return true 存在 false不存在
     */
    public static boolean hHasKey(String key, String item) {
        return redisTemplate.opsForHash().hasKey(key, item);
    }

    /**
     * hash递增
     *  如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCRBY 命令
     * @param key  键
     * @param item 项
     * @param by   要增加几(大于0)
     * @return
     */
    public static long hincr(String key, String item, long by) {
        return redisTemplate.opsForHash().increment(key, item, by);
    }

    /**
     * hash递减
     *
     * @param key  键
     * @param item 项
     * @param by   要减少记(小于0)
     * @return
     */
    public static long hdecr(String key, String item, long by) {
        return redisTemplate.opsForHash().increment(key, item, -by);
    }

    /**
     * 清空redis缓存
     * @return The number of keys that were removed.
     */
    public static long flushDB(){
        try{
            Set<String> keys = redisTemplate.keys("*");
            return redisTemplate.delete(keys);
        }catch(Exception e){
            e.printStackTrace();
            return 0;
        }
    }
}

5. 权限管理

        在权限管理中,我们使用Shiro框架作为认证和授权框架,并使用JWT作为前后端分离的“令牌”,除此之外我们还使用Redis作为Token信息的缓存。有人可能问,Token本来应该是无状态的,你这样存入Redis不就变成有状态的了?我们这里引入Redis主要是为了解决两个问题:

  • token不能自动刷新:这样就导致token的有效期是写死的。如果用户在写博客的场景下,写的过程中token过期了导致其内容全部丢失,这就是非常不好的用户体验。所以我们希望,用户在正常使用时,如果这个过程中token过期了,token可以实现自动刷新!

  • 用户退出后其token仍有效:如果用户主动退出,则旧的token在有效期内仍是有效的,可能会被别人盗用token登录,带来安全问题。当然解决这个问题的方式有几种:建立token白名单,建立token黑名单,无为而治(交给前端处理清除),使用redis+refreshToken进行token刷新(本项目方案)

        关于整套权限管理的解决方案,可以看我之前我文章解释的很详细,我们这里就直接拿来整合即可:https://blog.csdn.net/qq_40772692/article/details/121170343?spm=1001.2014.3001.5501

(1)JWTUtils Token工具类 

1.JWT Token令牌中主要存放两种信息:

  • userName:唯一标识用户身份的用户名
  • timeStamp:标识Token有效与否的时间戳(与Redis中的RefreshToken相对应)

2.密钥获取规则:为了保证安全性,我们不使用固定的密钥。我们通过每个Token的userName作secret,timeStamp作salt生成Md5加密字符串,然后截取部分加密字符串作为该Token的密钥。

public class JwtUtils {

    /**
     * 根据要放入的有效荷载信息生成token
     * @param userName 用户名
     * @param timeStamp 时间戳
     * @return
     */
    public static String creatToken(String userName,String timeStamp){
        String secretKey = MD5Utils.getMd5Middle(userName,timeStamp);
        //声明过期时间(以小时计算)
        Calendar instance = Calendar.getInstance();
        instance.add(Calendar.HOUR_OF_DAY, ConstantUtils.ACCESSTOKEN_ACTIVE);
        //生成JWT token
        String token = JWT.create()
                .withClaim("userName",userName)
                .withClaim("timeStamp",timeStamp)
                .withExpiresAt(instance.getTime())
                .sign(Algorithm.HMAC256(secretKey));
        return token;
    }


    /**
     * 验证token
     * @param token
     * @return
     */
    public static boolean verifyToken(String token,String userName,String timeStamp){
        String secretKey = MD5Utils.getMd5Middle(userName,timeStamp);
        //验证token 签名有效 + 未过期
        JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secretKey)).build();
        verifier.verify(token);
        return true;
    }

    /**
     * 获得token中的用户名信息,无需secret解密也能获得(不过可能是传输出错的信息)
     */
    public static String getUserName(String token){
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("userName").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

    /**
     * 获得token中的时间戳信息
     */
    public static String getTimeStamp(String token){
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("timeStamp").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }

}

(2)Realm 校验类

        Realm类主要进行一些简单的身份认证、权限校验功能。注意在实现Realm时可能会出现Realm内调用 Service 缓存和事务失效的问题,对于该问题分析如下:

  • 出现的原因:这是由于spring中的bean加载顺序问题,shiro会强制realm比事务和缓存提前加载,而service又在realm中,所以service就提前加载了,从而没有缓存和事务的支持。
  • 解决方法:同时使用@Lazy注解标注service,这样在realm用到service时才会去加载它,实现延迟加载策略!
/**
 * 自定义的 Shiro Realm
 */
public class CustomRealm extends AuthorizingRealm {

    //1.只要配置了在Spring里管理(@Bean),就可以使用Autowired注入
    //2.@Lazy 延迟注入,解决Realm内调用Service 缓存和事务失效问题
    @Autowired
    @Lazy
    IUserService userService;

    //重写supports方法:支持自定义JWTToken的认证与授权
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 授权校验
     * @param principalCollection
     * @return
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        //System.out.println("执行了 => 授权方法doGetAuthorizationInfo");
        //获取用户名(能执行到这一步,说明已经通过了认证,无需验证token)
        String username = JwtUtils.getUserName((String)principalCollection.getPrimaryPrincipal());
        //数据库查询角色权限信息
        User user = userService.getUserByName(username);
        //如果权限不为空
        if(user.getUserRole()!=null){
            //返回角色权限信息
            SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
            authorizationInfo.addRole(user.getUserRole());
            return authorizationInfo;
        }
        return null;
    }

    /**
     * 认证校验
     * @param authenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        //System.out.println("执行了 => 认证方法doGetAuthenticationInfo");
        //从主体传过来的认证信息中,获取需要认证的token
        String token = (String)authenticationToken.getPrincipal();
        //获取token 携带的校验信息
        String userName = JwtUtils.getUserName(token);
        if(userName==null || JwtUtils.getTimeStamp(token)==null){
            throw new UnsupportedTokenException("登录用户信息丢失");
        }
        //判断用户是否真实有效
        User user = userService.getUserByName(userName);
        if(user == null){
            throw new UnknownAccountException("登录用户不存在");
        }else if(user.getUserStatus()==0){
            throw new LockedAccountException("登录用户已被锁定");
        }
        return new SimpleAuthenticationInfo(token,token,this.getName());
    }
}

(3)JwtFilter 拦截器

        shiro原理再理解,授权注解(比如@RequireRoles)一般都是通过代理创建切面,对方法进行增强,在具体逻辑执行之前进行权限判断。一般认证只需一步,即通过认证判断即可。但是授权需要两步,先进行认证(token登录校验),如果登陆成功以后shiro会注册subject.Credentials()信息,绑定登陆状态,这时候再进行realm的授权判断。如果没有登陆,那subject.Credentials()信息就为空,直接不会进入realm的授权判断,直接返回无权的异常!这也就是为什么不携带token,直接不执行登录和授权操作判断的原因!

public class JwtFilter extends BasicHttpAuthenticationFilter {

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        //如果携带Token,说明要进行验证
        if(isLoginAttempt(request,response)){
            try{
                //进入 executeLogin 方法执行登入,检查 token 第一阶段是否正确
                executeLogin(request,response);
                return true;
            }catch (Exception e){
                //若有异常,则说明该token是一定异常的,不可刷新直接响应
                responseError(response,e.getMessage());
                return false;
            }
        }
        return true;
    }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        return false;
    }

    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        String token = ((HttpServletRequest) request).getHeader("AccessToken");
        JwtToken jwtToken = new JwtToken(token);
        // 提交给realm进行登入,如果错误他会抛出异常并被捕获
        Subject subject = getSubject(request, response);
        subject.login(jwtToken);
        // 如果没有抛出异常则代表第一阶段登入成功,进行token过期刷新检查
        return this.onLoginSuccess(jwtToken,subject,request,response);
    }

    @Override
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
        try{
            String jwtToken = (String) token.getCredentials();
            String userName = JwtUtils.getUserName(jwtToken);
            String accessToken_timeStamp = JwtUtils.getTimeStamp(jwtToken);
            JwtUtils.verifyToken(jwtToken,userName,accessToken_timeStamp);
            String refreshToken_timeStamp = String.valueOf(RedisUtils.get(userName));
            if(refreshToken_timeStamp==null || !accessToken_timeStamp.equals(refreshToken_timeStamp)){
                throw new Exception("登录信息异常");
            }
            return true;
        }catch(TokenExpiredException e){
            //token 刷新校验
            if (refreshToken(request,response)){
                return true;
            }else{
                throw new Exception("用户登录状态已失效");
            }
        }catch (Exception e){
            throw new Exception("登录信息出错");
        }
    }

    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        String token = ((HttpServletRequest)request).getHeader("AccessToken");
        return token!=null;
    }

    private void responseError(ServletResponse response, String message) {
        try {
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            httpServletResponse.setContentType("application/json;charset=utf-8");
            httpServletResponse.getWriter().print(JSON.toJSONString(ResultVo.error(message)));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @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(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }

    /**
     * 尝试刷新 Token:判断RefreshToken是否过期,未过期就返回新的AccessToken且继续正常访问
     * @param request
     * @param response
     * @return
     */
    private boolean refreshToken(ServletRequest request,ServletResponse response){
        String token = ((HttpServletRequest) request).getHeader("AccessToken");
        String userName = JwtUtils.getUserName(token);
        String accessToken_timeStamp = JwtUtils.getTimeStamp(token);
        String refreshToken_timeStamp = String.valueOf(RedisUtils.get(userName));
        if(refreshToken_timeStamp!=null && accessToken_timeStamp.equals(refreshToken_timeStamp)){
            //获取最新时间戳
            String currentTimeMillis = String.valueOf(System.currentTimeMillis());
            // 刷新refreshToken
            RedisUtils.put(userName,currentTimeMillis, ConstantUtils.REFRESHTOKEN_ACTIVE);
            // 刷新AccessToken,为当前最新时间戳
            token = JwtUtils.creatToken(userName,currentTimeMillis);
            // 设置响应的Header头新Token
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            httpServletResponse.setHeader("AccessToken", token);
            httpServletResponse.setHeader("Access-Control-Expose-Headers", "AccessToken");
            return true;
        }
        return false;
    }

}

(4)ShiroConfig 配置类

@Configuration
public class ShiroConfig {

    /**
     *  配置shiroFilter工厂
     */
    @Bean("shiroFilterFactoryBean")
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(@Qualifier("securityManager") SecurityManager securityManager){
        //新建拦截过滤器的工厂类
        ShiroFilterFactoryBean filterFactoryBean = new ShiroFilterFactoryBean();
        filterFactoryBean.setSecurityManager(securityManager);
        // 添加自己的过滤器到ShiroFilterFactory里,并且取名为jwt
        Map<String, Filter> filterMap = new LinkedHashMap<>();
        filterMap.put("jwt", new JwtFilter());
        filterFactoryBean.setFilters(filterMap);
        //配置拦截规则,所有请求都通过我们自己的JWT Filter即可
        Map<String, String> filterRuleMap = new LinkedHashMap<>();
        filterRuleMap.put("/user/login","anon");
        filterRuleMap.put("/user/register","anon");
        filterRuleMap.put("/resource/uploadImage","anon");
        filterRuleMap.put("/**", "jwt");
        filterFactoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return filterFactoryBean;
    }

    /**
     *  配置web相关的SecurityManager
     * @param :customRealm 使用@Qualifier()按名称注入参数
     * @return
     */
    @Bean("securityManager")
    public DefaultWebSecurityManager getDefaultWebSecurityManager(@Qualifier("customRealm") CustomRealm customRealm){
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        //关联realm对象
        securityManager.setRealm(customRealm);
        //关闭shiro自带的session存储,实现无状态Token
        DefaultSubjectDAO subjectDAO=new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator=new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        return securityManager;
    }

    /**
     * 配置自定义的 realm对象
     * @return
     */
    @Bean("customRealm")
    public CustomRealm getRealm(){
        CustomRealm customRealm = new CustomRealm();
        //这里不需要配置密码比对器了,默认即可
        return customRealm;
    }

//    /**
//     * 自动创建代理:解决redis重复代理问题
//     * @return
//     */
//    @Bean
//    @DependsOn("lifecycleBeanPostProcessor")
//    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
//        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
//        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
//        /**
//         * 解决重复代理问题 匹配前缀 authorizationAttributeSourceAdvisor
//         */
//        defaultAdvisorAutoProxyCreator.setUsePrefix(true);
//        defaultAdvisorAutoProxyCreator.setAdvisorBeanNamePrefix("authorizationAttributeSourceAdvisor");
//        return defaultAdvisorAutoProxyCreator;
//    }

}

6. 分页查询处理

6.1 设计思路

        如果我们要使用分页方式,一般要获取两种数据,一个是总数据量/总页数,另一个是分页数据列表。为了获取这两种数据,我们一般有三种思路:

  • 一是:在页面加载初始化时,直接查询返回所有数据,然后在前端完成分页展示。这种方式的弊端就是当数据量大时(十万百万千万级别数据),难以传输/效率低下。它的解决办法一般就是添加一个最大限制页数,限制传输数据数量。比如我们限制每次最多获取50页数据,前端最多展示到50页,多于50的用...展示(但不显示具体页数和内容,因为我们还没查询呢),当用户想要浏览50页之后的内容时,再点击...时,我们再重新查询50页之后的50页数据返回给前端,然后前端只显示50开始的页数内容(舍弃50之前),同理其前和后的其他数据也用...表示,这样能优化用户体验。

  • 二是:我们把分页的工作交给后端来进行,前端每次只接受分页好的数据展示即可。这样做的好处就是传输数据量小,分页实时和精确。但是带来的问题就是:一方面我们每次分页都要重新查询,增加了数据库负担;另一方面就是我们需要返回两个数据即总数据量/总分页数+分页数据列表,这两个数据只能通过两次数据库查询进行,为了解决幻读,我们可能还需要增加事务控制,防止两次查询不一致的问题,为了提高效率,我们可能还需要应用索引来查询。

  • 三是:后端改为一次查询,不查询数据总量/总页数,只返回分页数据。要实现这个效果,前端页面就必须配合做出改变,使用下滑滚动加载分页的方式(比如手机上的下滑列表),这样就不需要总页数这个信息了。我们只需要获取上次查询的最大Id,然后使用 select * from table where userId > id limit 100 这种方式。

6.2  关于 limit 查询优化的思考

在分页过程中,我们的查询语句难免要使用到 limit 关键字,limit语句基本用法如下:SELECT * FROM table LIMIT [offset,] rows | rows OFFSET offset

例子: mysql> SELECT * FROM table LIMIT 5,10; // 检索记录行 6-15

(1)limit语句缺点:limit offect,rows适用于小数据量,小偏移量offset的情况。但是当数据量和偏移量增大时,越往后分页,语句需要扫描的记录就越多,效率就越低。如 select * from table limit 0,10 这个没有问题,但当 limit 200000,10 的时候数据读取就很慢!、

(2)常见使用方法(普通分页查询):SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT ...

(3)limit查询优化方法(核心是减少数据量扫描):

  • 子查询优化(索引扫描):

    • 举例:SELECT * FROM table WHERE id >= (SELECT id FROM table ORDER BY id LIMIT 10000, 1) LIMIT 10

    • 注意: 如果使用子查询去优化LIMIT的话,则子查询必须是连续的,某种意义来讲,子查询不应该有where条件,where会过滤数据,使数据失去连续性。如果你查询的记录比较大,并且数据传输量比较大,比如包含了text类型的field,则可以通过建立子查询。为什么会这样呢?因为子查询是在索引上完成的,而普通的查询时在数据文件上完成的,通常来说,索引文件要比数据文件小得多,所以操作起来也会更有效率。

  • 配合前端返回索引id进行查询:

    • select * from table where status = xx and id > 100000 limit 10;

    • SELECT score,first_name,last_name,id FROM student WHERE id>=last_id ORDER BY id ASC LIMIT 10

  • 嵌套子查询: select xxx from table where id in (select id from table where status = xx limit 10 offset 100000);

6.3 第三方工具PageHelper的使用

        PageHelper是一个独立于myBatis的第三方分页插件。它的工作原理是注册一个sql拦截器,通过treadLoacl绑定查询参数,在查询sql语句执行之前,重构拼接limit关键字来对原始的sql语句进行自动分页处理。

  • 优点:使用pageHelper的好处就是不影响xml的开发,而mybatisPlus耦合度太高!并且使用插件方便快捷,可以同时查询出查询总数和分页数据返回给前端。
  • 缺点:PageHelper的本质就是在原始SQL语句上直接拼接Limit关键字,并没有进行优化。在大数据量+偏移量高的情况下效率过低,不适用于大数据场景(十万百万级还是自己手写分页优化sql)

        由于本博客项目比较小,涉及数据量也较少,以简便开发为主,所以选择PageHelper作为本博客的分页处理方式,但也提出了以上的分页优化思考,可供大家参考。

(1)引入依赖

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.4.1</version>
</dependency>

(2)XML配置

#pageHelper配置(官网推荐配置)
pagehelper:
 helperDialect: mysql
 reasonable: true
 supportMethodsArguments: true
 params: count=countSql

参数说明:

  • helperDialect:分页插件会自动检测当前的数据库链接,自动选择合适的分页方式。 你可以配置helperDialect属性来指定分页插件使用哪种方言。配置时,可以使用下面的缩写值:`oracle`,`mysql
  • reasonable:分页合理化参数,默认值为`false`。当该参数设置为 `true` 时,`pageNum<=0` 时会查询第一页, `pageNum>pages`(超过总数时),会查询最后一页。默认`false` 时,直接根据参数进行查询。`
  • supportMethodsArguments:支持通过 Mapper 接口参数来传递分页参数,默认值false,分页插件会从查询方法的参数值中,自动根据上面 params 配置的字段中取值,查找到合适的值时就会自动分页。 使用方法可以参考测试代码中。
  • params:为了支持startPage(Object params)方法,增加了该参数来配置参数映射,用于从对象中根据属性名取值, 可以配置 pageNum,pageSize,count,pageSizeZero,reasonable,不配置映射的用默认值, 默认值为pageNum=pageNum;pageSize=pageSize;count=countSql;reasonable=reasonable;pageSizeZero=pageSizeZero。

(3)调用方式 

        PageHelper最核心的方法是:PageHelper.startPage。只有紧跟在PageHelper.startPage方法后的第一个Mybatis的查询(Select)方法会被分页。有关PageHelper的分页方式有很多种,在介绍之前我们先来看一些注意事项:

  • PageHelper 方法使用了静态的 ThreadLocal 参数,分页参数和线程是绑定的。只要你可以保证在 PageHelper 方法调用后紧跟 MyBatis 查询方法,每次都将对应的分页参数消费掉,这就是安全的。因为 PageHelper 在 finally 代码段中自动清除了 ThreadLocal 存储的对象。否则,未被消费的分页参数将会保留到线程中,被下一次分页消耗,这就产生了莫名其妙的分页。
  • 注意pageNum的起始值为1,而不是0
//1.第一种,RowBounds方式的调用
List<User> list = sqlSession.selectList("x.y.selectIf", null, new RowBounds(0, 10));

//2.第二种,Mapper接口方式的调用,推荐这种使用方式。
PageHelper.startPage(1, 10);
List<User> list = userMapper.selectIf(1);

//3.第三种,Mapper接口方式的调用,推荐这种使用方式。
PageHelper.offsetPage(1, 10);
List<User> list = userMapper.selectIf(1);

//4.第四种,参数方法调用
//存在以下 Mapper 接口方法,你不需要在 xml 处理后两个参数
public interface CountryMapper {
    List<User> selectByPageNumSize(
            @Param("user") User user,
            @Param("pageNum") int pageNum, 
            @Param("pageSize") int pageSize);
}
//配置supportMethodsArguments=true
//在代码中直接调用:
List<User> list = userMapper.selectByPageNumSize(user, 1, 10);

//5.第五种,参数对象
//如果 pageNum 和 pageSize 存在于 User 对象中,只要参数有值,也会被分页
//有如下 User 对象
public class User {
    //其他fields
    //下面两个参数名和 params 配置的名字一致
    private Integer pageNum;
    private Integer pageSize;
}
//存在以下 Mapper 接口方法,你不需要在 xml 处理后两个参数
public interface CountryMapper {
    List<User> selectByPageNumSize(User user);
}
//当 user 中的 pageNum!= null && pageSize!= null 时,会自动分页
List<User> list = userMapper.selectByPageNumSize(user);

//6.第六种,ISelect 接口方式
//jdk6,7用法,创建接口
Page<User> page = PageHelper.startPage(1, 10).doSelectPage(new ISelect() {
    @Override
    public void doSelect() {
        userMapper.selectGroupBy();
    }
});
//jdk8 lambda用法(本项目主要调用方式)
Page<User> page = PageHelper.startPage(1, 10).doSelectPage(()-> userMapper.selectGroupBy());

//也可以直接返回PageInfo,注意doSelectPageInfo方法和doSelectPage
pageInfo = PageHelper.startPage(1, 10).doSelectPageInfo(new ISelect() {
    @Override
    public void doSelect() {
        userMapper.selectGroupBy();
    }
});
//对应的lambda用法
pageInfo = PageHelper.startPage(1, 10).doSelectPageInfo(() -> userMapper.selectGroupBy());

//count查询,返回一个查询语句的count数
long total = PageHelper.count(new ISelect() {
    @Override
    public void doSelect() {
        userMapper.selectLike(user);
    }
});
//lambda
total = PageHelper.count(()->userMapper.selectLike(user));

7. 多表查询处理

        在设计过程中,比如我们需要给每个博客文章一个类型type,并且这些类型标签是可以增加、删除、修改的,因此我们需要给他单独设置一个表为类型表Type。那么在文章表Blog中就需要包括所属Type的id(数据库表设计中已给出),但是在前端显示文章列表时,我们需要显示Type的name,因此我们需要对两个表进行联合查询(除此之外,博客评论和用户信息的联系等也需要联合查询)。这里主要有三个方案:

  • 一是:在sql查询层面,使用连接查询。即使用join关键字对两表连接查询关联信息。

  • 二是:在业务层面,使用多次单独查询,然后再分别将查询结果进行遍历组合。

  • 三是:我们不使用type-id作为文章表Blog与类型表Type的连接属性,而是直接使用type-name来作为文章表Blog的属性,这样两个表就没什么直接关系了。但是可能需要在业务层面加强关系控制,防止两表对应数据前后不一致,这种方式太过繁杂,不是很规范!此处不再分析。

7.1  SQL层面连接查询

        SQL层面的连接查询主要就是通过join关键字连接。在MyBatis的xml文件中实现时,可以有多种优化方式,这里仅以ResultMap对象嵌套属性映射(实体类继承方式)+SQL连接查询为例(博客评论表Comment+用户信息表User的关联user-id):

//1.实体类--博客评论表Comment(数据库映射表)
public class Comment {
    private Integer commentId;
    private String commentContent;
    private String commentCreate;
    private Integer commentBlogid;
    private Integer commentUserid;//与User表的关联属性

}

//2.实体类--用户信息表User(独立)
public class User {
    private Integer userId;
    private String userName;
    private String userNickname;
    private String userPassword;
    private String userRole;
    private String userImgurl;
    private Integer userStatus;

}

//3.实体类--博客评论表(响应结果封装表)
public class CommentVo extends Comment {
    private User commentUser;//评论用户信息
}
<mapper namespace="com.zju.sdust.pblog.dao.ICommentDao">

    <resultMap id="commentMap" type="Comment">
        <id property="commentId" column="comment_id"></id>
        <result property="commentContent" column="comment_content"></result>
        <result property="commentCreate" column="comment_create"></result>
        <result property="commentBlogid" column="comment_blogid"></result>
        <result property="commentUserid" column="comment_userid"></result>
    </resultMap>

    <resultMap id="commentVoMap" type="CommentVo" extends="commentMap">
        <association property="commentUser" resultMap="com.zju.sdust.pblog.dao.IUserDao.userMap"></association>
    </resultMap>

    <select id="selectCommentByblog" resultMap="commentVoMap">
        select c.comment_id,c.comment_content,c.comment_create,c.comment_blogid,c.comment_userid,u.user_id,u.user_name,u.user_nickname,u.user_imgurl
        from comment c,`user` u
        where c.comment_userid = u.user_id and c.comment_blogid = #{blogId}
        order by c.comment_id desc
    </select>

</mapper>

7.2 业务层面多次单表查询

(1)SQL语句执行分析

        所有的查询语句都是从from开始执行的,在执行过程中,每个步骤都会为下一个步骤生成一个虚拟表,这个虚拟表将作为下一个执行步骤的输入。

  1. FROM:对FROM子句中的前两个表执行笛卡尔积(Cartesian product)(交叉联接),生成虚拟表VT1

  2. ON:对VT1应用ON筛选器。只有那些使<join_condition>为真的行才被插入VT2。

  3. OUTER(JOIN):如果指定了OUTER JOIN(相对于CROSS JOIN 或(INNER JOIN),保留表(preserved table:左外部联接把左表标记为保留表,右外部联接把右表标记为保留表,完全外部联接把两个表都标记为保留表)中未找到匹配的行将作为外部行添加到 VT2,生成VT3.如果FROM子句包含两个以上的表,则对上一个联接生成的结果表和下一个表重复执行步骤1到步骤3,直到处理完所有的表为止。

  4. WHERE:对VT3应用WHERE筛选器。只有使<where_condition>为true的行才被插入VT4.

  5. GROUP BY:按GROUP BY子句中的列列表对VT4中的行分组,生成VT5.

  6. CUBE|ROLLUP:把超组(Suppergroups)插入VT5,生成VT6.

  7. HAVING:对VT6应用HAVING筛选器。只有使<having_condition>为true的组才会被插入VT7.

  8. SELECT:处理SELECT列表,产生VT8.

  9. DISTINCT:将重复的行从VT8中移除,产生VT9.

  10. ORDER BY:将VT9中的行按ORDER BY 子句中的列列表排序,生成游标(VC10).

  11. TOP:从VC10的开始处选择指定数量或比例的行,生成表VT11,并返回调用者。  

(2)多表查询效率分析

        在数据量不大的情况下多表连接查询和多次单表查询的效率差不多。如果数据量足够大,那肯定是多次单表查询的效率更高。在一些大的公司里面,都会禁用多表连接查询,原因就是一旦数据量足够大的时候多表连接查询效率会很慢,而且不利于分库分表的查询优化。

​ 

用分解关联查询的方式查询具有以下优势:

  • 多次单表查询,让缓存的效率更高;许多应用程序可以方便地缓存单表查询对应的结果对象。对 MYSQL 的查询缓存来说,如果关联中的某个表发生了变化,那么就无法使用查询缓存了,而拆分后,如果某个表很少改变,那么基于该表的查询就可以重复利用查询缓存结果了。

  • 将查询分解后,执行单个查询可以减少锁的竟争。

  • 在应用层做关联,可以更容易对数据库进行拆分,更容易做到高性能和可扩展。很多高性能的应用都会对关联查询进行分解。

  • 查询效率也可能会有所提升;这个例子中,使用 IN() 代替关联査询,可以让 MYSQL 按照 ID 顺序进行査询,这可能比随机的关联要更高效。

  • 可以减少冗余记录的查询;在应用层做关联査询,意味着对于某条记录应用只需要查询一次,而在数据库中做关联查询,则可能需要重复地访问一部分数据。从这点看,这样的重构还可能会减少网络和内存的消耗。

  • 这样做相当于在应用中实现了哈希关联,而不是使用 MYSQL 的嵌套循环关联。某些场景哈希关联的效率要高很多

  • 单表查询有利于后期数据量大了分库分表,如果联合查询的话,一旦分库,原来的sql都需要改动。

  • 一些大公司明确规定禁用join,因为数据量大的时候查询会很慢,所以在数据量不大的情况下,两种方式的查询都没什么明显的差别,使用多表连接查询更方便。但是如果在数据量达到几十万、几百万甚至上亿的数据,或者在一些高并发、高性能的应用中,一般建议使用单表查询。

(3)多表查询优化 

        在选择多表查询后,多次查询的结果需要在业务层面进行遍历组合。多表查询的弊端在于多次查询数据库(就相当于需要多次跟数据库建立连接通信)。但是对于多表查询的优化只能在业务层面考虑,主要核心思想是如何加速多表查询结果在业务层面的连接:

  • 桶排序(复杂度O(n)):我们的类型表查询时查出所有,按照id排序,然后遍历一次blog列表,每个blog的typeName = type[typeid-1],缺点是浪费空间,每个type在删除时不能真正删除(要保证typeId连续),需要设置status=0

  • 二分查找(复杂度O(nlogn)):每个type在删除时直接真正删除,类型表查询出所有按照id排序,然后对blog列表进行遍历,对每个TypeId进行二分查找其name,缺点是时间复杂度相比于上一个要高。

8. 文章浏览量统计

8.1 问题概述

  • 基本需求:每次用户访问博客文章时,该博客文章的浏览量就会+1,并更新到页面显示中。
  • 开发难点:文章浏览量是一种高频操作的数据,我们需要对浏览量数据进行高频率的查询、修改。如果直接与数据库进行交互,那么在高并发高频次通信的操作下,肯定会严重影响数据库的效率,甚至会造成数据库宕机。因此我们需要考虑的是如何在实现高频数据交互的前提下,尽可能地降低对数据库的影响。

8.2 解决思路 

(1)浏览量实时刷新

  • 效果:每次点击/访问博客文章,该博客文章的浏览量都会实时刷新+1,并更新到页面显示中。
  • 思路:将浏览量数据views分为两部分存储。第一部分为固化数据存储于数据库中,第二部分为临时缓存数据存储于Redis缓存中(以blogId为key,views为value存储到redis的hash结构中) 。
    • 当用户访问文章更新浏览量时:只对缓存库Redis进行操作。如果对应key已存在,则执行+1更新操作(redis为原子操作),否则不存在则存入1初始化。
    • 当用户查询获取文章浏览量时:获取数据分为两部分,一是查询数据库中的固化数据,二是查询Redis中的临时缓存数据。最终的查询结果为二者之和。
    • 定时刷新机制(时间宽度比较长):我们需要通过定时刷新机制,在固定时间(比如每天零点)时将Redis中的临时缓存浏览量数据刷新到数据库中进行固化,然后将Redis对应的浏览量数据清空,重新开始缓存。

(2)浏览量延时刷新 

  • 效果:每次点击/访问博客文章,该博客文章的浏览量并不会实时刷新显示,而是延迟固定时间更新显示一次,比如“浏览量数据每两小时更新一次”。
  • 思路:将浏览量数据views分为两部分存储。第一部分为固化数据存储于数据库中,第二部分为临时缓存数据存储于Redis缓存中(以blogId为key,views为value存储到redis的hash结构中) 。该思路与第一个的区别主要体现在获取文章浏览量和定时上,由于查询的频次变低了,因此效率会有一定的提升:
    • 当用户访问文章更新浏览量时:只对缓存库Redis进行操作。如果对应key已存在,则执行+1更新操作(redis为原子操作),否则不存在则存入1初始化。
    • 当用户查询获取文章浏览量时:获取数据只有一部分,就是查询数据库中的固化数据作为最终结果。而缓存中的数据只用于刷新,这也是延时刷新的原因。
    • 定时刷新机制(时间宽度比较短):我们需要通过定时刷新机制,在固定时间(比如每两个小时)时将Redis中的临时缓存浏览量数据刷新到数据库中进行固化,然后将Redis对应的浏览量数据清空,重新开始缓存。

8.3 定时机制实现-Quartz定时框架 

        Quartz是一个由Java开发带开源的定时任务框架,专门用来管理和执行任务调度。在Quartz中主要有几个核心对象:

  • JobDetail & Job:
    • Job用来定义任务执行逻辑:在Quartz中它被定义为一个接口,该接口执行方法为 void execute(JobExecutionContext context)。对于Job接口有很多的实现类,最常见的是QuartzJobBean,它是Job的简单实现,能够将JobDataMap和SchedulerContext的值作为bean属性传递给Job,其默认覆盖实现方法为 void executeInternal(obExecutionContext context)
    • JobDetail用来定义任务数据/任务属性:Quartz每次执行Job时,都会重新创建一个Job实例,会接收一个Job实现类,以便运行的时候通过newInstance()的反射调用机制去实例化Job。JobDetail是用来描述Job实现类以及相关静态信息,比如任务在scheduler中的组名等信息。
    • 为什么设计成JobDetail + Job组合的形式:因为任务是有可能并发执行,如果Scheduler直接使用Job,就会存在对同一个Job实例并发访问的问题。而JobDetail & Job 方式,sheduler每次执行,都会根据JobDetail创建一个新的Job实例,这样就可以规避并发访问的问题。
  • Trigger:Trigger是定时任务触发器,用于描述触发Job执行的时间触发规则等。Quartz提供了很多默认的触发器,比如SimpleTrigger(简单的按照一定时间间隔触发)、DailyTimeIntervalTrigger(按照规则的日期触发)、CronTrigger(按照cron表达式规则触发)。我们这里主要使用CronTrigger,可以通过cron表达式定义出各种复杂的调度方案,比较全能!
  • Scheduler:Scheduler是核心调度器,代表一个Quartz的独立运行容器。Trigger和JobDetail可以注册到Scheduler中。Scheduler可以将Trigger绑定到某一JobDetail上,这样当Trigger被触发时,对应的Job就会执行。一个Job可以对应多个Trigger,但一个Trigger只能对应一个Job。

(1)定时任务案例说明

        - 引入依赖

 <!--定时任务quartz-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

        -  定义任务逻辑Job

/**
 * 定时任务 执行逻辑 Job
 **/

//1.继承QuartzJobBean
public class SyncUserJob extends QuartzJobBean
{
    //2.重写executeInternal方法,该方法在定时任务执行时自动调用
    @Override
    protected void executeInternal(JobExecutionContext jobExecutionContext)
    {
        //3.获取JobDetail中传递的参数(JobDataMap)
        String userName = (String) jobExecutionContext.getJobDetail().getJobDataMap().get("userName");
        String blogUrl = (String) jobExecutionContext.getJobDetail().getJobDataMap().get("blogUrl");
        String blogRemark = (String) jobExecutionContext.getJobDetail().getJobDataMap().get("blogRemark");
 
        //4.获取当前时间
        Date date = new Date();
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
 
        //5.打印信息测试
        System.out.println("用户名称:" + userName);
        System.out.println("博客地址:" + blogUrl);
        System.out.println("博客信息:" + blogRemark);
        System.out.println("当前时间:" + dateFormat.format(date));
        System.out.println("----------------------------------------");
    }
}

        - 配置Quartz信息(JobDetail&Trigger)

/**
 * Quartz 定时任务配置类
 **/

@Configuration
public class QuartzConfig
{
    private static String JOB_GROUP_NAME = "PJB_JOBGROUP_NAME";
    private static String TRIGGER_GROUP_NAME = "PJB_TRIGGERGROUP_NAME";
 
    /**
     * 同步用户信息Job(任务详情)
     */
    @Bean
    public JobDetail syncUserJobDetail()
    {
        JobDetail jobDetail = JobBuilder.newJob(SyncUserJob.class)//绑定Job
                .withIdentity("syncUserJobDetail",JOB_GROUP_NAME)//设置名称
                .usingJobData("userName", "pan_junbiao的博客") //设置参数(键值对)给job传递数据
                .usingJobData("blogUrl","https://blog.csdn.net/pan_junbiao")
                .usingJobData("blogRemark","您好,欢迎访问 pan_junbiao的博客")
                .storeDurably() //即使没有Trigger关联时,也不需要删除该JobDetail
                .build();
        return jobDetail;
    }
 
    /**
     * 同步用户信息Job(触发器)
     */
    @Bean
    public Trigger syncUserJobTrigger()
    {
        //每隔5秒执行一次(cron表达式)
        CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule("0/5 * * * * ?");
 
        //创建触发器
        Trigger trigger = TriggerBuilder.newTrigger()
                .forJob(syncUserJobDetail())//关联上述的JobDetail
                .withIdentity("syncUserJobTrigger",TRIGGER_GROUP_NAME)//给Trigger起个名字
                .withSchedule(cronScheduleBuilder)//关联调度器
                .build();
        return trigger;
    }
}

(2)本项目的定时任务实现

//定义 定时任务
public class ViewsRefreshJob extends QuartzJobBean {

    @Autowired
    IBlogService blogService;

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        System.out.println("-----------quartz------------");
        //将 Redis 里的浏览量信息同步到数据库里
        //  1.获取所有的views键值对
        Map<Object, Object> blogViewsCounter = RedisUtils.hmget(ConstantUtils.BLOG_VIEWS_NAME);
        if(!blogViewsCounter.isEmpty()){
            //  2.删除所有的views缓存
            RedisUtils.del(ConstantUtils.BLOG_VIEWS_NAME);
            //  3.遍历刷新views到mysql
            blogService.transViewsFromRedis2DB(blogViewsCounter);
        }
    }
}

//定时任务配置类
@Configuration
public class QuartzConfig {

    //1.配置JobDetail数据
    @Bean
    public JobDetail quartzJobDetail(){
        return JobBuilder.newJob(ViewsRefreshJob.class)//载入定时任务业务类
                .withIdentity("quartzJobDetail")//可以给该JobDetail起一个id
                .storeDurably()//即使没有Trigger关联时,也不需要删除该JobDetail
                .build();
    }

    //2.配置任务触发器
    @Bean
    public Trigger quartzJobTrigger() {
        //cron表达式 定时调度器 ,每天晚上0点触发
        CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule("0 0 0 * * ?");
        return TriggerBuilder.newTrigger()
                .forJob(quartzJobDetail())//关联上述的JobDetail
                .withIdentity("quartzJobTrigger")//给Trigger起个名字
                .withSchedule(cronScheduleBuilder)//关联调度触发器
                .build();
    }
}

8.4 本项目代码实现逻辑(实时刷新版本)

(1)Controller层

@RestController
@RequestMapping("/blog")
public class BlogController {

    @Autowired
    IBlogService blogService;

    @Autowired
    IUserService userService;

    //根据ID访问文章详情
    @RequestMapping("/showBlogById")
    public ResultVo ShowBlogById(@RequestParam("blogId") int blogId){
        BlogVo blogVo = blogService.getBlogById(blogId);
        if(blogVo == null){
            return ResultVo.fail("该博客无法访问");
        }
        //1.访问量增加---存储到redis并查询结果
        long redisViews = RedisUtils.hincr(ConstantUtils.BLOG_VIEWS_NAME,blogVo.getBlogId().toString(),1);
        //2.访问量更新(views = mysql + redis)
        blogVo.setBlogViews(blogVo.getBlogViews() + redisViews);
        //返回结果
        return ResultVo.success()
                .setAttribute("blog",blogVo);
    }

}

(2)Service层

@Service
@CacheConfig(cacheNames = "blogCache",keyGenerator = "keyGenerator")
public class BlogServiceImpl implements IBlogService {

    @Autowired
    public IBlogDao blogDao;

    @Autowired
    public ITypeDao typeDao;

    @Autowired
    public IUserDao userDao;

    //ID-单体查询
    @Cacheable(unless="#result == null")
    @Transactional(rollbackFor = Exception.class)
    @Override
    public BlogVo getBlogById(int blogId) {
        Blog blog = blogDao.selectBlogById(blogId);
        if(blog == null || blog.getBlogStatus()==0){
            return null;
        }
        BlogVo blogVo = new BlogVo();
        BeanUtils.copyProperties(blog,blogVo);
        User user = userDao.selectUserById(blog.getBlogUserid());
        user.setUserPassword(null);
        Type type = typeDao.selectTypeById(blog.getBlogTypeid());
        blogVo.setBlogUser(user);
        blogVo.setBlogType(type);
        return blogVo;
    }

    //访问量迁移
    @CacheEvict(allEntries = true)
    @Transactional(rollbackFor = Exception.class)
    @Override
    public void transViewsFromRedis2DB(Map<Object, Object> blogViewsMap){
        //entrySet遍历map ---- 更新mysql views
        for (Map.Entry<Object, Object> entry : blogViewsMap.entrySet()) {
            //System.out.println("key = " + entry.getKey().toString() + ", value = " + entry.getValue().toString());
            int blogId =  Integer.parseInt((String)entry.getKey());
            long blogViews = (long) entry.getValue();
            blogDao.updateBlogIncreViews(blogId,blogViews);
        }
    }

    //类型匹配
    @Override
    public List<BlogVo> blogListAddTypeName(List<Blog> blogList, List<Type> typeList) {
        List<BlogVo> blogVoList = new ArrayList<BlogVo>();
        int len = blogList.size();
        for(int i = 0;i<len;i++){
            Blog blog = blogList.get(i);
            BlogVo blogVo = new BlogVo();
            BeanUtils.copyProperties(blog,blogVo);
            int typeIndex = binarySearch(typeList,blog.getBlogTypeid());
            if(typeIndex>=0) blogVo.setBlogType(typeList.get(typeIndex));
            blogVoList.add(blogVo);
        }
        return blogVoList;
    }

    //二分查找
    @Override
    public int binarySearch(List<Type> typeList, int typeId) {
        int left = 0,right = typeList.size()-1;
        while(left<=right){
            int mid = (left + right)/2;
            int midTypeId = typeList.get(mid).getTypeId();
            if (midTypeId==typeId){
                return mid;
            }else if(midTypeId < typeId){
                left = mid + 1;
            }else{
                right = mid -1;
            }
        }
        return -1;
    }
}

9. 后端接口实现

        下面将以文章编辑功能用例为例,展示后端接口实现的全部流程。首先我们先确定一下文章编辑功能用例的权限如下:

  • 只有editor、admin具有文章编辑/发布权限,普通注册用户和游客只能浏览文章
  • 每个作者只能直接编辑/管理自己发布的博客(包括admin),对于其他博客也只能浏览查看
  • admin的最高权限体现在后台管理系统(还未开发),能管理所有文章和用户信息

(1)Controller层 

@RestController
@RequestMapping("/blog")
public class BlogController {

    @Autowired
    IBlogService blogService;

    @Autowired
    IUserService userService;

    //1,权限过滤:只有editor和admin才有编辑权限
    @RequiresRoles(value = {"editor","admin"},logical = Logical.OR)
    @RequestMapping("/editBlog")
    public ResultVo EditBlog(@RequestBody Blog blog){
        //2.编辑博客内容空值简单校验
        if(blog == null || StringUtil.isNullOrEmpty(blog.getBlogTitle()) || StringUtil.isNullOrEmpty(blog.getBlogDescription()) ||
                StringUtil.isNullOrEmpty(blog.getBlogContent())){
            return ResultVo.fail("修改博客内容为空");
        }
        //3.校验权限信息:只有作者自己才能编辑该文章
        String loginToken = (String) SecurityUtils.getSubject().getPrincipal();
        String loginUserName = JwtUtils.getUserName(loginToken);
        if(loginUserName!=null) {
            BlogVo blogVo = blogService.getBlogByEditor(blog.getBlogId());
            if(blogVo!=null && blogVo.getBlogUser().getUserName().equals(loginUserName)){
                if(blog.getBlogStatus()==null){
                    blog.setBlogStatus(ConstantUtils.BLOG_STATUS_SHOW);
                }
                //4.校验成功
                blogService.modifyBlog(blog);
                return ResultVo.success("修改博客成功");
            }
        }
        return ResultVo.fail("博客编辑失败,异常操作");
    }

}

(2)Service层

@Service
@CacheConfig(cacheNames = "blogCache",keyGenerator = "keyGenerator")
public class BlogServiceImpl implements IBlogService {

    @Autowired
    public IBlogDao blogDao;

    @Autowired
    public ITypeDao typeDao;

    @Autowired
    public IUserDao userDao;


    //修改编辑blog信息
    @Transactional(rollbackFor = Exception.class)
    @CacheEvict(cacheNames = {"blogCache","typeCache"},allEntries = true)
    @Override
    public void modifyBlog(Blog newBlog) {
        Blog oldBlog = blogDao.selectBlogById(newBlog.getBlogId());
        String updateTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
        newBlog.setBlogUpdate(updateTime);
        blogDao.updateBlog(newBlog);
        //根据blog新的展示状态:更改type的博客数量
        if(newBlog.getBlogStatus()==ConstantUtils.BLOG_STATUS_SHOW){
            if(oldBlog.getBlogStatus()==ConstantUtils.BLOG_STATUS_SHOW){
                if(newBlog.getBlogTypeid() != oldBlog.getBlogTypeid()){
                    typeDao.updateTypeCountByIncre(newBlog.getBlogTypeid());
                    typeDao.updateTypeCountByDecre(oldBlog.getBlogTypeid());
                }
            }else{
                typeDao.updateTypeCountByIncre(newBlog.getBlogTypeid());
            }
        }else{
            if(oldBlog.getBlogStatus()==ConstantUtils.BLOG_STATUS_SHOW){
                typeDao.updateTypeCountByDecre(oldBlog.getBlogTypeid());
            }
        }
    }
}

10. 问题与总结

10.1 FastJson使用报错

  • 报错内容:org.springframework.web.HttpMediaTypeNotAcceptableException: Could not find acceptable representation。

  • 原因与解决:错误的原因是统一结果封装对象ResultMap没有声明Getter/Setter方法。FastJson是通过Getter方法来取值对实体类进行封装JSON格式化的,因此只需要在返回的实体类上添加Getter方法即可。

10.2 静态常量类

        该类中存放我们所用到的所有常量,并且为静态常量static。为了便于拓展,所有的常量的值应该由xml配置文件注入,这里就又涉及到了静态常量的注入问题,我们需要通过@Value+set方法的方式将xml配置属性注入对应的静态常量属性。

@Component
public class ConstantUtils {
    //1.Token 常量
    public static int ACCESSTOKEN_ACTIVE;
    public static int REFRESHTOKEN_ACTIVE;
    @Value("${constant.token.accessTokenActive:24}")
    public void setAccesstokenActive(int accesstokenActive) {
        ACCESSTOKEN_ACTIVE = accesstokenActive;
    }
    @Value("${constant.token.refreshTokenActive:259200}")
    public void setRefreshtokenActive(int refreshtokenActive) {
        REFRESHTOKEN_ACTIVE = refreshtokenActive;
    }

    //2.响应状态 常量
    public static final int RES_FAIL = 0;
    public static final int RES_SUCCESS = 1;
    public static final int RES_ERROR = 2;

    //3.加密 常量
    public static int  PWD_HashIterations;
    public static int  JWT_HashIterations;
    @Value("${constant.hashIterations.pwd:100}")
    public void setPWD_HashIterations(int PWD_HashIterations) {
        ConstantUtils.PWD_HashIterations = PWD_HashIterations;
    }
    @Value("${constant.hashIterations.jwt:66}")
    public void setJWT_HashIterations(int JWT_HashIterations) {
        ConstantUtils.JWT_HashIterations = JWT_HashIterations;
    }

    //4.缓存 相关
    public static int REDIS_INDEX_SERVICE;
    public static int REDIS_INDEX_UTILS;
    @Value("${constant.redis.serviceIndex:0}")
    public void setRedisIndexService(int redisIndexService) {
        REDIS_INDEX_SERVICE = redisIndexService;
    }
    @Value("${constant.redis.utilsIndex:1}")
    public void setRedisIndexUtils(int redisIndexUtils) {
        REDIS_INDEX_UTILS = redisIndexUtils;
    }

    //5.权限/常量字段 相关
    public static final String ROLE_RANK_0 = "user";
    public static final String ROLE_RANK_1 = "editor";
    public static final String ROLE_RANK_2 = "admin";
    public static final String POWER_CODE = "POWER_CODE";
    public static final String BLOG_VIEWS_NAME = "MAP_BLOG_VIEWS";

    //6.oss文件存储 相关
    public static String QINIU_AccessKey;
    public static String QINIU_SecretKey;
    public static String QINIU_ImgBucket;
    public static String QINIU_ImgDomain;
    @Value("${constant.oss.qiniu.accessKey}")
    public void setQINIU_AccessKey(String QINIU_AccessKey) {
        ConstantUtils.QINIU_AccessKey = QINIU_AccessKey;
    }
    @Value("${constant.oss.qiniu.secretKey}")
    public void setQINIU_SecretKey(String QINIU_SecretKey) {
        ConstantUtils.QINIU_SecretKey = QINIU_SecretKey;
    }
    @Value("${constant.oss.qiniu.imgBucket}")
    public void setQINIU_ImgBucket(String QINIU_ImgBucket) {
        ConstantUtils.QINIU_ImgBucket = QINIU_ImgBucket;
    }
    @Value("${constant.oss.qiniu.imgDomain}")
    public void setQINIU_ImgDomain(String QINIU_ImgDomain) {
        ConstantUtils.QINIU_ImgDomain = QINIU_ImgDomain;
    }

    //7.博客常量
    public static final int BLOG_STATUS_SHOW = 1;
    public static final int BLOG_STATUS_HIDE = 0;

    //8.string转换方法
    public static String valueOf(Object obj) {
        return (obj == null) ? null : obj.toString();
    }

}

10.3 Controller多@RequestBody参数接收

  • 使用场景:有时后端需要一次性接收多个不同的POST数据对象,所以一个@RequestBody有时无法满足需求。而@RequestParam适用于GET请求中获取请求头参数,@RequestBody适用于POST请求中获取请求体数据,二者不可混用。因此,我们需要去解决这个问题。
  • 能否使用多个RequestBody? 不能。SpringMVC中@RequestBody是读取的流的方式, 在取 body参数时第一个参数取到后把request.getInputStream()关闭,导致后面的@requestBody的对象拿取不到,就会报错。因此SpringMVC不支持多个@RequestBody参数接收
  • 方案一  :封装新的请求对象

        这种方法会很繁琐,每次传输可能都需要去封装一个对象,需求一变动,可能都需要重新封装对象接收,想想都可怕。

  • 方案二:使用Map

        使用Map<String,Object>来接收所有的参数,然后在通过data.get("name") 获取数据并反序列化为需要使用的实体对象。这种方式比较灵活,但是可读性不好。

@RequestMapping("/changeUserPassword")
public ResultVo ChangeUserPassword(@RequestBody Map<String,String> changeUser){
    //获取信息
    String userName = changeUser.get("userName");
    String oldPassword = changeUser.get("userOldPassword");
    String newPassword = changeUser.get("userNewPassword");

    //。。。
}
  •  方案三:使用String统一接收

        使用一个String变量统一接收所有的请求参数流数据,再使用Fastjson按顺序依次流式解析其中的Json对象字符串。

 // json传递多个对象解决办法
 public void test(@RequestBody String  json){
    // fastjson转成json对象
    JSONObject jsonObject = JSON.parseObject(json);
    // 转成不同的实体类
    User user = jsonObject.getObject("user", User.class);
    UserAccount userAccount = jsonObject.getObject("userAccount", UserAccount.class);
}
  •  方案四:自定义注解解析参数

        自定义注解解析参数。继承HandlerMethodArgumentResolver接口,重写supportsParameter()、resolveArgument()等方法,实现多RequestBody的解析。这种方式比较复杂。

        总结:建议使用第二种或者第三种,因为前端只用到了传一个混合参数进来。强行去扩展原生的代码结构,往往是我们的程序设计有问题。  

10.4 整合七牛云OSS对象存储 

        项目开发过程中涉及到很多的文件资源存储,比如图片。如果将资源直接存放到本地,虽然比较方便简单,但是本地存储容量有限且有很大的安全风险。因此我们需要引入第三方云存储服务器来存放项目资源,七牛云是比较常用的一个免费资源服务器(需要申请域名)。

(1)引入七牛云依赖

<!--七牛云-->
<dependency>
    <groupId>com.qiniu</groupId>
    <artifactId>qiniu-java-sdk</artifactId>
    <version>[7.7.0, 7.7.99]</version>
</dependency>

(2)Controller层:接收上传文件资源并预处理,转发给七牛云API上云,返回图片URL

@RestController
@RequestMapping("/resource")
public class ResourceController {

    @Autowired
    IResourceService resourceService;

    @RequestMapping("/uploadImage")
    public ResultVo UploadImage(MultipartFile image){
        try {
            //1.获取原始文件名称
            String originalImageName = image.getOriginalFilename();
            //2.获取文件后缀类型
            String suffix = originalImageName.substring(originalImageName.lastIndexOf("."));
            //3.重命名 = uuid+suffix
            String imageName = UUID.randomUUID() + suffix;
            //4.获取文件流
            FileInputStream inputStream = (FileInputStream) image.getInputStream();
            //5.文件上传(转发给七牛云API)
            String imgUrl = resourceService.addImage(inputStream,imageName);
            if(StringUtil.isNullOrEmpty(imgUrl)){
                return ResultVo.fail("图片上传失败");
            }
            return ResultVo.success()
                    .setAttribute("imgUrl",imgUrl);
        } catch (IOException e) {
            e.printStackTrace();
            return ResultVo.fail("图片上传错误");
        }
    }
    
}

(3)Service层:调用七牛云API连接,上传云资源

@Service
@CacheConfig(cacheNames = "resourceCache",keyGenerator = "keyGenerator")
public class ResourceServiceImpl implements IResourceService {

    @Autowired
    IResourceDao resourceDao;

    @Autowired
    IUserDao userDao;
    
    //七牛云上传图片
    @Override
    public String addImage(FileInputStream image, String imageName) {
        //1.构造指定Region服务区域对象的配置类
        Configuration config = new Configuration(Region.autoRegion());
        //2.构造上传管理对象
        UploadManager uploadManager = new UploadManager(config);
        //3.生成上传凭证,然后准备上传
        Auth auth = Auth.create(ConstantUtils.QINIU_AccessKey,ConstantUtils.QINIU_SecretKey);
        String upToken = auth.uploadToken(ConstantUtils.QINIU_ImgBucket);
        try {
            //4.上传文件,获取结果response
            Response response = uploadManager.put(image,imageName,upToken,null,null);
            //5.解析上传成功的结果 JSON格式
            Map map = JSON.parseObject(response.bodyString(),Map.class);
            //6.默认返回两个值 hash+key(若生成的hash值不为空则表示上传成功!)
            String hashKey = (String) map.get("hash");
            if(response.isOK() && !StringUtils.isNullOrEmpty(hashKey)){
                //7.返回文件图片可访问url
                return ConstantUtils.QINIU_ImgDomain + "/" + imageName;
            }
            return null;
        } catch (QiniuException e) {
            e.printStackTrace();
            return null;
        }
    }
    
}
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿阿阿安

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值