【凝水成冰】記學生事務系統的結構化


結構決定性質,也創造美。 ——紀念一次合作開發

散落的邏輯在精巧的架構下結合爲美麗的代碼,這就好像水分子因爲氫鍵形成穩定而美麗的冰晶一樣,細微而又宏大。

前言

從化學課抄來一句很著名、私底下也覺得很妙的話:

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的儲存情況來提供快速有效的實時任務報告。

後記

感謝各位讀者能花費寶貴的時間一直讀完這篇廢話連篇的文章!

寫這篇文章也是因爲這個工程拖得時間太久,代碼量大,覺得很有成就感值得紀念一下。從這個工程中我們都學到了很多,不僅僅是技術上的,也有團隊合作的精神和設計上的思想。我們從學生事務系統逐漸的成長中看到了結構化的美麗,親身經歷了架構設計和細節處理,相信也帶來了一個傑出的事務系統!

水分子結構化形成的冰晶,會像棱鏡一樣,將射入其中的光束引導向合適的方向。在太陽光線下,也可以折射出美麗的彩虹。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章