文章目录
- Ⅰ. UDP 和 TCP 的区别
- Ⅱ. UDP 套接字编程
- 一、常用方法
- 二、服务端
- 三、客户端
- Ⅲ. TCP 套接字编程
- 一、常用方法
- 二、服务端
- ① 多线程版本
- ② 线程池版本
- 三、客户端
Ⅰ. UDP 和 TCP 的区别
| 特性 | TCP(传输控制协议) | UDP(用户数据报协议) |
|---|---|---|
| 类型 | 面向连接、面向字节流 | 无连接、面向数据报 |
| 可靠性 | 可靠,数据按顺序到达、无丢包 | 不可靠,可能丢包、乱序 |
| 缓冲区 | 既有接收缓冲区,也有发送缓冲区 | 只有接收缓冲区 |
| 速度 | 较慢(需握手、确认等) | 较快(没有连接管理) |
| 应用场景 | 文件传输、Web、聊天 | 视频直播、游戏、广播 |
| Java支持类 | Socket、ServerSocket | DatagramSocket、DatagramPacket |
Ⅱ. UDP 套接字编程
一、常用方法
UDP是无连接的,因此每次发送数据报前都需要指定目标地址和目标端口(而TCP则只需要创建套接字时候绑定即可)- 在
Java中创建一个DatagramSocket对象,就是在操作系统中打开了一个socket文件,通过这个文件,可以读写数据,而该文件负责将内容与网卡进行交互,从而达到网络通信功能。 DatagramPacket就是一个数据报,DatagramSocket是真正的套接字文件,使用时候就是通过DatagramSocket中的send/receive方法来传输一个个数据报DatagramPacket。
| 类名 | 返回值 | 功能 |
|---|---|---|
| DatagramSocket() | 构造方法 | 创建一个 UDP 套接字,系统会随机分配一个可用端口 |
| DatagramSocket(int port) | 构造方法 | 绑定到指定端口 |
| DatagramSocket(int port, InetAddress laddr) | 构造方法 | 绑定到指定 IP 和端口 |
| DatagramSocket(SocketAddress bindaddr) | 构造方法 | 使用 InetSocketAddress 绑定地址和端口 |
| send(DatagramPacket p) | void | 发送一个数据报包到目标地址 |
| receive(DatagramPacket p) | void | 阻塞接收一个数据报包 |
| close() | void | 关闭套接字,释放资源 |
| isClosed() | boolean | 判断套接字是否已关闭 |
| setSoTimeout(int timeout) | void | 设置接收数据的超时时间(毫秒) |
| getSoTimeout() | int | 获取当前接收超时时间 |
| setReuseAddress(boolean on) | void | 设置是否允许地址重用 |
| getLocalPort() | int | 获取本地绑定的端口号 |
| getLocalAddress() | InetAddress | 获取本地绑定的 IP 地址 |
| DatagramPacket(byte[] buf, int length) | 构造方法 | 创建空数据报,用于接收数据 |
| DatagramPacket(byte[] buf, int length, InetAddress addr, int port) | 构造方法 | 创建用于发送的数据报,指定目标地址和端口 |
| DatagramPacket(byte[] buf, int offset, int length, InetAddress addr, int port) | 构造方法 | 发送指定字节数据,支持偏移和长度控制 |
| DatagramPacket(byte buf[], int offset, int length, SocketAddress address) | 构造方法 | 创建数据报,通过SocketAddress直接指定端口和地址 |
| getData() | byte[] | 获取数据报的缓冲区内容 |
| setData(byte[] buf) | void | 设置数据缓冲区 |
| getLength() | int | 获取数据报中有效数据长度 |
| setLength(int length) | void | 设置数据报的有效数据长度 |
| getSocketAddress() | SocketAddress | 获取地址、端口的一个结构体 相当于下面两个方法的结合 |
| getAddress() | InetAddress | 获取目标地址或发送者地址 |
| getPort() | int | 获取目标端口或发送者端口 |
| setAddress(InetAddress addr) | void | 设置数据包的目标地址 |
| setPort(int port) | void | 设置数据包的目标端口 |
💥注意事项:
- 要构造
InetAddress的话,需要调用InetAddress.getByName(s)静态方法,其中s是地址字符串,比如"127.0.0.1",最后就能拿到的就是地址为s的InetAddress对象! - 一般构造
DatagramPacket用getSocketAddress()的版本比较方便,因为这个方法实际上就包括了端口和地址,如下图所示:
- 创建
DatagramPacket的时候要传入的byte数组,实际上是一个应用层的缓冲区,通常建议缓冲区大小设置为大于或等于预期最大UDP包大小,比如4096或8192。- 此外,要使用
req.getLength()来获取实际接收数据的长度,而不是buf.length,即在读取数据时要用new String(req.getData(), 0, req.getLength()),避免读到多余的空字节。
- 此外,要使用
二、服务端
- 接收客户端发来的请求,并且进行解析
- 处理请求
- 封装成数据报进行响应
/** * 服务器不需要知道数据从哪里来,所以不需要用字段来存放客户端的IP和端口,直接从请求中获取即可 */publicclassServer{privateDatagramSocketsocket=null;// 通信就靠DatagramSocket对象publicServer(intport)throwsSocketException{socket=newDatagramSocket(port);// 服务端需要指定port,让别人来连接}publicvoidstart()throwsIOException{System.out.println("服务器启动!");while(true){// 1. (阻塞)接收客户端发来的请求,并且进行解析DatagramPacketreq=newDatagramPacket(newbyte[4096],4096);socket.receive(req);Stringdata=newString(req.getData(),0,req.getLength());// 从 DatagramPacket 取到有效的数据// 2. 处理请求Stringoutcome=func(data);// 3. 封装成数据报进行响应(注意要填写目的端口和ip)DatagramPacketresp=newDatagramPacket(outcome.getBytes(),0,outcome.getBytes().length,//req.getAddress(), req.getPort()); // 可以这样子写,但是麻烦,推荐下面的写法req.getSocketAddress());socket.send(resp);// 打印日志,看看效果System.out.printf("[%s:%d] req: %s, resp: %s\n",req.getAddress(),req.getPort(),data,outcome);}}// 业务代码(不是现在的重点)publicStringfunc(Stringdata){returndata;}publicstaticvoidmain(String[]args)throwsIOException{Serverserver=newServer(8080);server.start();}}三、客户端
- 构造请求数据报,注意要传入目的端口和IP到数据报中
- 发送请求到服务器
- 接收服务器发来的响应,然后进行解析
/** * 1. 因为客户端在发送数据报的时候需要知道服务器的IP和端口,并且由于UDP * 每次发送数据都得传入服务器的IP和端口,所以需要用单独的字段来保存 * * 2. 创建DatagramSocket时候传入的端口是指定客户端自己在本机的端口, * 而不是服务器的端口,注意和上面区分开! */publicclassClient{privateintport;// 服务器的端口privateStringaddr;// 服务器的地址privateDatagramSocketsocket=null;publicClient(intport,Stringaddr)throwsSocketException{this.port=port;this.addr=addr;socket=newDatagramSocket();// 让系统自动分配端口号}publicvoidstart()throwsIOException{System.out.println("客户端启动!");Scannersc=newScanner(System.in);while(true){// 1. 构造请求数据报,注意要传入目的端口和IP到数据报中System.out.print("请输入要发送给服务器的信息:");Stringmessage=sc.nextLine();DatagramPacketreq=newDatagramPacket(message.getBytes(),0,message.getBytes().length,InetAddress.getByName(addr),// 利用静态方法getByName构造InetAddressport);// 2. 发送请求到服务器socket.send(req);// 3. 接收服务器发来的响应,然后进行解析DatagramPacketresp=newDatagramPacket(newbyte[4096],4096);socket.receive(resp);Stringdata=newString(resp.getData(),0,resp.getLength());// 进行日志输出,查看效果System.out.println("响应:"+data);}}publicstaticvoidmain(String[]args)throwsIOException{Clientclient=newClient(8080,"127.0.0.1");client.start();}}运行效果如下所示:
Ⅲ. TCP 套接字编程
一、常用方法
TCP连接只需要在创建套接字时候绑定端口和地址即可,而不需要像UDP一样每次发送数据的时候都要指定目标端口和地址!ServerSocket这个类主要负责建立连接、监听新连接,而不负责数据的接收和发送!Socket这个类主要负责数据的接收和发送!- 因为
TCP是面向字节流的,所以实际上数据的接收和发送,都是通过Socket获取套接字文件的输入输出流InputStream/OutputStream,然后以字节为单位,来处理该底层套接字,本质还是文件IO。
- 因为
- 在
Java中,如果你没有显式调用connect()创建连接,但你直接使用Socket.getOutputStream().write(...)来写数据,则系统会在你第一次写数据的时候自动调用connect()建立连接!
| 方法 / 构造方法 | 返回值类型 | 功能说明 |
|---|---|---|
| ServerSocket() | 构造方法 | 创建未绑定的服务器套接字 |
| ServerSocket(int port) | 构造方法 | 创建并绑定到指定端口的套接字 |
| ServerSocket(int port, int backlog) | 构造方法 | 指定端口与连接请求队列长度 |
| ServerSocket(int port, int backlog, InetAddress bindAddr) | 构造方法 | 指定端口、队列长度和绑定地址 |
| accept() | Socket | 阻塞等待客户端连接,返回通信用的 Socket |
| close() | void | 关闭服务器套接字,释放资源 |
| isClosed() | boolean | 判断服务器套接字是否关闭 |
| getInetAddress() | InetAddress | 获取绑定的本地 IP 地址 |
| getLocalPort() | int | 获取绑定的本地端口 |
| setSoTimeout(int timeout) | void | 设置 accept() 阻塞的超时时间(毫秒) |
| getSoTimeout() | int | 获取当前 accept() 超时时间 |
| Socket() | 构造方法 | 创建未连接的套接字(用于延迟连接) |
| Socket(String host, int port) | 构造方法 | 创建并连接到指定主机和端口 |
| Socket(InetAddress address, int port) | 构造方法 | 同上,使用 IP 地址连接 |
| getInputStream() | InputStream | 获取输入流,用于接收数据 |
| getOutputStream() | OutputStream | 获取输出流,用于发送数据 |
| close() | void | 关闭连接,释放资源 |
| isClosed() | boolean | 判断是否关闭连接 |
| isConnected() | boolean | 判断是否连接成功 |
| isBound() | boolean | 判断是否已绑定本地地址 |
| getInetAddress() | InetAddress | 获取远程地址 |
| getPort() | int | 获取远程端口号 |
| getLocalAddress() | InetAddress | 获取本地地址 |
| getLocalPort() | int | 获取本地端口号 |
| setSoTimeout(int timeout) | void | 设置输入流读取的超时时间 |
| getSoTimeout() | int | 获取读取超时时间 |
二、服务端
- 监听新连接
- 创建新线程来处理新连接
Socket,防止主线程阻塞- 读取请求并进行解析
- 将解析后的请求进行业务处理
- 响应结果给客户端
- 关闭
Socket,防止资源泄露问题
这里需要关闭
Socket对象的原因是一个服务器会创建很多新线程来处理不同的连接,这些连接本质都是套接字文件,如果没有释放文件资源的话,最后就会导致资源泄露问题!至于
ServerSocket和DatagramSocket为什么不需要释放,是因为它们全局只有一个对象,而且生命周期是随着程序生命周期的,所以不会出现资源泄露问题!此外
Scanner和PrintWriter也不需要释放,因为它们是从 Socket.getXXX() 获得的,本质还是 Socket 对象套接字文件对应的流对象,所以不需要关心 Scanner 和 PrintWriter 的释放问题!
💥注意事项:
- 因为
Scanner这里使用的是nextLine()和hasNextLine(),所以PrintWriter在使用的时候不能用write(),而要用println(),因为write()是不带换行符的,此时就算回车结束了,字符串也不会带回车,那么进入判断语句中就卡在那里了!- 结论:用
Scanner.nextLine()读取,就一定要println()写入。
- 结论:用
- 在用
PrintWriter写入数据到套接字文件后,要调用flush()进行缓冲区刷新,不然只能缓冲区快满了自动刷新!
① 多线程版本
/** * 服务器只需要指定端口即可 */publicclassServer{privateServerSocketsocket=null;publicServer(intport)throwsIOException{socket=newServerSocket(port);}publicvoidstart()throwsIOException{System.out.println("TCP服务器启动!");while(true){// 1. 监听新连接Socketconn=socket.accept();System.out.println("获取到新连接:"+conn.getInetAddress()+"/"+conn.getPort());// 2. 创建新线程来处理新连接,防止主线程阻塞newThread(()->{work(conn);}).start();}}// 新连接实际上要处理的任务privatevoidwork(Socketconn){try(InputStreamin=conn.getInputStream();OutputStreamout=conn.getOutputStream();Scannersc=newScanner(in);PrintWriterpw=newPrintWriter(out)){while(true){// 3. 读取请求并进行解析if(sc.hasNextLine()==false){// 要判断是否断开连接:如果客户端断开连接了,则会返回falseSystem.out.printf("[%s:%d] 客户端下线!\n",conn.getInetAddress().toString(),conn.getPort());break;}Stringreq=sc.nextLine();// 4. 将解析后的请求进行业务处理Stringresp=func(req);// 5. 响应结果给客户端pw.println(resp);// ✔自动添加换行符,满足服务端 Scanner.nextLine(),使用write则会死循环!pw.flush();// ❗❗❗细节❗❗❗// 搞一下日志输出看看效果System.out.printf("[%s:%d] req: %s, resp: %s\n",conn.getInetAddress().toString(),conn.getPort(),req,resp);}}catch(IOExceptione){thrownewRuntimeException(e);}finally{// 6. 释放Socket资源try{conn.close();}catch(IOExceptione){thrownewRuntimeException(e);}}}privateStringfunc(Stringdata){returndata;}publicstaticvoidmain(String[]args)throwsIOException{Serverserver=newServer(9090);server.start();}}② 线程池版本
只需要改动上面start()函数里面创建线程的方式即可:
publicvoidstart()throwsIOException{System.out.println("TCP服务器启动!");while(true){// 监听新连接Socketconn=socket.accept();System.out.println("获取到新连接:"+conn.getInetAddress()+"/"+conn.getPort());// 创建线程池来处理新连接,推荐用newCachedThreadPoolExecutorServicepool=Executors.newCachedThreadPool();pool.submit(()->{work(conn);});}}三、客户端
- 输入数据,写入
Socket对象中,然后进行刷新 - 接收服务器的响应
publicclassClient{privateSocketconn=null;publicClient(Stringaddr,intport)throwsIOException{conn=newSocket(addr,port);}publicvoidstart(){System.out.println("客户端启动!");Scannertmp=newScanner(System.in);try(InputStreamin=conn.getInputStream();OutputStreamout=conn.getOutputStream();Scannersc=newScanner(in);PrintWriterpw=newPrintWriter(out)){while(true){// 1. 输入数据,写入Socket中,然后刷新System.out.print("请输入要发送的数据:");Stringreq=tmp.nextLine();pw.println(req);// ✔自动添加换行符,满足服务端 Scanner.nextLine(),使用write则会死循环!pw.flush();// ❗❗❗细节❗❗❗// 2. 接收服务器的响应if(sc.hasNextLine()==false){System.out.println("客户端断开连续!");break;}Stringresp=sc.nextLine();// 打印日志看看效果System.out.printf("[%s:%d] req: %s, resp: %s\n",conn.getInetAddress().toString(),conn.getPort(),req,resp);}}catch(IOExceptione){thrownewRuntimeException(e);}}publicstaticvoidmain(String[]args)throwsIOException{Clientclient=newClient("127.0.0.1",9090);client.start();}}