1. 文章
  2. Java 进阶线
  3. 网络、框架与复习

网络编程:UDP、TCP 与文件上传协议

更新于 34 分钟阅读 第 13 / 15 站 #java#java-advanced#day13

理解通信端点、消息边界、半关闭与多客户端处理。

我的学习 →

Java 进阶线 · 线路目录
本页导读

day13|java.net 包下的常用 API:网络编程

本日进入 java.net 包:先补上”网络通信要什么”的前置概念(CS/BS 架构、IP、端口、协议),再用两个协议把程序真正连起来——UDP 用 DatagramSocket/DatagramPacket 发数据包(无连接、不可靠、一次 64KB 内),TCP 用 Socket/ServerSocket 建可靠连接(三次握手、全双工、字节流)。学完后应能独立写出四类程序:UDP 一发一收与多发多收、TCP 一发一收与多发多收、支持多客户端并发的服务端(主线程 accept() + 子线程/线程池)、以及用 TCP 手写一个能被浏览器识别的简易 BS 服务端(按 HTTP 响应格式返回数据)。

本日 PPT 目录(第 9 页给出总纲,第 19/23/37/43/47/55/58/62/65 页作为进度锚点反复出现):网络通信三要素(IP 地址、端口号、协议)→ UDP 通信(快速入门、多发多收)→ TCP 通信(快速入门、多发多收、支持与多个客户端同时通信、综合案例:即时通信-群聊 / 简易版 BS 架构),最后附加拓展知识”用线程池优化服务端”。

三点说明(供复习时核对):

  • 本日共 73 页幻灯片。第 17、25、39、49、52 页的”常用方法”表格在抽取文本里只剩 [表格] 占位、没有表体,这些表格本文按课件提问页与配套示例工程(day13-网络编程/代码/day13-net,包 com.itheima.d1~d8)还原,还原处标注”(表格按课件提问页与配套示例整理)”。
  • PPT 抽出的 56 张图片全部逐张查看过:其中 4 张是损坏/空白占位图(day13-s48-44/45.png 162/244 字节、day13-s72-54/55.png 250/164 字节),其余绝大多数是装饰文字图(“客户端""服务器端""通信效率高!""视频直播”)、卡通人物、表情包与图库照片。本日没有任何代码截图、结构图或控制台截图,因此只归档了 8 张有说明价值的(第 2、6、29、38 页),这是本日配图偏少的原因。
  • 代码按课件顺序重新拼接并规范缩进,示例类名沿用课件的命名(Client/Server、Client1/Server1、ServerReaderThread)。

1. 什么是网络编程

1.1 定义与落点

课件第 2 页的定义:网络编程可以让设备中的程序与网络上其他设备中的程序进行数据交互(实现网络通信)。课上配的例子是两台手机上的聊天软件互发消息——真正在交换数据的是”一个设备里的程序”和”另一个设备里的程序”。

第 3 页给出落点:java.net.* 包下提供了网络编程的解决方案,本日所有类都来自这个包(InetAddress、DatagramSocket、DatagramPacket、Socket、ServerSocket)。

两台设备上的聊天程序在互发消息,这就是网络编程

全球设备互联示意,网络编程的目标是让不同设备上的程序交换数据

1.2 网络编程与 CS/BS 的关系

第 8 页一句话总结:无论是 CS 架构、还是 BS 架构的软件,都必须依赖网络编程。

2. 基本的通信架构:CS 与 BS

课件第 4–7 页:基本的通信架构有 2 种形式——CS 架构(Client 客户端 / Server 服务端)、BS 架构(Browser 浏览器 / Server 服务端)。两者的差别只在”客户端那一端是谁”:

架构客户端侧客户端侧是否需要程序员开发客户端侧用户需要安装什么服务端
CS(Client-Server)Client 客户端需要程序员开发实现需要安装客户端程序需要程序员开发实现
BS(Browser-Server)Browser 浏览器不需要程序员开发实现需要安装浏览器需要程序员开发实现

一句话记忆:BS 省掉了客户端开发(浏览器是现成的,用户装了浏览器就能用),而两边架构的服务端都得自己写——所以本日的重点全是服务端。

课件第 6 页用三个真实的浏览器页面举例说明”BS 的客户端就是一个浏览器”:

浏览器访问京东页面,BS 架构中客户端就是浏览器

浏览器访问 bilibili,同一台电脑上的浏览器可以访问不同服务端

浏览器访问企业内部 OA 系统(oa.itcast.cn),BS 架构的典型企业应用

3. 网络通信三要素

3.1 三要素的分工

课件第 10 页:

要素作用(课件原文)
IP设备在网络中的地址,是唯一的标识
端口应用程序在设备中唯一的标识
协议连接和数据在网络中传输的规则

第 11 页用一张示意图说明三者如何配合:一台设备(IP 地址 1)上某个程序用端口(6666/7777/8888)发出数据”你瞅啥?“,数据里必须写明目的地 IP(IP 地址 2)+ 目的地端口(8888),并且收发双方都按同一个协议来解释这串数据。缺任何一样,数据都到不了或读不懂。

第 16 页小结问答:

问题答案
网络通信至少需要几个要素IP、端口、协议
IP 地址是做什么的,具体有几种定位网络上的设备;有 IPv4、IPv6
如何查看本机 IP 地址,如何看是否与对方互通ipconfig;ping 192.168.10.23
本机 IP 是谁127.0.0.1 或者是 localhost

3.2 IP 地址:IPv4 与 IPv6

IP(Internet Protocol):全称”互联网协议地址”,是分配给上网设备的唯一标志。IP 地址有两种形式:

