Kỹ thuật lập trình Tcp socket với C#

    0

    Trong bài học này chúng ta sẽ phân tích chi tiết cách thức lập trình Tcp socket trong C#. Các phân tích này dựa trên code của bài thực hành lập trình tcp socket với C# cơ bản chúng ta đã thực hiện.

    Khởi tạo socket Tcp

    Để khởi tạo socket Tcp chúng ta vẫn áp dụng một trong hai overload của hàm tạo lớp Socket, tuy nhiên phải thay đổi giá trị của SocketType và ProtocolType cho phù hợp với Tcp. Socket Tcp yêu cầu SocketTypeStream, và ProtocolTypeTcp.

    Ở Client chúng ta đã sử dụng cách khởi tạo:

    // khởi tạo object của lớp socket để sử dụng dịch vụ Tcp
    // lưu ý SocketType của Tcp là Stream 
    var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
    

    Ở Server chúng ta đã sử dụng cách khởi tạo:

    // tcp sử dụng đồng thời hai socket: 
    // một socket để chờ nghe kết nối, một socket để gửi/nhận dữ liệu
    // socket listener này chỉ làm nhiệm vụ chờ kết nối từ Client
    var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
    

    Chúng ta cũng để ý rằng, đối với Client chúng ta chỉ khởi tạo một biến Socket, trong khi ở Server chúng ta phải khởi tạo hai biến Socket: Biến thứ nhất đặt tên là listener và được khởi tạo bằng hàm tạo của lớp Socket, biến thứ hai đặt tên là socket và nó được khởi tạo bằng phương thức Accept của listener.

    // tcp đòi hỏi một socket thứ hai làm nhiệm vụ gửi/nhận dữ liệu
    // socket này được tạo ra bởi lệnh Accept
    var socket = listener.Accept();

    Ở Client chỉ cần sử dụng một socket để việc gửi và nhận dữ liệu. Tuy nhiên, ở Server phải một socket chuyên môn đảm nhiệm việc chờ nghe yêu cầu kết nối từ client. Một khi có yêu cầu kết nối tới, Server tạo một socket khác để gửi và nhận dữ liệu. Chính socket thứ hai của Server mới là “đầu cầu” kết nối với socket của Client. Một khi thiết lập xong đầu cầu này của liên kết, socket listener sẽ quay về nhiệm vụ chính của mình: tiếp tục chờ yêu cầu liên kết mới từ các client khác.

    Liên kết Server với cổng và lắng nghe

    Tương tự như với socket Udp, socket listener của Server sau khi khởi tạo cũng phải yêu cầu hệ thống cho phép chiếm dụng một cổng đang trống bằng lệnh Bind:

    // yêu cầu hệ điều hành cho phép chiếm dụng cổng tcp 1308
    // server sẽ nghe trên tất cả các mạng mà máy tính này kết nối tới
    // chỉ cần gói tin tcp đến cổng 1308, tiến trình server sẽ nhận được
    listener.Bind(localEndPoint);
    

    Ở đây cần lưu ý rằng, cổng chiếm dụng bởi Tcp không liên quan tới cổng bị chiếm dụng bởi Udp. Điều này có nghĩa là, một tiến trình sử dụng Udp đã chiếm cổng, ví dụ, 1308, thì một tiến trình khác sử dụng Tcp cũng vẫn có thể đồng thời chiếm dụng cổng 1308.

    Vì vậy, để phân biệt rõ, người ta cũng gọi là cổng Tcp 1308, cổng Udp 1308. Để thử nghiệm, chúng ta có thể chạy thử đồng thời chương trình Server Udp và Server Tcp. Cả hai tiến trình này đều sử dụng cổng 1308 nhưng của hai giao thức khác nhau.

    Khác với Server Udp, Server Tcp còn phải thực hiện một thao tác nữa trước khi có thể tiếp nhận kết nối:

    // bắt đầu lắng nghe chờ các gói tin tcp đến cổng 1308
    listener.Listen(10);

    Lệnh Listen đặt socket listener vào chế độ chờ nghe yêu cầu kết nối từ các client. Socket listener cũng có thể xem là nơi thực hiện quá trình bắt tay ba bước. Khi quá trình bắt tay kết thúc, listener sinh ra một socket bản sao của nó để thực hiện gửi/nhận dữ liệu với socket của client. Khi hoàn thành nhiệm vụ gửi/nhận dữ liệu, socket bản sao sẽ bị hủy bỏ.

    Khi sử dụng lệnh Listen phải cung cấp một tham số có tên gọi là backlog thuộc kiểu số nguyên. Backlog quy định số lượng tối đa yêu cầu liên kết được đặt trong hàng đợi chờ đến lượt xử lý.

    Hình dung tại mỗi thời điểm có thể có nhiều yêu cầu kết nối từ các client nhưng Server chỉ có thể xử lý một yêu cầu (thực hiện bắt tay ba bước với client đó). Các yêu cầu còn lại sẽ được đặt trong một hàng đợi để chờ đến lượt xử lý. Số lượng yêu cầu tối đa đặt trong hàng chờ được quy định bởi tham số backlog.

    Tạo kết nối Tcp

    Theo quy trình làm việc của Tcp, trước khi có thể trao đổi dữ liệu, client phải thực hiện kết nối tới server. Quá trình tạo kết nối này đã được mô tả chi tiết trong bài lý thuyết về giao thức Tcp.

    Đối với Client, chúng ta sử dụng lệnh Connect của lớp Socket:

    // tạo kết nối tới Server
    socket.Connect(serverEndpoint);   
    

    Quá trình tạo liên kết này chỉ có thể theo chiều từ Client tới Server: Client đưa ra yêu cầu kết nối, Server lắng nghe và xử lý yêu cầu kết nối từ Client.

    Để Server bắt đầu xử lý yêu cầu kết nối từ Client, chúng ta sử dụng lệnh Accept của lớp Socket. Khi quá trình xử lý yêu cầu kết nối kết thúc và kết nối Tcp được tạo ra, một socket mới được Server sinh ra để thực hiện gửi/nhận dữ liệu.

    // tcp đòi hỏi một socket thứ hai làm nhiệm vụ gửi/nhận dữ liệu
    // socket này được tạo ra bởi lệnh Accept
    var socket = listener.Accept();
    

    Gửi dữ liệu qua liên kết Tcp

    Tương tự như Udp, các phương thức gửi và nhận dữ liệu của socket Tcp cũng chỉ làm việc với các chuỗi byte. Nhiệm vụ chuyển đổi dữ liệu của ứng dụng thành chuỗi byte (để gửi đi) và chuyển đổi chuỗi byte nhận được thành dữ liệu thuộc về chương trình.

    Socket Tcp sử dụng một phương thức khác để gửi dữ liệu.

    Ở Client:

    // 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.Send(sendBuffer);
    // không tiếp tục gửi dữ liệu nữa
    socket.Shutdown(SocketShutdown.Send);
    

    và ở Server:

    var sendBuffer = Encoding.ASCII.GetBytes(result);
    // gửi kết quả lại cho client
    socket.Send(sendBuffer);
    // không tiếp tục gửi dữ liệu nữa
    socket.Shutdown(SocketShutdown.Send);
    

    Chúng ta thấy lệnh Send được sử dụng để gửi dữ liệu với socket Tcp. Thông tin về tiến trình đối tác đã được lưu sẵn trong socket sau khi hoàn tất quá trình thiết lập liên kết Tcp, do đó chúng ta không cần cung cấp thông tin như đối với socket Udp.

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

    Như nội dung “truyền dữ liệu” của giao thức Tcp đã trình bày, khi phát lệnh Send, Tcp không đóng gói và truyền dữ liệu đi ngay lập tức. Thay vào đó, chuỗi byte dữ liệu sẽ được đưa vào một bộ nhớ đệm của Tcp. Sau đó, chương trình Tcp sẽ độc lập xử lý việc lấy dữ liệu từ bộ nhớ đệm, phân mảnh, đóng gói và chuyển tiếp xuống chương trình Ip. Toàn bộ quá trình phức tạp như truyền segment, nhận ACK, kiểm soát giá trị các trường của header được Tcp thực hiện mà không có sự can thiệp của ứng dụng.

    Bởi vì dữ liệu trước hết được đưa vào bộ đệm, quá trình truyền dữ liệu của Tcp khi nhìn từ ứng dụng sẽ giống như truyền một luồng byte liên tục chứ không phải ở dạng các gói byte độc lập như của Udp. Trên thực tế, .NET coi việc truyền dữ liệu qua liên kết Tcp là một luồng dữ liệu (Stream), coi Tcp socket là một backing store (nguồn dữ liệu, tương tự như file), và cung cấp lớp NetworkStream chuyên để làm việc với loại luồng dữ liệu này.

    Cơ chế truyền dữ liệu của Tcp khuyến nghị không nên phát nhiều lệnh Send với các dữ liệu nhỏ. Ngược lại, nên chuẩn bị một mảng dữ liệu lớn và phát một lệnh Send. Khi đó, cơ chế phân mảnh dữ liệu của Tcp sẽ hỗ trợ tốt hơn cho việc truyền dữ liệu.

    Vì được hình dung như một dòng byte liên tục, kể cả khi phát nhiều lệnh Send, dữ liệu trong mỗi lệnh Send cũng không được đóng gói độc lập như Udp và do đó ranh giới của dữ liệu ứng dụng không được duy trì. Chương trình ứng dụng phải có nhiệm vụ tạo ra các ranh giới để chương trình đích có thể tự phân tách các phần của dữ liệu.

    Luồng dữ liệu, lớp NetworkStream và Data Serialization sẽ được xem xét chi tiết ở chương sau.

    Shutdown tcp socket

    Ở cả Client và Server chúng ta thấy rằng sau lệnh Send đều có thêm một lệnh ShutDown. Phương thức ShutDown của lớp Socket chỉ sử dụng với socket Tcp và có ảnh hưởng quan trọng tới quá trình gửi/nhận dữ liệu. Phương thức này nhận một tham số kiểu enum SocketShutdown với ba giá trị: Send, Receive, Both.

    Lệnh ShutDown với giá trị tham số SocketShutdown.Send không ngắt kết nối Tcp. Nó ngăn không cho thực hiện các lệnh gửi dữ liệu tiếp theo. Nếu còn dữ liệu trong buffer chưa gửi hết, Tcp sẽ cố gắng gửi hết dữ liệu sau đó phát đi một chuỗi byte đặc biệt tới socket đối tác báo rằng quá trình truyền dữ liệu đã kết thúc. Đây là tín hiệu báo kết thúc dòng byte dữ liệu.

    Ở máy nhận, lệnh nhận dữ liệu (sẽ xem ở phần dưới) sẽ trả về giá trị 0, báo rằng dòng byte dữ liệu nhận đã kết thúc. Tín hiệu này có vai trò quan trọng trong việc gửi/nhận dữ liệu kích thước lớn.

    Nhận dữ liệu

    Để đọc dữ liệu vào chương trình chúng ta sử dụng phương thức Receive của lớp Socket như sau:

    // nhận mảng byte từ dịch vụ Tcp và lưu vào bộ đệm                    
    var length = socket.Receive(receiveBuffer);
    // chuyển đổi mảng byte về chuỗi
    var result = Encoding.ASCII.GetString(receiveBuffer, 0, length);
    // không tiếp tục nhận dữ liệu nữa
    socket.Shutdown(SocketShutdown.Receive);
    

    Tương tự như với Udp, lệnh đọc này cũng copy dữ liệu vào một mảng đệm của chương trình để xử lý sau. Lệnh Receive cũng trả về số lượng byte đọc được từ Tcp buffer.

    Tuy nhiên, việc nhận dữ liệu với socket Tcp khác biệt rất lớn với Udp. Khi dữ liệu tới, Tcp sẽ lưu hết vào một bộ nhớ đệm để chờ chương trình đọc ra. Khi nào chương trình phát lệnh đọc, dữ liệu sẽ được chuyển từ bộ nhớ đệm này vào chương trình và phần tương ứng trong bộ nhớ đệm sẽ được giải phóng.

    Nếu một bên kết thúc quá trình gửi dữ liệu và gọi lệnh ShutDown với tham số Send, lệnh Receive sẽ trả về giá trị 0, báo hiệu không còn dữ liệu để nhận và dòng byte nhận đã chấm dứt. Giá trị này có ý nghĩa quan trọng nếu nhận một khối lượng dữ liệu lớn vượt quá kích thước của mảng đệm. Khi đó, việc đọc dữ liệu phải đặt trong một vòng lặp và thực hiện liên tục cho tới khi Receive trả về giá trị 0.

    Sau khi hoàn tất nhận dữ liệu, chúng ta có thể gọi lệnh ShutDown với tham số Receive để ngăn các thao tác đọc dữ liệu tiếp theo. Nếu có dữ liệu tiếp tục đến cũng sẽ không được tiếp nhận nữa.

    Do Tcp đưa dữ liệu nhận được vào bộ đệm, nếu chương trình không kịp đọc và xử lý thì dữ liệu vẫn tiếp tục nằm ở đó chứ không mất đi. Đây là sự khác biệt rất lớn với Udp.

    Tương tự như khi gửi, quá trình nhận dữ liệu từ Tcp cũng được nhìn nhận như đọc một dòng byte liên tục và do đó có thể sử dụng giao diện luồng để hỗ trợ đọc dữ liệu.

    Chế độ blocking

    Chúng ta nên lưu ý rằng, các lệnh Connect, Accept, Send và Receive đã gặp ở trên mặc định đều hoạt động ở chế độ blocking. Điều này có nghĩa là một khi gọi một trong các lệnh tương ứng, toàn bộ việc thực thi của các lệnh phía sau đều sẽ dừng lại cho đến khi lệnh này hoàn tất.

    Ví dụ, đối với Client, lệnh Connect sẽ block luồng thực thi lại cho đến khi kết nối thành công, hoặc phát ra lỗi. Đối với Server, lệnh Accept sẽ block luồng thực thi cho đến khi có một yêu cầu kết nối tới và xử lý thành công. Chừng nào chưa có Client phát yêu cầu kết nối tới, Server sẽ bị block.

    Chế độ blocking giúp việc lập trình mạng an toàn và đơn giản hơn vì quá trình xử lý thường phải thực hiện theo trật tự tuyến tính, bước sau chỉ thực hiện được khi bước trước thành công. Đây là chế độ hoạt động mặc định của socket trên windows.

    Tuy nhiên, chế độ block cũng có nhiều hạn chế, đặc biệt với các ứng dụng có giao diện đồ họa. Trong quá trình thực hiện các lệnh blocking, toàn bộ giao diện sẽ bị treo. Chế độ block cũng làm giảm hiệu quả của Server khi phục vụ cùng lúc nhiều Client.

    Ở chương sau, chúng ta sẽ xem xét một số phương pháp giải quyết các vấn đề bày thông qua lập trình bất đồng bộ, lập trình đa luồng, và sử dụng chế độ non-blocking.

    Đóng liên kết

    Một khi quá trình trao đổi dữ liệu kết thúc có thể đóng liên kết lại và giải phóng các tài nguyên liên quan. Để đóng một socket Tcp sử dụng phương thức Close của lớp Socket:

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

    Trên thực tế, khi gọi lệnh Close, trước hết socket tự gọi lệnh ShutDown với giá trị tham số là Both để ngăn cản các thao tác đọc ghi. Lệnh đóng liên kết này sẽ kích hoạt quá trình như đã mô tả ở phần nội dung về giao thức Tcp. Sau đó, object sẽ bị hủy bỏ. Sau lệnh này, biến socket sẽ không trỏ vào object nào nữa. Việc gọi các lệnh trên socket sau khi gọi lệnh Close đều gây lỗi.

    Có một cách thức khác để ngắt liên kết nhưng không giải phóng object của socket là sử dụng phương thức Disconnect của lớp Socket. Phương thức Disconnect cũng gọi lệnh ShutDown như trên và ngắt kết nối nhưng không hủy bỏ object. Do đó, sau này có thể tái sử dụng object socket. Tuy nhiên, quá trình thiết lập liên kết sẽ phải thực hiện lại.

    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