學完本章你將能夠:

  • 說明 IP、埠號與 TCP/UDP 的差異,用 |InetAddress| 查詢主機。

  • 用 Socket 撰寫 TCP 的伺服器與用戶端,並演進到執行緒池版本。

  • 用 Java 11 的 |HttpClient| 發出同步與非同步請求。

  • 用 JDBC 完成 CRUD、辨識並防範 SQL 注入。

  • 用交易確保資料一致,並用批次處理提升效能。

  • 以 DAO 模式分層,理解連線池的價值。

現代軟體幾乎沒有孤島:前端要打 API、後端要連資料庫、服務之間要互相呼叫。本章 聚焦兩個最基礎也最關鍵的能力——網路通訊與資料庫存取。這兩者 都建立在前幾章的基礎上:伺服器要用第 4 章的執行緒池服務多個連線,非同步 HTTP 呼叫要用 |CompletableFuture|,連線池的概念也與執行緒池一脈相承。

我們先從網路的底層 Socket 講起,理解「連線」到底是什麼,再往上到便利的 |HttpClient|;資料庫部分則從 JDBC 的原始 API 出發,一路帶到防注入、交易 與分層設計等業界實務。

網路基礎速覽

網路通訊靠兩個座標定位對象:IP 位址標定「哪一台主機」,埠號 (port)標定「主機上的哪個程式」。兩者合起來(如 |140.112.1.1:8080|) 就能唯一指向網路上某個服務。傳輸層有兩大協定:

比較 TCP UDP
連線 需三向交握建立連線 無連線
可靠性 保證送達與順序 不保證
速度 較慢(有確認機制) 較快
適用 網頁、檔案傳輸 直播、遊戲、DNS

TCP 建立連線要經過「三向交握」:用戶端送 SYN、伺服器回 SYN-ACK、用戶端再回 ACK,雙方確認彼此都能收送後才開始傳資料。這個機制是 TCP「可靠」的基礎。

import java.net.InetAddress;

/** * 用 InetAddress 查詢本機與指定主機的 IP 位址。 */ public class InetAddressDemo

public static void main(String[] args) throws Exception

// 取得本機資訊 InetAddress local = InetAddress.getLocalHost(); System.out.println("本機名稱:" + local.getHostName()); System.out.println("本機位址:" + local.getHostAddress());

// 以網域名稱查詢 IP(DNS 解析) InetAddress site = InetAddress.getByName("localhost"); System.out.println("localhost → " + site.getHostAddress());

本機名稱:mycomputer.local 本機位址:192.168.1.20 localhost → 127.0.0.1

TCP Socket 程式設計

TCP 通訊的兩端分別用 |ServerSocket|(伺服器)與 |Socket|(用戶端)。 伺服器呼叫 |accept| 會阻塞等待,直到有用戶端連上才回傳一個 |Socket| 代表這條連線,之後雙方就透過它的輸入輸出串流讀寫資料。先看 一個 Echo(回聲)伺服器,它把收到的每一行原樣送回:

import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket;

/** * 單執行緒回聲伺服器:接受一個連線,把每行輸入原樣回傳。 * try-with-resources 確保 Socket 與串流自動關閉。 */ public class EchoServer public static void main(String[] args) throws Exception // 綁定 5000 埠,開始監聽 try (ServerSocket server = new ServerSocket(5000)) System.out.println("伺服器啟動,等待連線…"); try (Socket socket = server.accept(); // 阻塞等待 BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter( socket.getOutputStream(), true)) System.out.println("用戶端已連線"); String line; // 逐行讀取,直到用戶端關閉連線(readLine 回傳 null) while ((line = in.readLine()) != null) System.out.println("收到:" + line); out.println("回聲:" + line); // 原樣送回

搭配的用戶端連上伺服器、送出訊息並印出回聲:

import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.Socket;

/** * 回聲用戶端:連上伺服器,送出兩行訊息並印出回聲。 */ public class EchoClient

public static void main(String[] args) throws Exception

// 連到 localhost 的 5000 埠 try (Socket socket = new Socket("localhost", 5000); PrintWriter out = new PrintWriter( socket.getOutputStream(), true); BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())))

