【凝水成冰】记学生事务系统的结构化

343 阅读21分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

结构决定性质,也创造美。 ——纪念一次合作开发

散落的逻辑在精巧的架构下结合为美丽的代码,这就好像水分子因为氢键形成稳定而美丽的冰晶一样,细微而又宏大。

前言

从化学课抄来一句很著名、私底下也觉得很妙的话:

Die Entropie der Welt strebt einem Maximum zu.
世界的熵力争达到最大。
(1865, Rudolph J. E. Clausius)

这是整个世界的规律,就好像房间总是会慢慢变乱,代码总是会越写越烦一样。

说到学生事务系统,其实很多年前就有这方面的想法。某次学校拓展性课程出现(31/30)的bug的时候就很想重做一个,后来体育选课因为流量太大爆炸,更加坚定了这个想法,只是一直觉得没有能力和精力去实现它,因为应用有些庞大,需求有点复杂。

后来有了团队成员的帮助,就觉得轻松一些,有一战之力,于是就勇敢的迎战了。

能够十分有条理的完成这样一个有些复杂的工程,是一个团队的努力和成就。在这里特别感谢学校的大力支持,特别是学校给学生提供这样一个难能可贵机会,同时也感谢参与项目开发和对项目有贡献的大佬们:

  • CmdBlock
  • Phantomlsh
  • Tina
  • Queenie

弱弱加一句,本文作者是Phantomlsh,废话多预警。

最初的目标

故事开始于一个很小很小的办公室,烟雾缭绕、风扇狂转、指示灯频繁闪烁、键盘噼里啪啦响作一团,其间有两个人低语密谋着些什么......

打住打住!只不过是两个学生在商量一个小应用而已:让学生自己上传照片制作校园卡。

以往总是学校组织学生排队拍照片,不仅很麻烦,而且大家拍出来的头像照片也是千篇一律,因此信息组希望能够有一个网页可以让学生自己上传照片。没问题!小事!第二天我就和CmdBlock大佬面对面飞速构建了一个简单的应用:后端Golang + Mongodb,前端Vuejs加一堆奇奇怪怪的插件(其实登录部分之类的也拿了一些以前做过的项目的代码)。用户登录以后可以选择照片、剪裁然后上传。服务器根据用户保存照片文件,通过控制前端剪裁框的大小来让最后保存的照片尺寸统一。

这也许就是学生事务系统的雏形啦,一切都很简单,也没怎么考虑什么安全性和结构化,毕竟只是实现一个小小的功能嘛!没想到还没投入使用,就发现还需要做管理端。也就是说,老师登录以后可以查看并审核一个班所有人的照片。其实也不是什么很大的问题,一顿乱敲以后加了一个管理页面给老师查看照片。

当时觉得这个应用真是太好玩了(它后来确实也胜任了新一届学生的照片收集任务),我和CmdBlock两个人自娱自乐了好久。只不过没人想得到,这个为了收集照片而生的小玩意,最后引出了一个巨大的工程。

技术细节

  • 后端:Gin框架搭建http服务器,提供Restful API服务。
  • 登录:借鉴了以前做认证系统的经验,采取两次认证的方式处理用户登录请求(首次请求发送用户名,服务端返回随机字符串;第二次请求发送用户密码密码和随机字符串的散列摘要),避免明文传输用户密码。在两步验证中,后端要求其中有三秒的时间间隔,用以防止暴力破解密码。
  • 前端页面:因为没有CDN(哭),考虑用户加载页面的时候的服务器流量问题,前端页面不使用单页应用,同时尽量使用公用CDN上的js库。
  • 表单校验:在前端检查了身份证号的合法性,防止用户因为输错身份证号而白白发送登录请求,进一步减少服务器负荷。

地平线上的曙光

上一节的故事还是暑假的事情。那会儿我和CmdBlock两人天天在小办公室里面玩耍学习,根本闲不下来。所以事实上,照片上传应用不仅还没等到正式启用,甚至连名字都没想好的时候,就开始变得更加复杂。

