Как стать автором
Обновить

Создание приложения для real-time обмена геоданными с React, Socket.io и Leaflet

Уровень сложностиСредний
Время на прочтение14 мин
Количество просмотров5.8K
Автор оригинала: Manish Mehra

Есть много руководств о том, как сделать приложение для общения в реальном времени на React и Socket.io. Создание таких приложений полезно для обучения, но мне захотелось чего-то более творческого. Пришла идея сделать приложение, где можно делиться местоположением.

Итак, начнем.

Несколько скринов того, что получилось, и GitHub

Можно получать уведомления, когда кто-то присоединяется или уходит из канала, а также видеть текущее количество пользователей, находящихся в сети.

Как только оунер перестает делиться своим местоположением, канал удаляется, и другие пользователи не могут видеть локацию.

Тестировать приложение можно не выходя на улицу (изменение местоположения).

GitHub: https://github.com/manish-mehra/locshare

Настройка проекта

На фронтенде я настроил React-приложение с использованием Vite, TypeScript и Tailwind CSS. Для отображения координат на карте я взял известную библиотеку с открытым исходным кодом под названием Leaflet.

Я недавно начал использовать TypeScript и применил его в этом проекте. Однако важно отметить, что TypeScript не является обязательным инструментом.

Вот зависимости фронтенда:

{
  "name": "client",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "@types/leaflet": "^1.9.6",
    "@types/react-leaflet": "^3.0.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-icons": "^4.11.0",
    "react-leaflet": "^4.2.1",
    "react-router-dom": "^6.16.0",
    "react-toastify": "^9.1.3",
    "socket.io-client": "^4.7.2"
  },
  "devDependencies": {
    "@types/react": "^18.2.15",
    "@types/react-dom": "^18.2.7",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "@vitejs/plugin-react": "^4.0.3",
    "autoprefixer": "^10.4.15",
    "eslint": "^8.45.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.3",
    "postcss": "^8.4.29",
    "tailwindcss": "^3.3.3",
    "typescript": "^5.0.2",
    "vite": "^4.4.5"
  }
}

Для бэкенда есть базовый сервер node-express со следующими зависимостями:

{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\""
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "socket.io": "^4.7.2"
  },
  "devDependencies": {
    "@types/express": "^4.17.17",
    "concurrently": "^8.2.0",
    "nodemon": "^3.0.1",
    "typescript": "^5.2.2"
  }
}

Структура папок

React:

? src
├── ? components
│   ├── ? Element
│   │   ├── ? Header
│   │   ├── ? Map
│   │   ├── ? Status
│   │   ├── ? StatusPanel
│   ├── ? Layout
├── ? context
│   ├── ? socket.tsx
├── ? pages
│   ├── ? Home.tsx
│   ├── ? Location.tsx
├── ? types
├── ? App.tsx
├── ? main.tsx
├── ? index.css
...rest

Node:

? src
├── ? index.ts

Создание Socket Context

Сохраняя сокет в контексте, его можно сделать доступным для всего приложения. Функция connectSocket обрабатывает подключение. Если сокет - null (нет существующего подключения), функция устанавливает новое соединение сокета. Если сокет - не null (отключен), функция подключается к сокету.

// SocketProvider.js
import {useState, createContext, useContext, JSX} from 'react'
import {io, Socket} from 'socket.io-client'
import { SOCKET_URL } from '../config'

type SocketContextType = {
  socket: Socket | null;
  connectSocket: () => void;
}

type SocketProviderProps = {
  children: JSX.Element
}

export const SocketContext = createContext<SocketContextType | null>(null)

export const SocketProvider = ({children}: SocketProviderProps) => {
  const [socket, setSocket] = useState<Socket | null>(null)

  const connectSocket = () => {
    if(!socket) {
      const newSocket: Socket = io(SOCKET_URL)
      setSocket(newSocket)
      return
    }
    socket.connect()
  }

  return (
    <SocketContext.Provider value={{socket, connectSocket}}>
      {children}
    </SocketContext.Provider>
  )
}

export const useSocket = () => {
  const context = useContext(SocketContext)
  if(!context) {
    throw new Error('Something went wrong!')
  }
  return context
}

Здесь были сделаны упрощения. Добавлен кастомный хук useSocket, который обрабатывает логику доступа к SocketContext вместе с SocketProvider. Такой подход упрощает код, делая его более читаемым и понятным при работе со значениями контекста в компонентах.

Оборачивание приложения контекстом сокета

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { SocketProvider } from './context/socket.tsx'
import {ToastContainer} from 'react-toastify'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <SocketProvider>
      <>
        <App />
        <ToastContainer newestOnTop/>
      </>
    </SocketProvider>
  </React.StrictMode>,
)

Роуты приложения

Здесь есть две страницы: home и location. В home можно создать канал и передать местоположение. Страница location отображается, когда URL соответствует шаблону /location/1242, указывая, что 1242 - это определенный идентификатор канала или параметр.

Эти страницы обернуты в Layout Component.

import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Home from './pages/Home'
import Location from './pages/Location'

function App() {

  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="location/:roomId" element={<Location />}/>
        </Route>
      </Routes>
    </BrowserRouter>
  )
}

export default App

Layout Component

import { Outlet } from 'react-router-dom'
import Header from '../Elements/Header'

function index() {
  return (
    <div className='flex justify-center px-3 py-2'>
      <div className='flex flex-col w-full md:min-w-full xl:min-w-[1100px] xl:max-w-[1200px] mb-4'>
        <Header />
        <main>
          <Outlet />
        </main>
      </div>
    </div>
  )
}

export default index

Доступ к локации пользователя

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

// получить текущую локацию
navigator.geolocation.getCurrentPosition(success, error, options)

success, error (опционально), options (опционально) — это передаваемые коллбэк-функции, и через них можно получить доступ к координатам или ошибке или передать дополнительные параметры. Дополнительную информацию можно найти в доке Mozilla.

Просто текущая позиция не нужна. Необходимо именно отслеживать перемещение пользователя из точки А в точку Б.

Мы могли бы использовать функцию setInterval, которая неоднократно бы вызывала getCurrentPosition(). Но она не требуется, поскольку интерфейс геолокации предоставляет другой метод, watchPosition(). И этот метод вызывается каждый раз, когда позиция меняется.

// узнать текущую локацию
let id = navigator.geolocation.watchPosition(success, error, options)

// очистка
navigator.geolocation.clearWatch(id)

Метод watchPosition похож на setTimeout или setInterval, который работает асинхронно в фоновом режиме и отслеживает текущую позицию пользователя. Сохраняя его в переменной, сохраняется и ссылка на него. Позже, когда больше не нужно будет отслеживать местоположение пользователя, мы можем очистить переменную или установить ее значение как null. Если этого не сделать, все продолжит работать в фоновом режиме, что может привести к проблемам с памятью и ненужному потреблению ресурсов.

type GeolocationPosition = {
  lat: number
  lng: number
}
type LocationStatus = 'accessed' | 'denied' | 'unknown' | 'error'

export default function Home() {
  const [locationStatus, setLocationStatus] = useState<LocationStatus>('unknown')
  const [position, setPosition] = useState<GeolocationPosition | null>(null)

  useEffect(() => {
    let watchId: number | null = null
        // проверка воможности отслеживать геопозицию в браузере
    if('geolocation' in navigator) {
      watchId = navigator.geolocation.watchPosition((position) => {
      setPosition({
        lat: position.coords.latitude,
        lng: position.coords.longitude
      })
      setLocationStatus('accessed')
      }, (error) => {
        switch (error.code) {
          case error.PERMISSION_DENIED:
            setLocationStatus('denied')
            break
          case error.POSITION_UNAVAILABLE:
            setLocationStatus('unknown')
            break
          case error.TIMEOUT:
            setLocationStatus('error')
            break
          default:
            setLocationStatus('error')
            break
        }
      })
      return () => {
        if(watchId) {
          navigator.geolocation.clearWatch(watchId)
        }
      }
    }
  }, [])
...
...

При загрузке страницы, появляется запрос на доступ к местоположению и запрашивается разрешение на определение местоположения. На основании этого определяется, дал ли пользователь разрешение на определение местоположения. Как только геолоцирование разрешено, появляется доступ к объекту положения, который содержит координаты пользователя.

С кодом ошибки, предоставленным геолокацией, можно легко обработать ошибку и точно узнать, почему нет доступа к местоположению.

Определение координат на карте

Получив координаты пользователя можно передавать их в компонент Map, и местоположение будет показано на карте.

Нужно, чтобы маркер карты автоматически перемещался на переданные координатам. Для этого в компоненте Location Marker помещен метод map.flyTo, предоставленный инстансом leaflet, внутрь хука useEffect с позицией в качестве зависимости. При каждом изменении местоположения маркер будет перемещаться в нужное место.

import { useState, useEffect } from 'react'
import { MapContainer, TileLayer, useMapEvents, Marker, Popup } from 'react-leaflet'
import { GeolocationPosition } from '../../../types' 
import 'leaflet/dist/leaflet.css'

function Map({ location }: { location: GeolocationPosition }) {
  if (!location) return 'No location found'

  return (
    <div className='w-full bg-gray-100 h-[600px] md:h-[550px]'>
      <MapContainer center={[location.lat, location.lng]} zoom={30} scrollWheelZoom={true} className='h-screen'>
        <TileLayer
          url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' // URL слоя тайлов OpenStreetMap
        />
        <LocationMarker location={location} />
      </MapContainer>
    </div>
  )
}

function LocationMarker({ location }: { location: GeolocationPosition }) {

  const map = useMapEvents({})  // Используйте события карты для доступа к инстансу карты Leaflet
  const [position, setPosition] = useState({
    lat: location.lat,
    lng: location.lng
  })

  // Эффект обновления положения маркера и перелета в новое место при изменении данных о местоположении
  useEffect(() => {
    setPosition({
      lat: location.lat,
      lng: location.lng
    })
    map.flyTo([location.lat, location.lng]) // Изменение положения на карте
  }, [location])

  return position === null ? null : (
    <Marker position={position}>
      <Popup>User is here!</Popup>
    </Marker>
  )
}

export default Map

Теперь можно использовать этот компонент на домашней странице.

export default function Home() {
  const [locationStatus, setLocationStatus] = useState<LocationStatus>('unknown')
  const [position, setPosition] = useState<GeolocationPosition | null>(null)

    // ... rest

return(
<>  
{/** ...rest **/}
                {
            position && (<Map location={position}/>)
        }
</>
)

Настройка сокет-сервера node-express

В этом коде настроен простой HTTP-сервер, на Node.js и Express. Затем создан инстанс сервера Socket.io (переменная io) и ему передан сервер Express. Несмотря на то, что HTTP и веб-сокеты представляют из себя разные протоколы связи, Socket io позволяет как HTTP-серверу, так и серверу WebSocket использовать один и тот же инстанс сервера, позволяя им взаимодействовать через один и тот же сетевой порт.

import express, { Express, Request, Response } from 'express'
import {Socket, Server} from 'socket.io'
import cors from 'cors'
import dotenv from 'dotenv'

dotenv.config()

const app: Express = express()
const port = process.env.PORT || 5000

app.use(cors())
app.use(express.json())

app.get('/', (req: Request, res: Response) => {
  res.send('Welcome to LocShare!')
})


const server = app.listen(port, () => {
  console.log(`Server is running`)
})

const io: Server = new Server(server, {
  cors: {
    origin: '*',
  },
})

io.on('connection', (socket: Socket) => {
  console.log(`User connected: ${socket.id}`)
})

Когда пользователь делится местоположением, по сути, уже создается канал.

Этот канал будет иметь уникальный идентификатор. Как только канал будет создан, идентификатор отправляется пользователю.

Вот флоу:

  1. Пользователь делится местоположением: событие создания канала

  2. Сервер получает событие

    1. Создание идентификатора канала

    2. Присоединение к каналу

    3. Прикрепление идентификатора канала к текущему сокет-клиенту

    4. Привязка события создания канала и идентификатора канала

    5. Сохранение создателя канала

Здесь немного расширяется сокет. К нему присоединяется дополнительное свойство под названием roomId. Позже оно будет использовано при выходе пользователя из канала.

// Определение кастомного интерфейса, расширяющего интерфейс Socket
interface CustomSocket extends Socket {
  roomId?: string
}
const roomCreator = new Map<string, string>() // roomid => socketid

io.on('connection', (socket: CustomSocket) => {
  console.log(`User connected: ${socket.id}`)

  socket.on('createRoom', (data) => {
    const roomId = Math.random().toString(36).substring(2, 7)
    socket.join(roomId) // присоединение к каналу в сокетах
        socket.roomId = roomId //  присваивание roomId сокету
    const totalRoomUsers = io.sockets.adapter.rooms.get(roomId)
    socket.emit('roomCreated', { 
      roomId,
      position: data.position,
      totalConnectedUsers: Array.from(totalRoomUsers || []),
    })
    roomCreator.set(roomId, socket.id) // маппинг roomid с сокетом
  })
})

Присоединение к каналу

Чтобы присоединиться к каналу клиент генерирует событие joinRoom с идентификатором канала.

  1. Пользователь генерирует «joinRoom» с идентификатором канала

  2. Проверка существует ли канал:

    1. существует

      1. Присоединение к каналу

      2. Присвоение roomid сокету

      3. Уведомление создателю канала

      4. Сообщение тому, кто присоединился (сокет)

    2. !существует

      1. Уведомление сокета

...
...
socket.on('joinRoom', (data: {roomId: string}) => {

    // проверка существования канала
    const roomExists = io.sockets.adapter.rooms.has(data.roomId)
    if (roomExists) {
      socket.join(data.roomId)
      socket.roomId = data.roomId //  Присвоение roomid сокету

      // Уведомление создателю канала     
      const creatorSocketID = roomCreator.get(data.roomId)
      if (creatorSocketID) {
        const creatorSocket = io.sockets.sockets.get(creatorSocketID) // получение инстанса сокета создателя
        if (creatorSocket) {
          const totalRoomUsers = io.sockets.adapter.rooms.get(data.roomId)
          creatorSocket.emit('userJoinedRoom', {
            userId: socket.id,
            totalConnectedUsers: Array.from(totalRoomUsers || [])
          })
        }
      }
      // сообщение присоединившемуся
      io.to(`${socket.id}`).emit('roomJoined', {
        status: 'OK',
      })

    } else {
      io.to(`${socket.id}`).emit('roomJoined', {
        status: 'ERROR'
      })
    }
  })

Обновление локации

socket.on('updateLocation', (data) => {
    io.emit('updateLocationResponse', data)
})

Домашняя страница: подключение сокетов и создание канала

Теперь с домашней страницы можно подключиться к сокет-серверу. Для начала работы пользователь должен сначала поделиться своим местоположением. После получения координат можно подключаться к серверу. После успешного соединения автоматически создается событие createRoom.

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

type SocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
type RoomInfo = {
  roomId: string
  position: GeolocationPosition
  totalConnectedUsers: string[]
}

export default function Home() {
    // состояния, связанные с местоположением

  const {socket, connectSocket} = useSocket()
  const [socketStatus, setSocketStatus] = useState<SocketStatus>('disconnected')
    const [roomLink, setRoomLink] = useState<string>('')
    const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null)

  function connectToSocketServer() {
    connectSocket()
    setSocketStatus('connecting')
  }

  useEffect(() => {
    let watchId: number | null = null
    // логика леолокации
  }, [])

  useEffect(() => {
    if(socket) {
      socket.on('connect', () => {
        setSocketStatus('connected')
        socket.emit('createRoom', {
          position
        })
      })

      socket.on('roomCreated', (data: RoomInfo) => {
        toast.success('You are live!', {
          autoClose: 2000,
          })
        setRoomInfo(data)
      })

      socket.on('userJoinedRoom', (data: {userId: string, totalConnectedUsers: string[]}) => {
        setRoomInfo((prev) => {
          if(prev) {
            return {
              ...prev,
              totalConnectedUsers: data.totalConnectedUsers
            }
          }
          return null
        })

        toast.info(`${data.userId} joined the room`, {
          autoClose: 2000,
        })

        position && socket.emit('updateLocation', {
          position
        })
      })

      socket.on('disconnect', () => {
        setSocketStatus('disconnected')
      })
    }
  }, [socket])

  useEffect(() => {
    if(socket) {
      socket.emit('updateLocation', {
        position
      })
    }
  }, [position])


  return (
    <>
            {/* ...rest */}
            {
              socketStatus === 'disconnected' && (
                <div className='flex flex-col gap-6 items-start w-full'>
                  <button 
                    className={`${locationStatus === 'accessed' ? 'bg-purple-800' : 'bg-gray-600 cursor-not-allowed'}`}
                    onClick={() => {
                      if(locationStatus === 'accessed') {
                        connectToSocketServer()
                      } else {
                        toast.error('Please allow location access', {
                          autoClose: 2000,
                        })
                      }
                    }}
                    disabled={locationStatus !== 'accessed'}
                    >Share Location</button>
                  {/*  ...rest */}
              </div>
              )

Страница местоположения: доступ к идентификатору канала и присоединение к каналу

Когда страница загружается, извлекается идентификатор канала из URL. Впоследствии устанавливается соединение с сокетом. После успешного установления соединения запускается событие «join room», чтобы указать, что пользователь присоединился к указанной комнате.

import React, {useState, useEffect} from 'react'
import { useParams } from 'react-router-dom'
import {useSocket} from '../context/socket'

type RoomStatus = 'unknown' | 'joined' | 'not-exist'

function Location() {
  const { roomId } = useParams()
  const { socket, connectSocket } = useSocket()
  const [socketStatus, setSocketStatus] = useState<SocketStatus>('disconnected')
  const [roomStatus, setRoomStatus] = useState<RoomStatus>('unknown')
  const [position, setPosition] = useState<GeolocationPosition | null>(null)

  useEffect(() => {
    connectSocket()
    setSocketStatus('connecting')
    return () => {
      if(socket) {
        socket.disconnect()
        setSocketStatus('disconnected')
      }
    }
  }, [])

  useEffect(() => {

    if(socket){      

      socket.on('connect', () => {
        setSocketStatus('connected')
        socket.emit('joinRoom', {
          roomId
        })
      })

      socket.on('roomJoined', ({status}: {status: string}) => {
        if(status === 'OK') {
          setRoomStatus('joined')
        } else if (status === 'ERROR') {
          setRoomStatus('not-exist')
        } else {
          setRoomStatus('unknown')
        }
      })

      socket.on('updateLocationResponse', ({position}:{position: GeolocationPosition}) => {
        if(position) {
          setPosition(position)
        }
      })

      socket.on('disconnect', () => {
        setSocketStatus('disconnected')
      })
    }

  }, [socket])

// ...rest

Сервер: выход из канала

Вот логика, когда пользователь покидает комнату:

Пользователь покидает канал:

  1. Для создателя канала:

    1. Если уходящий пользователь является создателем:

      1. Закрытие канала

      2. Уведомление других пользователей

  2. Для гостя канала:

    1. Если уходящий пользователь является гостем:

      1. Уведомление создателю:

        1. Нотификация создателю комнаты об уходе.

      2. Выход из канала:

        1. Проверка того, что ушедший пользователь удален из списка участников комнаты.

io.on('connection', (socket: CustomSocket) => {
  console.log(`User connected: ${socket.id}`)

// ...rest code

socket.on('disconnect', () => {
    console.log(`User disconnected: ${socket.id}`)

    const roomId = socket.roomId
    if(roomId){
      // Удаление комнаты при выходе создателя
      if(roomCreator.get(roomId) === socket.id){
        // Уведомление гостям об удалении
        const roomUsers = io.sockets.adapter.rooms.get(roomId)
        if(roomUsers){
          for (const socketId of roomUsers) {
            io.to(`${socketId}`).emit('roomDestroyed', {
              status: 'OK'
            })
          }
        }
        io.sockets.adapter.rooms.delete(roomId)
        roomCreator.delete(roomId)    
      } else{
        socket.leave(roomId)
        // Уведомление создателю о выходе гостя
        const creatorSocketId = roomCreator.get(roomId)
        if(creatorSocketId){
          const creatorSocket = io.sockets.sockets.get(creatorSocketId)
          if(creatorSocket){
            creatorSocket.emit('userLeftRoom', {
              userId: socket.id,
              totalConnectedUsers: Array.from(io.sockets.adapter.rooms.get(roomId) || [])
            })
          }
        }
      }
    }

  })

})

Обновление домашней страницы и страницы местоположения: выход из комнаты

home.tsx

export default function Home() {

    // ...rest

  useEffect(() => {
    if(socket) {
      socket.on('connect', () => {
        setSocketStatus('connected')
        socket.emit('createRoom', {
          position
        })
      })
            // ...rest

            socket.on('userLeftRoom', (data: {userId: string, totalConnectedUsers: string[]}) => {
        setRoomInfo((prev) => {
          if(prev) {
            return {
                ...prev,
                totalConnectedUsers: data.totalConnectedUsers
              }
            }
            return null
          })
          toast.info(`${data.userId} left the room`, {
            autoClose: 2000,
          })
      })

      socket.on('disconnect', () => {
        setSocketStatus('disconnected')
      })
    }
  }, [socket])
// ...rest

location.tsx

function Location() {

  // ...rest

  useEffect(() => {

    if(socket){      

      socket.on('connect', () => {
        setSocketStatus('connected')
        socket.emit('joinRoom', {
          roomId
        })
      })

      // ...rest

            socket.on('roomDestroyed', () => {
        setRoomStatus('not-exist')
        socket.disconnect()
      })

      socket.on('disconnect', () => {
        setSocketStatus('disconnected')
      })
    }

  }, [socket])

function stopSharingLocation() {
    if(socket){
      socket.disconnect()
      setSocketStatus('disconnected')
      setRoomInfo(null)
      toast.success('You are no longer live!', {
        autoClose: 2000,
      })
    }
  }

// ...rest

Всё

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

PRы приветствуются, если захочется поучаствовать!


Спасибо @r1ndaman за редактуру статьи.

Теги:
Хабы:
Всего голосов 7: ↑6 и ↓1+5
Комментарии0

Публикации

Истории

Работа

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань