凤凰涅槃:Spring Boot 开发之路上的坎坷与成长

本文最后更新于:9 天前

每一场旅程都是一次自我超越,让我们不断前行,不断探索。

破冰

写作目标

今天是开发壁纸网站 memory-icon 的第一天,本来没打算写什么开发文档的,太耗时间,整体实现思路也简单,就不写了

但是有必要记录一下:从零开发一个 SpringBoot 项目,至少应该要做些什么

这就算给定制自己的 SpringBoot 模板开个好头了(2023/08/01 午)

开发思路

从零开发一个 SpringBoot 要做什么?一定是做到以下几点:(2023/08/01 午)

  • 新建模块

  • 导入必要的依赖:MySQL、Mybatis、MybatisPlus、commons-lang3、lombok、Gson 等

  • 新增必要 yaml 配置:项目名、启动端口、上下文路径、数据库地址

  • 新增 banner.txt

  • 新增数据库表,连接数据库,保存建表语句

  • 新增 controller 层、service 层

  • 利用 MybatisX-Generator 插件,快速生成 domain、mapper、service、XXXMaper.xml

  • 新增 config 层:CorsConfig、MybatisPlusConfig、Knife4jConfig、JsonConfig 等

思维碰撞

着手开发

快速编写 Controller 层

  • 什么是 Controller 层?(2023/08/14 早)
1
2
3
4
5
6
7
在Web开发中,Controller层是MVC(模型-视图-控制器)架构的一部分,用于接收并处理前端请求。
具体来说,Controller层负责以下几个任务:
1.接收HTTP请求:Controller通过定义特定的路由(URL映射)来接收前端发送的HTTP请求。
2.处理请求:Controller中的方法将根据请求的类型、路径以及请求参数等信息,执行相应的逻辑操作。
3.调用服务层:在Controller中,可以调用服务层(Service层、业务层)的方法来实现相关业务逻辑。
4.返回响应:在完成对请求的处理后,Controller负责将数据或者视图响应返回给前端。
Controller层的目标是实现请求-响应的流程控制,将请求按照业务逻辑分配给对应的服务层处理,并将执行结果返回给前端。同时,Controller层还可以处理一些与业务逻辑相关的验证或者数据转换等操作。
  • 引入依赖:
1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.7.14</version>
</dependency>
  • 这里贴个小技巧,alt + insert 快速添加依赖模板:

image-20230814130719252

  • 新增 controller 层,编写响应请求的逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author 邓哈哈
* 2023/8/14 12:06
* Function:
* Version 1.0
*/

@RestController
@RequestMapping("/test")
public class TestController {
@GetMapping("/name")
public String getName(String name) {
return String.format("我的名字是: %s", name);
}
}
  • 编辑 application.yaml 配置文件:
1
2
3
4
server:
port: 8088
servlet:
context-path: /api
  • 发送请求,测试接口
  • 这一步的操作方法有很多:

    • Postman 工具
    • Swagger + Knif4j 接口文档
    • 浏览器
    • IDEA 模拟浏览器
  • 有关 Postman 工具的使用,这里不再详述
  • 有关快速生成 Swagger + Knif4j 接口文档的教程,可以在下面 👇 的《快速生成 Swagger + Knif4j 接口文档》栏目中了解到
  • 有关 IDEA 如何模拟浏览器,发送 HTTP 请求,可以在《掌握-JetBrains-IntelliJ-IDEA:使用心得与技巧》一文中的《模拟浏览器发送请求》栏目中找到答案
  • 有关 SpringMVC 处理 HTTP 请求参数的方式,可以在《SpringBoot 配置》一文中的 《SpringMVC 请求参数的处理》栏目中找到答案
  • 浏览器发送请求,结果如下图所示:(2023/08/14 午)

image-20230814132007439

  • IDEA 模拟浏览器发送请求,结果如下图所示:
1
GET http://localhost:8088/api/test/name?name=邓啊呀

image-20230814132051083

全局异常处理

MybatisPlus

1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>
  • 配置:
1
2
3
4
5
6
7
8
9
10
11
mybatis-plus:
configuration:
#在映射实体或者属性时,将数据库中表名和字段名中的下划线去掉,按照驼峰命名法映射
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开启SQL日志
global-config:
db-config:
id-type: ASSIGN_ID # 主键自增长策略
logic-delete-field: isDelete # 全局逻辑删除的实体字段名
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
  • 以上是最常用的配置,其中,按照驼峰命名法映射是这样处理的:
    • 建库建表后,如果表字段是下划线命名的,而实体类是驼峰命名,如下:

    • image-20230821115235186

    • 当我们操作数据库时,MybatisPlus 会将驼峰命名的实体类属性,映射为下划线命名的表字段了:
    • ```java
      boolean updateById = setMealService.updateById(setmeal);

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - ##### 开启 SQL 日志不用多说,方便代码调试和分析报错信息

      - ##### 主键自增长策略,也可以在主键字段上添加注解:

      ```java
      /**
      * 主键
      */
      @TableId(value = "id", type = IdType.AUTO)
      private Long id;
  • 逻辑删除,被删除的数据记录不会直接从表中删除,而是在指定字段上标注为已删除,需要在字段上添加以下注解:

1
2
3
4
5
6
/**
* 是否删除(0-未删, 1-已删)
*/
@TableField(value = "isDelete")
@TableLogic
private Integer isDelete;
  • 最后在 WebConfig 下配置分页插件:(2023/08/22 早)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Web配置类
*/
@Configuration
@Slf4j
public class WebConfig implements WebMvcConfigurer {
/**
* 分页插件(官网最新)
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}

}

对象映射器(消息转换器)

  • xxxxxxxxxx     4.0.0            com.memory.api        memory-api        0.0.1-SNAPSHOT        ../pom.xml    ​    com.memory.client    memory-client    0.0.1-SNAPSHOT​                        com.memory.common            memory-common            0.0.1-SNAPSHOT                            com.belerweb            pinyin4j            2.5.1                            com.memory            memory-client-spring-boot-starter            0.0.1            ​                                        org.springframework.boot                spring-boot-maven-plugin                                                            repackage                                                    repackage                                                                                    ​xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
* 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
* 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
*/
public class JacksonObjectMapper extends ObjectMapper {

public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

public JacksonObjectMapper() {
super();
//收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);

//反序列化时,属性不存在的兼容处理
this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);


SimpleModule simpleModule = new SimpleModule()
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))

.addSerializer(BigInteger.class, ToStringSerializer.instance)
.addSerializer(Long.class, ToStringSerializer.instance)
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));

//注册功能模块 例如,可以添加自定义序列化器和反序列化器
this.registerModule(simpleModule);
}
}
  • 在 WebConfig 下作如下配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Web配置类
*/
@Configuration
@Slf4j
public class WebConfig implements WebMvcConfigurer {
/**
* 扩展消息转换器
*
* @param converters converters
*/
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
//创建消息转换器
MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
//设置具体的对象映射器
messageConverter.setObjectMapper(new JacksonObjectMapper());
//通过索引设置,让自己的转换器放在最前面,否则默认的jackson转换器会在最前面,用不上我们设置的转换器
converters.add(0, messageConverter);
}
}

全局跨域处理

  •    4.0.0            com.memory.api        memory-api        0.0.1-SNAPSHOT        ../pom.xml    ​    com.memory.client    memory-client    0.0.1-SNAPSHOT​                        com.memory.common            memory-common            0.0.1-SNAPSHOT                            com.belerweb            pinyin4j            2.5.1                            com.memory            memory-client-spring-boot-starter            0.0.1            ​                                        org.springframework.boot                spring-boot-maven-plugin                                                            repackage                                                    repackage                                                                                    ​xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Web配置类
*/
@Configuration
@Slf4j
public class WebConfig implements WebMvcConfigurer {
/**
* 允许跨域请求
*
* @param registry registry
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowCredentials(true)
.allowedOrigins("http://localhost:7070", "http://localhost:3000","http://120.55.62.195:7071")
.allowedMethods("GET", "POST", "DELETE", "PUT")
.maxAge(3600);
}
}

  • 只要是前端浏览器页面,调用后端接口,都应该在前后端做好跨域处理,不然就会遇到这种鸟问题,都不知道怎么解决:(2023/09/09 晚)

image-20230909174834655

Filter 全局过滤器

  • 年后,我学习了第一个完整的 SpringBoot 项目:瑞吉外卖,下面给出编写过滤器的一段代码:(2023/08/22 早)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/**
* @author 邓哈哈
* 2023/1/13 10:19
* Function:登录拦截
* Version 1.0
*/

/**
* 检查用户/员工是否完成登录
*/
@WebFilter(filterName = "LoginCheckFilter")
@Slf4j
public class LoginCheckFilter implements Filter {//验证是否登录
//路径匹配器,支持通配符
public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
//1.获取本次请求的URI
String requestURI = request.getRequestURI();

//定义不需要处理的请求路径
String[] urls = new String[]{
"/employee/login", //登录时
"/employee/logout", //登出时
"/backend/**",
"/front/**",
"/user/sendMsg",
"/user/login"
};

//2.判断本次请求是否需要处理
boolean check = check(urls, requestURI);

//3.如果不需要处理,则直接放行
if (check) {
log.info("本次请求不需要处理...");
filterChain.doFilter(request, response);//放行请求
return;
}

// 4.1.需要处理的员工的请求,则判断登录状态,如果已经登录,则直接放行
Long empId;
if ((empId = (Long) request.getSession().getAttribute("employee")) != null) {//Session中存储着员工id(登录成功)
log.info("该员工已登录,id为{}", empId);

BaseContext.setCurrentId(empId);//ThreadLocal
filterChain.doFilter(request, response);//放行请求
return;
}

//4.2.需要处理的用户的请求,则判断登录状态,如果已经登录,则直接放行
Long userId;
if ((userId = (Long) request.getSession().getAttribute("user")) != null) {//Session中存储着用户id(登录成功)
log.info("该用户已登录,id为{}", userId);

BaseContext.setCurrentId(userId);//ThreadLocal

filterChain.doFilter(request, response);//放行请求
return;
}

//5.如果未登录,则返回登录结果,通过输出流方式向客户端页面响应数据
log.info("该用户未登录...");

response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
}

/**
* 路径匹配,检查本次请求是否需要放行
*
* @param urls :已定义的不需要处理的请求
* @param requestURI :接收检查的请求
* @return
*/
public boolean check(String[] urls, String requestURI) {
for (String url : urls) {
boolean match = PATH_MATCHER.match(url, requestURI);
if (match) {//该请求不需要处理
return true;
}
}
return false;//该请求得处理
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 路径匹配,检查本次请求是否需要放行
*
* @param urls :已定义的不需要处理的请求
* @param requestURI :接收检查的请求
* @return
*/
public boolean check(String[] urls, String requestURI) {
for (String url : urls) {
boolean match = PATH_MATCHER.match(url, requestURI);
if (match) {//该请求不需要处理
return true;
}
}
return false;//该请求得处理
}
  • 这段代码的亮点包括以下几点:
    • 使用了@Log4j 注解,简化了日志记录的操作。通过这个注解,可以直接使用 log 变量来记录日志。
    • 使用了 AntPathMatcher 来匹配请求路径,支持通配符。这样可以减少对每个请求进行完全匹配的操作,提高了效率。
    • 定义了不需要处理的请求路径,使用数组来存储。这样可以在数组中添加需要排除的路径,只对数组中未包含的路径进行处理。
    • 使用 check 方法来判断当前请求是否需要处理。check 方法会遍历请求路径数组,通过 AntPathMatcher 进行匹配,如果匹配成功则返回 true,否则返回 false。
    • 如果当前请求需要处理,将继续调用 FilterChain 的 doFilter 方法,执行后续的过滤器或 Servlet 逻辑。如果不需要处理,则直接放行,不进行后续处理。
  • 这样的设计可以在过滤器中对请求进行过滤,在需要处理的请求中执行相应的逻辑,而不需要处理的请求可以直接放行,提高了性能和效率。(2023/08/22 早)

ThreadLocal

  • 从上面的代码中可以看到这样的业务流程:
1
2
3
4
5
6
7
8
9
// 4.1.需要处理的员工的请求,则判断登录状态,如果已经登录,则直接放行
Long empId;
if ((empId = (Long) request.getSession().getAttribute("employee")) != null) {//Session中存储着员工id(登录成功)
log.info("该员工已登录,id为{}", empId);

BaseContext.setCurrentId(empId);//ThreadLocal
filterChain.doFilter(request, response);//放行请求
return;
}
  • 这是将已登录员工的 id 封装在 ThreadLocal 中,为每一个使用到这个变量的线程创建了一个属于自己的副本
  • ThreadLocal 用作保存每个线程独享的对象,为每个线程都创建一个副本,这样每个线程都可以修改自己所拥有的副本, 而不会影响其他线程的副本,确保线程安全
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 基于ThreadLocal封装工具类,用户保存和获取当前登录用户id
*/
public class BaseContext {
private static ThreadLocal<Long> threadLocal = new ThreadLocal();

/**
* 设置当前线程的局部变量的值
*
* @param id
*/
public static void setCurrentId(Long id) {
threadLocal.set(id);
}

/**
* 返回当前线程对应的局部变量的值
*
* @return
*/
public static Long getCurrentId() {
return threadLocal.get();
}
}

公共字段自动填充

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* @author 邓哈哈
* 2023/1/16 11:12
* Function:
* Version 1.0
*/
@Configuration
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* 插入操作,自动填充
*
* @param metaObject
*/
@Override
public void insertFill(MetaObject metaObject) {
log.info("公共字段自动填充...");

metaObject.setValue("createTime", LocalDateTime.now());
metaObject.setValue("updateTime", LocalDateTime.now());
metaObject.setValue("createUser", BaseContext.getCurrentId());
metaObject.setValue("updateUser", BaseContext.getCurrentId());
}

/**
* 更新操作,自动填充
*
* @param metaObject
*/
@Override
public void updateFill(MetaObject metaObject) {
metaObject.setValue("updateTime", LocalDateTime.now());
metaObject.setValue("updateUser", BaseContext.getCurrentId());
}
}