版本长度表示法例子
IPv432bit(4 字节)点分十进制192.168.1.66
IPv6128 位冒分十六进制(分 8 段,每段每 4 位编码成一个十六进制位,段间用冒号分开)2001:0db8:0000:0023:0008:0800:200c:417a

课件第 12 页给了 IPv4 的二进制到点分十进制的换算示例:11000000 10101000 00000001 01000010 → 192.168.1.66。第 13 页对 IPv6 的评价是”号称可以为地球每一粒沙子编号”。

3.3 域名与 DNS

课件第 14 页:域名是为了让人不必记 IP。在浏览器里输入 http://www.itcast.cn(或 http://www.itheima.com)时,计算机先把域名交给 DNS 服务器解析成 IP 地址,再访问对应的服务器,服务器返回的数据最终展示在浏览器上。

(补充说明)课件这一页画的是”计算机 → DNS 服务器 → 目标服务器”的链路;DNS 只负责”域名换成 IP”这一步,换到 IP 之后的通信仍然要遵守 IP、端口、协议三要素。

3.4 特殊 IP 与常用命令

课件第 15 页:

  • 127.0.0.1、localhost:代表本机 IP,只会寻找当前所在的主机。
  • 公网 IP:可以连接互联网的 IP 地址;内网 IP(也叫局域网 IP):只能组织机构内部使用。
  • 192.168. 开头的就是常见的局域网地址,范围即 192.168.0.0--192.168.255.255,专门为组织机构内部使用。
  • ipconfig:查看本机 IP 地址。
  • ping IP地址:检查网络是否连通。

3.5 InetAddress 类

课件第 17–18 页:InetAddress 代表 IP 地址。

常用方法(表格按课件提问页与配套示例整理):

方法说明
public static InetAddress getLocalHost()获取本机 IP 地址对象
public static InetAddress getByName(String host)按域名(或 IP 文本)获取对方主机的 IP 地址对象
public String getHostAddress()获取 IP 地址字符串
public String getHostName()获取主机名
public boolean isReachable(int timeout)判断与该 IP 地址对象是否互通,单位毫秒(相当于 ping)

配套示例 InetAddressDemo1:

package com.itheima.d1_inetAddress;

import java.net.InetAddress;

public class InetAddressDemo1 {
    public static void main(String[] args) throws Exception {
        // 目标:InetAddress类代表IP地址对象,用来操作IP地址。
        // 1、获取本机IP地址对象
        InetAddress ip = InetAddress.getLocalHost();
        System.out.println(ip.getHostAddress());
        System.out.println(ip.getHostName());

        // 2、指定获取对方主机的IP地址对象
        InetAddress ip2 = InetAddress.getByName("www.baidu.com");
        System.out.println(ip2.getHostAddress());
        System.out.println(ip2.getHostName());

        // 3、判断本机与该主机是否能够联通:ping
        System.out.println(ip2.isReachable(5000));
    }
}

课件第 18 页的两个考点:如何获取本机 IP 对象 → getLocalHost();如何判断与该 IP 地址对象是否互通 → isReachable(int timeout)。

3.6 端口

课件第 21 页:端口是标记正在计算机设备上运行的应用程序的,被规定为一个 16 位的二进制,范围是 0~65535。

分类范围说明
周知端口0~1023被预先定义的知名应用占用(如 HTTP 占用 80,FTP 占用 21)
注册端口1024~49151分配给用户进程或某些应用程序
动态端口49152~65535一般不固定分配某种进程,而是动态分配

注意(课件原文):我们自己开发的程序一般选择使用注册端口,且一个设备中不能出现两个程序的端口号一样,否则出错。

第 22 页小结问答:

问题答案
端口号的作用是什么唯一标识正在计算机设备上运行的进程(程序)
一个设备中能否出现 2 个应用程序的端口号一样,为什么不可以,如果一样会出现端口冲突错误

后面所有示例都把服务端端口写在代码里(UDP 用 8888,TCP 用 9999,BS 用 8080),都属于注册端口;客户端创建 DatagramSocket()/Socket(...) 时由系统分配本地端口。

3.7 通信协议

课件第 24 页:网络上通信的设备,事先规定的连接规则,以及传输数据的规则被称为网络通信协议。

第 25 页对比两个模型:

  • OSI 网络参考模型:全球网络互联标准。
  • TCP/IP 网络模型:事实上的国际标准。

(补充说明)课件此页的对比表格未随文本导出,只保留了上面两句结论。补充常见的对应关系:OSI 自上而下为应用层、表示层、会话层、传输层、网络层、数据链路层、物理层;TCP/IP 模型通常压成应用层、传输层、网际层、网络接口层。本日讲的 UDP/TCP 位于其中的传输层。

4. 传输层的两个通信协议:UDP 与 TCP

课件第 26、27、33 页反复讲同一段内容,是后面所有代码的共同前提。

4.1 UDP 协议

UDP(User Datagram Protocol):用户数据报协议。特点:无连接、不可靠通信。

  • 不事先建立连接,数据按照包发。一包数据包含:自己的 IP、程序端口,目的地 IP、程序端口和数据(限制在 64KB 内)等。
  • 发送方不管对方是否在线,数据在中间丢失也不管,如果接收方收到数据也不返回确认,故是不可靠的。
  • 课件第 26 页把 UDP 的典型场景标为”视频直播""语音通话”,配的评价是”通信效率高!”。

4.2 TCP 协议

TCP(Transmission Control Protocol):传输控制协议。特点:面向连接、可靠通信。

  • TCP 的最终目的:要保证在不可靠的信道上实现可靠的传输。
  • 课件说法:TCP 主要有三个步骤实现可靠传输——三次握手建立连接,传输数据进行确认,四次挥手断开连接。
  • 第 33 页对 TCP 的评价是”通信效率相对不高!”。

4.3 三次握手建立可靠连接

课件第 28、30 页给出的三步:

  1. 发出连接请求;
  2. 返回一个响应;
  3. 再次发出确认信息,连接建立。

可靠连接的含义(课件原文):确定通信双方,收发消息都是正常无问题的(全双工)。

第 29 页用生活化的比喻讲这三步:你瞅啥?→ 瞅你咋地?→ 走,咱俩唠唠 → 于是两人关系确立——先两次互相确认”你在、我也在”,第三步才真正建立关系。

课件用生活化对话比喻 TCP 三次握手,确认双方都在线后才建立连接

4.4 四次挥手断开连接

课件第 31 页给出的四步:

  1. 发出断开连接请求;
  2. 返回一个响应:稍等(服务器将最后的数据处理完毕);
  3. 发出正式确认断开连接;
  4. 返回一个响应:确认断开。

目的(课件原文):确保双方数据的收发都已经完成!

第 32 页的比喻:我要走啦 / 好的,稍等 / 拿着苹果路上吃 / 爱过,溜啦! / 给你塞了两个苹果。

课件说法:这一步叫”四次握手断开连接”;更准确的表述:断开连接的过程通常称为”四次挥手”,“握手”一般专指建立连接。

4.5 第 34–36 页小结

问题课件答案
通信协议是什么计算机网络中,连接和通信数据的规则被称为网络通信协议
TCP 通信协议的特点面向连接的可靠通信协议;传输前采用”三次握手”方式建立连接,点对点的通信,所以可靠;连接中可进行大数据量的传输;通信效率较低
UDP 协议的特点用户数据报协议;面向无连接、不可靠传输的通信协议;速度快,有大小限制一次最多发送 64K,数据不安全,易丢失数据

课件第 35 页把 TCP 说成”点对点的通信”;更准确的表述是”端到端”(点对点通常指物理链路上的直接连接)。

5. UDP 通信

5.1 特点与两个核心类

第 38 页:UDP 无连接、不可靠通信。不事先建立连接;发送端每次把要发送的数据(限制在 64KB 内)、接收端 IP 等信息封装成一个数据包,发出去就不管了。Java 提供了 java.net.DatagramSocket 类来实现 UDP 通信。

课件用”扔韭菜”贯穿这一节,对照关系是:

角色比喻类/对象
发送端(客户端)扔韭菜的人DatagramSocket(无参构造)
接收端(服务端)接韭菜的人DatagramSocket(int port)
数据包韭菜盘子DatagramPacket

课件用'扔韭菜的人'比喻 UDP 的发送端(客户端)

课件用'韭菜'比喻 UDP 中的数据包,扔出去就不管了

DatagramSocket:用于创建客户端、服务端(表格按课件提问页与配套示例整理):

构造器/方法说明
public DatagramSocket()创建发送端的 Socket 对象(默认会分配端口)
public DatagramSocket(int port)创建接收端的 Socket 对象(注册端口)
public void send(DatagramPacket dp)发送数据包
public void receive(DatagramPacket dp)接收数据包(没有收到数据时会暂停等待)
public void close()释放资源

DatagramPacket:创建数据包(表格按课件提问页与配套示例整理):

构造器/方法说明
public DatagramPacket(byte[] buf, int length)创建接收数据用的数据包对象(准备一个缓冲区)
public DatagramPacket(byte[] buf, int length, InetAddress address, int port)创建发送数据用的数据包对象(写明目的地 IP 与端口)
public int getLength()获取本轮收到的字节数
public InetAddress getAddress()获取发送端的 IP 地址对象
public int getPort()获取发送端的端口

第 42 页提问页同样落在这几个点上:创建发送端 → DatagramSocket();创建接收端 → DatagramSocket(int port);数据包对象 → DatagramPacket;发送/接收 → send(DatagramPacket) / receive(DatagramPacket)。

5.2 一发一收

客户端(发送端)步骤(第 40 页):

  1. 创建 DatagramSocket 对象(客户端对象)——扔韭菜的人;
  2. 创建 DatagramPacket 对象封装需要发送的数据(数据包对象)——韭菜盘子;
  3. 使用 DatagramSocket 对象的 send 方法,传入 DatagramPacket 对象——开始抛出韭菜;
  4. 释放资源。
package com.itheima.d2_udp1;

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

/**
 * 需求:一发 一收。
 */
public class Client {
    public static void main(String[] args) throws Exception {
        // 目标:客户端实现(发送端)
        // 1、创建发送端对象(抛韭菜的人)
        DatagramSocket socket = new DatagramSocket(); // 默认会分配端口

        // 2、创建一个数据包对象,负责封装要发送的数据。(盘子)
        /**
         * 参数一:要发送的数据,字节数组
         * 参数二:发送的数据大小
         * 参数三:目的地IP地址
         * 参数四:接收端端口号
         */
        byte[] buffer = "今晚一起啤酒、龙虾、小烧烤,约吗??".getBytes();
        DatagramPacket packet = new DatagramPacket(buffer, buffer.length, InetAddress.getLocalHost(),
                8888); // 盘子

        // 3、把这一包数据发出去(抛出去韭菜)
        socket.send(packet);

        // 4、释放资源
        socket.close();

        System.out.println("客户端已经发送完毕!");
    }
}