其实想法很简单:既然做了照片上传服务,不如把学生会用到的别的服务也一起做了。CmdBlock和我一番合计,觉得当务之急就是更新学校的选课系统。具体来说,学校选课有三大问题:

  1. 人数限制容易挂,30个名额的课报了31个人。
  2. 并发堪忧,每次选课要么服务器爆炸重启好几次,要么就非常非常慢速。
  3. 页面太丑,不太支持移动端。

第三点先不谈,这一点大多靠Tina和Queenie,我和CmdBlock也不是很擅长前端网页设计这些东西。就前两点,其实是很难解决的一个矛盾:锁的本质是限制并发,让数据库读写单线程进行,使得学生们的请求“排队”处理,这样解决两个请求同时挤进去的问题;然而如果把数据库读写变成单线程的,并发情况可就糟糕了。(这个问题其实是因为当时我们太菜了,不了解也不熟悉Redis)

CmdBlock和我一起想了很久,最后做出了一个很大胆的决定。我们把每门课的剩余人数在选课开始之前读进内存作为全局变量,就能在程序的处理队列上处理人数限制了(if语句直接解决,说白了干了跟Redis差不多的事情)!为了能够持久化,定时和Mongodb同步一下。问题其实是很严重的,一旦服务端崩溃,选课就必须整个重来,因为内存中的剩余人数数据就会丢失。

管他呢!反正好用就行!这里只能庆幸选课那天服务端没炸了。我私底下也觉得这点并发数应该不可能把Golang这种高级的协程并发搞炸,但总是心里打鼓。

再次感激学校教务处和信息组,竟然真的批准了这个新的选课系统应用在新一届学生的拓展性课程选课上。那天云淡风轻、秋高气爽(雾),我和CmdBlock两个人紧张地在办公室里面盯着大屏上的服务器监控和笔记本上的服务端运行状态,大概也只有我们知道这套系统是多么脆弱和混乱了。

很遗憾,还没正式开始,就出了bug。bug出在时间上:前端页面用js获取电脑本地时间做前端时间限制。本来后端还是有时间限制的,可是运行服务端的服务器时钟快了好几分钟!!!导致时间还没到,就有些本地时间也快了的同学卡进了选课界面并且提前选完了,在此对其他同学表示歉意!

然后呢?然后当然是一切正常啦!看着服务端刷刷的日志和流畅的运行情况,我和CmdBlock都觉得非常激动。偶尔还有一两个小同学跑到办公室来特殊处理(忘了身份证号),也有一个机房的Chrome浏览器版本太旧不支持js里面的async/await导致无法登录,但是总体来说,一切都很完美,达到了理想中的高并发效果!只记得当时服务端没有任何一刻是在卡顿的,好多同学姗姗来迟,发现今年一点都不卡课程瞬间抢完。

从我们加入选课服务以后,这个小小的应用才正式改名为“学生事务系统”,并且走上了它蓬勃发展的道路。

技术细节

  • 选课:选课数据读入内存以便快速处理,充分利用Golang高并发的特点。
  • 模块化:不同的服务控制器分开编写,开始体现模块化的设计思路。

在终点启航

选课和照片上传任务成功完成,但我们也看到了其中的若干问题:

  1. 选课数据缓存在内存中不稳定,也不能使用负载均衡
  2. 代码开始出现了一定程度的混乱,不利于更多的任务类型加入
  3. 对于不同的选课需求,灵活性不够
  4. 没有管理端

也不是很明白为什么,也许是因为看了Jenkins一类的持续集成工具,我和CmdBlock同时产生了一个想法:或许我们应该把学生事务系统做成基于任务的结构。也就是说,管理员可以发布一个任务,设置它的模板(以选课为例),填写对应的数据(有哪些课,名额有多少),选择参加的学生(某个年级),然后学生们完成他们对应的任务就可以了。