  • 这段代码是一个实现了 MyBatis 的 MetaObjectHandler 接口的类。它的主要作用是实现自动填充功能,用于在插入和更新操作时自动填充某些公共字段的值

  • 具体来说,这段代码通过重写 insertFill 和 updateFill 方法实现了自动填充功能

    • insertFill 方法会在插入操作时被调用,自动填充一些字段的值。在这段代码中,它会设置 createTime、updateTime、createUser 和 updateUser 字段的值。其中,createTime 和 updateTime 字段会被设置为当前时间,createUser 和 updateUser 字段会被设置为当前用户的 ID

    • updateFill 方法会在更新操作时被调用,同样实现自动填充功能。在这段代码中,它会设置 updateTime 和 updateUser 字段的值,分别设置为当前时间和当前用户的 ID

  • 通过使用这个自定义的 MyMetaObjectHandler 类,可以实现在插入和更新操作时自动填充公共字段的值,避免了手动在每次操作中设置这些字段的麻烦,提高了开发效率(2023/08/22 早)

Lombok

commons-lang3

Gson

基于基础类,开发一个 VO 类

  • 在开发过程,我们经常中使用基础类来开发 VO 类,是因为:(2023/09/14 晚)
1
2
基础类可以作为扩展点,用于定义VO类的共同特性和行为。
你可以在基础类中定义一些通用的属性和方法,然后在VO类中添加自定义属性和方法,以实现更具体的功能
  • 最近在开发 MemoryChat 项目的过程中,遇到了这样的业务问题:

  • 查询用户已加入的队伍 / 已创建的队伍,我想要展示队伍的 username(队长昵称),在 team 表中只有 userId 字段(队长 Id)

  • 这就需要多表联查,封装携带队伍信息以及队长 username 的对象,并返回给前端

  • 那我们就开发一个 teamVO 类,用 username 字段代替 userId 字段,作为封装返回的对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* @author 邓哈哈
* 2023/9/12 23:28
* Function:
* Version 1.0
*/
@Data
public class TeamVO {
/**
* id
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;

/**
* 队伍名称
*/
private String name;

/**
* 描述
*/
private String description;

/**
* 最大人数
*/
private Integer maxNum;

/**
* 队长昵称
*/
private String userName;

/**
* 队伍图片
*/
private String imgUrl;

/**
* 已加人数
*/
private Integer joinNum;

............................
}
  • 根据相关查询条件,正确执行业务逻辑获取到 teamList 后,转换为 teamVOList
1
2
// 转换teamList为teamVOList
List<TeamVO> teamVOList = getTeamVOByTeam(teamList);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 转换teamList为teamVOList
*
* @param teamList teamList
* @return teamVOList
*/
public List<TeamVO> getTeamVOByTeam(List<Team> teamList) {
List<TeamVO> teamVOList = teamList.stream().map(team -> {
Long userId = team.getUserId();
String userName = userService.getById(userId).getUsername();
TeamVO teamVO = new TeamVO();

teamVO.setId(team.getId());
teamVO.setName(team.getName());
teamVO.setDescription(team.getDescription());
teamVO.setMaxNum(team.getMaxNum());
teamVO.setUserName(userName);
teamVO.setImgUrl(team.getImgUrl());
teamVO.setJoinNum(team.getJoinNum());
teamVO.setStatus(team.getStatus());
teamVO.setExpireTime(team.getExpireTime());
teamVO.setCreateTime(team.getCreateTime());
teamVO.setUpdateTime(team.getUpdateTime());
teamVO.setIsDelete(team.getIsDelete());

return teamVO;
}).collect(Collectors.toList());

return teamVOList;
}
  • 就这样,我们将队伍信息和队长 username 封装在了 teamVOList 对象中,成功返回到了前端
  • 现在,我们可以看到队伍的队长信息了:(2023/09/14 晚)

image-20230914222548059

踩坑记录

  • 每当我兴致勃勃地启动项目,准备大展身手的时候,项目启动窗口总会弹出冰冷的报错信息
  • 这里列举了:我遇到过的所有启动项目报错的解决办法

XXXMapper 包扫描不到

  • 当你看到这样的报错,你会怎么解决呢:
1
Unsatisfied dependency expressed through field 'baseMapper'; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.memory.memoryiconbackend.mapper.WallpaperMapper' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
  • 这个报错信息大致意思是,未扫描到你的 XXXMapper 包,项目启动失败
  • 这个问题可谓最常见了,刚刚我就又被这个问题恶心到了,网上查了半天,感觉他们都是一知半解
  • 那么我是怎么解决这个问题的呢?思路如下:

XXXMapper.xml 配置错误

  • 检查 resource 下的 XXXMapper.xml 配置,检查实体类扫描和 mapper 扫描路径是否正确:

image-20230801180028233

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.memory.memoryiconbackend.mapper.WallpaperMapper">

<resultMap id="BaseResultMap" type="com.memory.memoryiconbackend.model.Wallpaper">
<result property="id" column="id" jdbcType="VARCHAR"/>
<result property="name" column="name" jdbcType="VARCHAR"/>
<result property="url" column="url" jdbcType="VARCHAR"/>
<result property="type" column="type" jdbcType="VARCHAR"/>
<result property="tags" column="tags" jdbcType="VARCHAR"/>
<result property="createTime" column="create_time" jdbcType="VARCHAR"/>
<result property="updateTime" column="update_time" jdbcType="VARCHAR"/>
<result property="isDelete" column="is_delete" jdbcType="VARCHAR"/>
<result property="userId" column="user_id" jdbcType="VARCHAR"/>
</resultMap>

<sql id="Base_Column_List">
id,name,url,
type,tags,create_time,
update_time,is_delete,user_id
</sql>
</mapper>
  • 确保 XXXMapper 包的扫描路径正确后,再继续排查:

XXXMapper 上添加@Mapper 注解

  • 检查 XXXMapper 上是否添加了@Mapper 注解:

image-20230801180147709

1
2
3
4
@Mapper
public interface WallpaperMapper extends BaseMapper<Wallpaper> {

}
  • 如果这两部还没有解决你的问题,请一定继续往下看:

开启@MapperScan 注解

  • @MapperScan 注解是干嘛的呢?它是用来在项目启动后,扫描你的 XXXMapper 所在路径,用法如下:
1
2
3
4
5
6
7
8

@SpringBootApplication
@MapperScan("com.memory.memoryiconbackend.mapper.WallpaperMapper")
public class MemoryIconBackendApplication {
public static void main(String[] args) {
SpringApplication.run(MemoryIconBackendApplication.class, args);
}
}
  • 那这个注解跟上面提到的@Mapper 注解,功能不是一样的吗?都是将 XXXMapper 标注为一个 Bean,交给 Spring 管理
  • 没错,这两个注解的作用是可以说是一摸一样的,无非就是写的位置不一样
  • 正是因为这两个注解作用是一样的,所以在开发过程中,这两个注解写一个就行,而且只能写一个,否则会报错
  • 网上总会有蠢蛋,说在 XXXMapper 上,添加了@Mapper 注解之后,一定不要忘了在启动类上添加@MapperScan 注解
  • 这种方法肯定解决不了问题,是大错特错的
  • 所以,如果你已经在 XXXMapper 上添加了@Mapper 注解,一定记得删除启动类上的@MapperScan 注解
  • 如果到这里,你已经按照上面的方法解决了问题,成功启动了项目,恭喜你;如果仍旧报错,请继续往下看:

MybatisPlusConfig 配置

  • 我们在项目中,导入了 MybatisPlus 依赖之后,总会写一个 MybatisPlusConfig 的分页配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* mybatis-plus 分页配置类
*/
@Configuration
@MapperScan("com.memory.memoryiconbackend.mapper.WallpaperMapper")
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
  • 如果你的问题没有解决,一定是因为在这个配置类上边,写上了@MapperScan 注解:

image-20230801182811187

  • 而这个注解的作用,跟启动类上的@MapperScan 注解的作用是一模一样的,删除它就好了

总结

  • 如果你已经在 XXXMapper 上添加了@Mapper 注解,请把启动类和 MybatisPlusConfig 配置类上的@MapperScan 注解删除
  • 如果你已经在启动类和 MybatisPlusConfig 配置类上添加了@MapperScan 注解,请把 XXXMapper 上的@Mapper 注解删除
  • 希望这篇文章对你有帮助,感谢您的支持!😁

类文件版本不匹配

  • 如果在启动项目时,出现了这样的报错,你会怎么解决呢:

image-20230802082050802

  • 引起这样报错的原因只有一种:类文件版本不匹配,即项目里导入的依赖版本不兼容

Mybatis 版本 和 SpringBoot 版本不兼容

  • 查看上方报错信息,很显然是 XXXMapper 包扫描失败了,这就是 Mybatis 版本 和 SpringBoot 版本不兼容的问题
  • 如此配置:(2023/08/02 早)
1
2
3
4
5
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.0</version>
</dependency>
1
2
3
4
5
6
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.14</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

Jdk 版本 和 SpringBoot 版本不兼容

  • 当你启动项目时,看到了这样的报错:

image-20230802182601591

  • 这是由于 Jdk 版本 和 SpringBoot 版本不兼容导致的
  • JDK 版本不兼容,还并且可能导致其他配置构建失败:

image-20230802182701312

  • 这是我的 SpringBoot 坐标依赖:
1
2
3
4
5
6
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.9</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
  • 按照提示,升级 JDK 版本到 1.8 以上即可解决问题

相关依赖未导入

MySQL 相关依赖未导入

  • 今天在做 PicMemories 项目过程中,启动项目时,出现了以下报错:
1
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'downloadServiceImpl': Unsatisfied dependency expressed through field 'baseMapper'; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'downloadMapper' defined in file [D:\Project\星球项目\PicMemories\target\classes\com\memory\picmemories\mapper\DownloadMapper.class]: Cannot resolve reference to bean 'sqlSessionTemplate' while setting bean property 'sqlSessionTemplate'; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'dataSourceScriptDatabaseInitializer' defined in class path resource [org/springframework/boot/autoconfigure/sql/init/DataSourceInitializationConfiguration.class]: Unsatisfied dependency expressed through method 'dataSourceScriptDatabaseInitializer' parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'dataSource' defined in class path resource [org/springframework/boot/autoconfigure/jdbc/DataSourceConfiguration$Hikari.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.zaxxer.hikari.HikariDataSource]: Factory method 'dataSource' threw exception; nested exception is java.lang.IllegalStateException: Cannot load driver class: com.mysql.cj.jdbc.Driver
  • 经排查,发现是 MySQL 相关依赖导入错了,我导入的错误依赖如下:
1
2
3
4
5
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
<scope>runtime</scope>
</dependency>
  • 正确的依赖应该为:
1
2
3
4
5
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
  • 为什么会出现这样的错误呢?是因为我在构建项目时,选择导入以下依赖中,发生了错误:

image-20230802183420381

  • 这里一定要选择 MySQL Driver,而不是 MySQL Server Driver,否则就会导入错误的依赖而引发报错(2023/08/02 午)

快速生成 Swagger + Knif4j 接口文档

  • 昨天遇到的问题,今天总算解决了,废话少说,上案例:

  • 在开发 Memory-icon 和 PicMemories 项目时,都遇到了这个问题:明明 Knif4j 配置无误,接口文档访问却报 404 错误:

image-20230802184955677

  • Knif4j 官方文档:快速开始 | Knife4j (xiaominfo.com)
  • 按照官方文档,我们可以清楚地看到不同版本的 SpringBoot 导入 Swagger + Knif4j 接口文档的方式是不一样的

  • Spring Boot 版本在 2.4.0 ~ 3.0.0之间,以我为例,我的 Spring Boot 版本是 2.7.9

  • 那么使用 Swagger + Kni4j 自动生成接口文档的步骤如下:

    • 导入依赖坐标
    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi2-spring-boot-starter</artifactId>
    <version>4.0.0</version>
    </dependency>
    • 在 appilcation.yaml 中导入配置:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    knife4j:
    enable: true
    openapi:
    title: PicMemories 接口文档
    description: PicMemories 壁纸分享小程序
    concat: 3348407547@qq.com
    url: https://deng-2022.gitee.io/blog/
    version: 1.0
    license: Apache 2.0
    group:
    test1:
    group-name: 壁纸分享
  • 完毕,已经能够正常访问到接口文档了:http://localhost:8084/api/doc.html(2023/08/02 晚)

执行 SQL 找不到字段

  • 如果在执行 SQL 时,出现了以下问题:
1
Unknown column 'user_id' in 'field list'

image-20230803082954240

  • 报错信息显示,找不到字段 user_id,解决思路如下:
    • 首先,确定这个对应数据库中有这个字段 user_id
    • 其次,确保实体类属性和表中字段映射无误
    • 最后,检查数据库配置是否正确
  • 我就栽在最后一步上了,数据库连接到别的表了,怪不得找不到相应字段,折磨了我一晚上(2023/08/03 早)

2024 年 8 月 31 日

栽他妈一晚上跟头

image-20240831193750916

表记录/实体类 ID 自增长

