Vue Koa 搭建 ACM OJ

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

項目線上地址:http://acm.cjlu.edu.cn/

項目前端地址:https://github.com/acm309/PutongOJ-FE

項目後端地址:https://github.com/acm309/PutongOJ

這裏求一下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 特別喜歡的騷紫。

實現功能

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 ⭐️ 鼓勵一下吧~

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