一键评教软件设计及代码分析

· 5144字 · 11分钟

大到一个企业级应用,小到类似于该一键评教软件,都有自己的软件架构设计。通常来说,对于同一个需求,实现方式是多种多样的。

如何设计应用逻辑,如何组织代码模块,如何确定目录结构等等, 都需要在编码之前进行考虑。每个人的编码风格不尽相同,写出来的代码也各有千秋。要想得出一个最佳实践,就要不断总结自己的过往经验,学习别人的优秀设计,并再次将其运用于实践才能真正理解其中的奥义。

本文主要就是介绍一键评教程序的软件结构设计,并对代码进行简要分析,同时也会讲述一些自己遇到的问题。

1. 什么是一键评教 🔗

首先声明,该程序的本质目的是用于学习交流。

每学期进行教学评估的时候,都要评很多教师,每个教师都有很多选项,再加上教务系统网站比较老旧,操作不方面,评教总是要花很长时间。

“一键评教,用过都说好”。线上地址 http://pj.fyscu.com,源码 https://github.com/nodejh/teach_evaluation

爱美之心人皆有之,我也很喜欢好用又好看的事物,所以我在写代码的时候也尽量做到好看又好用。软件截图如下,是不是很好看:

输入账号的页面

输入账号的页面

2. 功能分析 🔗

需求很明确,就是能够在一个网页上实现点击按钮自动评估所有教师。那么要实现这样的需求,该怎么去做呢?

先来想想我们通常手动评教的步骤:

  1. 登录进入教务系统网站
  2. 找到教学评估链接并进入,这个时候就能看到所有需要进行评估的教师列表
  3. 从教师列表中点击某个教师,进入到该教师的教学评估页面
  4. 填写各种需要填写的表单
  5. 填写完毕之后,点击提交按钮进行评教
  6. 一切正常的情况下,则对该教师评教成功
  7. 然后返回到教师列表页面,选择下一个需要评估的教师
  8. 重复 3-7 这五个步骤,直到所有教师评估完毕

要用程序实现一键评教,其实就是用程序模拟上面的步骤。所以程序要实现的主要功能有:

  • 模拟登录,获取 cookie
  • 获取需要评估的教师列表
  • 对列表中的每个教师进行评教

然后我们需要一个用户界面来让用户进行操作。这个界面可以是 APP,也可以是网页。由于网页更方便更利于传播,所以我选择了网页。所以我们就还需要一个 HTTP 服务器,用来提供静态页面资源,并且接收并响应用户操作后发送的 HTTP 请求。

3. 软件设计 🔗

软件设计主要从三个方面来说明。一是技术选择,二是软件架构,三是目录结构。

3.1 技术选择 🔗

首先是各种技术的选择,包括前后端编程语言(语法)、第三方模块的选择、和服务器部署。

3.1.1 后端语言 🔗

该程序后端使用的是 Node.js。我的 Node.js 版本是 7.3。大量使用了 ES6 的语法,比如 Promise、模板字符串、箭头函数。只要你的 Node.js 版本 >= 6.0 应该都是可以运行的。

3.1.2 第三方模块 🔗

在开发程序之前,我想要尽量让应用的体积足够小,所以我尽量不使用第三方模块。

最终我只使用了 cheerioicon-lite 这两个第三方包。

  • cheerio 主要用来分析抓取到的 HTML 文档。其实最开始也想不用 cheerio,直接使用正则表达式来分析页面的,但正则表达式编写麻烦,我的能力也有限,所以最终选择了使用 cheerio
  • icon-lite 则是用来对 GBK 文本进行解码。因为教务系统网站使用的是 GBK 的编码,所以直接抓取的结果是乱码的。除了 icon-lite 这个包,我找不到其他的可以解决乱码问题的方案了。

3.1.3 编码规范 🔗

然后使用了 ESLint 来规范代码。主要是用的是 airbnb 的 Eslint 规则,并且根据自己的喜好对 .eslintrc 作了配置。具体配置在源码 .eslintrc 中可以看到。

3.1.4 前端技术 🔗

为了减小代码体积,提高加载速度,节省带宽,前端没有使用任何第三方 JS 库。并且这只是一个小应用,有没有必须使用庞大(相比该程序而言显得庞大)的第三方库。