for (String msg : new String[]"你好", "Java 網路") out.println(msg); // 送出 System.out.println(in.readLine()); // 讀回聲

執行時開兩個終端機:先啟動伺服器,再啟動用戶端。兩邊的輸出如下:

# 終端機 1(伺服器) \(java EchoServer 伺服器啟動,等待連線… 用戶端已連線 收到:你好 收到:Java 網路 # 終端機 2(用戶端)\) java EchoClient 回聲:你好 回聲:Java 網路

從單執行緒到執行緒池伺服器

上面的伺服器有個致命問題:它一次只能服務一個用戶端。因為主執行緒被 |accept| 之後的讀寫迴圈佔住,第二個用戶端只能乾等。解法呼應第 4 章: 每接受一條連線,就把它交給執行緒池處理,主執行緒立刻回頭 |accept| 下一個。

import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors;

/** * 執行緒池版回聲伺服器:可同時服務多個用戶端。 * 主執行緒只負責 accept,實際讀寫交給執行緒池。 */ public class PooledEchoServer

public static void main(String[] args) throws Exception ExecutorService pool = Executors.newFixedThreadPool(10); try (ServerSocket server = new ServerSocket(5000)) System.out.println("執行緒池伺服器啟動"); while (true) Socket socket = server.accept(); // 接受新連線 // 把這條連線的服務工作丟給執行緒池 pool.submit(() -> handle(socket));

/** 服務單一連線:回聲直到對方關閉 */ static void handle(Socket socket) try (socket; BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); PrintWriter out = new PrintWriter( socket.getOutputStream(), true)) String line; while ((line = in.readLine()) != null) out.println("回聲:" + line); catch (Exception e) System.out.println("連線異常:" + e.getMessage());

執行緒池伺服器啟動 (此時可同時開多個用戶端,各自都能得到回聲,互不阻塞)

「每連線一執行緒」的模型直觀,但連線數極大時(如上萬)會受限於平台執行緒的 成本。此時有兩條現代路線:用第 4 章的虛擬執行緒(每連線一條虛擬執行緒, 輕量且寫法不變),或改用 NIO 的非阻塞、事件驅動模型(如 Netty)。中小型服務用 執行緒池已綽綽有餘。

群聊伺服器實戰

把多執行緒與共享狀態結合,就能做出群聊伺服器:維護一份「所有連線」的清單, 任何人發言就廣播給全部人。關鍵在於這份清單會被多執行緒同時存取, 必須用執行緒安全的集合(呼應第 4 章)。

import java.io.BufferedReader; import java.io.InputStreamReader; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors;

/** * 群聊伺服器:維護所有用戶端的輸出串流,收到訊息即廣播給全部人。 * CopyOnWriteArrayList 保證多執行緒讀寫清單的安全(讀多寫少)。 */ public class ChatServer

// 所有已連線用戶端的輸出串流 static final List<PrintWriter> clients = new CopyOnWriteArrayList<>();

public static void main(String[] args) throws Exception ExecutorService pool = Executors.newCachedThreadPool(); try (ServerSocket server = new ServerSocket(6000)) System.out.println("群聊伺服器啟動於 6000 埠"); while (true) Socket socket = server.accept(); pool.submit(() -> serve(socket));

/** 服務單一用戶端:註冊、收訊廣播、離線時移除 */ static void serve(Socket socket) try (socket; BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream()))) PrintWriter out = new PrintWriter( socket.getOutputStream(), true); clients.add(out); // 註冊 String line; while ((line = in.readLine()) != null) broadcast(line); // 廣播 catch (Exception e) System.out.println("用戶端離線");

/** 把訊息送給每一個已連線的用戶端 */ static void broadcast(String msg) for (PrintWriter client : clients) client.println(msg);

群聊伺服器啟動於 6000 埠 (多個用戶端連入後,任一人發言,所有人都會即時收到)

UDP 收發

UDP 沒有連線的概念,直接用 |DatagramSocket| 收送 |DatagramPacket| 封包。它不保證送達,但省去建立連線的開銷,適合可容忍少量遺失、要求低延遲的 場景(如即時串流、遊戲同步)。

import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress;

/** * UDP 收發示範:接收端與傳送端各用一個 DatagramSocket。 * 此處在同一程式內先啟動接收再送出,示意收送流程。 */ public class UdpDemo

public static void main(String[] args) throws Exception

// 接收端:綁定 7000 埠,在另一執行緒等待封包 Thread receiver = new Thread(() -> try (DatagramSocket socket = new DatagramSocket(7000)) byte[] buf = new byte[1024]; DatagramPacket packet = new DatagramPacket(buf, buf.length); socket.receive(packet); // 阻塞等待 String text = new String(packet.getData(), 0, packet.getLength()); System.out.println("接收到:" + text); catch (Exception e) e.printStackTrace(); ); receiver.start(); Thread.sleep(100); // 確保接收端就緒

// 傳送端:把封包送往 localhost 的 7000 埠 try (DatagramSocket socket = new DatagramSocket()) byte[] data = "UDP 訊息".getBytes(); DatagramPacket packet = new DatagramPacket(data, data.length, InetAddress.getByName("localhost"), 7000); socket.send(packet); receiver.join();

接收到:UDP 訊息

HTTP 用戶端

早期用 |HttpURLConnection| 發 HTTP 請求非常繁瑣:要手動設定方法、開串流、 讀回應、關資源。Java 11 引入的 |HttpClient| 徹底改善,API 現代且支援 非同步。以下示範同步 GET、非同步 GET 與 POST JSON:

import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.concurrent.CompletableFuture;

/** * Java 11 HttpClient:同步 GET、非同步 GET、POST JSON。 * HttpClient 可重複使用,建議整個應用共用一個實體。 */ public class HttpClientDemo

public static void main(String[] args) throws Exception

HttpClient client = HttpClient.newHttpClient();

// 同步 GET:send 會阻塞直到回應返回 HttpRequest get = HttpRequest.newBuilder() .uri(URI.create("https://example.com")) .GET() .build(); HttpResponse<String> resp = client.send(get, HttpResponse.BodyHandlers.ofString()); System.out.println("同步狀態碼:" + resp.statusCode());

// 非同步 GET:回傳 CompletableFuture(呼應第 4 章) CompletableFuture<Integer> future = client .sendAsync(get, HttpResponse.BodyHandlers.ofString()) .thenApply(HttpResponse::statusCode); System.out.println("非同步狀態碼:" + future.get());

// POST JSON:設定標頭與請求主體 HttpRequest post = HttpRequest.newBuilder() .uri(URI.create("https://example.com/api")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString( "n̈ame:̈J̈avä")) .build(); System.out.println("已建立 POST 請求:" + post.method());

同步狀態碼:200 非同步狀態碼:200 已建立 POST 請求:POST

JDBC:Java 資料庫連接

JDBC 是 Java 存取關聯式資料庫的標準 API。它把「如何連接與操作資料庫」抽象成 一組介面,各家資料庫廠商提供各自的驅動實作。核心角色有四個: |DriverManager|(依連線 URL 取得連線)、|Connection|(一條資料庫 連線)、|Statement|/|PreparedStatement|(執行 SQL)、 |ResultSet|(查詢結果集)。

本章範例以 SQLite 為例(輕量、免安裝伺服器,資料就是一個檔案),需在 classpath 加入 |org.xerial:sqlite-jdbc| 驅動 JAR。連線 URL 格式為 |jdbc:sqlite:test.db|;MySQL 則類似 |jdbc:mysql://localhost:3306/db|。 自 JDBC 4.0 起,只要驅動 JAR 在 classpath,會透過 SPI 自動註冊,不必 再手動呼叫 |Class.forName|。以下範例的執行結果為合理的示意輸出。

建表與 CRUD

import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.Statement;

/** * JDBC 基本流程:連線、建表、以 Statement 執行 DDL/DML、查詢走訪。 * try-with-resources 確保連線與 Statement 自動關閉。 */ public class JdbcCrud

public static void main(String[] args) throws Exception

String url = "jdbc:sqlite:school.db"; try (Connection conn = DriverManager.getConnection(url); Statement stmt = conn.createStatement())

