Loạt bài viết của tôi về Bối cảnh mạng thống nhất vào năm 2023 sẽ tiếp tục! Bài đăng hôm nay sẽ đề cập đến các giao thức truyền dữ liệu được sử dụng trong các trò chơi nhiều người chơi trong thời gian thực.
Xin chào tất cả mọi người! Tôi là Dmitrii Ivashchenko, Kỹ sư phần mềm hàng đầu tại MY.GAMES. Chúng ta sẽ bắt đầu với một cái nhìn tổng quan ngắn gọn về những giao thức tồn tại ở các cấp độ tương tác mạng khác nhau.
Mô hình OSI (Open Systems Interconnection) là một mô hình khái niệm đặc trưng và tiêu chuẩn hóa các chức năng của một hệ thống truyền thông theo các cấp độ trừu tượng. Mô hình này được Tổ chức Tiêu chuẩn hóa Quốc tế (ISO) phát triển vào năm 1978.
Nó bao gồm bảy lớp:
Lớp vật lý : Lớp này xử lý việc truyền và nhận các bit thô qua kênh. Các giao thức ở lớp này mô tả giao diện vật lý và các đặc điểm của phương tiện, bao gồm biểu diễn bit, tốc độ truyền, cáp vật lý, thẻ và thiết kế đầu nối.
Lớp liên kết dữ liệu : Lớp này cung cấp khả năng truyền dữ liệu qua phương tiện vật lý và xử lý các lỗi có thể xảy ra ở cấp độ vật lý.
Lớp mạng : Lớp này xác định đường dẫn (định tuyến) để truyền dữ liệu giữa các mạng.
Lớp vận chuyển : Lớp này quản lý việc gửi tin nhắn giữa các điểm và cung cấp độ tin cậy nếu cần thiết.
Tầng phiên : Tầng này quản lý việc thiết lập, duy trì và chấm dứt phiên giữa người dùng và ứng dụng.
Lớp trình bày : Lớp này đảm bảo tính độc lập của dữ liệu khỏi sự khác biệt trong biểu diễn dữ liệu (mã hóa) ở phía người gửi và người nhận.
Lớp ứng dụng : Lớp này bao gồm các giao thức có mối quan hệ trực tiếp với các ứng dụng mà người dùng tương tác.
Điều đáng chú ý là mỗi lớp được xây dựng dựa trên lớp trước đó. Các lớp phía trên lớp Vận chuyển (Phiên, Bản trình bày và Ứng dụng) có tính chuyên biệt cao và không thể giúp chúng tôi tổ chức nhiều người chơi trong thời gian thực. Do đó, chúng tôi phải dừng lại ở Tầng vận chuyển và sử dụng các giao thức TCP và UDP của nó để trao đổi dữ liệu tối ưu giữa những người chơi.
TCP là một giao thức hướng kết nối, có nghĩa là giao tiếp xảy ra giữa hai thiết bị thiết lập kết nối để trao đổi dữ liệu. Giao thức này đảm bảo độ tin cậy vì nó đảm bảo rằng tất cả dữ liệu được truyền sẽ đến đích theo đúng thứ tự. Nếu bất kỳ dữ liệu nào bị mất trong quá trình truyền, TCP sẽ tự động thử lại yêu cầu cho đến khi tất cả dữ liệu được truyền thành công.
Thiết lập kết nối bao gồm các bước sau:
Ví dụ dưới đây cho thấy cách triển khai cơ bản của ứng dụng khách TCP và có thể được mở rộng để hoạt động với logic trò chơi và dữ liệu cụ thể.
Mã kết nối với máy chủ có địa chỉ IP và cổng được chỉ định, sau đó gửi và nhận dữ liệu thông qua kết nối. Luồng mạng được sử dụng để nhận dữ liệu không đồng bộ từ máy chủ.
using System; using System.Net; using System.Net.Sockets; using UnityEngine; public class TCPClient : MonoBehaviour { private TcpClient tcpClient; private NetworkStream networkStream; private byte[] receiveBuffer; private void Start() { // Example: Connect to server with IP address 127.0.0.1 (localhost) and port 5555 ConnectToServer("127.0.0.1", 5555); } private void ConnectToServer(string ipAddress, int port) { tcpClient = new TcpClient(); tcpClient.Connect(IPAddress.Parse(ipAddress), port); networkStream = tcpClient.GetStream(); // Start asynchronous operation to receive data from the server receiveBuffer = new byte[tcpClient.ReceiveBufferSize]; networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null); } private void ReceiveData(IAsyncResult result) { int bytesRead = networkStream.EndRead(result); byte[] receivedBytes = new byte[bytesRead]; Array.Copy(receiveBuffer, receivedBytes, bytesRead); string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes); Debug.Log("Received from server: " + receivedMessage); // Continue the asynchronous operation to receive data networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null); } private void SendData(string message) { byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message); networkStream.Write(sendBytes, 0, sendBytes.Length); networkStream.Flush(); } }
Phương thức ConnectToServer(string ipAddress, int port)
thiết lập kết nối đến máy chủ tại địa chỉ IP và cổng đã chỉ định. Việc nhận dữ liệu từ máy chủ được thực hiện theo phương thức ReceiveData(IAsyncResult result)
, trong khi việc truyền dữ liệu được thực hiện theo phương thức SendData(string message)
. Dữ liệu nhận được được xuất ra bàn điều khiển bằng cách sử dụng Debug.Log
.
Ví dụ mã bên dưới đại diện cho một máy chủ TCP đơn giản trong Unity. Mã khởi tạo máy chủ, lắng nghe cổng được chỉ định và chấp nhận các kết nối đến từ máy khách. Sau khi kết nối với máy khách, máy chủ sẽ gửi và nhận dữ liệu qua luồng mạng.
Phương thức StartServer(int port)
khởi tạo máy chủ trên cổng đã chỉ định và bắt đầu lắng nghe các kết nối đến. Khi kết nối máy khách được thiết lập, phương thức HandleIncomingConnection(IAsyncResult result)
được thực thi, phương thức này sẽ nhận dữ liệu từ máy khách và bắt đầu hoạt động không đồng bộ để nhận dữ liệu.
Dữ liệu đã nhận được xử lý theo phương thức ReceiveData(IAsyncResult result)
. Sau khi nhận dữ liệu từ máy khách, máy chủ có thể thực hiện xử lý cần thiết hoặc gửi dữ liệu trở lại máy khách.
Phương thức SendData(string message)
gửi dữ liệu tới máy khách thông qua luồng mạng. Dữ liệu được chuyển đổi thành một mảng byte và được gửi đến máy khách.
using System; using System.Net; using System.Net.Sockets; using UnityEngine; public class TCPServer : MonoBehaviour { private TcpListener tcpListener; private TcpClient connectedClient; private NetworkStream networkStream; private byte[] receiveBuffer; private void Start() { // Example: Start the server on port 5555 StartServer(5555); } private void StartServer(int port) { tcpListener = new TcpListener(IPAddress.Any, port); tcpListener.Start(); Debug.Log("Server started. Waiting for connections..."); // Start accepting client connections asynchronously tcpListener.BeginAcceptTcpClient(HandleIncomingConnection, null); } }
Phương thức HandleIncomingConnection
được sử dụng để xử lý kết nối máy khách đến. Sau khi chấp nhận kết nối, nó sẽ nhận được một luồng để trao đổi dữ liệu với máy khách và bắt đầu hoạt động không đồng bộ để nhận dữ liệu từ máy khách.
Sau đó, phương thức tạo bộ đệm để nhận dữ liệu, dựa trên kích thước bộ đệm nhận được từ máy khách được kết nối. Một hoạt động không đồng bộ được khởi chạy để đọc dữ liệu từ luồng bằng bộ đệm đã tạo. Sau khi thao tác đọc hoàn tất, dữ liệu sẽ được chuyển đến phương thức ReceiveData
để xử lý tiếp.
Ngoài ra, phương thức khởi chạy một hoạt động không đồng bộ khác để chấp nhận các kết nối đến để có khả năng chấp nhận ứng dụng khách tiếp theo.
Khi một máy khách kết nối với máy chủ, phương thức này sẽ được gọi để xử lý kết nối và máy chủ sẽ sẵn sàng chấp nhận không đồng bộ các máy khách tiếp theo.
private void HandleIncomingConnection(IAsyncResult result) { connectedClient = tcpListener.EndAcceptTcpClient(result); networkStream = connectedClient.GetStream(); Debug.Log("Client connected: " + connectedClient.Client.RemoteEndPoint); // Start asynchronous operation to receive data from the client receiveBuffer = new byte[connectedClient.ReceiveBufferSize]; networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null); // Accept next client connection asynchronously tcpListener.BeginAcceptTcpClient(HandleIncomingConnection, null); }
Phương thức ReceiveData
được sử dụng để xử lý dữ liệu nhận được từ máy khách. Sau khi thao tác đọc dữ liệu hoàn tất, phương thức sẽ kiểm tra số byte đã đọc. Nếu số byte nhỏ hơn hoặc bằng 0, điều này có nghĩa là máy khách đã bị ngắt kết nối. Trong trường hợp này, phương thức đóng kết nối với máy khách và kết thúc thực thi.
Nếu số byte được đọc lớn hơn 0, phương thức sẽ tạo một mảng byte cho dữ liệu nhận được và sao chép dữ liệu đã đọc vào mảng này. Sau đó, phương thức chuyển đổi các byte đã nhận thành một chuỗi bằng cách sử dụng mã hóa UTF-8 và xuất thông báo nhận được ra bàn điều khiển.
private void ReceiveData(IAsyncResult result) { int bytesRead = networkStream.EndRead(result); if (bytesRead <= 0) { Debug.Log("Client disconnected: " + connectedClient.Client.RemoteEndPoint); connectedClient.Close(); return; } byte[] receivedBytes = new byte[bytesRead]; Array.Copy(receiveBuffer, receivedBytes, bytesRead); string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes); Debug.Log("Received from client: " + receivedMessage); // Process received data // Continue the asynchronous operation to receive data networkStream.BeginRead(receiveBuffer, 0, receiveBuffer.Length, ReceiveData, null); } private void SendData(string message) { byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message); networkStream.Write(sendBytes, 0, sendBytes.Length); networkStream.Flush(); Debug.Log("Sent to client: " + message); }
Phương thức SendData
được sử dụng để gửi dữ liệu đến máy khách. Nó chuyển đổi chuỗi thông báo thành một mảng byte bằng mã hóa UTF-8 và ghi mảng này vào luồng mạng. Sau khi gửi dữ liệu, phương thức sẽ xóa luồng và xuất thông báo đã gửi tới bàn điều khiển.
Mặc dù độ tin cậy nghe có vẻ là một điểm cộng lớn, nhưng tính năng TCP này có thể gây ra sự cố trong các trò chơi nhiều người chơi thời gian thực. Việc truyền dữ liệu trong TCP có thể bị chậm lại do cơ chế truyền lại và kiểm soát luồng, điều này có thể dẫn đến độ trễ hoặc "độ trễ".
UDP là một giao thức đơn giản hơn không đảm bảo việc phân phối hoặc thứ tự gói tin. Điều này làm cho nó nhanh hơn nhiều so với TCP, vì nó không tốn thời gian thiết lập kết nối hoặc truyền lại các gói bị mất. Do tốc độ và sự đơn giản của nó, UDP thường được sử dụng trong các trò chơi mạng và các ứng dụng khác yêu cầu truyền dữ liệu thời gian thực.
Tuy nhiên, việc sử dụng UDP yêu cầu các nhà phát triển quản lý việc truyền dữ liệu cẩn thận hơn. Vì UDP không đảm bảo việc phân phối, bạn có thể cần triển khai các cơ chế của riêng mình để xử lý các gói bị mất hoặc các gói đến không đúng thứ tự.
Mã này thể hiện cách triển khai cơ bản của ứng dụng khách UDP trong Unity. Phương thức StartUDPClient
khởi tạo ứng dụng khách UDP và kết nối nó với máy chủ từ xa được chỉ định bởi địa chỉ IP và cổng. Máy khách bắt đầu nhận dữ liệu không đồng bộ bằng phương thức BeginReceive
và gửi thông báo đến máy chủ bằng phương thức SendData
.
using System; using System.Net; using System.Net.Sockets; using UnityEngine; public class UDPExample : MonoBehaviour { private UdpClient udpClient; private IPEndPoint remoteEndPoint; private void Start() { // Example: Start the UDP client and connect to the remote server StartUDPClient("127.0.0.1", 5555); } private void StartUDPClient(string ipAddress, int port) { udpClient = new UdpClient(); remoteEndPoint = new IPEndPoint(IPAddress.Parse(ipAddress), port); // Start receiving data asynchronously udpClient.BeginReceive(ReceiveData, null); // Send a message to the server SendData("Hello, server!"); } private void ReceiveData(IAsyncResult result) { byte[] receivedBytes = udpClient.EndReceive(result, ref remoteEndPoint); string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes); Debug.Log("Received from server: " + receivedMessage); // Continue receiving data asynchronously udpClient.BeginReceive(ReceiveData, null); } private void SendData(string message) { byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message); // Send the message to the server udpClient.Send(sendBytes, sendBytes.Length, remoteEndPoint); Debug.Log("Sent to server: " + message); } }
Khi dữ liệu được nhận từ máy chủ, phương thức ReceiveData
được gọi, phương thức này sẽ xử lý các byte đã nhận và chuyển đổi chúng thành một chuỗi. Tin nhắn nhận được sau đó được ghi vào bảng điều khiển. Máy khách tiếp tục nhận dữ liệu không đồng bộ bằng cách gọi lại BeginReceive
.
Phương thức SendData
chuyển đổi tin nhắn thành byte và gửi nó đến máy chủ bằng phương thức Send
của máy khách UDP.
Mã này thể hiện cách triển khai cơ bản của máy chủ UDP trong Unity. Phương thức StartUDPServer
khởi tạo máy chủ UDP trên cổng đã chỉ định và bắt đầu nhận dữ liệu không đồng bộ bằng phương thức BeginReceive
.
Khi dữ liệu được nhận từ máy khách, phương thức ReceiveData
được gọi, phương thức này sẽ xử lý các byte đã nhận và chuyển đổi chúng thành một chuỗi. Tin nhắn nhận được sau đó được ghi vào bảng điều khiển. Máy chủ tiếp tục nhận dữ liệu không đồng bộ bằng cách gọi lại BeginReceive
.
using System; using System.Net; using System.Net.Sockets; using UnityEngine; public class UDPServer : MonoBehaviour { private UdpClient udpServer; private IPEndPoint remoteEndPoint; private void Start() { // Example: Start the UDP server on port 5555 StartUDPServer(5555); } private void StartUDPServer(int port) { udpServer = new UdpClient(port); remoteEndPoint = new IPEndPoint(IPAddress.Any, port); Debug.Log("Server started. Waiting for messages..."); // Start receiving data asynchronously udpServer.BeginReceive(ReceiveData, null); } private void ReceiveData(IAsyncResult result) { byte[] receivedBytes = udpServer.EndReceive(result, ref remoteEndPoint); string receivedMessage = System.Text.Encoding.UTF8.GetString(receivedBytes); Debug.Log("Received from client: " + receivedMessage); // Process the received data // Continue receiving data asynchronously udpServer.BeginReceive(ReceiveData, null); } private void SendData(string message, IPEndPoint endPoint) { byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(message); // Send the message to the client udpServer.Send(sendBytes, sendBytes.Length, endPoint); Debug.Log("Sent to client: " + message); } }
Phương thức SendData
nhận một thông báo và một IPEndPoint
đại diện cho địa chỉ và cổng của máy khách. Nó chuyển đổi tin nhắn thành byte và gửi nó đến máy khách bằng phương thức Send
của máy chủ UDP.
Trong bối cảnh phát triển trò chơi, sự lựa chọn giữa TCP và UDP phần lớn phụ thuộc vào loại trò chơi của bạn. Nếu trò chơi của bạn yêu cầu cung cấp dữ liệu đáng tin cậy và thời gian phản hồi không phải là yếu tố quan trọng (ví dụ: trong chiến lược thời gian thực hoặc trò chơi theo lượt), thì TCP có thể là một lựa chọn phù hợp. Mặt khác, nếu trò chơi của bạn yêu cầu truyền dữ liệu nhanh và có thể xử lý một số trường hợp mất dữ liệu (ví dụ: trong game bắn súng góc nhìn thứ nhất hoặc game đua xe), thì UDP có thể là lựa chọn tốt nhất.
WebSocket là một giao thức truyền thông cho phép thiết lập kết nối liên tục giữa trình duyệt và máy chủ. Sự khác biệt chính so với HTTP thông thường là nó cho phép giao tiếp hai chiều; máy chủ không chỉ có thể đáp ứng các yêu cầu của máy khách mà còn có thể gửi tin nhắn đến nó.
WebSocket là một giao thức cấp ứng dụng dựa trên TCP. Nó hỗ trợ các tin nhắn, không phải luồng, giúp phân biệt nó với TCP thông thường. WebSocket bao gồm chức năng bổ sung có thể thêm một số chi phí hoạt động.
Đây là cách nó hoạt động, từng bước một:
Máy khách gửi một yêu cầu HTTP đặc biệt được gọi là "Yêu cầu nâng cấp". Yêu cầu này thông báo cho máy chủ rằng máy khách muốn chuyển sang WebSocket.
Nếu máy chủ hỗ trợ WebSocket và đồng ý chuyển đổi, nó sẽ phản hồi bằng một phản hồi HTTP đặc biệt xác nhận việc nâng cấp lên WebSocket.
Sau khi trao đổi các tin nhắn này, một kết nối hai chiều liên tục được thiết lập giữa máy khách và máy chủ. Cả hai bên có thể gửi tin nhắn bất cứ lúc nào, không chỉ để đáp ứng yêu cầu từ phía bên kia.
Bây giờ máy khách và máy chủ có thể gửi và nhận tin nhắn bất cứ lúc nào. Mỗi thông báo WebSocket được bao bọc trong các "khung" cho biết thời điểm thông báo bắt đầu và kết thúc. Điều này cho phép trình duyệt và máy chủ diễn giải chính xác thư, ngay cả khi chúng đến theo thứ tự hỗn hợp hoặc bị chia thành nhiều phần do sự cố mạng.
Một trong hai bên có thể đóng kết nối bất cứ lúc nào bằng cách gửi một khung "đóng kết nối" đặc biệt. Phía bên kia có thể phản hồi bằng cách xác nhận việc đóng và sau đó, cả hai bên phải ngay lập tức ngừng gửi bất kỳ dữ liệu nào khác. Mã trạng thái cũng có thể được sử dụng để chỉ ra lý do đóng cửa.
Đoạn mã dưới đây cung cấp một ví dụ về triển khai ứng dụng khách WebSocket trong Unity bằng ngôn ngữ C# và thư viện WebSocketSharp .
Trong phương thức Start()
, được gọi khi khởi tạo đối tượng, một phiên bản mới của WebSocket
được tạo, khởi tạo với địa chỉ của máy chủ WebSocket của bạn.
Sau đó, trình xử lý sự kiện OnOpen
và OnMessage
được thiết lập.
using UnityEngine; using WebSocketSharp; public class WebSocketClient : MonoBehaviour { private WebSocket ws; void Start() { ws = new WebSocket("ws://your-websocket-server-url/Auth"); ws.OnOpen += OnOpenHandler; ws.OnMessage += OnMessageHandler; ws.ConnectAsync(); } private void OnOpenHandler(object sender, System.EventArgs e) { var data = "Player1"; ws.Send(data) } private void OnMessageHandler(object sender, MessageEventArgs e) { Debug.Log("WebSocket server said: " + e.Data); } }
OnOpen
được kích hoạt khi kết nối được thiết lập với máy chủ. Trong ví dụ này, khi kết nối được thiết lập, một thông báo có nội dung "Player1" sẽ được gửi đến máy chủ.
OnMessage
được kích hoạt khi nhận được tin nhắn từ máy chủ. Tại đây, khi nhận được một tin nhắn, nội dung của nó sẽ được hiển thị trong bảng điều khiển.
Sau đó, phương thức ConnectAsync()
được gọi, kết nối không đồng bộ với máy chủ.
Đoạn mã dưới đây là một ví dụ về việc tạo một máy chủ WebSocket.
Trong phương thức Start()
, được gọi khi đối tượng được khởi tạo, một phiên bản mới của WebSocketServer
được tạo, được khởi tạo với địa chỉ của máy chủ WebSocket của bạn. Sau đó, dịch vụ WebSocket AuthBehaviour
được thêm vào máy chủ, có sẵn tại đường dẫn /Auth
. Sau đó, máy chủ được khởi động bằng phương thức Start()
.
using UnityEngine; using WebSocketSharp; using WebSocketSharp.Server; public class WebSocketServer : MonoBehaviour { void Start() { var socket = new WebSocketServer("ws://your-websocket-server-url"); socket.AddWebSocketService<AuthBehaviour>("/Auth"); socket.Start(); } } public class AuthBehaviour : WebSocketBehavior { protected override void OnMessage (MessageEventArgs e) { var playerName = e.Data; Debug.Log("WebSocket client connected: " + playerName); Send("Auth Complete: " + playerName); } }
AuthBehaviour
là một lớp bắt nguồn từ WebSocketBehavior
xác định hành vi của máy chủ khi nhận tin nhắn từ máy khách. Ở đây, phương thức OnMessage()
bị ghi đè, được gọi khi máy chủ nhận được tin nhắn từ máy khách.
Trong phương pháp này, văn bản thông báo trước tiên được trích xuất, sau đó một thông báo được xuất ra bảng điều khiển cho biết máy khách nào đã kết nối, sử dụng tên được truyền trong thông báo. Sau đó, máy chủ sẽ gửi lại một thông báo cho máy khách có chứa thông tin về việc hoàn thành xác thực.
Chúng ta đã thảo luận về cách tạo kết nối cho trò chơi Unity bằng TCP, UDP và WebSockets, cũng như ưu điểm và nhược điểm của từng loại. Bất chấp bản chất nhẹ của UDP và độ tin cậy của TCP, cả hai đều không phải là lựa chọn tốt để tổ chức nhiều người chơi trong thời gian thực trong trò chơi. UDP sẽ mất một số dữ liệu và TCP sẽ không cung cấp tốc độ truyền cần thiết. Trong bài viết tiếp theo, chúng ta sẽ thảo luận về cách tổ chức truyền dữ liệu đáng tin cậy bằng UDP.