有段时间我挺怕“线上偶发网络问题”这几个字的——你写的代码明明很朴素:连上、发一句、收一句。结果一上生产就变成玄学:卡住不返回、偶尔超时、同一个地址有时行有时不行、POST 过去像石沉大海。后来我把 Java 这套网络工具链从低到高捋了一遍,才发现:不是网络太难,是我们经常“用错层级”或者“顺序搞反”。
这篇我就按我自己的理解路线走一遍:从 Socket → SSL Socket → URL/URI → URLConnection(含 POST)→ HttpClient(HTTP/2)→ JDK 自带 HTTP Server → 发邮件(别手搓 SMTP)。你看完基本就能把“网络交互”这块在 Java 里打通。
最常见的坑不是 DNS 也不是防火墙(当然它们也烦),而是:
getInputStream(),异常一抛你就丢了服务端的错误页所以我的习惯是:先选对工具层级,再谈排查。下面从最低层开始。
这类 Demo 我很爱用:它不需要协议知识,能快速验证“网络通不通、读写有没有问题”。
服务端:接收一句就回一句,输入 BYE 退出。
import java.io.*;
import java.net.*;
import java.util.*;
import java.util.concurrent.*;
publicclassEchoServer{
publicstaticvoidmain(String[] args)throws Exception {
int port = args.length >= 1 ? Integer.parseInt(args[0]) : 8189;
try (ServerSocket server = new ServerSocket(port)) {
// 小技巧:用虚拟线程处理每个连接,写起来像同步,跑起来又很轻
ExecutorService es = Executors.newVirtualThreadPerTaskExecutor();
while (true) {
Socket client = server.accept(); // 阻塞等待连接
es.submit(() -> serve(client));
}
}
}
privatestaticvoidserve(Socket client){
try (var in = new Scanner(client.getInputStream());
var out = new PrintWriter(client.getOutputStream(), true)) {
out.println("你好!输入 BYE 结束。");
while (in.hasNextLine()) {
String line = in.nextLine();
out.println("回声: " + line);
// 用 strip() 比 trim() 更“现代”,能处理更多空白字符
if (line.strip().equals("BYE")) break;
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
为什么这么写:
accept() 是阻塞的:服务端天然就是“等人来敲门”PrintWriter(..., true) 开启自动 flush:不然你可能以为“没回包”,其实是你没 flush如果你需要的是安全通道(比如内部服务用 TLS),Java 也能很快把 Socket 升级成 SSL Socket:
import javax.net.ServerSocketFactory;
import javax.net.ssl.SSLServerSocketFactory;
import java.net.*;
publicclassSslEchoServer{
publicstaticvoidmain(String[] args)throws Exception {
int port = args.length >= 1 ? Integer.parseInt(args[0]) : 8189;
ServerSocketFactory factory = SSLServerSocketFactory.getDefault();
try (ServerSocket s = factory.createServerSocket(port)) {
while (true) {
Socket incoming = s.accept();
// 这里省略处理逻辑,和普通 Socket 一样读写
}
}
}
}
我个人的提醒:
我以前写过很多这种代码:
String url = "https://xx.com/api?name=" + name + "&city=" + city;
看似简单,实际上超容易拼错、编码错。
openStream() 或 openConnection()(像“快递真的送出去”)现在更推荐的构造方式是:
var url = new java.net.URI(urlString).toURL();
(是的,有些 URL 构造器在较新版本里被标记不建议使用,因为可能构造出“表面合法但实际上不规范”的 URL。)
URI 里面可能有:
你可以这样拆:
URI u = new URI("https://google.com?q=Beach+Chalet");
System.out.println(u.getScheme());
System.out.println(u.getHost());
System.out.println(u.getQuery());
这个在写爬虫、做文档链接处理时特别爽:
URI base = new URI("https://docs.mycompany.com/api/java/net/ServerSocket.html");
URI relative = new URI("../../java/net/Socket.html#Socket()");
URI combined = base.resolve(relative);
System.out.println(combined);
// https://docs.mycompany.com/api/java/net/Socket.html#Socket()
如果你只是“拿网页内容”,URL.openStream() 就够了:
var url = new URI("https://example.com").toURL();
try (var in = new Scanner(url.openStream())) {
while (in.hasNextLine()) System.out.println(in.nextLine());
}
但一旦你需要:
那就用 URLConnection / HttpURLConnection。
它背后会自动处理请求/响应头,所以调用顺序很关键:
openConnection()connect()getInputStream())import java.io.*;
import java.net.*;
import java.util.*;
publicclassURLConnectionDemo{
publicstaticvoidmain(String[] args)throws Exception {
String urlName = args.length > 0 ? args[0] : "http://horstmann.com";
var url = new URI(urlName).toURL();
URLConnection conn = url.openConnection();
conn.setConnectTimeout(5000); // 我个人习惯:必须设,别让线程无限等
conn.setReadTimeout(5000);
// 如果需要 Basic Auth
// conn.setRequestProperty("Authorization", "Basic " + base64(username:password));
conn.connect();
// 1) 打印 header
Map<String, List<String>> headers = conn.getHeaderFields();
for (var e : headers.entrySet()) {
String k = e.getKey();
for (String v : e.getValue()) System.out.println(k + ": " + v);
}
System.out.println("----------");
System.out.println("Content-Type: " + conn.getContentType());
System.out.println("Content-Length: " + conn.getContentLength());
System.out.println("Content-Encoding: " + conn.getContentEncoding());
System.out.println("Date: " + conn.getDate());
System.out.println("Last-Modified: " + conn.getLastModified());
System.out.println("----------");
// 2) 打印内容前 10 行
String encoding = conn.getContentEncoding();
if (encoding == null) encoding = "UTF-8";
try (var in = new Scanner(conn.getInputStream(), encoding)) {
for (int i = 1; in.hasNextLine() && i <= 10; i++) {
System.out.println(in.nextLine());
}
if (in.hasNextLine()) System.out.println("...");
}
}
}
常见误区:
getInputStream() 只是“拿输入流”,其实它可能触发真正的网络交互+%XXapplication/x-www-form-urlencoded这就是“模拟网页表单提交”的经典方式:
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
publicclassPostFormDemo{
publicstaticvoidmain(String[] args)throws Exception {
URL url = new URI("https://host/path").toURL();
URLConnection connection = url.openConnection();
connection.setDoOutput(true); // 关键:不设就拿不到 OutputStream
try (var out = new PrintWriter(connection.getOutputStream())) {
out.print("name=" + URLEncoder.encode("旺仔", StandardCharsets.UTF_8));
out.print("&city=" + URLEncoder.encode("南京", StandardCharsets.UTF_8));
}
try (var in = new Scanner(connection.getInputStream(), StandardCharsets.UTF_8)) {
while (in.hasNextLine()) System.out.println(in.nextLine());
}
}
}
为什么必须 setDoOutput(true):
getInputStream(),要看 getErrorStream()HTTP 404/500 时,getInputStream() 可能直接抛异常,但服务端其实回了错误页。你得这样兜底:
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
try {
try (InputStream in = conn.getInputStream()) {
// read ok
}
} catch (IOException e) {
InputStream err = conn.getErrorStream();
if (err != null) {
System.out.println(new String(err.readAllBytes(), StandardCharsets.UTF_8));
} else {
throw e;
}
}
(这个技巧我用来排查第三方接口特别好用,能看到他们到底回了啥。)
HttpURLConnection 能自动跟一些 redirect,但跨 HTTPS→HTTP默认不自动(安全原因)。更坑的是:有些场景你设置的 header(比如 User-Agent)在自动重定向里不一定被保留,你就得手动跟 Location。
URLConnection 历史包袱挺重。现在我更推荐优先用 java.net.http.HttpClient:
import java.net.URI;
import java.net.http.*;
publicclassHttpClientGetDemo{
publicstaticvoidmain(String[] args)throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://example.com"))
.GET()
.build();
HttpResponse<String> resp =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(resp.statusCode());
System.out.println(resp.body());
}
}
import java.net.URI;
import java.net.http.*;
publicclassHttpClientPostJsonDemo{
publicstaticvoidmain(String[] args)throws Exception {
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.ALWAYS)
.build();
String json = """
{"name":"旺仔","city":"南京","note":"来杯热美式"}
""";
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://host/api/order"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> resp =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(resp.statusCode());
System.out.println(resp.body());
}
}
firstValue 很舒服(且大小写不敏感)var lastModified = resp.headers().firstValue("Last-Modified");
System.out.println(lastModified.orElse("(none)"));
sendAsync + CompletableFuture适合“并发拉多个接口,然后聚合结果”:
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenAccept(r -> System.out.println(r.body()));
我个人建议:
你需要一个临时的 HTTP 服务?JDK 就自带。
jwebserver在当前目录起一个静态文件服务(默认 8000 端口):
jwebserver
换目录/端口:
jwebserver -d /tmp -p 8189
像极了 python -m http.server,但不用装 Python(当然大部分人都有…)。
这个我用来观察浏览器/客户端到底发了什么:
import com.sun.net.httpserver.*;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
publicclassMiniHttpEchoServer{
publicstaticvoidmain(String[] args)throws Exception {
int port = args.length >= 1 ? Integer.parseInt(args[0]) : 8189;
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/echo", exchange -> {
String method = exchange.getRequestMethod();
URI uri = exchange.getRequestURI();
var sb = new StringBuilder();
sb.append(method).append(" ").append(uri).append("\n\n");
exchange.getRequestHeaders().forEach(
(k, vs) -> vs.forEach(v -> sb.append(k).append(": ").append(v).append("\n"))
);
sb.append("\n");
byte[] bodyBytes = exchange.getRequestBody().readAllBytes();
sb.append(new String(bodyBytes, StandardCharsets.UTF_8)).append("\n");
byte[] resp = sb.toString().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=UTF-8");
exchange.sendResponseHeaders(200, resp.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(resp);
}
});
server.start();
System.out.println("Listening on http://localhost:" + port + "/echo");
}
}
以前确实可以直接 Socket 连 25 端口,然后按 SMTP 协议写 HELO / MAIL FROM / RCPT TO ...。但现在基本都会遇到:
所以我的结论很简单:用 Jakarta Mail,别跟 SMTP 细节硬刚(尤其你只是想发一封告警邮件)。
URLEncoder.encode(..., UTF_8)我现在判断一个网络调用能不能上线,除了功能正确,还会问自己几个问题:
这些东西不会让你代码更“炫”,但会让你半夜少爬几次起来看日志。
我个人的实践顺序一般是:
HttpServer / jwebserverHttpClientURLConnection/HttpURLConnectionSocket/SSL Socket你把这条路线记住,网络问题就不会那么“玄”了——它只是在提醒你:别用螺丝刀去敲钉子。