JAVAHK开发心得
爪洼慧考项目介绍
项目简介
我们希望能开发一款支持线上考试的系统,教师端包括出题功能、组卷功能、监考功能和改卷功能;学生端则可以参加考试、查看成绩。
考试过程中学生的答题状态和进度能实时反馈给教师端;
当学生出现切屏、退出考试页面等行为时,也会通知教师端;
提交试卷后,客观题可以自动批改,同时提供了易用的批改页面给老师使用。
数据库设计
Springboot架构
src/main
├── java/com/buaa/javahuikao
├── config # 配置类
├── controller # 控制器,处理http请求
├── dto # 简单的POJO,封装前端发送的数据或后端业务逻辑层处理后的数据
├── entity # 实体类,与数据库表直接映射
├── mapper # 存放MyBatis的Mapper接口类
└── service # 服务类,包含业务逻辑
└── resources
└── mapper # 存放MyBatis的Mapper XML文件,包含操作数据库的SQL语句
处理流程:
- 前端通过HTTP请求发送JSON数据
- Controller类接收数据,自动解析为一个RequestDTO类实例
- Service类接收DTO实例,处理逻辑,将其转换为Entity类实例
- Service类随后调用Mapper接口进行数据库操作;
- Mapper接口定义了和数据库交互的方法,Mapper XML文件中包含具体的SQL映射,MyBatis根据这些文件生成具体的数据库操作方法
- 读取数据后,结果封装为Entity类实例,在Service层中把结果数据转换为DTO类实例,返回给Controller层
- Controller 层将处理后的 DTO 类实例作为响应返回给前端
技术细节
MyBatis Mapper 注解 及 XML配置
-
注解方式:
@Mapper public interface StudentAnswersMapper { @Select("SELECT id FROM student_answers WHERE student_id = #{studentId} AND exam_id = #{examId}") Integer getStudentAnswersId(@Param("studentId") int studentId, @Param("examId") int examId); } -
XML配置
<mapper namespace="com.buaa.javahuikao.mapper.StudentAnswersMapper"> <select id="getStudentAnswersId" resultType="java.lang.Integer"> SELECT id FROM student_answers WHERE student_id = #{studentId} AND exam_id = #{examId} </select> </mapper> -
MyBatis允许同时使用注解和XML,但如果一个方法中同时在注解和XML中定义,XML配置会覆盖注解。
-
本项目中统一使用了第二种方式。IDEA中安装MyBatis-plus插件后,也可以实现从XML跳转到定义的位置。
请求字段命名匹配
-
在DTO类中添加注解指定
public class SingleAnswersContentDTO { @JsonProperty("question_id") private Integer questionId; @JsonProperty("student_id") private Integer studentId; @JsonProperty("exam_id") private Integer examId; private AnswerDTO answer; // getters and setters } -
配置全局命名策略,在
application.yml中添加spring: jackson: property-naming-strategy: SNAKE_CASE -
在后端接收HTTP请求体时,对字段大小写不匹配、多出字段或缺少字段的处理方式取决于后端框架和具体实现。以下是常见情况和处理方式:
1. 字段大小写不匹配
-
Java (Spring Boot):
- 默认大小写敏感,字段名必须与POJO属性完全匹配(可通过
@JsonProperty注解指定别名) - 示例:若POJO属性为
studentId,请求体中必须为studentId,studentid或student_id会导致映射失败(值为null)
- 默认大小写敏感,字段名必须与POJO属性完全匹配(可通过
-
Python (Django/Flask):
- 通常大小写敏感,但可通过序列化器自定义(如DRF的
source参数) - 示例:Django的
student_id不会自动匹配studentId
- 通常大小写敏感,但可通过序列化器自定义(如DRF的
-
Node.js (Express):
- 完全取决于代码实现,通常大小写敏感
-
解决方案:
- 前后端统一命名规范(推荐驼峰或下划线之一)
- 在后端使用注解/配置指定别名(如Spring的
@JsonProperty("student_id"))
2. 多出字段
-
Java (Spring Boot):
- 默认忽略多余字段(不报错)
- 可通过
@JsonIgnoreProperties(ignoreUnknown = false)强制校验,此时会抛出异常
-
Python (DRF):
- 默认允许多余字段,但可在序列化器中设置
strict = True禁止
- 默认允许多余字段,但可在序列化器中设置
-
Node.js (Express):
- 默认允许多余字段,需手动校验
-
解决方案:
- 严格模式下可拒绝含多余字段的请求(适合高安全性场景)
- 宽松模式下可记录日志但不处理(适合快速迭代)
3. 缺少字段
-
Java (Spring Boot):
- 若字段标记为
@NotNull或required=true,会返回400 Bad Request - 否则字段值为
null
- 若字段标记为
-
Python (DRF):
- 若字段设置
required=True,返回400错误 - 否则字段值为
None或默认值
- 若字段设置
-
Node.js (Express):
- 需手动检查,未处理时字段值为
undefined
- 需手动检查,未处理时字段值为
-
解决方案:
- 必填字段应显式声明(如Spring的
@NotNull或DRF的required=True) - 非必填字段设置默认值(如Java的
private String name = "";)
- 必填字段应显式声明(如Spring的
总结表
情况 典型行为 推荐处理方式 大小写不匹配 字段映射失败(值为 null)统一命名或使用别名注解 多出字段 默认忽略,可能记录日志 严格模式拦截或允许但记录 缺少字段 非必填字段为 null,必填字段报错显式声明必填字段并返回明确错误 -
考虑这么多不如一开始就在团队内统一好命名规范……!
Websocket
原理介绍
WebSocket 是一种网络通信协议,提供全双工通信通道,使服务器可以主动向客户端推送数据。与传统的 HTTP 请求-响应模式不同,WebSocket 在建立连接后,允许服务器和客户端之间进行双向实时通信。
WebSocket 的出现主要是为了解决 HTTP 协议在实时通信方面的一些局限性:
- 连接重用:HTTP 协议在每次请求时都需要重新建立连接(HTTP/1.1 之前),这在需要频繁通信的场景中效率很低。
- 非实时性:传统的 HTTP 请求-响应模型不能满足实时互动的需求,因为服务器无法主动向客户端推送信息。
- 开销较大:每次 HTTP 请求都会携带完整的头信息,增加了不必要的网络负载。
HTTP的不足
- 单工通信:HTTP 是单工的,客户端发送请求后服务器才能响应,服务器不能主动发送消息。
- 频繁的连接开销:每个 HTTP 连接在传输完毕后通常都需要关闭,再次通信需要重新建立连接,这在需要频繁实时交互的应用中显得尤为低效。
- 头部开销:HTTP 请求和响应都包含大量的头部信息,这对于小数据包的传输非常不利。
并且由于http是单向的,必须有客户端发起请求,我们开发的服务端才会接收返回响应。
WebSocket的连接方式
客户端发起请求:客户端发送一个特殊的 HTTP 请求,请求升级到 WebSocket。这个请求看起来像一个标准的 HTTP 请求,但包含一些特定的头部字段来指示这是一个 WebSocket 升级请求:
- Upgrade: websocket:明确请求升级到 WebSocket。
- Connection: Upgrade:指示这是一个升级请求。
- Sec-WebSocket-Key:一个 Base64 编码的随机值,服务器将用它来构造一个响应头,以确认连接的有效性。
- Sec-WebSocket-Version:指示 [WebSocket 协议](https://so.csdn.net/so/search?q=WebSocket 协议&spm=1001.2101.3001.7020)的版本,通常是 13。
服务器响应:如果服务器支持 WebSocket,并接受升级请求,则它会返回一个 HTTP 101 Switching Protocols 响应,包含以下头部: - Upgrade: websocket 和 Connection: Upgrade:确认升级到 WebSocket。
- Sec-WebSocket-Accept:使用客户端的 Sec-WebSocket-Key 计算得出的一个值,用于验证连接。
建立 WebSocket 连接:一旦握手成功,原始的 HTTP 连接就升级到 WebSocket 连接。此时,客户端和服务器可以开始在这个长连接上双向发送数据。
数据传输:与 HTTP 不同,WebSocket 允许服务器直接发送消息给客户端,而不需要客户端先发送请求。
在我们的项目中,因为监考模块需要实时获知学生状态和答题进度,所以主要在这部分配置了WebSocket。
配置过程
首先在pom.xml文件中添加配置:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
IDEA中也可以直接点击”添加启动项“。搜索Websocket配置。
-
在
config文件夹下创建文件WebSocketConfig.java文件@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic"); // 客户端订阅地址前缀 config.setApplicationDestinationPrefixes("/app"); // 服务端接收消息地址前缀 } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") // WebSocket连接端点 .setAllowedOriginPatterns("*") .withSockJS(); } }添加了
@Configuration注解后,这个类在构建时就会自动加载。以及自定义的WebSocket处理器:
@Component @Slf4j public class CustomWebSocketHandler extends TextWebSocketHandler { // 用于存储WebSocket会话 private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String sessionId = session.getId(); sessions.put(sessionId, session); log.info("WebSocket连接建立成功:{}", sessionId); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); log.info("收到消息:{}", payload); // 发送回复消息 String replyMessage = "服务器收到消息:" + payload; session.sendMessage(new TextMessage(replyMessage)); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String sessionId = session.getId(); sessions.remove(sessionId); log.info("WebSocket连接关闭:{}", sessionId); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { log.error("WebSocket传输错误", exception); } // 广播消息给所有连接的客户端 public void broadcastMessage(String message) { sessions.values().forEach(session -> { try { session.sendMessage(new TextMessage(message)); } catch (IOException e) { log.error("广播消息失败", e); } }); } } -
后端添加发送方法,以学生考试的状态变更为例:
public void singleStatusNotify(int examId,int studentId,String status, String description) { Map<String, Object> payload = new HashMap<>(); payload.put("examId", examId); payload.put("studentId", studentId); payload.put("status", status); payload.put("description", description); messagingTemplate.convertAndSend("/topic/exam/" + examId + "/status", payload); } -
前端建立请求、监听路径代码:
//建立WebSocket连接 const socketStatus = new SockJS("http://localhost:8081/ws"); const stompClientStatus = Stomp.over(socketStatus); stompClientStatus.connect({}, () => { console.log("WebSocket Status 连接成功!"); const examId = exam_id const subscription = stompClientStatus.subscribe( `/topic/exam/${examId}/status`, (message) => { const data = JSON.parse(message.body); console.log("收到状态更新:", data); updateStudentStatus(data.studentId, data.status, data.description); } ); });
多线程
在考试进行过程中,学生提交答案、考试状态更新时可能出现大量流量,我们针对这两种场景配置了线程池:
// config/AsyncConfig.java文件
@Configuration
@EnableAsync
public class AsyncConfig {
/**
* 答案提交线程池
*/
@Bean(name = "answerSubmitExecutor")
public Executor answerSubmitExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(50); // 较大核心线程数应对IO等待
executor.setMaxPoolSize(100);
executor.setQueueCapacity(1000); // 缓冲突发流量
executor.setThreadNamePrefix("AnswerSubmit-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
/**
* 状态更新线程池
*/
@Bean(name = "statusUpdateExecutor")
public Executor statusUpdateExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(500); // 适度队列缓冲
executor.setThreadNamePrefix("IO-Executor-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return executor;
}
}
使用时在对应的方法上添加注解和线程池名即可:
@Async("statusUpdateExecutor")
Redis
Redis常被用作读缓存,尽可能减少数据库访问。
我们的项目中为了加快学生端读取试卷的速度,将开始考试的题目放入了Redis缓存中。
添加配置:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</dependency>
# Redis配置
spring:
data:
redis:
host: 127.0.0.1
port: 6379
# password: redis123
lettuce:
pool:
max-active: 50 # 匹配线程池大小
max-idle: 20
min-idle: 5
Redis配置类:
// config/RedisConfig.java文件
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory factory){
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
然后在调取题目的时候,优先从Redis中读取:
public StudentExamQuestionDTO getExamQuestions(int examId){
String cacheKey = EXAM_QUESTIONS_KEY_PREFIX + examId;
//尝试从缓存获取
try{
String cachedData = (String) redisTemplate.opsForValue().get(cacheKey);
if(cachedData != null) {
return objectMapper.readValue(cachedData,StudentExamQuestionDTO.class);
}
}
catch(Exception e){
System.out.println("redis缓存错误:"+ e);
}
//缓存未命中
StudentExamQuestionDTO questions = examQuestionMapper.getExamQuestions(examId);
//写入缓存
if (questions!=null){
try{
String jsonData = objectMapper.writeValueAsString(questions);
redisTemplate.opsForValue().set(
cacheKey,
jsonData,
CACHE_EXPIRE_HOURS,
TimeUnit.HOURS
);
}
catch(JsonProcessingException e){
System.out.println("写入缓存错误:"+ e);
}
}
return questions;
}
另外,Redis其实也可以用作写缓存。但是我们的系统中没有特别合适的应用场景,所以没用(
Windows本地测试Redis
理论上Redis应当部署在服务器上,并且有多个节点。然而在开发过程中我们需要在windows系统上配置redis。
- 下载Releases · microsoftarchive/redis
- 解压后在解压目录下执行
redis-server.exe redis.windows.conf
DateTime类型配置
在存储考试时间的时候,我们使用了DateTime类型,并且在对应的实体类中将字段定义为private LocalDateTime startTime;
然而第一次运行时报错了,报错信息为:
Java 8 date/time type `java.time.LocalDateTime` not supported by default:
原因是jackson默认不支持java8的时间类型,需要添加一个时间模块。遂添加配置。
// config/JacksonConfig.java文件
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
// 注册Java 8日期时间模块
objectMapper.registerModule(new JavaTimeModule());
// 禁用日期作为时间戳
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 设置全局日期格式
objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
return objectMapper;
}
}
开发心得
这次开发的项目相对比较小型,开发周期在10天左右,团队也只有三名成员;因为是很熟悉的队友,所以合作过程还是比较顺利的。
我主要负责了学生参加考试以及教师监考的部分。之前的开发中一直在做前端,这一次写了后端,感觉需要考虑的内容还是比前端多很多。前三天基本在熟悉Springboot的架构以及基本接口的写法,也做了一些关于分层结构的笔记方便回顾;写到后期感觉分层细化得太多,有些DTO类冗余了,一些简单的SQL查询可以直接使用注解,也可以学习使用Mybatis-plus。
随后我学习使用线程池,插入WebSocket以及Redis配置。一边学习理论知识一边写代码,成功后确实十分有成就感。
吸取的教训是前期开发有些太依赖ai,后期debug很费劲。此外我们一开始没有做好命名约定,后期因为字段名称的问题修改命名花了很多时间;最后,我们一开始应该规划好有哪些接口可以复用,现在写出的接口都有些过于冗余,当然也有在写前端把任务量全部丢给后端的问题。之后开发应当规划得更加细致一点,无论是自己单独写前端/后端还是模块开发,都应该合理分配前后端的任务量。

浙公网安备 33010602011771号