0

Go Zero to Hero - Bài 55: Directional Channels (Channel một chiều)

Mặc định, khi bạn tạo ra một Channel bằng hàm make(chan int), đó là một Bidirectional Channel (Channel hai chiều). Nó cho phép bạn vừa gửi dữ liệu vào (ch <-), vừa nhận dữ liệu ra (<-ch). Tuy nhiên, khi xây dựng các hệ thống lớn, việc để các luồng tự do thao tác cả hai đầu ống dễ dẫn đến lỗi logic. Đó là lý do Directional Channels (Channel một chiều) ra đời.


1. Bản chất của Directional Channels

Khi khai báo tham số cho một hàm, Go cho phép bạn "bẻ gãy" một chiều của Channel đó, biến nó thành:

  • Send-only Channel (Chỉ gửi): Ký hiệu là chan<- T. Bạn chỉ có thể nhét dữ liệu vào, tuyệt đối không được móc ra.
  • Receive-only Channel (Chỉ nhận): Ký hiệu là <-chan T. Bạn chỉ có thể móc dữ liệu ra, tuyệt đối không được nhét vào.

💡 Mẹo ghi nhớ siêu dễ: Cứ nhìn vị trí của mũi tên <- so với chữ chan. Mũi tên chĩa VÀO chữ chan là chỉ gửi, mũi tên kéo RA TỪ chữ chan là chỉ nhận.


2. Ứng dụng thực chiến: Phân quyền rạch ròi cho Worker

Hãy quay lại bài toán quen thuộc: Hệ thống máy chủ AFC. Chúng ta có 2 nhóm Goroutine:

  1. Nhóm Thu thập (Collector): Chuyên đọc dữ liệu từ máy quét thẻ và gửi vào ống nước. (Nhóm này không có lý do gì để lấy dữ liệu từ ống nước ra).
  2. Nhóm Xử lý (Processor): Chuyên nhận dữ liệu từ ống nước để lưu xuống Database. (Nhóm này không có lý do gì để gửi thêm dữ liệu vào ống).

Bằng cách dùng Channel một chiều ở tham số hàm, trình biên dịch (Compiler) sẽ trở thành cảnh sát giao thông, chặn đứng mọi ý đồ gõ code sai logic ngay từ lúc bạn đang viết code.

package main

import (
    "fmt"
    "time"
)

// 1. HÀM CHỈ GỬI (Send-only)
// Tham số: ch chan<- string (Mũi tên chĩa vào chan)
// Hàm này chỉ được phép ĐẨY dữ liệu vào ch. Nếu gọi <-ch sẽ bị lỗi biên dịch.
func ticketCollector(ch chan<- string, machineID string) {
    fmt.Printf("[%s] Khách quẹt thẻ...\n", machineID)
    
    // Hợp lệ: Nhét dữ liệu vào ống
    ch <- fmt.Sprintf("VÉ-TỪ-%s", machineID) 
    
    // 🚨 BÁO LỖI BIÊN DỊCH NẾU BẠN CỐ TÌNH:
    // data := <-ch (invalid operation: cannot receive from send-only channel ch)
}

// 2. HÀM CHỈ NHẬN (Receive-only)
// Tham số: ch <-chan string (Mũi tên kéo ra từ chan)
// Hàm này chỉ được phép RÚT dữ liệu khỏi ch. Nếu gọi ch <- sẽ bị lỗi biên dịch.
func databaseProcessor(ch <-chan string) {
    // Hợp lệ: Rút dữ liệu ra
    ticket := <-ch 
    fmt.Printf("[DB Server] Đã lưu giao dịch: %s xuống Postgres\n", ticket)
    
    // 🚨 BÁO LỖI BIÊN DỊCH NẾU BẠN CỐ TÌNH:
    // ch <- "Dữ liệu ảo" (invalid operation: cannot send to receive-only channel ch)
}

