Kỹ thuật lập trình Udp socket

    0

    Trong bài học này chúng ta sẽ phân tích chi tiết kỹ thuật lập trình Udp socket trong .NET framework, bao gồm cách khởi tạo, cách gửi nhận dữ liệu, cách đóng socket. Tất cả phân tích này thực hiện đối với code của phần thực hành đã thực hiện ở bài trước.

    Trong phần thực hành ở bài trước chúng ta đã xây dựng được một ứng dụng mạng đầu tiên theo mô hình client/server sử dụng socket Udp. Chúng ta sẽ phân tích chi tiết code ứng dụng này để hiểu rõ hơn kỹ thuật lập trình với socket Udp trong C#.

    Khởi tạo socket Udp

    Đối với cả client và server trước khi có thể làm việc với dịch vụ Udp đều phải thực hiện khởi tạo socket Udp.

    Có hai overload để khởi tạo object của lớp Socket. Trong phần thực hành trên chúng ta đã sử dụng cả hai overload này cho client và server:

    var socket = new Socket(SocketType.Dgram, ProtocolType.Udp);
    var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);

    Hai overload này đều yêu cầu cung cấp thông tin về loại socket (SocketType) và loại giao thức (ProtocolType).

    SocketType là một kiểu liệt kê (enum) xác định loại socket sẽ được sử dụng. SocketType có thể nhận một trong các giá trị sau: Dgram (dành cho socket UDP), Raw (dành cho socket thô), Stream (dành cho socket TCP).

    ProtocolType là một kiểu liệt kê thông báo cho Windows Socket API về loại giao thức sẽ được sử dụng trong socket và phải được lựa chọn phù hợp với SocketType. ProtocolType có rất nhiều giá trị khác nhau: Tcp (giao thức TCP), Udp (giao thức UDP), IPv4 (giao thức IPv4), IPv6 (giao thức IPv6), Raw (gói tin IP thô), v.v..

    Đối với socket Udp, giá trị SocketType phải là Dgram, ProtocolType là Udp.

    Overload thứ hai yêu cầu cung cấp thêm thông tin về họ địa chỉ (AddressFamily) được sử dụng. AddressFamily là một kiểu liệt kê xác định loại phương pháp đánh địa chỉ mạng nào được sử dụng cho socket, trong đó InterNetwork là giá trị tương ứng với loại địa chỉ IPv4, InternetWorkV6 là giá trị tương ứng với loại địa chỉ IPv6.

    Chiếm dụng cổng cho server

    Một object của lớp Socket sau khi được khởi tạo với tham số phù hợp với giao thức Udp đã sẵn sàng để sử dụng dịch vụ vận chuyển (đầu cuối – đầu cuối, tức là tiến trình – tiến trình) của Udp.

    Việc sử dụng này có sự khác biệt ở Client và Server:

    • Client có thể ngay lập tức sử dụng socket để gửi gói tin tới Server.
    • Server phải yêu cầu hệ điều hành cho phép chiếm dụng một cổng để chờ nghe gói tin đến từ Client.

    Client và Server phải thỏa thuận với nhau về giá trị cổng này (tức là Client phải biết được Server đang chiếm dụng và chờ nghe ở cổng nào).

    Việc yêu cầu chiếm dụng cổng này được thực hiện bởi chuỗi lệnh sau trong code Server:

    var localIp = IPAddress.Any;
    var localPort = 1308;
    var localEndPoint = new IPEndPoint(localIp, localPort);
    var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
    socket.Bind(localEndPoint);

    Lớp hỗ trợ lưu địa chỉ Ip và đầu cuối

    Ở đây chúng ta gặp hai class mới:

    Lớp IPAddress là một lớp hỗ trợ để làm việc với địa chỉ IPv4 như chuyển đổi qua lại giữa các cách thức biểu diễn địa chỉ IP (chuỗi ký tự, danh sách các octet của IP). Lớp IPAddress chứa danh sách các hằng số IP đặc biệt (Loopback Broadcast cho IpV4, IpV6Loopback, IpV6Broadcast). Hai giá trị đặc biệt của IPAddress Any (đại diện cho Ip của tất cả các giao diện mạng) và None (không liên kết với bất kỳ giao diện mạng nào).

    Lớp IPEndPoint hỗ trợ lưu trữ thông tin về điểm cuối dùng khi khởi tạo socket, thực hiện kết nối Tcp, hoặc để thực hiện truyền thông qua Udp. Do socket là điểm đầu và điểm cuối của truyền thông liên tiến trình end-to-end, mỗi điểm cuối chứa địa chỉ của tiến trình trên liên mạng, bao gồm địa chỉ IP của máy và một cổng đại diện cho tiến trình.

    Phương thức Bind

    Ở Server phải dùng lệnh Bind của Socket để yêu cầu hệ điều hành cho phép chiếm dụng một cổng. Lệnh Bind yêu cầu cung cấp một object thuộc kiểu IPEndPoint làm tham số.  Như trên đã nói, một endpoint chứa địa chỉ Ip của host và số cổng mà tiến trình cần sử dụng.

    Nếu cổng đang rỗi (chưa có tiến trình nào sử dụng) và chương trình được cấp quyền thì nó sẽ chiếm dụng cổng này. Nếu cổng đã bị chiếm dụng bởi một tiến trình khác sẽ xảy ra lỗi. Nếu người dùng không có đủ quyền khi chạy ứng dụng thì chương trình Server cũng không chạy được tiếp.

    Địa chỉ Ip có thể là địa chỉ đang được gán cho bất kỳ NIC (Network Interface Card, card mạng) nào. Để xem được địa chỉ Ip đang gán cho các các NIC có thể dùng chương trình IpConfig của hệ thống từ Command Prompt:

    Xem thông tin địa chỉ IP với lệnh IPConfig
    Xem thông tin địa chỉ IP với lệnh IPConfig

    Nếu một máy tính có nhiều card mạng, mỗi card được nối với một mạng và sẽ có Ip riêng của mạng đó. Khi lựa chọn địa chỉ Ip cụ thể nào, Server sẽ chỉ nhận gói tin đến từ mạng tương ứng.

    Để Server có thể nhận gói tin đến từ tất cả các mạng, lớp IPAddress cung cấp giá trị Any (IPAddress.Any mà chúng ta đã sử dụng ở phần thực hành trên) dùng cho các mạng IpV4 hoặc IpV6Any dùng cho mạng IpV6.

    Như vậy, trong đoạn code của Server ở trên, chúng ta yêu cầu hệ điều hành cho phép sử dụng cổng 1308 để lắng nghe gói tin đến từ tất cả các mạng mà máy tính kết nối tới.

    Gửi dữ liệu qua socket Udp

    Để gửi dữ liệu qua socket Udp chúng ta phải thực hiện hai thao tác:

    1. chuyển đổi dữ liệu người dùng thành chuỗi byte;
    2. phát lệnh gửi dữ liệu bằng phương thức SendTo của lớp Socket.

    Trong phần thực hành, code gửi dữ liệu ở Client như sau:

    // biến đổi chuỗi thành mảng byte
    var sendBuffer = Encoding.ASCII.GetBytes(text);
    // gửi mảng byte trên đến tiến trình server
    socket.SendTo(sendBuffer, serverEndpoint);

    và code ở Server:

    // chuyển chuỗi thành dạng in hoa
    var result = text.ToUpper();
    var sendBuffer = Encoding.ASCII.GetBytes(result);
    // gửi kết quả lại cho client
    socket.SendTo(sendBuffer, remoteEndpoint);

    Chuyển đổi dữ liệu sang mảng byte

    Giao thức Udp (cũng như các giao thức truyền thông khác) đều chỉ chấp nhận và truyền đi các chuỗi byte mà không quan tâm đến ý nghĩa của dữ liệu. Việc đặt ra ý nghĩa của dữ liệu và chuyển đổi dữ liệu là trách nhiệm của ứng dụng. Vì lý do trên, trước khi gửi dữ liệu qua socket Udp (và cả socket Tcp sẽ xem xét ở phần tiếp theo), chúng ta phải chuyển đổi dữ liệu người dùng thành một chuỗi byte.

    Dữ liệu được xử lý trong chương trình C# rất đa dạng, bao gồm các kiểu dữ liệu cơ sở do C# định nghĩa sẵn (như số nguyên, số thực), chuỗi ký tự, ký tự, logic, enum, struct, class. Ứng với mỗi kiểu dữ liệu sẽ có cách thức khác nhau để chuyển đổi sang mảng byte. Trong đa số các trường hợp, việc chuyển đổi từ các kiểu dữ liệu của C# sang mảng byte là phức tạp. Việc chuyển đổi này có tên gọi là data serialization.

    Đối với các kiểu dữ liệu cơ sở, C# hỗ trợ sẵn một số phương thức để thực hiện chuyển đổi sang mảng byte. Đối với dữ liệu văn bản, .NET cung cấp lớp Encoding với phương thức GetBytes. Đối với các kiểu dữ liệu cơ sở khác (số nguyên, số thực, logic), .NET cung cấp lớp BitConverter. Cả hai class này sẽ được xem xét chi tiết ở một bài khác.

    Trong phần thực hành trên chúng ta chúng ta đã sử dụng lớp Encoding để chuyển đổi một chuỗi ký tự thành mảng byte theo mã hóa ASCII.

    Phương thức SendTo

    Khi muốn gửi dữ liệu qua socket Udp, chúng ta phải biết được địa chỉ của máy đích và số cổng mà tiến trình đích đang chờ. Để ra lệnh cho dịch vụ Udp bắt đầu gửi dữ liệu, chúng ta sử dụng lệnh SendTo của lớp Socket. Lệnh SendTo nhận một chuỗi byte (kết quả của biến đổi dữ liệu) và địa chỉ của tiến trình đích (lưu trong một object thuộc kiểu IPEndPoint).

    // gửi mảng byte trên đến tiến trình server
    socket.SendTo(sendBuffer, serverEndpoint);
    // gửi kết quả lại cho client
    socket.SendTo(sendBuffer, remoteEndpoint);
    

    Khi phát lệnh này, chương trình Udp của hệ thống sẽ nhận chuỗi byte từ ứng dụng, đặt vào trước chuỗi byte đó một chuỗi byte nhỏ khác (gọi là header của Udp, sẽ xem xét chi tiết ở bài tiếp theo) để tạo ra một gói tin (thực chất chỉ là một chuỗi byte lớn) gọi là Udp datagram. Sau đó, Udp tiếp tục truyền chuỗi byte datagram này tới chương trình Ip. Phương thức SendTo trả lại kết quả là tổng số byte đã được gửi đi.

    Trong phần thực hành trên, Client yêu cầu người dùng tự nhập Ip và port của Server (ở dạng chuỗi ký tự và chuyển đổi thành kiểu phù hợp), sau đó sử dụng thông tin này để gửi chuỗi byte đến tiến trình Server.

    Đối với Server, để gửi dữ liệu (đã xử lý) lại cho Client, Server phải biết được endpoint của Client tương ứng. Giá trị endpoint này Server thu được khi nó nhận dữ liệu từ Client.

    Nhận dữ liệu qua socket Udp

    Việc nhận dữ liệu qua socket Udp phức tạp hơn so với gửi dữ liệu.

    Khi một gói tin truyền qua socket Udp (dưới dạng một chuỗi byte), chúng ta không biết được chính xác nó chứa bao nhiêu byte. Vì vậy trước hết chúng ta phải tạo ra một mảng byte đệm đủ lớn để có thể chứa hết dữ liệu trong Udp datagram. Sau đó chúng ta gọi lệnh ReceiveFrom của lớp Socket để chuyển hết dữ liệu đọc được từ Udp socket sang mảng byte đệm này. Chuỗi byte trong mảng đệm sau đó sẽ được chuyển đổi thành dữ liệu người dùng mà chương trình có thể xử lý.

    Server

    Trong code nhận dữ liệu của Server:

    var size = 1024;
    var receiveBuffer = new byte[size];
    // biến này về sau sẽ chứa địa chỉ của tiến trình client nào gửi gói tin tới
    EndPoint remoteEndpoint = new IPEndPoint(IPAddress.Any, 0);
    // khi nhận được gói tin nào sẽ lưu lại địa chỉ của tiến trình client
    var length = socket.ReceiveFrom(receiveBuffer, ref remoteEndpoint);
    var text = Encoding.ASCII.GetString(receiveBuffer, 0, length);

    chúng ta tạo ra một mảng đệm chứa tối đa 1024 byte. Nếu gói tin từ client vượt quá kích thước này, chúng ta sẽ mất những byte từ vị trí 1024. Cũng vì lý do này, client không nên gửi cho server các gói tin vượt quá 1024 byte.

    Khi gọi lệnh ReceiveFrom, chúng ta phải cung cấp một tham số thứ hai theo kiểu ref (reference – truyền tham biến). Tham số thứ hai này sẽ chứa endpoint của máy vừa gửi gói tin tới. Đối với Server, đây chính là địa chỉ của tiến trình Client mà nó đang tương tác cùng. Server sẽ sử dụng endpoint này để gửi dữ liệu ngược trở lại cho Client.

    Client

    Code nhận dữ liệu của Client cũng tương tự như vậy:

    var size = 1024; // kích thước của bộ đệm
    var receiveBuffer = new byte[size]; // mảng byte làm bộ đệm
    // endpoint này chỉ dùng khi nhận dữ liệu
    EndPoint dummyEndpoint = new IPEndPoint(IPAddress.Any, 0);
    // nhận mảng byte từ dịch vụ Udp và lưu vào bộ đệm
    // biến dummyEndpoint có nhiệm vụ lưu lại địa chỉ của tiến trình nguồn
    // tuy nhiên, ở đây chúng ta đã biết tiến trình nguồn là Server
    // do đó dummyEndpoint không có giá trị sử dụng 
    var length = socket.ReceiveFrom(receiveBuffer, ref dummyEndpoint);
    // chuyển đổi mảng byte về chuỗi
    var result = Encoding.ASCII.GetString(receiveBuffer, 0, length);
    

    Ở đây chúng ta rất dễ nhận thấy, biến dummyEndpoint thực chất là dư thừa, bởi vì chúng ta đã biết chính xác endpoint của Server rồi. Việc sử dụng nó ở đây chỉ là để phù hợp với yêu cầu của phương thức ReceiveFrom.

    Kết quả của phương thức ReceiveFrom là tổng số byte chuyển từ socket sang bộ đệm. Đây chính là kích thước thật của dữ liệu nhận được. Giá trị này có vai trò quan trọng trong việc biến đổi mảng byte trở lại dữ liệu người dùng. Trong cả code Client và Server, chúng ta đều dùng giá trị này cùng với phương thức GetString của của lớp Encoding để chuyển đổi mảng byte trở lại chuỗi ký tự ASCII.

    Đặc điểm của truyền dữ liệu qua Udp socket

    Có một số vấn đề trong gửi nhận dữ liệu cần lưu ý.

    Khi phát lệnh gửi hoặc nhận dữ liệu, tiến trình sẽ bị “treo” tạm thời cho đến khi quá trình gửi hoặc nhận kết thúc. Đây là do chế độ blocking của socket, vốn được sử dụng mặc định.

    Client và Server phải thỏa thuận với nhau về trình tự gửi – nhận dữ liệu. Nếu một bên phát lệnh gửi thì bên kia phải phát lệnh nhận. Nếu hai bên không thực hiện theo thỏa thuận này, ví dụ cùng phát lệnh nhận dữ liệu, cả hai sẽ cùng bị treo (dead blocking).

    Vấn đề này có liên quan tới chế độ blocking của socket: khi phát lệnh nhận dữ liệu, lệnh này sẽ khóa luồng thực thi của ứng dụng lại để chờ dữ liệu đến. Chừng nào chưa có dữ liệu tới sẽ tiếp tục block việc thực thi của chương trình. Nếu cả hai tiến trình cùng phát lệnh nhận dữ liệu, cả hai sẽ cùng bị block vĩnh viễn vì không có dữ liệu tới.

    Vì server phải hoạt động liên tục để phục vụ client, thông thường người ta sẽ đặt code của server vào một vòng lặp vô tận.

    Nếu máy nguồn phát đi bao nhiêu gói tin (số lần gọi lệnh SendTo) thì máy đích phải có bấy nhiêu lệnh nhận gói tin. Nếu máy đích không nhận kịp thời, gói tin sẽ mất đi.

    Đóng socket

    Khi hoàn thành các thao tác gửi nhận dữ liệu và không có nhu cầu sử dụng socket nữa chúng ta có thể đóng socket đó lại bằng lệnh Close:

    // đóng socket và giải phóng tài nguyên 
    socket.Close(); 

    Thao tác này sẽ giải phóng biến socket và tất cả các tài nguyên bị nó chiếm dụng.

    Một khi gọi lệnh Close, socket sẽ không thể tái sử dụng được nữa. Nếu muốn sử dụng lại biến socket chúng ta bắt buộc phải khởi tạo nó sử dụng kỹ thuật khởi tạo đã trình bày ở trên.

    Trong phần thực hành, mỗi khi Client hoàn thành một vòng truy vấn – phản hồi, chúng ta giải phóng biến socket. Khi người dùng quyết định gửi một chuỗi ký tự mới tới Server, chương trình Client khởi tạo một giá trị mới cho biến socket. Ở chương trình Server chúng ta sử dụng một object socket duy nhất và không đóng nó.

    Chúng ta để ý rằng, mỗi lần Server nhận một chuỗi ký tự từ Client, nó đồng thời hiển thị địa chỉ của tiến trình Client, bao gồm Ip và số cổng. Giá trị cổng của tiến trình Client thay đổi qua mỗi truy vấn. Đây là do mỗi lần khởi tạo object của lớp Socket, hệ thống lại cấp cho tiến trình “mượn” tạm một giá trị cổng Udp để sử dụng. Khi có lệnh Close (hoặc khi kết thúc chương trình), cổng này sẽ bị thu hồi và có thể lại được cấp cho một socket khác.

    Giá trị cổng udp client thay đổi khi tạo object mới của socket
    Giá trị cổng udp client thay đổi khi tạo object mới của socket

    Advice

    Việc khởi tạo hoặc đóng socket đều đòi hỏi thời gian xử lý. Do đó, cần cân đối giữa tái sử dụng một object socket hoặc giải phóng/khởi tạo lại socket mới. Nếu ứng dụng không có nhu cầu gửi nhận gói tin thường xuyên với tần suất cao thì nên giải phóng socket sau khi hoàn thành mỗi lượt thực hiện công việc. Ngược lại, chúng ta nên tái sử dụng object của socket.

    Theo dõi
    Thông báo của
    guest

    0 Thảo luận
    Phản hồi nội tuyến
    Xem tất cả bình luận