Vue + Koa 搭建 ACM OJ

kerminate發表於2018-06-19

花了兩個多月時間,我與 lazzzis 完成了第二版本的Putong OJ,因為中間忙著春招以及畢業設計等,專案最近才正式上線。

專案線上地址:acm.cjlu.edu.cn/

專案前端地址:github.com/acm309/Puto…

專案後端地址:github.com/acm309/Puto…

這裡求一下star啊(^o^)/~

本OJ前端架構為 Vue2.5 + vue-router + vuex + axios + iview + stylus + webpack3.6 後端架構為 Koa2 + MongoDB + redis

開發背景

我們學校 acm 起步較晚,最早的 OJ 是由 Hust OJ 魔改而來,介面寫的比較粗糙。2年前,那屆的 acm 隊長本來決定使用 Vue + Go 重寫一下 OJ,但是因為一些原因,他跑路了,最後只 fork 了一個開源 OJ。一年前,lazzzis 開始重構 OJ,採用了 Vue + node,開發出了 Putong OJ 的第一個版本。今年,由於老師增加了功能上的一些需求,再加上後端資料結構又發生了一些變化,以及對第一個版本不太滿意,我與 lazzzis 再次重構,開發出了 Putong OJ V2 版本。

技術選型

考慮過要用 React 開發,(好吧,說實話寫OJ的時候我還不會React),但是 Vue 上手簡單且中文資源豐富,所以決定使用 Vue 全家桶。最初 vue1.0 時官方推薦 vue-resource,後來在 2.0 時 Vue 官方不再推薦 vue-resource, 而是推薦使用 axios 作前後端通訊。開發初期一開始用了 element 作為 vue 的 UI 庫,後來轉而使用了 iview。其實這兩個 UI 庫相當像,都是 ant-design 風格的,api也比較一致,在我眼裡比較大的區別是 element 的元件更大,iview 的更小巧(視覺上的大小,element small 的跟 iview 的 default 差不多大)。

後端其實我們不太喜歡 Java,最後用了輕量又方便的 node。資料庫用了 MongoDB,主要是方便 js 操作,同時用 redis 做資料快取,並做了簡單的訊息佇列。

預覽

主題色採用了 lazzzis 特別喜歡的騷紫。

Vue + Koa 搭建 ACM OJ
Vue + Koa 搭建 ACM OJ

實現功能

OJ分為web端和判題端,這邊主要分析web端,判題端 由 Acdream的判題端 魔改而來。web 端共有訊息模組,題目模組,討論模組,狀態模組,排行模組,比賽模組與管理員模組七大模組。 本OJ提供了兩種使用者,普通使用者和管理員使用者。顧名思義,普通使用者只能答題,參加比賽,發帖,檢視資訊等,管理員使用者擁有對資訊,題目,比賽等增刪改查的許可權。

  • 訊息模組 就是 OJ 的首頁,含有列表頁和訊息詳情頁,主要就是管理員釋出的訊息。
  • 題目模組 OJ 的核心模組之一,含有題目列表頁和題目詳情頁,題目詳情頁裡有 6 個 tab 頁,題目描述,提交,我的提交,統計,編輯,測試資料。其中編輯和測試資料兩個 tab 頁僅管理員可見。
  • 討論模組 其實就是討論區,使用者可在上面發帖評論。
  • 狀態模組 使用者提交題目的判題結果。
  • 排行模組 使用者排名,有分組功能,便於老師統計結果
  • 比賽模組 核心模組之一,含有比賽列表和比賽詳情頁,比賽詳情頁有 6 個 tab 頁,總覽,題目,提交,狀態,排名,編輯。其中編輯頁僅管理員可見。
  • 管理員模組 核心模組之一,含有建立訊息,建立題目,建立比賽,使用者管理四個功能頁。

給大家提前註冊了一個普通使用者賬號,賬號 123456 ,密碼 123456 ,歡迎去試用一下。

前端

先來看看前端的專案結構,通過腳手架 vue-cli 構建

├── dist // 生成打包好的檔案
│   ├── static
│   │   ├── css
│   │   ├── fonts
│   │   ├── img
│   │   └── js  
│   └── index.html
└── src
    ├── main.js // 專案入口
    ├── router // 路由檔案,說明了各個路由將會使用的元件
    │   ├── index.js // router 的配置以及引用元件
    │   └── routes.js // 定義各個路由
    ├── assets // 網站 logo 圖資源
    ├── components // 一些小元件
    ├── store // vuex 檔案
    │   └── modules // 子模組
    ├── utils // js 工具方法
    └── views // 路由對應的元件 (這些元件在 router.js 中都被引入)
        ├── Admin
        ├── Contest
        ├── News
        └── Problem

複製程式碼

前端一共有三十多張頁面,但其實大多數都是隻有圖表,頁面邏輯並不複雜。 iview 按需載入,減小前端打包大小。 為了保證首屏載入的速度,對部分路由進行懶載入。

