目录
前言
本篇博客主要介绍Socket套接字基础和使用UDP套接字和TCP套接字进行网络编程,实现一个简单的回显服务器。
一、认识Socket套接字
概念:Socket套接字,是由系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。基于Socket套接字的网络程序开发就是网络编程。
Socket套接字分类:
主要分为三类:流套接字;数据报套接字:原始套接字(了解即可)
流套接字:使用传输层TCP协议 ;TCP,即Transmission Control Protocol(传输控制协议),传输层协议。 以下为TCP的特点:有连接,可靠传输,面向字节流,有接收缓冲区,也有发送缓冲区,大小不限。
对于字节流来说,可以简单的理解为,传输数据是基于IO流,流式数据的特征就是在IO流没有关闭的情况下,是无边界的数据,可以多次发送,也可以分开多次接收。
数据报套接字:使用传输层UDP协议; UDP,即User Datagram Protocol(用户数据报协议),传输层协议。 以下为UDP的特点:无连接,不可靠传输,面向数据报,有接收缓冲区,无发送缓冲区,大小受限:一次最多传输64k。
对于数据报来说,可以简单的理解为,传输数据是一块一块的,发送一块数据假如100个字节,必须一 次发送,接收也必须一次接收100个字节,而不能分100次,每次接收1个字节。
二、UDP数据报套接字编程
2.1、传输层提供的一些API
进行代码编写前我们先来了解一下传输层给应用层提供的一些API:UDP我们主要了解DatagramSocket、DatagramPacket、InetSocketAddress这三个API。其中Datagram意思就是数据博爱,Socket则说明了这是一个Socket对象。
Socket对象的介绍:这是一个对应到系统中的一个特殊的文件(Socket文件),socket文件并不是对应到硬盘上某个存储区域,而是对应到网卡这个硬件设备,因此,要进行网络通信就需要有socket这样的文件对象,借助socket对象才能间接操作网卡(socket对象相当于遥控器)。从socket对象中写数据相当于通过网卡发送消息,读数据相当于通过网卡接收消息。
DatagramSocket API:
这是一个UDP Socket,用于发送和接收UDP数据报;以下是DatagramSocket的构造方法:
方法签名 | 方法说明 |
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口 (一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用 于服务端) |
DatagramSocket的一些方法:
方法签名 | 方法说明 |
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻 塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
DatagramPacket API:
构造方法:
方法签名 | 方法说明 |
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() | 获取数据报中的数据 |
InetSocketAddress API:
方法签名 | 方法说明 |
InetSocketAddress(InetAddress addr, int port) | 创建一个Socket地址,包含IP地址和端口号 |
2.2 Udp回显服务器
Udp回显服务器:
服务器方面实现的主要思路是:①启动时需要循环等待着读取客户端的请求;②读取到请求之后,通过调用process方法计算响应;③将计算的响应返回。
客户端方面:①启动时循环等待用户发起请求;②读取到请求之后发送给服务器;③接收服务器返回的响应。
代码中的一些细节:
这里我们使用Socket对象来进行接收和发送数据操作。
传递后的数据为什么要转成String类型:完全是为了方便我们处理数据;
输出型参数:这里的send和receive使用的是输出型参数一般情况下我们是利用返回值作为方法的"输出",而使用参数作为方法的"输入",而这里则是利用传入的参数作为输出,也就是把参数传进去之后对参数进行填充,以此来接收方法的输出。
DatagramPacket和DatagramSocket的区别:对于DatagramPacket这个是用来构造一个用于发送数据的数据报的,而DatagramSocket是用来将构造好的数据报发送出去的。
为什么我们这里没有显示的调用close方法:close方法是要在我们确认了客户端和服务器都不需要再使用socket对象的时候才去调用的。我们这里的UdpEchoServer是在循环结束后才不需要,也就是start方法结束,也就是main方法结束,如果main方法结束了,UdpEchoServer自然也就结束了。因此这里不需要显示的调用close。客户端方面也是同理。
服务器代码:
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class BlogUdpEchoServer {
//创建一个udp服务器
DatagramSocket socket = null;
//这里的异常一般是端口被占用时抛出的
public BlogUdpEchoServer(int port) throws SocketException {//需要手动指定端口号,确保客户端可以通过端口号找到服务器
socket = new DatagramSocket(port);
}
//start方法是主逻辑的实现
public void start() throws IOException {
System.out.println("服务器启动:");
while (true) {//循环着等待客户端发来请求
//对于服务器端的操作
//1.读取客户端发来的请求(这里使用的是输入型参数)
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
//将响应转成字符串类型(完全是为了方便处理数据)
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
//2.计算响应 这里使用process方法计算响应,并返回
String response = process(request);
//将响应再转成DatagramPacket类型返回给客户端
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getSocketAddress());
//3.返回响应
socket.send(responsePacket);
System.out.printf("客户端端口号:%d,request:%s,response:%s",requestPacket.getPort(),request,response);
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
BlogUdpEchoServer udpEchoServer = new BlogUdpEchoServer(9090);
udpEchoServer.start();
}
}
客户端代码:
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.util.Scanner;
public class BlogUdpEchoClient {
DatagramSocket socket = null;
//客户端需要知道服务器的IP和端口号,才可以找到服务器
int serverPort;
String serverIP;
public BlogUdpEchoClient(int serverPort, String serverIP) throws SocketException {
this.serverIP = serverIP;
this.serverPort = serverPort;
socket = new DatagramSocket();//这里的客户端不需要手动指定端口号,系统自动分配空闲端口
}
public void start() throws IOException {//再start方法中实现客户端服务器通信的主逻辑
while(true) {
//1.客户端构造请求(这里通过键盘输入请求)
Scanner scanner = new Scanner(System.in);
System.out.println("->");
String request = scanner.next();
//构造成DatagramPacket数据报,客户端这边需要把服务器端口号和IP一起写进去
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.length(),
InetAddress.getByName(serverIP), serverPort);
//2.客户端发送请求给服务器
socket.send(requestPacket);
//3.客户端接收服务器返回的响应,并输出
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, requestPacket.getLength());
System.out.printf("request:%s,response:%s", request, response);
}
}
public static void main(String[] args) throws IOException {
BlogUdpEchoClient udpEchoClient = new BlogUdpEchoClient(9090, "127.0.0.1");
udpEchoClient.start();
}
}
运行结果:
总结:回显服务器本质上没有任何的实际意义,这里只不过是用来练习编写代码而已,而对于客户端服务器模型,也都是这样子的一个类型,都可以按照这样子的一个思路去实现。
Udp实现简单的翻译器:输入英文单词,返回对应的中文意思
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
public class UdpDictServer extends BlogUdpEchoServer{
private HashMap<String,String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
dict.put("dog","小狗");
dict.put("tiger","老虎");
dict.put("hello","你好");
dict.put("point","点");
dict.put("cat","小猫");
}
@Override
public String process(String request){
return dict.getOrDefault(request,"词典还未收录该单词");
}
public static void main(String[] args) throws IOException {
UdpDictServer udpDictServer = new UdpDictServer(9090);
udpDictServer.start();
}
}
三、TCP流套接字编程
3.1 传输层提供的一些API
ServerSocket API:
ServerSocket 是创建TCP服务端Socket的API。
构造方法:这个构造方法说明服务器必须绑定一个端口
方法签名 | 方法说明 |
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
ServerSocket 方法:
方法签名 | 方法说明 |
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket 对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
Socket API:
Socket 是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端 Socket。
不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。
构造方法:
方法签名 | 方法说明 |
Socket(String host, int port) | 创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的 进程建立连接 |
Socket 方法:
方法签名 | 方法说明 |
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
TCP中长连接和短连接的区别:
短连接:处理一个客户端连接中,会发来一个请求就完了(在代码中体现为处理一个连接的方法就可以不写循环,那就只会处理一次请求);
长连接:处理一个客户端连接,会发来多个请求,需要处理多个请求(在代码中体现为在处理一个连接的方法中使用while(true)循环来处理多个请求);
代码实现:
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;
public class BlogTcpEchoServer {
//服务器需要指定端口号
ServerSocket serverSocket = null;
public BlogTcpEchoServer(int port) throws IOException {//创建服务器的socket并指定端口号
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {//start方法里面实现主要的连接和收发请求的操作
System.out.println("服务器上线");
while (true) {//while循环才可以同时处理多个连接
//Tcp进来之后需要先建立连接,建立连接之后再通信,通过连接返回的Socket对象作为收发请求的"遥控器"
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(() -> {//因为这里的客户端可能多个,而如果不使用一个线程处理一个连接就无法处理多个连接同时进行
try {
processConnection(clientSocket);//新创建一个方法用来处理一个连接
} catch (IOException e) {
e.printStackTrace();
}
});
t.start();
}
}
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("客户端上线:%s,%d\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
//1.读取客户端请求(和Udp差别在于Tcp是面向字节流)
try(InputStream inputStream = clientSocket.getInputStream();//从连接的对象里读取请求,返回的响应也同样需要借助对象里的输出流
OutputStream outputStream = clientSocket.getOutputStream()){
//因为我们并不确定用户会发过来多少次请求,因此需要使用循环来处理多次请求
while (true){
//将请求从字节流构造成字符流
Scanner scanner = new Scanner(inputStream);//Scanner就是处理字符流的
//响应原本应该使用outputStream.write方法来写进网卡的,但是write方法无法写字符流,所以套上了printWriter
PrintWriter printWriter = new PrintWriter(outputStream);
if(!scanner.hasNext()){//如果读到流的结尾了,说明就结束了
System.out.println("客户端下线\n");
break;
}
String request = scanner.next();//否则就读取字符串,next读到\n换行就会停止
//2.根据请求计算响应
String response = process(request);
//3.返回响应
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 {
BlogTcpEchoServer tcpEchoServer = new BlogTcpEchoServer(1234);
tcpEchoServer.start();
}
}
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;
public class BlogTcpEchoClient {
//客户端需要知道服务器的端口号和IP地址
Socket socket = null;
String serverIP;
int serverPort;
public BlogTcpEchoClient(String serverIP,int serverPort){
this.serverPort = serverPort;
this.serverIP = serverIP;
try {
socket = new Socket(serverIP,serverPort);//客户端不需要手动指定端口号,系统自动分配端口
} catch (IOException e) {
e.printStackTrace();
}
}
public void start(){
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
while (true) {
//1.构造请求
Scanner scanner = new Scanner(System.in);
System.out.print("->");
String request = scanner.next();//键盘输入请求内容
//套上PrintWriter才可以直接发送字符流
PrintWriter printWriter = new PrintWriter(outputStream);
//2.发送请求给服务器
printWriter.println(request);//使用println是因为这个自带换行符
printWriter.flush();//需要刷新缓冲区,才可以立即发送
//3.接收服务器响应
Scanner scannerInput = new Scanner(inputStream);
String response = scannerInput.next();
//4.输出响应
System.out.printf("req:%s,resp:%s\n", request, response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
BlogTcpEchoClient tcpEchoClient = new BlogTcpEchoClient("127.0.0.1",1234);
tcpEchoClient.start();
}
}
运行结果:这里可以同时服务多个服务器
如何理解输入输出流:为什么InputStream是接收网卡传来的数据?OutputStream是把数据写进网卡?这里我们需要理解输入输出流要站在网卡的角度来理解,主要抓住在输入输出的对象是网卡,而不是我们使用者。因为这里的输入输出都是相对于网卡来说的,input就可以理解为输入到网卡中,所以就是网卡在接收数据,因此就可以从网卡中读网卡接收到的数据;output就可以理解为从网卡中输出,也就是网卡在发送数据。因此读数据就是从网卡上读数据,读其他设备输入到网卡的数据,写数据就是网卡把我们的数据输出到其他设备。
Tcp服务器中的一些细节说明:
一个小约定:由于Tcp是面向字节流的,不像Udp一条消息是一个数据报,在Tcp中如果要区分一条消息就需要采用分隔符的方法或者约定长度,就是在每条数据的开头规定n个字节来记录消息长度,接下去就读多少个字节。这里我们就采用分割符的方法来解决这个问题。以下程序规定"\n"作为分割符。
IDEA一般一个程序只能启动一个,但是这里我们需要多个客户端,所以一个程序需要启动多次,在IDEA中需要简单配置一下:
Tcp和Udp在写代码上的区别:Tcp是有连接的,面向字节流,而Udp只需要有一个DatagramSocket对象就可以接收和发送数据,也不需要建立连接的操作。Tcp则需要有一个serverSocket对象作为一个接收客户端发送过来的连接的对象,将收到连接返回的对象交给一个Socket对象去处理。Udp是以数据报为单位发送的,只要构造成一个DatagramPacket数据报对象就可以;而Tcp则是以字节为单位的,所以这里我们需要有一个分隔符类的东西去分割一个字节流里的数据,否则就无法区分哪里到哪里是一条完整的消息了。
为什么下面的代码需要构造成Scanner和PrintStream对象:
这里之所以这样子构造是因为Tcp传输的是字节流数据,对于Scanner:我们是将这个数据转成字符流,方便找到里面的"\n"这个字符,否则就需要一个字节一个字节的去处理,比较麻烦; 对于PrintStream:由于outputStream.write方法无法直接把字符流数据写进网卡中,所以将这个包装成PrintStream对象就可以直接写字符串的数据了。总之这里的操作就是为了操作方便的。
为什么需要调用flush方法: 在客户端和服务器方都有一个写入网卡缓冲区,当我们要写数据进网卡时,数据会先保存在缓冲区,当缓冲区满了再一次性发送,如果一有数据就发,这样子效率会比较低,因为把数据写进网卡是一个效率比较低的操作,频繁写效率自然会低。所以当我们要写数据进网卡,而没有调用flush方法的话,数据就不会立即被写入网卡,需要等网卡缓冲区满了才会写进去。因此需要调用flush方法来刷新缓冲区。
为什么是调用printStream.println()而不是print:由于这里我们约定要以"\n"作为数据的分割符,所以要写数据进网卡是需要带上"\n"的,由于这是一个回显服务器,所以客户端和服务器的规则相同,都以"\n"来作为分割符。使用println发送就会自带换行符了。
创建线程来处理一个连接和不创建线程来处理一个连接的区别:
如果没创建线程来处理连接一次就只能处理一个连接,无法同时处理多个客户端的请求,这就会出现以下情况:先启动的客户端有返回响应,后面启动的就只能阻塞等待了。因为在processConnection方法中是个死循环等待客户端的请求的,除非客户端断开连接才会结束。因此客户端2只有等到客户端1断开连接才可以收到服务器返回的响应。
为什么TCP服务器这边需要手动关闭资源而UDP不需要
TCP这边一个客户端就需要一个连接,这个连接的生命周期是和客户端一致的,客户端关闭后,如果连接没关闭,当客户端的连接达到一定数量后,服务器端的连接都没有关闭,就会导致资源泄漏的问题。而UDP那边始终只要一个Socket对象在处理响应,这个UDP对象的生命周期是伴随着整个服务器的,只要服务器关闭了,才需要关闭。