func main() {
    // Trong hàm main, ta vẫn tạo ra một Channel 2 CHIỀU bình thường.
    // Vì hàm main đóng vai trò là "Nhà cái", nó cần cầm cả 2 đầu ống.
    ticketQueue := make(chan string, 10) // Buffered channel

    // Khi truyền vào các hàm, Go tự động ÉP KIỂU TỪNG PHẦN (Implicit Conversion)
    // ticketQueue (2 chiều) -> chan<- string (1 chiều)
    go ticketCollector(ticketQueue, "CỔNG-A1")
    
    // ticketQueue (2 chiều) -> <-chan string (1 chiều)
    go databaseProcessor(ticketQueue)

    // Đợi 1 chút để xem log
    time.Sleep(1 * time.Second)
    fmt.Println("[Main] Hệ thống hoạt động hoàn hảo và an toàn tuyệt đối!")
}

3. Tại sao đây là tiêu chuẩn của Senior Gopher?

Việc sử dụng Directional Channels không làm code của bạn chạy nhanh hơn, nhưng nó mang lại 3 giá trị to lớn cho khâu bảo trì:

  1. Code tự làm tài liệu (Self-documenting): Khi một đồng nghiệp mới đọc API của bạn, nhìn thấy func Process(ch <-chan int), họ hiểu ngay lập tức: "À, cái hàm này là một Consumer, nó sẽ rút cạn cái channel của mình, mình phải cẩn thận chuẩn bị dữ liệu cho nó". Họ không cần phải đọc ruột hàm để đoán.
  2. Chống Bug ngầm: Tránh tình trạng 2 Goroutine cùng gửi và cùng nhận chéo cho nhau trên cùng một channel gây ra Deadlock vòng tròn cực kỳ khó debug.
  3. Encapsulation (Đóng gói): Khi bạn viết một package logger cung cấp một Channel cho người dùng bên ngoài bắn log vào, bạn chỉ trả về cho họ một cái chan<- string (chỉ gửi). Như vậy, không ai có thể can thiệp thò tay vào lấy trộm log của hệ thống ra được!

4. Một quy tắc thiết kế nhỏ: Constructor trả về Channel một chiều

Trong thực tế, người ta thường dùng Pattern khởi tạo (Constructor) trả thẳng về một Channel một chiều để đóng gói kín kẽ mọi thứ:

// Hàm khởi tạo bộ xử lý sự kiện
// Nó trả về một cái ống CHỈ GỬI để người khác nhét sự kiện vào.
func NewEventStream() chan<- string {
    // 1. Tạo ống 2 chiều nội bộ
    ch := make(chan string, 100)
    
    // 2. Kích hoạt luồng Consumer (chỉ nhận) chạy ngầm để tiêu thụ dữ liệu
    go func(in <-chan string) {
        for event := range in { 
            fmt.Println("[Event Worker] Xử lý:", event)
        }
    }(ch)
    
    // 3. Trả về mặt CÒN LẠI của ống (chỉ gửi) cho hàm main sử dụng
    // Tự động ép kiểu từ (chan) sang (chan<-)
    return ch
}

func main() {
    // stream lúc này mang kiểu chan<- string
    stream := NewEventStream()
    
    // Gửi sự kiện thoải mái
    stream <- "User đăng nhập"
    stream <- "User quẹt thẻ"
    
    time.Sleep(time.Millisecond)
}

Nhờ cách thiết kế này, logic xử lý bên trong Worker được giấu kín hoàn toàn. Hàm main chỉ có duy nhất một đặc quyền là ném dữ liệu vào cái hố đen stream, đảm bảo kiến trúc sạch (Clean) tuyệt đối.


Tổng kết

Bạn đã nắm vững nghệ thuật "Phân quyền" cho ống dẫn dữ liệu:

  1. Bản chất: Channel một chiều là cách ép kiểu Channel 2 chiều để giới hạn quyền hạn của hàm (chỉ được gửi chan<- hoặc chỉ được nhận <-chan).
  2. Tự động chuyển đổi: Go tự động ép kiểu từ 2 chiều sang 1 chiều khi truyền vào tham số hàm. Điều ngược lại (từ 1 chiều sang 2 chiều) là không thể.
  3. Thực chiến: Bắt buộc áp dụng cho mọi tham số hàm giao tiếp bằng Channel trong dự án Backend để trình biên dịch bảo vệ bạn khỏi những lỗi ngớ ngẩn do gõ nhầm.

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í