简介:这是一份面向图书杂志采购与借阅系统的软件架构设计说明书,以单份 Word 文档呈现,适合软件工程课程设计、架构入门与项目文档写作参考。文档围绕系统架构展开,从简介、架构表示方式、设计目标与约束,到用例视图、逻辑视图、进程视图、实施视图等模块逐层说明,并涵盖关键功能需求、质量需求、开发策略、接口通信与非功能性需求等内容,便于项目经理、程序设计员和测试人员理解系统蓝图与职责边界。压缩包内共 1 个 docx 文件,约 367KB,体量轻便,可直接查阅目录与章节。已有 1439 人学习下载,说明其在同类架构文档中具有较高参考热度。读者可借助其中多视图建模思路、架构决策记录与评估方法,对照完成需求分析、模块划分和测试框架设计,尤其适合需要撰写架构说明书或梳理分层结构的学习者借鉴。
1. 图书采购借阅系统的架构说明书,为什么比源码更值得先拆
接手一个 2010 年前后的 Java Web 项目,多数人的第一反应是直奔 src 目录翻代码,结果在两三百个文件里迷路,还搞不清哪个包该改。图书杂志采购和借阅系统这类项目,信息密度最高的地方其实是一份叫《软件架构设计说明书》的 docx:它把系统切成用例视图、逻辑视图、进程视图、实施视图、部署视图五份,每一份只回答一类涉众的问题,项目经理、程序员、测试设计员各取所需,互不干扰。
这份文档适合三类人看:要做毕业设计却不知道架构章节怎么落笔的学生,要接手遗留 SSH 项目却看不懂包命名规则的维护者,以及需要把"稳定、安全、便捷"这类形容词翻译成可验收指标的工程师。
最该先看的是第三节"架构设计目标与约束"里的三行数字——查询响应不超过 10 秒,其余交互不超过 3 秒,平均故障间隔时间不低于 200 小时。这三个数字不是装饰,它们直接决定了逻辑视图为什么要把查询单独抽一层、部署视图为什么必须把数据库和 Web 容器拆开。先读这三行,再回头看视图,整份说明书就顺了。
2. 用 4+1 视图把架构说明书拆成五个可验证的切面
2.1 五视图各自的读者和产出物
架构文档最容易写崩的地方,是把五个视图写成五份互相抄的废话。判断标准很简单:每个视图必须对应一个具体的产出物,且这个产出物只有这个视图能给。下表是照着这份说明书的章节结构整理出来的对应关系,写文档时可以直接当检查表用。
| 视图 | 回答的核心问题 | 主要读者 | 必须产出的内容 |
|---|---|---|---|
| 用例视图 | 系统对外提供哪些可观察行为 | 客户、测试设计员 | 参与者清单、关键用例说明 |
| 逻辑视图 | 内部由哪些包和子系统组成 | 程序设计员 | 层次模型、设计包清单 |
| 进程视图 | 运行时刻进程与线程如何交互 | 程序设计员、性能测试 | 角色进程消息序列 |
| 实施视图 | 代码如何映射到构件与配置管理 | 配置管理员 | 实施模型、模块归属 |
| 部署视图 | 软件构件跑在哪些物理节点上 | 运维、项目经理 | 节点拓扑、部署方案 |
关键点在于层次:用例视图决定逻辑视图的包划分,逻辑视图决定进程视图里哪些调用要跨进程,进程视图和部署视图共同决定质量指标能不能达标。顺序颠倒过来写,文档基本就要返工。
2.2 用例视图里的五类参与者与权限边界
这份说明书把参与者分成游客、读者、图书管理员、采购管理员、系统管理员五类。抽用例时不要对着需求文档逐条抄,按下面的顺序走一遍,能省掉大量返工:
- 圈出需求文档里所有动宾短语:借书、还书、续借、罚款、订购、取消订购、验收确定、编目入库、预约。
- 按操作对象归类,同一对象的动词合并成一个用例,比如"续借"和"还书"都归到流通管理。
- 标出每个用例的主参与者与触发条件,借书的前置条件是证件有效且无超期。
- 反向检查:是否存在某个角色能覆盖全部用例。系统管理员就是典型例外,他只管管理员账号,不碰图书业务。
判断完边界,落到数据库就是一张角色权限关系。早期项目常见做法是直接在用户表里放一个 role 字段,结果加一个"采编人员"就要改代码。
-- 把用例视图里的五类参与者落成三张表,避免角色硬编码在 Java 里 CREATE TABLE sys_role ( role_id INT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(32) NOT NULL UNIQUE COMMENT 'GUEST/READER/LIB_ADMIN/PUR_ADMIN/SYS_ADMIN', role_name VARCHAR(64) NOT NULL ); CREATE TABLE sys_permission ( perm_id INT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(64) NOT NULL UNIQUE COMMENT '如 book.query / loan.renew / purchase.instore' ); CREATE TABLE role_permission ( role_id INT NOT NULL, perm_id INT NOT NULL, PRIMARY KEY (role_id, perm_id) ); -- 借阅记录按读者加状态建联合索引,直接服务“查看借阅/归还信息”的 3 秒约束 CREATE INDEX idx_loan_reader_status ON loan_record (reader_id, loan_status);三张表的分工是:sys_role存角色定义,sys_permission存原子权限,role_permission做多对多关联。perm_code用点号分段,前缀对应模块,方便在拦截器里做通配匹配。联合索引的顺序不能反,reader_id在前是因为查询一定带读者身份,loan_status在后做范围过滤,反过来建索引就只能用到第一列。
2.3 从关键质量需求倒推架构约束
质量需求如果不翻译成技术动作,就等于没写。"查询不超过 10 秒"这条,在图书表几万条数据时其实很容易达标,但借阅排行榜、新书通报、藏书多条件查询是三条不同的 SQL,任何一条走了全表扫描都会拖垮整体。对应动作是给查询类接口单独走一个只读数据源,并在book表的title、isbn、category_id上按实际查询组合建索引。
"其他交互不超过 3 秒"约束的是写操作之外的所有请求,包括登录、注册、改个人信息。这类请求的共同点是单表操作加会话校验,慢下来的原因通常是登录后每次请求都重新查一遍权限。缓解方式是登录时把权限码集合放进会话,拦截器只读会话不查库。
"MTBF 不低于 200 小时"则决定了部署视图不能把应用和数据库塞在同一台机器。200 小时约等于八天多,一个教学规模的项目平均八天出一次故障,说明单点故障必须被隔离,数据库进程崩溃不能把 Web 容器一起带走。
提示:这三条指标写在文档里时要带上测量口径,比如"查询响应时间指服务端处理时间,不含网络传输",否则验收时双方各说各话。
3. 逻辑视图落地:Struts+Spring+Hibernate 的包结构与装配配置
3.1 四个基础包的职责切分
说明书里给出了bpms.action、bpms.actionForm、bpms.db、bpms.domain四个基础包,这个划分是 SSH 时代最典型的分层法,但只给包名不给依赖规则,代码照样写乱。补上依赖方向之后才是可执行的约束。
| 包名 | 放什么 | 允许依赖 | 一旦出现就说明分层破了 |
|---|---|---|---|
| bpms.action | Struts Action,参数接收与页面跳转 | actionForm、service | 直接写 JDBC 或 HQL |
| bpms.actionForm | 表单对象,纯数据载体 | 无 | 任何业务判断 |
| bpms.domain | 实体类与 Hibernate 映射 | 无 | 依赖 action 或 db |
| bpms.db | DAO 与 Hibernate 会话操作 | domain | 页面跳转、request 取值 |
依赖只能单向:action 依赖 service,service 依赖 db,db 依赖 domain。反向依赖一次都不要开,尤其是让 domain 引用 action,编译期看着没事,打包时会带出整个 Web 层。
3.2 Struts 与 Spring 的装配配置
Struts 负责路由和表单绑定,Spring 负责对象生命周期和事务,两者靠DelegatingActionProxy接起来。这个代理类是整套配置的关键,它让 Struts 不去 new Action,而是按path去 Spring 容器里找同名 bean。
<!-- struts-config.xml:只做路由,业务全部下沉到 Service --> <struts-config> <form-beans> <form-bean name="bookSearchForm" type="bpms.actionForm.BookSearchForm"/> </form-beans> <action-mappings> <action path="/bookSearch" type="org.springframework.web.struts.DelegatingActionProxy" name="bookSearchForm" scope="request" validate="false"> <forward name="list" path="/WEB-INF/jsp/book/list.jsp"/> </action> </action-mappings> </struts-config><!-- applicationContext.xml:bean 名必须与 struts 的 path 一致,否则启动即报找不到 Action --> <bean name="/bookSearch" class="bpms.action.BookSearchAction"> <property name="bookService" ref="bookService"/> </bean> <bean id="bookService" class="bpms.service.impl.BookServiceImpl"> <property name="bookDao" ref="bookDao"/> </bean> <!-- 声明式事务:按方法名前缀决定传播行为和只读标记 --> <bean id="txProxyTemplate" abstract="true" class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean"> <property name="transactionManager" ref="transactionManager"/> <property name="transactionAttributes"> <props> <prop key="query*">PROPAGATION_REQUIRED,readOnly</prop> <prop key="save*">PROPAGATION_REQUIRED</prop> <prop key="*">PROPAGATION_REQUIRED</prop> </props> </property> </bean>scope="request"表示表单对象每个请求新建,避免上一个用户的数据残留到下一个用户;validate="false"是关掉 Struts 自带的校验框架,改由 Spring 侧的校验器统一处理,两套校验同时开着最容易出现"页面提示通过、后台报错"的诡异现象。事务属性里query*加readOnly,让 Hibernate 跳过脏检查,查询类接口能省下可观的 CPU。PROPAGATION_REQUIRED表示有事务就加入、没有就新建,写操作必须用这个,改成SUPPORTS会导致部分写操作没有事务包裹。
3.3 Hibernate 映射与事务边界
实体映射别偷懒全用注解,book与category、loan_record与reader这几组关系在 hbm.xml 里显式写出抓取策略更可控。默认的懒加载在页面渲染时才触发查询,一旦 Action 里提前把 Session 关了,就是经典的LazyInitializationException。
// BookDaoImpl:查询方法不手动开事务,交给 Spring 代理 public class BookDaoImpl extends HibernateDaoSupport implements BookDao { public List<Book> queryByCondition(BookSearchForm form, int pageNo, int pageSize) { // 用 HQL 拼条件,禁止把表单字段直接字符串拼接进 SQL StringBuilder hql = new StringBuilder("from Book b where 1=1 "); Map<String, Object> params = new HashMap<String, Object>(); if (StringUtils.hasText(form.getTitle())) { hql.append("and b.title like :title "); params.put("title", "%" + form.getTitle().trim() + "%"); } if (form.getCategoryId() != null) { hql.append("and b.categoryId = :categoryId "); params.put("categoryId", form.getCategoryId()); } hql.append("order by b.createTime desc"); Query query = getSession().createQuery(hql.toString()); for (Map.Entry<String, Object> e : params.entrySet()) { query.setParameter(e.getKey(), e.getValue()); } query.setFirstResult((pageNo - 1) * pageSize); // 分页起点,从 0 开始 query.setMaxResults(pageSize); // 每页条数,建议固定 20 return query.list(); } }setFirstResult的算法是(页码 - 1) × 每页条数,页码从 1 开始传,别从 0 开始,否则第一页会变成空。like条件用参数占位符而不是拼接,既防注入也让 Hibernate 能复用执行计划。分页条数固定成常量而不是让页面传,是为了防止有人传 10000 把库拖死。
3.4 分层落地常踩的三个坑
第一个是 N+1 查询。列表页显示每本书的类别名,如果类别是懒加载,20 本书就是 21 条 SQL。解法是在 hbm.xml 里对该关联加fetch="join",或在 HQL 里写left join fetch。
第二个是 ActionForm 跨请求复用。把 scope 设成 session 之后,用户 A 的查询条件会出现在用户 B 的页面上,这是权限事故的高发点。
第三个是事务自调用失效。Service 内部用this.saveXxx()调自己的另一个方法,Spring 的 AOP 代理拦不到,事务配置直接作废。要么抽成独立的 DAO 方法,要么注入自身代理。
4. 进程视图走通三条主链路:查询、注册与采购入库的调用时序
4.1 搜索图书链路的六步消息
说明书里画的搜索链路是主界面向后台发请求、后台取数据、数据库返回、再层层回传,一共六步。落到代码上,这六步压缩成一次 Action 调用加一次 Service 查询,真正需要关注的是每一步失败时回传什么。
// BookSearchAction:接收分页参数,异常统一转成状态信息返回页面 public class BookSearchAction extends DispatchAction { private BookService bookService; public ActionForward query(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) { BookSearchForm searchForm = (BookSearchForm) form; int pageNo = searchForm.getPageNo() == null ? 1 : searchForm.getPageNo(); try { PageResult<Book> page = bookService.queryByCondition(searchForm, pageNo, 20); request.setAttribute("page", page); request.setAttribute("status", "SUCCESS"); return mapping.findForward("list"); } catch (Exception e) { // 不把堆栈抛给页面,只回一个状态码,详细日志落 log4j log.error("book search failed, form=" + searchForm, e); request.setAttribute("status", "QUERY_ERROR"); return mapping.findForward("list"); } } public void setBookService(BookService bookService) { this.bookService = bookService; } }pageNo为空时兜底成 1,防止第一页就报空指针。异常不往上抛而是转成QUERY_ERROR状态码,对应进程视图里那条"状态信息(成功与否)"的回传消息,页面拿到状态码只显示一句提示,不暴露数据库结构。日志里带上完整表单对象,排查时不用再问用户输入了什么。
分页参数怎么定,直接影响 10 秒那条指标。经验值如下表。
| 参数 | 建议值 | 说明 |
|---|---|---|
| pageSize | 20 | 列表页可视范围,超过 50 单页渲染会明显变慢 |
| 最大页深 | 500 | 深度分页改用按主键游标,setFirstResult到几万会全表扫 |
| 查询超时 | 8 秒 | 留在 10 秒指标之内,超时直接中断并回状态码 |
| 缓存范围 | 排行榜类 | 新书推荐、借阅排行按小时缓存,藏书查询不缓存 |
4.2 注册与改资料的状态回传为什么要统一
游客注册和读者修改个人信息,在进程视图里是两条几乎一样的消息序列:填表、提交、写库、回传状态。这两个用例的差异只在目标实体和权限校验,所以状态码必须共用一套枚举,否则页面要写两套判断逻辑。
// 统一状态码:注册、改资料、采购入库共用,页面只需处理这几种 public enum ResultCode { SUCCESS, // 操作成功 PARAM_INVALID, // 必填项缺失或格式错误 DUPLICATE, // 账号、ISBN 等唯一键冲突 NO_PERMISSION, // 会话失效或角色不符 SYSTEM_ERROR // 数据库或未知异常 }DUPLICATE单独拎出来,是因为账号重复和 ISBN 重复在页面上要给出不同的跳转引导。NO_PERMISSION和SYSTEM_ERROR分开,前者回登录页,后者只刷新当前页并提示稍后重试,混在一起用户会被莫名踢下线。
4.3 采购入库的状态流转
采购管理模块是这份说明书里状态最多的一条链路:图书订购、取消订购、验收确定、编目入库,捐赠和交换来的图书要先清点确认再编目。状态不能只用一个字段表示,否则分不清"已验收但未编目"和"已编目但未上架"。
| 当前状态 | 触发动作 | 数据变更 | 是否可回退 |
|---|---|---|---|
| 订购中 | 取消订购 | 状态置为已取消,释放预算占用 | 可回退 |
| 订购中 | 验收确定 | 数量、单价、实收册数写入明细 | 不可 |
| 已验收 | 编目入库 | 生成图书主记录,分配条码与分类号 | 不可 |
| 已入库 | 清点确认 | 捐赠/交换图书补录来源字段 | 不可 |
| 修补中 | 重新上架 | 状态回可借,同时解除流通冻结 | 可回退 |
"修补中"这个状态容易被漏掉,它属于流通管理而不是采购,但会直接影响借书权限判断——处于修补中的图书,即使库存字段显示有货,也必须拒绝借出。
4.4 借书权限校验的判定顺序
说明书明确列出了借书时要自动区别的因素:超期、未交罚款、证件有效期、预约、其他违规。这五项的判定顺序不能随意调,因为它们的处理动作不同,有的要跳罚款页,有的只是拒绝。
// 借书前置校验:短路返回,顺序即优先级 public ResultCode checkBorrowPermission(Reader reader, Book book) { if (reader == null || !reader.isCardValid()) { return ResultCode.NO_PERMISSION; // 证件过期,最先判,后续查询无意义 } if (reader.getUnpaidFine() != null && reader.getUnpaidFine() > 0) { return ResultCode.NO_PERMISSION; // 有未缴罚款,直接拒 } if (reader.getOverdueCount() > 0) { return ResultCode.NO_PERMISSION; // 有超期未还,先还书再借 } if (book.getStatus() == BookStatus.REPAIRING) { return ResultCode.PARAM_INVALID; // 修补中,页面提示改借其他书 } if (book.getAvailableCopies() <= 0) { return ResultCode.DUPLICATE; // 无可借副本,引导走预约 } return ResultCode.SUCCESS; }证件有效性排第一,因为证件过期时读者身份本身就不成立,后面几项查了也是白查。罚款和超期都返回NO_PERMISSION,但页面提示词不同,实现时可以在 ResultCode 外再挂一个 message key。可借副本为零时返回DUPLICATE并引导预约,而不是简单拒绝,这条对应说明书里"预约处理"这个用例。
5. 质量约束转验收项:3 秒响应与 200 小时 MTBF 怎么测
文档写完不算完,架构说明书里的指标要能被复测才有意义。三个数字里最容易糊弄的是响应时间,最常见的做法是用ab或 JMeter 打一轮,然后拿平均值交差。平均值会掩盖长尾,验收要看的是 95% 分位。
# 对查询接口压测,-n 总请求数,-c 并发数,输出里直接看 95% 那行 ab -n 2000 -c 50 "http://localhost:8080/bpms/bookSearch.do?method=query&pageNo=1" # 只读接口连打三轮,取最差的一轮作为验收数据,避免缓存命中造成假象 for i in 1 2 3; do ab -n 1000 -c 30 "http://localhost:8080/bpms/bookSearch.do?method=query&pageNo=1" \ | grep -E "95%|Failed" done-c 50是并发连接数,压测值参考的是同时在线阅读的用户规模,不是注册用户总数。三轮取最差是因为第一轮往往命中了 Hibernate 二级缓存,数据虚高。Failed requests只要不为零就要先查原因,连接超时和业务异常在ab的输出里是混在一起的。
10 秒那条指标还要配合慢查询日志验证,否则压测时数据量太小,上线必炸。
-- 开慢查询,阈值定 2 秒,留出到 10 秒之间的排查余量 SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 2; -- 对藏书多条件查询做执行计划检查,重点看 type 和 rows 两列 EXPLAIN SELECT b.book_id, b.title, b.author FROM book b WHERE b.category_id = 12 AND b.title LIKE '软件%' ORDER BY b.create_time DESC LIMIT 20;type出现ALL就是全表扫描,rows超过五位数基本要加索引。title上的前置模糊匹配用不到索引,所以这类查询要么限定分类先缩小范围,要么依赖单独的搜索引擎,别指望在 MySQL 里硬扛。
MTBF 不低于 200 小时属于运行期指标,测法是采日志里的启动时间戳,算两次启动之间的间隔。
# 从日志中提取应用启动时间,计算相邻两次启动的间隔(单位:小时) grep "Application startup complete" app.log \ | awk '{print $1" "$2}' \ | awk 'NR>1 {cmd="date -d \""$0"\" +%s"; cmd | getline t; close(cmd); print (t-prev)/3600 " 小时"; prev=t} NR==1 {cmd="date -d \""$0"\" +%s"; cmd | getline t; close(cmd); prev=t}'间隔时长小于 200 的记录都要单独看一次堆栈,常见原因是连接池耗尽和OutOfMemoryError。连接池上限如果按默认的 8 配,并发 50 时就会大面积等待超时,这个值和压测并发数要一起定。
文档本身的交付也有坑。这类架构说明书通常以 docx 形式流转,用 WPS 打开时目录域和交叉引用容易错位,建议定稿后另存一份 PDF 作为评审版本,docx 只留作可编辑源文件。图表编号改成手动维护的固定文本,别依赖自动编号,否则任何一次增删章节都会让引用全部错位。
本文还有配套的精品资源,点击获取