作为一个学生事务系统,这样的设计才能够满足复杂的事务需求,例如同时开展体育选课和校本课程选课。然而这样的设计有巨大的实现难度,究竟如何才能让不同的任务和它们不同的逻辑,运行在同一个程序框架之下呢?特别是,后端还是Golang这样的强类型编译语言。

当时已经快到我大学开学的时候了,所以我只是提出了这样一个想法,剩下来的事情都丢给了CmdBlock。他确实给出了一个极其精妙的设计,写本文的时候我又问了一下他,摘录如下:


CmdBlock: (以下为引用)

首先想到的是每次请求的时候刷新用户token的机制(增加安全性),token得找个地方存,考虑到可能的负载均衡,不能直接使用后端程序内存。然而存储数据库又慢又蠢,于是找到了高性能的内存数据库Redis。在尝试的过程中发现它不仅快,而且满足操作的单线程。

事务系统的目标是通用化,最好是日程安排那样,按照时间轴的那种模式。一开始对于任务设计了这样的流程:

  1. 创建并指定需要的信息(例如课程目录等)
  2. 指定开始和结束时间,在有效时间内接受用户的提交
  3. 结束后管理员导出数据并销毁任务

后来考虑到任务的复用(同一种选课对同一批人进行多次,并且不能选以前选过的课程),就添加了开启和关闭的概念(晚自习的突发奇想):

  1. 创建任务
  2. 录入信息
  3. 指定开始时间并且开启任务
  4. 到达开始时间,接受用户提交
  5. 到达结束时间,停止接受用户提交
  6. 导出结果并关闭任务
  7. 回到2,或者销毁的

这样同一个任务的数据可以多次使用(和修改),任务逻辑也可以读取用户以往的提交记录。

实现不同任务对应不同的程序逻辑,目标是模块化。加一个文件就是一个模块,用了匿名函数的写法,用路由传入任务类型来运行不同的模块。

对于选课来说,发现选课数据可以放在Redis里面,用lua脚本去操作。所以任务在开启的时候可以把Mongodb里面的数据复制进Redis,实现单线程操作和高并发,同时支持负载均衡。


以上就是CmdBlock的设计,不得不感叹一句:
晚自习真的是太闲了! CmdBlock太强了!

当时我沉迷于学业,CmdBlock也开学了,整个项目就进展的很慢。所幸也没什么急迫的需求,CmdBlock就悠哉游哉地写,悠哉游哉地开启了一段新的航程。

技术细节

  • 缓存:缓存数据使用Redis,支持负载均衡的同时保证高并发和操作的单线程性。
  • 用户凭证:用户凭证每次请求都会刷新,通过请求的header传递,前端页面通过编写axios的intercept实现自动保存header到网页的sessionStorage,以及在发送前读取并设置header。
  • 后端验证:主要使用中间件进行校验,避免控制器反复读取来自数据库的数据。
  • 任务复用:通过管理任务的开启和关闭的状态实现任务的多次使用。任务关闭时,缓存中存储的用户完成该任务的状态就会被清空,再次开启时用户就可以重新完成该任务。
  • 服务器时间:使用ntp自动校正服务器时间。
  • 前端页面:开始采取双前端设计,学生使用的高并发前端页面分开加载(与之前一样),管理员使用的管理端使用Vuecli编写单页应用。

果断的蜕变

quarter学制下的一学期很快过去,圣诞节和元旦其间我回到了熟悉的高中校园。一学期的时间内有些新的见识,比如了解并使用了Nodejs做后端,但最令人吃惊的还是CmdBlock大佬发现了Redis玩耍一番以后我查看了CmdBlock慢慢积累而成的代码,一个人(CmdBlock上课去了)在熟悉的小办公室里面表情逐渐凝重。

倒不是写的不好,反而是写的太好了只能创造者自己理解。很多架构上的设计让我觉得有些冗余,比如任务类型这种完全可以通过数据库查询得到的字段,却需要路由传递;还有任务信息,CmdBlock的设计是对每一种任务在代码中写一个对应的结构体,同时在Mongodb中单独开一个collection存放任务信息,我觉得完全没有必要。有很多诸如此类的问题,让我在课间一路找到CmdBlock的班上跟他讨论,最终我们毅然做出重要决策:重构。

