摘要: 本文系统复盘了《软件工程实务》课程中的四个高价值认知:需求分析如何用 UML 用例图 拒绝伪需求、架构设计时 MVC 解耦 的落地方案、编码阶段 Git分支管理 的血泪教训,以及测试阶段 JUnit 自动化测试 的脚本实现。文末附赠组员评分表和避坑清单。

引言:大学里最特殊的一门课

如果把大二的程序设计比作“造一个可以动的玩具”,那《软件工程实务》就是“盖一栋需要住人的楼”。

学期初,老师把全班分成若干五人小组,要求在9周内交付一套Web系统。我自荐当了组长。回头看,代码量总计约8000行,而文档加起来超过了1.5万字。正是这1.5万字,让我真正明白了软件工程的骨架究竟是什么。

第一章:需求分析不是记笔记,是签合同

1.1 第一次翻车

我们选的项目是“基于Spring Boot的线上二手书交易平台”。我最初写的需求文档长这样:

 

错误示范:

  • 功能1:用户注册登录

  • 功能2:浏览商品

  • 功能3:下单购买

老师看完反问了我三句话,我当场哑口无言:

  1. 卖家怎么上架旧书?ISDN扫描识别还是手动录入?

  2. “下单”之后钱给谁了?你们做不做支付,还是货到付款?

  3. 书卖掉了,库存状态怎么同步?

这一刻我意识到,老板要的功能逻辑,和用户遇到的实际场景,中间差着十万八千里

1.2 重构:用用例图定位边界

我们花了一整个周末重做需求调研,画出了核心用例图:

graph TD
    A[普通用户]
    B[卖家]
    C[系统管理员]
    
    A --- A1((浏览/搜索图书))
    A --- A2((加入购物车))
    A --- A3((下订单))
    
    B --- B1((发布图书信息))
    B --- B2((确认发货))
    B --- B3((查看销售记录))
    
    C --- C1((审核图书上架))
    C --- C2((处理投诉))
    C --- C3((用户管理))

在此基础上,我们把用例描述细化到触控级别:

用例ID用例名称参与者前置条件基本事件流
UC-01发布图书卖家已登录1.点击“我要卖书”
2.扫描/输入ISBN
3.系统调用豆瓣API自动填充书名、封面
4.卖家填写新旧程度、价格
5.提交进入待审核
UC-02下单购买买家购物车非空1.确认购物车
2.填写收货地址
3.系统生成待付款订单
4.跳转模拟支付页面
5.支付成功扣减库存

1.3 一课一得 ①:需求是一切灾难的源头

核心认知:需求阶段的1小时修改,等于编码阶段的10小时重写。

这个阶段产出的SRS(软件需求规格说明书)被老师评为班级第一,原因只有一条:我们把所有功能都还原成了“用户在什么情况下、用这个功能、达成什么目的”的三段式描述。


第二章:设计——从一张E-R图开始的软件骨架

2.1 为什么不能直接开写?

组员小刘是个急性子,在文档还没写完时就搭建了一个Spring Boot空项目,把数据库建了出来。结果表里连“订单状态”这个基础字段都没有,因为当初我们压根没考虑到“待支付、已支付、已发货、已收货、已取消”这五种状态的流转。

这件事让我们组付出了惨痛代价:第一版物理数据库被全部删掉重建。

2.2 E-R图与表结构设计

我们重新设计了完整的E-R图(转换为关系模式):

核心实体(5表):

  • user (用户表)

    • id BIGINT PK AUTO_INCREMENT

    • username VARCHAR(50) NOT NULL UNIQUE

    • password_hash CHAR(60) NOT NULL

    • phone VARCHAR(20)

    • role ENUM('BUYER','SELLER','ADMIN') NOT NULL

    • create_time DATETIME DEFAULT CURRENT_TIMESTAMP

  • book (图书表)

    • id BIGINT PK

    • isbn VARCHAR(13)

    • title VARCHAR(200) NOT NULL

    • author VARCHAR(100)

    • cover_url VARCHAR(500)

    • owner_id BIGINT FK -> user.id

    • original_price DECIMAL(10,2)

    • selling_price DECIMAL(10,2) NOT NULL

    • condition_desc TEXT

    • status ENUM('PENDING','APPROVED','REJECTED','SOLD') DEFAULT 'PENDING'

  • order_main (订单主表)

    • id BIGINT PK

    • order_no VARCHAR(32) UNIQUE NOT NULL

    • buyer_id BIGINT FK -> user.id

    • total_amount DECIMAL(10,2) NOT NULL

    • order_status ENUM('UNPAID','PAID','SHIPPED','RECEIVED','CANCELLED') DEFAULT 'UNPAID'

    • create_time DATETIME

    • pay_time DATETIME

  • order_item (订单明细)

    • id BIGINT PK

    • order_id BIGINT FK -> order_main.id

    • book_id BIGINT FK -> book.id

    • purchase_price DECIMAL(10,2)

  • system_log (操作日志)

    • id BIGINT PK

    • user_id BIGINT

    • operation VARCHAR(100)

    • ip_address VARCHAR(45)

    • op_time DATETIME

同时,我们写了一版详细的API接口文档片段:

/**
 * 图书服务接口 - 概要设计产出物
 */
@RestController
@RequestMapping("/api/v1/books")
public class BookController {
    
    @Autowired
    private BookService bookService;

    /**
     * 分页查询已上架图书
     * 请求方式: GET
     * 请求路径: /api/v1/books?page=1&size=20&keyword=算法
     */
    @GetMapping
    public ApiResponse<PageResult<BookVO>> listBooks(
        @RequestParam(defaultValue = "1") Integer page,
        @RequestParam(defaultValue = "20") Integer size,
        @RequestParam(required = false) String keyword
    ) {
        return ApiResponse.success(bookService.findAllByPage(page, size, keyword));
    }
}

2.3 一课一得 ②:设计是团队协作的共同语言

核心认知:代码还没写一行,但所有接口签名、字段类型、状态枚举值已经全部定死——这就是设计的力量。

这份设计文档后来成了组内的“字典”。谁对字段有疑问,第一反应是翻这篇文档而不是直接问,效率提升一倍。


第三章:编码实战——Git分支管理的真实教训

3.1 合并地狱

我永远记得项目第四周的周三晚上10点。小刘和小张同时往main分支合并代码,Git报了22个冲突。我们三个人在腾讯会议屏幕共享,从10点手动消冲突消到了凌晨1点40。

第二天我把Git规范写进了组规:

# ========== 我们的Git分支规范 ==========

# 1. 主分支保护
git checkout main
# 禁止直接push到main,只接受Pull Request合并

# 2. 功能分支命名规则
git checkout -b feature/书籍搜索功能
git checkout -b feature/订单支付模块

# 3. 修复分支命名规则
git checkout -b bugfix/购物车数量计算错误
git checkout -b hotfix/数据库连接池溢出

# 4. 每天下班前必须执行的操作顺序
git add .
git commit -m "[feat] 完成卖家发布图书的后端接口 #UC-01"
git pull origin main --rebase
# 解决完本地的rebase冲突后
git push origin feature/书籍搜索功能

同时我们把 .gitignore 文件完善了三版,确保 .idea/ 、 *.log 、 application-prod.yml 这些敏感文件永远不会被提交。

3.2 一课一得 ③:Git规范不是条条框框,是团队的保险丝

核心认知:在没有规范的自由里,五个人能在24小时内毁掉整个仓库。


第四章:测试——找到那个隐藏最深的Bug

4.1 场景描述

系统上线前三天,我发现一个诡异现象:当两个人同时下单购买同一本书时,两个人的订单都显示“已付款”,但库存只扣了1个。这意味着在并发场景下,书会被“卖两次”。

