0

Bài 5: Gửi nhận dữ liệu nhị phân & Bài toán truyền tải File qua mạng

Ở Bài 4, chúng ta đã xây dựng thành công ứng dụng Chat cơ bản, nơi dữ liệu truyền qua lại là các dòng văn bản (text lines) kết thúc bằng ký tự xuống dòng (\n). Nhưng trong thực tế, mạng máy tính không chỉ truyền chữ viết — nó truyền dữ liệu nhị phân (binary data) như hình ảnh, âm thanh, video, hay file nén.

Trong bài học này, chúng ta sẽ giải quyết một bài toán kinh điển trong lập trình mạng: Làm thế nào để truyền tải một file bất kỳ qua TCP Socket, cách chia nhỏ gói tin (chunking) và ráp nối lại chính xác ở đầu nhận?

1. Thách thức: TCP Stream và Bài toán "Biên gói tin" (Framing)

TCP hoạt động như một Byte Stream (Dòng các byte liên tục). Nó không hề biết khái niệm "file" hay "tin nhắn" là gì; đối với TCP, tất cả chỉ là một chuỗi các byte nối đuôi nhau trôi đi trên đường ống mạng.

  • Vấn đề: Nếu bạn có một file nặng 10MB, bạn không thể cứ thế ném cả 10MB vào bộ nhớ rồi gọi một lệnh conn.Write() duy nhất (có thể gây cạn kiệt RAM hoặc treo tiến trình nếu mạng chập chờn). Hơn thế nữa, khi dữ liệu đến đầu bên kia, làm sao server biết khi nào thì file bắt đầu, khi nào file kết thúc, và tên file là gì?

  • Giải pháp: Chúng ta phải thiết kế một Application Protocol (Giao thức tầng ứng dụng) đơn giản gồm 2 phần:

    1. Metadata (Siêu dữ liệu): Gửi thông tin mô tả trước (Tên file, kích thước file).

    2. Payload (Dữ liệu nhị phân): Chia nhỏ file thành các khối dung lượng cố định (chunks, ví dụ mỗi lần đọc/gửi 4KB) và truyền đi tuần tự.

2. Thiết kế giao thức truyền file đơn giản

Luồng giao tiếp truyền file giữa Client và Server sẽ diễn ra theo các bước tuần tự sau:

Plaintext

[ Client (Người gửi) ]                              [ Server (Người nhận) ]
          │                                                    │
          │ ── 1. Gửi Tên file & Kích thước file ────────────► │
          │                                                    │ (Tạo file trống trên ổ cứng)
          │ ── 2. Gửi Chunk 1 (4KB dữ liệu nhị phân) ────────► │
          │ ── 3. Gửi Chunk 2 (4KB dữ liệu nhị phân) ────────► │ (Ghi liên tục vào file)
          │ ── ...                                             │
          │ ── N. Hoàn tất (Đóng kết nối / Xác nhận) ────────► │

3. Hiện thực hóa mã nguồn bằng Golang

A. File Receiver (Server nhận file)

Server sẽ mở một cổng lắng nghe, nhận thông tin tên file, kích thước, sau đó đọc từng chunk từ socket và ghi vào ổ cứng:

Go

package main

import (
	"fmt"
	"io"
	"net"
	"os"
	"path/filepath"
)

func main() {
	listener, err := net.Listen("tcp", ":8080")
	if err != nil {
		fmt.Printf("Lỗi khởi động server: %v\n", err)
		return
	}
	defer listener.Close()

	fmt.Println("📥 File Server đang chờ nhận file tại cổng :8080...")

	for {
		conn, err := listener.Accept()
		if err != nil {
			continue
		}
		go handleReceiveFile(conn)
	}
}

