博客 / 詳情

返回

體育數據平台全棧開發實踐:從架構設計到多端實現

前言
在開發體育數據類應用時,無論是比分網站、數據分析平台還是移動應用,都會面臨一些共性問題:如何設計合理的數據結構?如何實現實時數據推送?如何保證多端數據一致性?本文將分享我們在實際項目中的架構設計和技術選型經驗,希望能為開發者提供一些參考。
技術選型
前端技術棧

框架: Vue 2.x
UI組件: Element UI (PC端) + Vant (移動端)
構建工具: Webpack 4
數據可視化: ECharts
狀態管理: Vuex

後端技術棧

框架: Spring Boot 2.x
持久層: MyBatis Plus
數據庫: MySQL 8.0
緩存: Redis
實時通信: WebSocket

選型考慮
選擇這套技術棧主要基於以下考量:

成熟穩定: 技術棧經過大量項目驗證,社區活躍
學習成本: 主流技術,團隊上手快
生態豐富: 豐富的插件和工具鏈支持
性能表現: 滿足體育數據實時性要求

圖片

架構設計
整體架構
┌─────────────────────────────────────────┐
│ 客户端層 │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ PC端 │ │ H5端 │ │ App端│ │
│ └──┬───┘ └──┬───┘ └──┬───┘ │
└─────┼─────────┼─────────┼──────────────┘

  │         │         │
  └─────────┴─────────┘
          │

┌─────────────┴─────────────────────────┐
│ API網關層 │
│ (統一鑑權、限流、路由) │
└───────────────┬───────────────────────┘

┌───────────────┴───────────────────────┐
│ 服務層 │
│ ┌─────────┐ ┌─────────┐ │
│ │賽事服務 │ │用户服務 │ ... │
│ └────┬────┘ └────┬────┘ │
└───────┼────────────┼───────────────────┘

    │            │

┌───────┴────────────┴───────────────────┐
│ 數據層 │
│ ┌─────────┐ ┌─────────┐ │
│ │ MySQL │ │ Redis │ │
│ └─────────┘ └─────────┘ │
└───────────────────────────────────────┘

