Java socket programming คือการให้โปรแกรมสองตัวคุยกันผ่านเครือข่ายด้วย package java.net ฝั่ง server เปิด ServerSocket บน port หนึ่งแล้วรอ ฝั่ง client เชื่อมต่อเข้ามาด้วย Socket จากนั้นทั้งสองฝั่งอ่านและเขียนข้อมูลผ่าน stream ธรรมดาของ Java แค่นี้ก็พอจะสร้างแอป chat ที่ใช้งานได้จริงโดยไม่ต้องพึ่ง framework
บทความนี้อธิบายโมเดล client-server, IP address และ port แล้วค่อย ๆ สร้างแอป chat ทีละขั้น เริ่มจากส่งข้อความธรรมดา ต่อด้วยการส่งข้อความเป็น object ที่ serialize แล้ว และปิดท้ายด้วย server ที่แยก thread ให้ client แต่ละคน โค้ดทั้งหมดใช้ Java 17 ขึ้นไป
พื้นฐาน client-server
โปรแกรมแบบ standalone ทำงานจบในเครื่องเดียว ส่วนโปรแกรมแบบ client-server คุยกับโปรแกรมอื่นผ่านเครือข่าย โดย server รอรับ request และ client เป็นฝ่ายเชื่อมต่อเข้าไป แอป chat เป็นแบบ client-server ผู้ใช้แต่ละคนรัน client ของตัวเอง แล้วมี server ตัวเดียวคอยส่งต่อข้อความระหว่างทุกคน
IP address, port และ protocol
การจะคุยกับโปรแกรมบนเครื่องอื่นต้องรู้สองอย่าง:
- IP address บอกว่าเป็นเครื่องไหนในเครือข่าย เช่น
192.168.1.20หรือ127.0.0.1สำหรับเครื่องตัวเอง - Port บอกว่าเป็นโปรแกรมไหนบนเครื่องนั้น เป็นตัวเลข 16 bit ตั้งแต่ 0 ถึง 65535
| ช่วง port | ชื่อเรียก | ใช้กับแอปของเราได้ไหม |
|---|---|---|
| 0–1023 | Well-known port (HTTP 80, HTTPS 443, SSH 22) | ไม่ควร สงวนไว้ให้ service ของระบบ |
| 1024–49151 | Registered port | ได้ เลือกตัวที่ยังว่าง |
| 49152–65535 | Dynamic/ephemeral port | ไม่ควร OS ใช้ช่วงนี้กับการเชื่อมต่อขาออก |
บน Windows นั้น Hyper-V และ WSL อาจจองช่วง port สูง ๆ ไว้เป็นก้อน ทำให้ bind แล้วเจอ BindException ทั้งที่ไม่มีโปรแกรมไหนใช้ port นั้นอยู่ (ดูช่วงที่ถูกจองได้ด้วย netsh int ipv4 show excludedportrange protocol=tcp) บทความนี้จึงใช้ port 5050
Protocol คือกติกาที่ทั้งสองฝั่งตกลงร่วมกัน เช่น HTTP ทำงานอยู่บน TCP ซึ่งเป็น transport protocol ที่ Socket และ ServerSocket ใช้ TCP ให้ stream ของ byte ที่เชื่อถือได้และเรียงลำดับถูกต้อง ถ้า packet หายกลางทาง TCP จะส่งใหม่ให้เอง ส่วน protocol ของแอป chat เราอยู่ชั้นบนสุด ทำหน้าที่กำหนดว่า byte เหล่านั้นหมายถึงอะไร
Socket คือปลายทางด้านหนึ่งของการเชื่อมต่อ ประกอบด้วย IP address กับ port ขั้นตอนการใช้งานเหมือนเดิมเสมอ คือเปิดการเชื่อมต่อ รับส่งข้อมูล แล้วปิด
Java socket programming ด้วย java.net
ฝั่ง server:
- สร้าง
ServerSocketผูกกับ port - เรียก
accept()ซึ่งจะ block รอจนมี client เชื่อมต่อเข้ามา แล้วคืนSocketของ client คนนั้น - อ่านและเขียนผ่าน stream ของ socket นั้น ปิดเมื่อเสร็จ แล้ววนกลับไป
accept()
ฝั่ง client สร้าง Socket โดยระบุ host และ port ของ server (constructor จะเชื่อมต่อทันที) แล้วใช้ stream แบบเดียวกัน
ขั้นที่ 1: server และ client แบบส่งข้อความธรรมดา
เวอร์ชันแรกส่งข้อความทีละบรรทัด server พิมพ์ทุกอย่างที่ได้รับออกมา
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class SimpleServer {
public static void main(String[] args) throws IOException {
try (ServerSocket serverSocket = new ServerSocket(5050)) {
System.out.println("Waiting for clients on port 5050...");
while (true) {
try (Socket socket = serverSocket.accept();
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = in.readLine()) != null) { // null = client closed the connection
System.out.println(socket.getRemoteSocketAddress() + " says: " + line);
}
}
}
}
}
}import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class SimpleClient {
public static void main(String[] args) throws IOException {
String host = args.length > 0 ? args[0] : "127.0.0.1";
try (Socket socket = new Socket(host, 5050);
PrintWriter out = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) {
out.println("Hello from the client");
out.println("สวัสดีครับ");
}
}
}รายละเอียดที่ควรสังเกต:
- try-with-resources ปิด socket และ stream ให้เสมอ แม้จะเกิด exception กลางทาง
- ระบุ charset ทุกครั้ง ถ้าไม่ใส่
StandardCharsets.UTF_8ข้อความภาษาไทยอาจเพี้ยนเมื่อสองเครื่องใช้ encoding default ต่างกัน PrintWriter(..., true)เปิด auto-flush ตอนเรียกprintlnถ้าไม่เปิด ข้อมูลอาจค้างอยู่ใน buffer และไปไม่ถึง serverreadLine()คืนnullเมื่อ client ปิดการเชื่อมต่อ server ใช้จุดนี้รู้ว่า client คนนั้นเลิกคุยแล้ว
Server ตัวนี้รับได้ ทีละ client เท่านั้น ระหว่างที่อ่านข้อมูลจาก client คนแรก คนที่สองต้องรออยู่ที่ accept() แต่แอป chat ต้องให้ทุกคนเชื่อมต่อพร้อมกันได้
ขั้นที่ 2: ส่งข้อความ chat เป็น object
เมื่อข้อความต้องมีหลาย field เช่น ผู้ส่ง ข้อความ และเวลา เราส่งเป็น Java object ไปทั้งก้อนได้เลย ไม่ต้องคิด format ข้อความขึ้นมาเอง Object serialization คือการแปลง object เป็นลำดับของ byte ที่ส่งผ่านเครือข่ายได้ ObjectOutputStream เขียน object ออกไป และ ObjectInputStream ประกอบ object กลับคืนที่ปลายทาง
import java.io.Serializable;
import java.time.Instant;
public record ChatMessage(String sender, String text, Instant sentAt) implements Serializable {
}Record ทำให้ข้อความเป็น immutable และใช้ Instant แทน java.util.Date แบบเก่า ถ้าเป็น class ธรรมดาควรประกาศ private static final long serialVersionUID = 1L; ไว้ด้วยเพื่อให้สองฝั่งตรงกันเรื่องเวอร์ชันของ class แต่ record ไม่จำเป็นต้องมี
กฎสองข้อที่ช่วยไม่ให้ object stream ค้าง:
- สร้าง
ObjectOutputStreamก่อนแล้ว flush ทันที จากนั้นค่อยสร้างObjectInputStreamเพราะ constructor ของ input stream จะ block รอ header จากอีกฝั่ง ถ้าทั้งสองฝั่งสร้าง input stream ก่อน ก็จะรอกันไปตลอดกาล - เรียก
reset()หลังส่งทุกข้อความ เมื่อการเชื่อมต่ออยู่นานObjectOutputStreamจำทุก object ที่เคยเขียนไว้เพื่อใช้ส่ง back-reference ถ้าไม่reset()memory จะโตขึ้นทุกข้อความ
ขั้นที่ 3: chat server ที่แยก thread ให้แต่ละ client
Server รับการเชื่อมต่อใน loop แล้วส่ง client แต่ละคนไปให้ thread ของตัวเองจาก ExecutorService handler แต่ละตัวอ่านข้อความจาก client ของมัน แล้ว broadcast ไปให้ทุกคน
import java.io.EOFException;
import java.io.IOException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.time.Instant;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ChatServer {
static final int PORT = 5050;
// Only these classes may be deserialized; everything else is rejected.
private static final ObjectInputFilter FILTER = ObjectInputFilter.Config.createFilter(
"maxdepth=5;ChatMessage;java.time.Instant;java.time.Ser;!*");
private final Set<ClientHandler> clients = new CopyOnWriteArraySet<>();
public static void main(String[] args) throws IOException {
new ChatServer().start();
}
void start() throws IOException {
ExecutorService pool = Executors.newCachedThreadPool();
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
System.out.println("Chat server listening on port " + PORT);
while (true) {
Socket socket = serverSocket.accept(); // blocks until a client connects
pool.execute(new ClientHandler(socket)); // one thread per client
}
} finally {
pool.shutdownNow();
}
}
void broadcast(ChatMessage message) {
for (ClientHandler client : clients) {
client.send(message);
}
}
static ObjectOutputStream openOutput(Socket socket) throws IOException {
ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream());
out.flush(); // push the stream header now so the peer's ObjectInputStream can start
return out;
}
private class ClientHandler implements Runnable {
private final Socket socket;
private ObjectOutputStream out;
ClientHandler(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try (socket;
ObjectOutputStream out = openOutput(socket);
ObjectInputStream in = new ObjectInputStream(socket.getInputStream())) {
in.setObjectInputFilter(FILTER);
this.out = out;
clients.add(this);
broadcast(new ChatMessage("server", socket.getRemoteSocketAddress() + " joined", Instant.now()));
while (true) {
if (in.readObject() instanceof ChatMessage message) {
broadcast(message);
}
}
} catch (EOFException e) {
// the client closed the connection normally
} catch (IOException | ClassNotFoundException e) {
System.err.println("Client error: " + e.getMessage());
} finally {
clients.remove(this);
}
}
synchronized void send(ChatMessage message) {
try {
out.writeObject(message);
out.reset(); // forget previously written objects so memory does not grow
out.flush();
} catch (IOException e) {
clients.remove(this);
}
}
}
}ส่วนต่าง ๆ ทำงานร่วมกันอย่างไร:
accept()อยู่บน main thread การอ่านข้อมูลทั้งหมดเกิดใน handler thread ดังนั้น client ที่ช้าจะไม่ขวางการเชื่อมต่อใหม่CopyOnWriteArraySetวน loop ได้อย่างปลอดภัยแม้ thread อื่นกำลังเพิ่มหรือลบ client และในแอป chat การ broadcast เกิดบ่อยกว่าการเข้าออกมากsendเป็นsynchronizedเพราะหลาย handler อาจ broadcast ไปหา client คนเดียวกันพร้อมกัน และการเขียนพร้อมกันจะทำให้ObjectOutputStreamเสียObjectInputFilterสำคัญเพราะการ deserialize class อะไรก็ได้ที่มาจากเครือข่ายเป็นช่องโจมตีที่รู้จักกันดี filter นี้อนุญาตแค่ChatMessageและ class ที่Instantต้องใช้
เรื่องฝั่ง thread (ExecutorService, thread pool, การปิดให้เรียบร้อย) อ่านต่อได้ที่ Java thread และ multithreading
ขั้นที่ 4: chat client
Client ต้องทำสองอย่างพร้อมกัน คือมี background thread คอยพิมพ์ข้อความที่เข้ามา ขณะที่ main thread อ่านสิ่งที่ผู้ใช้พิมพ์จากคีย์บอร์ด
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.Socket;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
public class ChatClient {
private static final DateTimeFormatter TIME =
DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault());
public static void main(String[] args) throws IOException {
String host = args.length > 0 ? args[0] : "127.0.0.1";
String name = args.length > 1 ? args[1] : "guest";
try (Socket socket = new Socket(host, 5050);
ObjectOutputStream out = openOutput(socket);
ObjectInputStream in = new ObjectInputStream(socket.getInputStream())) {
Thread reader = new Thread(() -> {
try {
while (true) {
if (in.readObject() instanceof ChatMessage m) {
System.out.printf("[%s] %s: %s%n", TIME.format(m.sentAt()), m.sender(), m.text());
}
}
} catch (IOException | ClassNotFoundException e) {
System.out.println("Disconnected from server.");
}
});
reader.setDaemon(true);
reader.start();
BufferedReader console = new BufferedReader(new InputStreamReader(System.in));
String line;
while ((line = console.readLine()) != null && !line.equals("/quit")) {
out.writeObject(new ChatMessage(name, line, Instant.now()));
out.reset();
out.flush();
}
}
}
private static ObjectOutputStream openOutput(Socket socket) throws IOException {
ObjectOutputStream out = new ObjectOutputStream(socket.getOutputStream());
out.flush();
return out;
}
}พิมพ์ /quit แล้ว loop จะจบ try-with-resources ปิด socket ให้ และ handler ฝั่ง server จะได้ EOFException แล้วลบ client คนนั้นออก
ถ้าทำหน้าจอด้วย Swing แทน console อย่าอ่านข้อมูลจาก socket บน event dispatch thread ไม่อย่างนั้นหน้าต่างจะค้าง ให้อ่านใน background thread แล้วอัปเดต component ผ่าน SwingUtilities.invokeLater ส่วน UI อธิบายไว้ใน คู่มือ Java Swing
ลองรันแอป chat
วางไฟล์ทั้งสามไว้ในโฟลเดอร์เดียวกัน แล้วรัน:
javac ChatMessage.java ChatServer.java ChatClient.java
java ChatServer # terminal 1
java ChatClient 127.0.0.1 alice # terminal 2
java ChatClient 127.0.0.1 bob # terminal 3อะไรที่ alice พิมพ์จะขึ้นทั้งสอง terminal ถ้าจะคุยข้ามเครื่อง ให้ส่ง IP ในวง LAN ของเครื่อง server ให้ client และเปิด port 5050 ใน firewall
ปัญหา socket ที่พบบ่อย
| อาการ | สาเหตุที่น่าจะเป็น | วิธีแก้ |
|---|---|---|
| ทั้งสองฝั่งค้างตอนเชื่อมต่อ | สร้าง ObjectInputStream ก่อนทั้งคู่ | สร้างและ flush ObjectOutputStream ก่อน |
| ข้อความไปไม่ถึง | ไม่ได้ flush output | เรียก flush() หรือใช้ PrintWriter แบบ auto-flush |
เจอ BindException ตอนเปิด server | port ถูกใช้อยู่หรือถูก OS จองไว้ | เปลี่ยน port ในช่วง 1024–49151 |
| Memory ของ server โตขึ้นเรื่อย ๆ | ObjectOutputStream จำ object ที่เขียนไปแล้ว | reset() หลังส่งทุกข้อความ |
| ภาษาไทยเพี้ยน | ใช้ charset default ของเครื่อง | ระบุ UTF-8 ให้ชัด |
| Client ช้าคนเดียวทำให้ทุกคนช้า | broadcast เขียนลง socket ของแต่ละคนโดยตรง | ให้แต่ละ client มี queue ขาออกของตัวเอง |
คำถามที่พบบ่อย
แอป chat ควรใช้ TCP หรือ UDP
TCP เพราะข้อความ chat ต้องมาครบและเรียงลำดับถูกต้อง UDP เหมาะกับข้อมูลที่ความเร็วสำคัญกว่าการเสีย packet ไปบ้าง เช่น เสียงสนทนา
Java socket server ควรใช้ port อะไร
Port ไหนก็ได้ที่ว่างในช่วง 1024 ถึง 49151 หลีกเลี่ยง well-known port ที่ต่ำกว่า 1024 และ dynamic range ที่สูงกว่า 49151
ส่ง Java serialization ผ่านเครือข่ายปลอดภัยไหม
ปลอดภัยเฉพาะเมื่อมี ObjectInputFilter ที่เข้มงวด และใช้ระหว่างโปรแกรมที่เราควบคุมเองทั้งสองฝั่ง ถ้าเป็น API สาธารณะหรือต้องคุยข้ามภาษา ให้ส่งเป็น JSON หรือ Protocol Buffers แทน
Server แบบ thread ต่อ client รับได้กี่คน
ถ้าใช้ platform thread โดยทั่วไปอยู่ในหลักพัน ไม่ถึงหลักหมื่น เพราะแต่ละ thread ต้องจอง stack ของตัวเอง ตั้งแต่ Java 21 ขึ้นไป virtual thread (Executors.newVirtualThreadPerTaskExecutor()) ทำให้ดีไซน์เดิมรองรับได้มากขึ้นอีกมาก
สรุปสิ่งที่ควรจำ
ServerSocket.accept()รอรับ client และSocketแต่ละตัวให้ทั้ง input stream และ output stream- ใช้ try-with-resources กับทุก socket และ stream และระบุ UTF-8 ให้ชัดเมื่อส่งข้อความ
- แยก thread ให้ client แต่ละคน เพื่อไม่ให้การเชื่อมต่อหนึ่งขวางคนอื่น
- ถ้าใช้ object stream ให้สร้างและ flush output ก่อน,
reset()หลังส่งทุกข้อความ และ filter สิ่งที่จะ deserialize
ถ้า client เป็น browser จะต้องเปลี่ยนไปใช้ WebSocket และเมื่อระบบใหญ่ขึ้นก็ต้องเพิ่ม pub/sub ข้ามหลาย server ถ้าคุณกำลังสร้างระบบ real-time และอยากได้ทีมที่มีประสบการณ์ Vectorkub ช่วยได้