这是一个典型的超卖Bug,在高并发下才会触发。

4.2 测试脚本与修复

我立刻补了一个JUnit并发测试用例:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

@SpringBootTest
public class OrderConcurrencyTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private BookService bookService;

    @Test
    public void testConcurrentOrderPreventOverselling() throws Exception {
        int threadCount = 20;   // 模拟20人同时抢一本书
        Long bookId = 10001L;
        
        // 初始化库存为1本
        bookService.setStock(bookId, 1);
        
        ExecutorService pool = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);
        AtomicInteger successCount = new AtomicInteger(0);

        for (int i = 0; i < threadCount; i++) {
            pool.execute(() -> {
                try {
                    Long userId = Thread.currentThread().getId();
                    boolean result = orderService.createOrder(userId, bookId);
                    if (result) {
                        successCount.incrementAndGet();
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        pool.shutdown();

        // 最终库存应为0,成功下单数必须为1
        int finalStock = bookService.getStock(bookId);
        System.out.println("最终库存: " + finalStock);
        System.out.println("成功下单数: " + successCount.get());
        
        assertEquals(0, finalStock, "库存必须为0,防止超卖");
        assertEquals(1, successCount.get(), "20个人抢1本书,只能有1个成功");
    }
}

修复方案: 在下单的Service层加了数据库行级锁:

-- 使用SELECT ... FOR UPDATE 防止并发超卖
SELECT stock FROM book WHERE id = #{bookId} FOR UPDATE;

4.3 一课一得 ④:软件质量不靠人眼保证,靠测试用例堆出来

核心认知:并发Bug就像隐形炸弹,没测到就等于没解决。


第五章:项目交付的最后三公里

5.1 写部署文档的觉悟

系统部署到老师的验收环境时,出了一件尴尬的事:我们本地用的MySQL 8.0,老师的服务器装的是MySQL 5.7。建表语句里的 utf8mb4 字符集、新日期类型的默认值语法,在5.7上报了整整17个Error。

我们连夜赶出一份《部署环境依赖性清单》,连中间件版本号都精确到了小版本:

软件版本要求备注
JDK11.0.19不支持JDK8以下
MySQL8.0.33最低8.0,需开启InnoDB引擎
Redis7.0.11用于存储登录态Token和购物车临时数据
Nginx1.24.0应配置反向代理到8080端口

5.2 一课一得 ⑤:真正交付的是“系统 + 文档 + 安全感”

核心认知:你的代码在别人环境里跑不起来,那就不是Bug,是交付失败。


第六章:组内评分表与个人成长复盘

6.1 组员贡献度互评

老师要求我们提交一份组内互评表。我们小组第一次打分时吵得面红耳赤,后来定下量化标准才顺利通过:

成员角色代码得分文档得分协作沟通总分
张XX(我)组长/后端90959292.3
刘XX前端主力88809086.0
王XX数据库设计85928587.3
李XX测试82888886.0
赵XXUI/部署80859085.0

6.2 我的五个一课一得

阶段核心认知提炼
需求分析SRS文档要把用户故事穷举到不会产生歧义的程度
概要设计数据库ER图和接口文档是团队协作的唯一标准
编码Git规范是项目保命的底线原则
测试自动化测试是代码防塌方的最后一道安全网
交付环境不一致不属于技术Bug,属于部署事故


写在最后:软件工程是一门遗憾的艺术

如果让我重来一次,我会在第二周就引入自动化测试脚本,而不是第四周出了合并事故才补救。我会在第一天就定下接口文档的更新规则,而不是等前端跑来问我“这个字段改类型了你为什么不通知我”。

软件工程实务,消除的不是代码里的Bug,而是团队协作里的信息差。

感谢这门课程的五次小组评审,每一次都像给项目做了一次CT扫描。更感谢组员们在凌晨的腾讯会议里没有挂机。

代码会过时,但工程化的肌肉记忆不会。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