前端使用的是 ES5 的语法。最开始想用 ES6 来写的,但 ES6 写好的代码还需要编译成 ES5 才能在浏览器运行,并且还需要引入各种 polyfill,最终还是决定使用 ES5。

前端唯一使用了第三方资源的,只有两个字体图标了。一个是 heart 的图标 ❤️,毕竟是用心写的代码,Made with ❤️ by nodejh;还有一个就是“关闭”小图标。图标使用的是 IcoMoon 的字体图标库,可以自己在里面找到需要的图标然后下载使用。

该程序只有 index.html 这一个 HTML 文件,所以本质上也是一个单页应用。所有的操作和交互都在这一个页面完成。

3.1.5 前端代码压缩 🔗

为了进一步压缩前端资源文件的体积,所以对静态资源进行了压缩。

压缩 CSS 使用的是在线压缩工具 CSS Compressor

压缩 JS 使用的是 UglifyJS2

3.1.6 部署 🔗

完成编码后,代码是部署在 Ubuntu 16.04 上的,然后使用了 pm2 进行进程的管理。

3.2 软件架构 🔗

程序的整体架构主要分为三层,可以就将其理解为 MVC 的三个层次。

MVC 是一种设计模式,设计模式不是一层不变的,我们需要根据自己的实际业务灵活运用。MVC 是一个很经典的设计模式,生活中的很多事物,我们也可以根据 MVC 对其进行定义。就拿人来进行类比,大脑就是 C(Controller),控制着人的一切活动。躯体外表就是 V(View) 层,一方面是表现着一个人的外观,另一方面是人的各种活动的外在表现。体内各种器官比如心脏、肺等就相当于 M(Model),从表面可能并不能直观看到 M 层的作用,但它受大脑控制,进行着血液循环呼吸系统等重要功能,而这些器官可能又跟躯体相互作用,比如影响人的精神面貌或高矮胖瘦。

说正经的。

首先是该一键评教程序的 M 层,包括页面抓取、页面分析和评教等功能模块。

然后是 V 层,主要是前端页面,直接给用户使用,与用户交互的界面。比如用户点击“开始”按钮的时候,就向 C 层发送一个 HTTP 请求。

C 是控制中心,接收 V 层的 HTTP 请求,根据 HTTP 请求决定调用哪些 M 层的模块,然后将模块调用后的处理结果返回给 V。

这样一个事件的处理流程可能就是:

V(HTTP 请求)---> C (调用 M 的对应模块)---> M(返回处理结果) ---> C(HTTP 响应) ---> V

3.3 目录结构 🔗

了解了软件的整体架构之后,就来看看代码的目录结构,代码的目录结构也完美地印证了这三层架构。

代码的主要目录/子目录及其功能如下:

|____app.js  # 入口文件
|____controller  # C 层目录,定义了各种控制器
| |____evaluate.js  # 评教的控制器
| |____evaluationList.js  # 获取需要评教列表的控制器
| |____staticServer.js  # 静态服务器控制器
|____helper  # 一些自定义的功能模块
| |____colors.js  # 十六进制颜色代码,主要是为了改变 console.log 的颜色
| |____dateformat.js  # 时间格式化
| |____getContentType.js  # 获取文件后缀名对应的 Content-Type,用于静态服务器
| |____log.js  # 自定义的彩色 console.log() 输出,告别满屏黑白日志
| |____request.js  # HTTP 请求的封装
|____models  # M 层目录,定义了各种模块及实现
| |____evaluate.js  # 评教功能模块
| |____getEvaluationList.js  # 获取需要评教的教师列表
| |____loginZhjw.js  # 模拟登录教务系统
| |____showEvaluatePage.js  # 显示某个具体的评教页面

看完目录结构,再回头看看软件的三层架构,肯定就清晰很多了。

4. 代码分析 🔗

接下来再对一些重要的功能模块以及涉及到的代码进行简要分析。相信了解完代码的执行流程之后,对软件的整体架构理解,定会再进一步。

4.1 app.js 🔗

app.js 是整个项目的入口文件,启动项目的时候使用 node app.js 即可启动。

在 app.js 里面,主要是创建了 HTTP Server,然后根据请求的路径,调用对应的控制器:

if (method === 'POST' && pathname === '/api/evaluationList') {
    // 模拟登录,获取需要评教的老师列表
    return evaluationListController(req, res);
  }

  if (method === 'POST' && pathname === '/api/evaluate') {
    // 评教
    return evaluateController(req, res);
  }

  if (method === 'GET') {
    // 所有 GET 请求都当作是请求静态资源
    return staticServerController(req, res);
  }