心疼吗?当然心疼!那么多代码就丢掉不用了!幸好CmdBlock写的代码片段和函数都很容易使用,让人觉得神清气爽,很多东西都可以拿来在重构以后的程序中使用,所以我们很快(大概一两天?)就重构完成了大多数内容。

当时我认为,开发框架的时候,选课任务过于复杂,应该选择一个初级的任务作为模板。因此我选择了通知功能,管理员发布通知,学生查看通知,并且有已阅读回执。

整个项目最重要的当然是模块化的设计部分。首先是任务信息,可以使用GolangMap[string]interface{}类型直接读取并处理,不需要特殊的结构体;其次我认为Golang提供的接口类型本来就是用来完成模块化的。只需要用一个map映射任务类型到对应的模块结构体上,调用结构体实现的接口方法就可以完成模块化,具体来说是以下函数:

  1. Open 开启任务(复制任务信息到缓存)
  2. RealTime 任务实时数据(读取缓存数据)
  3. Response 响应任务(读写缓存和持久化存储)

一个模块就是一种任务类型,对应了一个代码文件和三个接口函数的实现代码。

对于任务权限的区分,我也是突发奇想,想用一种树状结构存储不同的权限节点,例如2019级节点下面有各个班的节点作为子节点。每一个用户必须隶属于某一个权限节点,同时每一个节点上存储这个节点授权的任务列表。这个想法来自线段树的lazy tag,虽然它们完全不同。在查询一个用户被授权的任务列表的时候,程序从该用户隶属的权限节点开始,沿着权限树向根节点上溯,记录下每一个节点授权的任务列表,合并生成一个用户有权限访问的所有任务。这样就可以实现不检索所有任务或者所有用户,但快速给出任务列表的计算。同时权限树结构给任务权限分配带来了前所未有的灵活:管理员可以把一个任务分配给2019级全体和2018级18班,而不用做特殊的处理。

为了防止权限树过于庞大,进而影响对权限树的查询,用户节点(叶子节点)并不存储在权限树中。为了实现对单个用户的特殊授权,在Redis的token存储中新增了可以超越权限限制的临时授权。

管理模型也着实捏了一把汗,添加了一个新的角色role字段,超级管理员管理权限树和用户,老师角色管理用户,学生角色完成任务。任务默认添加进入super节点,但是老师角色也需要按照权限树的结构获取任务列表和管理授权。这样,权限树顺便做到了分层管理。

由于程序结构的复杂性,我在传统的模型层和控制器层中间加了一个半层,叫做services,封装了一些在多个控制器中都会用到的复杂逻辑片段。

任务数据进入后端的简要流程如下:

  1. 请求进入,在Gin框架下前往中间件
  2. 中间件调用服务层进行层层校验,同时把相关数据库查询结果存入Gin框架的context.keys
  3. 请求数据到达控制器,控制器解析请求数据
  4. 控制器调用服务层,数据前往任务模块的分拣函数
  5. 分拣函数读取context.keys中的任务类型,调用正确的任务模块中的处理函数
  6. 处理函数写入模型,同时返回处理结果
  7. 在控制器中处理结果被打包为json格式,返回前端

就宛如水流在复杂的管道中流动,又好似电子束在磁力的约束作用下分分合合,至此,学生事务系统后端的结构化方案被设计出来,在保持性能的同时最大程度地保留了灵活性,实现了多个模块、多种授权方案、多个任务同时进行、多个后端负载均衡。

数据在一个体系内流转,从而在结构的美感中创造价值。

技术细节

  • 任务信息:使用Golang中的Map[string]interface{}类型直接读写并处理Mongodb中的json格式的任务信息。
  • 模块接口:使用Golang的接口实现不同模块结构体,代码优雅。
  • 权限树:树状结构保存用户权限模型,每个节点记录了该节点对应的任务授权信息。为了保持程序运行正确,启动后端时会自动检查关键的权限节点(例如超级管理员)是否丢失,丢失则重新创建。
  • 用户角色:考虑到权限树基于独立collection的不稳定性,添加用户角色字段,程序用这个字段区分超级管理员、老师和学生。
  • 请求上下文:利用请求上下文保存相关的数据库查询结果,防止多次冗余查询。