服务端(接收端)步骤(第 41 页):

  1. 创建 DatagramSocket 对象并指定端口(服务端对象)——接韭菜的人;
  2. 创建 DatagramPacket 对象接收数据(数据包对象)——韭菜盘子;
  3. 使用 DatagramSocket 对象的 receive 方法,传入 DatagramPacket 对象——开始接收韭菜;
  4. 释放资源。
package com.itheima.d2_udp1;

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

public class Server {
    public static void main(String[] args) throws Exception {
        System.out.println("===服务器启动成功===");
        // 目标:开始开发一个服务端程序。(接收端)
        // 1、创建接收端对象,注册端口(接收端人)
        DatagramSocket socket = new DatagramSocket(8888);

        // 2、创建一个数据包对象(韭菜盘子)
        byte[] buffer = new byte[1024 * 64];
        DatagramPacket packet = new DatagramPacket(buffer, buffer.length);

        // 3、接收数据(接收韭菜)
        socket.receive(packet); // 暂停等待!!

        // 4、把数据输出
        // 获取本轮收到多少字节。
        int len = packet.getLength();

        String msg = new String(buffer, 0, len);
        System.out.println(msg);

        // 获取发送端的IP和端口!!
        InetAddress ip = packet.getAddress();
        System.out.println("对方ip:" + ip.getHostAddress());
        System.out.println("对方端口:" + packet.getPort());

        // 5、释放资源
        socket.close();
    }
}

两个必须记住的细节:

  • receive(packet) 会暂停等待(阻塞),直到真的收到一个数据包才继续往下走。
  • 还原字符串必须用 packet.getLength() 取本轮实际字节数,再 new String(buffer, 0, len);直接用 buffer.length 会把缓冲区里的多余内容(\0)一起读出来。

5.3 多发多收

第 44 页:客户端可以反复发送数据

  1. 创建 DatagramSocket 对象(发送端对象);
  2. 使用 while 死循环不断接收用户的数据输入,如果用户输入的 exit 则退出程序;
  3. 如果用户输入的不是 exit,把数据封装成 DatagramPacket;
  4. 使用 DatagramSocket 对象的 send 方法将数据包对象进行发送;
  5. 释放资源。
package com.itheima.d3_udp2;

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

/**
 * 需求:多发多收
 */
public class Client {
    public static void main(String[] args) throws Exception {
        // 目标:客户端实现(发送端)
        // 1、创建发送端对象(抛韭菜的人)
        DatagramSocket socket = new DatagramSocket(); // 默认会分配端口

        Scanner sc = new Scanner(System.in);
        while (true) {
            // 2、创建一个数据包对象,负责封装要发送的数据。(盘子)
            System.out.println("请说:");
            String msg = sc.nextLine();

            if ("exit".equals(msg)) {
                System.out.println("退出成功!");
                socket.close();
                break;
            }

            byte[] buffer = msg.getBytes();
            DatagramPacket packet = new DatagramPacket(buffer, buffer.length, InetAddress.getLocalHost(),
                    8888); // 盘子

            // 3、把这一包数据发出去(抛出去韭菜)
            socket.send(packet);
        }
    }
}

第 45 页:接收端可以反复接收数据

  1. 创建 DatagramSocket 对象并指定端口(接收端对象);
  2. 创建 DatagramPacket 对象接收数据(数据包对象);
  3. 使用 DatagramSocket 对象的 receive 方法传入 DatagramPacket 对象;
  4. 使用 while 死循环不断的进行第 3 步。
package com.itheima.d3_udp2;

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

public class Server {
    public static void main(String[] args) throws Exception {
        System.out.println("===服务器启动成功===");
        // 目标:开始开发一个服务端程序。(接收端)
        // 1、创建接收端对象,注册端口(接收端人)
        DatagramSocket socket = new DatagramSocket(8888);

        // 2、创建一个数据包对象(韭菜盘子)
        byte[] buffer = new byte[1024 * 64];
        DatagramPacket packet = new DatagramPacket(buffer, buffer.length);

        while (true) {
            // 3、接收数据(接收韭菜)
            socket.receive(packet); // 暂停等待!!

            // 4、把数据输出
            // 获取本轮收到多少字节。
            int len = packet.getLength();
            String msg = new String(buffer, 0, len);
            System.out.println("收到:" + msg);

            // 获取发送端的IP和端口!!
            InetAddress ip = packet.getAddress();
            System.out.println("对方ip:" + ip.getHostAddress());
            System.out.println("对方端口:" + packet.getPort());
            System.out.println("-------------------------------------------------------------");
        }
    }
}

第 46 页提问:UDP 的接收端为什么可以接收很多发送端的消息? 课件答案:接收端只负责接收数据包,无所谓是哪个发送端的数据包——每个数据包里自带发送端的 IP 与端口,接收端靠 packet.getAddress() / packet.getPort() 事后才知道是谁发的,所以 UDP 接收端天然没有”客户端列表”的概念。

6. TCP 通信

6.1 特点与 Socket 管道

第 48 页:TCP 面向连接、可靠通信。通信双方事先会采用”三次握手”方式建立可靠连接,实现端到端的通信;底层能保证数据成功传给服务端。Java 提供了 java.net.Socket 类来实现 TCP 通信。

课件示意图把一条连接画成两端各有一个 socket(端点),中间是通道,并且每一端都有字节输出流(发)和字节输入流(收)——这正是第 28 页说的”全双工”:同一时刻双方都能发也能收。

6.2 客户端:Socket

Socket(表格按课件提问页与配套示例整理):