当请求方法是 POST 且路径是 /api/evaluationList 时,就说明前端是发送的一个获取需要评估的教师列表的请求,所以紧接着执行 evaluationListController(req, res);,调用该控制器,并且使用 return 来停止代码的执行。

如果有新的 API 的请求,都可以在这里加。

如果所有的自定义的请求及路径都不满足,并且请求的方法是 GET,那就当作是请求静态资源文件,如 HTML、CSS、JS 或图片等。这里就调用 staticServerController(req, res)staticServerController 是在 Controller 里面定义的返回静态文件的方法。

如果 GET 请求也不是,则返回 400 Bad Request

然后程序监听了 5000 端口,这样发送请求到 5000 端口,代码就能接收到请求并进行处理了。

4.2 静态资源服务器 🔗

前面已经提到了,staticServerController 是在 Controller 里面定义的返回静态文件的方法,也就是一个静态资源服务器。

因为我们的软件很简单,所以完全没有必要使用 express 或 koa 等框架,自己写一个简单的静态服务器完全足够应对所有业务需求了。

主要代码如下,代码优美,注释详尽,通俗易懂:

/**
 * 静态服务器
 * @param  {object} req request
 * @param  {object} res response
 * @return {null}   null
 */
const staticServerController = (req, res) => {
  let pathname = url.parse(req.url).pathname;
  if (path.extname(pathname) === '') {
    // 没有扩展名,则指定访问目录
    pathname += '/';
  }

  if (pathname.charAt(pathname.length - 1) === '/') {
    // 如果访问的是目录,则添加默认文件 index.html
    pathname += 'index.html';
  }
  // 拼接实际文件路径
  const filepath = path.join(__dirname, './../public', pathname);
  fs.access(filepath, fs.F_OK, (error) => {
    if (error) {
      res.writeHead(404);
      res.end('<h1>404 Not Found</h1>');
      return false;
    }
    const contentType = getContentType(filepath);
    res.writeHead(200, { 'Content-Type': contentType });
    // 读取文件流并使用管道将文件流传输到HTTP流返回给页面
    fs.createReadStream(filepath)
      .pipe(res);
  });
};

这里需要稍微留意的是 getContentType 这个方法,这个方法的定义和实现被放在了 helper/getContentType.js 里面,其主要作用,就是根据请求路径的后缀名来确定 HTTP Response 里面的 Content-Type 类型,以便浏览器或客户端识别:

/**
 * 获取 Content-Type
 * @param  {string} filepath 文件路径
 * @return {string}          文件对应的 Conent-Type
 */
const getContentType = (filepath) => {
  let contentType = '';
  const ext = path.extname(filepath);
  switch (ext) {
    case '.html':
      contentType = 'text/html';
      break;
    case '.js':
      contentType = 'text/javascript';
      break;
    case '.css':
      contentType = 'text/css';
      break;
    case '.gif':
      contentType = 'image/gif';
      break;
    case '.jpg':
      contentType = 'image/jpeg';
      break;
    case '.png':
      contentType = 'image/png';
      break;
    case '.ico':
      contentType = 'image/icon';
      break;
    case '.manifest':
      contentType = 'text/cache-manifest';
      break;
    default:
      contentType = 'application/octet-stream';
  }
  return contentType;
};

这样我们的一个简单的静态资源文件服务器就成型了,单独把这两段代码拿出去也是完全可以运行的。

当用户请求 localhost:5000 的时候,根据上面的代码,就会去寻找 public/index.html 这个文件然后返回给客户端。

index.html 就是我们的前端页面。

4.3 模拟登录 🔗

