You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Socket.io+REST API项目Fetch请求偶发重复执行问题求助

Fetch请求偶发重复执行问题排查与解决

我正在开发一个基于Socket.io和REST API的游戏项目,目前遇到异常:Fetch请求有时会执行两次,有时正常执行一次。已经尝试调整代码,但始终没能定位根源。以下是相关代码,希望找到解决方案(这是我的大学作业)。

客户端代码

socket.on('connect', () => {
    socket.emit('joinGame', { gameID: gameID, page: page });

    socket.on('role', (data) => {
        if (data.host === socket.id){
            //Determina a quien le toca primero
            fetch(`http://localhost:3000/players/turn?idG=${gameID}`, {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json'
                },
            })
            .then(res => res.json())
            .then(data => {
                currentPlayerFront = data.name
                localStorage.setItem('currentPlayer', currentPlayerFront);
                console.log('currentPlayerFront: ', currentPlayerFront)
                socket.emit('sendCurrentPlayer', { gameID: gameID, player: currentPlayerFront});
                if (currentPlayerFront === getCurrentPlayer()){
                    diceButton.disabled = false;
                }
            })
            .catch(error => {
                console.error(error)
            })
        }
    })
})

服务端Socket.io事件处理代码

io.on('connection', (socket) => {

    // JoinRoom: un jugador se une a la partida, busca la patida por su gameID y genera un emit para actualizar a los juagadores en la lista de juagadores de la partida
    socket.on('joinRoom', async (data) => {
        socket.join(data.gameID);
        const game = await Game.findOne({ gameID: data.gameID });
        if (game) {
            io.to(data.gameID).emit('updatePlayers', {
                gameID: data.gameID,
                players: game.players,
                playerCount: game.players.length,
            });
            players.push({ id: socket.id, name: game.players[game.players.length - 1].name, page: data.page, gameID: data.gameID });
        }
    });

    // determineHost: determina cual jugador es el host de la partida segun su socket.id
    socket.on('determineHost', async (data) => {
        const game = await Game.findOne({ gameID: data.gameID })
        if (game) {
            if (!game.host){
              game.host = socket.id
              await game.save()
            }
            io.to(data.gameID).emit('role', { host: game.host })
        }
    })

    // startGame: redirige a todos los jugadores a game.html y coloca a todos los jugadores en la primera casilla con su color correspondiente
    socket.on('startGame', async (data) => {
        const game = await Game.findOne({ gameID: data.gameID })
        if (game) {
            game.start = true
            game.host = null
            await game.save()
        }
        players.length = 0
        const html = fs.readFileSync(path.join(__dirname, '..', 'views', 'game.html'), 'utf8');
        io.to(data.gameID).emit('startGame', {site: `http://localhost:3000/game?gameID=${data.gameID}`, html: html });
    })

    // JoinGame: al Generarse una conexion en el front-end se une a uan partida con id
    socket.on('joinGame', async (data) => {
        socket.join(data.gameID)
        const game = await Game.findOne({ gameID: data.gameID })
        if (game){
            if (game.host == null){
                game.host = socket.id
                await game.save()
                io.to(data.gameID).emit('role', { host: game.host })
            }
        }
        players.push({ id: socket.id, page: data.page, gameID: data.gameID })
    })

    socket.on('sendCurrentPlayer', (data) => {
        io.to(data.gameID).emit('sendCurrentPlayer', { player: data.player })
    })

    socket.on('newQuestion', (data) => {
        io.to(data.gameID).emit('updateQuestion', {question: data.question , player: data.player, gameID: data.gameID})
    })

    // Escuchar evento para actualizar la posición del jugador
    socket.on('updatePlayerPosition', (data) => {
        // Emitir el evento a todos los clientes conectados
        io.to(data.gameID).emit('updatePlayerPosition', { player: data.player, points: data.points});
    });

    socket.on('clearTimer', (data) => {
        io.to(data.gameID).emit('clearTimer');
    })

    // NextTurn: Accediendo a la base de datos le notifica a los jugadores quien es el siguiente en jugar
    socket.on('nextTurn', async (data) => {
        const game = await Game.findOne({ gameID: data.gameID })
        if (game && game.start) {
            console.log('nuevo turno: ', game.turn)
            let currentPlayer = {id: players[game.turn].id, name: game.players[game.turn].name}
            console.log(currentPlayer)
            io.to(data.gameID).emit('getCurrentPlayer', currentPlayer)
        }
    })

    // Blockbuttons: bloquea los botones de respuesta para los jugadores que no tienen el turno
    socket.on('blockbuttons', (data) => {
        const game = Game.findOne({ gameID: data.gameID })
        if (game) {
            let currentPlayer = {id: players[game.turn].id}
            io.to(data.gameID).emit('blockbuttons', currentPlayer)
        }
    })

    // Answer: verifica si la respuesta es correcta o incorrecta y emite el evento correspondiente
    socket.on('answer', (data) => {
        if (data.response === 'correct') {
            io.to(data.gameID).emit('correctAnswer')
        } else if (data.response === 'incorrect') {
            io.to(data.gameID).emit('incorrectAnswer')
        }
    })

    // EndGame: al finalizar el juego determina quien es el ganador y redirige a los jugadores a la pagina de resultados
    socket.on('endGame', async (data) => {
        const game = await Game.findOne({ gameID: data.gameID })
        if (game) {
            game.winner = data.player.name
            await game.save()
            io.to(data.gameID).emit('cleanBoard', {winner: game.winner})
        }
    })
})

