กลับไปยังบทความ

Backend

Java Socket Programming: สร้างแอป Chat แบบ Client-Server ตั้งแต่ศูนย์

คู่มือ Java socket programming ทีละขั้น ตั้งแต่ port และ TCP, ServerSocket กับ Socket, การส่งข้อความ chat ด้วย object serialization ไปจนถึง server ที่รับหลาย client ด้วย thread ต่อ client

24 กันยายน 256910 นาทีในการอ่าน

java socket programming chat app

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–1023Well-known port (HTTP 80, HTTPS 443, SSH 22)ไม่ควร สงวนไว้ให้ service ของระบบ
1024–49151Registered portได้ เลือกตัวที่ยังว่าง
49152–65535Dynamic/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:

  1. สร้าง ServerSocket ผูกกับ port
  2. เรียก accept() ซึ่งจะ block รอจนมี client เชื่อมต่อเข้ามา แล้วคืน Socket ของ client คนนั้น
  3. อ่านและเขียนผ่าน stream ของ socket นั้น ปิดเมื่อเสร็จ แล้ววนกลับไป accept()

ฝั่ง client สร้าง Socket โดยระบุ host และ port ของ server (constructor จะเชื่อมต่อทันที) แล้วใช้ stream แบบเดียวกัน

ขั้นที่ 1: server และ client แบบส่งข้อความธรรมดา

เวอร์ชันแรกส่งข้อความทีละบรรทัด server พิมพ์ทุกอย่างที่ได้รับออกมา

java
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);
                    }
                }
            }
        }
    }
}
java
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 และไปไม่ถึง server
  • readLine() คืน null เมื่อ client ปิดการเชื่อมต่อ server ใช้จุดนี้รู้ว่า client คนนั้นเลิกคุยแล้ว

Server ตัวนี้รับได้ ทีละ client เท่านั้น ระหว่างที่อ่านข้อมูลจาก client คนแรก คนที่สองต้องรออยู่ที่ accept() แต่แอป chat ต้องให้ทุกคนเชื่อมต่อพร้อมกันได้

ขั้นที่ 2: ส่งข้อความ chat เป็น object

เมื่อข้อความต้องมีหลาย field เช่น ผู้ส่ง ข้อความ และเวลา เราส่งเป็น Java object ไปทั้งก้อนได้เลย ไม่ต้องคิด format ข้อความขึ้นมาเอง Object serialization คือการแปลง object เป็นลำดับของ byte ที่ส่งผ่านเครือข่ายได้ ObjectOutputStream เขียน object ออกไป และ ObjectInputStream ประกอบ object กลับคืนที่ปลายทาง

java
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 ค้าง:

  1. สร้าง ObjectOutputStream ก่อนแล้ว flush ทันที จากนั้นค่อยสร้าง ObjectInputStream เพราะ constructor ของ input stream จะ block รอ header จากอีกฝั่ง ถ้าทั้งสองฝั่งสร้าง input stream ก่อน ก็จะรอกันไปตลอดกาล
  2. เรียก reset() หลังส่งทุกข้อความ เมื่อการเชื่อมต่ออยู่นาน ObjectOutputStream จำทุก object ที่เคยเขียนไว้เพื่อใช้ส่ง back-reference ถ้าไม่ reset() memory จะโตขึ้นทุกข้อความ

ขั้นที่ 3: chat server ที่แยก thread ให้แต่ละ client

Server รับการเชื่อมต่อใน loop แล้วส่ง client แต่ละคนไปให้ thread ของตัวเองจาก ExecutorService handler แต่ละตัวอ่านข้อความจาก client ของมัน แล้ว broadcast ไปให้ทุกคน

java
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 อ่านสิ่งที่ผู้ใช้พิมพ์จากคีย์บอร์ด

java
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

วางไฟล์ทั้งสามไว้ในโฟลเดอร์เดียวกัน แล้วรัน:

bash
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 ตอนเปิด serverport ถูกใช้อยู่หรือถูก 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 ช่วยได้

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