// 建表(若不存在) stmt.execute("CREATE TABLE IF NOT EXISTS student (" + "id INTEGER PRIMARY KEY, name TEXT, score INTEGER)"); // 清空舊資料以利重複執行 stmt.execute("DELETE FROM student"); // 新增資料 stmt.executeUpdate( "INSERT INTO student VALUES (1, ’Alice’, 90)"); stmt.executeUpdate( "INSERT INTO student VALUES (2, ’Bob’, 75)");

// 查詢並走訪結果集 ResultSet rs = stmt.executeQuery( "SELECT id, name, score FROM student ORDER BY id"); while (rs.next()) // 逐列前進 System.out.printf(" rs.getInt("id"), rs.getString("name"), rs.getInt("score"));

1 | Alice | 90 2 | Bob | 75

SQL 注入與 PreparedStatement

用字串拼接組 SQL 是重大的資安漏洞。假設用「使用者輸入」拼查詢,惡意輸入就能 竄改語意:

/** * 反面教材:用字串拼接組 SQL。 * 當輸入是 ’ OR ’1’=’1,WHERE 條件恆真,等於繞過驗證取得全部資料。 */ public class SqlInjection public static void main(String[] args) String userInput = "’ OR ’1’=’1"; // 惡意輸入 // 危險的字串拼接 String sql = "SELECT * FROM users WHERE name = ’" + userInput + "’"; System.out.println("實際送出的 SQL:"); System.out.println(sql); // 條件變成 name = ” OR ’1’=’1’,永遠成立 → 全表外洩

實際送出的 SQL: SELECT * FROM users WHERE name = ” OR ’1’=’1’

永遠不要用字串拼接把使用者輸入組進 SQL。如上所示,輸入 !’ OR ’1’=’1! 就能讓 |WHERE| 條件恆真,繞過驗證竊取整張表;更惡意 的輸入甚至能刪表。正解是用 |PreparedStatement| 的參數化查詢: 先把 SQL 骨架送給資料庫預編譯,再用 |setXxx| 綁定參數值。此時 參數只會被當成「資料」而非「SQL 語法」,注入無從發生。

import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet;

/** * 用 PreparedStatement 參數化查詢:安全且可預編譯重用,效能更好。 * 問號 ? 是參數佔位符,用 setXxx 依序綁定實際值。 */ public class PreparedStatementDemo public static void main(String[] args) throws Exception String url = "jdbc:sqlite:school.db"; try (Connection conn = DriverManager.getConnection(url)) String sql = "SELECT name, score FROM student " + "WHERE score >= ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) ps.setInt(1, 80); // 綁定第 1 個參數 ResultSet rs = ps.executeQuery(); while (rs.next()) System.out.println(rs.getString("name") + ":" + rs.getInt("score"));

Alice:90

交易管理

一組操作要嘛「全部成功」、要嘛「全部不做」,這就是交易。轉帳是經典 例子:從 A 扣款與往 B 加款必須同進退,中途失敗就要回滾。JDBC 預設 每條 SQL 自動提交,要手動控制交易需先關閉自動提交。

import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement;

/** * 轉帳交易:扣款與加款包在同一交易中,任一步失敗即整筆回滾。 */ public class TransferDemo

public static void main(String[] args) throws Exception String url = "jdbc:sqlite:bank.db"; try (Connection conn = DriverManager.getConnection(url)) conn.setAutoCommit(false); // 開始手動交易 try update(conn, "UPDATE account SET balance = balance - 100 " + "WHERE id = 1"); // A 扣 100 // 模擬中途失敗:若餘額不足應中止 if (isOverdrawn(conn)) throw new IllegalStateException("餘額不足"); update(conn, "UPDATE account SET balance = balance + 100 " + "WHERE id = 2"); // B 加 100 conn.commit(); // 全部成功才提交 System.out.println("轉帳成功"); catch (Exception e) conn.rollback(); // 失敗則回滾 System.out.println("轉帳失敗已回滾:" + e.getMessage());

static void update(Connection conn, String sql) throws Exception try (PreparedStatement ps = conn.prepareStatement(sql)) ps.executeUpdate();

/** 示意:檢查是否透支(此處固定回傳 false 讓交易成功) */ static boolean isOverdrawn(Connection conn) return false;

轉帳成功 (若中途拋例外,會印出「轉帳失敗已回滾」,且兩帳戶餘額不變)