构造器/方法说明
public Socket(String host, int port)创建客户端 Socket 对象,同时请求与服务端建立连接
public OutputStream getOutputStream()获得字节输出流对象(发)
public InputStream getInputStream()获得字节输入流对象(收)
public InetAddress getInetAddress()获取连接的对端 IP 地址对象
public int getPort()获取连接的对端端口
public void close()关闭 socket 管道,释放资源

客户端实现步骤(第 50 页):

  1. 创建客户端的 Socket 对象,请求与服务端的连接;
  2. 使用 socket 对象调用 getOutputStream() 方法得到字节输出流;
  3. 使用字节输出流完成数据的发送;
  4. 释放资源:关闭 socket 管道。

第 51 页考点:TCP 通信客户端的代表类 → Socket;创建方式 → public Socket(String host, int port);发/收数据 → getOutputStream()(发)、getInputStream()(收)。

6.3 服务端:ServerSocket

ServerSocket(表格按课件提问页与配套示例整理):

构造器/方法说明
public ServerSocket(int port)注册服务端端口
public Socket accept()阻塞等待客户端的连接请求,并得到 Socket 管道对象

服务端实现步骤(第 53 页):

  1. 创建 ServerSocket 对象,注册服务端端口;
  2. 调用 ServerSocket 对象的 accept() 方法,等待客户端的连接,并得到 Socket 管道对象;
  3. 通过 Socket 对象调用 getInputStream() 方法得到字节输入流、完成数据的接收;
  4. 释放资源:关闭 socket 管道。

第 54 页考点:服务端用的类 → ServerSocket(注册端口),调用 accept() 阻塞等待接收客户端连接,得到 Socket 对象。

一发一收的完整代码(配套示例 d4_tcp1):

package com.itheima.d4_tcp1;

import java.io.DataOutputStream;
import java.io.OutputStream;
import java.net.Socket;

public class Client1 {
    public static void main(String[] args) throws Exception {
        // 目标:TCP通信客户端开发。 一发一收
        // 1、创建socket管道与服务端建立链接
        Socket socket = new Socket("127.0.0.1", 9999);

        // 2、从socket管道中得到一个字节输出流,往外写数据出去。
        OutputStream os = socket.getOutputStream();

        // 3、把低级流包装成特殊数据输出流
        DataOutputStream dos = new DataOutputStream(os);

        // 4、发数据出去给服务端
        dos.writeInt(12);
        dos.writeUTF("服务端您好,我是骚气的客户端,请问约吗?6666~~~~");
        dos.flush();

        // 5、释放资源
        socket.close();
    }
}
package com.itheima.d4_tcp1;

import java.io.DataInputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class Server1 {
    public static void main(String[] args) throws Exception {
        System.out.println("==服务端启动===");
        // 目标:掌握TCP通信服务端的开发。
        // 1、注册端口
        ServerSocket serverSocket = new ServerSocket(9999);

        // 2、阻塞等待客户端的socket链接请求
        Socket socket = serverSocket.accept();

        // 3、从通信管道socket中得到一个字节输入流
        InputStream is = socket.getInputStream();

        // 4、把字节输入流包装成特殊数据输入流
        DataInputStream dis = new DataInputStream(is);

        // 5、收数据
        System.out.println("收到1:" + dis.readInt());
        System.out.println("收到2:" + dis.readUTF());

        // 6、释放资源
        socket.close();
    }
}

要点:客户端与服务端必须使用成套的读写方法——客户端 writeInt/writeUTF,服务端就要 readInt/readUTF(DataInputStream/DataOutputStream 在上一天已经学过);accept() 会阻塞等待,没有客户端连上时程序就停在这一行;os/is 都是 socket 管道里的字节流,数据要 flush() 才会真正发出去。

6.4 多发多收

第 56 页:客户端使用死循环,让用户不断输入消息;服务端也使用死循环,控制服务端收完消息,继续等待接收下一个消息。

第 57 页小结:客户端使用循环反复地发送消息;服务端使用循环反复地接收消息。

package com.itheima.d5_tcp2;

import java.io.DataOutputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;

public class Client1 {
    public static void main(String[] args) throws Exception {
        // 目标:TCP通信客户端开发。 多发多收
        // 1、创建socket管道与服务端建立链接
        Socket socket = new Socket("127.0.0.1", 9999);

        // 2、从socket管道中得到一个字节输出流,往外写数据出去。
        OutputStream os = socket.getOutputStream();

        // 3、把低级流包装成特殊数据输出流
        DataOutputStream dos = new DataOutputStream(os);

        Scanner sc = new Scanner(System.in);
        while (true) {
            // 4、发数据出去给服务端
            System.out.println("请说:");
            String msg = sc.nextLine();

            if ("exit".equals(msg)) {
                System.out.println("退出成功!");
                socket.close();
                break;
            }

            dos.writeUTF(msg);
            dos.flush();
        }
    }
}
package com.itheima.d5_tcp2;

import java.io.DataInputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class Server1 {
    public static void main(String[] args) throws Exception {
        try {
            System.out.println("==服务端启动===");
            // 1、注册端口
            ServerSocket serverSocket = new ServerSocket(9999);

            // 2、阻塞等待客户端的socket链接请求
            Socket socket = serverSocket.accept();
            System.out.println("上线了~~~");

            // 3、从通信管道socket中得到一个字节输入流
            InputStream is = socket.getInputStream();

            // 4、把字节输入流包装成特殊数据输入流
            DataInputStream dis = new DataInputStream(is);

            while (true) {
                // 5、收数据
                System.out.println("收到:" + dis.readUTF());
                System.out.println("谁发的:" + socket.getInetAddress().getHostAddress());
                System.out.println("它的端口:" + socket.getPort());
                System.out.println("------------------------------------------------------");
            }
        } catch (Exception e) {
            System.out.println("下线了~~~");
        }
    }
}

