前言:该篇文章直接从Socket套接字讲起,如果想要了解一些关于其的基础知识,请阅读博主的另一篇博客网络套接字预备知识
目录
一、UDP网络编程
1.1 基础概念
1.2 DatagramSocket API
1.3 DatagramPacket API
1.4 UDP回显服务器
1.4.1 服务器:
1.4.2 服务器客户端
Tip1:
Tips2:
1.5 UDP翻译服务器
二、TCP网络编程
2.1 TCP回显服务器
2.2 TCP回显客户端
2.3 解决无法同时启动多个客户端的问题
2.4 TCP中的长短连接
Socket套接字,是由系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。基于Socket套接字的网络程序开发就是网络编程。
其实Socket本质上也是文件(操作系统把其抽象出来的),其对应网卡这一设备。
数据报套接字:使用传输层UDP协议: UDP,即User Datagram Protocol(用户数据报协议),传输层协议。
以下为UDP的特点:
解析:对于数据报来说,可以简单的理解为,传输数据是一块一块的,发送一块数据假如100个字节,必须一次发送,接收也必须一次接收100个字节,而不能分100次,每次接收1个字节。
我们知道我们的数据是从应用层开始封装,一直到物理层封装完成并发送,那数据传输的第一步就是将应用层的数据交给传输层,为了完成这个过程,操作系统提供了一组API即socket,用来实现将应用层的数据转交给传输层(内核)进一步传输。
常见传输层协议有两种,分别是UDP与TCP,其中UDP类型的socket,有两个相关网络传输的核心类,一个是DatagramSocket,其实例的对象表示UDP版本的socket,这个socket可以理解为操作网卡的遥控器。
DatagramSocket 构造方法:
方法签名 | 方法说明 |
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口 (一般用于客户端) |
DatagramSocket(int port) |
创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用 于服务端) |
DatagramSocket 方法:
void receive(DatagramPacket p) |
从此套接字接收数据报(如果没有接收到数据报,该方法会阻 塞等待) |
void send(DatagramPacket p) |
从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
DatagramPacket是UDP Socket发送和接收的数据报。
DatagramPacket 构造方法:
方法签名 | 方法说明 |
DatagramPacket(byte[] buf, int length) |
构造一个DatagramPacket以用来接收数据报,接收的数据保存在 字节数组(第一个参数buf)中,接收指定长度(第二个参数 length) |
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) |
构造一个DatagramPacket以用来发送数据报,发送的数据为字节 数组(第一个参数buf)中,从0到指定长度(第二个参数 length)。address指定目的主机的IP和端口号 |
DatagramPacket 方法
方法签名 | 方法说明 |
InetAddress getAddress() |
从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取 接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获 取接收端主机端口号 |
byte[] getData() | 获取数据报中的数据 |
构造UDP发送的数据报时,需要传入 SocketAddress ,该对象可以使用 InetSocketAddress 来创
建。
为了加强大家对UDP网络编程的理解,这便利用了回显服务器,顾名思义,其作用主要是返回你所输入的请求。
步骤:
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循环的原因是因为,服务器不知道客户端什么时候发送请求,所以需要严阵以待。
//直到客户端发送请求过来后,才解除阻塞
while(true) {
//循环里面处理一次请求
// 1. 读取请求并且分析
//这里的字节数组大小可任意。
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
//需要注意的是这里receive方法的参数,是个输出型参数,调用receive的时候,需要手动的传入一个空的
//DatagramPacket 对象,然后把对象交给receive,在receive里面负责把从网卡读到的数据,给填充到这个对象中。
socket.receive(requestPacket);
//把这个DatagramPacket 对象转成字符串,方便去打印
String request = new String(requestPacket.getData(),0, requestPacket.getLength());
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写到客户端
//还需在文件上写上客户端的数据才行,于是加上了requestPacket.getSocketAddress()
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
// 4. 打印一个日志,记录当前的情况
//requestPacket.getAddress().toString()客户端的端口号,客户端的IP地址:requestPacket.getPort()
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 {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
步骤:
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 {
//这里并不是说没有端口,而是让系统自动指定一个空闲的端口,因为如果是指定端口的话,可能会存在端口
//已经被占用的情况
socket = new DatagramSocket();
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().length;不能随意更改(比如修改为request.length),因为如果这个请求
//是中文的话,这两个值就不相同(如果都是ASCII的话就相同)。
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);
//如果服务器未把响应结果返回来,那么这时客户端也会进入阻塞状态,直到结果返回来。
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();
}
}
对于服务器来说,“读取请求并分析”,“根据请求计算响应”,“把响应写回客户端”.的三个请求执行速度极快,如果同一时间内,多个客户端发来请求,服务器也是可以响应的,但是在服务器本质上却是串行执行的。
当然了,具体速度还是要取决于实际的业务场景,如果确实快,就天然可以处理比较多的并发。
反之则需要使用多线程(多个CPU),和分布式了(多台主机来处理)。
上面我们也说到:服务器有时候是需要处理多个客户端的,那么如何在idea中处理多个客户端呢?
以上面的回显服务器为例,以下两个不同的端口号(这里的端口号是系统随机分配的)就代表两个不同的客户端。
翻译服务器大致内容与回显相同,只是process方法(根据请求计算响应)不一样。
这也说明一个服务器最核心的地方是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 dict = new HashMap<>();
public UdpTranslateServer (int port) throws SocketException {
super(port);
dict.put("cat","猫咪");
dict.put("dog","修勾");
//这里可以填入很多内容,像市面上见到的许多翻译软件,
// 都是这样实现的(有一个很大的hash表,包含了几乎所有单词)
}
@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();
}
}
TCP相较于UDP有很大的不同,TCP是需要建立连接的,而且是通过文件的读与写操作来进行以字节为单位的数据传输。
这里简单介绍两个常用的类:
ServerSocket API(给服务端使用的类)。
ServerSocket 构造方法:
方法签名 | 方法说明 |
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
ServerSocket 方法:
方法签 名 |
方法说明 |
Socket accept() |
开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket 对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
void close() |
关闭此套接字 |
Socket API(既可以用于客户端也可以用于服务器)
Socket 构造方法:
方法签名 | 方法说明 |
Socket(String host, int port) |
创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的 进程建立连接 |
socket方法:
方法签名 | 方法说明 |
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
第一个方法可以获取对方的IP地址和端口号,其中的后面两个方法,是通过socket来获取流对象,对其进行读和写。
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;
public class TcpEchoServer {
//
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 来接受客户端的连接
Socket clientSocket = listenSocket.accept();
//2.再调用这个连接
procession(clientSocket);
}
}
private void procession(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] 客户端下线",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 {
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
import sun.security.krb5.SCDynamicStoreConfig;
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 {
private Socket socket = null;
public TcpEchoClient(String serverIP,int serverPort) throws IOException {
// 和服务器建立连接,就需要知道服务器在哪,这里的IP是服务器的IP,而
//端口则是服务器随机分配的端口
socket = new Socket(serverIP,serverPort);
}
public void start() {
Scanner scanner = new Scanner(System.in);
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);
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();
}
}
但是上述代码其实是有些问题的,当客户端的数量从单个变为多个的时候,这时服务器就无法响应多个客户端的请求了。
原因如下:
因为在服务器代码中procession方法里面还有一层循环,这层循环需要与当前的客户端传输完成后才退出,此时如果有其他的客户端发送请求,就无法跳出循环来与其建立连接,这就像打电话遇到占线的情况一样。简单来说,就是一个线程只能连接一个客户端,所以最简单的方法就是使用多线程,这样就可以做到每连接一个客户端,就创建一个线程与其对接。
注:这里并不是说客户端的消息并未发送出去,客户端的数据是实实在在的发送出去了,但是服务器由于处于阻塞状态,没有时间进行处理):
于是对上述代码进行改进:
这里我们运用多线程的方法,让服务器即能快速重复的调用accept,又能循环的处理客户端的请求。
将上述的 start()方法优化如下:
public void start() throws IOException {
System.out.println("服务器启动");
while(true) {
//1.先调用 accept 来接受客户端的连接
Socket clientSocket = listenSocket.accept();
//2.这里的连接,应该实现多线程,也就是每个客户端连接上来的时候,都有一个线程负责处理
Thread t = new Thread(()->{
try{
procession(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
});
t.start();
}
}
但是仍然有些小瑕疵,因为这样需要频繁的创建以及销毁进程,于是我们再次进行优化,引入线程池:
public void start() throws IOException {
System.out.println("服务器启动");
ExecutorService service = Executors.newCachedThreadPool();
while(true) {
//1.先调用 accept 来接受客户端的连接
Socket clientSocket = listenSocket.accept();
//2.这里的连接,应该实现多线程,也就是每个客户端连接上来的时候,都有一个线程负责处理
service.submit(new Runnable() {
@Override
public void run() {
try {
procession(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接;
对比以上长短连接,两者区别如下:
扩展:
基于BIO(同步阻塞IO)的长连接会一直占用系统资源。对于并发要求很高的服务端系统来说,这样的消耗是不能承受的。
由于每个连接都需要不停的阻塞等待接收数据,所以每个连接都会在一个线程中运行。
一次阻塞等待对应着一次请求、响应,不停处理也就是长连接的特性:一直不关闭连接,不停的处理请求。
实际应用时,服务端一般是基于NIO(即同步非阻塞IO)来实现长连接,性能可以极大的提升。