全面并发!

所谓全面并发,并不是指服务端支持的高并发,而是说在这个阶段,整个项目的代码多线并行,由许多团队成员协助共同推进。在元旦放假期间,我和CmdBlock基本完成了后端代码,但是还有很多细节和调整在接下来的一段时间内继续由CmdBlock处理。在前端上,CmdBlock本来就写了一个旧版的管理端,所以管理端还是由他牵头,而我就在有限的时间内帮助处理学生前端。

整个工程被分解成了三个项目:

  • 后端
  • 学生前端
  • 管理前端

收集了同学们关于上一个版本前端的意见,新的前端版本回到了浅色系,同时很多细节上做了进一步的优化。就登录界面而言,学习了Gooogle和Microsoft的两步输入用户名和密码的方案,契合后端登录的两步验证算法。这里穿插了一个趣事:因为后端两步验证必须间隔三秒(防止爆破),有的时候前端密码输入太快不足三秒,就会被拒绝登录。当时CmdBlock问我怎么解决,我说:“不需要解决,输入一个比较复杂的密码加上动画时间,肯定超过三秒。如果真的有密码输入这么快的,要么是密码简单,要么是复制粘贴了密码,都不推荐。”所以最后我们搞了一个非常神奇的设定,如果密码输入太快被拒绝登录,就弹出“安全风险”的提示框。

前端分为两个项目的道理依然是减少峰值加载量。但是由于管理端没有登录界面,开发调试的时候非常麻烦。为此我特地搞了一个debug-web项目来代理api请求到真实服务端,用来调试学生前端。管理前端是Vuecli搭建的,自己就可以反向代理,就先代理到真实登录界面,完成登录以后再继续开发调试。

后端项目结构上没有大动,保持了上一节的设计。

学生前端也没有很多可圈可点之处,只是为了支持链接访问,在任务页面上可以设置callback。可以通过链接访问任务,然后跳转向登录,登录完毕以后自动跳转到任务页面,跳过了加载任务列表和前端任务时间限制。

管理前端着实很复杂,为了有效管理权限树等复杂的数据结构,和任务多种多样的信息,我们采取了比较基础的选项卡和json编辑器这种原始的方法,以适配灵活多变的任务需求。这部分主要是CmdBlock和Tina的设计,我也不是很清楚,但是功能非常全面,用起来很舒服,管理员可以看到任务的实时报告。

在这个阶段,Tina和Queenie也在两个前端项目之间反复横跳,完成了很多的代码,以及更重要的设计工作。

至此,学生事务系统的框架已经完工。截至本文完稿,它支持了通知和选课两个任务模块。但是我相信,只要有更多的需求,新的模块就会快速出现,并且以一种高效稳定的姿态完成它的任务。

技术细节

  • 登录前端:将输入用户名和输入密码分为两个界面,契合后端的两步登录验证。
  • 学生前端回调:用户可以通过链接直接访问任务,跳转到登录以后会自动回调访问原始的任务链接。
  • 管理端实时报告:通过直接获取Redis的储存情况来提供快速有效的实时任务报告。

后记

感谢各位读者能花费宝贵的时间一直读完这篇废话连篇的文章!

写这篇文章也是因为这个工程拖得时间太久,代码量大,觉得很有成就感值得纪念一下。从这个工程中我们都学到了很多,不仅仅是技术上的,也有团队合作的精神和设计上的思想。我们从学生事务系统逐渐的成长中看到了结构化的美丽,亲身经历了架构设计和细节处理,相信也带来了一个杰出的事务系统!

水分子结构化形成的冰晶,会像棱镜一样,将射入其中的光束引导向合适的方向。在太阳光线下,也可以折射出美丽的彩虹。