2018 中山大学软件工程初级实训 – Agenda

本项目是 2018 年中山大学软件工程初级实训课程内容。项目为编写一个简单的会议管理系统,需要在完成用户添加、删除,会议添加、删除、修改等功能,主要目的是让学生了解软件工程设计的思想(例如三层模型)以及锻炼实际编码能力(主要为 C++11,运用 Lambda 表达式和 STL)。代码通过教师编写的 gtest 测试用例进行测试,每周六定时评测三次。本人在实现了基本要求之外,还实现了 C++/Python 接口,使用 Django 构建后端 RESTFul API,并使用 React 和 MaterialUI 构建前端页面。

完整代码参考:https://github.com/howardlau1999/sysu-agenda

Web 端在线演示:https://agenda.howardlau.me/

基本要求

基本的需求是实现一个程序,用户可以

  • 查询会议
  • 创建会议
  • 查询用户列表
  • 删除自己发起的会议
  • 退出自己参与的会议

会议包括会议标题、发起人、参与人(可以有一个或多个、不能和发起人一样、不能重复)、开始时间、结束时间等。任何用户在任何时间点只可以至多参与一个会议。会议的标题是唯一的,不可以重复。

数据封装类

在课程的第一阶段,需要完成的内容是类 DateUserMeetingStorage的编写,都属于比较基础的编码。其中 UserMeeting 为简单的数据封装类,提供 gettersetter。而 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

一开始想利用 asiorestbed 等库直接使用 C++ 来构建后端,并想用 Qt 构建 GUI,后来经过一点调查,决定尝试融合不同的语言,在 C++ 代码基础上,用 Python 构建后端(为什么不用 Node.js?当时没想到……而且以为它不能调用 C++ 模块,事实上 Node.js 可以通过 node-gyp 来构建 V8 引擎可以调用的 C++ 模块。)

首先需要将写代码将 AgendaService 类接口包装起来,具体方法参考这篇文章:将 C++ 程序编译成 Python 模块

之后便是通过 djangorestframework 包装这些方法,提供 API 供前端调用。这一部分主要都是编写包装代码。

构建前端

由于 Agenda 不是重交互类型的应用,所以可以使用 ReactReact 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,打开指向的域名验证成功与否,就完成部署了。