注意这里的”多发多收”其实只有一条连接(一个客户端对一个服务端):循环让同一条连接上可以反复收发。客户端断开时服务端会抛异常,所以课件用 catch 打印”下线了~~~”。

6.5 支持与多个客户端同时通信

第 59 页提问:目前我们开发的服务端程序,是否可以支持与多个客户端同时通信?不可以。因为服务端现在只有一个主线程,只能处理一个客户端的消息。

第 60 页给出改造后的结构:主线程负责接收客户端连接,每来一个客户端就分配一个子线程去处理它那条 socket。

第 61 页小结:主线程定义了循环负责接收客户端 Socket 管道连接;每接收到一个 Socket 通信管道后分配一个独立的线程负责处理它。

package com.itheima.d6_tcp3;

import java.net.ServerSocket;
import java.net.Socket;

public class Server1 {
    public static void main(String[] args) throws Exception {
        try {
            System.out.println("==服务端启动===");
            // 1、注册端口
            ServerSocket serverSocket = new ServerSocket(9999);

            while (true) {
                // 2、阻塞等待客户端的socket链接请求
                Socket socket = serverSocket.accept();
                System.out.println(socket.getInetAddress().getHostAddress() + " ==> 上线了~~~");

                // 3、把这个客户端管道交给一个独立的子线程来处理。
                new ServerReaderThread(socket).start();
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
package com.itheima.d6_tcp3;

import java.io.DataInputStream;
import java.io.InputStream;
import java.net.Socket;

/**
 * 服务端读数据的线程
 */
public class ServerReaderThread extends Thread {
    private Socket socket;

    public ServerReaderThread(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // 3、从通信管道socket中得到一个字节输入流
            InputStream is = socket.getInputStream();
            // 4、把字节输入流包装成特殊数据输入流
            DataInputStream dis = new DataInputStream(is);

            while (true) {
                // 5、收数据
                System.out.println("收到:" + dis.readUTF());
                System.out.println("谁发的:" + socket.getInetAddress().getHostAddress());
                System.out.println("它的端口:" + socket.getPort());
                System.out.println("------------------------------------------------------");
            }

        } catch (Exception e) {
            System.out.println(socket.getInetAddress().getHostAddress() + " ==> 下线了~~~");
        }
    }
}

分线程带来的一件事:主线程只做 accept(),收数据的死循环被搬进了子线程——因为 accept() 和 readUTF() 都是阻塞方法,放在同一个线程里必然互相挡住。客户端示例与 6.4 完全一样,可以开多个 Client1 去连同一个服务端。

7. 综合案例

7.1 即时通信-群聊(端口转发)

第 64 页:群聊是指一个客户端把消息发出去,其他在线的全部客户端都可以收到消息。需要用到端口转发的设计思想:服务端需要把在线的 socket 管道存储起来,一旦收到一个消息要推送给其他管道。

第 63 页的结构图:主线程负责接收客户端连接(socket14),每个 socket 交给子线程 14;另外用一个集合存储在线 socket,子线程收到消息后遍历集合转发给其他管道。

package com.itheima.d7_tcp4;

import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;

public class Server1 {
    public static List<Socket> allSockets = new ArrayList<>();

    public static void main(String[] args) throws Exception {
        try {
            System.out.println("==服务端启动===");
            // 1、注册端口
            ServerSocket serverSocket = new ServerSocket(9999);

            while (true) {
                // 2、阻塞等待客户端的socket链接请求
                Socket socket = serverSocket.accept();
                System.out.println(socket.getInetAddress().getHostAddress() + " ==> 上线了~~~");
                allSockets.add(socket);
                // 3、把这个客户端管道交给一个独立的子线程来处理。
                new ServerReaderThread(socket).start();
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
package com.itheima.d7_tcp4;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.InputStream;
import java.net.Socket;

/**
 * 服务端读数据的线程
 */
public class ServerReaderThread extends Thread {
    private Socket socket;

    public ServerReaderThread(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // 3、从通信管道socket中得到一个字节输入流
            InputStream is = socket.getInputStream();
            // 4、把字节输入流包装成特殊数据输入流
            DataInputStream dis = new DataInputStream(is);

            while (true) {
                // 5、收数据
                String msg = dis.readUTF();
                System.out.println("收到:" + msg);
                System.out.println("谁发的:" + socket.getInetAddress().getHostAddress());
                System.out.println("它的端口:" + socket.getPort());
                System.out.println("------------------------------------------------------");

                // 马上把这个消息分发给当前全部在线的socket
                sendMsgToAll(msg);
            }

        } catch (Exception e) {
            System.out.println(socket.getInetAddress().getHostAddress() + " ==> 下线了~~~");
            Server1.allSockets.remove(socket);
        }
    }

    private void sendMsgToAll(String msg) throws Exception {
        // 遍历全部在线socket,把消息分出去
        for (Socket sk : Server1.allSockets) {
            DataOutputStream dos = new DataOutputStream(sk.getOutputStream());
            dos.writeUTF(socket.getInetAddress().getHostAddress() + "说了:" + msg);
            dos.flush();
        }
    }
}

客户端在这基础上多了一步:上线后马上开一个线程专门读服务端推过来的消息,否则主线程卡在 sc.nextLine() 上,永远看不到别人说的话。

package com.itheima.d7_tcp4;

import java.io.DataOutputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;

public class Client1 {
    public static void main(String[] args) throws Exception {
        // 目标:TCP通信客户端开发。 一发多收 群聊
        // 1、创建socket管道与服务端建立链接
        Socket socket = new Socket("127.0.0.1", 9999);
        // 客户端上线后,马上找个线程负责读取他的消息
        new ClientReaderThread(socket).start();

        // 2、从socket管道中得到一个字节输出流,往外写数据出去。
        OutputStream os = socket.getOutputStream();

        // 3、把低级流包装成特殊数据输出流
        DataOutputStream dos = new DataOutputStream(os);

        Scanner sc = new Scanner(System.in);
        while (true) {
            // 4、发数据出去给服务端
            System.out.println("请说:");
            String msg = sc.nextLine();

            if ("exit".equals(msg)) {
                System.out.println("退出成功!");
                socket.close();
                break;
            }

            dos.writeUTF(msg);
            dos.flush();
        }
    }
}
package com.itheima.d7_tcp4;

import java.io.DataInputStream;
import java.io.InputStream;
import java.net.Socket;

/**
 * 服务端读数据的线程
 */
public class ClientReaderThread extends Thread {
    private Socket socket;

    public ClientReaderThread(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // 3、从通信管道socket中得到一个字节输入流
            InputStream is = socket.getInputStream();
            // 4、把字节输入流包装成特殊数据输入流
            DataInputStream dis = new DataInputStream(is);

            while (true) {
                // 5、收数据
                System.out.println("收到:" + dis.readUTF());
            }

        } catch (Exception e) {
            System.out.println(" ==> 客户端自己下线了~~~");
        }
    }
}

三个容易踩的点:

  • 转发循环里如果某个 socket 已经断开,writeUTF 会抛异常,所以下线时要把该 socket 从集合里 remove。
  • 转发时拼上发送方 IP(socket.getInetAddress().getHostAddress() + "说了:"),否则群里看不出是谁说的。
  • 集合必须是所有子线程共享的(课件写成 public static List<Socket> allSockets)。

7.2 实现一个简易版的 BS 架构

第 66 页需求:要求从浏览器中访问服务器,并立即让服务器响应一个很简单的网页给浏览器展示,网页内容就是”黑马程序员666”。

第 67 页:浏览器访问 http://服务器IP:服务器端口,例如 http://127.0.0.1:8080。结构上仍然是”主线程 accept() + 子线程”,只是连接方从自己写的客户端换成了浏览器。注意:服务器必须给浏览器响应 HTTP 协议规定的数据格式,否则浏览器不识别返回的数据。

第 68 页给出 HTTP 响应的固定格式:

部分格式课件示例
状态行协议版本 + 空格 + 状态码 + 空格 + 状态符 + 回车换行HTTP/1.1 200 OK
响应头头部字段名 + : + 值 + 回车换行(可有多行)Content-Type : text/html;charset=UTF-8
空行回车换行必须换行
响应正文真正给浏览器展示的网页数据<div style="color: red;font-size: 120px;text-align: center">黑马程序员磊哥</div>

第 69 页小结:客户端使用浏览器发起请求(不需要开发客户端);服务端必须按照 HTTP 协议响应数据。

package com.itheima.d8_bs;

import java.io.PrintStream;
import java.net.Socket;

/**
 * 服务端读数据的线程
 */
public class ServerReaderRunnable implements Runnable {
    private Socket socket;

    public ServerReaderRunnable(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            PrintStream ps = new PrintStream(socket.getOutputStream());
            ps.println("HTTP/1.1 200 OK");
            ps.println("Content-Type:text/html;charset=UTF-8");
            ps.println(); // 必须换行
            ps.println("<div style='color:red;font-size:120px;'>牛逼的就业班147期~</div>");
            ps.println("<img src='https://nimg.ws.126.net/?url=...'></img>");
            ps.flush();

            ps.close();
            socket.close();
        } catch (Exception e) {
            System.out.println(socket.getInetAddress().getHostAddress() + " ==> 下线了~~~");
        }
    }
}

课件说法:状态行的第三部分叫”状态符”;更准确的表述:它是原因短语(reason phrase),如 OK,只给人看,程序判断响应结果靠的是状态码(200 表示成功)。

7.3 延伸:文件上传为什么需要 shutdownOutput()

PPT 本日没有专门讲文件上传(这块在配套作业里),但它是把上面所有知识拼起来后最容易踩坑的地方,结论只有一条:服务端要一直 read() 到流末尾才认为文件收完,而客户端发完文件后一直在等”上传成功”回执,双方互等,谁都不动。

解决方式是客户端写完文件字节后调用 socket.shutdownOutput():只关闭输出方向的流(半关闭),不关闭 socket 管道。这样服务端读到的 read() 会返回 -1,结束接收循环,再写出”文件上传成功”回执,客户端才读得到。

对应的服务端骨架(与 7.2 的结构一致,只是把写 HTTP 换成”先收文件、再回执”):

// 服务端线程中(片段):接收文件 + 回执
try (InputStream is = socket.getInputStream();
     BufferedOutputStream bos = new BufferedOutputStream(
             new FileOutputStream("D:/upload/" + fileName))) {
    byte[] buffer = new byte[1024];
    int len;
    while ((len = is.read(buffer)) != -1) {   // 客户端 shutdownOutput() 后这里才会读到 -1
        bos.write(buffer, 0, len);
    }
}

// 收到文件后再回执,否则双方互相等待
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bw.write("文件上传成功");
bw.newLine();
bw.flush();

8. 服务端的并发优化:线程池

第 70–73 页属于”拓展知识”:每次请求都开一个新线程,到底好不好? 课件给的答案是——高并发时,容易宕机! 因此使用线程池进行优化。

第 72 页的结构图:主线程仍然只负责 accept(),拿到的 socket 不再 new Thread(...).start(),而是包装成一个 Runnable 任务提交给线程池(图中标注了线程池、任务队列、核心线程),由池中的线程去执行任务。

package com.itheima.d8_bs;

import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.*;

public class Server1 {
    public static void main(String[] args) throws Exception {
        try {
            System.out.println("==服务端启动===");
            // 1、注册端口
            ServerSocket serverSocket = new ServerSocket(8080);

            ExecutorService pool = new ThreadPoolExecutor(3, 5, 1, TimeUnit.MINUTES,
                    new ArrayBlockingQueue<>(10), Executors.defaultThreadFactory(),
                    new ThreadPoolExecutor.AbortPolicy());

            while (true) {
                // 2、阻塞等待客户端的socket链接请求
                Socket socket = serverSocket.accept();
                System.out.println(socket.getInetAddress().getHostAddress() + " ==> 上线了~~~");

                // 3、把这个客户端管道交给线程池里的线程来处理。
                pool.submit(new ServerReaderRunnable(socket));
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

课件用的线程池参数:核心线程 3、最大线程 5、临时线程空闲存活 1 分钟、任务队列 ArrayBlockingQueue<>(10)、默认线程工厂、拒绝策略 AbortPolicy。服务端线程数量因此可控,避免高并发时线程无限创建把机器压垮。

(补充说明)课件没有展开讲线程池参数的含义,这部分在第 12 天已经学过,这里只是把同一套线程池直接搬到网络服务端:Runnable 换成”读 socket”的任务即可,ServerReaderRunnable 与前面 ServerReaderThread 的逻辑完全一样。

9. 作业与练习

作业文档:day12作业/作业13/day13作业.md(相对 day13-网络编程/)。

一、选择题(4 题):TCP 描述、Internet 中的主要通信协议、IPv4 地址长度、TCP 的特点(重点辨析”传输速度快”不属于 TCP 特点)。

二、今日方法:要求写出 InetAddress(getByName()/getHostName()/getHostAddress()/getLocalHost()/isReachable(int timeout))、UDP(DatagramSocket、send()、receive()、getLength())、TCP(Socket、getOutputStream()、getInputStream()、ServerSocket、accept())三类 API。

三、简答题:TCP 特点、UDP 特点、三次握手过程、四次挥手过程。

四、排错题:给定”客户端上传 .txt 文件、服务端多线程接收并回执’文件上传成功‘“的代码,找出 5 处错误。作业文档给出的修改点是:

位置修改
第 9 行Socket s = ss.accept();
第 13 行new Thread(() -> { ... })
第 64 行if (!file.exists() || !file.isFile() || !file.getName().endsWith(".txt"))
第 70 行BufferedOutputStream bos = new BufferedOutputStream(s.getOutputStream());
第 79 行s.shutdownOutput()——统一回执与文件字节的先后顺序

五、代码题:分别创建客户端与服务端并绑定端口。服务端要求用多线程侦听客户端请求、主动给每位客户端发送”欢迎光临!请上传文件(文本文件)!“、收到文件保存到本地并回复上传成功、把文件中的敏感词”操”替换成 ** 后再保存;客户端要求创建套接字向服务端侦听端口发请求、从本地选择一个带敏感词的文本文件上传、并把服务端响应的数据在控制台输出。

10. 复习要点

  1. 网络通信三要素是什么?各自解决什么问题?(IP 定位设备、端口定位设备上的程序、协议规定连接与传输规则)
  2. CS 与 BS 架构的区别到底是什么?(只在客户端那一端:客户端是否需要程序员开发、用户是否需要安装客户端;BS 的客户端就是浏览器)
  3. IPv4 与 IPv6 的长度、表示法分别是什么?127.0.0.1/localhost、192.168.x.x、ipconfig、ping 各表示什么?
  4. InetAddress 的常用方法有哪些?getLocalHost()、getByName()、getHostAddress()、getHostName()、isReachable(int timeout) 分别做什么?
  5. 端口的取值范围是多少(16 位、0~65535)?周知端口、注册端口、动态端口如何分段?为什么自研程序要选注册端口,两个程序能否用同一个端口?
  6. UDP 与 TCP 的特点各自是什么?(UDP 无连接、不可靠、一包最多 64KB、效率高;TCP 面向连接、可靠、可传大数据量、效率相对不高)TCP 为什么要在不可靠的信道上做可靠传输?
  7. 三次握手与四次挥手分别干什么?“全双工”在这里指什么?为什么断开连接要四步?
  8. UDP 通信的四个固定步骤、用到的两个类是哪些?receive() 为什么是阻塞的?为什么要用 packet.getLength() 而不是缓冲区长度?
  9. UDP 的接收端为什么能接收多个发送端的数据包?多发多收在客户端和服务端分别靠什么实现?
  10. TCP 通信的客户端与服务端步骤、Socket 与 ServerSocket 各自的关键方法是什么?为什么客户端 writeInt/writeUTF 时服务端必须用 readInt/readUTF?
  11. 一个服务端如何同时处理多个客户端?(主线程循环 accept() + 每个 socket 一个子线程/一个线程池任务)为什么 accept() 和 readUTF() 不能放在同一个线程里?
  12. 群聊的”端口转发”是怎么做的?在线 socket 为什么要放进共享集合,下线时又要做什么?
  13. 浏览器为什么能访问你写的服务端?HTTP 响应必须包含哪四部分?空行能不能省?
  14. 文件上传时服务端和客户端为什么会互相等待?shutdownOutput() 解决的是什么问题?为什么每次请求都 new Thread 在高并发下容易宕机?