核心模塊設計

  1. 賽事管理模塊
    負責賽事的CRUD操作和狀態管理。
    數據庫設計:
    CREATE TABLE match (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    match_name VARCHAR(255) NOT NULL COMMENT '賽事名稱',
    home_team VARCHAR(100) NOT NULL COMMENT '主隊',
    away_team VARCHAR(100) NOT NULL COMMENT '客隊',
    start_time DATETIME NOT NULL COMMENT '開始時間',
    status TINYINT DEFAULT 0 COMMENT '狀態: 0-未開始 1-進行中 2-已結束',
    home_score INT DEFAULT 0 COMMENT '主隊得分',
    away_score INT DEFAULT 0 COMMENT '客隊得分',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_start_time (start_time),
    INDEX idx_status (status)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

後端實現:
@RestController
@RequestMapping("/api/match")
public class MatchController {


@Autowired
private MatchService matchService;

/**
 * 分頁查詢賽事列表
 */
@GetMapping("/list")
public Result getMatchList(@RequestParam Map<String, Object> params) {
    // 參數驗證
    Integer page = MapUtils.getInteger(params, "page", 1);
    Integer limit = MapUtils.getInteger(params, "limit", 10);
    
    // 查詢數據
    PageUtils pageData = matchService.queryPage(params);
    
    return Result.ok().put("page", pageData);
}

/**
 * 獲取賽事實時數據
 */
@GetMapping("/realtime/{matchId}")
public Result getRealtimeData(@PathVariable Long matchId) {
    if (matchId == null || matchId <= 0) {
        return Result.error("無效的賽事ID");
    }
    
    RealtimeData data = matchService.getRealtimeData(matchId);
    
    return Result.ok().put("data", data);
}

/**
 * 創建賽事
 */
@PostMapping("/create")
public Result create(@RequestBody MatchEntity match) {
    // 數據驗證
    ValidatorUtils.validateEntity(match);
    
    [matchService.save](<http://matchService.save>)(match);
    
    return Result.ok();
}

}

  1. 實時推送模塊
    基於WebSocket實現比分實時更新。
    @Component
    @ServerEndpoint("/websocket/match/{matchId}")
    public class MatchWebSocket {

    private static final ConcurrentHashMap<String, Session> sessionMap = new ConcurrentHashMap<>();

    @OnOpen
    public void onOpen(@PathParam("matchId") String matchId, Session session) {

     sessionMap.put(matchId + "_" + session.getId(), session);
     [log.info](<http://log.info>)("WebSocket連接建立: matchId={}, sessionId={}", matchId, session.getId());

    }

    @OnClose
    public void onClose(@PathParam("matchId") String matchId, Session session) {

     sessionMap.remove(matchId + "_" + session.getId());
     [log.info](<http://log.info>)("WebSocket連接關閉: matchId={}, sessionId={}", matchId, session.getId());

    }

    @OnError
    public void onError(Session session, Throwable error) {

     log.error("WebSocket錯誤: sessionId={}", session.getId(), error);

    }

    /**

    • 推送比分更新
      */

    public static void pushScoreUpdate(Long matchId, ScoreUpdate update) {

     String message = JSON.toJSONString(update);
     
     sessionMap.entrySet().stream()
         .filter(entry -> entry.getKey().startsWith(matchId + "_"))
         .forEach(entry -> {
             try {
                 entry.getValue().getBasicRemote().sendText(message);
             } catch (IOException e) {
                 log.error("推送消息失敗", e);
             }
         });

    }
    }

前端實現:
// WebSocket連接管理
export class WebSocketManager {
constructor(matchId) {

this.matchId = matchId
[this.ws](<http://this.ws>) = null
this.reconnectTimer = null
this.maxReconnectTimes = 5
this.reconnectCount = 0

}

connect() {

const wsUrl = `ws://[localhost:8080/websocket/match/${this.matchId}`](<http://localhost:8080/websocket/match/${this.matchId}`>)

[this.ws](<http://this.ws>) = new WebSocket(wsUrl)

[this.ws](<http://this.ws>).onopen = () => {
  console.log('WebSocket連接成功')
  this.reconnectCount = 0
}

[this.ws](<http://this.ws>).onmessage = (event) => {
  const data = JSON.parse([event.data](<http://event.data>))
  this.handleMessage(data)
}

[this.ws](<http://this.ws>).onerror = (error) => {
  console.error('WebSocket錯誤:', error)
}

[this.ws](<http://this.ws>).onclose = () => {
  console.log('WebSocket連接關閉')
  this.reconnect()
}

}

handleMessage(data) {

// 觸發事件或調用回調
if (this.onScoreUpdate) {
  this.onScoreUpdate(data)
}

}

reconnect() {

if (this.reconnectCount >= this.maxReconnectTimes) {
  console.error('WebSocket重連次數超限')
  return
}

this.reconnectCount++
const delay = Math.min(1000 * Math.pow(2, this.reconnectCount), 30000)

this.reconnectTimer = setTimeout(() => {
  console.log(`嘗試重連 (${this.reconnectCount}/${this.maxReconnectTimes})`)
  this.connect()
}, delay)

}

disconnect() {

if (this.reconnectTimer) {
  clearTimeout(this.reconnectTimer)
}
if ([this.ws](<http://this.ws>)) {
  [this.ws](<http://this.ws>).close()
}

}
}

  1. 緩存策略
    針對不同數據設置不同的緩存策略:
    @Service
    public class MatchService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**

    • 獲取賽事數據(帶緩存)
      */

    public MatchEntity getById(Long matchId) {

     String cacheKey = "match:" + matchId;
     
     // 先查緩存
     MatchEntity match = (MatchEntity) redisTemplate.opsForValue().get(cacheKey);
     
     if (match == null) {
         // 緩存未命中,查詢數據庫
         match = matchMapper.selectById(matchId);
         
         if (match != null) {
             // 根據比賽狀態設置不同的緩存時間
             long ttl = getCacheTTL(match.getStatus());
             redisTemplate.opsForValue().set(cacheKey, match, ttl, TimeUnit.SECONDS);
         }
     }
     
     return match;

    }

    /**

    • 根據比賽狀態返回緩存時長
      */

    private long getCacheTTL(Integer status) {

     if (status == 0) {
         // 未開始: 緩存30分鐘
         return 1800;
     } else if (status == 1) {
         // 進行中: 緩存5秒
         return 5;
     } else {
         // 已結束: 緩存1小時
         return 3600;
     }

    }
    }

圖片

前端實現要點

  1. 移動端適配
    使用flexible.js實現REM佈局:
    // flexible.js
    (function(win, lib) {
    var doc = win.document;
    var docEl = doc.documentElement;
    var metaEl = doc.querySelector('meta[name="viewport"]');
    var dpr = 0;
    var scale = 0;
    var tid;

    function refreshRem() {
    var width = docEl.getBoundingClientRect().width;
    if (width / dpr > 540) {
    width = 540 * dpr;
    }
    var rem = width / 10;
    docEl.style.fontSize = rem + 'px';
    }

    win.addEventListener('resize', function() {
    clearTimeout(tid);
    tid = setTimeout(refreshRem, 300);
    }, false);

    refreshRem();
    })(window, window['lib'] || (window['lib'] = {}));

  2. 組件封裝
    封裝通用的比賽卡片組件:
    <template>
    <div class="match-card" @click="handleClick">
    <div class="match-header">
    <span class="match-time"> formatTime(match.startTime) </span>
    <span class="match-status" :class="statusClass"> statusText </span>
    </div>

    <div class="match-body">
    <div class="team home">

     <img :src="match.homeTeamLogo" class="team-logo">
     <span class="team-name"> match.homeTeam </span>
     <span class="team-score"> match.homeScore </span>

    </div>

    <div class="vs">VS</div>

    <div class="team away">

     <span class="team-score"> match.awayScore </span>
     <span class="team-name"> match.awayTeam </span>
     <img :src="match.awayTeamLogo" class="team-logo">

    </div>
    </div>
    </div>
    </template>

<script>
import { formatTime } from '@/utils/date'

export default {
name: 'MatchCard',

props: {

match: {
  type: Object,
  required: true
}

},

computed: {

statusText() {
  const statusMap = {
    0: '未開始',
    1: '進行中',
    2: '已結束'
  }
  return statusMap[this.match.status] || '未知'
},

statusClass() {
  return `status-${this.match.status}`
}

},

methods: {

formatTime,

handleClick() {
  this.$emit('click', this.match)
}

}
}
</script>

<style scoped lang="scss">
.match-card {
background: #fff;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.3s;

&:hover {

box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);

}

.match-header {

display: flex;
justify-content: space-between;
margin-bottom: 12px;

.match-time {
  color: #999;
  font-size: 14px;
}

.match-status {
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 4px;
  
  &.status-0 {
    background: #e8f4ff;
    color: #409eff;
  }
  
  &.status-1 {
    background: #fef0f0;
    color: #f56c6c;
  }
  
  &.status-2 {
    background: #f0f9ff;
    color: #909399;
  }
}

}

.match-body {

display: flex;
align-items: center;
justify-content: space-between;

.team {
  display: flex;
  align-items: center;
  flex: 1;
  
  &.home {
    justify-content: flex-start;
  }
  
  &.away {
    justify-content: flex-end;
    flex-direction: row-reverse;
  }
  
  .team-logo {
    width: 40px;
    height: 40px;
    border-radius: 50%;
  }
  
  .team-name {
    margin: 0 12px;
    font-size: 16px;
    font-weight: 500;
  }
  
  .team-score {
    font-size: 24px;
    font-weight: bold;
    color: #303133;
  }
}

.vs {
  font-size: 14px;
  color: #909399;
  margin: 0 16px;
}

}
}
</style>

部署實踐
環境準備

JDK 8+
Node.js 14+
MySQL 8.0+
Redis 5+
Nginx (生產環境)

Docker部署
Dockerfile (後端):
FROM openjdk:8-jdk-alpine

VOLUME /tmp

COPY target/sports-platform.jar app.jar

ENTRYPOINT "java","-[Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]

EXPOSE 8080

Dockerfile (前端):
FROM node:14-alpine as build

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

FROM nginx:alpine

COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf

EXPOSE 80

docker-compose.yml:
version: '3.8'

services:
mysql:

image: mysql:8.0
environment:
  MYSQL_ROOT_PASSWORD: root123
  MYSQL_DATABASE: sports_platform
ports:
  - "3306:3306"
volumes:
  - mysql_data:/var/lib/mysql

redis:

image: redis:5-alpine
ports:
  - "6379:6379"

backend:

build: ./server
ports:
  - "8080:8080"
depends_on:
  - mysql
  - redis
environment:
  SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/sports_platform
  SPRING_REDIS_HOST: redis

frontend:

build: ./web
ports:
  - "80:80"
depends_on:
  - backend

volumes:
mysql_data:

性能優化

  1. 數據庫優化

為常用查詢字段建立索引
使用連接池(HikariCP)
分頁查詢避免全表掃描
使用批量操作減少數據庫交互

  1. 接口優化

實現接口緩存
使用CDN加速靜態資源
啓用Gzip壓縮
合理設置HTTP緩存頭

  1. 前端優化

路由懶加載
組件按需引入
圖片懶加載
使用虛擬滾動處理長列表

總結
本文從架構設計、技術選型、核心模塊實現到部署實踐,完整地介紹了一個體育數據平台的開發過程。主要經驗總結:
架構層面

分層清晰: 客户端層、API層、服務層、數據層職責明確
模塊解耦: 各功能模塊獨立,便於維護和擴展
統一規範: API設計、錯誤處理、日誌記錄保持一致

技術層面

緩存策略: 根據數據特性設置不同的緩存時長
實時推送: WebSocket實現低延遲的數據更新
性能優化: 多級緩存、數據庫索引、前端懶加載等

工程層面

代碼規範: 統一的命名規範和註釋規範
錯誤處理: 完善的異常捕獲和錯誤提示
日誌記錄: 關鍵操作的日誌記錄,便於問題排查

希望這些實踐經驗能為正在開發類似項目的同學提供一些參考。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.