  • 如何实现:往数据库表中插入数据记录时,id 自增长?
    • 建表语句:
    1
    `user_id`   bigint auto_increment primary key comment '用户id',
    • 实体类映射:
    1
    2
    3
    4
    5
    /**
    * 用户id
    */
    @TableId(type = IdType.ASSIGN_ID)
    private Long userId;
    • 插入数据:
    1
    2
    3
    4
    5
    6
    7
    8
    // 3.向数据库中插入用户数据
    User user = new User();
    user.setUsername(username);
    user.setPassword(encryptPassword);
    user.setPassword(phone);
    user.setAvatar("http://ry2s7czdf.hd-bkt.clouddn.com/imgs/avatar/winter_nature_6-wallpaper-2560x1600.jpg");

    boolean save = this.save(user);
    • 表记录:(2023/08/04 早)
    1
    id = 1687297781782978562

测试类添加@SpringBootTest 注解

  • 如果不加这个注解,导入的对象值为 null:(2023/08/05 午)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MemoryClientTest {
@Resource
private MemoryClient memoryClient;

@Test
public void testMemorySdk() {
if(memoryClient != null){
System.out.println("成功了");
memoryClient.getNameByGet("邓哈哈");
memoryClient.getNameByPost("邓嘻嘻");

// User user = new User();
// user.setName("邓尼玛");
User user = new User("邓尼玛");
memoryClient.getUserByPost(user);
}else {
System.out.println("失败!");
}
}
}
  • 还有一点要注意,测试类返回值必须为 void,返回其他值会报错:(2023/08/07 早)
1
no test were found

image-20230807102218377

Enum 类不能使用@DATA 注解

  • 手写 getXXX() / setXXX():
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public enum WallpaperStatusEnum {
/**
* 0 - 公开, 在队伍大厅中可以直接加入
*/
REVIEW(0, "审核中"),

/**
* 1 - 私有, 在队伍大厅中不可以直接加入
*/
PASS(1, "已发布"),

/**
* 2 - 公开且加密, 加入队伍需要密码
*/
NOPASS(2, "不通过");

/**
* 状态码
*/
private int value;

/**
* 状态描述
*/
private String text;

/**
* 判断用户状态
*
* @param value 用户状态
* @return 存在与否
*/
public static WallpaperStatusEnum getEnumByValue(Integer value) {
if (value == null) {
return null;
}
WallpaperStatusEnum[] values = WallpaperStatusEnum.values();
for (WallpaperStatusEnum teamStatusEnum : values) {
if (teamStatusEnum.getValue() == value) {
return teamStatusEnum;
}
}
return null;
}

WallpaperStatusEnum(int value, String text) {
this.value = value;
this.text = text;
}

public int getValue() {
return value;
}

public void setValue(int value) {
this.value = value;
}

public String getText() {
return text;
}

public void setText(String text) {
this.text = text;
}
}

循环依赖

  • 我在项目中如此写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class WallpaperServiceImpl extends ServiceImpl<WallpaperMapper, Wallpaper>
implements WallpaperService {
@Resource
private WallpaperService wallpaperService;

/**
* 分页查询
* 分类查询壁纸
*
* @return 分类壁纸
*/
@Override
public List<Wallpaper> getPageByType(Integer searchType) {
if (WallpaperTypeEnum.getEnumByValue(searchType) == null) {
throw new BusinessException(ErrorCode.PARMS_ERROR, "没有这样的壁纸类型");
}

QueryWrapper<Wallpaper> type_wqw = new QueryWrapper<>();
type_wqw.eq("type", searchType);

return wallpaperService.list(type_wqw);
}
}
  • 产生了循环依赖,会报错:
1
2
3
4
5
6
7
8
Description:

The dependencies of some of the beans in the application context form a cycle:

wallpaperController
┌─────┐
| wallpaperServiceImpl
└─────┘
  • 应该写成这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class WallpaperServiceImpl extends ServiceImpl<WallpaperMapper, Wallpaper>
implements WallpaperService {
/**
* 分页查询
* 分类查询壁纸
*
* @return 分类壁纸
*/
@Override
public List<Wallpaper> getPageByType(Integer searchType) {
if (WallpaperTypeEnum.getEnumByValue(searchType) == null) {
throw new BusinessException(ErrorCode.PARMS_ERROR, "没有这样的壁纸类型");
}

QueryWrapper<Wallpaper> type_wqw = new QueryWrapper<>();
type_wqw.eq("type", searchType);

return this.list(type_wqw);
}
}

时间戳格式问题

1
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")

MyBatis 逻辑删除

1
2
3
4
5
/**
* 是否删除
*/
@TableLogic
private Integer isDelete;
1
2
3
4
5
6
7
mybatis-plus:
global-config:
# 逻辑删除
db-config:
logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)

Redis 的引入和测试

  • 快速实现 Redis 的引入,主要做到以下几点:(2023/08/07 早)
    • 导入相关依赖坐标
    • 作相关 yaml 配置
    • 作测试
    • 项目引入
  • 导入依赖坐标:
1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.6.4</version>
</dependency>
  • 写配置:
1
2
3
4
5
# redis 配置
redis:
port: 6379
host: localhost
database: 0
  • 作测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@SpringBootTest
@Slf4j
class UserServiceImplTest {
@Resource
private RedisTemplate redisTemplate;
@Resource
private StringRedisTemplate stringRedisTemplate = new StringRedisTemplate();

@Test
void loginTest() {

String redisKey = "pic_memories:user:login:session_key";


stringRedisTemplate.opsForValue().set("pic", "memory");

stringRedisTemplate.opsForValue().set(redisKey, "memory", 20, TimeUnit.HOURS);
redisTemplate.opsForValue().set(redisKey + "2", "memory2");
}
}
  • 这里注意:
  • RedisTemplate 底层的序列化方式,会导致存入的序列化后的 value 值成为乱码
  • StringRedisTemplate 继承了 RedisTemplate 有效解决了这个问题,但只能存放<String,String>
  • 综上,我们在使用 Redis 缓存技术时,可以自己自定义(封装一个)RedisTemplate
  • 自定义 RedisTemplate<String, Object> (config/RedisTemplateConfig)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class RedisTemplateConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
// 1.创建RedisTemplate对象
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
// 2.设置连接工厂
redisTemplate.setConnectionFactory(connectionFactory);
// 3.设置Key的序列化
redisTemplate.setKeySerializer(RedisSerializer.string());
// 4.创建JSON序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
// 5.设置value的序列化
redisTemplate.setValueSerializer(jsonRedisSerializer);
return redisTemplate;
}
}
  • 解决 RedisTemplate 存入的序列化后的 value 值成为乱码的问题(2023/08/07 早)

接入阿里云对象存储服务

