目录
1. TCP和UDP
- 进行网络编程我们就需要用到socket套接字,这是操作系统给应用程序提供的API,就是应用层给传输层提供的,socket属于传输层。
- 在网络传输层中有很多协议,最典型的就是TCP和UDP。由于这两种协议差异很大,所以操作系统提供了两个版本的API。
2. TCP特点与UDP特点对比
2.1 TCP特点
- 有连接;打电话就是有连接,发微信就是无连接,需要通信双方建立连接才能说话就是有连接。
- 可靠传输;可靠传输就比如打电话时对方回应你能知到对方收到信息。
- 面向字节流;文件操作的时候就是 流 ,TCP和文件操作一样,也是基于流的。
- 全双工。一个通道双向通信,对应的半双工就是一个通道单向通信。
2.2 UDP特点
- 无连接;
- 不可靠传输;
- 面向数据报;以数据报为基本单位。
- 全双工。
3. UDP socket 相关类
- DatagramSocket:
构造方法及其方法类似于文件操作,socket本质也是文件,广义的文件也就是各种硬件设备和软件资源,socket对应的就是网卡这个硬件设备,通过网卡发送数据就是写文件,通过网卡接收文件就是读文件。
- DatagramPacket(UDP数据报,也就是一次发送/接收的基本单位)
DatagramPacket构造方式:
①构造空的packet,不需要指定端口;
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
②构造有数据的packet,使用InetAddress描述地址和端口号
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getSocketAddress());
③构造有数据的packet,使用ip和端口号描述发给谁
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(this.serverIP), this.serverPort);
3.1 回显服务器(UDP)
UDP版本的客户端服务器程序:回显服务器(发出什么收回什么,没有任何意义,仅仅用来了解socket api的使用)。
回显服务器整个流程(没有数据就会阻塞,有数据才会继续执行):
服务器启动;构造socket.receive()进入循环立即执行,由于客户端还没有数据发来,暂时阻塞
→客户端启动;构造socket对象,读取用户数据;构造packet并且发送(socket.send);同时socket.receive就会解除阻塞
→服务器执行process方法,根据请求计算响应;socket.send发送响应给客户端
→客户端也会socket.receive,此时服务器的响应还没回来,receive也会阻塞;
客户端收到数据才会从receive中被唤醒,并且拿到响应数据;将数据展示到界面上
→服务器进入到下次循环,继续在socket.receive阻塞
服务器:
rt 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[4096], 4096);// 创建对象并分配内存空间(空内存空间)
socket.receive(requestPacket);
// 把这个 DatagramPacket 对象转成字符串, 方便打印
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
// 2.根据请求计算响应
String response = process(request);
// 3.把响应写回到客户端
// 使用带有数据的内存空间进行构造,requestPacket.getSocketAddress()就是客户端的ip和端口号,用一个对象来表示了(使用带有数据的内存空间来构造)
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
// 4. 打印一个日志, 记录当前服务器的工作的情况,打印当前的请求和响应
System.out.printf("[%s:%d] req: %s; resp: %s\n", requestPacket.getAddress().toString(),
requestPacket.getPort(), request, response);
}
}
// 回显服务器,响应数据就和请求是一样的.
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
// 端口是一个16位的整数,0-65535,但是0-1024称为“知名端口号”,被一些较为著名的应用程序占用,1024-65535是可以随意使用的端口号
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
客户端:
rt java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP;
private int serverPort;
// 客户端的构造方法,参数是传一个服务器的IP和服务器的端口
// 两个参数一会会在发送数据的时候用到.暂时先把这俩参数存起来
public UdpEchoClient(String serverIP, int serverPort) throws SocketException {
// 客户端给服务器发送一个请求,客户端用自己的主机ip,就是源ip,不用自己绑定一个端口,操作系统会自动分配一个空闲的端口。
// 不确定端口是否空闲,避免端口冲突
socket = new DatagramSocket();
// 假设 serverIP 是形如 1.2.3.4 这种点分十进制的表示方式 (常用的IP表现方式)
this.serverIP = serverIP;
this.serverPort = serverPort;
}
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
while (true) {
// 1. 从控制台读取用户输入的内容.
System.out.print("请输入: ");
String request = scanner.next();
// 2. 构造一个 UDP 请求, 发送给服务器.
// request.getBytes()把字符串转换成字节数组,此处需要以字节为单位描述,这就是不用request.length的原因
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(this.serverIP), this.serverPort);
socket.send(requestPacket);
// 3. 从服务器读取 UDP 响应数据. 并解析
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
// 调用receive的时候,当前数据还未到达,receive就会阻塞,直到数据到达
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
// 4. 把服务器的响应显示到控制台上.
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
允许启动多个客户端:
运行结果展示:
3.2 翻译服务器(UDP)
我们上面实现的回显服务器没有什么作用,下面实现一个英汉互译的翻译服务器。
逻辑类似于回显服务器,只有process(根据请求计算响应)不同。
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
// 翻译服务器,基于回显服务器
public class UdpTranslateServer extends UdpEchoServer {
// 翻译的本质上就是 key -> value
private Map<String, String> dict = new HashMap<>();
public UdpTranslateServer(int port) throws SocketException {
super(port);
dict.put("cat", "小猫");
dict.put("dog", "小狗");
dict.put("hello","你好");
// 在这里就可以填入很多很多的内容. 翻译词典就是有一个非常大的哈希表, 包含了几十万个单词.
}
// 重写 process 方法, 实现查询哈希表的操作
@Override
public String process(String request) {
return dict.getOrDefault(request, "词在词典中未找到");
}
// start 方法和父类完全一样, 不用写了.
public static void main(String[] args) throws IOException {
UdpTranslateServer server = new UdpTranslateServer(9090);
server.start();
}
}
服务器程序的基本流程相似,核心就是根据请求计算响应。
4. TCP socket 相关类
- ServerSocket:给服务器端使用的类
构造方法:ServerSocket(int port):绑定端口,监听端口
其他方法:①Socket accept():接受客户端连接;②void close():关闭套接字。
-
Socket:既会给服务器端使用,又会给客户端使用
构造方法:Socket(String host,int port):尝试和指定的服务器建立连接,传输数据
其他方法:①通过socket可以获取两个流对象进行读和写:InputStream getInputStream()、OutputStream getOutputStream() ;②InetAddress getInetAddress()返回套接字获取到对方的IP地址和端口。
4.1 回显服务器(TCP)
服务器:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
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 {
// 代码中会涉及到多个 socket 对象. 使用不同的名字来区分.
private ServerSocket listenSocket = null;
public TcpEchoServer(int port) throws IOException {
listenSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
// 1. 先调用 accept 来接受客户端的连接.
// 如果当前没有客户端连接accept就会阻塞
Socket clientSocket = listenSocket.accept();
// 2. 再处理这个连接.
processConnection(clientSocket);
}
}
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress().toString(), clientSocket.getPort());
// 处理客户端的请求.
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
while (true) {
// 1. 读取请求并解析.
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()) {
// 读完了, 连接可以断开了.
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress().toString(),
clientSocket.getPort());
break;
}
String request = scanner.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写回给客户端
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(response);
// 刷新缓冲区确保数据确实是通过网卡发送出去了.
printWriter.flush();
System.out.printf("[%s:%d] req: %s; resp: %s\n", clientSocket.getInetAddress().toString(),
clientSocket.getPort(), request, response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
// 为啥这个地方要关闭 socket ? 而前面的 listenSocket 以及 udp 程序中的 socket 没有 close?
// 由于socket也是文件,文件最后一定需要关闭释放。
// listenSocket是在TCP服务器程序中的的唯一对象,不可能占满描述符表,随着进程的退出会自动释放。
// 而clientSocket是在循环中,每创建一个实例都需要消耗一个文件描述符表,因此需要及时释放不用的clientSocket。
clientSocket.close();
}
}
public String process(String request) {
return request;
}
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.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
// 客户端需要使用这个 socket 对象来建立连接.
private Socket socket = null;
public TcpEchoClient(String serverIP, int serverPort) throws IOException {
// 和服务器建立连接. 就需要知道服务器在哪.
socket = new Socket(serverIP, serverPort);
}
public void start() {
Scanner scanner = new Scanner(System.in);
// Scanner不需要关闭是因为本质上是关闭了里面包含的InputStream,
// scanner自身不会打开文件描述,且InputStream只有一个对象,且在try里面,最终会自己结束
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
while (true) {
// 1. 从控制台读取数据, 构造成一个请求
System.out.print("请输入:");
String request = scanner.next();
// 2. 发送请求给服务器
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
// 这个 flush 一定要记得,否则可能导致请求没有真发出去.
printWriter.flush();
// 3. 从服务器读取响应
Scanner respScanner = new Scanner(inputStream);
String response = respScanner.next();
// 4. 把响应显示到界面上
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090);
client.start();
}
}
但是我们的服务器是需要与多个客户端建立连接,如果没有客户端建立连接,服务器就会阻塞到accept,如果有客户端建立连接,此时就会进入processConnection方法,建立连接了,但是客户端还没有发消息,此时就会在scanner.hasNext()阻塞,此时就无法第二次调用到accept,也就无法处理第二个客户端了。
UDP没有这个问题的原因是客户端直接发消息即可,而TCP是一个连接处理多次请求,无法快速调用accept(长连接),若TCP每个连接只处理一个客户端请求就不会出现这种问题(短连接)。
while (true) {
// 1. 读取请求并解析.
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()) {
// 读完了, 连接可以断开了.
System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress().toString(),
clientSocket.getPort());
break;
}
改进方案:使用多线程可以解决上述存在的问题,每个客户端发出请求都能连接到新的线程。
服务器端改进(多线程):
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
// 1. 先调用 accept 来接受客户端的连接.
// 如果当前没有客户端连接accept就会阻塞
Socket clientSocket = listenSocket.accept();
// 2. 再处理这个连接. 这里应该要使用多线程. 每个客户端连上来都分配一个新的线程负责处理
Thread t = new Thread(() -> {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
});
t.start();
}
}
但是上述循环会循环多次,频繁创建销毁线程,可以使用线程池解决这个问题。
服务器端改进(线程池):
public void start() throws IOException {
System.out.println("服务器启动!");
ExecutorService service = Executors.newCachedThreadPool();
while (true) {
// 1. 先调用 accept 来接受客户端的连接.
// 如果当前没有客户端连接accept就会阻塞
Socket clientSocket = listenSocket.accept();
// 2. 再处理这个连接. 这里应该要使用多线程. 每个客户端连上来都分配一个新的线程负责处理
// 此处使用多线程可以解决问题, 但是会导致频繁创建销毁多次线程!!
// Thread t = new Thread(() -> {
// try {
// processConnection(clientSocket);
// } catch (IOException e) {
// e.printStackTrace();
// }
// });
// t.start();
service.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
4.2 翻译服务器(TCP)
// 翻译服务器,基于回显服务器
public class TcpTranslateServer extends TcpEchoServer{
// 定义一个字典
private Map<String, String> dict = new HashMap<>();
public TcpTranslateServer(int port) throws IOException {
super(port);
// 构造字典的内容
dict.put("cat", "猫");
dict.put("dog", "狗");
dict.put("pig", "猪");
dict.put("hello", "你好");
}
@Override
public String process(String request) {
return dict.getOrDefault(request,"没有这个单词!");
}
public static void main(String[] args) throws IOException {
TcpTranslateServer server = new TcpTranslateServer(9090);
server.start();
}
}