本项目是 2018 年中山大学软件工程初级实训课程内容。项目为编写一个简单的会议管理系统,需要在完成用户添加、删除,会议添加、删除、修改等功能,主要目的是让学生了解软件工程设计的思想(例如三层模型)以及锻炼实际编码能力(主要为 C++11,运用 Lambda 表达式和 STL)。代码通过教师编写的 gtest
测试用例进行测试,每周六定时评测三次。本人在实现了基本要求之外,还实现了 C++/Python 接口,使用 Django 构建后端 RESTFul API,并使用 React 和 MaterialUI 构建前端页面。
完整代码参考:https://github.com/howardlau1999/sysu-agenda
Web 端在线演示:https://agenda.howardlau.me/
基本要求
基本的需求是实现一个程序,用户可以
- 查询会议
- 创建会议
- 查询用户列表
- 删除自己发起的会议
- 退出自己参与的会议
会议包括会议标题、发起人、参与人(可以有一个或多个、不能和发起人一样、不能重复)、开始时间、结束时间等。任何用户在任何时间点只可以至多参与一个会议。会议的标题是唯一的,不可以重复。
数据封装类
在课程的第一阶段,需要完成的内容是类 Date
、User
、Meeting
、Storage
的编写,都属于比较基础的编码。其中 User
和 Meeting
为简单的数据封装类,提供 getter
和 setter
。而 Date
类主要完成的任务是日期的存储(但不涉及计算),只需要完成和指定格式字符串的相互存取、验证日期合法性以及比较日期前后即可,都属于比较简单而基础的内容。其中可能比较容易出错的是验证日期合法性,下面给出一份参考代码:
bool is_leap_year(int year) {
return ((t_date.m_year % 4 == 0 && t_date.m_year % 100 != 0) ||
t_date.m_year % 400 == 0);
}
bool Date::isValid(const Date& t_date) {
static const int days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
if (t_date.m_year < 1000 || t_date.m_year > 9999) return false;
if (t_date.m_month < 1 || t_date.m_month > 12) return false;
if (t_date.m_day < 1) return false;
if (t_date.m_month == 2 && is_leap_year(t_date.year)) {
if (t_date.m_day > 29) return false;
} else if (t_date.m_day > days[t_date.m_month - 1]) return false;
if (t_date.m_hour < 0 || t_date.m_hour > 23) return false;
if (t_date.m_minute < 0 || t_date.m_minute > 59) return false;
return true;
}
存储类
而 Storage
类则负责完成数据增删改查(不负责操作的合法性验证)和序列化、反序列化,使用 csv
文件格式存储。难点在于 csv
文件的读取和写入。不过只要多加细心,就可以通过测试。csv
的读取最好是一个个字符读取,并维持一个状态机状态,实现起来逻辑比较清晰:
std::list<std::list<std::string>> parse_csv(std::istream &file) {
std::list<std::list<std::string>> lines;
while (!file.eof()) {
std::list<std::string> line_items;
bool same_line = true;
int ch;
while (same_line && (ch = file.get()) != EOF) {
std::string item;
if (ch == '"') {
while ((ch = file.get()) != EOF) {
if (ch == '"') {
ch = file.get();
if (ch == ',') break;
if (ch == '\n' || ch == EOF) {
same_line = false;
break;
}
}
item += ch;
}
line_items.push_back(item);
} else {
item += ch;
while ((ch = file.get()) != EOF) {
if (ch == ',') break;
if (ch == '\n') {
same_line = false;
break;
}
item += ch;
}
line_items.push_back(item);
}
}
if (!line_items.empty()) lines.push_back(line_items);
}
return lines;
}
csv
的写入相对来说就比较简单了,由于课程要求每一个字段都用双引号包裹起来,所以就不存在判断是不是需要加双引号的情况,但需要注意的是, csv
在遇到字段数据中包含双引号,逗号和换行符的时候,是需要加双引号的,并且双引号需要写成两个双引号的形式,用一个函数预处理一下字符串就好了:
std::string csv_value(const std::string &value) {
std::string formatted;
for (auto ch : value) {
formatted += ch;
if (ch == '"') formatted += ch;
}
return std::move(formatted);
}
逻辑处理类
AgendaService
类是整个程序最难编写的部分,在这个类里,需要对输入做合法性检验,这其中有许多的细节需要考虑:
- 任何对数据的修改,都需要验证操作用户的合法性
- 任何涉及对会议参与者、发起者的增加,都需要验证用户存在性,并且用户在时间段内是空闲的,并且不会产生重复的参与者
- 一旦某个会议没有参与者,则需要删除这个会议
其他的话就没有什么需要特别注意的地方了。据说这个类的单元测试代码有超过五百行,细节覆盖很全面,所以比较难全部通过测试用例,需要细心和耐心来修正代码。
构建 RESTFul API
一开始想利用 asio
、restbed
等库直接使用 C++
来构建后端,并想用 Qt
构建 GUI,后来经过一点调查,决定尝试融合不同的语言,在 C++
代码基础上,用 Python
构建后端(为什么不用 Node.js
?当时没想到……而且以为它不能调用 C++
模块,事实上 Node.js
可以通过 node-gyp
来构建 V8
引擎可以调用的 C++
模块。)
首先需要将写代码将 AgendaService
类接口包装起来,具体方法参考这篇文章:将 C++ 程序编译成 Python 模块。
之后便是通过 djangorestframework
包装这些方法,提供 API
供前端调用。这一部分主要都是编写包装代码。
构建前端
由于 Agenda 不是重交互类型的应用,所以可以使用 React
和 React Material UI
,可以比较方便地做出比较好看的界面。前端采用的是 SPA
单页应用技术,使用 JWT 来进行用户身份验证。在用户创建会议时,添加参与人的时候会有自动补全,对会议的操作也很简单直接。
最终实现效果如下图所示:
部署
在本地调试好前后端之后,就可以在服务器上部署了。前端没有动态生成的 HTML
,全部是静态文件,最好使用 nginx
等服务器来处理请求。而 django
应用一种部署方式就是通过 uwsgi
接口(python manage.py runserver
仅供调试使用)。为了避免跨域问题,API 和前端文件需要部署在同一个域名和端口下。
部署前端
首先对前端进行打包操作,运行:
npm run build
如果不想要 .map
文件,就运行:
GENERATE_SOURCEMAP=false npm run build
将生成的 build
文件夹放在喜欢的地方,记录好路径。
部署后端
在 django
的应用目录下运行命令
uwsgi --socket webagenda.sock --module webagenda.wsgi --chmod-socket=666 &
启动一个 uwsgi
服务进程。
配置 nginx
在 nginx
配置文件里配置好静态前端文件的地址,并且指示其将 API 请求通过 sock
文件的方式传递给 uwsgi
服务进程。
upstream agenda {
server unix:///path/to/webagenda/webagenda.sock;
}
server {
listen 443 ssl http2 ;
listen [::]:443 ssl http2;
ssl on;
ssl_certificate /path/to/your.crt;
ssl_certificate_key /path/to/your.key;
server_name your.domain;
root /path/to/your/build;
index index.html index.htm;
location /api/v1 {
uwsgi_pass agenda;
include uwsgi_params;
}
}
重启 nginx
,打开指向的域名验证成功与否,就完成部署了。