要想获取评教列表或进行评教,第一步就是登录教务系统。经抓包分析,教务系统使用的是 session cookie 的认证机制,关于如何抓包分析,可以看我的另一篇文章《模拟登录某某大学图书馆系统》[http://nodejh.com/post/Crawler-for-SCU-Libirary/]。这一步我们需要获取登录后的 cookie

登录的时候,是向 http://202.115.47.141/loginAction.do 发送的 POST 请求,请求的 Content-Typeapplication/x-www-form-urlencoded ,参数是 zjh=xx&mm=xx

曾经教务系统可以使用 GET 方式登录,所有有一种快捷登录方式,就是在浏览器地址栏 http://202.115.47.141/loginAction.do?zjh=[你的学号]&&mm=[你的密码]。而且这种方法可以绕过“登录人数已满”的限制。这在选课时期,这种强制的登录方式还是很好用的。不过 GET 方法也有缺点就是,你的学号和密码就直接暴露了,不安全。曾经还通过 Google 搜索,搜到了某个同学的账号及密码。现在教务系统估计是升级了禁止了这个方法。

模拟登录教务系统的程序在 models/loginZhjw.js 里面,详细代码就不贴了,总的来说,就是通过 Node.js 的 HTTP 模块,设置一个自定义的 HTTP Headers 信息,然后发送 HTTP 请求。当然,其他任何编程语言道理都一样。

模拟登录后,教务系统会返回 HTTP Response。HTTP Response 的 Content-Type 都是 text/html,也就是说返回的始终都是 HTML 文本。所以我们就可以根返回的 HTML 文本的内容判断是否登录成功。

如果文本包含下面 errorText 对象的属性字符串之一,都是登录失败:

 const errorText = {
        number: '你输入的证件号不存在,请您重新输入!',
        password: '您的密码不正确,请您重新输入!',
        database: '数据库忙请稍候再试',
        notLogin: '请您登录后再使用',
      };

同时,也经过抓包发现,登录成功后返回的 HTML 文本的 title 部分是:

<title>学分制综合教务</title>

而其他情况都不是。所以就可以大致判断,除了上面几种 errorText 是登录失败之前,只有返回的 HTML 包含 <title>学分制综合教务</title> 才是返回成功。

登录成功后的 HTTP Response Headers 部分含有一个 set-cookie 属性,而这个属性的值就是登录成功后的 cookie。我们抓那么多包,做了那么多准备,找的就是它。所以最终从登录成功的响应中取出 cookie 的代码如下:

const cookie = result.headers['set-cookie'].join().split(';')[0];

之后获取需要评估的教师列表和评教,都需要在发送 HTTP 请求时在 HTTP Headers 里面带上该 cookie。

4.3 获取需要评估的教师列表 🔗

获取需要评估的教师列表就简单很多了,发送的是 GET 请求,然后在 HTTP Headers 里面设置 Cookie 即可,其头发送 HTTP 请求的头信息大概如下:

const options = {
    hostname: '202.115.47.141',
    port: 80,
    path: '/jxpgXsAction.do?oper=wjShow',
    method: 'POST',
    headers: {
      Cookie: data.cookie,
      'Content-Type': 'application/x-www-form-urlencoded',
      'Content-Length': Buffer.byteLength(postData),
    },
  };

4.4 进行评教 🔗

获取到教师列表之后,就可以进行评教了。但这里有一个坑,就是进行评教之前,必须先访问评教页面,再发送评教请求。不然是无法评教成功的。就是这个问题,导致我纠结了好久。

也就是说,用程序模拟评教的时候,就要发送两个 HTTP 请求了,一是发送请求到某个老师的评教页面,对应的是 models/showEvaluatePage.js 这个文件;二是发送评教请求,对应的是 models/evaluate.js。而且这两个请求都是 POST 类型的。所以代码类逻辑似于下面这样:

// 显示评教页面
showEvalutePage(data)
    .then(() => {
    // 评教
    return evaluate(data)
    })
    .then((result) => {
    // 评教结果
    })
        .catch((exception) => {
            // 捕获异常
        });

4.5 public/js/style.js 🔗

前端的 JS 代码都在 style.js 这个文件里面了,主要就是监听了按钮的点击事件,然后发送 HTTP 请求,并根据请求结果增删页面的 DOM。

前端由于没有使用 jQuery 等第三方库,所以操作 DOM 和事件监听都是原生 JS 实现的。发送 AJAX 请求也是自己封装的 XHR 对象。有关于 XHR 的更多内容,可以参考我之前写的 《AJAX: XHR, jQuery and Fetch API》

5. 总结 🔗

到这里,这篇文章就基本完成了。本文讲述的实践,可能也不是最佳的实践,也有很多值得继续商讨和改进之处。只有不断实践,不断总结,才能写出更美的代码。人生不也一样?总结过去的教训,才能更好地前行。


Github Issues https://github.com/nodejh/nodejh.github.io/issues/36

comments powered by Disqus