JAVAHK开发心得

爪洼慧考项目介绍

项目简介

我们希望能开发一款支持线上考试的系统,教师端包括出题功能、组卷功能、监考功能和改卷功能;学生端则可以参加考试、查看成绩。

考试过程中学生的答题状态和进度能实时反馈给教师端;

当学生出现切屏、退出考试页面等行为时,也会通知教师端;

提交试卷后,客观题可以自动批改,同时提供了易用的批改页面给老师使用。

数据库设计

Springboot架构

src/main
├── java/com/buaa/javahuikao
	├── config		# 配置类
    ├── controller	# 控制器,处理http请求
    ├── dto			# 简单的POJO,封装前端发送的数据或后端业务逻辑层处理后的数据
    ├── entity		# 实体类,与数据库表直接映射
    ├── mapper		# 存放MyBatis的Mapper接口类
    └── service		# 服务类,包含业务逻辑

└── resources
	└── mapper		# 存放MyBatis的Mapper XML文件,包含操作数据库的SQL语句

处理流程:

  1. 前端通过HTTP请求发送JSON数据
  2. Controller类接收数据,自动解析为一个RequestDTO类实例
  3. Service类接收DTO实例,处理逻辑,将其转换为Entity类实例
  4. Service类随后调用Mapper接口进行数据库操作;
  5. Mapper接口定义了和数据库交互的方法,Mapper XML文件中包含具体的SQL映射,MyBatis根据这些文件生成具体的数据库操作方法
  6. 读取数据后,结果封装为Entity类实例,在Service层中把结果数据转换为DTO类实例,返回给Controller层
  7. 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,请求体中必须为studentIdstudentidstudent_id会导致映射失败(值为null
    • Python (Django/Flask)

      • 通常大小写敏感,但可通过序列化器自定义(如DRF的source参数)
      • 示例:Django的student_id不会自动匹配studentId
    • 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)

      • 若字段标记为@NotNullrequired=true,会返回400 Bad Request
      • 否则字段值为null
    • Python (DRF)

      • 若字段设置required=True,返回400错误
      • 否则字段值为None或默认值
    • Node.js (Express)

      • 需手动检查,未处理时字段值为undefined
    • 解决方案

      • 必填字段应显式声明(如Spring的@NotNull或DRF的required=True
      • 非必填字段设置默认值(如Java的private String name = "";

    总结表

    情况 典型行为 推荐处理方式
    大小写不匹配 字段映射失败(值为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配置。

image-20250529163421170
  1. 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);
                }
            });
        }
    }
    
  2. 后端添加发送方法,以学生考试的状态变更为例:

    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);
    }
    
  3. 前端建立请求、监听路径代码:

    //建立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。

  1. 下载Releases · microsoftarchive/redis
  2. 解压后在解压目录下执行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很费劲。此外我们一开始没有做好命名约定,后期因为字段名称的问题修改命名花了很多时间;最后,我们一开始应该规划好有哪些接口可以复用,现在写出的接口都有些过于冗余,当然也有在写前端把任务量全部丢给后端的问题。之后开发应当规划得更加细致一点,无论是自己单独写前端/后端还是模块开发,都应该合理分配前后端的任务量。

posted @ 2025-06-18 12:42  qiuer0121  阅读(36)  评论(0)    收藏  举报