1. 项目概述:EE308FZ_Beta Spring_Beta Sprint总结
最近刚完成EE308FZ课程的Beta Spring_Beta Sprint项目,这是一次典型的敏捷开发实践。作为团队的技术负责人,我想通过这篇总结记录下整个开发周期中的关键节点、技术决策和实战经验。不同于普通的课程作业,这次项目采用了完整的Scrum流程,从需求分析到最终交付,每个环节都严格按照敏捷方法论执行。
我们团队由5名成员组成,开发周期为3周,最终交付了一个基于Spring Boot的在线学习管理系统。系统主要功能包括课程管理、作业提交、成绩统计和师生互动模块。虽然项目规模不大,但完整走完整个敏捷流程后,我们对软件工程实践有了更深刻的理解。
2. 敏捷开发流程实施
2.1 Sprint规划会议
在项目启动阶段,我们召开了为期半天的Sprint规划会议。会议前,产品负责人(由课程老师担任)已经准备好了初步的产品Backlog。我们的首要任务是将这些需求拆分为可执行的用户故事(User Story)。
实际操作中,我们发现原始需求描述过于宽泛,比如"实现课程管理功能"这样的表述无法直接用于开发。通过多次讨论,我们将其拆解为:
- 教师可以创建新课程(包含课程名称、描述、开课时间等基本信息)
- 学生可以浏览可选课程列表
- 教师可以管理已创建课程的详细信息
每个用户故事都按照INVEST原则进行评估,确保其独立性、可协商性、有价值、可估算、小型化和可测试性。我们使用斐波那契数列进行故事点估算,初期由于经验不足,估算偏差较大,但经过两个Sprint后逐渐趋于准确。
2.2 每日站会实践
每日15分钟的站会(Stand-up Meeting)是保持团队同步的关键。我们固定在每天上午9:30进行,每位成员需要回答三个经典问题:
- 昨天完成了什么?
- 今天计划做什么?
- 遇到什么障碍?
初期有些成员会把站会变成技术讨论会,经常超时。后来我们制定了严格规则:技术细节讨论必须放在站会后的"停车场会议"中进行。这个调整显著提高了会议效率。
站会的一个实用技巧是使用物理看板(我们在教室白板上贴便利贴)和数字工具(同时使用Jira)双轨并行。物理看板适合快速同步,数字工具则便于跟踪历史记录和生成燃尽图。
3. 技术架构与实现
3.1 技术栈选型
基于项目需求和团队技术背景,我们选择了以下技术栈:
- 后端:Spring Boot 2.7 + Spring Data JPA
- 前端:Thymeleaf + Bootstrap 5
- 数据库:H2(开发环境)/MySQL(生产环境)
- 构建工具:Gradle
- 版本控制:Git/GitHub
选择Spring Boot是因为它提供了快速开发的便利性,同时团队成员都有Java基础。考虑到项目规模和课程要求,我们没有采用前后端分离架构,而是使用传统的服务端渲染方案,这大大降低了开发复杂度。
3.2 领域模型设计
系统核心领域模型包括:
java复制@Entity
public class Course {
@Id @GeneratedValue
private Long id;
private String title;
private String description;
@ManyToOne
private Teacher creator;
@ManyToMany
private Set<Student> students = new HashSet<>();
// 其他字段和方法
}
@Entity
public class Assignment {
@Id @GeneratedValue
private Long id;
private String title;
private String description;
private LocalDateTime deadline;
@ManyToOne
private Course course;
// 其他字段和方法
}
在设计过程中,我们特别注意了关联关系的处理。例如,课程( Course )与作业( Assignment )是一对多关系,但作业提交( Submission )与作业是多对一关系。初期我们曾考虑使用DTO来传输数据,但考虑到项目规模,最终直接使用了实体类,这为后期带来了一些性能问题。
3.3 关键功能实现
以作业提交功能为例,核心实现步骤如下:
- 控制器层处理HTTP请求:
java复制@Controller
@RequestMapping("/assignments")
public class AssignmentController {
@PostMapping("/{id}/submit")
public String submitAssignment(
@PathVariable Long id,
@RequestParam MultipartFile file,
Principal principal) {
// 获取当前学生
Student student = studentRepository.findByUsername(principal.getName());
// 保存文件到存储系统
String filePath = storageService.store(file);
// 创建提交记录
Submission submission = new Submission();
submission.setStudent(student);
submission.setAssignment(assignmentRepository.findById(id).get());
submission.setFilePath(filePath);
submission.setSubmittedAt(LocalDateTime.now());
submissionRepository.save(submission);
return "redirect:/assignments/" + id;
}
}
- 文件存储服务实现:
java复制@Service
public class FileSystemStorageService implements StorageService {
private final Path rootLocation;
public FileSystemStorageService(@Value("${upload.path}") String uploadPath) {
this.rootLocation = Paths.get(uploadPath);
}
@Override
public String store(MultipartFile file) {
try {
if (file.isEmpty()) {
throw new StorageException("Failed to store empty file.");
}
String filename = UUID.randomUUID() + "_" + file.getOriginalFilename();
Path destinationFile = this.rootLocation.resolve(filename)
.normalize().toAbsolutePath();
Files.copy(file.getInputStream(), destinationFile,
StandardCopyOption.REPLACE_EXISTING);
return filename;
} catch (IOException e) {
throw new StorageException("Failed to store file.", e);
}
}
}
这个实现有几个值得注意的点:
- 使用UUID重命名上传文件,避免文件名冲突
- 文件路径通过配置项
upload.path指定,便于不同环境配置 - 存储操作与业务逻辑分离,符合单一职责原则
4. 测试策略与实践
4.1 单元测试覆盖率
我们使用JUnit 5 + Mockito进行单元测试,目标是达到70%以上的代码覆盖率。对于服务层的关键方法,如成绩计算逻辑,我们特别加强了测试:
java复制class GradingServiceTest {
@Mock
private SubmissionRepository submissionRepository;
@InjectMocks
private GradingService gradingService;
@Test
void calculateAverageGrade() {
// 准备测试数据
Student student = new Student("testuser");
Assignment assignment1 = new Assignment();
Assignment assignment2 = new Assignment();
Submission s1 = new Submission();
s1.setGrade(80);
s1.setAssignment(assignment1);
s1.setStudent(student);
Submission s2 = new Submission();
s2.setGrade(90);
s2.setAssignment(assignment2);
s2.setStudent(student);
// 模拟Repository行为
when(submissionRepository.findByStudent(student))
.thenReturn(Arrays.asList(s1, s2));
// 执行测试
double average = gradingService.calculateAverageGrade(student);
// 验证结果
assertEquals(85.0, average, 0.01);
}
}
测试中遇到的典型问题包括:
- 时间相关测试(如截止日期检查)需要特殊处理,我们最终使用了Clock类来注入虚拟时间
- 数据库集成测试速度慢,后来我们配置了H2内存数据库专用于测试
- 前端页面测试困难,最终只对关键业务逻辑进行了测试
4.2 端到端测试
使用Selenium进行了基本的端到端测试,主要覆盖核心用户流程:
java复制public class StudentSubmissionTest {
private WebDriver driver;
private String baseUrl;
@BeforeEach
void setup() {
driver = new ChromeDriver();
baseUrl = "http://localhost:8080";
}
@Test
void submitAssignment() throws Exception {
// 登录
driver.get(baseUrl + "/login");
driver.findElement(By.name("username")).sendKeys("student1");
driver.findElement(By.name("password")).sendKeys("password");
driver.findElement(By.tagName("button")).click();
// 导航到作业页面
driver.findElement(By.linkText("Assignments")).click();
driver.findElement(By.partialLinkText("Submit")).click();
// 上传文件
File testFile = new File("src/test/resources/test.txt");
driver.findElement(By.name("file")).sendKeys(testFile.getAbsolutePath());
driver.findElement(By.tagName("button")).click();
// 验证提交结果
assertTrue(driver.getPageSource().contains("Submission successful"));
}
@AfterEach
void tearDown() {
driver.quit();
}
}
端到端测试的主要挑战是测试脆弱性(页面结构变化导致测试失败)和执行速度慢。我们通过以下方式缓解:
- 使用明确的CSS选择器而非XPath
- 将长流程拆分为多个短测试
- 在CI中只运行关键路径测试
5. 持续集成与部署
5.1 GitHub Actions配置
我们在项目中配置了GitHub Actions工作流,实现以下自动化:
- 代码推送时运行单元测试
- 创建Pull Request时运行完整测试套件
- 主分支更新时构建Docker镜像
核心工作流配置如下:
yaml复制name: CI Pipeline
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run tests
run: ./gradlew test
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Build with Gradle
run: ./gradlew build
- name: Build Docker image
run: docker build -t learning-system .
5.2 Docker化部署
项目Dockerfile配置:
dockerfile复制FROM eclipse-temurin:17-jdk-jammy as builder
WORKDIR /app
COPY . .
RUN ./gradlew build
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
部署时遇到的典型问题包括:
- 构建镜像体积过大(通过多阶段构建解决)
- 配置文件外部化问题(使用环境变量和volume挂载)
- 数据库连接问题(确保容器网络配置正确)
6. 项目回顾与改进
6.1 Sprint回顾会议发现
在最后的Sprint回顾会议上,我们总结了以下关键经验:
- 正面实践:
- 每日站会确实帮助团队保持同步
- 使用物理看板提高了任务可视化程度
- 测试驱动开发(TDD)在部分模块效果显著
- 待改进点:
- 用户故事拆分不够细致,导致部分任务过大
- 代码审查流程不规范,后期才引入强制PR
- 技术债务积累过快,缺乏及时重构
- 具体改进措施:
- 引入更严格的定义完成(DoD)标准
- 为每个用户故事编写验收测试
- 预留20%时间用于技术债务处理
6.2 技术债务分析
项目结束时,我们识别出以下主要技术债务:
- 代码质量:
- 部分控制器方法过长,需要重构
- 异常处理不一致,有些直接返回500错误
- 缺乏API版本控制策略
- 性能问题:
- N+1查询问题(特别是课程列表页)
- 文件上传缺乏大小限制
- 无缓存机制,频繁访问数据库
- 安全缺陷:
- 密码以明文存储(应使用BCrypt)
- 缺乏CSRF保护
- 无API速率限制
这些债务大部分是由于时间压力和技术决策不当造成的。如果项目继续演进,我们应该优先解决安全问题,然后是性能问题,最后是代码结构问题。
7. 个人收获与建议
通过这次Beta Sprint项目,我深刻体会到敏捷开发不仅仅是流程和工具,更是一种思维方式和团队协作文化。以下是对未来类似项目的建议:
- 需求分析阶段:
- 花更多时间细化用户故事
- 尽早与利益相关者确认验收标准
- 建立更完善的需求追踪机制
- 技术实施方面:
- 从一开始就建立代码规范
- 每日构建并运行完整测试套件
- 为复杂模块编写技术设计文档
- 团队协作:
- 轮换Scrum Master角色
- 建立更有效的代码审查流程
- 定期进行知识分享会
对于课程作业性质的项目,我建议后续团队可以:
- 适当缩小范围,确保深度而非广度
- 尽早建立自动化构建和部署流程
- 预留缓冲时间应对意外问题
这次实践最宝贵的收获是理解了理论(敏捷方法论)与实践(真实项目开发)之间的差距,以及如何通过持续改进来缩小这种差距。虽然项目还有很多不足,但整个过程让我们获得了教科书上无法提供的实战经验。