func handleReceiveFile(conn net.Conn) {
	defer conn.Close()
	fmt.Printf("🔗 Đã kết nối với nguồn gửi: %s\n", conn.RemoteAddr().String())

	// 1. Đọc tên file và kích thước (Giả định định dạng đơn giản: "filename:filesize\n")
	buf := make([]byte, 1024)
	n, err := conn.Read(buf)
	if err != nil {
		return
	}
	header := string(buf[:n])
	// Phân tách thông tin (Ví dụ thực tế nên dùng JSON hoặc Protobuf, ở đây dùng định dạng text đơn giản)
	fmt.Printf("📦 Nhận được thông tin file: %s", header)

	// Tạo file đích để lưu trữ
	outDir := "./received_files"
	os.MkdirAll(outDir, os.ModePerm)
	outFile, err := os.Create(filepath.Join(outDir, "downloaded_file.bin"))
	if err != nil {
		fmt.Println("Lỗi tạo file trên server:", err)
		return
	}
	defer outFile.Close()

	fmt.Println("⏳ Đang nhận dữ liệu nhị phân và ghi vào ổ cứng...")

	// 2. Nhận dữ liệu streaming và ghi vào file cho đến khi hết luồng
	_, err = io.Copy(outFile, conn)
	if err != nil && err != io.EOF {
		fmt.Println("Lỗi trong quá trình nhận file:", err)
		return
	}

	fmt.Println("✅ Đã nhận và lưu file thành công!")
}

B. File Sender (Client gửi file)

Client mở file cục bộ, đọc theo từng khối buffer (chunking) và đẩy vào socket TCP:

Go

package main

import (
	"fmt"
	"io"
	"net"
	"os"
)

func main() {
	filePath := "sample.mp4" // Đường dẫn file bạn muốn gửi thử
	
	// Mở file cần gửi
	file, err := os.Open(filePath)
	if err != nil {
		fmt.Printf("Không thể mở file %s: %v\n", filePath, err)
		return
	}
	defer file.Close()

	fileInfo, err := file.Stat()
	if err != nil {
		fmt.Println("Lỗi đọc thông tin file:", err)
		return
	}

	// Kết nối tới Server
	conn, err := net.Dial("tcp", "localhost:8080")
	if err != nil {
		fmt.Printf("Không thể kết nối đến server: %v\n", err)
		return
	}
	defer conn.Close()

	fmt.Printf("🚀 Đang gửi file: %s (%d bytes)...\n", fileInfo.Name(), fileInfo.Size())

	// 1. Gửi header thông tin cơ bản trước
	header := fmt.Sprintf("%s:%d\n", fileInfo.Name(), fileInfo.Size())
	conn.Write([]byte(header))

	// 2. Truyền tải file bằng cơ chế chunking (sử dụng io.Copy tối ưu hóa buffer của Go)
	written, err := io.Copy(conn, file)
	if err != nil {
		fmt.Printf("Lỗi khi truyền file: %v\n", err)
		return
	}

	fmt.Printf("✨ Đã gửi thành công tổng cộng %d bytes qua mạng!\n", written)
}

4. Tại sao kỹ thuật Chunking lại quan trọng?

Trong đoạn code trên, hàm io.Copy bên dưới mui xe của Go thực hiện một vòng lặp hoàn hảo: nó cấp phát một bộ nhớ đệm (buffer thường là 32KB), đọc một mẩu nhỏ từ file, đẩy qua socket TCP, rồi lặp lại cho đến khi hết file.

Điều này mang lại 3 lợi ích chí mạng cho hệ thống mạng:

  1. Tiết kiệm RAM: Bạn có thể truyền một file nặng 4GB mà máy tính chỉ tốn vỏn vẹn vài kilobyte RAM để làm bộ nhớ đệm truyền tải.

  2. Không gây nghẽn luồng (Non-blocking flow): Chia nhỏ gói tin giúp hệ điều hành xen kẽ việc truyền tải dữ liệu của bạn với các tiến trình mạng khác một cách công bằng.

  3. Dễ dàng kiểm soát lỗi: Nếu đường truyền bị đứt giữa chừng ở chunk thứ 500, bạn biết chính xác tiến trình đã đi đến đâu để thực hiện cơ chế truyền lại (resume download) nếu cần.

Tổng kết

Chúng ta đã vượt qua ranh giới của văn bản đơn thuần để tiến vào thế giới truyền tải dữ liệu nhị phân thông qua kỹ thuật Chunking và thiết lập Protocol định tuyến dữ liệu.

Tuy nhiên, server hiện tại của chúng ta vẫn đang ở dạng tuần tự (Sequential): Khi đang bận nhận file cho một Client, nếu có Client thứ hai kết nối vào, Client thứ hai buộc phải xếp hàng chờ đợi. Làm thế nào để Server có thể phục vụ hàng ngàn Client cùng một lúc mà không bị block?

Đó chính là nội dung của Bài 6: Phá vỡ giới hạn 1-1: Xử lý đa kết nối với Đa luồng (Multithreading / Goroutines)!

Bạn đã sẵn sàng bước sang Bài 6 chưa?


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí