Springboot项目--在线博客
主要是记录跟视频编写的时候遇到的一些问题的解决方案以及我觉得需要注意的点
项目流程:
- 创建父工程、新建子工程
- 设置pom.xml依赖
- 编写主运行程序MainApp
- MybatisPlus的配置文件,配置一下分页插件
- WebMVC的配置文件,配置一下跨域允许
- dao层,pojo实体类以及mapper底层与数据库的映射
- service层,处理交互数据,集成相关mapper的地方。
- controller层,处理地址映射,调用服务的地方。
- vo,params包,请求体类。
- Result,R类,前后端同一发送/接收R类。
1、父子项目的作用
现在的项目很流行用微服务进行开发,所以有必要将许多服务部署在不同的服务器上,所以将不同的子服务(子项目)依赖一个父项目(总项目)。
既可以达到分布式,又方便不同服务的同时开发。
2、父子项目依赖的注意点
如果父项目依赖了springboot的项目,那么父项目本身就无需再声明 springbootstarter已经声明过的依赖,只需要声明starter中没有声明的依赖。
父项目中要将packing设置为pom
<packaging>pom</packaging>
3、跨域配置
前后端分离的项目,服务不同,域名也不同,所以需要进行跨域配置
@Configuration
public class WebMVCConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
//跨域配置
//允许http://localhost:8080域名访问所有的其他域名
registry.addMapping("/**").allowedOrigins("http://localhost:8080");
}
}
4、获得文章信息分页展示
难度并不是很高,但是逻辑上有点绕,所以写下来捋一捋。
1、首先编写前后端交互的R函数,方便前端提取数据
package com.mszlu.blog.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class Result {
private boolean success;
private int code;
private String msg;
private Object data;
public static Result success(Object data){
return new Result(true,200,"success",data);
}
public static Result fail(int code,String msg){
return new Result(false,code,msg,null);
}
}
这两个静态函数的作用:
- 简化代码,因为交互频繁,不用每次都new一个新的result对象
- 成功失败一眼看出,而且巧妙,值得学习
2、Controller层
//json数据进行交互
@RestController
@RequestMapping("/articles")
public class ArticleController {
@Autowired
private ArticleService articleService;
/**
* 首页 文章列表
* @param pageParams
* @return
*/
@PostMapping
public Result listArticle(@RequestBody PageParams pageParams){
Result r = articleService.listArticle(pageParams);
return r;
}
}
最上层的Controller层主要注解了一个方法,就是文章的分页查询,返回Result对象
3、Service层
定义接口和实现类,这个没什么好说的。
该方法的步骤:
1、分页查询,按照条件排序
2、获得分页后的数据,但该数据不完全是我们要展示出来的数据,所以我们要将数据封装进ArticleVo里
3、封装进对象里有2种信息,一钟是将article中与articleVo中相同的属性复制过去,一种是处理剩余的特殊的与文章相关的信息。
- 创建时间信息
- 标签信息
- 作者姓名
@Service
public class ArticleServiceImpl implements ArticleService {
@Autowired
private ArticleMapper articleMapper;
@Autowired
private TagService tagService;
@Autowired
private SysUserService sysUserService;
@Override
public Result listArticle(PageParams pageParams) {
/*
* 1.分页查询Article数据库表,得到结果
*/
Page<Article> page = new Page<>(pageParams.getPage(),pageParams.getPageSize());
LambdaQueryWrapper<Article> queryWrapper = new LambdaQueryWrapper<>();
//是否置顶进行排序
//按照创建时间进行排列
queryWrapper.orderByDesc(Article::getWeight,Article::getCreateDate);
Page<Article> articlePage = articleMapper.selectPage(page, queryWrapper);
List<Article> records = articlePage.getRecords();
// 能直接返回吗?很明显不能
// 返回的List数据并不完全是我们要展示出来的数据,所以我们要将数据封装进ArticleVo里
List<ArticleVo> articleList = copyList(records,true,true);
//success是静态方法,所以可以直接调用
return Result.success(articleList);
}
// 将整个集合全部转化
private List<ArticleVo> copyList(List<Article> records,boolean isTag,boolean isAuthor) {
List<ArticleVo> articleVoList = new ArrayList<>();
for (Article record : records) {
articleVoList.add(copy(record,isTag,isAuthor));
}
return articleVoList;
}
//将article输入,返回能够展示的articleVo类
private ArticleVo copy(Article article,boolean isTag,boolean isAuthor){
ArticleVo articleVo = new ArticleVo();
//将article中与articleVo中相同的属性复制过去
BeanUtils.copyProperties(article,articleVo);
//处理剩余不一致的
articleVo.setCreateDate(new DateTime(article.getCreateDate()).toString("yyyy-MM-dd HH:mm"));
//获得标签信息
if(isTag){
Long articleId = article.getId();
articleVo.setTags(tagService.findTagsByArticleId(articleId));
}
//获得作者id
if(isAuthor){
Long authorId = article.getAuthorId();
articleVo.setAuthor(sysUserService.findUserById(authorId).getNickname());
}
//并不是所有的接口 都需要标签,作者信息
return articleVo;
}
}
4、dao层
除了mybatisplus已经封装好了的基础方法,多表查询这样的操作就要用mybatis传统方法去写了。
<select id="findTagsByArticleId" resultType="com.mszlu.blog.dao.pojo.Tag" parameterType="long">
select id,avatar,tag_name as tagName from ms_tag
where id in
(select tag_id from ms_article_tag where article_id = #{articleId})
</select>
先查出该文章有的tag标签的id,然后根据查询结果筛选id对应的标签信息。
5、总结
Controller层直接返回result对象类。
Service层集成服务,可以调用其他Service方法。
每个Service方法又要用到该Service方法层级下的dao方法。
避免出现该Service运用别Service层级下的dao方法。
5、查询最热标签端口
这个接口主要的难点在于sql语句的编写
1、查询最热的n个标签
<!-- List<Long> findHotsTagIds(int limit); -->
<select id="findHotsTagIds" resultType="java.lang.Long" parameterType="int">
SELECT tag_id FROM `ms_article_tag` group by tag_id ORDER BY count(*) DESC LIMIT #{limit}
</select>
首先按tag_id 分组 groupby,然后按tag_id出现的次数排序 desc表示从高到底排
然后返回查询的最高的limit 次数的 tag_id
2、根据tagId查询tagName (集合多次查询)
<!-- List<Tag> findTagsByTagIds(List<Long> tagIds); -->
<!-- sql语句 slect id,tag_name as tagName from ms_tag where id in (x,x,x)
collection 表示集合 item 表示项名 separator 表示分隔符 open 以什么开头 close 以什么结尾
-->
<select id="findTagsByTagIds" resultType="com.mszlu.blog.dao.pojo.Tag" parameterType="list">
select id,tag_name as tagName from ms_tag
where id in
<foreach collection="tagIds" item="tagId" separator="," open="(" close=")">
#{tagId}
</foreach>
</select>
6、JWT技术
jwt 可以生成 一个加密的token,做为用户登录的令牌,当用户登录成功之后,发放给客户端。
请求需要登录的资源或者接口的时候,将token携带,后端验证token是否合法。
jwt 有三部分组成:A.B.C
A:Header,{"type":"JWT","alg":"HS256"} 固定
B:playload,存放信息,比如,用户id,过期时间等等,可以被解密,不能存放敏感信息
C: 签证,A和B加上秘钥 加密而成,只要秘钥不丢失,可以认为是安全的。
jwt 验证,主要就是验证C部分 是否合法。
引入依赖包
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
jwt工具类
public class JWTUtils {
//密钥,密钥可以随时更换且自己设定,密钥不暴露,则认为是安全的
private static final String jwtToken = "123456Mszlu!@###$$";
public static String createToken(Long userId){
Map<String,Object> claims = new HashMap<>();
claims.put("userId",userId);
JwtBuilder jwtBuilder = Jwts.builder()
.signWith(SignatureAlgorithm.HS256, jwtToken) // 签发算法,秘钥为jwtToken
.setClaims(claims) // body数据,要唯一,自行设置
.setIssuedAt(new Date()) // 设置签发时间
.setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 60 * 1000));// 一天的有效时间
String token = jwtBuilder.compact();
return token;
}
public static Map<String, Object> checkToken(String token){
try {
Jwt parse = Jwts.parser().setSigningKey(jwtToken).parse(token);
return (Map<String, Object>) parse.getBody();
}catch (Exception e){
e.printStackTrace();
}
return null;
}
}
7、注册
注册功能思路:
1、判断参数 是否合法
2、判断账户是否存在,存在 返回账户已经被注册
3、如果账户不存在,注册用户
4、生成token 并返回
5、加上事务,一旦中途出现任何问题,需要回滚
1、2没什么好说的。
3中需要注意的点是,mybatisplus在添加的数据的时候,默认生成id是根据分布式雪花算法添加的id。
4的话只需要使用之前已经编写好的token生成方法即可。
5中事务的功能是为了防止注册功能在中途出现问题导致数据添加进去,但是没有显示注册成功的错误。
可在Service接口上加注释
@Transactional 表明该接口下的所有方法都是事务。
8、拦截器
在访问需要登录之后才能访问的接口时,需要将请求拦截,验证之后再放行。
@Component
@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private LoginService loginService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//在controller执行方法之前进行执行(handler)
/**
* 1、需要判断 请求的接口路径是否为HandlerMethod (controller方法)
* 2、判断token是否为空 未登录
* 3、如果token不为空,登录验证 loginService.checkToken
* 4、如果认证成功,放行即可
*/
//handler 可能是访问静态资源的 RequestResourceHandler 类型
if(!(handler instanceof HandlerMethod)){
return true;
}
//获取头部信息的token,在请求对象request里
String token = request.getHeader("Authorization");
//使用日志信息,需要使用lombok插件在类上注解上@Slf4j
log.info("=================request start===========================");
String requestURI = request.getRequestURI();
log.info("request uri:{}",requestURI);
log.info("request method:{}",request.getMethod());
log.info("token:{}", token);
log.info("=================request end===========================");
if(StringUtils.isBlank(token)){
Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(),ErrorCode.NO_LOGIN.getMsg());
//自定义一个跳转
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(JSON.toJSONString(result));
return false;
}
SysUser sysUser = loginService.checkToken(token);
if(sysUser ==null){
Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(),ErrorCode.NO_LOGIN.getMsg());
response.setContentType("application/json;charset=utf-8");
response.getWriter().print(JSON.toJSONString(result));
return false;
}
return true;
}
}
写完拦截器之后还需要在webMVCConfig中配置,(根本上来说拦截器的功能受到mvc控制)
@Override
public void addInterceptors(InterceptorRegistry registry) {
//拦截test接口
registry.addInterceptor(loginInterceptor).addPathPatterns("/test");
//registry.addInterceptor(loginInterceptor).addPathPatterns("/**").excludePathPatterns("/login");
}
主要注入拦截器,add拦截的接口或者exclude排除相关的接口
9 ThreadLocal
作用:在经过验证登录的拦截器的时候,我们想要同时携带登录用户信息时,该怎么办?ThreadLocal很好的解决了这个问题。
原理:ThreadLocal起到了线程隔离的作用,意思是如果同时有很多的线程正在工作,那么每个线程访问的ThreadLocal对象是互相独立的,各用户之间不会相互干扰。
ThreadLocal类:
public class UserThreadLocal {
private UserThreadLocal(){}
//线程变量隔离
private static final ThreadLocal<SysUser> LOCAL = new ThreadLocal<>();
public static void put(SysUser sysUser){
LOCAL.set(sysUser);
}
public static SysUser get(){
return LOCAL.get();
}
public static void remove(){
LOCAL.remove();
}
}
使用的时候在拦截器那块,在放行之前,将用户对象放入本地线程中。
UserThreadLocal.put(sysUser);
注意:在访问接口之后,需要将本地线程中的用户删除,不然会有内存泄露的危险
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//如果不删除 ThreadLocal中用完的信息 会有内存泄露的风险
UserThreadLocal.remove();
}
内存泄露的原因:

