第一次作业 前后端分离计算器系统
学号:832401105
公网访问地址:http://43.128.135.64
提交状态:已确认完成
课程与作业信息
| 项目 | 内容 |
|---|---|
| 课程 | 软件工程 |
| 作业 | 第一次作业 前后端分离计算器系统 |
| 作业目标 | 完成一个由前端负责交互、后端负责表达式解析与计算、数据库负责持久化历史记录的计算器系统 |
| 核心要求 | 四则运算、复合表达式、异常处理、历史记录查询与删除、前后端独立仓库、部署与博客说明 |
| 其他参考 | 作业要求文档、前后端 README、前后端代码规范、系统总体设计文档 v2 |
| 截止时间 | 2026 年 10 月 7 日 23:59 |
目录
- Git 仓库与代码规范
- PSP 表
- 部署和评测说明
- 成品展示
- 需求分析
- 总体架构
- 项目结构
- 前端设计
- 后端设计
- API 设计
- 数据库设计
- 表达式计算模块
- 历史记录模块
- 异常处理
- 前后端交互流程
- 测试
- 关键代码说明
- 开发过程与收获
- 不足与后续改进
- 总结
Git 仓库与代码规范
| 项目 | 地址 |
|---|---|
| 前端仓库 | 832401105_calculator_frontend |
| 后端仓库 | 832401105_calculator_backend |
| 前端代码规范 | frontend codestyle.md |
| 后端代码规范 | backend codestyle.md |
前端和后端分别保存在两个 Git 仓库中。前端仓库只负责页面、状态和 HTTP 请求,后端仓库负责表达式处理、计算和 SQLite 数据访问。
PSP 表
预计时间来自项目中的832401105_Assignment1_PSP.xlsx,实际时间根据开发完成后的记录填写。预计总用时为 1015 分钟,目前已记录的实际用时为 1684 分钟;README 和 codestyle与“博客撰写”两项尚未提供实际时间。
| PSP 阶段 | 预计时间 | 实际使用时间 |
|---|---|---|
| 需求分析 | 10 min | 15 min |
| 系统设计 | 30 min | 104 min |
| Web 前端基础 | 30 min | 78 min |
| 前端界面开发 | 60 min | 146 min |
| HTTP API JSON 学习 | 60 min | 133 min |
| C++ 后端搭建 | 120 min | 217 min |
| 表达式解析模块 | 75 min | 287 min |
| SQLite 学习与设计 | 180 min | 96 min |
| 计算历史模块 | 75 min | 103 min |
| 前后端联调 | 120 min | 139 min |
| 异常处理与完善 | 60 min | 74 min |
| 测试 | 60 min | 88 min |
| 部署 | 30 min | 204 min |
| README 和 codestyle | 45 min | 20 min |
| 博客撰写 | 60 min | 30 min |
| 合计 | 1015 min | 1734 min |
部署和评测说明
公网地址
公网访问地址:http://43.128.135.64
项目已经完成公网部署,助教可以通过上述地址访问并测试系统。以下本地运行说明仍保留,用于代码检查、独立构建和问题排查。
本地运行环境
- 支持 C++17 的编译器;
- CMake 3.16 或更高版本;
- 任意静态文件服务器;
- 现代浏览器;
- 无需安装额外的 HTTP、JSON 或 SQLite 包,后端仓库已经包含相关源码。
后端启动
在后端仓库根目录执行:
cmake-Emake_directory data cmake-S.-Bbuild cmake--buildbuildWindows 启动命令:
.\build\calculator_backend.exeLinux 或 macOS 启动命令:
./build/calculator_backend后端监听http://127.0.0.1:8080。程序必须从后端仓库根目录启动,使相对路径data/calculator.db能够正确解析。
前端启动
在前端仓库根目录启动静态文件服务器:
python-mhttp.server5500然后访问:
http://127.0.0.1:5500评测建议
- 先启动后端,再打开前端页面;
- 分别测试四则运算、小数、优先级、括号和一元负号;
- 输入非法表达式和除零表达式,检查页面错误信息;
- 打开 History,确认成功计算已经写入数据库;
- 刷新前端后重新打开 History,确认记录仍然存在;
- 删除一条记录,确认页面重新查询并显示最新数据;
- 停止后端,再点击等号,确认前端不能独立产生新的有效结果。
成品展示
以下图片来自本地运行的真实前后端程序,使用独立的测试数据库生成。
1 主界面
主界面包含表达式输入框、结果区域、四列按键区和历史记录入口。
2 加法
前端发送12+8,后端返回结果 20。
3 减法
表达式20-7的后端计算结果为 13。
4 乘法
页面使用乘号×展示表达式,API 内部使用*。
5 除法
页面使用除号÷,后端按照二元除法进行求值。
6 小数
表达式12.5/2返回 6.25。
7 运算符优先级
1+2*3返回 7,说明乘法优先于加法。
8 括号
(1+2)*3返回 9,括号改变了默认计算顺序。
9 一元负号
3*-2返回 -6,解析器能够区分一元负号和二元减法。
10 非法表达式
1+*2被语法状态机拒绝,错误直接显示在结果区。
11 除零错误
10/(3-3)在求值阶段触发除零检查,不会写入历史记录。
12 历史记录
History 面板从右侧打开,按最新记录优先的顺序展示表达式、结果和时间。
13 刷新后历史仍然存在
刷新前端页面后重新查询数据库,之前的记录仍然存在,说明历史不是只保存在浏览器内存中。
14 删除指定历史记录
删除一条记录后,前端再次请求历史接口并刷新列表。最上方的3*-2记录已经被移除。
需求分析
功能需求
本系统需要实现四类主要功能:
- 后端完成加、减、乘、除;
- 后端解析包含小数、括号、优先级和一元正负号的复合表达式;
- 每次成功计算后把表达式、结果和时间写入数据库;
- 前端查询历史记录,并通过记录 ID 删除指定数据。
前后端分离要求
前端只发送原始表达式,不发送由浏览器计算得到的结果。停止后端后,页面仍然可以输入字符和点击按钮,但不能得到新的有效计算结果。
安全要求
后端不能使用eval、exec或类似机制执行用户输入。本项目实现了自己的 Tokenizer、Parser 和 Calculator,用户字符串不会作为通用程序代码执行。
非功能需求
- 界面在桌面端和移动端保持可用;
- API 使用清晰的 HTTP 方法和 JSON 数据;
- 历史记录必须持久化;
- 模块职责应清晰,方便测试和维护;
- README 应使助教能够独立构建和运行项目。
总体架构
用户 │ ▼ Web 前端 HTML CSS JavaScript │ │ HTTP JSON ▼ C++ HTTP 服务 │ ├── 请求校验与路由 ├── Tokenizer ├── ExpressionParser ├── Calculator └── Database │ ▼ SQLite前端和后端是两个独立程序。前端通过 Fetch API 访问三个后端接口;后端完成解析、求值和数据访问后返回 JSON。
功能结构图
计算器系统 │ ├── 表达式输入与编辑 │ ├── 数字和小数 │ ├── 四则运算符 │ ├── 括号 │ ├── 清空 │ └── 删除末尾字符 │ ├── 表达式计算 │ ├── JSON 与字段校验 │ ├── 词法分析 │ ├── 语法检查 │ ├── 中缀转后缀 │ └── 栈求值 │ ├── 结果与错误展示 │ ├── 成功结果 │ ├── 输入错误 │ └── 后端或数据库错误 │ └── 历史记录管理 ├── 保存成功计算 ├── 查询全部记录 ├── 刷新后恢复记录 └── 删除指定记录项目结构
前端仓库
832401105_calculator_frontend/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── api.js │ ├── ui.js │ └── app.js ├── docs/ │ └── 前端设计文档_v1.md ├── README.md └── codestyle.mdapi.js只处理网络请求,ui.js保存表达式并更新 DOM,app.js绑定事件并协调 API 与 UI。
后端仓库
832401105_calculator_backend/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── parser/ │ │ ├── Token.h │ │ ├── Tokenizer.h │ │ ├── Tokenizer.cpp │ │ ├── ExpressionParser.h │ │ └── ExpressionParser.cpp │ ├── calculator/ │ │ ├── Calculator.h │ │ └── Calculator.cpp │ ├── database/ │ │ ├── Database.h │ │ └── Database.cpp │ ├── api/ │ │ └── ApiServer.h/.cpp │ ├── common/ │ │ └── Error.h/Result.h │ └── test/ │ ├── tokenizer_test.cpp │ ├── parser_test.cpp │ ├── calculator_test.cpp │ └── database_test.cpp ├── third_party/ │ ├── httplib.h │ ├── json.hpp │ └── sqlite/ ├── README.md └── codestyle.md当前 HTTP 路由仍直接写在main.cpp中,ApiServer、Error和Result是预留文件。它们体现了后续分层方向,但不能描述为已经完成的独立模块。
前端设计
前端使用 HTML5、CSS3 和原生 JavaScript,不依赖 npm 或前端框架。
页面结构
- 表达式输入框允许直接键盘编辑;
- 结果区显示计算结果或错误;
- 四列网格提供数字、运算符、括号、清空、退格和等号;
- History 按钮打开右侧历史面板;
- 遮罩使历史面板打开时的主界面不可操作。
表达式状态
ui.js中的rawExpression保存后端格式。显示时把*和/转换为×和÷,文本框输入后再转换回 API 格式。
当前按钮输入追加到表达式末尾,退格按钮删除最后一个字符。直接编辑文本框时可以使用浏览器原生光标,但按键输入尚未按照selectionStart插入到当前光标位置。
请求状态
计算期间等号按钮会被禁用并显示...。请求结束后由finally恢复按钮,避免重复提交和按钮无法恢复的问题。
后端设计
后端使用 C++17。主要依赖为:
| 组件 | 版本 | 用途 |
|---|---|---|
| cpp-httplib | 0.58.0 | HTTP Server 与路由 |
| nlohmann/json | 3.12.0 | JSON 校验、解析和序列化 |
| SQLite | 3.53.4 | 历史记录持久化 |
后端启动时打开data/calculator.db、初始化数据表、注册路由并监听127.0.0.1:8080。只有表达式成功计算且历史记录成功写入后,计算接口才返回成功。
API 设计
| 方法 | 路径 | 作用 | 成功响应 |
|---|---|---|---|
| POST | /api/calculate | 校验并计算表达式 | { "success": true, "result": 9.0 } |
| GET | /api/history | 查询全部历史记录 | JSON 数组 |
| DELETE | /api/history/{id} | 删除指定记录 | { "success": true, "deleted_id": 1 } |
| OPTIONS | .* | CORS 预检 | 204 |
计算请求:
{"expression":"(1+2)*3"}计算错误:
{"success":false,"error":"Division by zero"}历史查询接口当前直接返回数组:
[{"id":1,"expression":"(1+2)*3","result":9.0,"created_at":"2026-10-07 15:05:00"}]输入和计算错误使用 400,数据库保存或查询异常使用 500。当前删除接口没有检查受影响行数,因此不存在的 ID 也会返回成功;这是后续需要修复的接口语义问题。
数据库设计
系统只使用一张历史表:
CREATETABLEIFNOTEXISTScalculation_history(idINTEGERPRIMARYKEYAUTOINCREMENT,expressionTEXTNOTNULL,resultREALNOTNULL,created_atTEXTNOTNULLDEFAULTCURRENT_TIMESTAMP);| 字段 | 说明 |
|---|---|
id | 自增主键,也是删除接口使用的记录标识 |
expression | 用户提交的原始表达式 |
result | 后端计算得到的double结果 |
created_at | SQLite 自动生成的 UTC 时间 |
查询使用ORDER BY id DESC,最新记录排在最前面。插入和删除使用预处理语句和参数绑定,不通过字符串拼接 SQL。
表达式计算模块
完整流程如下:
原始字符串 ↓ Tokenizer ↓ Token 序列 ↓ 语法状态机和一元运算符识别 ↓ 调度场算法 ↓ 后缀表达式 ↓ 数字栈求值Tokenizer
Tokenizer 从左到右识别数字、+ - * /和括号。数字中出现第二个小数点时抛出Invalid number format,其他不支持的字符抛出Invalid character。
当前实现只忽略普通空格,且小数必须以数字开头。因此0.5合法,.5不合法,5.可以解析。
语法状态机
Parser 使用两个状态:
ExpectOperand:允许数字、左括号、一元正号和一元负号;ExpectOperator:允许二元运算符和右括号。
同一个-在ExpectOperand状态下转换为u-,在ExpectOperator状态下保留为二元减法。这个设计同时完成语法校验和一元运算符识别。
中缀转后缀
调度场算法使用输出数组和运算符栈。优先级为:
| 运算符 | 优先级 |
|---|---|
u+、u- | 3 |
*、/ | 2 |
+、- | 1 |
例如:
3 * -2 + (4 - 1)转换为:
3 2 u- * 4 1 - +后缀求值
Calculator 使用std::stack<double>。数字压栈;一元运算符弹出一个值;二元运算符先弹出右操作数,再弹出左操作数。所有 Token 处理结束后,栈中必须只剩一个结果。
整体时间复杂度和额外空间复杂度都是O(n)。
计算历史模块
成功计算后的处理顺序为:
计算成功 ↓ Database::insertHistory ↓ SQLite 写入表达式和结果 ↓ API 返回成功结果查询历史时,后端读取全部记录并生成 JSON 数组。前端使用事件委托处理动态生成的删除按钮。删除成功后不直接假设本地列表正确,而是再次请求GET /api/history。
刷新页面后,前端的 JavaScript 状态会被清空,但 SQLite 文件仍然存在。重新打开 History 时,记录由数据库重新读取,因此满足持久化要求。
异常处理
| 错误 | 检测位置 | HTTP 状态 |
|---|---|---|
| JSON 非法 | HTTP 处理层 | 400 |
缺少expression | HTTP 处理层 | 400 |
expression不是字符串 | HTTP 处理层 | 400 |
| 空表达式 | Parser | 400 |
| 非法字符 | Tokenizer | 400 |
| 非法数字格式 | Tokenizer | 400 |
| 表达式结构错误 | Parser | 400 |
| 括号不匹配 | Parser | 400 |
| 除零 | Calculator | 400 |
| 历史保存失败 | Database | 500 |
| 历史查询或删除异常 | Database | 500 |
前端优先显示响应中的error字段。计算错误显示在结果区,历史请求错误显示在历史面板中,不使用弹窗打断输入。
前后端交互流程
计算
用户输入表达式并点击等号 ↓ 前端 POST /api/calculate ↓ 后端校验 JSON 和字段 ↓ Tokenizer → Parser → Calculator ↓ 成功后写入 SQLite ↓ 返回 JSON 结果 ↓ 前端更新结果区查询历史
用户点击 History ↓ 前端打开面板并显示 Loading ↓ GET /api/history ↓ 后端按 ID 倒序查询 SQLite ↓ 前端渲染记录删除记录
用户点击某条记录的 Delete ↓ DELETE /api/history/{id} ↓ 后端删除数据库记录 ↓ 前端再次 GET /api/history ↓ 显示最新列表测试
已执行的核心链路测试
| 测试输入或操作 | 预期结果 | 实际结果 |
|---|---|---|
12+8 | 20 | 通过 |
20-7 | 13 | 通过 |
6*7 | 42 | 通过 |
84/12 | 7 | 通过 |
12.5/2 | 6.25 | 通过 |
1+2*3 | 7 | 通过 |
(1+2)*3 | 9 | 通过 |
3*-2 | -6 | 通过 |
1+*2 | Syntax error | 通过 |
10/(3-3) | Division by zero和 HTTP 400 | 通过 |
| 刷新后查询历史 | 记录仍存在 | 通过 |
| 删除指定记录 | 数据库删除并刷新列表 | 通过 |
这些测试使用独立的临时数据库,不会修改开发数据库。
当前测试结构
后端src/test中有 Tokenizer、Parser、Calculator 和 Database 四个开发测试程序。当前 CMake 默认只构建database_test,还没有接入 CTest 或其他自动化测试框架。后续应把交互式测试改为固定输入和断言,形成可重复的回归测试。
关键代码说明
1 前端只发送表达式
asyncfunctioncalculateExpression(expression){constresponse=awaitfetch(`${API_BASE_URL}/api/calculate`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({expression:expression})});constdata=awaitresponse.json();if(!response.ok){thrownewError(data.error||"Calculation failed");}returndata;}前端没有计算表达式,只提交原始字符串并显示后端返回的result。这样满足前后端分离要求,也避免浏览器和服务端维护两套计算规则。
2 后端计算流水线
autotokens=Tokenizer::tokenize(expression);autopostfix=ExpressionParser::toPostfix(tokens);result=Calculator::evaluate(postfix);分词、解析和求值被拆成三个步骤。每一层只处理一种问题,便于定位非法字符、语法错误或计算错误。
3 一元运算符识别
caseTokenKind::Plus:token.text="u+";token.type=TokenKind::UnaryPlus;operators.push(token);break;caseTokenKind::Minus:token.text="u-";token.type=TokenKind::UnaryMinus;operators.push(token);break;这段逻辑只会在 Parser 期待操作数时执行。因此表达式开头、左括号后和二元运算符后的+/-会被识别为一元运算符。
4 二元运算的操作数顺序
doubleright=numbers.top();numbers.pop();doubleleft=numbers.top();numbers.pop();后缀表达式求值时,先弹出的是右操作数。减法和除法不能交换左右顺序,因此代码显式使用left operator right。
5 除零检查
caseTokenKind::Divide:if(right==0){throwstd::runtime_error("Division by zero");}result=left/right;break;除零属于求值阶段错误。异常被 HTTP 处理层转换为 400 响应,失败计算不会写入数据库。
6 SQLite 参数绑定
sqlite3_bind_text(statement,1,expression.c_str(),-1,SQLITE_TRANSIENT);sqlite3_bind_double(statement,2,result);SQL 模板与数据分开,表达式不会被直接拼接进 SQL 字符串。这样既使代码更清晰,也减少 SQL 注入风险。
7 删除后重新查询
awaitdeleteHistory(id);awaitloadHistory();数据库是历史记录的最终数据源。删除成功后重新查询,可以保证页面展示与数据库一致。
开发过程与收获
从 Git 提交顺序可以看到,我先建立前后端仓库和基础目录,然后依次完成 HTTP 与 JSON 环境、表达式解析、后缀求值、一元运算符、前后端计算联调、历史记录和界面完善。这种顺序先打通核心计算,再增加持久化和展示,减少了同时调试多个模块的复杂度。
一元负号与减法的歧义
-既可以表示减法,也可以表示负号。仅根据字符本身无法判断。我使用ExpectOperand和ExpectOperator状态记录当前位置需要什么,从而把-5和3*-2中的减号转成u-。这让我更清楚地理解了词法分析和语法分析的区别。
运算符优先级和括号
直接边读取边计算很容易在优先级和括号上出错。调度场算法把中缀表达式转换成后缀表达式后,Calculator 只需要处理顺序 Token。解析和计算职责分开后,错误更容易定位。
前端显示符号与 API 符号
界面使用×和÷更符合计算器习惯,但后端只识别*和/。我在 UI 层增加formatExpression()和normalizeExpression(),保持 API 表达式简单,同时不影响展示效果。
前后端联调
前端和后端运行在不同端口,浏览器请求需要 CORS。后端统一设置允许来源、方法和Content-Type,并处理 OPTIONS 预检。联调过程也说明接口字段必须保持一致,例如当前错误字段是error,历史查询成功响应是数组而不是带history字段的对象。
数据库路径和持久化
SQLite 文件使用相对路径data/calculator.db。这要求程序从正确的工作目录启动,并提前创建data目录。刷新页面后重新读取数据库的测试让我确认了“页面状态”和“持久化状态”是两个不同层次。
主要收获
- 理解了前后端分离不是目录分开,而是职责和计算位置分开;
- 掌握了 HTTP 方法、状态码、JSON 和 CORS 的基本使用;
- 实现了 Tokenizer、语法状态机、调度场算法和后缀表达式求值;
- 学会使用 SQLite 预处理语句进行持久化操作;
- 认识到 README、API 契约、测试和实际代码需要同步维护。
不足与后续改进
- 完善部署配置管理。项目已经部署到公网,但开发环境中的后端监听地址和前端 API 地址仍采用固定配置。后续可统一改为环境配置,并增加 HTTPS 反向代理。
- 修复历史记录渲染安全问题。当前部分历史内容通过
innerHTML插入,应改用createElement()和textContent。 - 完善删除接口语义。使用
sqlite3_changes()判断是否实际删除记录,不存在时返回 404。 - 拆分 API 层。将
main.cpp中的路由和处理函数迁移到ApiServer,减少入口文件职责。 - 统一错误类型。实现
Error.h和Result.h,用错误码稳定映射 HTTP 状态,而不是只传递字符串异常。 - 增加自动化测试。把四个开发测试接入 CTest,并增加 API 与前端端到端测试。
- 完善输入体验。让按键输入和退格遵循文本光标位置,增加 Enter 提交和更明显的键盘焦点样式。
- 改善历史长列表。增加滚动容器、分页或数量限制,并完善 Delete 按钮样式。
- 限制资源使用。对请求体和表达式长度设置上限,公网部署时限制 CORS 来源和请求频率。
- 统一时间和结果格式。将 SQLite UTC 时间转换为本地时间,并规定浮点结果的有效数字和尾零规则。
总结
本项目完成了一个前后端分离的 Web 计算器。前端负责输入、展示和历史交互;C++ 后端独立完成表达式校验、调度场转换和栈求值;SQLite 保存成功计算并支持查询与删除。四则运算、复合表达式、小数、括号、一元负号、非法表达式、除零、历史持久化和指定记录删除均已通过本地核心链路测试。
项目已经完成公网部署和作业提交,助教可以通过公开地址进行评测。后续仍可继续完善自动化测试、接口语义、安全渲染和部署配置,使项目从课程作业进一步发展为更稳定的 Web 应用。