批次處理與 DAO 模式

大量寫入時,逐條執行會來回資料庫很多次。用 |addBatch| 累積、 |executeBatch| 一次送出,能大幅減少往返、提升效能。而在架構上,我們不會 把 SQL 灑滿業務程式,而是用 DAO 模式(Data Access Object)把資料存取 邏輯集中到專門的類別,對上層只暴露乾淨的介面——這呼應中級篇的 Repository 概念。

import java.sql.Connection; import java.sql.PreparedStatement; import java.util.List;

/** * DAO 模式:把資料存取封裝在專門類別,對上層暴露乾淨方法。 * saveAll 用批次一次寫入多筆,減少資料庫往返。 */ public class StudentDao

record Student(int id, String name, int score)

/** DAO 介面:上層只依賴這個抽象 */ interface Dao void saveAll(List<Student> students) throws Exception;

/** JDBC 實作 */ static class JdbcStudentDao implements Dao

private final Connection conn; JdbcStudentDao(Connection conn) this.conn = conn;

@Override public void saveAll(List<Student> students) throws Exception String sql = "INSERT INTO student VALUES (?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql)) for (Student s : students) ps.setInt(1, s.id()); ps.setString(2, s.name()); ps.setInt(3, s.score()); ps.addBatch(); // 累積一筆 int[] result = ps.executeBatch(); // 一次送出全部 System.out.println("批次寫入 " + result.length + " 筆");

批次寫入 3 筆

每次操作都 |DriverManager.getConnection| 建立新連線非常昂貴(TCP 交握、 認證、資源配置)。正式系統一律用連線池:預先建立一批連線重複使用, 用完歸還而非關閉。業界最主流的是 |HikariCP|(Spring Boot 的預設),以 輕量與高效著稱。連線池與第 4 章的執行緒池是同一種「資源池化」思想的體現。

本章小結

  • IP 標定主機、埠號標定程式;TCP 可靠有序需交握,UDP 無連線但低延遲。

  • |ServerSocket.accept| 阻塞等待連線;用 Socket 的輸入輸出串流 讀寫資料。

  • 單執行緒伺服器一次只能服務一人;用執行緒池(或虛擬執行緒)可同時服務 多個連線。

  • 群聊伺服器需以執行緒安全的集合維護連線清單並廣播。

  • Java 11 |HttpClient| 提供現代的同步與非同步 HTTP API,非同步版 回傳 |CompletableFuture|。

  • JDBC 四大角色:|DriverManager|、|Connection|、 |PreparedStatement|、|ResultSet|。

  • 字串拼接 SQL 會招致注入攻擊;一律用 |PreparedStatement| 參數化 查詢,兼顧安全與效能。

  • 交易用 |setAutoCommit(false)| + |commit|/|rollback| 確保一致;批次用 |addBatch|/|executeBatch| 提效。

  • DAO 模式集中資料存取邏輯;連線池(如 HikariCP)避免重複建立連線的開銷。

本章練習

  1. 觀念題:為什麼單執行緒的 Socket 伺服器一次只能服務一個用戶端?執行緒池 如何解決? (提示:想想 |accept| 之後主執行緒在做什麼。)

  2. 觀念題:|PreparedStatement| 為何能防止 SQL 注入?它相較字串拼接 還有什麼效能優勢? (提示:關鍵在「預編譯」與「參數只當資料」。)

  3. 實作題:擴充回聲伺服器,當收到 |bye| 時主動關閉該連線並印出 「用戶端離線」。 (提示:在讀迴圈內判斷內容後 |break|。)

  4. 實作題:用 |HttpClient| 對某公開 API 發出非同步 GET,取得回應後 印出前 100 個字元。 (提示:|sendAsync| 後接 |thenApply|。)

  5. 實作題:用 JDBC 與 SQLite 建立 |product| 表,實作完整 CRUD, 全部查詢改用 |PreparedStatement|。 (提示:更新與刪除用 |executeUpdate|。)

  6. 實作題:寫一個轉帳交易,故意在扣款後拋出例外,驗證回滾後兩個帳戶的 餘額都沒有變動。 (提示:|catch| 內呼叫 |rollback|,事後查詢餘額確認。)