// 路由懶載入
const ProblemStatistics = r => require.ensure([], () => r(require('@/views/Problem/Statistics')), 'statistics')
const ProblemEdit = r => require.ensure([], () => r(require('@/views/Problem/ProblemEdit')), 'admin')
const Testcase = r => require.ensure([], () => r(require('@/views/Problem/Testcase')), 'admin')
const ContestEdit = r => require.ensure([], () => r(require('@/views/Contest/ContestEdit')), 'admin')
const NewsEdit = r => require.ensure([], () => r(require('@/views/News/NewsEdit')), 'admin')
const ProblemCreate = r => require.ensure([], () => r(require('@/views/Admin/ProblemCreate')), 'admin')
const ContestCreate = r => require.ensure([], () => r(require('@/views/Admin/ContestCreate')), 'admin')
const NewsCreate = r => require.ensure([], () => r(require('@/views/Admin/NewsCreate')), 'admin')
const UserManage = r => require.ensure([], () => r(require('@/views/Admin/UserManage/Usermanage')), 'admin')
const UserEdit = r => require.ensure([], () => r(require('@/views/Admin/UserManage/UserEdit')), 'admin')
const GroupEdit = r => require.ensure([], () => r(require('@/views/Admin/UserManage/GroupEdit')), 'admin')
const AdminEdit = r => require.ensure([], () => r(require('@/views/Admin/UserManage/AdminEdit')), 'admin')
const TagEdit = r => require.ensure([], () => r(require('@/views/Admin/UserManage/TagEdit')), 'admin')
複製程式碼

同時前端用了不少第三方元件實現小的需求。

  • vue-echarts: 基於 Vue 的 Echarts元件,在專案中用於展示提交結果的統計分析圖。
  • vue2-editor: 基於 Vue 的富文字編輯器,用於題目內容的編輯,支援圖片上傳等基本功能。
  • Vue.Draggable: 基於 Vue 的拖拽元件,方便管理員對比賽題目順序做改動。
  • vue-clipboard2: 基於 Vue 的剪下板,方便使用者複製程式碼。
  • vuex-router-sync: 使 vue-router 的 $route 能夠在 vuex 中的 state 訪問到。
  • highlight.js: 頁面裡程式碼高亮。

後端

├── config // 專案配置(資料庫等)
├── model // 資料庫 model
├── routes // 後端路由
├── controllers // 主要功能實現
├── services // 主要服務(判題、郵件提醒、更新)
├── utils // js 工具函式
├── test // 測試
├── app.js
└── manage.js

複製程式碼

後端使用 koa2 開發,使用 async/await 代替回撥,避免 callback hell. 主要資料都儲存在 MongoDB 中,使用的 node 的 mongoose 包。為了避免多人同時提交題目,造成的高併發問題,介面遵循 RESTful 設計,使用 redis 對判題做了佇列快取。使用者提交的題目會進入 redis 中,再一個個彈出佇列交給判題端處理。正常 ACM 比賽最後一小時會進行封榜(不再進行排名和ac題目的更新,但是會更新使用者的提交次數),在這裡也用了 redis 對比賽排行榜進行更新,比賽過程中只將資料儲存在 redis 中,並實現封榜,賽後再將比賽所有資訊儲存到 mongo 中。

// 比賽時返回比賽排行榜
const ranklist = async (ctx) => {
  const contest = ctx.state.contest
  const ranklist = ctx.state.contest.ranklist
  let res
  const deadline = 60 * 60 * 1000
  await Promise.all(Object.keys(ranklist).map((uid) =>
    User
      .findOne({ uid })
      .exec()
      .then(user => { ranklist[user.uid].nick = user.nick })))

  if (Date.now() + deadline < contest.end) {
    // 若比賽未進入最後一小時,最新的 ranklist 推到 redis 裡
    const str = JSON.stringify(ranklist)
    await redis.set(`oj:ranklist:${contest.cid}`, str) // 更新該比賽的最新排名資訊
    res = ranklist
  } else if (!isAdmin(ctx.session.profile) &&
    Date.now() + deadline > contest.end &&
    Date.now() < contest.end) {
    // 比賽最後一小時封榜,普通使用者只能看到題目提交的變化
    const mid = await redis.get(`oj:ranklist:${contest.cid}`) // 獲取 redis 中該比賽的排名資訊
    res = JSON.parse(mid)
    Object.entries(ranklist).map(([uid, problems]) => {
      Object.entries(problems).map(([pid, sub]) => {
        if (sub.wa < 0) {
          res[uid][pid] = {
            wa: sub.wa
          }
        }
      })
    })
    const str = JSON.stringify(res)
    await redis.set(`oj:ranklist:${contest.cid}`, str) // 將更新後的 ranklist 更新到 redis
    // 比賽結束
    res = ranklist
  }
  ctx.body = {
    ranklist: res
  }
}
複製程式碼

專案使用 docker 進行一鍵部署。寫了 Dockerfile 對 web 端進行映象定製,在 docker-compose 中配置專案所需的所有映象。部署過程

最後

篇幅有限,無法展現更多的內容,有興趣的話可以進入專案地址閱讀原始碼,當然,如果覺得專案還不錯的話 ?,就給個 star ⭐️ 鼓勵一下吧~

相關文章