Многие из нас не прочь посостязаться в эрудиции. Утолить свой соревновательный азарт можно с помощью специальных приложений, которые предлагают ответить на вопросы из разных профессиональных областей знаний. 

В статье мы рассмотрим способ реализации приложения для соревнований на эрудицию, написанное на Golang и работающее в режиме реального времени. 

Архитектура приложения 

Ниже  —  требования, предъявляемые к бизнес-логике.

  • Когда количество подключенных пользователей достигает 2-х человек, в течение 3 секунд автоматически начинается соревнование.
  • Соревнование включает 3 состояния: NOT_STARTED (не начато), STARTED (начато) и FINISHED (закончено).
  • Вопросы сопровождаются 4 вариантами ответов, и пользователь должен ответить на вопрос в течение 10 секунд
  • По окончании соревнования выводится рейтинговая таблица, отражающая результаты пользователей. 

Архитектурные решения 

В данном разделе я аргументирую выбранные решения и представляю видение проекта. Эта часть статьи  —  как спойлер к фильму.

  • Websocket—  важный протокол для реализации приложений в реальном времени. Он обеспечивает двустороннее взаимодействие между клиентом и сервером. Здесь мы не будем подробно разбирать его концепции, поскольку по ним представлено немало практических материалов. Websocket осуществляет отправку вопросов и получение ответов от подключенных пользователей.
  • Для каждого пользователя используется уникальный id (по аналогии с id сессии), благодаря которому их легко различать. Мы сохраняем пользователей с id сессии, и с их помощью сервер управляет операциями чтения и записи. 
  • sync.Map поддерживает одновременные операции чтения и записи. sessionID применяется в качестве ключа, а структура Client  —  в качестве значения, как показано ниже. Client состоит из двух полей: клиентского соединения WebSocket для записи и чтения и totalScore для расчета рейтинговой таблицы. 
Модель карты Client
  • Широковещание (англ. broadcast)  —  это специальный термин для обозначения метода одновременной передачи сообщения всем получателям. К сожалению, такого метода нет в gorilla/websocket, поэтому воспользуемся пользовательским методом широковещания для отправки сообщений пользователям. 
Метод широковещания 
  • Для отправки вопросов пользователям необходимо определить модель. Я отказалась от применения в приложении структуры Question из-за наличия у нее поля correct_answer. Мне хотелось скрыть информацию от подключенных пользователей, поэтому я создала модель QuestionDTO, как показано ниже:
Модель Question DTO

Управление потоком соревнования 

Для управления потоком соревнования используется метод RunCompetition().

Создаем горутину и вызываем эту функцию в основном потоке приложения: 

func RunCompetition() {
CompetitionState = CompetitionNotStartedState

for {
if CompetitionState == CompetitionNotStartedState {
time.Sleep(CompetitionStateDuration)

numberOfClients := CountClient()

msg := DetermineCompetitionState(numberOfClients)
BroadcastMessage([]byte(msg))

if numberOfClients == 2 {
time.Sleep(CompetitionStartDuration)
CompetitionState = CompetitionStartedState
}
} else if CompetitionState == CompetitionStartedState {
PrepareQuestions()
StartSendingQuestions()
CompetitionState = CompetitionFinish
} else if CompetitionState == CompetitionFinish {
leaderBoard := CreateLeaderBoard()
jsonBytes, _ := json.Marshal(leaderBoard)
BroadcastMessage(jsonBytes)

BroadcastMessage([]byte(CompetitionFinishedStateMessage))

break
}
}
}

Метод RunCompetition() содержит 3 состояния CompetitionState.

  • CompetitionNotStartedState. Для начала соревнования необходимо наличие двух пользователей. Когда их количество numberOfClients равняется 2, состояние меняется на CompetitionStartedState.
  • CompetitionStartedState. В этом состоянии каждые 10 секунд мы отправляем вопросы всем подключенным пользователям.
func StartSendingQuestions() {
for i := range Questions {
Questions[i].IsTimeout = false

questionDTO := Questions[i].ToDTO()
questionDTOBytes, _ := json.Marshal(questionDTO)
BroadcastMessage(questionDTOBytes)

time.Sleep(QuestionResponseIntervalDuration)

Questions[i].IsTimeout = true
}
}

При отправке вопросов преобразуем структуру Question в questionDTO, чтобы скрыть правильный ответ correct_answer от пользователей и предотвратить обманные действия. 

По истечении 10 секунд IsTimeout устанавливается в значение true, поскольку истекает время, выделенное на ответ. 

  • CompetitionFinish. После отправки всех вопросов состояние CompetitionState меняется на CompetitionFinish. В момент окончания соревнования мы должны создать и отправить LeaderBoard

Обработка ответов пользователей 

Нам нужно получить ответы от пользователей, чтобы проверить и подсчитать результаты:

func HandleClientAnswer(sessionID string, message []byte) {
var ClientMsg ClientMessage
json.Unmarshal(message, &ClientMsg)

for _, question := range Questions {
if question.ID == ClientMsg.QuestionId {
if question.IsTimeout == true {
fmt.Println("Response Time is out")
} else {
load, _ := Clients.Load(sessionID)
client := load.(Client)
if ClientMsg.Answer == question.CorrectAnswer {
client.totalScore += ScoreForCorrectAnswer
Clients.Store(sessionID, client)
fmt.Printf("Right Answer!!! SessionId: %s, TotalScore: %d\n", sessionID, client.totalScore)
} else {
fmt.Printf("Wrong Answer!! Your Answer is : %s, Right Answer is : %s, SessionId: %s, TotalScore: %d\n", ClientMsg.Answer, question.CorrectAnswer, sessionID, client.totalScore)
}
}
}
}
}

Когда пользователи за установленный промежуток времени правильно отвечают на вопрос, они получают балл (+10).

Отметим ряд важных моментов.

  • IsTimeout  —  установленный промежуток времени. 
  • Сравнение question.ID и ClientMsg.QuestionId определяет, задан ли вопрос. 
  • Сравнение ClientMsg.Answer и question.CorrectAnswer определяет, правильно ли пользователь отвечает на вопрос. 

При выполнении этих условий результат пользователя сохраняется в карте с помощью sessionID. Вызываем данную функцию в методе ws():

func ws(c echo.Context) error {
numberOfClients := CountClient()
if numberOfClients >= 2 {
return c.String(http.StatusBadRequest, "")
}

wsConn, err := Upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
defer wsConn.Close()

sessionID := IDGenerator()

Clients.Store(sessionID, Client{
wsConn: wsConn,
totalScore: 0,
})

for {
_, message, err := wsConn.ReadMessage()
if err != nil {
Clients.Delete(sessionID)
c.Logger().Errorf("Client disconnect msg=%s err=%s", string(message), err.Error())
return nil
}

HandleClientAnswer(sessionID, message)
}
}

Чтобы приложение оставалось простым, соревнование начинается при наличии двух пользователей. Если же их больше двух, то приложение выдает ошибку http.StatusBadRequest

Исходный код можно найти здесь.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Dilara Görüm: Build a Basic Real-Time Competition App With Go

Предыдущая статьяСоздание готового к производству приложения React с помощью Next.js и Dokku
Следующая статья8 советов по разработке на JavaScript, которые освободят вас от переработок