目录
[1. 客户端和服务器](#1. 客户端和服务器)
[2. 请求和响应](#2. 请求和响应)
[1. DatagramSocket](#1. DatagramSocket)
[2. DatagramPacket](#2. DatagramPacket)
[3. UDP回显客户端服务器程序](#3. UDP回显客户端服务器程序)
[4. UDP字典客户端服务器程序](#4. UDP字典客户端服务器程序)
[1. ServerSocket](#1. ServerSocket)
[2. Socket](#2. Socket)
[3. TCP回显客户端服务器程序](#3. TCP回显客户端服务器程序)
[4. 服务器引入多线程](#4. 服务器引入多线程)
[5. 服务器引入线程池](#5. 服务器引入线程池)
[6. TCP字典客户端服务器程序](#6. TCP字典客户端服务器程序)
一、什么是网络编程?
网络编程,指网络上的主机,通过不同的进程 ,以编程的方式实现网络通信(或称网络数据传输)。
即便是同一个主机,只要是不同进程,基于网络来传输数据,也属于网络编程。但是,我们的目的是提供网络上的不同主机,基于网络来传输数据资源。
网络编程,本质上就是学习"传输层"给"应用层"提供的API,通过代码的形式,把数据交给传输层,进一步的通过层层封装,就可以把数据通过网卡发送出去了。
二、网络编程中的基本概念
1. 客户端和服务器
**客户端:**主动发起通信的一方,称为客户端.
**服务器:**被动接受的一方,称为服务器,可以提供对外服务.
同一个程序在不同场景中,可能是客户端也可能是服务器(服务器可能还需要主动向别的服务器发起通信,此时的服务器相对于被发起通信的服务器来说,就是客户端).
2. 请求和响应
**请求(request):**客户端给服务器发送数据。
**响应(response):**服务器给客户端返回数据。
一般来说,获取一个网络资源,涉及到两次网络数据传输:
- 第一次:请求数据的发送.
- 第二次:响应数据的发送.
就比如在快餐店点一份炒饭:
先要发起请求:点一份炒饭;再有快餐店提供的对于响应:提供一份炒饭。
三、Socket套接字
Socket套接字,是由系统提供的用于网络编程的技术,是基于TCP/IP协议的网络通信的基本操作单元。基于Socket套接字的网络程序开发就是网络编程。
前面说过,要想进行网络编程,需要使用的系统API,本质上是由传输层提供的。
传输层涉及到的主要协议有两个:
- 流套接字:TCP(传输控制协议)
- 数据报套接字:UDP(用户数据报协议)
TCP的特点:
- 有连接(类似于打电话,需要接通才能通信)
- 可靠传输(尽可能完成数据传输,起码可以知道当前这个数据对方是否接收到了)
- 面向字节流(此处字节流和文件中的字节流完全一致,网络中传输数据的基本单位就是字节)
- 全双工(一个信道,可以双向通信)
UDP的特点:
- 无连接(类似于发微信/短信,直接发送过去)
- 不可靠传输
- 面向数据报(每次传输的基本单位是一个数据报(由一系列的字节构成的)特定的结构)
- 全双工(一个信道,可以双向通信)
UDP数据报套接字编程
UDP socket API的使用
1. DatagramSocket
DatagramSocket 是 UDP Socket(套接字),用于发送和接收UDP数据报
构造方法:
重要方法:
2. DatagramPacket
DatagramPacket 是 UDP Socket(套接字)发送和接收的数据报(每次发送接收数据的基本单位)
构造方法:
3. UDP回显客户端服务器程序
通过这个程序,了解 socket api 的使用,和典型的客户端服务器基本工作流程。
对于服务器,需要指定端口号来创建 socket (类似于饭店,需要指定具体位置),主要流程如下:
- 接收客户端请求,并解析
- 根据请求,计算出响应(回显服务器,则是直接将请求的数据返回)
- 将响应写回给客户端
注意事项详见代码注释:
java
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
//Udp回显服务器
public class UdpEchoServer {
//Udp套接字
private DatagramSocket socket;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port); //服务器:指定端口号创建
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
//1.接收客户端的请求,并解析
DatagramPacket requestServer = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestServer);
//2.根据请求,计算出响应
String request = new String(requestServer.getData(), 0, requestServer.getLength());
String response = process(request);
//3.将响应写回给客户端(需要指定发送到的IP地址及端口号)
DatagramPacket responseServer = new DatagramPacket(
response.getBytes(), response.getBytes().length, requestServer.getSocketAddress());
socket.send(responseServer);
//打印日志
System.out.printf("[%s:%d] request:%s response:%s\n",
responseServer.getAddress(), responseServer.getPort(), request, response);
}
}
//根据请求计算响应(由于是回显程序,直接返回请求的内容)
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer udpEchoServer = new UdpEchoServer(9090);
udpEchoServer.start();
}
}
对于客户端,服务器的端口号可以由系统随机分配,但需要知道服务器的IP地址及端口号(去饭店吃饭,需要知道饭店的地址及具体是哪个门店),主要流程如下:
- 客户端读取用户请求
- 构造请求的数据报,并发送到服务器(此时就需要指定服务器的IP地址及端口号)
- 读取服务器的响应,并解析出响应的内容
- 输出服务器的响应
java
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
//Udp回显客户端
public class UdpEchoClient {
private DatagramSocket socket;
private String address;
private int port;
//客户端需要知道服务器的IP地址及端口号
public UdpEchoClient(String address, int port) throws SocketException {
this.address = address;
this.port = port;
socket = new DatagramSocket(); //服务器:随机端口号创建
}
public void start() throws IOException {
System.out.println("客户端启动");
Scanner in = new Scanner(System.in);
while (true) {
System.out.print("-> ");
if (!in.hasNext()) {
break;
}
//1.控制台读取请求内容
String request = in.next();
//2.构造请求的数据报,并发送到服务器(需要指定目的IP地址和目的端口号发送请求)
DatagramPacket requestPacket = new DatagramPacket(
request.getBytes(), request.getBytes().length, InetAddress.getByName(address), port);
socket.send(requestPacket);
//3.读取服务器的响应,并解析出响应的内容
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
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 udpEchoClient = new UdpEchoClient("127.0.0.1", 9090);
udpEchoClient.start();
}
}
先运行服务器,再运行客户端,看程序的执行效果:
- 可以看到,服务器端能够正确接收到请求并作出响应,并打印出日志(客户端IP,客户端端口号,请求内容,响应内容)
- 客户端也能够正确发送请求,并正确解析服务器端返回的响应
这个程序并不能直接做到"跨主机通信",因为这台主机可能不能直接访问到另一台主机(NAT机制)。但是可以通过以下手段实现"跨主机通信":
- 将服务器端程序打成 jar 包
- 把 jar 包传到云服务器上,并运行
经过这样的操作,其他主机通过运行上述的客户端程序,就能够发起通信了。
4. UDP字典客户端服务器程序
基于上述回显服务器,还可以实现出一些其他带有一点业务逻辑的服务器。
改进成一个"字典服务器",英译汉的效果。请求是一个英文单词,响应返回对应的中文翻译。
主要逻辑其实和回显服务器基本一致,唯一的区别就在于,服务器端将客户端请求的数据,计算成响应的方式不一致。回显服务器是直接返回客户端请求的数据,这里的字典服务器则是英译汉效果。
而上述代码中,这个根据请求数据计算响应数据的操作,是通过process方法实现的。因此只需要让这个字典服务器继承回显服务器,并重写process方法即可。这里英译汉的业务逻辑通过打表的方式实现。
java
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
//Udp字典服务器
public class UdpDictServer extends UdpEchoServer {
Map<String, String> map;
public UdpDictServer(int port) throws SocketException {
super(port);
map = new HashMap<>();
map.put("cat", "小猫");
map.put("dog", "小狗");
map.put("animal", "动物");
}
//通过重写 计算响应的process方法,达成 英->汉 的效果
@Override
public String process(String request) {
return map.getOrDefault(request, "找不到该单词");
}
public static void main(String[] args) throws IOException {
UdpDictServer udpDictServer = new UdpDictServer(9090);
udpDictServer.start();
}
}
还是先运行字典服务器,再运行回显客户端(这里客户端是通用的,因为回显客户端只进行发送请求和接收响应并解析的操作),看程序的执行效果:
- 同样,服务器端能够正确接收到请求、解析请求,并计算出响应、写回给客户端,并打印出日志(客户端IP,客户端端口号,请求内容,响应内容)
- 客户端也能够正确发送请求,并正确解析服务器端返回的响应
TCP流套接字编程
1. ServerSocket
ServerSocket 类是创建TCP服务器端Socket的API. (只能给服务器端使用)
构造方法:
重要方法:
2. Socket
Socket 类用于创建客户端 Socket,或服务器端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket. (服务器端和客户端都能使用)
构造方法:
重要方法:
3. TCP回显客户端服务器程序
使用TCP协议实现回显客户端服务器程序。与UDP协议实现的最大区别是,TCP是有连接的,和打电话一样,需要一方(客户端)拨号,一方(服务器)接通,因此TCP协议首要操作就是等待客户端连接。
和UDP回显服务器一样,对于这里的服务器,同样需要指定端口号创建TCP服务器端Socket,即ServerSocket。
- 服务器启动后,就需要监听当前绑定端口(accept方法),等待客户端连接。
- 当成功建立连接后,会返回一个Socket对象。这个对象保存了对端信息,即客户端信息,可以用来接收和发送数据(TCP是面向字节流的,通过这个Socket对象获取对应输入流和输出流,通过输入输出流进行对 socket 的读写,达成接收和发送数据的功能)。
后续流程和UCP回显服务器一致。此处由于每有一个客户端连接,就会有一个clientSocket,这里消耗的Socket会越来越多,因此每当一个客户端连接结束,就需要释放这个clientSocket。
java
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;
//TCP回显服务器
public class TcpEchoServer {
private ServerSocket serverSocket;
public TcpEchoServer(int port) throws IOException {
//指定服务器端口号,创建一个serverSocket
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
//监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线\n", clientSocket.getInetAddress(), clientSocket.getPort());
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
while (true) {
//1.读取客户端请求的数据
//利用scanner读取客户端输入的信息
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()) {
System.out.printf("[%s:%d] 客户端下线\n",
clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
//这里的next()需要遇到\n才停止,因此需要对端写入的时候,要同时写入\n换行符
String request = scanner.next();
//2.解析请求的数据,并计算出响应
String response = process(request);
//3.将响应写回到客户端
//outputStream.write(response.getBytes(), 0, response.getBytes().length);
PrintWriter writer = new PrintWriter(outputStream);
writer.println(response);
writer.flush();
//打印日志
System.out.printf("[%s:%d] request:%s response:%s\n",
clientSocket.getInetAddress(), clientSocket.getPort(), request, response);
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
clientSocket.close();
}
}
//回显服务器,直接返回原数据
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
对于客户端,需要 指定服务器的IP和端口号建立连接,即使用 Socket(String host, int port) 创建Socket的时候,就开始发起与对应服务器建立连接的请求了。
主要流程和UDP回显客户端程序的流程也基本一致,只需要注意请求和响应数据的方式是不同的,是通过操作输入输出流完成的即可。
java
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
import java.util.Scanner;
//TCP回显客户端
public class TcpEchoClient {
private Socket clientSocket;
//需要指定服务器的IP和端口号
public TcpEchoClient(String serverAddress, int serverPort) throws IOException {
//与对应客户端建立连接
clientSocket = new Socket(InetAddress.getByName(serverAddress), serverPort);
}
public void start() {
System.out.println("客户端启动!");
try (Scanner scannerConsole = new Scanner(System.in);
InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
while (true) {
//1.用户从控制台输入数据
System.out.print("-> ");
String request = scannerConsole.next();
//2.将该数据作为请求,发送给服务器
//outputStream.write(request.getBytes(), 0, request.getBytes().length);
//outputStream.write('\n');
PrintWriter writer = new PrintWriter(outputStream);
writer.println(request);
writer.flush(); //刷新缓冲区,确保数据发送出去
//3.读取服务器的响应,并解析响应的内容
Scanner scannerNetwork = new Scanner(inputStream);
String response = scannerNetwork.next();
//4.将响应输出到控制台
System.out.println(response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1", 9090);
tcpEchoClient.start();
}
}
先运行服务器,再运行客户端,看执行效果:
可以看到,服务器和客户端都能满足我们的需求,但这里其实还存在一个问题。
当我们开启多个客户端想要进行连接通信时,只有第一个连接到的客户端才能正确通信,其他的客户端是没有反应的。要想某个客户端能正常通信,只有当其他客户端都下线(结束程序),这个客户端才能接收到响应数据。
可以看到,此处的这个客户端并没有正确通信,当另一个客户端下线之后,该客户端此前发送的数据又正常请求并响应了。
分析过程:
- 第一个客户端连上服务器之后,服务器就会从accept这里返回(解除阻塞),然后进入到processConnection方法中.
- 接下来服务器就会在processConnection循环处理客户端的请求,只有当客户端退出之后,连接结束,才会退出循环.
- 而服务器在循环处理客户端请求的时候,第二个客户端发起连接请求,而服务器这里并不能执行到accept。因此并不能成功连接,只有当客户端退出,才会执行回到accept进行连接.
第二个客户端之前发的请求为什么能被立即处理?
- 当前TCP在内核中,每个 socket 都是由缓冲区的。客户端发送的数据通过客户端代码,已经写入到该客户端的输出流(缓冲区)了,这里数据确实发送了,只不过数据在服务器的接收缓冲区中。
- 一旦第一个客户端退出,回到第一层循环,执行accept连接操作,后续processConnection方法里的 next 就能把之前缓冲区的内容给读出来。
解决上述问题的核心思路就是使用多线程:
- 单个线程,无法既能给客户端提供服务,又能去快速执行到第二次 accept 方法进行连接。
- 通过引入多线程,让主线程只负责执行 accept。每次有一个客户端连接上来,就分配一个新的线程,由新的线程负责给客户端提供服务。
由于这里不涉及多个线程修改共享变量,因此没有线程安全问题,我们只需要改动 start 方法即可。
4. 服务器引入多线程
java
//多线程
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
//监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(() -> {
try {
processConnection(clientSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
t.start();
}
}
通过引入多线程,这里的服务器就能支持多个客户端同时与其通信了。
上述问题,不是TCP引起的,而是代码两次循环嵌套引起的,UDP服务器,就是只有一层循环,因此不会有这个问题。
而这个多线程版本同样还有一些问题:
- 每有一个客户端连接,就会创建一个新的线程,每当这个客户端结束,就要销毁这个线程。
- 如果客户端比较多,并且频繁连接、关闭,就会使服务器频繁创建和销毁线程。
前面讲过,线程池解决的就是线程频繁创建和销毁的问题,因此,这里的优化方案就是使用线程池。
5. 服务器引入线程池
java
public void start() throws IOException {
System.out.println("服务器启动!");
ExecutorService threadPool = Executors.newCachedThreadPool();
while (true) {
//监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息
Socket clientSocket = serverSocket.accept();
threadPool.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
});
}
}
线程开销问题解决了。但是,如果当前的场景使线程频繁创建,但是不销毁呢?
- 这种情况下,如果继续使用多线程/线程池,就会导致当前服务器积累大量的线程,此时,对于服务器的负担是非常重的。
要解决这个新问题,还可以引入其他的方案:
- **协程:**轻量级线程,本质上还是一个线程,用户态可以通过手动调度的方式让这一个线程"并发"执行多个任务。
- **IO 多路复用:**系统内核级别的机制,本质上是让一个线程同时去负责处理多个socket。
6. TCP字典客户端服务器程序
同UDP字典客户端服务器程序:
java
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
//TCP字典服务器
public class TcpDictServer extends TcpEchoServer {
Map<String, String> map;
public TcpDictServer(int port) throws IOException {
super(port);
map = new HashMap<>();
map.put("cat", "小猫");
map.put("dog", "小狗");
map.put("animal", "动物");
}
@Override
public String process(String request) {
return map.getOrDefault(request, "未找到该单词");
}
public static void main(String[] args) throws IOException {
TcpDictServer tcpDictServer = new TcpDictServer(9090);
tcpDictServer.start();
}
}