每个线程维护一个ThreadLocalMap,key为使用弱引用的ThreadLocal实例。
弱引用:每次jvm回收时都会将弱引用回收,所以如果不remove掉threadlocal,key就会不定期被回收,那么只剩下一个value,永远的保存在内存里。
如果线程请求多了,value就会非常多,导致内存泄露。
10 线程池的使用
我们要增加一个功能:当你查看一篇文章的时候,阅读数+1。
最普通的办法是什么呢?在查看文章的方法接口中,增加更新操作,使得阅读数+1。
但是这样做有问题,一旦更新操作出现问题,会导致文章查看不了,小功能一旦瘫痪,大功能也跟着瘫痪了,有点捞。
可以把更新操作扔到线程池中去执行,和主线程就不相关了。
1、开启多线程池的配置
/**
* 开启多线程池的配置
*/
@Configuration
@EnableAsync
public class ThreadPoolConfig {
@Bean("taskExecutor")
public Executor asyncServiceExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 设置核心线程数
executor.setCorePoolSize(5);
// 设置最大线程数
executor.setMaxPoolSize(20);
//配置队列大小
executor.setQueueCapacity(Integer.MAX_VALUE);
// 设置线程活跃时间(秒)
executor.setKeepAliveSeconds(60);
// 设置默认线程名称
executor.setThreadNamePrefix("码神之路博客项目");
// 等待所有任务结束后再关闭线程池
executor.setWaitForTasksToCompleteOnShutdown(true);
//执行初始化
executor.initialize();
return executor;
}
}
2、将配置名称以@Async标注在需要多线程的服务上
@Component
public class ThreadService {
//期望此操作在线程池执行,不会影响原有的主线程
@Async("taskExecutor")
public void updateArticleViewCount(ArticleMapper articleMapper, Article article) {
int viewCounts = article.getViewCounts();
Article updateArticle = new Article();
updateArticle.setViewCounts(viewCounts + 1);
LambdaUpdateWrapper<Article> lambdaUpdateWrapper = new LambdaUpdateWrapper<>();
lambdaUpdateWrapper.eq(Article::getId,article.getId());
//下面这步是为了在多线程的环境下,保证线程安全
lambdaUpdateWrapper.eq(Article::getViewCounts,viewCounts);
articleMapper.update(updateArticle,lambdaUpdateWrapper);
try {
Thread.sleep(5000);
System.out.println("更新完成了.....");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
11 雪花分布式id传到前端精度缺失的问题
//防止前端精度损失 把id转为string
@JsonSerialize(using = ToStringSerializer.class)
private Long id;
分布式id比较长,传到前端,会有精度损失,必须转为string类型,就不会有问题了
12 AOP进行日志增强
aop就是在不改变方法的前提下,在方法前或者方法后进行方法增强的功能。
一般方法已经在之前的笔记中提到过,这次要介绍的是自定义一个注解来标明切点,从而来实现aop的功能
1、定义一个注解
import java.lang.annotation.*;
//Type 代表可以放在类上 METHOD 代表可以放在方法上
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogAnnotation {
String module() default "";
String operator() default "";
}
注解上面的3个初始注解不能省略,并且可以自定义一些属性。
2、创建切面类
@Component
@Aspect //切面 定义了通知和切点的关系
@Slf4j
public class LogAspect {
//切点 有这注释的方法都是切点
@Pointcut("@annotation(com.mszlu.blog.common.aop.LogAnnotation)")
public void pt(){
}
//环绕通知
@Around("pt()")
public Object log(ProceedingJoinPoint joinPoint) throws Throwable {
//环绕前增强
long beginTime = System.currentTimeMillis();
//执行方法
Object result = joinPoint.proceed();
//环绕后增强 执行时长(毫秒)
long time = System.currentTimeMillis() - beginTime;
//保存日志
recordLog(joinPoint, time);
return result;
}
private void recordLog(ProceedingJoinPoint joinPoint, long time) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
LogAnnotation logAnnotation = method.getAnnotation(LogAnnotation.class);
log.info("=====================log start================================");
log.info("module:{}",logAnnotation.module());
log.info("operation:{}",logAnnotation.operator());
//请求的方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = signature.getName();
log.info("request method:{}",className + "." + methodName + "()");
//请求的参数
Object[] args = joinPoint.getArgs();
String params = JSON.toJSONString(args[0]);
log.info("params:{}",params);
log.info("excute time : {} ms",time);
log.info("=====================log end================================");
}
}
3、使用
@PostMapping
@LogAnnotation(module="文章",operator="获取文章列表")
public Result listArticle(@RequestBody PageParams pageParams){
Result r = articleService.listArticle(pageParams);
return r;
}
至此之后,加了@LogAnnotation的注解的方法都是切点。十分方便。
13 图片服务器
如果将图片资源全部放在应用服务器上,那对应用服务器的负载太大了,一旦有很多用户并发的进入网站,很有可能应用服务器会出现卡顿的情况,导致文字都不能很好的显示。
但如果将图片资源单独的放在一个图片服务器上,所有图片资源的请求全部放在图片服务器上,那么即使图片服务器处理不过来,也只会影响图片,不会影响网页内容的显示,而且图片显示的会更快。
本项目采用的是七牛云服务器来负责这个云服务器。
1、首先编写文件上传工具类,主要功能是将文件上传至服务器,采用的是字节数组的方法
@Component
public class QiniuUtils {
public static final String url = "http://rab15ts2p.hn-bkt.clouddn.com/";
@Value("${qiniu.accessKey}")
private String accessKey;
@Value("${qiniu.accessSecretKey}")
private String accessSecretKey;
public boolean upload(MultipartFile file,String fileName){
//构造一个带指定 Region 对象的配置类
Configuration cfg = new Configuration(Region.huanan());
//...其他参数参考类注释
UploadManager uploadManager = new UploadManager(cfg);
//...生成上传凭证,然后准备上传
String bucket = "xcnb";
//默认不指定key的情况下,以文件内容的hash值作为文件名
try {
byte[] uploadBytes = file.getBytes();
Auth auth = Auth.create(accessKey, accessSecretKey);
String upToken = auth.uploadToken(bucket);
Response response = uploadManager.put(uploadBytes, fileName, upToken);
//解析上传成功的结果
DefaultPutRet putRet = JSON.parseObject(response.bodyString(), DefaultPutRet.class);
return true;
} catch (Exception ex) {
ex.printStackTrace();
}
return false;
}
}
注:
- url为域名地址。
- 采用配置文件注入的方法注入密钥的值。
- upload方法从开发者手册里找,bucket是存储空间的名称。
2、后端提供controller接口
@RestController
@RequestMapping("/upload")
public class UploadController {
@Autowired
private QiniuUtils qiniuUtils;
@PostMapping
public Result upload(@RequestParam("image") MultipartFile file){
// 原始文件名称
String originalFilename = file.getOriginalFilename();
// substringAfterLast:拿到"."之后的字符串。这里的作用就是拿到文件的后缀名
// 拿到唯一的文件名称
String fileName = UUID.randomUUID().toString() + "." + StringUtils.substringAfterLast(originalFilename, ".");
// 上传文件至七牛云 云服务器 按量付费 速度快 会把图片发放到离用户最近的服务器上
// 降低我们自身应用服务器的带宽消耗
boolean upload = qiniuUtils.upload(file, fileName);
if(upload){
return Result.success(QiniuUtils.url + fileName);
}
return Result.fail(20001,"上传失败");
}
}
浙公网安备 33010602011771号