首页 / 资讯中心 / 文章详情

Java网络编程入门:从TCP/IP基础到Socket与HTTP实战

Java网络编程入门:从TCP/IP基础到Socket与HTTP实战 ★ FEATURED ARTICLE
翻到 Java 笔记的第十篇正好是网络编程。不少朋友学 Java 时把集合、IO 练得很熟一碰到 Socket 就发怵总觉得 IP、端口、协议是另一个世界的东西。其实网络编程没有想象中那么神秘核心就是让两个进程通过 TCP 或 UDP 互相搬数据。这篇笔记我按自己的学习路线整理先从 TCP/IP 模型里最简单的那几层说起然后直接写一个能跑的 Socket 例子再延伸到多线程服务器、HTTP 请求、粘包拆包等实战中躲不开的问题。适合刚学完 Java 基础、想用 Java 做小项目或对接一些接口的朋友参考。1. 网络编程前的必要准备1.1 先理解 TCP/IP 模型里你真正用到的部分很多教材上来就铺开整个分层模型说实话容易把人劝退。你在 Java 里写网络程序日常打交道最多的是传输层和应用层再往下就是操作系统帮你处理了。所以我建议先用一个“寄快递”的类比把骨架搭起来IP 地址相当于收货地址用来找到具体是哪台机器。端口相当于公司里的“某个部门”机器收到数据之后要交给对应的进程。协议相当于快递包装上的交接规则TCP 是“要签收的挂号件”UDP 是“直接扔传达室的平信”。TCP 和 UDP 的区别也很直观。TCP 要先建立连接发送前要握手收完要确认数据歪了要重传所以适合文件传输、网页访问这些不能丢数据的场景。UDP 不要连接直接扔数据速度快但可能丢包适合视频通话、游戏同步这些丢几帧也无所谓的场景。Java 提供了一整套 API 把底层细节挡住。你在代码里 new 一个 Socket操作系统会帮你完成 DNS 解析、路由、握手你只需要往输出流里写数据再从输入流里读数据。所以我一直觉得先跑起来回头再补理论效率高得多。1.2 地址、端口与协议三者的分工你可能遇到过这种代码InetAddress address InetAddress.getByName(example.com); System.out.println(address.getHostAddress());InetAddress 就是 Java 里表示 IP 地址的类。本机回环地址 127.0.0.1 代表“我自己”如果你只想本机测试客户端连这个地址就行。生产环境里连接另一台服务器就要填对方的公网 IP 或域名同时还要配上端口。端口是个 16 位整数范围 0 到 65535。1024 以下很多是知名端口比如 80 是 HTTP、443 是 HTTPS。你自己写练习程序时通常选 1024 以上且没人占用的端口比如 12345、8080、9000 这类。写网络程序最烦的一种报错就是端口被占用。启动服务端时报java.net.BindException: Address already in use说明端口已经被别的进程占了。排查命令我后面会专门写这里你先记住一点服务端要绑定一个固定端口客户端只需要随机分配一个本地临时端口所以客户端主动连接时一般不关心自己用的是哪个端口。1.3 Java 网络编程到底能做什么学这块最怕空泛。你至少要能在脑子里列出几个可落地的场景多人在线聊天室一条消息从客户端发给服务端服务端广播给其他人。文件传输工具把文件切成多个数据块用 Socket 或者自定义协议传过去。HTTP 接口调用用 HttpURLConnection 或后来 Java 11 的 HttpClient 请求后端接口。RPC 框架的底层通信很多框架的消息收发本质就是 Socket 加自定义协议。网络爬虫简单版用 HTTP 客户端抓页面再解析。写这篇笔记时我会先把 TCP 的 Socket 流程讲清楚因为这是理解后面所有网络编程的基石。2. 从 TCP Socket 开始写第一份通信代码2.1 一次完整 TCP 通信的流程先别急着复制代码脑子里得有流程。服务端做的事情是创建一个 ServerSocket绑定端口然后不停调用 accept() 等待客户端连接。客户端做的事情是创建一个 Socket指定服务器 IP 和端口发起连接。accept() 是一个阻塞方法它会让当前线程停在那里直到有客户端连上来才返回一个 Socket 对象。这个返回的 Socket 才是真正和客户端传输数据的通道。连接建立之后就是普通的读写流操作了。用生活里的场景类比ServerSocket 像前台总机一直守着电话accept() 就是“有电话进来我就接然后把这个客户转给具体坐席”。坐席挂了电话前台还继续等着下一个电话。2.2 服务端示例与逐行解读下面这个是最原始的服务端一次只处理一个客户端public class TcpServer { public static void main(String[] args) throws Exception { ServerSocket serverSocket new ServerSocket(12345); System.out.println(服务端启动端口 12345); while (true) { Socket socket serverSocket.accept(); System.out.println(客户端连入 socket.getRemoteSocketAddress()); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); String line; while ((line in.readLine()) ! null) { System.out.println(收到客户端消息 line); out.println(服务端收到 line); } socket.close(); } } }逐行说几个重点。new ServerSocket(12345)做了两件事创建 Socket 并绑定端口。如果端口被占用这行就会抛异常。accept()返回的 Socket 是已连接的对象getRemoteSocketAddress()可以拿到客户端的 IP 和端口方便打印日志。读数据用了 BufferedReader 的 readLine()它按行读取所以客户端发送时要带换行符否则 readLine() 会一直等。写出时我加了第二个参数 true表示自动 flush不然 PrintWriter 会缓存客户端可能一直收不到消息。这个版本有个明显问题外层 while 每一轮只能处理一个客户端处理完才 accept 下一个。如果第一个客户端连上来之后不发消息也不断开第二个客户端就会卡在 accept() 上。2.3 客户端示例对应的客户端代码public class TcpClient { public static void main(String[] args) throws Exception { Socket socket new Socket(127.0.0.1, 12345); BufferedReader in new BufferedReader( new InputStreamReader(socket.getInputStream(), UTF-8)); PrintWriter out new PrintWriter( new OutputStreamWriter(socket.getOutputStream(), UTF-8), true); out.println(你好服务端); System.out.println(收到回复 in.readLine()); socket.close(); } }这段代码里有个隐藏问题in.readLine()会一直阻塞直到服务端发来一行数据。如果服务端异常没回复客户端就会卡死。所以生产代码里几乎一定要设置超时调用socket.setSoTimeout(3000)超过 3 秒没数据就抛 SocketTimeoutException借此可以及时退出或者重试。2.4 资源关闭和超时为什么是重点网络编程里出 bug 最多的不是我上面那些逻辑而是资源没关干净。Socket 底层占的是操作系统文件描述符数量是有限的。你每 new 一个 Socket不 close文件描述符就少一个。压测几次就会发现报Too many open files这时候程序基本废了。我建议养成两个习惯能用 try-with-resources 就用代码简洁关闭还可靠。如果要手写 close在 finally 里关并且先关输出流再关输入流最后关 Socket。超时也值得多说一句。连不上远端时默认的 TCP 连接超时可能很久。你可以在 connect 之前设置socket.connect(new InetSocketAddress(192.168.1.10, 8080), 3000);这样 3 秒连不上就直接报错用户不用傻等一两分钟。读数据超时则用 setSoTimeout它只对当前 Socket 的读操作生效。3. 让服务端真正“并发”多线程与线程池3.1 单线程服务端的问题前面的服务端代码在真实场景里基本不能用因为一个客户端没断开其他人全部排队。我最早练习时也踩了这个坑开了两个终端同时连服务端第二个客户端一直没反应还以为是代码写错了其实是第一个客户端阻塞了 accept 循环之后的处理逻辑。要解决这个问题思路很自然每来一个客户端就单独开一个线程去处理它主线程继续 accept 下一个连接。3.2 用线程池改写服务端直接 new Thread 写起来简单但客户端一多线程数爆炸系统先扛不住。更稳妥的方案是用线程池。改造后的服务端核心结构如下public class ThreadPoolServer { public static void main(String[] args) throws Exception { ServerSocket serverSocket new ServerSocket(12345); ExecutorService pool Executors.newFixedThreadPool(8); while (true) { Socket socket serverSocket.accept(); pool.submit(new ClientHandler(socket)); } } static class ClientHandler implements Runnable { private final Socket socket; ClientHandler(Socket socket) { this.socket socket; } Override public void run() { try (Socket s socket; BufferedReader in new BufferedReader( new InputStreamReader(s.getInputStream(), UTF-8)); PrintWriter out new PrintWriter( new OutputStreamWriter(s.getOutputStream(), UTF-8), true)) { String line; while ((line in.readLine()) ! null) { System.out.println(Thread.currentThread().getName() 收到 line); out.println(服务端回显 line); } } catch (IOException e) { e.printStackTrace(); } } } }newFixedThreadPool(8)表示最多同时执行 8 个客户端任务。第 9 个客户端连进来后连接会被保存到任务队列等前面的处理完再执行。这样可以防止线程数量无限增长。3.3 并发下的共享状态与坑线程池版本能解决并发连接但如果你在 Handler 里访问共享变量就要注意线程安全。比如统计在线人数private final AtomicInteger onlineCount new AtomicInteger(); // 连接建立时 onlineCount.incrementAndGet(); // 连接关闭时 onlineCount.decrementAndGet();用普通 int 在多线程下会有可见性问题最好用 AtomicInteger 或者加锁。还有几个容易忽略的点客户端断开时readLine() 返回 null循环结束但你不一定知道客户端是正常断开还是异常断开。服务端可以在 finally 里做清理。线程池如果用 newCachedThreadPool短连接场景没问题长连接且量大时可能导致线程无限创建。固定线程池更可控。服务端处理某个客户端逻辑时抛异常不能影响 accept 主循环。你可以在 run() 里 catch 掉主线程继续接客。4. HTTP 客户端编程与 URL 处理4.1 URL、URLConnection 与 HttpURLConnection 的区别Socket 是底层传输但平时大家很少直接拿 Socket 访问网页更多是用 HTTP。Java 里最原始的一套类就是 URL 和 URLConnection。URL 负责表示一个网络地址URLConnection 是连接的抽象实际用的时候通常强转成 HttpURLConnection因为只有它提供了设置请求方法、响应码这些 HTTP 特有的能力。如果你用的是 Java 11 及以上我更推荐官方后来推出的 HttpClientAPI 更顺手还支持异步。不过这里仍然讲 HttpURLConnection因为很多老项目还在用而且它能帮助你理解 HTTP 请求的本质。4.2 一个请求网页内容的示例下面是用 HttpURLConnection 发起 GET 请求的例子public class HttpClientDemo { public static String get(String urlStr) throws Exception { HttpURLConnection conn (HttpURLConnection) new URL(urlStr).openConnection(); conn.setRequestMethod(GET); conn.setConnectTimeout(3000); conn.setReadTimeout(3000); conn.setRequestProperty(User-Agent, Mozilla/5.0); int code conn.getResponseCode(); System.out.println(响应码 code); if (code ! 200) { conn.disconnect(); return HTTP code; } StringBuilder sb new StringBuilder(); try (BufferedReader reader new BufferedReader( new InputStreamReader(conn.getInputStream(), UTF-8))) { String line; while ((line reader.readLine()) ! null) { sb.append(line).append(\n); } } finally { conn.disconnect(); } return sb.toString(); } public static void main(String[] args) throws Exception { System.out.println(get(https://example.com)); } }这里有几个关键点。openConnection()这个动作并不会真的发起网络请求只有调用 getResponseCode() 或 getInputStream() 时请求才真正发出。这是一个容易概念混淆的地方。设置 User-Agent 也很有必要。很多服务端会拒绝没有浏览器标识的默认 Java 请求你放一个正常浏览器的 User-Agent 可以避免一部分拦截。4.3 HTTP 响应解析与常见响应码判断请求是否成功不能只看有没有返回内容还得看响应码。常见的状态码200成功。301、302资源被临时或永久重定向需要处理 Location 头或者让 HttpURLConnection 自动跟随。403没有权限访问。404资源不存在。500服务端内部错误。默认情况下HttpURLConnection 对重定向是自动跟随的但如果你用 POST 请求部分场景需要手动处理。你可以这样读取重定向地址String location conn.getHeaderField(Location);如果服务端返回 403 或 500getInputStream()会抛 IOException这时候有些信息在getErrorStream()里需要从错误流中读取。很多新手写爬虫时在这里栽跟头只写了 getInputStream没有处理非 200 的情况。4.4 生产环境更推荐什么如果只是学原理HttpURLConnection 够了。但如果真要做一个小工具我更建议使用 Java 11 的 HttpClient 或者第三方请求库。原始 HttpURLConnection 写起来啰嗦对连接池、超时、上传下载的支持都比较原始。不过我还是建议你把原理吃透。很多奇怪的线上问题比如代理设置、DNS 缓存、连接复用都跟底层实现有关。你理解了请求是怎么发出去的排查问题时就不会瞎猜。5. 粘包、拆包与自定义协议设计5.1 为什么会粘包和拆包TCP 是面向字节流的协议它不保证一次 write 的数据会被对端一次性 read 出来。你可以把它想象成一根水管你倒进去的是三杯水但到了另一端可能混成了一杯也可能倒一杯水要被接成两杯。这在网络编程里就叫粘包和拆包。粘包是多个小消息被合并到了一个包拆包是一条大消息被分成了多段。很多人刚写 TCP 时会遇到这种诡异现象客户端发了两条消息服务端一次 readLine 读出来两行或者明明发了一条很长的消息服务端却只读到前半段。根本原因在于 TCP 没有“消息边界”。它只负责把收到的字节按照顺序交给你至于这些字节应该分成几段TCP 不管。UDP 则是有消息边界的一个数据报就是一条完整消息所以 UDP 反而很少遇到这种问题。5.2 四种常见解决方案既然 TCP 不帮你划分边界那应用层就得自己设计。常见方案有四种方案思路优点缺点固定长度每条消息都用固定字节数比如 1024 字节实现简单浪费空间短消息也占满长度分隔符消息末尾加换行符或者特殊字符简单直观消息内容不能包含分隔符长度前缀前面用 2 或 4 字节表示消息体长度灵活业界常用需要多读一步自定义协议魔数、版本、类型、长度等字段组合可扩展性强设计复杂实际项目里用得最多的是长度前缀。它既能精确切分消息又能支持变长内容是很多 RPC 和消息框架的标准做法。5.3 一个长度前缀协议的最小实现下面用 DataOutputStream 和 DataInputStream 演示。发送端先写一个 int 表示长度再写消息体。发送端片段Socket socket new Socket(127.0.0.1, 12345); DataOutputStream out new DataOutputStream(socket.getOutputStream()); String message 这是第一条消息; byte[] bytes message.getBytes(UTF-8); out.writeInt(bytes.length); out.write(bytes); out.flush();接收端片段DataInputStream in new DataInputStream(socket.getInputStream()); int length in.readInt(); byte[] buffer new byte[length]; in.readFully(buffer); String message new String(buffer, UTF-8); System.out.println(收到消息 message);这里最关键的是readFully。如果你自己用普通 read 循环去读可能只读了部分字节就返回readFully 会保证读满 length 个字节才返回正好配合长度前缀。写这段代码时还有两个常见问题如果用 BufferedReader 包了一层再想读字节流就会很麻烦所以做二进制协议时直接用 DataInputStream 更省心。writeInt 写的是大端序的 4 字节整数两端一致就行。如果跨语言通讯要确认另一端也按同一字节序解析。5.4 实际业务中的协议设计建议到了真正的业务系统里长度前缀通常还不够。较为稳妥的协议头至少包含魔数比如 0x12345678用来快速判断是不是合法数据。版本号方便以后升级协议。消息类型请求还是响应是心跳还是业务数据。消息体长度用来切分消息。可选的序列化方式比如 JSON 还是二进制。协议设计最忌讳“随手写、随便改”。一旦上线客户端和服务端版本不同步错误解析会引发各种玄学问题。建议在设计文档里把字节顺序、字段长度、字符编码都写清楚。另外如果你做的是长连接最好加心跳机制。客户端定时发一个小包服务端收到后返回。这样能及时发现断线也能防止中间设备因为空闲太久把连接回收掉。6. 常见问题与排查技巧实录6.1 连接 refused 还是 timeout 该怎么判断网络异常信息永远是排查的第一线索。最常见的两种异常Connection refused的意思是连接请求被拒绝。通常是对端机器存在但对应端口没有程序在监听或者防火墙直接把请求回绝了。你先确认服务端有没有启动再用 telnet 或系统自带的工具试一下端口通不通。Connection timed out的意思是请求发出去了但长时间没有响应。这常见于网络不可达、对端防火墙把包悄悄丢掉、或者对端服务忙碌没来得及握手。你可以用一个简单的命令测试端口通不通telnet 192.168.1.10 8080如果端口通会进入连接成功状态如果显示连接失败或卡住问题基本不在 Java 代码而在网络环境。很多程序员一碰到连不上就开始改代码其实先分层排查更高效。6.2 端口占用如何快速定位服务端报 BindException 时先查端口。Windows 上可以执行netstat -ano | findstr 12345Linux 上可以执行netstat -nltp | grep 12345如果你知道进程名也可以直接用lsof -i :12345得到进程号之后判断是不是自己的旧进程残留。如果是可以直接结束进程然后重新启动服务端。还有个小经验你写练习代码时经常按一次运行就启动一个服务端老进程没关掉新进程自然绑不上端口。IDE 里明明只启动了一次但上次调试中断没销毁线程一样会占着端口。遇到这种情况先把所有 Java 进程列出来再排除。6.3 最容易忽略的三个小细节第一写完数据忘记 flush。PrintWriter 如果不开启 autoFlush数据可能还在缓冲区对方当然收不到。排查这类问题先检查有没有 flush 或 close。第二客户端和服务端的字符编码不一致。一边用 UTF-8一边用 GBK中文就会变成乱码。我建议两边统一使用 UTF-8并且明确指定编码不要依赖系统默认编码。第三关闭顺序和异常吞掉。有些代码 catch 之后只 e.printStackTrace()根本不打印完整堆栈问题就被掩盖了。排查网络问题时日志宁可多打也不能省。6.4 用抓包手段看清数据流如果你觉得网络问题像玄学推荐你学会用抓包工具看底层数据。发一个请求在抓包工具里看 TCP 三次握手是否完成数据包有没有正常发出重传是不是很频繁。看到这些很多问题就一目了然。不过也要提醒一句抓包看到的不只是你应用层的数据还包括底层的 ACK、SYN 等控制包。刚开始可能觉得乱不用怕先关注几条关键信息源端口、目标端口、数据段长度、标志位。我个人做网络编程排查时会按这个顺序来先看日志里有没有异常再看端口通不通接着用抓包工具确认数据有没有发出去最后才怀疑代码逻辑。大部分“连不上”最终都指向防火墙、安全组、端口占用而不是 Java 本身。说实话网络编程这块内容多但真正需要掌握的硬通货就是 Socket 读写、并发处理、HTTP 调用和协议设计这几个方向。我练的时候最笨的办法就是故意把服务端搞挂、把粘包调出来然后一个个解决。踩过坑之后那些概念才真正变成自己的。你学完今天这些内容可以试着给自己的小项目加一个心跳机制或者做一个简单的文件传输工具比单纯背 API 有用得多。
阅读完成 · 觉得有帮助?
咨询建站