自定义 Banner

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
启动成功!
${AnsiColor.BRIGHT_GREEN}
*
( `
)\))( ( ) ( (
((_)()\ ))\ ( ( )( )\ )
(_()((_)/((_) )\ ' )\(()\(()/(
| \/ (_)) _((_)) ((_)((_))(_))
| |\/| / -_) ' \() _ \ '_| || |
|_| |_\___|_|_|_|\___/_| \_, |
|__/
${AnsiColor.BRIGHT_WHITE}
欢迎使用~
spring boot 版本为 ${spring-boot.version}
作者:@Memory
项目名:PicMemories
线上访问地址: 未完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.boot.Banner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class LaunchApplication {

public static void main(String[] args) {
SpringApplication app = new SpringApplication(LaunchApplication.class);
app.setBannerMode(Banner.Mode.OFF); // 设置 Banner 模式为关闭
app.run(args);
}

}

主键自增长

  • 今天使用 MybatisPlus 执行插入数据时,发现了这样的报错:(2023/08/15 午)
1
2
3
4
5
6
7
8
org.springframework.jdbc.BadSqlGrammarException:
### Error updating database. Cause: java.sql.SQLSyntaxErrorException: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'like ( id,
user_id,
wallpaper_id ) VALUES ( 1691349539878477825,
168799521293' at line 1
### The error may exist in com/memory/picmemories/mapper/LikeMapper.java (best guess)
### The error may involve com.memory.picmemories.mapper.LikeMapper.insert-Inline
### The error occurred while setting parameters
  • 在上面的栏目《表记录/实体类 ID 自增长》中,已经实现了如何使记录中的 id 自增长,这次我就犯了这个错误:
1
2
3
4
5
6
7
8
9
10
create table `like`
(
id bigint primary key comment '点赞id',
user_id bigint not null comment '点赞用户id',
wallpaper_id bigint not null comment '被点赞的壁纸id',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null comment '更新时间',
is_delete varchar(256) default '0' not null comment '是否删除'
)
comment '点赞信息';
1
2
3
4
5
/**
* 点赞id
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
1
2
3
4
5
6
// 4.新增记录
Like like = new Like();
like.setUserId(userId);
like.setWallpaperId(wallpaperId);

boolean save = likeService.save(like);
  • 如上,很显然,我没有将 id 字段设置为自增长,所以才会出现这样的报错(2023/08/15 午)

修改表字段后要做的那些事

  • 修改表字段后,应该做哪些事?

    • 修改对应实体属性
    • 修改 XXXmapper.xml 文件(mapper 路径、domain 路径)
  • 使用插件 MybatisX-Generator 快速生成 mapper、domain、service(2023/08/16 午)

YAML 配置重复键

  • 启动项目报了这样的错误:(2023/08/23 晚)

image-20230823164029471

  • 这个错误通常是由于在 YAML 配置文件中多次定义了相同的键,检查 yaml 配置即可

git 合并提交

  • 在上午,我使用 git 进行合并提交项目代码后,下午运行项目,代码变成了这样:

image-20230826182411757

  • 没什么大问题,这是因为:
  • 你使用 git 合并推送项目代码后遇到了冲突(conflict),冲突的部分被 Git 标记为<<<<<<< HEAD, =======和>>>>>>>。
  • 这是因为在合并时,Git 无法确定如何自动合并这些不同版本的代码。2023/08/26 晚)
  • 为解决冲突,你需要手动编辑冲突的文件,然后再次提交。以下是我采取的一些步骤:

    • 打开标记有冲突的文件,找到<<<<<<< HEAD, =======和>>>>>>>之间的冲突代码段。
    • 理解每个版本的更改,并决定要保留哪些部分。可以选择保留某个版本的代码,或者进行适当的修改以使两个版本的更改合并。
    • 对冲突的代码段进行适当修改,解决冲突。
    • 删除冲突标记(<<<<<<< HEAD, =======和>>>>>>>)。
    • 完成修改后,保存文件。
  • 完成(2023/08/26 午)

Spring 项目起不来

  • 这个栏目标题起的有点怪啊,不过确实是这么回事:(2023/08/29 早)
  • 今早计划测试一把 Spring 的定时任务功能,结果构建完成一个 SpringBoot 项目之后,项目却启动不起来:

image-20230829113246414

  • 准确的说,项目没有启动成为一个 Web 服务器后台,这是为什么呢?
  • 妈的,原来是构建项目时,忘记导入相关依赖了:

image-20230829112442434

  • 即:
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
  • 呐,导入以上依赖后,SpringBoot 成功运行起来了:(2023/08/29 早)

image-20230829112724037

  • 真是学傻了,这么基础的问题,平时竟然没注意到,呵呵呵

  • 顺带提一下,SpringBoot 项目构建完成后,默认导入以下两个依赖:
1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

Spring 项目起不来(2)

  • 这次是什么原因呢?看报错:(2023/09/06)

image-20230906172719883

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
***************************
APPLICATION FAILED TO START
***************************

Description:

Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.

Reason: Failed to determine a suitable driver class

Action:

Consider the following:
If you want an embedded database (H2, HSQL or Derby), please put it on the classpath.
If you have database settings to be loaded from a particular profile you may need to activate it (no profiles are currently active).

Process finished with exit code 1
  • 这个就很清晰了,因为引入了数据库相关依赖,却没有作相关配置:
1
2
3
4
5
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
1
2
3
4
5
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.31</version>
</dependency>
1
2
3
4
5
6
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/memory_api
username: root
password: Dw990831

mapper 注入为 null

  • 从来没有踩过这个坑,这次让我记忆深刻了(2023/09/07 晚)
  • 如图,我注入了 interfaceInfoMapper

image-20230907214553078

  • 但项目运行后,通过 debug 发现,该 interfaceInfoMapper 为 null,为什么注入不成功呢?我犯了一个错误:

image-20230907214839239

  • 如上,我 new 了一个 interfaceIdSource,这个问题被我忽视了:
1
类的实例化问题:确保你在使用 InterfaceIdSource 类时是通过 Spring 容器来获取实例,而不是通过 new 关键字手动创建对象。只有通过 Spring 容器管理的对象才会进行自动注入。
  • 直接注入即 interfaceIdSource 可解决问题:(2023/09/07 晚)

image-20230907215126113

MybatisPlus 踩坑记录

  • 我在根据 id 查询用户时,写了如下代码:
1
2
3
4
5
6
for (Friends friends : friendsList) {
Long friendId = friends.getFriendId();
uqw.eq("id", friendId);
User one = userService.getOne(uqw);
userList.add(one);
}
  • 结果,查出来的 one 对象均为 null,可能是因为 id 设置为了主键

  • 我们改写为 MybatisPlus 提供的的根据 id 查询方法,成功解决问题(2023/09/12 午)
1
2
3
4
5
6
// 2.根据id查询道好友信息
for (Friends friends : friendsList) {
Long friendId = friends.getFriendId();
User one = userService.getById(friendId);
userList.add(one);
}

日常犯傻

  • 使用 Vue 的 ref() 语法时,容易忘记取.value
1
const currentUserId = currentUser.value.id;
  • 访问后端服务器路径,容易忘记写/api
1
const socketUrl = `ws://localhost:8081/api/websocket/${currentUserId}`;

导入 Excel 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
File file = null;
try {
file = ResourceUtils.getFile("classpath:test_excel.xlsx");
} catch (FileNotFoundException e) {
e.printStackTrace();
}
// 读取数据
List<Map<Integer, String>> list = null;

list = EasyExcel.read(file)
.excelType(ExcelTypeEnum.XLSX)
.sheet()
.headRowNumber(0)
.doReadSync();

if (CollUtil.isEmpty(list)) {
return "";
}
  • 这部分代码,简单地读取了 resourse 目录下test_excel.xlsx 文件,并成功获取表格数据
  • 附上原表格数据和解析效果:

image-20231005162100413


image-20231005162143753

  • 修改代码,接收上传的文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 智能分析
*
* @param multipartFile Excel文件
* @param genChartByAiRequest 指定图表信息
* @param request request
* @return 生成的图表信息
*/
@PostMapping("/generate")
public BaseResponse<String> uploadFile(@RequestPart("file") MultipartFile multipartFile,
GenChartByAiRequest genChartByAiRequest, HttpServletRequest request) {
String name = genChartByAiRequest.getName();
String goal = genChartByAiRequest.getGoal();
String chartType = genChartByAiRequest.getChartType();

// 校验
ThrowUtils.throwIf(StringUtils.isBlank(goal), ErrorCode.PARAMS_ERROR, "目标为空");
ThrowUtils.throwIf(StringUtils.isNotBlank(name) && name.length() > 100, ErrorCode.PARAMS_ERROR, "名称过长");

String result = ExcelUtils.excelToCsv(multipartFile);
return ResultUtils.success(result);
  • 逐行解析 Excle 表格,获取数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* excel 转 csv
*
* @param multipartFile
* @return
*/
public static String excelToCsv(MultipartFile multipartFile) {
File file = null;
try {
file = ResourceUtils.getFile("classpath:test_excel.xlsx");
} catch (FileNotFoundException e) {
e.printStackTrace();
}
// 读取数据
List<Map<Integer, String>> list = null;
list = EasyExcel.read(file)
.excelType(ExcelTypeEnum.XLSX)
.sheet()
.headRowNumber(0)
.doReadSync();
if (CollUtil.isEmpty(list)) {
return "";
}
// 转换为 csv
StringBuilder stringBuilder = new StringBuilder();
// 读取表头
LinkedHashMap<Integer, String> headerMap = (LinkedHashMap) list.get(0);
List<String> headerList = headerMap.values().stream().filter(ObjectUtils::isNotEmpty).collect(Collectors.toList());
stringBuilder.append(StringUtils.join(headerList, ",")).append("\n");
// 读取数据
for (int i = 1; i < list.size(); i++) {
LinkedHashMap<Integer, String> dataMap = (LinkedHashMap) list.get(i);
List<String> dataList = dataMap.values().stream().filter(ObjectUtils::isNotEmpty).collect(Collectors.toList());
stringBuilder.append(StringUtils.join(dataList, ",")).append("\n");
}
return stringBuilder.toString();
}
  • ,我们在接口文档中,上传 Excel 文件:

image-20231005161702354

  • 成功解析获取数据并返回:

image-20231005161948245

校验文件

  • 只要涉及到用户自主上传操作,一定要校验文件(图像)

  • 校验:

    • 文件大小
    • 文件后缀
    • 文件内容(成本略高)
    • 文件合规(敏感内容)
  • 校验文件大小和文件后缀
1
2
3
4
5
6
7
8
9
10
/**
* 允许上传的文件大小
*/
long ONE_MB = 1024 * 1024L;


/**
* 合法的文件后缀
*/
List<String> VALID_FILE_SUFFIX_LIST = Arrays.asList("xlsx", "xls");
1
2
3
4
5
6
7
8
9
// 3.1.校验文件
// 3.1.1.校验文件大小
long size = multipartFile.getSize();
ThrowUtils.throwIf(size > ONE_MB, ErrorCode.PARAMS_ERROR, "文件超过 1M");

// 3.1.2.校验文件后缀
String originalFilename = multipartFile.getOriginalFilename();
String suffix = FileUtil.getSuffix(originalFilename);
ThrowUtils.throwIf(!VALID_FILE_SUFFIX_LIST.contains(suffix), ErrorCode.PARAMS_ERROR, "文件后缀非法");

限流

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.21.0</version>
</dependency>
  • 做好相关配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
@ConfigurationProperties(prefix = "spring.redis")
@Data
public class RedissionConfig {
private String host;

private String port;

private String password;

private Integer database;

@Bean
public RedissonClient redissonClient() {
// 1. 创建配置
Config config = new Config();
String redisAddress = String.format("redis://%s:%s", host, port);
// 使用单个Redis,没有开集群 useClusterServers() 设置地址和使用库
config.useSingleServer().setAddress(redisAddress).setDatabase(database).setPassword(password);
// 2. 创建实例
return Redisson.create(config);
}
}
1
2
3
4
redis:
port: 6379
host: localhost
database: 1
  • 限流实现(区别不同的限流器,每个用户都分别拥有对应的限流器)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 限流实现
*
* @param key 识别用户的key
*/
public void doRateLimit(String key) {
// 创建限流器
RRateLimiter rateLimiter = redissonClient.getRateLimiter(key);
rateLimiter.trySetRate(RateType.OVERALL, 2, 1, RateIntervalUnit.SECONDS);
// 每当一个操作来了之后,请求一个令牌
boolean canOp = rateLimiter.tryAcquire(1);
// 超出发放令牌数目,请求过于频繁
ThrowUtils.throwIf(!canOp, ErrorCode.TOO_MANY_REQUEST);
}
  • 简单测试,效果良好,测试结果如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
void doRateLimit() throws InterruptedException {
String userId = "1";
for (int i = 0; i < 2; i++) {
redisLimiterManager.doRateLimit(userId);
System.out.println("成功");
}
Thread.sleep(1000);
for (int i = 0; i < 5; i++) {
redisLimiterManager.doRateLimit(userId);
System.out.println("成功");
}
}

image-20231011180516219

  • 业务中使用限流器,调用智能分析接口时,限制用户在单位时间内的调用次数 (2023/10/11 晚)
1
2
// 限流(限制用户的调用次数,以用户id为key,区分各个限流器)
redisLimiterManager.doRateLimit("genCharByAi_" + loginUser.getId());

Slf4j 导入依赖错误

  • 这是个很有意思的问题,起因是我计划实现一个延迟队列 Demo,结果在运行时出现了这样的报错:

image-20231019123726686

  • 这些报错是什么意思呢?简单解释一下:

这些警告信息是由 SLF4J(Simple Logging Facade for Java)引发的,它是一个为 Java 程序提供日志功能的简单抽象层。

警告 No SLF4J providers were found 表示在类路径上未找到任何 SLF4J 提供者。这意味着没有配置合适的日志库实现,导致程序在运行时无法正常输出日志。

警告 Defaulting to no-operation (NOP) logger implementation 表示在缺少真正的日志库实现时,SLF4J 默认使用了一个空操作的日志记录器实现。这意味着代码在运行时不会输出任何日志。

警告 Class path contains SLF4J bindings targeting slf4j-api versions 1.7.x or earlier 表示在类路径上发现了与 SLF4J 绑定,但它们是针对 1.7.x 版本或更早版本的 slf4j-api。这可能导致不兼容或出现日志记录问题。

警告 Ignoring binding found at [jar:file:/D:/softWare/Maven/apache-maven-3.6.1/mvn_resp/ch/qos/logback/logback-classic/1.2.12/logback-classic-1.2.12.jar!/org/slf4j/impl/StaticLoggerBinder.class] 表示 SLF4J 发现了一个冲突的绑定,将被忽略。这意味着在类路径上有多个不同版本的 SLF4J 绑定或日志库实现。

解决这些警告的方法是确保正确配置了 SLF4J 绑定和日志库实现,以及它们的版本兼容性。

  • 同时我也受到了 AI 的点拨,可以从以下几个方面排查问题:
1
2
3
4
5
1、确保在项目的依赖管理中添加了正确的 SLF4J 绑定和日志库实现。
2、确保依赖的版本与所使用的 SLF4J 版本兼容。推荐使用 SLF4J 版本 1.7.x 或更高版本。
3、检查项目的构建路径,确保没有冲突的 SLF4J 绑定存在于类路径上。
4、如果问题仍然存在,可以尝试在项目中排除掉冲突的 SLF4J 绑定,或通过 Maven 或 Gradle 等构建工具来管理依赖关系。
5、如果仍然无法解决问题,可以参考 SLF4J 的文档和常见问题页面(https://www.slf4j.org/codes.html)寻找更详细的解决方案。
  • 我检查了引入的相关依赖,发现除了 lombok 依赖外,还引入了一个 slf4j-api 依赖:
1
2
3
4
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
1
2
3
4
5
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.5</version>
</dependency>
  • 这就是问题所在了,删除这个依赖即可解决问题,程序成功运行:(2023/10/19 早)

image-20231019124205016

实现 updateTime 字段自动更新

  • 什么意思呢?我们希望在修改完数据库表中的记录后,该条记录对应的 uodateTime 字段实现自动更新
  • 实现方法很简单,在 IDEA 中,直接修改表的 updateTime 字段属性,如下:

image-20231107224250849

  • 对应的 DDL 语句是这样的:
1
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
  • 这样,我们更新记录后,该记录 updateTime 字段会自动更新为最近修改时间(2023/11/07 晚)

image-20231107224550821

实体类未序列化

  • 我们在后端写接口时,会这样编写接收的参数:(2023/11/20 晚)
1
2
3
4
5
6
7
8
@PostMapping("/list/page/vo")
public BaseResponse<Page<Picture>> listPictureByPage(@RequestBody Picture picture) throws IOException {
// controller层对参数的校验
String category = picture.getCategory();

Page<Picture> picturePage = pictureService.listPictureVOByPage(category);
return ResultUtils.success(picturePage);
}
  • 当然,要理解这种接收参数的编写语法,需要系统学习 SpringMVC 的相关内容,这里只谈一点:

    Picture 要支持序列化才能实现网络传输
  • 妈的,所以才会出现这样的报错:

1
2
om.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `memory.cloud.memoryclient.model.domain.Picture` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream); line: 1, column: 2]
  • 排了一个小时的错,可算发现了:我编写的 Picture 不支持序列化,改写成如下这样即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
public class Picture implements Serializable {
public Picture() {
}

/**
* 所属分类
*/
private String category;

/**
* 图片名
*/
private String title;

/**
* 图片路径
*/
private String url;

private static final long serialVersionUID = 1L;
}
  • 平时这种实体类都是使用 MybatisX-Generator 快速生成的,没注意到实体类要支持序列化 (2023/11/20 晚)

存储数据库编码错误

  • 终于解决了如何正确保存含 emoji 表情数据到数据库中的问题了
  • 直接保存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
 // 1. 获取数据
String url = "https://juejin.cn/post/7313418992310976549";
try {
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.81")
.get();


Elements title = doc.select(".article-area .article-title");
System.out.println("----------------博文标题----------------");
System.out.println(title.text());

Elements content = doc.select(".article-viewer p");
// for (Element p : content) {
// p.select("img").remove();
// p.select("a").remove();
// }

System.out.println("---------------博文正文------------------");
// System.out.println(content);

byte[] contentBytes = content.toString().getBytes(StandardCharsets.UTF_8);

System.out.println(contentBytes);
// 获取博文
Article article = new Article();
article.setId(Long.valueOf("7313418992310976549"));
article.setTitle(title.text());

// 直接保存
article.setContent(content.toString());

// article.setContent(Arrays.toString(contentBytes));
// article.setContent(content.toString());
article.setAuthorId(0L);
article.setView(0);
article.setLikes(0);
article.setComments("");
article.setCollects(0);
article.setTags("");

// articleService.save(article);

String decodedContent = new String(contentBytes, StandardCharsets.UTF_8);
System.out.println("-------------解码后--------------");
System.out.println(decodedContent);

} catch (IOException e) {
throw new RuntimeException(e);
}
}
article.setContent(contentBytes);

  • 这里直接保存,会出现字符编码无法识别而转换错误,就是因为保存的数据记录中有 emoji 这样的小图标
  • 这里我也查询了相关文章,解决这个问题,虽然最后没有解决,但仍可做参考:

🔥 推荐阅读:

1
Error updating database.  Cause: java.sql.SQLException: Incorrect string value: '\xF0\x9F\x8D\x84 \xE5...' for column 'content' at row 1
  • 经过测试,转码保存解决报错:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Document doc = Jsoup.connect(url)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.81")
.get();


Elements title = doc.select(".article-area .article-title");
System.out.println("----------------博文标题----------------");
System.out.println(title.text());

Elements content = doc.select(".article-viewer p");
// for (Element p : content) {
// p.select("img").remove();
// p.select("a").remove();
// }

System.out.println("---------------博文正文------------------");
// System.out.println(content);

byte[] contentBytes = content.toString().getBytes(StandardCharsets.UTF_8);
1
2
3
String decodedContent = new String(contentBytes, StandardCharsets.UTF_8);
System.out.println("-------------解码后--------------");
System.out.println(decodedContent);

image-20231224104703715

  • 保存到数据库中的问题解决了,接下来就是保证正确从数据库中拿到数据并解码出原数据:
1
2
String contentStr = article.getContent();
byte[] contentBytes = contentStr.getBytes(StandardCharsets.UTF_8);
1
2
3
4
5
6
7
8
9
// 移除两端的方括号
String contentStrWithoutBrackets = contentStr.substring(1, contentStr.length() - 1);
// 使用逗号作为分隔符分割字符串
String[] byteStrs = contentStrWithoutBrackets.split(",");
// 将字符串数组转换为字节数组
byte[] contentBytes = new byte[byteStrs.length];
for (int i = 0; i < byteStrs.length; i++) {
contentBytes[i] = Byte.parseByte(byteStrs[i]);
}
  • 经过诸多尝试,仍无法正确解码
  • 经过前面的测试发现,转码后保存 byte [] 可以解决编码错误,问题是出在保存数据库时
  • 由于字段 content 为 text(varchar 也可以,可能会出现要保存的数据记录过长而导致溢出,就选择 text 了),所以我们在保存 byte [] 到数据库中时,是先转换成字符串再保存的
