Truyền dữ liệu: luồng (stream), NetworkStream

    5

    Trong bài học này chúng ta sẽ làm quen với luồng dữ liệu (stream), một thành phần đặc biệt quan trọng hỗ trợ lập trình mạng. Chúng ta cũng sẽ xem xét chi tiết việc sử dụng NetworkStream, loại luồng hỗ trợ làm việc với Tcp socket.

    Luồng dữ liệu (stream)

    Khái niệm luồng

    Luồng dữ liệu (stream) là thành phần trung gian giữa ứng dụng và nguồn dữ liệu (file, network, v.v.). Stream giúp thống nhất, ổn định, tăng hiệu quả và đơn giản hơn trong việc đọc/ghi dữ liệu với các loại nguồn.

    Có thể hình dung stream như một đường ống nối giữa chương trình và nguồn dữ liệu. Đường ống stream đó cho phép một chuỗi byte chạy qua, giống như nước chảy qua ống.

    Kiến trúc của stream trong .NET framework chia làm ba thành phần: luồng làm việc với nguồn dữ liệu (backing store stream), luồng hỗ trợ (decorator stream), bộ điều hợp luồng (stream adapter).

    Kiến trúc stream trong .NET
    Kiến trúc stream trong .NET

    Nguồn dữ liệu

    Nguồn dữ liệu (backing store) là nơi chứa dữ liệu mà từ đó chúng ta có thể đọc vào và/hoặc ghi ra theo từng byte hoặc từng khối byte.

    Có một số loại nguồn dữ liệu chính như file, bộ nhớ (memory), mạng (network). Ứng với mỗi loại nguồn dữ liệu, .NET tạo ra một loại stream, phân biệt là FileStream, MemoryStream, NetworkStream. Các loại stream này được gọi chung là backing store stream (luồng làm việc với nguồn dữ liệu).

    Một số nhiệm vụ chính mà mỗi luồng phải thực hiện bao gồm đóng/mở luồng, đọc/ghi dữ liệu, định vị trong luồng.

    Tất cả các lớp backing store stream đều là lớp con của System.IO.Stream. Stream là một lớp abstract chứa các thành viên mà tất cả các lớp kế thừa từ nó phải thực thi. Qua đó, Stream tạo ra một giao diện chung cho tất cả các loại luồng.

    Luồng hỗ trợ

    Luồng hỗ trợ (decorator stream) làm việc với luồng backing store để cung cấp những chức năng mã hóa, nén hoặc tạo bộ đệm.

    Luồng hỗ trợ phải chứa một luồng backing store bên trong và sử dụng cho việc đọc/ghi dữ liệu.

    Chúng ta chưa xem xét cách sử dụng luồng hỗ trợ trong bài học này.

    Bộ tiếp hợp

    Các bộ tiếp hợp luồng (stream adapter) được sử dụng để chuyển đổi từ byte sang dữ liệu cấp cao giúp đơn giản hóa việc ghi đọc các dữ liệu này.

    Luồng backing store và luồng decorator làm việc trực tiếp với byte hoặc mảng byte. Nếu chương trình cần làm việc với dữ liệu cấp cao hơn (object, số, văn bản, xml, json), chúng ta có thể sử dụng adapter cho việc biến đổi này.

    Ví dụ, để đọc/ghi văn bản có thể dùng lớp StreamWriter/StreamReader; để đọc ghi số/boolean có thể dùng lớp BinaryWriter/BinaryReader; nếu cần làm việc với Xml thì dùng XmlWriter/XmlReader.

    Bạn có thể đọc thêm về luồng trong .NETluồng file.

    NetworkStream

    NetworkStream là một backing store stream hỗ trợ làm việc với socket Tcp. Lớp này kế thừa trực tiếp từ lớp Stream và nằm trong không gian tên System.Net.Sockets.

    NetworkStream hỗ trợ đọc/khi dữ liệu từ Tcp socket nhưng không hỗ trợ định vị (seek) trên luồng này (khác với FileStream).

    Ví dụ minh họa

    Trước hết chúng ta cùng thực hiện lại bài thực hành socket Tcp nhưng chuyển sang sử dụng luồng thay vì dùng trực tiếp các phương thức Send/Receive của Tcp socket.

    Bước 1. Tạo solution với tên gọi TcpNetworkStream và hai project (dạng Console App .NET framework) đặt tên lần lượt là Client và Server.

    Bước 2. Thiết lập chế độ Multiple startup projects cho Client và Server, trong đó Server sẽ chạy trước.

    Solution TcpNetworkStream với hai project Client và Server ở chế độ Multiple startup projects
    Solution TcpNetworkStream với hai project Client và Server ở chế độ Multiple startup projects

    Bước 3. Viết code cho Client (lớp Program, file Program.cs)

    using System;
    using System.Text;
    using System.Net;
    using System.Net.Sockets;
    namespace Client
    {
        class Program
        {
            static void Main(string[] args)
            {
                Console.Title = "Tcp Client";
                Console.OutputEncoding = Encoding.UTF8;
                Console.Write("Server Ip: ");
                var address = IPAddress.Parse(Console.ReadLine());
                var serverEndpoint = new IPEndPoint(address, 1308);
                while (true)
                {
                    Console.Write("# Command > ");
                    var request = Console.ReadLine();
                    var client = new Socket(SocketType.Stream, ProtocolType.Tcp);
                    client.Connect(serverEndpoint);
                    // khởi tạo biến của NetworkStream từ tcp socket
                    var stream = new NetworkStream(client);
                    // lưu ý bổ sung \r\n vào cuối chuỗi truy vấn
                    // do bên server sẽ sử dụng phương thức ReadLine của bộ tiếp hợp StreamReader,
                    // nếu không bổ sung cặp \r\n thì ReadLine sẽ không thể dừng việc đọc
                    var sendBuffer = Encoding.UTF8.GetBytes(request + "\r\n");
                    // ghi mảng byte vào stream thay vì dùng phương thức Send của Socket
                    stream.Write(sendBuffer, 0, sendBuffer.Length);
                    // yêu cầu stream đẩy dữ liệu đi ngay
                    // nếu không có lệnh này, dữ liệu sẽ nằm tại bộ nhớ tạm và tcp socket sẽ không nhận được
                    stream.Flush();
                    var receiveBuffer = new byte[1024];
                    // đọc mảng byte từ stream thay vì dùng phương thức Receive của Socket
                    var count = stream.Read(receiveBuffer, 0, 1024);
                    var response = Encoding.UTF8.GetString(receiveBuffer, 0, count);
                    // lưu ý cắt bỏ các ký tự thừa \r\n ở cuối chuỗi phản hồi trước khi in ra console
                    // do bên server sẽ sử dụng phương thức WriteLine của bộ tiếp học StreamWriter,
                    // tất cả chuỗi gửi lên đường truyền đều tự động bổ sung cặp \r\n vào cuối, do đó chúng ta phải cắt bỏ trước khi in ra.
                    Console.WriteLine(response.Trim());
                    // đóng socket cũng đồng nghĩa với đóng và xóa bỏ các object của NetworkStream tạo ra từ socket này. 
                    // Chúng ta cũng có thể gọi lệnh stream.Close() thay vì đóng socket. Khi đóng luồng mạng cũng sẽ tự đóng socket bên dưới
                    client.Close();
                }
            }
        }
    }

    Bước 4. Viết code cho Server (lớp Program, file Program.cs)

    using System;
    using System.IO;
    using System.Net;
    using System.Net.Sockets;
    namespace Server
    {
        class Program
        {
            static void Main(string[] args)
            {
                Console.Title = "Tcp Server";
                var listener = new Socket(SocketType.Stream, ProtocolType.Tcp);
                listener.Bind(new IPEndPoint(IPAddress.Any, 1308));
                listener.Listen(10);
                Console.WriteLine($"Server started at {listener.LocalEndPoint}");
                while (true)
                {
                    var worker = listener.Accept();
                    // khởi tạo object của NetworkStream từ tcp socket
                    var stream = new NetworkStream(worker);
                    // sử dụng bộ tiếp hợp StreamReader để đọc chuỗi truy vấn
                    // vì truy vấn ở dạng chuỗi (theo quy ước giữa client và server), StreamReader sẽ giúp đọc chuỗi byte từ luồng NetworkStream và tự động chuyển đổi thành chuỗi ký tự Utf-8
                    var reader = new StreamReader(stream);
                    // sử dụng bộ tiếp học StreamWriter để ghi chuỗi phản hồi vào luồng NetworkStream
                    // vì phản hồi ở dạng chuỗi theo quy ước giữa client và server, StreamWriter sẽ giúp biến đổi chuỗi ký tự (utf-8) thành mảng byte và đưa vào luồng NetworkStream
                    // chú ý sử dụng AutoFlush = true để tự động flush luồng NetworkStream
                    var writer = new StreamWriter(stream) { AutoFlush = true };
                    // đọc thông qua bộ tiếp hợp thay vì đọc trực tiếp chuỗi byte từ luồng NetworkStream
                    // phương thức ReadLine của StreamReader sẽ tự làm việc với NetworkStream bên trong để đọc ra chuỗi byte, sau đó tự động biến đổi thành chuỗi utf-8.
                    // lưu ý: lệnh ReadLine sẽ đọc đến khi nào nhìn thấy cặp ký tự \r\n thì sẽ dừng lại. Trong kết quả trả về, ReadLine sẽ xóa bỏ hai ký tự thừa này. Vì vậy, ở client chúng ta phải tự bổ sung cặp \r\n vào cuối chuỗi truy vấn. Nếu không làm như vậy, ReadLine sẽ không dừng việc đọc.
                    var request = reader.ReadLine();
                    var response = string.Empty;
                    switch (request.ToLower())
                    {
                        case "date": response = DateTime.Now.ToLongDateString(); break;
                        case "time": response = DateTime.Now.ToLongTimeString(); break;
                        case "year": response = DateTime.Now.Year.ToString(); break;
                        case "month": response = DateTime.Now.Month.ToString(); break;
                        case "day": response = DateTime.Now.Day.ToString(); break;
                        case "dow": response = DateTime.Now.DayOfWeek.ToString(); break;
                        case "doy": response = DateTime.Now.DayOfYear.ToString(); break;
                        default: response = "UNKNOW COMMAND"; break;
                    }
                    // ghi thẳng chuỗi utf-8 vào luồng bằng phương thức WriteLine của StreamWriter thay vì tự biến đổi chuỗi sang mảng byte.
                    // phương thức WriteLine tự thêm cặp \r\n vào cuối chuỗi, tự động biến đổi chuỗi này thành mảng byte và ghi vào stream.
                    // vì lý do này, bên client sẽ phải tự mình cắt bỏ chuỗi \r\n trước khi in ra.
                    // nếu sử dụng ReadLine của StreamReader thì không cần tự cắt bỏ \r\n vì ReadLine sẽ tự động xóa bỏ cặp ký tự này giúp.
                    writer.WriteLine(response);
                    // nếu không sử dụng AutoFlush thì phải tự mình gọi lệnh Flush
                    //writer.Flush();
                    // đóng socket sẽ đóng toàn bộ các luồng liên quan
                    worker.Close();
                }
            }
        }
    }

    Bước 5. Dịch và chạy thử

    Kết quả chạy chương trình tcp client / server sử dụng NetworkStream
    Kết quả chạy chương trình tcp client / server sử dụng NetworkStream

    Chúng ta có thể nhận thấy, kết quả thực hiện chương trình này không khác biệt so với phần thực hành Tcp cơ bản đã làm.

    Cách sử dụng NetworkStream với Tcp socket

    Trong ví dụ trên chúng ta đã xem xét hai cách thức khác nhau để làm việc với luồng NetworkStream. Ở client chúng ta trực tiếp sử dụng NetworkStream để đưa dữ liệu vào và đọc dữ liệu ra từ tcp socket ở dạng chuỗi byte. Ở server chúng ta sử dụng bộ tiếp hợp StreamWriter và StreamReader để ghi và đọc dữ liệu ở mức độ cao (string utf-8), thay vì đọc ghi thẳng chuỗi byte với NetworkStream.

    Khởi tạo object NetworkStream

    Để khởi tạo object của NetworkStream chúng ta cung cấp một object Socket đã được tạo với tham số của Tcp, hoặc object Socket thu được từ lệnh Accept (cũng là Tcp socket)

    // ở client
    // khởi tạo biến của NetworkStream từ tcp socket
    var stream = new NetworkStream(client);
    // ở server
    // khởi tạo object của NetworkStream từ tcp socket
    var stream = new NetworkStream(worker);

    Object stream vừa tạo ra có đầy đủ các phương thức đọc/ghi/định vị như đã trình bày ở phần trên. Tuy nhiên, do NetworkStream không hỗ trợ định vị nên các phương thức định vị chỉ tồn tại về hình thức (không thực sự hoạt động).

    Đọc ghi dữ liệu với NetworkStream

    Ở chương trình Client chúng ta trực tiếp sử dụng NetworkStream để đọc ghi dữ liệu:

    // ghi mảng byte vào stream thay vì dùng phương thức Send của Socket
    stream.Write(sendBuffer, 0, sendBuffer.Length);
    // đọc mảng byte từ stream thay vì dùng phương thức Receive của Socket
    var count = stream.Read(receiveBuffer, 0, 1024);

    Chúng ta dễ dàng nhận thấy, phương thức Read và Write của stream hoạt động không khác biệt so với Receive và Send của Tcp socket: tất cả đều làm việc với mảng byte. Chúng ta phải tự mình thực hiện các biến đổi giữa mảng byte và kiểu dữ liệu cấp cao hơn. Việc sử dụng trực tiếp NetworkStream trong ví dụ Client không thực sự đem lại hiệu quả.

    Sử dụng trực tiếp NetworkStream đặc biệt hiệu quả nếu chúng ta cần “nối luồng” ghi gửi/nhận file qua mạng. Khi đó, luồng mạng sẽ nối tiếp với luồng file qua một bộ nhớ đệm nhỏ, giúp tiết kiệm bộ nhớ rất nhiều.

    Sử dụng bộ tiếp hợp

    Ở server, thay vì trực tiếp dùng NetworkStream, chúng ta sử dụng bộ tiếp hợp StreamWriterStreamReader.

    var reader = new StreamReader(stream);
    var writer = new StreamWriter(stream) { AutoFlush = true };

    Do giao thức chúng ta tự chế tạo quy ước rằng, dữ liệu truyền giữa client và server đều là các chuỗi ký tự utf-8, ở client và server đều phải chuyển đổi giữa chuỗi và mảng byte trước khi truyền và sau khi nhận.

    Như ở phần lý thuyết luồng ở trên đã nói, bộ tiếp hợp giúp đọc/ghi dữ liệu cấp cao (string, int, bool, v.v.) và tự động giúp chuyển đổi qua lại với mảng byte. Do đó ở server chúng ta sử dụng StreamWriter và StreamReader giúp ghi đọc chuỗi utf-8 dễ dàng hơn. StreamWriter giúp ghi chuỗi vào luồng mạng; StreamReader giúp đọc chuỗi từ luồng mạng. Các thao tác chuyển đổi với mảng byte và ghi vào luồng NetworkStream được thực hiện tự động.

    Tất cả các bộ tiếp hợp đều được khởi tạo dựa trên một luồng backing store hoặc luồng decorator nào đó. Tự bản thân luồng tiếp hợp không làm việc được trực tiếp với nguồn dữ liệu. Ở server, StreamWriter và StreamReader được khởi tạo dựa trên một object của NetworkStream.

    Đọc ghi dữ liệu với bộ tiếp hợp

    Khi sử dụng bộ tiếp hợp, chúng ta có thể đọc/ghi chuỗi với luồng mạng sử dụng các phương thức quen thuộc giống hệt như làm việc với console:

    var request = reader.ReadLine();
    writer.WriteLine(response);

    Phương thức ReadLine của StreamReader sẽ tự làm việc với NetworkStream bên trong để đọc ra chuỗi byte, sau đó tự động biến đổi thành chuỗi utf-8. Lưu ý, lệnh ReadLine sẽ đọc đến khi nào nhìn thấy cặp ký tự \r\n thì sẽ dừng lại. Trong kết quả trả về, ReadLine sẽ xóa bỏ hai ký tự thừa này. Vì vậy, ở client chúng ta phải tự bổ sung cặp \r\n vào cuối chuỗi truy vấn. Nếu không làm như vậy, ReadLine sẽ không dừng việc đọc.

    Phương thức WriteLine của StreamWriter tự động biến đổi chuỗi sang mảng byte (theo mã utf-8) và ghi vào luồng. Phương thức WriteLine luôn tự thêm cặp \r\n vào cuối chuỗi trước khi biến đổi chuỗi này thành mảng byte và ghi vào stream. Vì lý do này, bên client sẽ phải tự mình cắt bỏ chuỗi \r\n trước khi in ra. Nếu sử dụng ReadLine của StreamReader thì không cần tự cắt bỏ \r\n vì ReadLine sẽ tự động xóa bỏ cặp ký tự này giúp.

    Flush và AutoFlush

    Khi ghi dữ liệu vào luồng, chuỗi byte không được chuyển đi ngay (đến socket tcp) mà sẽ nằm ở bộ nhớ đệm của luồng. Chỉ khi có những tác động nhất định, chuỗi byte mới được chuyển đi. Cách thức làm việc này giúp tăng hiệu quả của luồng. Tuy nhiên, với luồng mạng, điều này gây nguy hiểm vì liên quan đến quy trình gửi nhận: Client chưa đẩy dữ liệu vào tcp buffer, server sẽ không nhận được dữ liệu và sẽ treo đến chừng nào luồng mạng bên client được giải phóng.

    Do đó, ngay sau khi phát lệnh gửi, chúng ta tự mình gọi lệnh Flush để yêu cầu đẩy dữ liệu đi ngay.

    // client
    stream.Flush();
    // server
    writer.Flush();

    Các bộ tiếp hợp hỗ trợ thêm cơ chế AutoFlush: mỗi khi phát lệnh gửi, lệnh Flush sẽ được gọi tự động.

    // chú ý sử dụng AutoFlush = true để tự động flush luồng NetworkStream
    var writer = new StreamWriter(stream) { AutoFlush = true };

    Đóng luồng

    Khi socket hoàn thành nhiệm vụ gửi nhận ta cần đóng socket. Đóng socket cũng đồng nghĩa với đóng và xóa bỏ các object của NetworkStream tạo ra từ socket này.

    // đóng socket sẽ đóng toàn bộ các luồng liên quan
    // server
    worker.Close();
    // client
    client.Close();

    Chúng ta cũng có thể gọi lệnh Close() của stream thay vì đóng socket. Khi đóng luồng mạng cũng sẽ tự đóng socket bên dưới

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

    5 Thảo luận
    Cũ nhất
    Mới nhất
    Phản hồi nội tuyến
    Xem tất cả bình luận
    Bình

    Link đến bài đọc thêm về luồng bị lỗi rồi. ad up lại dc ko ạ!

    Cần Giải Đáp

    Chị ơi cho em hỏi lệnh này nghĩa là sao ạ
    var NetStream = NetworkStream(worker)
    var StreamW = StreamReader(NetStream);
    StreamW.Flush(); với cả cái Flush nó có chức năng gì ạ?
    Em xem ở ngoài em không rõ nên e hỏi ạ. Em cảm ơn
    

    Cần Giải Đáp

    Vâng chị tại e xem thêm ngoài nên chưa rõ ạ