一.项目架构

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 {@Resourceprivate 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独立,需要重新设置序列化方式@Beanpublic RedisTemplate<String,Object> redisTemplate(@Qualifier("redisTokenFactory") RedisConnectionFactory redisConnectionFactory) {RedisTemplate<String, Object> template = new RedisTemplate();template.setConnectionFactory(redisConnectionFactory);GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();// value值的序列化采用fastJsonRedisSerializertemplate.setValueSerializer(jsonRedisSerializer);template.setHashValueSerializer(jsonRedisSerializer);// key的序列化采用StringRedisSerializertemplate.setKeySerializer(new StringRedisSerializer());template.setHashKeySerializer(new StringRedisSerializer());return template;}/*** 缓存注解@Cache 配置*/@Beanpublic 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的生成方式: 类名.方法名字&[参数列表]*/@Beanpublic KeyGenerator keyGenerator(){return new KeyGenerator() {@Overridepublic 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;@Autowiredpublic 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 tokenString 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@LazyIUserService userService;//重写supports方法:支持自定义JWTToken的认证与授权@Overridepublic boolean supports(AuthenticationToken token) {return token instanceof JwtToken;}/*** 授权校验* @param principalCollection* @return*/@Overrideprotected 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*/@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {//System.out.println("执行了 => 认证方法doGetAuthenticationInfo");//从主体传过来的认证信息中,获取需要认证的tokenString 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 {@Overrideprotected 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;}@Overrideprotected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {return false;}@Overrideprotected 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);}@Overrideprotected 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("登录信息出错");}}@Overrideprotected 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();}}@Overrideprotected 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());// 刷新refreshTokenRedisUtils.put(userName,currentTimeMillis, ConstantUtils.REFRESHTOKEN_ACTIVE);// 刷新AccessToken,为当前最新时间戳token = JwtUtils.creatToken(userName,currentTimeMillis);// 设置响应的Header头新TokenHttpServletResponse 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里,并且取名为jwtMap<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存储,实现无状态TokenDefaultSubjectDAO 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: mysqlreasonable: truesupportMethodsArguments: trueparams: 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() {@Overridepublic 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() {@Overridepublic void doSelect() {userMapper.selectGroupBy();}
});
//对应的lambda用法
pageInfo = PageHelper.startPage(1, 10).doSelectPageInfo(() -> userMapper.selectGroupBy());//count查询,返回一个查询语句的count数
long total = PageHelper.count(new ISelect() {@Overridepublic 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_imgurlfrom comment c,`user` uwhere 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方法,该方法在定时任务执行时自动调用@Overrideprotected 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(任务详情)*/@Beanpublic 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(触发器)*/@Beanpublic 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 {@AutowiredIBlogService blogService;@Overrideprotected 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到mysqlblogService.transViewsFromRedis2DB(blogViewsCounter);}}
}//定时任务配置类
@Configuration
public class QuartzConfig {//1.配置JobDetail数据@Beanpublic JobDetail quartzJobDetail(){return JobBuilder.newJob(ViewsRefreshJob.class)//载入定时任务业务类.withIdentity("quartzJobDetail")//可以给该JobDetail起一个id.storeDurably()//即使没有Trigger关联时,也不需要删除该JobDetail.build();}//2.配置任务触发器@Beanpublic 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 {@AutowiredIBlogService blogService;@AutowiredIUserService 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 {@Autowiredpublic IBlogDao blogDao;@Autowiredpublic ITypeDao typeDao;@Autowiredpublic IUserDao userDao;//ID-单体查询@Cacheable(unless="#result == null")@Transactional(rollbackFor = Exception.class)@Overridepublic 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)@Overridepublic void transViewsFromRedis2DB(Map<Object, Object> blogViewsMap){//entrySet遍历map ---- 更新mysql viewsfor (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);}}//类型匹配@Overridepublic 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;}//二分查找@Overridepublic 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 {@AutowiredIBlogService blogService;@AutowiredIUserService 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 {@Autowiredpublic IBlogDao blogDao;@Autowiredpublic ITypeDao typeDao;@Autowiredpublic IUserDao userDao;//修改编辑blog信息@Transactional(rollbackFor = Exception.class)@CacheEvict(cacheNames = {"blogCache","typeCache"},allEntries = true)@Overridepublic 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 {@AutowiredIResourceService resourceService;@RequestMapping("/uploadImage")public ResultVo UploadImage(MultipartFile image){try {//1.获取原始文件名称String originalImageName = image.getOriginalFilename();//2.获取文件后缀类型String suffix = originalImageName.substring(originalImageName.lastIndexOf("."));//3.重命名 = uuid+suffixString 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 {@AutowiredIResourceDao resourceDao;@AutowiredIUserDao userDao;//七牛云上传图片@Overridepublic 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.上传文件,获取结果responseResponse 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.返回文件图片可访问urlreturn ConstantUtils.QINIU_ImgDomain + "/" + imageName;}return null;} catch (QiniuException e) {e.printStackTrace();return null;}}}

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