1
article.setContent(Arrays.toString(contentBytes));
  • 而后才引发了解码失败的问题,因为对 byte [] 直接解码是可以获取原文内容 content 的,但是先转字符串存入,取出时就不好处理了
  • 那就干脆直接保存 byte [] 到数据库中了,改变字段 content 属性为 blob:

image-20231224104321929

  • 改变对应实体类的字段数据类型为 byte []:
1
2
3
4
/**
* 文章内容
*/
private byte[] content;
  • 接下来,我们选择直接保存 byte [] 到数据库中即可:
1
article.setContent(contentBytes);
  • 这里也可以看出,将 byte [] 转字符串数组后保存和直接保存 byte [] 到数据库中的形式是很不一样的(如下图所示):

image-20231224104532681

  • 接下来,就可以直接从数据库中取出数据并解码了:
1
2
3
4
5
6
7
8
9
10
11
12
13
Article article = articleService.getById(7313418992310976549L);

Long id = article.getId();
String title = article.getTitle();
byte[] content = article.getContent();

String decodedContent = new String(content, StandardCharsets.UTF_8);
Integer type = article.getType();

System.out.println(id);
System.out.println(title);
System.out.println(decodedContent);
System.out.println(type);
  • 解码结果如下:

image-20231224104725599

  • 至此,我们成功解决了如何正确保存数据记录到数据库中的问题,并成功解决了编码问题

汉字转拼音

1
2
3
4
Random random = new Random();
int randomPage = random.nextInt(5) + 1;
String url = String.format("https://www.vcg.com/creative-image/%s/?page=%d", category, randomPage);
String userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.81";
  • 拼接 category ,只要是中文字符就不定时出现报错,尝试将中文转为拼音:
  • 导入依赖:(2024/01/18 晚)
1
2
3
4
5
<dependency>
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId>
<version>2.5.1</version> <!-- 使用时检查是否有更新的版本 -->
</dependency>
  • 开始转换:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 带声调
StringBuilder output = new StringBuilder();
for (char c : name.toCharArray()) {
try {
String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(c);
if (pinyinArray != null && pinyinArray.length > 0) {
output.append(pinyinArray[0]).append(" ");
} else {
output.append(c).append(" ");
}
} catch (Exception e) {
output.append(c).append(" ");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 不带声调
net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat format = new net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat();
format.setToneType(net.sourceforge.pinyin4j.format.HanyuPinyinToneType.WITHOUT_TONE);

StringBuilder output = new StringBuilder();
for (char c : name.toCharArray()) {
try {
String[] pinyinArray = PinyinHelper.toHanyuPinyinStringArray(c, format);
if (pinyinArray != null && pinyinArray.length > 0) {
output.append(pinyinArray[0]).append("");
} else {
output.append(c);
}
} catch (Exception e) {
output.append(c).append("");
}
}

无法加载 Spring 的配置文件

启动测试类,发现这样的报错:(2024/02/13 早)

image-20240213094455214

在 SpringBoot 中报错 java.lang.IllegalStateException: Failed to load ApplicationContext,无法加载 Spring 的配置文件

出现这种问题,无外于这几个方面:jar 包过于老旧、@Bean 注入有分歧、配置文件中的数据库连接失败、未扫描到 Mapper

1、jar 包有问题或者过老,换最新的试试。
2、 内部 Bean 配置有歧义,Spring 自身无法分辨
3、缺少某个依赖、或属性的配置
4、引用 外部属性文件的情况下,属性文件内有错误,无法加载。比如属性文件配置的数据库连接 有问题
5、在使用到 applicationContext 的 地方引用的路径不正确。

🍖 推荐阅读: java.lang.IllegalStateException: Failed to load ApplicationContext-CSDN 博客

我的问题解决了,确实是配置文件中数据库连接有问题。我这个项目配置了本地 Elasticsearch 的,启动 本地 ES 就好了

1
2
3
4
5
# Elasticsearch 配置
elasticsearch:
uris: http://localhost:9200
username: root
password: ******

@ControllerAdvice 拦截

2024 年 4 月 25 日

🍖 推荐阅读:@ControllerAdvice 的介绍及三种用法(转载)-CSDN 博客

在看单位的后端开发代码模板,看到了这个类:

这段代码是一个名为 ApiResultHandler 的类,它实现了 Spring 框架中的 ResponseBodyAdvice 接口。这个类的主要作用是对 RestController 的接口方法进行拦截,对返回的结果进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
@ControllerAdvice(annotations = {RestController.class})
public class ApiResultHandler implements ResponseBodyAdvice {

private static final Class[] annos = {
RequestMapping.class,
GetMapping.class,
PostMapping.class,
DeleteMapping.class,
PutMapping.class
};

...............................
}

首先,定义了一个静态数组 annos,包含了 RequestMapping、GetMapping、PostMapping、DeleteMapping 和 PutMapping 这五个注解,用于判断一个方法是否使用了这些注解。

1
2
3
4
5
6
7
8
/**
* 对所有RestController的接口方法进行拦截
*/
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
AnnotatedElement element = returnType.getAnnotatedElement();
return Arrays.stream(annos).anyMatch(anno -> anno.isAnnotation() && element.isAnnotationPresent(anno));
}

重写了 supports 方法,用于判断当前拦截的方法是否使用了上述五个注解之一。如果使用了,返回 true,表示需要拦截;否则返回 false,表示不需要拦截。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@Override
public Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {

if(body instanceof RestfulResponse){
return body;
}

//feign内部请求,不转格式
HttpHeaders headers = request.getHeaders();
List<String> list = headers.get(FeginClientConfig.KEEP_ORIGINAL);
if(list != null && list.contains(FeginClientConfig.KEEP_ORIGINAL)) {
return body;
}

if(body instanceof ResultCode) {
return new RestfulResponse(((ResultCode) body).getCode(), ((ResultCode) body).getDesc());
}

AnnotatedElement element = returnType.getAnnotatedElement();
if(element.isAnnotationPresent(SuccessfulMessage.class)) {
boolean ignore = element.getAnnotation(SuccessfulMessage.class).ignore();
if(ignore) {
return body;
}
}

String msg = null;

//1. 配置文件优先级最高
//2. 方法上的 SuccessfulMessage
//3. 默认
// RequestMapping 成功
// GetMapping 查询成功
// PostMapping 添加成功
// DeleteMapping 删除成功
// PutMapping 修改成功

if(element.isAnnotationPresent(SuccessfulMessage.class)) {
/*String value = element.getAnnotation(SuccessfulMessage.class).value();
//解析EL ${}
if(value != null) {
value = value.trim();
if(value.startsWith("${") && value.endsWith("}")) {
value = value.substring(2, value.length()-1);
String[] separator = StringUtils.splitByWholeSeparator(value, ":");
String key = separator[0];
String defaultValue = separator.length > 1 ? separator[1] : null;
//查询配置文件
msg = env.getProperty(key, defaultValue);
} else {
msg = value;
}
}*/
msg = element.getAnnotation(SuccessfulMessage.class).value();
}

if(msg == null) {
if(element.isAnnotationPresent(GetMapping.class)) {
msg = "操作成功";
} else if(element.isAnnotationPresent(PostMapping.class)) {
msg = "操作成功";
} else if(element.isAnnotationPresent(PutMapping.class)) {
msg = "修改成功";
} else if(element.isAnnotationPresent(DeleteMapping.class)) {
msg = "删除成功";
} else {
msg = "请求成功";
}
}
return RestfulResponse.success(msg, body);
}

重写了 beforeBodyWrite 方法,这个方法会在 Controller 方法执行后,返回结果之前被调用。在这个方法中,对返回的结果进行了处理:

  • 如果返回的结果已经是 RestfulResponse 类型,直接返回;
  • 如果请求头中包含 FeginClientConfig.KEEP_ORIGINAL,表示是内部请求,不进行格式转换,直接返回;
  • 如果返回的结果是 ResultCode 类型,将其转换为 RestfulResponse 类型并返回;
  • 如果方法上有 SuccessfulMessage 注解,根据注解的属性决定是否忽略该次拦截;
  • 根据方法上的注解(如 GetMapping、PostMapping 等)设置默认的成功消息;
  • 最后,将成功消息和原始结果封装成 RestfulResponse 对象并返回。

定时任务实现

纯手写单线程循环

1
2
3
4
5
6
7
8
9
10
11
12
public static void timer1() {
new Thread(() -> {
while (true) {
System.out.println("定时任务A 当前时间: " + LocalDateTime.now());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}).start();
}

Timer 和他的小伙伴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void timer2() {
// 单线程
Timer timer = new Timer();
System.out.println("1秒后执行任务A,A完成后,等待1秒开始定时执行任务B,当前时间: " + LocalDateTime.now());
// 1秒后执行
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("定时任务A 当前时间: " + LocalDateTime.now());
}
}, 1000); // 这里 1000,就是代表延迟 1000 毫秒后再执行

// 每隔2秒执行一次这个任务
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("定时任务B 当前时间: " + LocalDateTime.now());
}
}, 1000, 2000); // 1000 同理,2000 即执行完本次任务后,隔 2000 毫秒后再一次执行,达到定时任务的效果
}

ScheduledExecutorService

  • 定时执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void timer4() {
ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
System.out.println("2秒后开始执行任务,此刻时间---" + LocalDateTime.now());
// 固定频率(每隔5秒)开始执行一个任务
scheduledExecutorService.scheduleAtFixedRate(() -> {
System.out.println("任务开始---" + LocalDateTime.now());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("任务结束---" + LocalDateTime.now());
}, 2000, 5000, TimeUnit.MILLISECONDS);
}
  • 延时执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void timer5() {
ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
System.out.println("2秒后开始执行任务,此刻时间---" + LocalDateTime.now());
// 任务完成后间隔4秒开始执行下一次任务
scheduledExecutorService.scheduleWithFixedDelay(() -> {
System.out.println("任务开始---" + LocalDateTime.now());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("任务结束---" + LocalDateTime.now());
}, 2000, 4000, TimeUnit.MILLISECONDS);
}

DelayQueue 延迟任务

  • DelayQueue 是 JDK 提供的 api,是一个延迟队列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
public class DelayQueueDemo {

public static void main(String[] args) {
DelayQueue<SanYouTask> sanYouTaskDelayQueue = new DelayQueue<>();

new Thread(() -> {
while (true) {
try {
SanYouTask sanYouTask = sanYouTaskDelayQueue.take();
log.info("获取到延迟任务:{}", sanYouTask.getTaskContent());
} catch (Exception e) {
}
}
}).start();

log.info("提交延迟任务");
sanYouTaskDelayQueue.offer(new SanYouTask("三友的java日记5s", 5L));
sanYouTaskDelayQueue.offer(new SanYouTask("三友的java日记3s", 3L));
sanYouTaskDelayQueue.offer(new SanYouTask("三友的java日记8s", 8L));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
public class SanYouTask implements Delayed {

private final String taskContent;

private final Long triggerTime;

public SanYouTask(String taskContent, Long delayTime) {
this.taskContent = taskContent;
this.triggerTime = System.currentTimeMillis() + delayTime * 1000;
}

@Override
public long getDelay(TimeUnit unit) {
return unit.convert(triggerTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}

@Override
public int compareTo(Delayed o) {
return this.triggerTime.compareTo(((SanYouTask) o).triggerTime);
}
}
  • getDelay方法返回这个任务还剩多久时间可以执行,小于 0 的时候说明可以这个延迟任务到了执行的时间了。
  • compareTo这个是对任务排序的,保证最先到延迟时间的任务排到队列的头。
    • taskContent:延迟任务的具体的内容
    • delayTime:延迟时间,秒为单位
实现原理

🍻 offer方法在提交任务的时候,会通过根据compareTo的实现对任务进行排序,将最先需要被执行的任务放到队列头。

🍛take方法获取任务的时候,会拿到队列头部的元素,也就是队列中最早需要被执行的任务,通过 getDelay 返回值判断任务是否需要被立刻执行,如果需要的话,就返回任务,如果不需要就会等待这个任务到延迟时间的剩余时间,当时间到了就会将任务返回。

  • 效果如下:

Spring 提供定时任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @author 邓哈哈
* 2023/8/29 11:50
* Function:
* Version 1.0
*/

@EnableScheduling
@Component
public class Timer {
@Scheduled(cron = "*/2 * * * * *")
public void timer() {
System.out.println("哈哈哈哈");
}
}
  • 如果有多个定时任务类,可以考虑把@EnableScheduling 注解添加在启动类上

Cron 表达式

image-20230829122937976

image-20230829123041111

image-20230829123201856

代码合并

2024 年 5 月 22 日

今天学习:在团队协同开发工作中,如何使用 IDEA 整合的 git 功能实现本地代码提交至远程仓库

选择要提交的文件:

image-20240522114357514

编写完毕 commit 信息后,选择好分支,拉取远程仓库最新代码:

image-20240522114503426

直接 push 即可:

image-20240522114610086

IDEA 连接 MySQL 失败

2024 年 5 月 29 日

经过排查,发现是从 maven 下载 MySQL 驱动失败了,

image-20240529080517771

只好是手动指定本地 MySQL 驱动路径,直接在本机的 maven 目录下找就可以,我的在这:

1
D:\softWare\Maven\apache-maven-3.6.1\mvn_resp\mysql

image-20240529082955156

设置下时区,有两种设置方法:

解决:Server returns invalid timezone. Need to set ‘serverTimezone‘ property._server returns invalid timezone. need to set ‘serv-CSDN 博客

image-20240529081423926

确实解决了问题:

1
2
3
4
5
6
DBMS: MySQL (ver. 5.7.19-log)
Case sensitivity: plain=lower, delimited=lower
Driver: MySQL Connector/J (ver. mysql-connector-java-8.0.21 (Revision: 33f65445a1bcc544eb0120491926484da168f199), JDBC4.2)

Ping: 33 ms
SSL: no

但是连接上了 MySQL,却不显示数据库,显示为空是什么情况。

参考:【IDEA 连接 MySQL 服务后不显示本地数据库?——这样做!】_idea 的数据库内容没办法显示-CSDN 博客

image-20240529083348441

那是因为我连接数据库时没有选定具体数据库,默认只有系统自带的。

可以如上图一样勾选,也可以在连接数据库时的配置中填充,就不会出现这样的问题:

image-20240529083514118

Swagger 文档

2024 年 5 月 29 日

1
2
3
4
5
6
spring:
server:
address: localhost
port: 8101
servlet:
context-path: /api

在项目启动入口如此编写:统计启动时间,读取配置获取 ip、端口号和路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@SpringBootApplication(exclude = {RedisAutoConfiguration.class})
@MapperScan("com.yupi.springbootinit.mapper")
@EnableScheduling
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true)
@Slf4j
public class MainApplication {

public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(MainApplication.class, args);
long start = System.currentTimeMillis();


long time = (System.currentTimeMillis() - start) / 1000;
String info = "启动完成,耗时%d秒,swagger访问链接:http://%s:%s%s/doc.html";
String address = ctx.getEnvironment().getProperty("server.address");
String port = ctx.getEnvironment().getProperty("server.port");
String path = ctx.getEnvironment().getProperty("server.servlet.context-path");
log.info(String.format(info, time, address, port, path));
}

}

image-20240529090430264

这样启动项目后,很便捷地就能访问到 swagger 接口文档了:

image-20240529090523288

2024 年 8 月 31 日

image-20240831160000499

这个错误是由于Spring Data Redis在尝试从Redis中反序列化存储的对象时遇到了问题。具体来说,错误提示中提到了java.lang.ClassNotFoundException: com.memory.spring_0829.model.entity.User,这意味着在尝试加载并实例化User类时,JVM在类路径中找不到这个类。

这个问题通常有几个可能的原因:

