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

Posted 阿阿阿安

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了个人博客项目开发总结 项目架构及后端开发相关的知识,希望对你有一定的参考价值。

一.项目架构

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,并返回调用者。  

项目规划:

zabbix-server端:172.16.1.2

zabbix-agent端/nginx调度器:172.16.1.99

zabbix-agent端/后端RS1:172.16.1.3

zabbix-agent端/后端RS2:172.16.1.10


把nginx调度器的ip地址设置的大一些,是为了设置zabbix Discovery的时候,自动区分发现nginx调度器及后端apache构建的web服务集群,所以下面可以设置两个zabbix Discovery,分别发现不同作用的主机。


1.

zabbix-server端:

安装以下应用

yum install zabbix-server-mysql zabbix-web-mysql zabbix-web zabbix-agent zabbix-get -y

详细的server端配置请参照:http://blog.51cto.com/12667170/2044254


三个zabbix-agent端:

安装以下应用

yum install -y zabbix-agent zabbix-sender

详细的agent端配置请参照:http://blog.51cto.com/12667170/2044254


2.创建nginx调度器

在nginx.conf配置文件中的http段内添加upstream内容,将后端两台RS加入到该upstream中

upstream zrs {

    server 172.16.1.3;

    server 172.16.1.10;

}

server {

    listen 80;

    location / {

        proxy_pass http://zrs;

        proxy_set_header X-Real-IP $remote_addr;

    }

}


两个后端rs各配置一个index.html方便测试,并开启httpd服务


测试可以看到使用了RoundRobin模式对后端rs访问。

[[email protected] ~]# for i in {1..10}; do curl http://172.16.1.99; done

<h1>172.16.1.3</h1>

<h1>172.16.1.10</h1>

<h1>172.16.1.3</h1>

<h1>172.16.1.10</h1>

<h1>172.16.1.3</h1>

<h1>172.16.1.10</h1>

<h1>172.16.1.3</h1>

<h1>172.16.1.10</h1>

<h1>172.16.1.3</h1>

<h1>172.16.1.10</h1>


3.自动发现nginx调度器及后端apache构建的web服务集群


创建nginx discovery

技术分享图片


创建web discovery

技术分享图片


分别打开nginx调度器端和后端rs主机上的zabbix-agent服务

查看Hosts,已经添加进了这三台主机


4.使用自定义参数监控调度器上nginx服务的相关统计数据及速率数据


为了监控nginx状态,在nginx的主配置文件的server中添加location监控nginx的状态值。


[[email protected] ~]# vim /etc/nginx/nginx.conf 


  location /status {

          stub_status on;

  }


[[email protected] ~]# systemctl restart nginx.service

[[email protected] ~]# curl 172.16.1.99/status

Active connections: 1 

server accepts handled requests

 1 1 1 

Reading: 0 Writing: 1 Waiting: 0 


监控nginx的状态,需要item的key,默认的没有,需要自定义参数UserParameters


5.自定义参数(UserParameters)


nginx自定义参数

vim /etc/zabbix/zabbix_agentd.d/userparameter_nginx.conf


UserParameter=nginx.active,curl -s http://172.16.1.99/status | awk '/^Active/{print $NF}'

UserParameter=nginx.accepts,curl -s http://172.16.1.99/status | awk '/^[[:space:]]+[0-9]/{print $1}'

UserParameter=nginx.handled,curl -s http://172.16.1.99/status | awk '/^[[:space:]]+[0-9]/{print $2}'

UserParameter=nginx.requests,curl -s http://172.16.1.99/status | awk '/^[[:space:]]+[0-9]/{print $3}'


保存退出,重启服务

[[email protected] zabbix_agentd.d]# systemctl restart zabbix-agent.service


在server端查看,可以不用eno查看状态,直接用第二种方法就能获取值,都成功了


[[email protected] ~]# zabbix_get -s 172.16.1.99 -k net.if.in[eno16777736,bytes]

285905110

[[email protected] ~]# zabbix_get -s 172.16.1.99 -k nginx.active

1

[[email protected] ~]# zabbix_get -s 172.16.1.99 -k nginx.accepts

9

[[email protected] ~]# zabbix_get -s 172.16.1.99 -k nginx.handled

10

[[email protected] ~]# zabbix_get -s 172.16.1.99 -k nginx.requests

11



接下来可以根据上面自定义的参数UserParameters,创建新的item项,可以输入刚才自定义的key。


在Hosts的172.16.1.99主机后面点击Items,然后Create item

如下创建4个监控项,也就是刚才设置的,注意的是preprocessing中改为Change per second

技术分享图片

技术分享图片

技术分享图片

技术分享图片


可以给这些监控创建一个graph

技术分享图片


经过一段时间后,查看这个graph有了数值,表示自定义参数监控设置成功。

技术分享图片


6.nginx调度器创建监控模板,在模板中定义出:items, trigger, graph。


创建nginx template

技术分享图片

在这个模版上创建item,监控入站流量,注意的是preprocessing中改为Change per second

技术分享图片


创建trigger

技术分享图片


为了触发器被触发需要定义动作actions

技术分享图片

技术分享图片


这时需要定义Administration中users下面的media

技术分享图片


再定义media types中的email中的media type

技术分享图片


创建graph

技术分享图片


7.后端apache服务配置监控模板,在模板中定义出:items, trigger, graph。


创建web template

技术分享图片


在这个模版上创建item,监控出站流量,注意的是preprocessing中改为Change per second

技术分享图片


创建trigger为了跟上面的trigger区分,这里Severity改为High

技术分享图片


创建graph

技术分享图片



8.链接应用模版

如下图,在172.16.1.99的host旁边,选择templates

技术分享图片

链接nginx template

技术分享图片


同样的步骤为两个后端rs链接模版


9.查看Hosts,刚才自定义配置监控都成功了。

技术分享图片


以上是关于个人博客项目开发总结 项目架构及后端开发的主要内容,如果未能解决你的问题,请参考以下文章

Java开发学习视频!基于java的个人博客系统的设计与实现

Java开发学习视频!基于java的个人博客系统的设计与实现

[Alpha阶段]项目展示博客

Zabbix:项目实战之--自动发现nginx调度器及后端web服务集群自定义参数监控

SpringBoot实战——个人博客项目

Gamma项目展示(未完成)