Mô hình client-server
Tới chương này, ta đã có đủ công cụ để bước ra khỏi một máy đơn lẻ. Một chương trình hữu ích thường không sống một mình: nó cần nói chuyện với chương trình khác, trên cùng máy hay trên máy ở đầu kia thế giới. Lập trình mạng là kỹ thuật cho hai chương trình trao đổi dữ liệu qua hạ tầng mạng. Java có sẵn thư viện mạng gọn và đủ mạnh trong gói java.net, che giấu phần lớn chi tiết phức tạp của giao thức để ta tập trung vào logic ứng dụng. Trước khi viết dòng mã đầu tiên, bài này dựng nền: vai trò máy chủ và máy khách, cách định danh một đầu giao tiếp bằng địa chỉ IP và cổng, và sự khác nhau giữa hai giao thức vận chuyển TCP và UDP.
Đặt vấn đề cho gần: bạn viết một ứng dụng điểm danh cho lớp. Sinh viên bấm nút trên điện thoại, giảng viên nhìn thấy danh sách cập nhật ngay trên máy tính. Hai chương trình này chạy trên hai máy khác nhau, có thể cách nhau vài mét hoặc vài trăm cây số, và chúng phải tìm được nhau, thống nhất ai nói trước, ai chờ, dữ liệu đóng gói ra sao, mất gói thì làm gì. Toàn bộ những câu hỏi đó là nội dung của bài này. Trả lời xong, việc viết mã ở bài sau chỉ còn là gõ vài chục dòng.
Mô hình client-server
Mô hình client-server là cách tổ chức hệ phân tán trong đó một bên gọi là máy chủ (server) chờ và đáp ứng yêu cầu, còn bên kia gọi là máy khách (client) chủ động khởi tạo kết nối và gửi yêu cầu. Máy chủ thường phục vụ nhiều máy khách cùng lúc.
Hình dung một quán cà phê: nhân viên pha chế đứng sau quầy, luôn ở đó chờ khách; khách bước vào, gọi món, nhận đồ rồi rời đi. Nhân viên là máy chủ, khách là máy khách. Điểm cốt lõi là tính bất đối xứng: máy chủ thụ động chờ ở một địa chỉ cố định ai cũng biết, còn máy khách chủ động tìm đến. Trình duyệt web là máy khách, máy chủ web là máy chủ; ứng dụng nhắn tin trên điện thoại là máy khách, máy chủ của hãng là máy chủ. Cùng một chương trình đôi khi vừa đóng vai khách vừa đóng vai chủ, nhưng ở mỗi kết nối cụ thể, vai trò luôn rõ ràng.
Các tầng mạng, nhìn sơ lược
Mạng máy tính được tổ chức thành nhiều tầng chồng lên nhau, mỗi tầng chỉ lo một việc và dùng dịch vụ của tầng dưới. Mô hình học thuật OSI có bảy tầng, nhưng khi lập trình ta chỉ cần bốn tầng của mô hình TCP/IP thực tế.
| Tầng | Lo việc gì | Đơn vị dữ liệu | Ví dụ |
|---|---|---|---|
| Ứng dụng | Ý nghĩa của dữ liệu trao đổi | Bản tin | HTTP, SMTP, DNS |
| Giao vận | Đưa dữ liệu tới đúng tiến trình | Đoạn, gói dữ liệu | TCP, UDP |
| Mạng | Đưa gói tin tới đúng máy | Gói tin | IP, định tuyến |
| Liên kết | Truyền bit trên đường vật lý | Khung | Ethernet, Wi-Fi |
Cách hoạt động là đóng gói lồng nhau. Bản tin của bạn được tầng giao vận bọc thêm một phần đầu ghi cổng nguồn và cổng đích, tầng mạng bọc tiếp phần đầu ghi địa chỉ IP nguồn và đích, tầng liên kết bọc thêm lần nữa rồi đẩy ra dây. Ở đầu kia, mỗi tầng bóc đúng lớp vỏ của mình rồi chuyển phần ruột lên trên. Nhờ vậy chương trình Java của bạn chỉ làm việc ở tầng ứng dụng và tầng giao vận, không cần biết gói tin đã đi qua bao nhiêu bộ định tuyến hay chạy trên cáp quang hay sóng Wi-Fi. Lớp Socket chính là cái tay nắm mà Java đưa cho bạn để chạm vào tầng giao vận.
Địa chỉ IP và cổng
Để hai chương trình tìm thấy nhau qua mạng, mỗi máy cần một địa chỉ IP. Phiên bản phổ biến là IPv4, gồm bốn số từ 0 tới 255 cách nhau bởi dấu chấm, ví dụ 192.168.1.10; phiên bản mới hơn là IPv6 dài hơn và rộng hơn nhiều. Một địa chỉ đặc biệt là 127.0.0.1, tên gọi localhost, luôn trỏ về chính máy đang chạy, rất tiện khi thử nghiệm máy chủ và máy khách trên cùng một máy.
Tuy nhiên một máy có thể chạy nhiều dịch vụ cùng lúc: máy chủ web, máy chủ thư, máy chủ trò chơi. Riêng địa chỉ IP chưa đủ để biết gói tin gửi tới dịch vụ nào. Vì thế cần thêm cổng (port), một số nguyên 16 bit từ 0 tới 65535 đánh dấu từng dịch vụ trên cùng một máy. Cặp (địa chỉ IP, cổng) xác định duy nhất một đầu giao tiếp, gọi là điểm cuối (endpoint). Các cổng dưới 1024 dành cho dịch vụ chuẩn, ví dụ 80 cho web HTTP, 443 cho HTTPS, 22 cho SSH; khi tự viết chương trình thử nghiệm, ta nên chọn cổng cao như 5000 hay 8080 để tránh đụng dịch vụ hệ thống.
Một kết nối được xác định bởi bốn yếu tố: địa chỉ và cổng của máy khách, địa chỉ và cổng của máy chủ. Nhờ vậy nhiều máy khách khác nhau có thể cùng nối tới một cổng máy chủ mà hệ điều hành vẫn phân biệt được từng kết nối.
Trên thực tế ta hiếm khi gõ địa chỉ IP bằng tay, mà dùng tên miền rồi để hệ thống tên miền DNS dịch tên đó ra địa chỉ. Java bọc việc này trong lớp InetAddress.
import java.net.InetAddress;
public class DiaChiDemo {
public static void main(String[] args) throws Exception {
InetAddress may = InetAddress.getLocalHost(); // this machine
System.out.println(may.getHostName() + " -> " + may.getHostAddress());
// DNS resolves a name into one or more IP addresses
for (InetAddress a : InetAddress.getAllByName("dau.edu.vn")) {
System.out.println(a.getHostAddress());
}
}
}
Một tên miền có thể ứng với nhiều địa chỉ IP, đó là cách các dịch vụ lớn chia tải cho nhiều máy chủ. Nếu tên không tra được, InetAddress ném UnknownHostException, và đó thường là dấu hiệu máy bạn không có mạng hoặc gõ sai tên chứ không phải mã sai.
Chạy máy chủ hai lần trên cùng một cổng sẽ nhận BindException với thông báo địa chỉ đang được dùng, vì cổng đã bị tiến trình trước chiếm giữ; hãy tắt tiến trình cũ hoặc đổi số cổng. Còn nếu bạn nghe ở cổng dưới 1024 như 80, chương trình cần quyền quản trị trên Linux và macOS, nên khi thử nghiệm hãy chọn cổng cao như 5000 hay 8080.
TCP và UDP
Trên nền IP, hai giao thức vận chuyển thường dùng là TCP và UDP, đại diện cho hai triết lý khác nhau.
TCP (Transmission Control Protocol) hướng kết nối. Trước khi gửi dữ liệu, hai bên bắt tay thiết lập một kết nối; sau đó dữ liệu chảy như một dòng byte liên tục, đảm bảo tới nơi đầy đủ, đúng thứ tự, không trùng lặp. Nếu một gói thất lạc, TCP tự động gửi lại. Đổi lại sự tin cậy này là chi phí: thời gian bắt tay, bộ nhớ giữ trạng thái kết nối, và độ trễ khi phải chờ gửi lại. TCP hợp với truyền tệp, trang web, thư điện tử, nơi mất một byte là hỏng.
UDP (User Datagram Protocol) phi kết nối. Không bắt tay, không bảo đảm: mỗi gói tin gọi là gói dữ liệu (datagram) được gửi đi độc lập, có thể tới nơi không theo thứ tự, có thể trùng, thậm chí mất hẳn mà bên gửi không hề biết. Bù lại UDP rất nhẹ và nhanh, không tốn thời gian thiết lập. UDP hợp với truyền hình trực tuyến, gọi thoại, trò chơi nhiều người, nơi dữ liệu mới quan trọng hơn dữ liệu cũ và mất một khung hình vẫn chấp nhận được.
| Tiêu chí | TCP | UDP |
|---|---|---|
| Kiểu liên lạc | Hướng kết nối, bắt tay trước | Phi kết nối, gửi ngay |
| Độ tin cậy | Bảo đảm tới nơi, gửi lại khi mất | Không bảo đảm, có thể mất gói |
| Thứ tự dữ liệu | Đúng thứ tự gửi | Có thể đảo thứ tự |
| Đơn vị dữ liệu | Dòng byte liên tục (stream) | Gói rời rạc (datagram) |
| Chi phí, độ trễ | Cao hơn | Thấp, nhẹ |
| Lớp Java | Socket, ServerSocket | DatagramSocket, DatagramPacket |
| Ứng dụng tiêu biểu | Web, truyền tệp, thư | Phát trực tuyến, gọi thoại, game |
Phần lớn ví dụ trong các bài tiếp theo dùng TCP vì nó tin cậy và là lựa chọn mặc định cho hầu hết ứng dụng. UDP được giới thiệu ngắn ở bài Lập trình socket để bạn biết khi nào nên cân nhắc nó.
Giao thức tầng ứng dụng
TCP và UDP mới chỉ lo chuyện đưa byte tới nơi, chúng không hề biết những byte đó nghĩa là gì. Việc quy ước ý nghĩa thuộc về giao thức tầng ứng dụng: ai nói trước, bản tin có dạng gì, kết thúc ở đâu, trả lời ra sao. HTTP quy ước máy khách gửi một dòng yêu cầu kèm các dòng tiêu đề rồi máy chủ đáp lại kèm mã trạng thái; SMTP quy ước một cuộc đối đáp từng dòng để chuyển thư; DNS quy ước một bản tin nhị phân ngắn hỏi tên đổi lấy địa chỉ.
Khi bạn viết một ứng dụng nhắn tin qua socket, bạn cũng đang tự thiết kế một giao thức tầng ứng dụng, dù chỉ là quy ước mỗi bản tin kết thúc bằng một dấu xuống dòng. Hãy ghi quy ước đó ra giấy trước khi gõ mã, vì phần lớn lỗi trong lập trình mạng không nằm ở socket mà nằm ở chỗ hai đầu hiểu khác nhau về nơi một bản tin kết thúc.
Khi nào chọn TCP, khi nào chọn UDP
Câu hỏi quyết định là: mất một mẩu dữ liệu thì hỏng việc, hay chỉ hơi khó chịu? Nếu mất một byte làm hỏng cả tệp, hỏng chữ ký số, hỏng một dòng lệnh SQL, thì phải là TCP. Nếu dữ liệu có nhịp thời gian và cái mới thay thế cái cũ, ví dụ vị trí nhân vật trong trò chơi hay khung hình thoại, thì mất một gói không đáng để chờ gửi lại, và UDP hợp hơn. Câu hỏi thứ hai là quy mô: TCP giữ trạng thái cho từng kết nối nên một máy chủ giữ hàng trăm nghìn kết nối phải trả giá bằng bộ nhớ, còn UDP không giữ gì cả.
Trong Java, hai lựa chọn này hiện ra thành hai cặp lớp khác nhau ngay từ dòng mã đầu tiên.
import java.net.*;
public class ChonGiaoThuc {
// TCP: connect first, then bytes flow as an ordered, reliable stream
static void kieuTcp() throws Exception {
try (Socket socket = new Socket("localhost", 5000)) {
socket.getOutputStream().write("xin chao".getBytes());
}
}
// UDP: no connection, each datagram is fired on its own
static void kieuUdp() throws Exception {
try (DatagramSocket socket = new DatagramSocket()) {
byte[] data = "xin chao".getBytes();
DatagramPacket goi = new DatagramPacket(
data, data.length, InetAddress.getByName("localhost"), 5000);
socket.send(goi); // fire and forget, delivery is not guaranteed
}
}
}
Đừng hiểu nhầm rằng UDP luôn nhanh hơn nên luôn tốt hơn. UDP nhanh vì nó bỏ hết phần việc bảo đảm; nếu ứng dụng của bạn vẫn cần bảo đảm, bạn sẽ phải tự cài lại chuyện đánh số gói, phát hiện mất, gửi lại, và gần như chắc chắn làm tệ hơn TCP đã được tinh chỉnh suốt mấy chục năm. Lựa chọn UDP chỉ đúng khi bạn thật sự chấp nhận mất mát. Cũng đáng biết là HTTP/3 chạy trên QUIC, một giao thức xây trên nền UDP nhưng tự cài lại phần tin cậy ở tầng trên, đúng theo tinh thần đó.
Bài tập thực hành
Bài 1. Giải thích bằng lời tại sao cặp (địa chỉ IP, cổng) là cần thiết, chỉ riêng địa chỉ IP thì chưa đủ để hai chương trình giao tiếp. Cho một ví dụ thực tế về một máy chạy hai dịch vụ trên hai cổng khác nhau.
Gợi ý
Địa chỉ IP chỉ định danh được máy, không định danh được dịch vụ nào trên máy đó. Một máy chủ có thể vừa chạy web HTTP trên cổng 80 vừa chạy SSH trên cổng 22; cùng địa chỉ IP nhưng hai cổng khác nhau giúp hệ điều hành biết gói tin đến thuộc về dịch vụ nào. Vì vậy cần cặp (địa chỉ IP, cổng) để xác định duy nhất một điểm cuối.
Bài 2. Lập bảng so sánh ngắn gọn TCP và UDP theo ba tiêu chí: độ tin cậy, thứ tự dữ liệu và chi phí. Với mỗi giao thức, nêu một ứng dụng phù hợp và giải thích lựa chọn.
Gợi ý
TCP bảo đảm tới nơi, giữ đúng thứ tự, chi phí cao hơn; hợp với truyền tệp hay web vì mất dữ liệu là hỏng. UDP không bảo đảm, có thể đảo thứ tự, chi phí thấp; hợp với gọi thoại hay phát trực tuyến vì dữ liệu mới quan trọng hơn dữ liệu cũ và mất một khung hình vẫn chấp nhận được.
Bài 3. Một địa chỉ 127.0.0.1 khác gì so với một địa chỉ IPv4 thường như 192.168.1.10? Khi nào ta dùng localhost và khi nào phải dùng địa chỉ IP thật của máy chủ?
Gợi ý
127.0.0.1 (tên gọi localhost) luôn trỏ về chính máy đang chạy, gói tin không thực sự ra mạng; rất tiện khi thử máy chủ và máy khách trên cùng một máy. Khi máy chủ và máy khách nằm trên hai máy khác nhau, máy khách phải dùng địa chỉ IP thật của máy chủ trong mạng để tìm tới đúng máy.
Bài 4. Chọn giao thức vận chuyển cho ba tình huống sau và giải thích trong một câu: (a) tải một tệp cài đặt 500 MB; (b) truyền tiếng nói trong một cuộc gọi trực tuyến; (c) đồng bộ bảng điểm giữa máy khách và máy chủ của trường.
Lời giải
(a) TCP. Một byte sai là tệp cài đặt hỏng, không chạy được, nên bắt buộc phải bảo đảm đầy đủ và đúng thứ tự dù có chậm hơn.
(b) UDP. Tiếng nói có nhịp thời gian, gói cũ tới trễ thì đã vô nghĩa; chờ gửi lại một mẩu âm thanh chỉ làm cuộc gọi giật thêm, trong khi mất một mẩu rất ngắn thì tai người gần như không nhận ra.
(c) TCP. Điểm số là dữ liệu phải đúng tuyệt đối và không được đảo thứ tự cập nhật; hơn nữa số lượng kết nối nhỏ nên chi phí giữ trạng thái của TCP không thành vấn đề.
Nguyên tắc chung rút ra: hỏi mất một mẩu dữ liệu thì hỏng việc hay chỉ hơi khó chịu. Hỏng việc thì chọn TCP, chỉ khó chịu và dữ liệu mới thay được dữ liệu cũ thì cân nhắc UDP.
Câu hỏi tự kiểm
- 1Yếu tố nào xác định duy nhất một đầu giao tiếp (endpoint) trên mạng?
- 2Về đơn vị dữ liệu, TCP cung cấp thứ gì?
- 3Tải một tệp cài đặt 500 MB thì nên dùng giao thức vận chuyển nào?
- 4Các cổng có số dưới 1024 có đặc điểm gì?
Tóm tắt
Năm ý cần mang theo sang bài sau. Vai trò trong một kết nối luôn bất đối xứng: máy chủ chờ ở một điểm cuối ai cũng biết, máy khách chủ động tìm tới. Một điểm cuối là cặp địa chỉ IP và cổng, còn một kết nối được nhận diện bằng bốn yếu tố gồm địa chỉ và cổng của cả hai đầu. Mạng xếp thành các tầng lồng nhau, và mã Java của bạn chỉ chạm vào tầng giao vận qua lớp Socket. Tầng ứng dụng là nơi bạn tự quy ước ý nghĩa của byte, nên hãy viết quy ước đó ra trước khi gõ mã. Cuối cùng, chọn TCP khi mất dữ liệu là hỏng việc, chọn UDP khi dữ liệu mới thay được dữ liệu cũ.
Mô hình client-server đặt một bên thụ động chờ ở địa chỉ cố định, một bên chủ động tìm tới; cặp địa chỉ IP và cổng định danh từng đầu giao tiếp. Hai giao thức vận chuyển TCP và UDP đại diện hai triết lý: tin cậy mà nặng, hay nhẹ mà không bảo đảm. Nắm vững nền tảng này rồi, ta bước sang viết mã socket thực sự ở bài tiếp theo.