游戏回合判定API路由代码

router.get('/turn', async (req, res) => {
    const game = await Game.findOne({ gameID: req.query.idG })
    if (game && game.start) {
      if (game.players.length != 0) {
          game.turn = Math.floor(Math.random() * game.players.length)
          await game.save()
          console.log('turno: ' , game.turn)
          res.status(200).json({ name: game.players[game.turn].name })
      } else {
          res.status(404).send('No hay jugadores')
      }
    } else {
      res.status(404).send('Juego no encontrado y no empezado')
    }
})

问题根源分析

  1. Socket.io事件重复绑定:客户端在socket.on('connect')内部绑定socket.on('role'),如果Socket连接发生重连(比如网络波动),会重复绑定role事件监听器。每次触发role事件时,所有绑定的监听器都会执行,导致Fetch请求多次发送。
  2. 服务端重复触发role事件:服务端的joinGame和determineHost都可能触发role事件,如果这两个逻辑被多次执行(比如客户端重复发送joinGame),会导致客户端多次收到role事件,进而重复执行Fetch。
  3. API非幂等:当前/turn接口每次请求都会随机修改game.turn,即使是重复请求也会改变回合结果,放大了重复请求的负面影响。

解决方案

客户端修复

将role事件监听器改为只执行一次,避免重复绑定导致多次触发Fetch:

// 只绑定一次role事件监听器,确保逻辑仅执行一次
socket.once('role', (data) => {
    if (data.host === socket.id){
        fetch(`http://localhost:3000/players/turn?idG=${gameID}`, {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json'
            },
        })
        .then(res => res.json())
        .then(data => {
            currentPlayerFront = data.name
            localStorage.setItem('currentPlayer', currentPlayerFront);
            console.log('currentPlayerFront: ', currentPlayerFront)
            socket.emit('sendCurrentPlayer', { gameID: gameID, player: currentPlayerFront});
            if (currentPlayerFront === getCurrentPlayer()){
                diceButton.disabled = false;
            }
        })
        .catch(error => {
            console.error(error)
        })
    }
})

socket.on('connect', () => {
    socket.emit('joinGame', { gameID: gameID, page: page });
})

服务端优化

在joinGame逻辑中增加判断,确保role事件仅在首次设置host时触发,避免重复发送:

socket.on('joinGame', async (data) => {
    socket.join(data.gameID)
    const game = await Game.findOne({ gameID: data.gameID })
    if (game){
        // 仅当host未设置时才赋值并触发role事件
        if (game.host == null){
            game.host = socket.id
            await game.save()
            io.to(data.gameID).emit('role', { host: game.host })
        }
    }
    players.push({ id: socket.id, page: data.page, gameID: data.gameID })
})

API幂等性优化

修改/turn接口,只有当game.turn未初始化时才随机生成,重复请求不会改变回合结果:

router.get('/turn', async (req, res) => {
    const game = await Game.findOne({ gameID: req.query.idG })
    if (game && game.start) {
      if (game.players.length !== 0) {
          // 仅在turn未设置时才生成随机值
          if (game.turn === undefined || game.turn === null) {
              game.turn = Math.floor(Math.random() * game.players.length)
              await game.save()
              console.log('turno: ' , game.turn)
          }
          res.status(200).json({ name: game.players[game.turn].name })
      } else {
          res.status(404).send('No hay jugadores')
      }
    } else {
      res.status(404).send('Juego no encontrado y no empezado')
    }
})

内容的提问来源于stack exchange,提问作者Mateo Nicolas Suarez

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 03:04:54