文章目录
- Socket套接字
- 概念
- 分类
- 流式套接字:使用传输层TCP协议
- 数据报套接字:使用传输层UDP协议
- Unix域套接字
- TCP vs UDP
- 有连接 vs 无连接
- 可靠传输 vs 不可靠传输
- 面向字节流 vs 面向数据报
- 全双工 vs 半双工
- UDP数据报套接字编程
- DatagramSocket
- DatagramPacket
- 代码示例
- TCP流套接字编程
- ServerSocket(专门给服务器用的)
- Socket(客户端和服务器都要用)
- 代码示例
Socket套接字
概念
Socket套接字,是系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。
基于Socket套接字的网络程序开发就是网络编程。操作系统提供的网络编程的API就称为"socket API"
分类
Socket套接字针对传输层协议主要划分为如下三类:
流式套接字:使用传输层TCP协议
TCP,即Transmission Control Protocol(传输控制协议)
以下为TCP的特点:
- 有连接
- 可靠传输
- 面向字节流
- 有接收缓冲区,也有发送缓冲区
- 大小不限
- 全双工
数据报套接字:使用传输层UDP协议
UDP,即User Datagram Protocol(用户数据报协议)
以下为UDP的特点:
- 无连接
- 不可靠传输
- 面向数据报
- 有接收缓冲区,无发送缓冲区
- 大小受限:⼀次最多传输64k
- 全双工
Unix域套接字
不能跨主机通信,只是本地主机上的进程和进程之间的通信方式(现在使用很少了)
TCP vs UDP
TCP和UDP都是传输层协议,都是给应用层提供服务的。但是这两个协议差异非常大,因此我们就需要两套API来分别表示
特点:
- TCP:有连接,可靠传输,面向字节流,全双工
- UDP:无连接,不可靠传输,面向数据报,全双工
有连接 vs 无连接
- 有连接就好比打电话,得对面接通了,然后才能通信。可以选择接通也可以选择直接挂掉,通信双方保存对方的信息
- 无连接就好比发短信/发微信,不需要"先接通",直接就可以发送,无法拒绝,通信双方不需要保存对方的信息
计算机的连接(Connection),认为是,要建立连接的双方各自保存对方的信息。此时,就认为是建立了一个"抽象的连接"。双方把对方的信息删除掉就是断开连接
“链接”(link)是网络中常用的词语,指利用技术手段将网址、文字、图片等与相应的网页联系起来,一点击网址、文字、图片等就出现网页页面。
可靠传输 vs 不可靠传输
首先要区分 可靠 != 安全
- 可靠:尽力做到数据完整地到达对方,而不是确保。在网络通信的过程中,可能存在很多意外情况,比如丢包(传输的过程中会经过很多的交换机和路由器进行转发。如果路由器/交换机转发的数据量超出自己的硬件极限水平,此时,多出来的数据就会被直接丢弃掉),为了减少丢包就引入"可靠传输"
- 安全:传输的数据是否容易被黑客截获掉;一旦被截获之后,是否会造成严重的影响
TCP内部就提供了一系列的机制来实现可靠传输,但如果出现网线断开的情况,纵使软件再可靠也无济于事
UDP是不可靠传输。传输数据的时候压根不关心对方是否收到,发了就完了
可靠传输的效率会大打折扣,所以UDP的效率比TCP高
面向字节流 vs 面向数据报
字节流可以简单的理解为,传输数据是基于IO流。流式数据的特征就是在IO流没有关闭的情况下,是无边界的数据,可以多次发送,也可以分开多次接收。
数据报可以简单的理解为,⼀次发送全部数据报,⼀次接收全部的数据报,发送⼀块数据假如100个字节,必须⼀次发送,接收也必须⼀次接收100个字节,而不能分100次,每次接收1个字节。
全双工 vs 半双工
- 全双工:一条链路,能够进行双向通信(TCP,UDP都是全双工),能同时接收数据和发送数据
- 半双工:一条链路,只能进行单向通信,要么接收数据,要么发送数据,不能同时进行
UDP数据报套接字编程
java中使用UDP协议通信,主要基于 DatagramSocket 类来创建数据报套接字,并使用DatagramPacket 作为发送或接收的UDP数据报
socket API都是系统提供的,不同的系统提供的API不一样。Java对系统的这些API进一步封装了
DatagramSocket
构造方法:
方法签名 | 方法说明 |
---|---|
DatagramSocket() | 创建⼀个UDP数据报套接字的Socket,绑定到本机任意⼀个随机端口(⼀般用于客户端) |
DatagramSocket(int port) | 创建⼀个UDP数据报套接字的Socket,绑定到本机指定的端口(⼀般用于服务端) |
方法:
方法签名 | 方法说明 |
---|---|
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字,socket也是一种文件,如果不关闭会占用文件描述符表一个表项 |
系统中本身就有socket这样的概念,系统中的socket可以理解成是一种文件,是"网卡"这个硬件设备的抽象表现形式,针对socket文件的读写操作就相当于针对网卡这个硬件设备进行读写。DatagramSocket就是对于操作系统的socket概念的封装,可以视为是"操作网卡"的遥控器,针对这个对象进行读写操作,就是针对网卡进行读写操作
"遥控属性"这样的概念,计算机中起了个专门的名字:“句柄”(handle)
磁盘上的普通文件就是硬盘的抽象表现形式。可以直接操作硬盘,但不方便。借助文件这个"遥控器"就可以很方便地完成了
DatagramPacket
DatagramPacket是DatagramSocket发送和接收的数据报。是针对UDP数据报的一个抽象表示。一个DatagramPacket对象就相当于一个UDP数据报,一次发送/接收,就是传输了一个DatagramPacket对象
构造方法:
方法签名 | 方法说明 |
---|---|
DatagramPacket(byte[] buf, int length) | 构造⼀个DatagramPacket用来接收数据报,接收的数据保存在字节数组(第⼀个参数buf)中,接收指定长度(第⼆个参数length) |
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) | 构造⼀个DatagramPacket用来发送数据报,发送的数据为字节数组(第⼀个参数buf)中,从offset开始length长度的部分。address指定目的主机的IP和端⼝号 |
方法:
方法签名 | 方法说明 |
---|---|
InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号 |
byte[] getData() | 获取数据报中的数据 |
构造UDP发送的数据报时,第四个参数需要传入 SocketAddress ,该对象可以使用 InetSocketAddress(Socket地址,包含IP地址和端口号)来创建
代码示例
网络程序就会有客户端,也有服务器
Echo称为"回显",意思就是请求发了啥,响应就是啥。这个过程,没有计算,也没有业务逻辑,这是最简单的客户端-服务器程序,只是单纯地去认识socket API的用法。一般的服务器,给ta发送不同的请求会返回不同的响应
服务器完整代码:
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
private DatagramSocket socket = null;
// 参数是服务器要绑定的端⼝
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
// 使⽤这个⽅法启动服务器
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
//1.读取请求并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[1024], 1024);
//如果没有客户端发送请求,服务器的代码就会在receive这里阻塞,这里的阻塞是系统内核控制的。直到有客户端发来请求为止
socket.receive(requestPacket);
//为了方便在Java代码中处理(进行打印),可以把上述数据报中的二进制数据拿出来构造成String
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
//2.根据请求计算响应
String response = process(request);
//3.把响应返回客户端
//由于UDP,UDP socket自身没有记录请求方的ip和端口号等信息,但是DatagramPacket这个对象里有
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), 0, response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
// 记录⽇志, ⽅便观察程序执行效果
System.out.printf("[%s:%d] req=%s, resp=%s\n", requestPacket.getAddress(), requestPacket.getPort(),
request, response);
}
}
//当前是"回显服务器"
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server= new UdpEchoServer(9090);
server.start();
}
}
网络编程本质上也是IO操作
服务器程序,需要在程序启动时把端口号确定下来,因为客户端要能知道服务器在哪才能发起请求
程序猿可以自行指定端口号,只要确保:
- 端口号是合法的,在1~65535之间
- 不能和其他进程使用的端口号冲突
可以先随便写个端口号试试,如果程序运行没有出现任何异常说明是不冲突的。如果冲突了就会抛出异常,换别的端口号即可
DatagramPacket这个对象是一个UDP数据报,包含两个部分:
- 报头通过类的属性来表示
- DatagramPacket构造方法传递的字节数组作为持有载荷的空间
String有一个版本的构造方法可以通过字节数组来构造,字节数组转String会根据编码格式转换为对应的字符
这个方法返回的对象InetSocketAddress里面就包含了IP和端口号
客户端完整代码:
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIp;
private int serverPort;
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
// 这个 new 操作, 就不再指定端⼝了. 让系统⾃动分配⼀个空闲端⼝
socket = new DatagramSocket();
this.serverIp = serverIp;
this.serverPort = serverPort;
}
public void start() throws IOException {
System.out.println("客户端启动!");
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("请输入要发送的请求: ");
//1.从控制台读取用户输入
String request = scanner.nextLine();
//2.构造请求并发送
//构造请求数据报的时候,不光要有数据,还要有"目标"
//InetAddress对象提供了getByName工厂方法,把上述字符串格式的ip地址转成Java能识别的InetAddress对象,这个对象里有ip地址(按照32位整数的形式来保存)
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), 0, request.getBytes().length,
InetAddress.getByName(serverIp), serverPort);
socket.send(requestPacket);
//3.读取响应数据
DatagramPacket responsePacket = new DatagramPacket(new byte[1024], 1024);
//此处的receive也会阻塞,阻塞直到响应到达为止。这是系统内核完成好了的功能
socket.receive(responsePacket);
//4.显示响应到控制台上
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
//服务器和客户端在一个主机上,就用环回ip"127.0.0.1",如果不在同一个主机上,服务器ip是啥就写啥
//此处端口号写的是服务器在创建socket对象时指定的端口号
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
构造客户端的socket对象时,没有指定端口号,不代表没有。操作系统自动分配了一个不冲突的端口号,这个自动分配的端口号,每次启动程序可能不一样
- 服务器要有固定端口号,是因为客户端要主动给服务器发送请求,如果服务器端口号不固定,每次都变,此时客户端就不知道请求发给谁了。服务器是在自己的电脑上的,就算端口冲突也可以自行调整
- 客户端的端口号让系统自动分配,是因为给客户端指定的端口号可能和客户端所在电脑上的其他程序冲突,一旦端口冲突就会导致程序启动不了
为什么客户端和服务器程序都没有调用close?
因为DatagramSocket的生命周期和整个程序的生命周期是一样的,只要客户端/服务器在运行着,这个socket对象就不能释放。如果把进程结束掉,就意味着进程持有的所有资源都释放了,包括持有的内存和文件,也就不需要额外调用close。如果在某个程序中,socket的生命周期和进程不一样,需要提前释放掉,就需要调用close
注意:先运行服务器,后运行客户端。服务器是被动方,要先准备好
在客户端的控制台中输入要发送的内容
服务器控制台会打印如下内容
- 客户端ip是127.0.0.1
- 客户端的端口号是52170(系统自动分配的结果)
- 请求内容是hello
- 响应内容也是hello
默认情况下,如果IDEA只写了一个客户端,那就只能启动一个客户端,要想启动多个需要对IDEA的配置进行修改
把Allow multiple instances
勾选上,点击OK,这样就允许多个实例了
再次运行客户端,这样就会多出一个客户端。运行几次多出几个客户端,这些客户端都可以和服务器进行通信,而且端口号不同
编写⼀个英译汉的服务器. 只需要继承UdpEchoServer以及重写 process方法即可
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
public class UdpDictServer extends UdpEchoServer {
private Map<String, String> dict = null;
public UdpDictServer(int port) throws SocketException {
super(port);
dict = new HashMap<>();
dict.put("hello", "你好");
dict.put("cat", "小猫");
// 可以在这⾥继续添加千千万万个单词
}
@Override
public String process(String request) {
return dict.getOrDefault(request, "该词汇没有查询到");
}
public static void main(String[] args) throws IOException {
UdpDictServer server = new UdpDictServer(9090);
server.start();
}
}
TCP流套接字编程
ServerSocket(专门给服务器用的)
ServerSocket 是创建TCP服务端Socket的API。
构造方法:
方法签名 | 方法说明 |
---|---|
ServerSocket(int port) | 创建⼀个服务端流套接字Socket,并绑定到指定端⼝ |
方法:
方法签名 | 方法说明 |
---|---|
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回⼀个服务端Socket对象,并基于该Socket对象建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
Socket(客户端和服务器都要用)
Socket 是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。
不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,以及用来与对方收发数据的。
构造方法:
方法签名 | 方法说明 |
---|---|
Socket(String host, int port) | 创建⼀个流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接 |
方法:
方法签名 | 方法说明 |
---|---|
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回此套接字的输⼊流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
TCP传输的是字节流,传输的基本单位就是字节。getInputStream和getOutputStream可以获取到socket内部持有的流对象
代码示例
服务端完整代码:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TcpEchoServer {
//ServerSocket不用手动关闭,因为生命周期是跟随整个服务器进程的
private ServerSocket serverSocket = null;
// 这个操作就会绑定端⼝号
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
// 启动服务器
public void start() throws IOException {
System.out.println("服务器启动!");
//创建自动扩容的线程池,可以重复利用线程
ExecutorService pool = Executors.newCachedThreadPool();
while (true) {
//如果客户端没有连接过来,accept会产生阻塞,直到客户端连接
//通过这个Socket和客户端交互
Socket clientSocket = serverSocket.accept();
pool.submit(new Runnable() {
@Override
public void run() {
try {
procesConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
//通过这个方法来处理一个连接的逻辑
private void procesConnection(Socket clientSocket) throws IOException {
//1.打印日志,告知当前有客户端连上了
System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
//2.从socket中获取流对象
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
//针对每个连接,客户端都可能会发来多个请求,服务器也就需要返回多个响应了
Scanner scanner = new Scanner(inputStream);
while (true) {
//读取请求并解析
//情况1:TCP连接存在,这里就会阻塞等待请求到达
//情况2:一旦TCP断开连接,Scanner就相当于读到socket文件的EOF,hasNextLine解除阻塞,并返回false
//情况3:接收到数据,hasNextLine解除阻塞,并返回true
if(!scanner.hasNextLine()) {
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
//如果直接用read方法来读,读出来的是字节数组还要转成String
//如果使用Scanner的话,直接读出来的就是String。Scanner已经做好转换操作了
String request = scanner.nextLine();
//根据请求计算响应
String response = process(request);
//把响应返回客户端
outputStream.write(response.getBytes());
//服务器打印日志
System.out.printf("[%s:%d] req=%s, resp=%s\n", clientSocket.getInetAddress(), clientSocket.getPort(), request, response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
clientSocket.close();
}
}
//由于是回显服务器,直接把请求作为响应返回
private String process(String request) {
//给响应后面这里加上一个换行符,使客户端Scanner读取响应的时候,也有明确的分隔符,不然会一直阻塞
return request + "\n";
}
public static void main(String[] args) throws IOException {
//服务器要先处理客户端发来的连接
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
客户端完整代码:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
private Socket socket = null;
public TcpEchoClient(String serverIp, int serverPort) throws IOException {
//这样的构造过程,就会和服务器之间建立TCP连接
//具体建立连接的流程,都是系统内核完成的
socket = new Socket(serverIp, serverPort);
}
public void start() throws IOException {
System.out.println("客户端启动!");
Scanner scanner = new Scanner(System.in);
try (InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream()) {
Scanner scannerNetWork = new Scanner(in);
while (true) {
//1.从控制台读取数据
System.out.println("请输入要发送的数据: ");
String request = scanner.nextLine();
//2.把请求发给服务器,发送的请求末尾要带有\n,这和服务器的nextLine是对应的
request += "\n";
out.write(request.getBytes());
//3.从服务器读取到响应
if(!scannerNetWork.hasNextLine()) break;
String response = scannerNetWork.nextLine();
//4.打印响应到控制台
System.out.println(response);
}
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090);
client.start();
}
}