A. 項(xiàng)目介紹
I. 技術(shù)棧及工具
- Maven
- Spring, SpringMVC, SpringBoot
- MyBatis Plus
- Mysql
- Redis
- Spring Security
- docker
- nginx
- 七牛云
B. 開發(fā)
I. 項(xiàng)目結(jié)構(gòu)
- common:
- aop
- cache
- config:
- controller:
- dao:
- dos
- mapper
- pojo
- handler:
- service:
- impl
- utils:
- vo:
- params
項(xiàng)目采用經(jīng)典MVC結(jié)構(gòu),即View為前端實(shí)現(xiàn)、Controller進(jìn)行控制、Service負(fù)責(zé)業(yè)務(wù)邏輯、dao層用于操作數(shù)據(jù)庫。在此之外,我們還加入了以下一些常用packages:
common
common 基礎(chǔ)工具包和常量package。比如用aop實(shí)現(xiàn)日志輸出和緩存類。config
config是項(xiàng)目中一般都有的包用于存放整個(gè)項(xiàng)目的配置類。比如常見的WebMVCConfig類定義跨域ip,攔截器等等、線程池配置類或者ORM框架配置類......handler
handler也是項(xiàng)目中非常常見的package,存放例如通用的異常處理、登錄攔截器等等。utils
用于存放通用工具類-
vo
vo(view object)類用于存放視圖對(duì)象,與前端交互專用。使用過程通常如下:
a. 從數(shù)據(jù)庫中提取數(shù)據(jù)并存為entity類型對(duì)象
b. 用org.springframework.beans.BeanUtils的BeanUtils.copyProperties(article, articleVo);方法把存于pojo對(duì)象中的數(shù)據(jù)復(fù)制到Vo對(duì)象中(注意:如果變 量名或者變量類型不同時(shí),無法自動(dòng)復(fù)制數(shù)據(jù),此時(shí)需要手動(dòng)在service中用set方法復(fù)制數(shù)據(jù))vo中往往包含params package,用于存放前端傳來的數(shù)據(jù)。
另外比較值得關(guān)注的是dao層的結(jié)構(gòu),dao層通常包含以下packages:
pojo: 與數(shù)據(jù)庫表結(jié)構(gòu)一一對(duì)應(yīng),通過DAO層向上傳輸數(shù)據(jù)源對(duì)象。
dos: 與數(shù)據(jù)庫表結(jié)構(gòu)不一致,通過DAO層向上傳輸臨時(shí)數(shù)據(jù)源對(duì)象
mapper:用于操作數(shù)據(jù)庫表
參考:https://juejin.cn/post/6844903636334542856
II. 項(xiàng)目功能實(shí)現(xiàn)
-
業(yè)務(wù)功能(博客、評(píng)論、標(biāo)簽、分類、賬戶的增刪改查)
-
雪花算法生成Entity ID
我們使用雪花算法來分配數(shù)據(jù)ID用于避免分布式系統(tǒng)數(shù)據(jù)ID相同的問題,在MyBatis-Plus 3.3版本之后自動(dòng)實(shí)現(xiàn)雪花算法生成ID。
-
MyBatis自動(dòng)分頁
在MyBatis配置類中添加分頁配置即可自動(dòng)實(shí)現(xiàn)分頁
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加密
為避免用戶的賬號(hào)密碼泄漏,我們將存于數(shù)據(jù)庫中的密碼使用MD5進(jìn)行加密
-
JWT生成Token并用Redis儲(chǔ)存
前端將用戶的賬戶密碼傳入后端并驗(yàn)證成功后,使用JWT技術(shù)生成一個(gè)特定的Token暫時(shí)存于緩存數(shù)據(jù)庫Redis中并返回給前端存于cookie中。這樣前端每次請(qǐng)求都攜帶這個(gè)token,用戶便可以一直以登錄狀態(tài)訪問網(wǎng)站直到token在Redis中被自動(dòng)刪除。(本程序設(shè)定為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) // 簽發(fā)算法,秘鑰為jwtToken .setClaims(claims) // body數(shù)據(jù),要唯一,自行設(shè)置 .setIssuedAt(new Date()) // 設(shè)置簽發(fā)時(shí)間 .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 60 * 1000));// 一天的有效時(shí)間 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; } }
-
- **登錄攔截器**
在用戶還未登錄的情況下,我們不希望有些功能向他們開發(fā),這時(shí)候我們就可以使用登錄攔截器來定義哪些地址無法被未登錄的用戶使用。為實(shí)現(xiàn)此功能,我們首先需要定義一個(gè)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;
/**
* 在執(zhí)行controller方法(handler)之前進(jìn)行執(zhí)行
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
/**
* 1. 需要判斷 請(qǐng)求的接口路徑 是否為HandlerMethod(controller方法)
* 2. 判斷token是否為空,如果為空-> 未登錄
* 3. 如果token 不為空-> 登錄驗(yàn)證 loginService checkToken
* 4. 如果認(rèn)證成功-> 放行即可
*/
if (!(handler instanceof HandlerMethod)) {
// handler 可能是 RequestResourceHandler。springboot程序訪問靜態(tài)資源時(shí),默認(rèn)去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中用完的信息,會(huì)有內(nèi)存泄漏的風(fēng)險(xiǎn)
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. 項(xiàng)目?jī)?yōu)化
-
跨域
通常情況下,我們的Request來自另一個(gè)端口或IP,這時(shí)候我們就需要在
WebMVCContig implements WebMvcConfigurer下的addCorsMappings方法中定義允許訪問程序的IP地址和端口號(hào):/** * 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"); } -
緩存數(shù)據(jù)
我們可以將用戶訪問過的文章加入到Redis中,這樣用戶再次訪問它時(shí)可以直接走緩存加載,這樣提高了程序的性能。實(shí)現(xiàn)緩存的步驟如下:
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(); // 調(diào)用的方法名 String methodName = signature.getName(); Class[] parameterTypes = new Class[pjp.getArgs().length]; Object[] args = pjp.getArgs(); // 參數(shù) 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)) { // 加密 以防出現(xiàn)key過長(zhǎng)以及字符轉(zhuǎn)義獲取不到的情況 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(); // 執(zhí)行原方法 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, "系統(tǒng)錯(cuò)誤"); } }b. 在需要使用的方法上添加`@Cache`注解/** * return articles to the index page. * @param pageParams * @return */ @PostMapping //加上此注解 代表要對(duì)此接口記錄日志 @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 //切面 定義了通知和切點(diǎn)的關(guān)系 @Slf4j public class LogAspect { @Pointcut("@annotation(com.mszlu.blog.common.aop.LogAnnotation)") public void pt(){} //環(huán)繞通知 @Around("pt()") public Object log(ProceedingJoinPoint joinPoint) throws Throwable { long beginTime = System.currentTimeMillis(); //執(zhí)行方法 Object result = joinPoint.proceed(); //執(zhí)行時(shí)長(zhǎng)(毫秒) 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()); //請(qǐng)求的方法名 String className = joinPoint.getTarget().getClass().getName(); String methodName = signature.getName(); log.info("request method:{}",className + "." + methodName + "()"); // //請(qǐng)求的參數(shù) Object[] args = joinPoint.getArgs(); String params = JSON.toJSONString(args[0]); log.info("params:{}",params); //獲取request 設(shè)置IP地址 HttpServletRequest request = HttpContextUtils.getHttpServletRequest(); log.info("ip:{}", IpUtils.getIpAddr(request)); log.info("excute time : {} ms",time); log.info("=====================log end================================"); } }
b. 為需要日志輸出功能的方法添加此注解
```java
@PostMapping
//加上此注解 代表要對(duì)此接口記錄日志
@LogAnnotation(module="文章", operator="獲取文章列表")
@Cache(expire = 5 * 60 * 1000,name = "listArticle")
public Result listArticle(@RequestBody PageParams pageParams) {
return articleService.listArticle(pageParams);
}
```
-
線程池異步操作
待更新
-
application.yml分類
一般來說,一個(gè)項(xiàng)目我們需要不同的運(yùn)行環(huán)境,這個(gè)時(shí)候我們就可以定義不同的
application.yml來實(shí)現(xiàn)定義多運(yùn)行環(huán)境。一般來說,我們可以將此配置放到resources/config文件夾下。定義配置文件步驟如下:a. 創(chuàng)建
application.yml:# 主配置 spring: profiles: active: prod # 選擇使用哪個(gè)開發(fā)環(huán)境(這里使用application-prod.yml)b. 創(chuàng)建并實(shí)現(xiàn)不同的application.yml,一般包括:
- application-dev.yml
- application-prod.yml
- application-test.yml
c. 可以使用如下命令執(zhí)行jar文件并指定環(huán)境:
java -jar javafile.jar --spring.profiles.active=prod
IV. 小細(xì)節(jié)
- 最好在Entity類中添加Lombok的
@NoArgsConstructor和@llArgsConstructor注解。否則在使用ORM從數(shù)據(jù)庫中提取數(shù)據(jù)時(shí),當(dāng)返回值不完全對(duì)應(yīng)entity中所有的field時(shí),可能會(huì)出錯(cuò)。
C. 運(yùn)維
I. Redis
-
安裝Redis步驟
docker pull redis docker run —name myblog-redis -d -it redis 查看Redis是否被安裝且啟動(dòng)
docker exec -it myblog-redis bash # 進(jìn)入redis container
redis-server --version # 查看redis版本
redis-cli # 進(jìn)入redis
keys * # 查看數(shù)據(jù)庫緩存
Ctirl + C # 退出Redis
exit 或 Ctirl + P + Q # 退出Docker容器但不關(guān)閉
- docker-compose.yml編寫
myblog-redis:
image: redis:6.2.6
container_name: myblog-redis
expose:
- 6379
networks:
- myblog
II. Mysql
-
安裝Mysql和數(shù)據(jù)卷掛載
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 -
數(shù)據(jù)初始化
FROM mysql:8.0.27 #將初始化數(shù)據(jù)庫命令放入這個(gè)文件夾后在初始化后自動(dòng)執(zhí)行query COPY initial_db.sql /docker-entrypoint-initdb.d/ -
錯(cuò)誤排查
a. 如果提示缺少mysql-files文件,我們可以自己創(chuàng)建一個(gè)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: # 不再會(huì)有不被允許的log - seccomp:unconfined # network_mode: "bridge" networks: - myblog
III. Nginx
1. 編寫myblog.conf文件并放入conf.d文件夾中
# 定義http://myblogbackend,并設(shè)置負(fù)載均衡,weight代表負(fù)載均衡權(quán)重
upstream myblogbackend {
server blog-api:8888 weight=1;
}
server {
# 設(shè)置監(jiān)聽端口號(hào)和服務(wù)器ip
listen 80;
listen [::]:80;
server_name localhost;
#access_log /var/log/nginx/host.access.log main;
# 所有 /api請(qǐng)求會(huì)重定向到http://myblogbackend
location /api {
proxy_pass http://myblogbackend;
}
# 所有 除/api請(qǐng)求會(huì)重定向到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項(xiàng)目生成一個(gè)包含靜態(tài)文件的文件夾
dist在根目錄下:
npm run build
- 將文件夾復(fù)制到nginx中并寫好配置即可使用(可以使用docker的數(shù)據(jù)卷掛載來完成,配置見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: # 不再會(huì)有不被允許的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