  1. 类路径不一致:存储到Redis中的对象是在一个类路径配置下序列化的,但是在尝试反序列化时,应用的类路径配置可能已经改变,导致JVM无法找到User类。
  2. 部署问题:如果你是在开发环境和生产环境之间迁移应用,确保所有必要的类和资源都正确地打包并部署到了生产环境。
  3. 序列化/反序列化配置:你可能在序列化对象时使用了Java的默认序列化机制(如JdkSerializationRedisSerializer),但是User类可能不再存在于反序列化时的环境中,或者类的定义已经改变(比如类的包名或路径被修改了)。

YAML 格式有误

2024 年 6 月 1 日

image-20240603100138051

image-20240607082241950

今天上午遇到的报错:

1
2
09:56:32.110 [restartedMain] ERROR org.springframework.boot.SpringApplication - Application run failed
org.yaml.snakeyaml.error.YAMLException: java.nio.charset.MalformedInputException: Input length = 1

这个错误日志显示你的 Spring Boot 应用程序在尝试启动时遇到了问题,特别是在解析 YAML 配置文件时。错误 org.yaml.snakeyaml.error.YAMLException: java.nio.charset.MalformedInputException: Input length = 1 通常意味着 YAML 解析器在读取配置文件时遇到了字符编码问题。

具体来说,MalformedInputException 指的是输入数据(在这里是 YAML 配置文件)与期望的字符编码格式不匹配。这可能是因为:

  1. YAML 文件可能包含无法用当前字符集(通常是 UTF-8)解码的字符。
  2. YAML 文件可能已被保存为不同的字符编码格式(如 ISO-8859-1 或其他)。
  3. 在读取或处理 YAML 文件时,可能指定了错误的字符编码。

最后发现是因为不小心缩进了一下 application.yaml 配置文件,改回来就好了。

Spring Cloud 依赖版本

2024 年 6 月 7 日

🍖 推荐阅读:版本发布说明 | https://sca.aliyun.com

Nacos 未配置

2024 年 6 月 7 日

image-20240607082241950

这个问题真的恶心,一直提醒我 yaml 配置文件有误,也不知道是格式有误还是少配置了些东西:

image-20240607083651186

我把配置文件删得只剩端口号了,它才提示我检测到 nacos 依赖,却没有扫描到相关配置。

1
2
3
Add a spring.config.import=nacos: property to your configuration.
If configuration is not required add spring.config.import=optional:nacos: instead.
To disable this check, set spring.cloud.nacos.config.import-check.enabled=false.

要么配置不检测 nacos 相关配置,要么显示配置 nacos。

妈的就不能直接提示么。

还有一个问题,坑死我了。

yaml 配置文件里最好不要写中文注释,否则也会不停地提示你配置文件格式有误,你还找不到哪里有问题。

找不着主类

2024 年 6 月 7 日

image-20240607083156819

怪了,我好像也没怎么改配置吧,突然就行了:

image-20240607110520450

依赖配置心得

2024 年 6 月 7 日

