A. 项目介绍
I. 技术栈及工具
- Maven
- Spring, SpringMVC, SpringBoot
- MyBatis Plus
- Mysql
- Redis
- Spring Security
- docker
- nginx
- 七牛云
B. 开发
I. 项目结构
- common:
- aop
- cache
- config:
- controller:
- dao:
- dos
- mapper
- pojo
- handler:
- service:
- impl
- utils:
- vo:
- params
项目采用经典MVC结构,即View为前端实现、Controller进行控制、Service负责业务逻辑、dao层用于操作数据库。在此之外,我们还加入了以下一些常用packages:
common
common 基础工具包和常量package。比如用aop实现日志输出和缓存类。config
config是项目中一般都有的包用于存放整个项目的配置类。比如常见的WebMVCConfig类定义跨域ip,拦截器等等、线程池配置类或者ORM框架配置类......handler
handler也是项目中非常常见的package,存放例如通用的异常处理、登录拦截器等等。utils
用于存放通用工具类-
vo
vo(view object)类用于存放视图对象,与前端交互专用。使用过程通常如下:
a. 从数据库中提取数据并存为entity类型对象
b. 用org.springframework.beans.BeanUtils的BeanUtils.copyProperties(article, articleVo);方法把存于pojo对象中的数据复制到Vo对象中(注意:如果变 量名或者变量类型不同时,无法自动复制数据,此时需要手动在service中用set方法复制数据)vo中往往包含params package,用于存放前端传来的数据。
另外比较值得关注的是dao层的结构,dao层通常包含以下packages:
pojo: 与数据库表结构一一对应,通过DAO层向上传输数据源对象。
dos: 与数据库表结构不一致,通过DAO层向上传输临时数据源对象
mapper:用于操作数据库表
参考:https://juejin.cn/post/6844903636334542856
II. 项目功能实现
-
业务功能(博客、评论、标签、分类、账户的增删改查)
-
雪花算法生成Entity ID
我们使用雪花算法来分配数据ID用于避免分布式系统数据ID相同的问题,在MyBatis-Plus 3.3版本之后自动实现雪花算法生成ID。
-
MyBatis自动分页
在MyBatis配置类中添加分页配置即可自动实现分页
package com.soul.blog.config; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @MapperScan("com.soul.blog.dao.mapper") // scan all mapper in this location public class MybatisPlusConfig { /** * paging plugin * @return */ @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; } }
-
-
登录/登出
-
MD5加密
为避免用户的账号密码泄漏,我们将存于数据库中的密码使用MD5进行加密
-
JWT生成Token并用Redis储存
前端将用户的账户密码传入后端并验证成功后,使用JWT技术生成一个特定的Token暂时存于缓存数据库Redis中并返回给前端存于cookie中。这样前端每次请求都携带这个token,用户便可以一直以登录状态访问网站直到token在Redis中被自动删除。(本程序设定为1日后删除)
以下为JWT工具类模板,定义了生成token和解析token的方法:
package com.soul.blog.utils; import io.jsonwebtoken.Jwt; import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.util.Date; import java.util.HashMap; import java.util.Map; public class JWTUtils { private static final String jwtToken = "123456Myblog!@#$$"; 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; } }
-
- **登录拦截器**
在用户还未登录的情况下,我们不希望有些功能向他们开发,这时候我们就可以使用登录拦截器来定义哪些地址无法被未登录的用户使用。为实现此功能,我们首先需要定义一个LoginInterceptor,并在`WebMVCContig implements WebMvcConfigurer`下的`addInterceptors`方法中定义需要拦截的地址:
```java
package com.soul.blog.handler;
import com.alibaba.fastjson.JSON;
import com.soul.blog.dao.pojo.SysUser;
import com.soul.blog.service.LoginService;
import com.soul.blog.utils.UserThreadLocal;
import com.soul.blog.vo.ErrorCode;
import com.soul.blog.vo.Result;
import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@Log4j2
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private LoginService loginService;
/**
* 在执行controller方法(handler)之前进行执行
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
/**
* 1. 需要判断 请求的接口路径 是否为HandlerMethod(controller方法)
* 2. 判断token是否为空,如果为空-> 未登录
* 3. 如果token 不为空-> 登录验证 loginService checkToken
* 4. 如果认证成功-> 放行即可
*/
if (!(handler instanceof HandlerMethod)) {
// handler 可能是 RequestResourceHandler。springboot程序访问静态资源时,默认去classpath下的static目录去查询
return true;
}
String token = request.getHeader("Authorization");
log.info("=================request start===========================");
String requestURI = request.getRequestURI();
log.info("request uri:{}",requestURI);
log.info("request method:{}",request.getMethod());
log.info("token:{}", token);
log.info("=================request end===========================");
if (StringUtils.isBlank(token)) {
printNoLogin(response);
return false;
}
SysUser sysUser = loginService.checkToken(token);
if (sysUser == null) { // if no this token in redis
printNoLogin(response);
return false;
}
// 我们希望在Controller中 直接获取用户的信息 -> 使用TreadLocal
UserThreadLocal.put(sysUser);
return true; // 放行
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 如果不删除ThreadLocal中用完的信息,会有内存泄漏的风险
UserThreadLocal.remove();
}
private void printNoLogin(HttpServletResponse response) throws IOException {
Result result = Result.fail(ErrorCode.NO_LOGIN.getCode(), ErrorCode.NO_LOGIN.getMsg());
response.setContentType("text/json;charset=utf-8");
response.getWriter().print(JSON.toJSONString(result)); // 把result写入response
}
}
```
```java
@Autowired
private LoginInterceptor loginInterceptor;
/**
* inject interceptor to SpringMVC
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/test")
.addPathPatterns("/comments/create/change") // 拦截test接口
.addPathPatterns("/articles/publish")
;
}
```
III. 项目优化
-
跨域
通常情况下,我们的Request来自另一个端口或IP,这时候我们就需要在
WebMVCContig implements WebMvcConfigurer
下的addCorsMappings
方法中定义允许访问程序的IP地址和端口号:/** * Cors (跨域) conf. * @param registry */ @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**").allowedOrigins("*"); // registry.addMapping("/**").allowedOrigins("http://85.214.77.110:81"); // registry.addMapping("/**").allowedOrigins("http://localhost:81"); }
-
缓存数据
我们可以将用户访问过的文章加入到Redis中,这样用户再次访问它时可以直接走缓存加载,这样提高了程序的性能。实现缓存的步骤如下:
a. 在common包中定义缓存注解:
package com.soul.blog.common.cache; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Cache { long expire() default 1 * 60 * 1000; String name() default ""; }
package com.soul.blog.common.cache; import com.alibaba.fastjson.JSON; import com.soul.blog.vo.Result; import java.lang.reflect.Method; import java.time.Duration; import lombok.extern.log4j.Log4j2; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang3.StringUtils; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.Signature; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; @Aspect @Component @Log4j2 public class CacheAspect { @Autowired private RedisTemplate<String, String> redisTemplate; @Pointcut("@annotation(com.soul.blog.common.cache.Cache)") public void pt(){} @Around("pt()") public Object around(ProceedingJoinPoint pjp) { try{ Signature signature = pjp.getSignature(); // 类名 String className = pjp.getTarget().getClass().getSimpleName(); // 调用的方法名 String methodName = signature.getName(); Class[] parameterTypes = new Class[pjp.getArgs().length]; Object[] args = pjp.getArgs(); // 参数 String params = ""; for (int i = 0; i < args.length; i++) { if (args[i] != null) { params += JSON.toJSONString(args[i]); parameterTypes[i] = args[i].getClass(); } else { parameterTypes[i] = null; } } if (StringUtils.isNotEmpty(params)) { // 加密 以防出现key过长以及字符转义获取不到的情况 params = DigestUtils.md5Hex(params); } Method method = pjp.getSignature().getDeclaringType().getMethod(methodName, parameterTypes); //获取Cache 注解 Cache annotation = method.getAnnotation(Cache.class); long expire = annotation.expire(); String name = annotation.name(); // 先从redis获取 String redisKey = name + "::" + className + "::" + methodName + "::" + params; String redisValue = redisTemplate.opsForValue().get(redisKey); if (StringUtils.isNotEmpty(redisValue)) { log.info("走了缓存~~~, {}, {}", className, methodName); Result result = JSON.parseObject(redisValue, Result.class); return result; } Object proceed = pjp.proceed(); // 执行原方法 redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(proceed), Duration.ofMillis(expire)); log.info("存入缓存~~~ {}, {}", className, methodName); return proceed; } catch (Throwable throwable) { throwable.printStackTrace(); } return Result.fail(-999, "系统错误"); } }
b. 在需要使用的方法上添加`@Cache`注解
/** * return articles to the index page. * @param pageParams * @return */ @PostMapping //加上此注解 代表要对此接口记录日志 @LogAnnotation(module="文章", operator="获取文章列表") @Cache(expire = 5 * 60 * 1000,name = "listArticle") // 缓存注解 public Result listArticle(@RequestBody PageParams pageParams) { return articleService.listArticle(pageParams); }
-
日志输出
我们可以使用Spring Boot的AOP功能添加日志输出,步骤如下:
a. 编写日志注解
package com.mszlu.blog.common.aop; import java.lang.annotation.*; //Type 代表可以放在类上面 Method 代表可以放在方法上 @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface LogAnnotation { String module() default ""; String operator() default ""; }
package com.mszlu.blog.common.aop; import com.alibaba.fastjson.JSON; import com.mszlu.blog.utils.HttpContextUtils; import com.mszlu.blog.utils.IpUtils; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; @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); //获取request 设置IP地址 HttpServletRequest request = HttpContextUtils.getHttpServletRequest(); log.info("ip:{}", IpUtils.getIpAddr(request)); log.info("excute time : {} ms",time); log.info("=====================log end================================"); } }
b. 为需要日志输出功能的方法添加此注解
```java
@PostMapping
//加上此注解 代表要对此接口记录日志
@LogAnnotation(module="文章", operator="获取文章列表")
@Cache(expire = 5 * 60 * 1000,name = "listArticle")
public Result listArticle(@RequestBody PageParams pageParams) {
return articleService.listArticle(pageParams);
}
```
-
线程池异步操作
待更新
-
application.yml分类
一般来说,一个项目我们需要不同的运行环境,这个时候我们就可以定义不同的
application.yml
来实现定义多运行环境。一般来说,我们可以将此配置放到resources/config
文件夹下。定义配置文件步骤如下:a. 创建
application.yml
:# 主配置 spring: profiles: active: prod # 选择使用哪个开发环境(这里使用application-prod.yml)
b. 创建并实现不同的application.yml,一般包括:
- application-dev.yml
- application-prod.yml
- application-test.yml
c. 可以使用如下命令执行jar文件并指定环境:
java -jar javafile.jar --spring.profiles.active=prod
IV. 小细节
- 最好在Entity类中添加Lombok的
@NoArgsConstructor
和@llArgsConstructor
注解。否则在使用ORM从数据库中提取数据时,当返回值不完全对应entity中所有的field时,可能会出错。
C. 运维
I. Redis
-
安装Redis步骤
docker pull redis docker run —name myblog-redis -d -it redis
查看Redis是否被安装且启动
docker exec -it myblog-redis bash # 进入redis container
redis-server --version # 查看redis版本
redis-cli # 进入redis
keys * # 查看数据库缓存
Ctirl + C # 退出Redis
exit 或 Ctirl + P + Q # 退出Docker容器但不关闭
- docker-compose.yml编写
myblog-redis:
image: redis:6.2.6
container_name: myblog-redis
expose:
- 6379
networks:
- myblog
II. Mysql
-
安装Mysql和数据卷挂载
docker run -id -p 3307:3306 --name=c_mysql -v /mnt/docker/mysql/conf:/etc/mysql/conf.d -v /mnt/docker/mysql/logs:/logs -v /mnt/docker/mysql/data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=root mysql
-
数据初始化
FROM mysql:8.0.27 #将初始化数据库命令放入这个文件夹后在初始化后自动执行query COPY initial_db.sql /docker-entrypoint-initdb.d/
-
错误排查
a. 如果提示缺少mysql-files文件,我们可以自己创建一个mysql-files文件夹并挂载到docker中
-
docker-compose.yml 编写:
myblog-mysql: build: context: ./blog_mysql image: mysql container_name: myblog-mysql ports: - 3307:3306 volumes: # - ./blog_mysql/my.cnf:/etc/mysql/my.cnf - ./blog_mysql/data:/var/lib/mysql - ./blog_mysql/mysql-files:/var/lib/mysql-files environment: MYSQL_ROOT_PASSWORD: root security_opt: # 不再会有不被允许的log - seccomp:unconfined # network_mode: "bridge" networks: - myblog
III. Nginx
1. 编写myblog.conf文件并放入conf.d文件夹中
# 定义http://myblogbackend,并设置负载均衡,weight代表负载均衡权重
upstream myblogbackend {
server blog-api:8888 weight=1;
}
server {
# 设置监听端口号和服务器ip
listen 80;
listen [::]:80;
server_name localhost;
#access_log /var/log/nginx/host.access.log main;
# 所有 /api请求会重定向到http://myblogbackend
location /api {
proxy_pass http://myblogbackend;
}
# 所有 除/api请求会重定向到index.html
location / {
root /myblog/web/;
index index.html;
}
location ~* \.(jpg|jpeg|gif|png|swf|rar|zip|css|js|map|svg|woff|ttf|txt)$ {
root /myblog/web/;
index index.html;
add_header Access-Control-Allow-Origin *;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
- 编写docker-compose.yml文件
myblog-nginx:
image: nginx:1.21.5
container_name: myblog-nginx
ports:
- 81:80
- 443:443
links:
- blog-api
depends_on:
- blog-api
volumes:
- ./nginx/:/etc/nginx/
- ./blog-api/web:/myblog/web
networks:
- myblog
VI. vue 打包
- 我们可以使用如下命令打包vue项目生成一个包含静态文件的文件夹
dist
在根目录下:
npm run build
- 将文件夹复制到nginx中并写好配置即可使用(可以使用docker的数据卷挂载来完成,配置见nginx下的myblog.conf)
V. 完整的docker-compose.yml文件:
version: "3.5"
services:
myblog-mysql:
build:
context: ./blog_mysql
image: mysql
container_name: myblog-mysql
ports:
- 3307:3306
volumes:
# - ./blog_mysql/my.cnf:/etc/mysql/my.cnf
- ./blog_mysql/data:/var/lib/mysql
- ./blog_mysql/mysql-files:/var/lib/mysql-files
environment:
MYSQL_ROOT_PASSWORD: root
security_opt: # 不再会有不被允许的log
- seccomp:unconfined
# network_mode: "bridge"
networks:
- myblog
myblog-redis:
image: redis:6.2.6
container_name: myblog-redis
expose:
- 6379
networks:
- myblog
blog-api:
image: blog-api
container_name: blog-api
build:
context: ./blog-api
ports:
- 8887:8888
# network_mode: "bridge"
depends_on:
- myblog-mysql
- myblog-redis
links:
- myblog-mysql
- myblog-redis
networks:
- myblog
myblog-nginx:
image: nginx:1.21.5
container_name: myblog-nginx
ports:
- 81:80
- 443:443
links:
- blog-api
depends_on:
- blog-api
volumes:
- ./nginx/:/etc/nginx/
- ./blog-api/web:/myblog/web
networks:
- myblog
networks:
myblog:
name: myblog_network
driver: bridge