  1. 文顶顶iOS开发博客链接整理及部分项目源代码下载

    网上的iOS开发的教程很多,但是像cnblogs博主文顶顶的博客这样内容图文并茂,代码齐全,示例经典,原理也有阐述,覆盖面宽广,自成系统的系列教程却很难找.如果你是初学者,在学习了斯坦福iOS7公开课 ...

  2. java 博客系统_讲解开源项目:5分钟搭建私人Java博客系统

    本文适合刚学习完 Java 语言基础的人群,跟着本文可了解和运行 Tale 项目.示例均在 Windows 操作系统下演示 本文作者:HelloGitHub-秦人 HelloGitHub 推出的< ...

  3. Android 程序员不得不收藏的 90+ 个人博客(持续更新,android项目实战手机安全卫士

    来自滴滴出行,Android 开发助手 开发者,android-open-project 维护者 ,android-open-project-analysis 维护者. 中二病也要开发 ANDROID ...

  4. Diango博客--19.使用 Docker部署项目到线上服务器

    文章目录 1.克隆代码到服务器 2.创建环境变量文件用于存放项目敏感信息 3.在 .production 文件写入下面的内容并保存 4.修改 Nginx 配置 5.修改项目配置文件 6.启动容器 7. ...

  5. 博客开张+第1个项目:云云图书馆

    我是编程业余爱好者,工作是中学里面的档案管理员.除了大学学过C++,我的工作跟编程没什么关系,但因为管理电子档案,所以跟excel打交道还是挺多的. 今年3月,我被小姐妹安利了在风变学python,于 ...

  6. 个人博客系统的设计与实现_一个 Go 开发的快速、简洁、美观、前后端分离的个人博客系统...

    大家好,我是你们的章鱼猫. 我们从来不含糊说推荐就推荐,所以今天给大家推荐一个 go.echo.vue 开发的快速.简洁.美观.前后端分离的个人博客系统 (blog),同时基于这个系统也可以方便二次开 ...

  7. 《.NET应用架构设计:原则、模式与实践》新书博客--试读-1.1.2 架构师的职责

    1.1.2   架构师的职责<?xml:namespace prefix = o ns = "urn:schemas-microsoft-com:office:office" ...

  8. 新型前端开发工程师的三个境界 后端开发工程师如何快速转前端

    初入软件开发这一行时,当时还没有前后端分离这个概念,所有的开发工程师既能写html,也能写后台服务,随着技术的发展,前后端分离成为趋势,目前团队不少人能熟悉的写java后台服务,却难以hold住前端页 ...

  9. python开发需要掌握哪些知识-Python后端开发如何入门,要学习那些系统性的知识?...

    Python本身就属于后端语言,学习知识如下: 第一部分:各个领域应用的语言. /> 大家看这个内容,其实你很明显发现,其实各个语言都有他的用处.我们可以说Python是应用最广的.但是暂时还是 ...

最新文章

  1. 【Harvest源码分析】GetFourZeroCrossingIntervals函数
  2. CVCode简繁转换的扩展:GBK与Big5转换
  3. Vue-cli3.0Mock数据使用
  4. Spring Cloud sleuth with zipkin over RabbitMQ教程
  5. axis2+myeclipse6.5环境搭建
  6. 让360安全浏览器默认使用谷歌内核
  7. 阿里云ECS修复ubuntu 16.04漏洞过程
  8. python gzip压缩文件
  9. 微信开发(1) -- 将本地开发环境映射到公网访问
  10. 访问局域网计算机切换用户,Win7切换用户账户访问共享文件夹的方法
  11. 衡量系统性能常见指标
  12. (10.2.1)15款优秀移动APP产品原型设计工具
  13. vue-如何获取上一个路由地址
  14. Canvas基础教程
  15. 四象限分析法分析你是否适合做管理
  16. 关于 Thread.currentThread()
  17. 设置了相对定位relative之后,改变top值,如何去掉多余空白?
  18. 牛客2019跨年AK场
  19. 数显之家快讯:【SHIO世硕心语】2021,给实体经济企业家的二十条建议!
  20. 品高云生态video:爱数备份系统+品高云=云备份服务

热门文章

  1. Ftp客户端上传、下载操作示例
  2. 新世纪电子商务淘金模式
  3. 程序猿的一万种解压方式
  4. 日天干和时辰地支构成的时辰干支,以北京时间(UTC+8)为准
  5. “is not a valid date and time ”错误提示 的【排除故障】
  6. java华容道教程_Java初学之华容道游戏
  7. 5001:飞碟(Your Ride Is Here)(YJF出版)
  8. 数据结构 C++实现 算术表达式求值
  9. Golang语言社区——为什么说未来 5 年将是 Go 语言的天下?
  10. HTMLEncode