  1. 有些公共依赖不要搞在父项目中,比如 Swagger、MybatisPlus 这些
  2. 微服务项目一定要注意版本兼容,Spring Boot,Spring Cloud Alibaba,Spring Cloud 这些。

日志输出

2024 年 6 月 7 日

1
2
3
4
5
6
7
logging:
level:
root: INFO
org:
springframework: INFO
springframework.web: INFO
springframework.boot.autoconfigure: WARN

搞这么个配置,启动项目后的日志打印就清晰多了:

image-20240607101425070

编码格式

2024 年 8 月 6 日

配置文件里有中文字符出现编码格式错误导致项目起不来,这报错我受够了,今天解决:

image-20240806172106827

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
17:15:56.384 [Thread-0] DEBUG org.springframework.boot.devtools.restart.classloader.RestartClassLoader - Created RestartClassLoader org.springframework.boot.devtools.restart.classloader.RestartClassLoader@47e2d5ed
17:15:56.715 [restartedMain] ERROR org.springframework.boot.SpringApplication - Application run failed
org.yaml.snakeyaml.error.YAMLException: java.nio.charset.MalformedInputException: Input length = 1
at org.yaml.snakeyaml.reader.StreamReader.update(StreamReader.java:218)
at org.yaml.snakeyaml.reader.StreamReader.ensureEnoughData(StreamReader.java:176)
at org.yaml.snakeyaml.reader.StreamReader.ensureEnoughData(StreamReader.java:171)
at org.yaml.snakeyaml.reader.StreamReader.peek(StreamReader.java:126)
at org.yaml.snakeyaml.scanner.ScannerImpl.scanToNextToken(ScannerImpl.java:1218)
at org.yaml.snakeyaml.scanner.ScannerImpl.fetchMoreTokens(ScannerImpl.java:329)
at org.yaml.snakeyaml.scanner.ScannerImpl.checkToken(ScannerImpl.java:251)
at org.yaml.snakeyaml.parser.ParserImpl$ParseImplicitDocumentStart.produce(ParserImpl.java:214)
at org.yaml.snakeyaml.parser.ParserImpl.peekEvent(ParserImpl.java:166)
at org.yaml.snakeyaml.parser.ParserImpl.checkEvent(ParserImpl.java:156)
at org.yaml.snakeyaml.composer.Composer.checkNode(Composer.java:93)
at org.yaml.snakeyaml.constructor.BaseConstructor.checkData(BaseConstructor.java:124)
at org.yaml.snakeyaml.Yaml$1.hasNext(Yaml.java:509)
at org.springframework.beans.factory.config.YamlProcessor.process(YamlProcessor.java:198)
at org.springframework.beans.factory.config.YamlProcessor.process(YamlProcessor.java:166)
at org.springframework.boot.env.OriginTrackedYamlLoader.load(OriginTrackedYamlLoader.java:84)
at org.springframework.boot.env.YamlPropertySourceLoader.load(YamlPropertySourceLoader.java:50)
at org.springframework.boot.context.config.StandardConfigDataLoader.load(StandardConfigDataLoader.java:54)
at org.springframework.boot.context.config.StandardConfigDataLoader.load(StandardConfigDataLoader.java:36)
at org.springframework.boot.context.config.ConfigDataLoaders.load(ConfigDataLoaders.java:107)
at org.springframework.boot.context.config.ConfigDataImporter.load(ConfigDataImporter.java:128)
.............................................

原来是文件编码 File Encodings 下没有覆盖到 resources 路径下的文件,所以每次修改编码格式都不起作用的。

image-20240806172009453

MySQL 连接失败

2024 年 8 月 14 日

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void init() {
Properties params = new Properties();
String configFile = "src/main/resources/database.properties";
try {
FileInputStream in = new FileInputStream(configFile);
params.load(in);
} catch (IOException e) {
e.printStackTrace();
}
driver = params.getProperty("driver");
url = params.getProperty("url");
user = params.getProperty("user");
password = params.getProperty("password");
}

image-20240814145419358

1
2
3
4
5
6
7
8
9
Hello world!
java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
at java.base/java.lang.Class.forName0(Native Method)
at java.base/java.lang.Class.forName(Class.java:315)
at com.memory.util.BaseDao.getConnection(BaseDao.java:50)
at com.memory.Main.main(Main.java:20)

搭建 Spring

2024 年 8 月 19 日

如何搭建一个 Spring 项目?推荐阅读:【spring基础】如何搭建一个spring项目?-CSDN博客

今晚,我学会了如何搭建 Spring 项目,以及三种注入 Bean 的方式:基于 XML 文件,基于 XML 文件 + 注解,完全基于注解

代码已上传至:shixun_life: 实训生活,记录我的成长 (gitee.com)

基于 XML:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UserDaoImpl1 implements UserDao {
@Override
public void sayHello() {
System.out.println("你好啊!小一!");

}

@Override
public void sayHi() {
System.out.println("嗨嗨!小一!");

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UserServiceImpl implements UserService {
@Autowired
private UserDaoImpl1 userDaoImpl1;
// private UserDaoImpl2 userDaoImpl2;

@Override
public void sayHello() {
userDaoImpl1.sayHello();
// userDaoImpl2.sayHello();
}

public void setUserDaoImpl1(UserDaoImpl1 userDaoImpl1) {
this.userDaoImpl1 = userDaoImpl1;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">

<bean class="com.memory.dao.impl.UserDaoImpl1" name="userDaoImpl1"></bean><!--将UserDao1的对象配置成bean-->
<bean class="com.memory.dao.impl.UserDaoImpl2" name="userDaoImpl2"></bean><!--将UserDao2的对象配置成bean-->

<bean class="com.memory.service.impl.UserServiceImpl" name="userService"><!--将UserService的对象配置成bean-->
<property name="userDaoImpl1" ref="userDaoImpl1"></property><!--表示UserService的对象依赖于UserDao1对象,即告诉spring在UserService类中会用到UserDao1类型的成员变量,是spring注入的依据-->
</bean>

</beans>
1
2
3
4
5
6
7
@Test
public void classPathXmlTest() {
// 基于xml配置方式的创建容器的方法
ClassPathXmlApplicationContext ioc = new ClassPathXmlApplicationContext("spring.xml");// 让spring根据xml文件中的信息创建一个spring容器
UserService bean = ioc.getBean(UserServiceImpl.class);// 在容器中获取UserService类型的bean
bean.sayHello();// 调用该实例化对象的方法
}

基于 XML 文件 + 注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class UserDaoImpl1 implements UserDao {
@Override
public void sayHello() {
System.out.println("你好啊!小一!");

}

@Override
public void sayHi() {
System.out.println("嗨嗨!小一!");

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class UserServiceImpl implements UserService {
@Autowired
private UserDaoImpl1 userDaoImpl1;
// private UserDaoImpl2 userDaoImpl2;

@Override
public void sayHello() {
userDaoImpl1.sayHello();
// userDaoImpl2.sayHello();
}

public void setUserDaoImpl1(UserDaoImpl1 userDaoImpl1) {
this.userDaoImpl1 = userDaoImpl1;
}
}
1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd">


<!-- 表示扫描的范围,扫描在com.memory包内的所有带@Component注解的类,并将其对象交给spring管理-->
<context:component-scan base-package="com.memory"></context:component-scan>
</beans>

基于注解:

1
2
3
4
@Configuration
@ComponentScan("com.memory")
public class SpringConfig {
}
1
2
3
4
5
6
7
8
@Test
public void annotationConfigTest() {
//基于配置类的创建容器的方法
AnnotationConfigApplicationContext ioc = new AnnotationConfigApplicationContext(SpringConfig.class);
UserServiceImpl bean = ioc.getBean(UserServiceImpl.class);//在容器中获取UserService类型的bean
bean.sayHello();// 调用该实例化对象的方法

}

JWT 实践

2024 年 8 月 19 日

推荐阅读:什么是 JWT – JSON WEB TOKEN_jsonwebtoken-CSDN博客

这是昨天晚上看仿 B 站视频时,学习到的 JWT 在实际业务中的使用方式:

1、用户登录,以 uid 和 role 为键缓存用户信息;生成 token,以 uid 和 role 为键缓存 token 信息;返回 token 值给前端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 用户登录
* @param username 账号
* @param password 密码
* @return CustomResponse对象
*/
@Override
public CustomResponse login(String username, String password) {
CustomResponse customResponse = new CustomResponse();

//验证是否能正常登录
//将用户名和密码封装成一个类,这个类不会存明文了,将是加密后的字符串
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username, password);

// 用户名或密码错误会抛出异常
Authentication authenticate;
try {
authenticate = authenticationProvider.authenticate(authenticationToken);
} catch (Exception e) {
customResponse.setCode(403);
customResponse.setMessage("账号或密码不正确");
return customResponse;
}

//将用户取出来
UserDetailsImpl loginUser = (UserDetailsImpl) authenticate.getPrincipal();
User user = loginUser.getUser();

// 顺便更新redis中的数据
redisUtil.setExObjectValue("user:" + user.getUid(), user); // 默认存活1小时

// 检查账号状态,1 表示封禁中,不允许登录
if (user.getState() == 1) {
customResponse.setCode(403);
customResponse.setMessage("账号异常,封禁中");
return customResponse;
}

//将uid封装成一个jwttoken,同时token也会被缓存到redis中
String token = jwtUtil.createToken(user.getUid().toString(), "user");

final_map.put("token", token);
final_map.put("user", userDTO);
customResponse.setMessage("登录成功");
customResponse.setData(final_map);
return customResponse;
}

2、根据加密主体,加密算法和密钥,生成 Token,设置其他属性并封装载荷(即声明)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 生成token
* @param uid 用户id
* @param role 用户角色 user/admin
* @return token
*/
public String createToken(String uid, String role) {
// uuid 加密算法 密钥 时间戳
String uuid = getUUID();
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
SecretKey secretKey = getTokenSecret();
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
long expMillis = nowMillis + JwtUtil.JWT_TTL;
Date expDate = new Date(expMillis);

String token = Jwts.builder()
.setId(uuid) // 随机id,用于生成无规则token
.setSubject(uid) // 加密主体
.claim("role", role) // token角色参数 user/admin 用于区分普通用户和管理员
.setIssuer("https://api.teriteri.fun") // 发行方 都是用来验证token合法性的,可以不设置,
.setAudience("https://www.teriteri.fun") // 接收方 本项目也没有额外用来验证合法性的逻辑
.signWith(secretKey, signatureAlgorithm)
.setIssuedAt(now)
.setExpiration(expDate)
.compact();

try {
//缓存token信息,管理员和用户之间不要冲突
redisUtil.setExValue("token:" + role + ":" + uid, token, JwtUtil.JWT_TTL, TimeUnit.MILLISECONDS);
} catch (Exception e) {
log.error("存储redis数据异常", e);
}
return token;
}

3、生成 Token 密钥:

1
2
3
4
5
6
7
8
/**
* 获取token密钥
* @return 加密后的token密钥
*/
public static SecretKey getTokenSecret() {
byte[] encodeKey = Base64.getDecoder().decode(JwtUtil.JWT_KEY);
return new SecretKeySpec(encodeKey, 0, encodeKey.length, "HmacSHA256");
}

4、校验 Token,由传入的 Token 获取 Claim 信息,再根据 Claim 中的 uid 和 role 获取缓存的 Token,比较是否相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 校验传送来的token和缓存的token是否一致
* @param token token
* @return true/false
*/
public boolean verifyToken(String token) {
Claims claims = getAllClaimsFromToken(token);
if (null == claims) {
return false;
}
String uid = claims.getSubject();
String role;
if (claims.containsKey("role")) {
role = claims.get("role").toString();
} else {
role = "";
}
String cacheToken;
try {
cacheToken = String.valueOf(redisUtil.getValue("token:" + role + ":" + uid));
} catch (Exception e) {
cacheToken = null;
log.error("获取不到缓存的token", e);
}
return StringUtils.equals(token, cacheToken);
}

5、根据密钥和 Token 值即可获取所有 Claim 信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 获取Claims信息
* @param token token
* @return token的claims
*/
public static Claims getAllClaimsFromToken(String token) {
if (StringUtils.isEmpty(token)) {
return null;
}
Claims claims;
try {
claims = Jwts.parserBuilder()
.setSigningKey(getTokenSecret())
.build()
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException eje) {
claims = null;
// log.error("获取token信息异常,jwt已过期");
} catch (Exception e) {
claims = null;
// log.error("获取token信息失败", e);
}
return claims;
}

6、Token 验证失败,则说明 Token 已过期需重新登录;根据 uid 和 role 查询到的用户信息不匹配,则会登录失败。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
  /**
* token 认证过滤器,任何请求访问服务器都会先被这里拦截验证token合法性
* @param request
* @param response
* @param filterChain
* @throws ServletException
* @throws IOException
*/
@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");

if (!StringUtils.hasText(token) || !token.startsWith("Bearer ")) {
// 通过开放接口过滤器后,如果没有可解析的token就放行
filterChain.doFilter(request, response);
return;
}

token = token.substring(7);

// 解析token
boolean verifyToken = jwtUtil.verifyToken(token);
if (!verifyToken) {
// log.error("当前token已过期");
response.addHeader("message", "not login"); // 设置响应头信息,给前端判断用
response.setStatus(403);
// throw new AuthenticationException("当前token已过期");
return;
}
String userId = JwtUtil.getSubjectFromToken(token);
String role = JwtUtil.getClaimFromToken(token, "role");

// 从redis中获取用户信息
User user = redisUtil.getObject("security:" + role + ":" + userId, User.class);

if (user == null) {
// log.error("用户未登录");
response.addHeader("message", "not login"); // 设置响应头信息,给前端判断用
response.setStatus(403);
// throw new AuthenticationException("用户未登录");
return;
}

// 存入SecurityContextHolder,这里建议只供读取uid用,其中的状态等非静态数据可能不准,所以建议redis另外存值
UserDetailsImpl loginUser = new UserDetailsImpl(user);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);

// 放行
filterChain.doFilter(request, response);
}

8、总而言之,使用 JWT 是在校验用户身份前,先校验是否携带了正确的 Token,再校验用户身份是否存在。

构建 Spring Boot 失败

2024 年 8 月 20 日

image-20240820211756592

Initialization failed for ‘https:start.spring.io’ Please check URL, network and proxy settings. - 小洋ssr - 博客园 (cnblogs.com)

我也不知道怎么搞的,总之把那个 Server URL 改成阿里云的以后又改回来,莫名其妙就行了。

Spring Boot 初始框架

2024 年 8 月 20 日

今晚完善了自己的 Spring Boot 初始框架:memory-springboot-init: Spring Boot 初始框架 (gitee.com)

以后会慢慢学习和完善。

我想要做一个练习:前后端不分离与前后端分离的Java Web开发对比介绍_java 前后端不分离-CSDN博客

快速地构建一个 Spring Boot 项目,确保导入以下依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

做好相关配置 appication.xml:

1
2
3
4
5
6
7
spring:
application:
name: spring-demo
server:
port: 8080
thymeleaf:
cache: false

新增 Controller 层,处理 Web 请求:

1
2
3
4
5
6
7
8
@Controller
public class HelloController {
@GetMapping("/hello")
public String sayHello(Model model) {
model.addAttribute("message", "Hello, World!");
return "hello";
}
}

新增 hello.html:

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1 th:text="${message}"></h1>
</body>
</html>

最终项目结构:

image-20240820214137674

启动项目,访问:Title,得到以下结果:

image-20240820214210911

二维码生成

2024 年 8 月 27 日

使用JAVA代码实现生成二维码_java 生成二维码-CSDN博客

导入依赖:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.4.1</version> <!-- 或者更高版本 -->
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.4.1</version> <!-- 或者更高版本 -->
</dependency>

生成二维码的工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class QRCodeGenerator {
/**
* 永久二维码
* @param text
* @param width
* @param height
* @param filePath
*/
public static void generateQRCodeImage(String text, int width, int height, String filePath) {
try {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.L);
BitMatrix bitMatrix = qrCodeWriter.encode(text, BarcodeFormat.QR_CODE, width, height, hints);

BufferedImage bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
bufferedImage.createGraphics();

Graphics2D graphics = (Graphics2D) bufferedImage.getGraphics();
graphics.setColor(java.awt.Color.WHITE);
graphics.fillRect(0, 0, width, height);
graphics.setColor(java.awt.Color.BLACK);

for (int i = 0; i < width; i++) {
for (int j = 0; j < height; j++) {
if (bitMatrix.get(i, j)) {
graphics.fillRect(i, j, 1, 1);
}
}
}

ImageIO.write(bufferedImage, "png", new File(filePath));
} catch (WriterException | IOException e) {
e.printStackTrace();
}
}
}

新增一个 Controller 控制器处理请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
@RestController
@RequestMapping("/api/common")
public class CommonController {

@Value("${backend.path}")
private String path;

@GetMapping("/QRCode")
public String save() {
try {
String QR_CODE_IMAGE_PATH = path + "MyQRCode.png"; // 生成的二维码图片保存路径
String QR_CODE_TEXT = "https://baidu.com"; // 二维码内容,可以是URL等任意文本
QRCodeGenerator.generateQRCodeImage(QR_CODE_TEXT, 350, 350, QR_CODE_IMAGE_PATH);

return "创建成功";
} catch (Exception e) {
log.error(e.toString());
return "接口异常";
}
}
}

打开 Postman 或者 IDEA 自带的 http 请求调试工具,构建请求并发送:

image-20240827184928087

image-20240827184953421

如上,请求处理完成,成功生成二维码:

image-20240827185043950

手机扫码,即可跳转至百度首页:

image-20240827185117749

EasyExcel

2024 年 9 月 2 日

alibaba/easyexcel: 快速、简洁、解决大文件内存溢出的java处理Excel工具 (github.com)

读Excel | Easy Excel 官网 (alibaba.com)

一文带你彻底玩转EasyExcel:导入导出excel数据起飞_easyexcel 导入-CSDN博客

我十分看好这个博客,介绍得相当清晰条理,介绍了 Excel 提供的监听器、注解、数据导入导出以及格式转换等内容,很值得学习。

image-20240902210643568

2024 年 9 月 5 日

历时三天,总算有时间能聊聊如何使用 EasyExcel实现表格的导入导出功能,Demo 代码已经写完了,顺便把曹老板也教会了。

导出功能实现,Controller 层新增接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 导出 Excel 表格
*
* @param path
*/
@PostMapping("/excel/write")
public BaseResponse<Boolean> writeExcel(@RequestParam String path) {
// controller 层校验参数
ThrowUtils.throwIf(StringUtils.isAnyBlank(path), ErrorCode.PARAMS_ERROR);

// 执行导出
Boolean result = userService.writeExcel(path);
return ResultUtils.success(result);
}

导出表格,业务逻辑:

1
2
3
4
5
6
/**
* 导出表格
*
* @param path 文件路径
*/
Boolean writeExcel(String path);
1
2
3
4
5
6
7
8
9
10
11
12
@Override
public Boolean writeExcel(String path) {
// 1.定义文件路径
String fullPath = DOWNLOAD_PATH + USER_TABLE_NAME;
// 2.检查文件是否存在
this.isExist(fullPath);
// 3.根据路径导出
List<UserVO> userList = this.listUserByPage();
EasyExcel.write(fullPath, User.class).sheet(USER_SHEET_NAME).doWrite(userList);

return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 检查文件是否存在
*
* @param fullPath
*/
public void isExist(String fullPath) {
File file = new File(fullPath);
if (file.exists()) {
// 文件存在,则删除
boolean isDeleted = file.delete();
if (!isDeleted) {
// 删除文件失败的处理
throw new RuntimeException("删除文件失败: " + fullPath);
}
// 可以在这里添加日志或其他操作,表明文件已被删除
System.out.println("文件已成功删除: " + fullPath);
}

// 文件不存在或已被删除,则尝试创建其父目录
File parentDir = file.getParentFile();
if (!parentDir.exists() && !parentDir.mkdirs()) {
// 创建目录失败的处理
throw new RuntimeException("创建父级目录失败: " + parentDir.getAbsolutePath());
}
// 只有在成功创建目录(或目录已存在)时才打印日志
if (parentDir.exists()) {
// 可以在这里添加日志或其他操作,表明目录已存在或已被创建
System.out.println("父级目录已存在或已成功创建: " + parentDir.getAbsolutePath());
}
}

有个巨坑,常量 doc/ownload/user.xlsx 前面多写了个/,变成 /doc/ownload/user.xlsx 后问题百出。。

这里支持用户选择导出文件的路径,导出文件名已经在配置文件中做好配置:

1
2
3
4
5
/**
* 导出的表名
*/
@Value("${file.path.userExcelTable}")
String userExcelTable = "";
1
2
3
file:
path:
userExcelTable: "/user.xlsx"

再配置实体类,标记实体类和 Excel 表格列名的关系:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 用户管理
* @TableName user
*/
@TableName(value ="user")
@Data
public class User implements Serializable {
/**
* 用户名
*/
@TableId(type = IdType.ASSIGN_ID)
@ExcelProperty("id")
private Long id;

/**
* 登录名
*/
@ExcelProperty("登录名")
private String userAccount;

/**
* 角色名称
*/
@ExcelProperty("角色名称")
private String userRole;

.............................................

/**
* 是否删除
*/
@TableLogic
@ExcelIgnore
private Integer isDeleted;

/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@ExcelIgnore
private Date createTime;

/**
* 更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@ExcelIgnore
private Date updateTime;

@TableField(exist = false)
private static final long serialVersionUID = 1L;
}

Controoler 层接受 @RequestParam 传参,尝试发送请求:

1
2
POST http://localhost:8101/api/user/excel/write?path=doc
Content-Type: application/x-www-form-urlencoded

image-20240905163442236

显然,导出表是成功的:

image-20240905163549534

在指定导出目录下,可以找到已经成功导出的文件:

image-20240905163750774

image-20240905163757164

Postman

2024 年 9 月 5 日

下午尝试用 Postman 测试导入 Excel 文件功能时,却发现连基本的请求都发不出去”:

image-20240905171834528

特么竟然是请求 url 不规范,就不应该在前面加什么 http://,已解决,太坑了。

导入功能实现,Controller 层新增接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 导入 Excel 表格
*
* @param multipartFile
* @return
*/
@PostMapping("/excel/read")
public BaseResponse<Boolean> readExcel(@RequestPart("file") MultipartFile multipartFile) {
// controller 层校验参数
ThrowUtils.throwIf(multipartFile == null, ErrorCode.PARAMS_ERROR);

// 执行导入
Boolean result = userService.readExcel(multipartFile);
return ResultUtils.success(result);
}

注意接收参数,待会儿使用 Postman 测试接口时要注意,这个问题在上半年实习那会儿,导入导出表格的时候学到了。

导入表格的业务逻辑:

1
2
3
4
5
6
7
/**
* 导入表格
*
* @param multipartFile
* @return
*/
Boolean readExcel(MultipartFile multipartFile);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public Boolean readExcel(MultipartFile multipartFile) {
// 1.转换文件为字节流
InputStream inputStream;
try {
inputStream = multipartFile.getInputStream();
} catch (IOException e) {
throw new RuntimeException(e);
}
// 2.执行文件处理逻辑
EasyExcel.read(inputStream, User.class, new UserExcelListener()).sheet().doRead();
// 3.入库
boolean saveBatch = this.saveBatch(CACHED_DATA_LIST);
ThrowUtils.throwIf(!saveBatch, ErrorCode.PARAMS_ERROR);
return true;
}

执行读取的时候,当然需要有相应的监听器,做表格数据到实体类之间的转换:

实体类字段上的注释,还是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 用户管理
* @TableName user
*/
@TableName(value ="user")
@Data
public class User implements Serializable {
/**
* 用户名
*/
@TableId(type = IdType.ASSIGN_ID)
@ExcelProperty("id")
private Long id;

/**
* 登录名
*/
@ExcelProperty("登录名")
private String userAccount;

/**
* 角色名称
*/
@ExcelProperty("角色名称")
private String userRole;

.............................................

/**
* 是否删除
*/
@TableLogic
@ExcelIgnore
private Integer isDeleted;

/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@ExcelIgnore
private Date createTime;

/**
* 更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
@ExcelIgnore
private Date updateTime;

@TableField(exist = false)
private static final long serialVersionUID = 1L;
}

监听器:

1
2
3
4
5
6
7
8
9
/**
* 每隔100条处理下,然后清理list ,方便内存回收
*/
private static final int BATCH_COUNT = 100;

/**
* 缓存的数据
*/
public static List<User> CACHED_DATA_LIST = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 当读取到一行数据时,会调用这个方法,并将读取到的数据以及上下文信息作为参数传入
* 可以在这个方法中对读取到的数据进行处理和操作,处理数据时要注意异常错误,保证读取数据的稳定性
*
* @param user
* @param context
*/
@Override
public void invoke(User user, AnalysisContext context) {
log.info("解析到一条数据:{}", user);
CACHED_DATA_LIST.add(user);
if (CACHED_DATA_LIST.size() >= BATCH_COUNT) {
// 处理缓存的数据,入库
boolean saveBatch = userService.saveBatch(CACHED_DATA_LIST);
ThrowUtils.throwIf(!saveBatch, ErrorCode.PARAMS_ERROR);
// 然后清空
CACHED_DATA_LIST.clear();
}
}

这里仅仅把最核心的处理逻辑列出来,详细的监听器代码已上传。

CACHED_DATA_LIST 就是在监听器中处理表格中的数据后,收集到的用户信息集合,在业务代码中直接选择入库就可以。

顺便更新了 ExcelConstant 常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface ExcelConstant {
/**
* 导出存放路径
*/
String DOWNLOAD_PATH = "doc/download/";

/**
* 用户表名
*/
String USER_TABLE_NAME = "user.xlsx";

/**
* 用户 sheet
*/
String USER_SHEET_NAME = "用户信息";
}

然后用 Postman 这样发送请求,上传准备好的表格文件:

image-20240905215631262

image-20240905215637691

当然,数据导入是很成功的:

image-20240905215740452

image-20240905215747052

代码生成器

2024 年 9 月 7 日

这属于是给自己挖的坑了,实习期间只是稍微了解使用了下部门内部的代码生成器,结果简历上写了自己主导并参与开发,赶紧补救下:

SpringBoot日常:整合属于自己的代码生成器_springboot 代码生成-CSDN博客

代码生成器 | MyBatis-Plus (baomidou.com)

好好学一学咯。

消息状态

2024 年 9 月 7 日

简历上写道:设计人脉圈消息列表展示并标记消息状态(已读,未读),这个消息状态标记是怎么实现的呢,今晚借助 AI 硬生学会了。

我也不废话,直接给出精彩的思路,将来有时间了一定会付诸实践的:

在设计人脉圈消息列表时,我会将消息状态(如已读、未读、待处理等)作为消息实体的一个字段来存储。

在查询消息列表时我会根据这个字段的值来过滤和展示不同的消息状态。此外我还会在数据库中为消息状态设置索引以加快查询速度。

这样的功能怎么实现?

消息本身包含了发送人信息,接收人信息,状态等等,如何正确处理消息的已读状态?

就是说人脉圈里的消息大家都能看见,怎么样才能维护到每个用户看到的未读信息呢?

回答

实现人脉圈消息系统中消息状态的正确管理,特别是已读状态的维护,需要综合考虑数据存储、查询效率以及用户体验。以下是一个详细的实现方案:

1. 数据库设计

首先,确保你的消息表(假设为messages)包含以下关键字段:

  • id:消息的唯一标识符。
  • sender_id:发送人的ID。
  • content:消息内容。
  • status:消息状态,如unread(未读)、read(已读)、pending(待处理)等。
  • timestamp:消息发送的时间戳。

此外,为了高效处理消息的已读状态,可以引入一个额外的表(如message_reads)来记录哪些用户已经阅读了哪些消息。这个表可能包含以下字段:

  • user_id:阅读消息的用户的ID。
  • message_id:被阅读的消息的ID。
  • read_timestamp:用户阅读消息的时间戳(可选,用于更细粒度的分析)。

2. 已读状态的处理逻辑

2.1 发送消息

当一条新消息被发送时,它默认被标记为unread状态,并存储在messages表中。此时,message_reads表中不会有关于这条消息的任何记录,因为它还没有被任何人阅读。

2.2 读取消息

当某个用户打开消息列表或查看特定消息时,系统需要执行以下操作:

  • 查询messages表,获取所有目标用户(即当前登录用户)相关的消息,包括未读和已读的消息。

  • 对于每一条消息,检查

    1
    message_reads

    表以确定该用户是否已经阅读过这条消息。

    • 如果message_reads表中存在相应记录,说明该消息已被当前用户阅读,status可以显示为read
    • 如果不存在记录,说明该消息对于当前用户来说是未读的,status显示为unread
  • 如果用户阅读了之前未读的消息,系统应在message_reads表中插入一条新记录,记录该用户阅读了哪条消息以及阅读的时间。

2.3 更新消息状态(可选)

在某些情况下,你可能想要在messages表中直接更新消息的status字段,以便快速过滤出所有未读消息。然而,这种方法在多人可见的人脉圈中可能不适用,因为你需要为每个用户维护独立的消息状态。不过,如果你只是想要快速获取所有用户的总体未读消息数,可以在messages表中维护一个总未读计数,并在用户阅读消息时更新这个计数(但这通常不是最佳实践,因为它需要处理并发更新和回滚等复杂问题)。

3. 性能优化

  • messages表和message_reads表的关键字段(如user_idmessage_id)设置索引,以加快查询速度。
  • 使用缓存来存储用户最近的未读消息列表,以减少数据库查询次数。
  • 考虑使用消息队列或事件驱动架构来处理消息状态的更新,以提高系统的响应性和可扩展性。

4. 用户体验

  • 在UI层面,清晰地区分已读和未读消息,如使用不同的颜色或图标。
  • 提供“标记为已读”的功能,允许用户一键将所有未读消息标记为已读。
  • 在用户进入消息列表时,自动加载并更新未读消息的状态。

通过上述方案,你可以有效地实现人脉圈消息系统中的已读状态管理,同时保证系统的性能和用户体验。

我大概明白了你的意思,但还是有漏洞的。

如果用户读到了消息,怎么处理才能保证当前读到消息的用户,他看到的消息变成已读状态了,而不影响其他用户看到消息的状态,总不能直接修改 message 的 status 值吧。

您提出的问题非常关键,确实需要确保在用户阅读消息时,仅将该消息对于当前用户的状态标记为已读,而不影响其他用户对该消息的状态。在之前描述的方案中,我们并没有直接修改messages表中的status字段来标记消息为已读,而是依赖于message_reads表来跟踪每个用户阅读消息的情况。

这里是一个更详细的处理流程,以确保只有当前用户看到的消息被标记为已读:

  1. 用户请求消息列表:
    • 当用户请求查看消息列表时,系统从messages表中检索出所有与该用户相关的消息(可能是通过某种关联表或字段来确定哪些消息是“与该用户相关的”,比如消息是发送给该用户的,或者该用户是消息所属人脉圈的一员)。
    • 对于每一条检索出的消息,系统同时查询message_reads表,以确定该用户是否已经阅读过这条消息。
    • 根据查询结果,系统构建消息列表,其中每条消息的显示状态(如未读、已读)根据message_reads表中的记录来确定。
  2. 用户阅读特定消息:
    • 当用户点击或以其他方式选择阅读某条消息时,系统执行以下操作:
      • 首先,检查message_reads表,看是否已经存在该用户阅读该消息的记录。
      • 如果不存在记录,说明该消息对于当前用户来说是未读的,系统应在message_reads表中插入一条新记录,记录该用户阅读了哪条消息以及阅读的时间。
      • 插入记录后,无需修改messages表中的status字段,因为该字段可能用于表示消息在系统中的整体状态(如是否已被删除、是否等待审核等),而不是针对单个用户的阅读状态。
      • 用户的阅读行为仅影响message_reads表,因此不会影响其他用户对该消息的阅读状态。
  3. 更新UI:
    • 在用户阅读消息后,系统应更新用户界面,以反映该消息对于当前用户来说已经是已读状态。这通常涉及到修改前端页面上的消息显示状态,比如将未读消息的标记(如红点或粗体)移除。
  4. 考虑并发和性能:
    • 在高并发的场景下,需要确保对message_reads表的读写操作是原子性的,以避免数据不一致的问题。这可以通过使用数据库事务、乐观锁或悲观锁等机制来实现。
    • 同时,为了提高性能,可以考虑使用缓存来存储用户最近的阅读记录,减少对数据库的查询次数。但是,缓存的使用需要谨慎处理缓存失效和更新的问题,以确保数据的准确性。

通过上述流程,我们可以确保在用户阅读消息时,仅将该消息对于当前用户的状态标记为已读,而不影响其他用户对该消息的状态。

高并发状态下确实需要保证原子性啊,所有用户都在查找维护更新自己的消息状态,那这块儿内容又该怎么实现呢?

在高并发的场景下,确保对message_reads表的读写操作是原子性的,以防止数据不一致,是一个非常重要的任务。这里,我将详细解释如何通过数据库事务、乐观锁和悲观锁等机制来实现这一目标。

1. 数据库事务

数据库事务是一种机制,它确保数据库从一个一致的状态转变到另一个一致的状态。事务具有四个关键属性,即ACID特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。

  • 原子性:事务是数据库操作的最小工作单位,一个事务中的所有操作要么全部完成,要么全部不完成,不可分割。
  • 隔离性:事务之间的操作是相互隔离的,即并发执行的事务之间不会互相干扰。

message_reads表的场景中,每当用户阅读一条消息时,可以将插入记录到message_reads表的操作作为一个事务来处理。这样,即使在高并发的环境下,也能保证操作的原子性,从而避免数据不一致的问题。

2. 乐观锁

乐观锁是一种用于在并发控制中避免数据更新冲突的技术。它假设多用户并发的事务在处理时不会互相干扰,只有在更新数据时才会检查是否存在冲突。

message_reads表中使用乐观锁,通常的做法是在表中添加一个版本号(version)或时间戳(timestamp)字段。每次读取记录时,都获取该字段的值。在更新记录时,检查版本号或时间戳是否发生了变化(即检查在读取记录之后是否有其他事务修改了该记录)。如果没有变化,则执行更新操作,并将版本号或时间戳加一;如果发生了变化,则说明存在冲突,此时可以根据业务需求选择重试或报错。

3. 悲观锁

与乐观锁不同,悲观锁假定在数据处理过程中最坏的情况,即数据在处理过程中很可能被其他事务并发修改。因此,悲观锁会在数据处理开始时就对数据加锁,以防止其他事务对数据进行修改。

在数据库层面,悲观锁通常通过SQL语句中的锁定语句(如SELECT ... FOR UPDATE)来实现。当执行这类语句时,数据库会对被选中的数据进行加锁,直到当前事务结束(提交或回滚)。

message_reads表的场景中,如果需要保证极高的数据一致性,并且可以接受加锁带来的性能开销,那么可以使用悲观锁。例如,在用户阅读消息并准备更新message_reads表之前,先执行一个SELECT ... FOR UPDATE语句来锁定该消息对应的记录,然后再进行更新操作。

总结

在高并发的场景下,保证对message_reads表的读写操作是原子性的,可以通过数据库事务、乐观锁或悲观锁等机制来实现。选择哪种机制取决于具体的业务需求、系统性能要求以及可接受的数据一致性级别。在实际应用中,往往需要根据实际情况灵活选择或组合使用这些机制。

总结


凤凰涅槃:Spring Boot 开发之路上的坎坷与成长
https://test.atomgit.net/blog/2023/07/06/凤凰涅槃:Spring Boot 开发之路上的坎坷与成长/
作者
Memory
发布于
2023年7月6日
更新于
2024年9月7日
许可协议