目录
套接字socket
TCP和UDP特点比较
特点
比较
UDP回显服务器/客户端的编写
UDP的socket api
回显服务器
客户端
TCP回显服务器/客户端的编写
TCP的socket api
回显服务器
客户端
优化服务器
1.关闭服务器创建的socket对象
2.引入线程池,为多个客户端提供服务
套接字socket
操作系统提供的网络编程的API称为“socket api”,在传输层中,TCP和UDP两种协议的特点和差异非常大,操作系统中就提供了两套api来分别表示:
流式套接字 -> TCP使用
数据报套接字 -> UDP使用
除此之外操作系统还有其他的关于网络编程的API,比如Unix域套接字,只是本地主机上的进程和进程之间的通信方式,不能跨主机通信,现在很少使用。
TCP和UDP特点比较
特点
TCP:有连接,可靠传输,面向字节流,全双工。
UDP:无连接,不可靠传输,面向数据报,全双工。
比较
有连接vs无连接:
有连接则是通信双方保存对方的信息,删除信息则断开连接,无连接则是通信双方不需要保存各自信息。在计算机中,各自保存双方的信息,就认为是建立了一个抽象的连接。
可靠传输vs不可靠传输:
可靠 != 安全,可靠值要传输的数尽可能的全部传输给对方,在网络通信过程中,可能会存在多种意外情况,比如丢包,丢包的过程是随机的,无法预知。为了对抗丢包,引入了可靠传输特点,TCP具体这一特点,内部提供了一系列机制来实现可靠传输,UDP则是不可靠传输,传输数据时不会关心数据是否到达,接收方是否收到。
面向字节流vs面向数据报:
TCP和文件操作都是面向字节流的,读写操作非常灵活。UDP面向数据报,传输的基本单位是一个个UDP数据报,每次读写只能读写一个完整的UDP数据报。
全双工vs版双工:
全双工:一条链路能够进行双向通信(TCP/UDP都是),在;一条链路上既可以接收也可以发送。
半双工:一条链路,只能进行单向通信,在Linux中,系统提供的一种软件资源:管道,就是半双工,接收和发送不能同时进行。
UDP回显服务器/客户端的编写
UDP的socket api
java对于系统提供的网络编程api(socket api)进行了进一步封装,进行UDP网络编程代码编写时,需要重点理解两个类:
(1)DatagramSocket:这个类是对操作系统socket概念的封装,系统中的socket可以理解为文件,socket文件可以视为网卡这个设备的抽象表示形式,针对socket文件的读写操作课相当于对网卡这个硬件设备进行读写。其实之前学习的普通文件,就是对硬盘这个硬件设备的抽象,直接操作硬盘不方便,借助文件进行操作就可以很方便的完成。类似于电视机的遥控器,通过遥控器来使用电视剧更加方便。
计算机中对具有”遥控属性“这样概念的叫做句柄(handle)。
(2)DatagramPacket:针对UDP数据报的抽象表示,一个DatagramPacket对象,就相当与一个UDP数据报,一次发送/一次接收就是传输了一个DatagramPacket对象。
回显服务器
Echo称为回显,正常服务器发送不同请求就会有不同响应,回显服务器就是请求发了什么,响应就是啥,这个过程不涉及计算和逻辑业务,是最简单的客户端服务器程序。
编写服务器程序时,首先需要确定端口号,客户端是主动的一方,服务器是被动的一方,客户端需要找到服务器在哪。
IP地址(服务器所在主机的IP地址),port端口号(一个主机上,有多个程序都要进行网络通信,需要把那个程序用的哪个端口号记录下来,并要确保一个端口号不能被两个或多个进程关联)。
import java.io.IOException;
import java.net.DatagramSocket;
public class UdppEchoServer {
private DatagramSocket socket = null;
public UdppEchoServer(int port) throws IOException {
socket = new DatagramSocket(port);
}
}
可以看到抛出的一异常可以是IOException,也就是说明网络编程的本质是IO操作。
接下来要让服务器可以不停的处理请求,不停的返回响应:
第一步:读取请求并对请求进行解析,构造一个数据报类的实例,对客户端发送的数据进行接收,放入实例中,对数据进行解析,最后为了方便打印,将数据报中的二进制数拿出来,转换为String类型的数据,String有一个构造方法,通过字节数组来构造。
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
String request = new String(requestPacket.getData(),0, requestPacket.getLength());
第二步:根据请求返回响应,由于是回显服务器,计算请求的方法直接返回就行,返回的响应使用String进行接收。
String response = process(request);
public String process(String request) {
return request;
}
第三步:将响应返回给客户端,发送时需要知道接收对象的IP和端口号,可以通过接收的UDP数据报拿到发送客户端的信息,拿到之后放到响应的数据报中使用send()方法发送,最后在服务器上面打印关键信息。
//把响应写回到客户端
//构造UDP数据包
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);
让程序启动后不断运行,加上while循环,一个完整的UDP回显服务器就此完成了。
客户端
客户端需要有自己的端口号和ip地址,客户端和服务器在一台主机上时就可以写本地环回地址(127.0.0.1),端口号是服务器在创建socket时指定的端口号。
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverPort;
private String serverIP;
public UdpEchoClient(String serverPort,String serverIP) throws SocketException {
socket = new DatagramSocket();
this.serverPort = serverPort;
this.serverIP = serverIP;
}
}
在创建socket对象时并没有指定端口号,这是因为操作系统会自动分配一个端口号,这个自动的端口号每次重启都会不一样。
服务器需要固定端口号,而客户端需要让系统自动分配:
(1)服务器要有固定端口号,是因为客户端需要主动给服务器发请求,如果服务器端口号不是固定的,客户端就会不知道把请求发给谁了。
(2)客户端需要系统自动分配,指定固定的端口号是不行的,指定客户端的端口号,可能会和客户端所在电脑上的其他程序冲突,一旦端口冲突,就会导致程序启动不了。服务器在自己手里,就算端口冲突也是可以调整的,但客户端不在本机上时,端口冲突难以解决。
客户端逻辑在编写时和服务器有相同之处,客户端发送请求到服务器后,使用UDP数据报来接受响应。
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private int serverPort;
private String serverIP;
public UdpEchoClient(String serverIP,int serverPort) throws SocketException {
socket = new DatagramSocket();
this.serverPort = serverPort;
this.serverIP = serverIP;
}
public void start() throws IOException {
System.out.println("客户端启动");
Scanner scanner = new Scanner(System.in);
while (true) {
//输入请求
System.out.println("请输入请求: ");
String request = scanner.next();
//构造请求
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),0,request.getBytes().length,
InetAddress.getByName(serverIP),serverPort);
//发送请求
socket.send(requestPacket);
//构造接收的数据报
DatagramPacket responsePack = new DatagramPacket(new byte[4096],4096);
//接收服务器返回的结果
socket.receive(responsePack);
//转换成String类型
String response = new String(responsePack.getData(),0, responsePack.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1",9090);
udpEchoClient.start();
}
}
服务器与客户端同时启动,客户端发送消息,服务器都会回显回来,并在控制台打印相应的ip地址,端口号,请求和响应:
TCP回显服务器/客户端的编写
TCP的socket api
主要涉及两个类:ServerSocket和Socket。
ServerSocket是专门提供给服务器使用的,ServerSocket在 实例化时调用的构造方法中自带端口号,实例化的对象包含一个accept方法,是一个类似接通功能的方法。
Socket是给客户端和服务器都提供服务,通过Socket的构造方法能够和指定的服务器建立连接。
TCP中通过使用InputStream和OutputStream进行文件读写操作,通过两个get方法来获取socket内部流对象。
Socket socket = new Socket();
socket.getInputStream();
socket.getOutputStream();
Tcp是字节流传输,传输基本单位是字节。
ServerSocket和Socket的功能不同:对于服务器来说,需要上来与客户端建立连接,建立连接要使用ServerSocket对象的accept方法,方法的返回值为socket类型;服务器一启动就会执行到建立连接的位置,如果此时没有客户端连接那么accept方法就会进入阻塞状态,直到有客户端连接。也就是说,ServerSocket是用与建立连接使用的,连接后将socket对象交给socket进行处理。
回显服务器
每次创建一个服务器对象都要创建一个SerrverSocket来连接客户端,创建服务器时要包含 端口号,否则客户端没有端口号就无法连接。
public class TcpEchoServer {
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
}
在服务器中创建start方法用来启动服务器,调用start方法后服务器开始不断的执行客户端的请求,不停的处理客户端的请求就需要不停的与客户端建立连接,通过调用process()方法去处理客户端的请求,将和客户端建立连接的信息使用socket接收,传递给process方法:
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
Socket socket = serverSocket.accept();
possess(socket);
}
}
实现process方法,要注意TCP是面向字节流传输,此时进行读写操作时需要使用InputStream和OutputStream,这两个类在使用完后必须回收防止资源泄露,避免数据丢失,使用 try-catch方法里的try with source用法,把对象放到try()中,使用 完毕会自动回收资源。获得数据后可以使用Read类读取请求,但是Read类读取请求后得到的是byte数组,需要进一步转换成字符串 ,这里使用Scanner去读取可以直接转换成String类型:
private void possess(Socket clientSocket) {
System.out.println("客户端上线");
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
Scanner scanner = new Scanner(inputStream);
while (true) {
String request = scanner.next();
}
}catch (IOException e) {
e.printStackTrace();
}
}
使用Scanner类读取数据时,读到"空白符"(空白符是一类字符的统称,包括但不限于换行,回车,空格,制表符,翻页符等)才会读取完毕 ,客户端在发送数据时,务必在每个请求的末尾加上空白符。
由于TCP是按照字节来传输的,在实际传输中,应该使若干个字节构成一个“应用层的数据报”,此时就可以通过使用空白符作为“分割符”,来区分不同的数据报。
将读取的请求交给处理请求的函数,使用的是process函数,这里对请求的处理实际上是直接返回请求(回显服务器):
String request = scanner.next();
String response = possessFun(request);
outputStream.write(response.getBytes());
在读取请求之前,应该判断文件是否有输入 ,可以在进入while循环之后使用if语句判断请求是否有输入,使用scanner的next方法来判断,请求到达后,并且带有明确的分隔符就会返回true,如果TCP断开连接,就会返回false,使用scanner读取到文件末尾或者TCP断开连接就会返回false,否则就会阻塞等待客户端继续发送请求:
Tcp断开连接->阻塞解除返回false。
Tcp没有断开连接->对方们没有发数据过来,阻塞等待。
客户端发送请求 -> 接触阻塞并返回true。
服务器完整代码:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoServer {
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
Socket socket = serverSocket.accept();
possess(socket);
}
}
private void possess(Socket clientSocket) {
System.out.println("客户端上线");
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
Scanner scanner = new Scanner(inputStream);
while (true) {
if(!scanner.hasNext()) {
System.out.println("客户端下线");
break;
}
String request = scanner.next();
String response = possessFun(request);
outputStream.write(response.getBytes());
}
}catch (IOException e) {
e.printStackTrace();
}
}
private String possessFun(String request) {
return request+" \n";
}
}
客户端
客户端在构造方法中应该有服务器的ip地址和端口号,在构造过程中就和服务器建立连接。
private Socket socket = null;
public TcpClientSocket(String address,int port) throws IOException {
socket = new Socket(address,port);
}
建立start方法去启动客户端输入请求,同样使用try-catch语句实例化字节流对象,使用Scanner在控制台上输入请求,将接收的语句发送给服务器之前使用‘/n’作为分割符 ,发送给服务器使用OutputStream,传输的单位是字节,将请求转换为byte进行发送。
public void start() {
System.out.println("'客户端启动");
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
Scanner scanner = new Scanner(System.in);
Scanner Rescanner = new Scanner(inputStream);
while (true) {
String request = scanner.next();
request += "/n";
outputStream.write(request.getBytes());
}
}
然后如同服务器一样,判断来自服务器的响应是否到达,使用scanner接收响应,在将接收的响应打印到控制台,客户端就完成编写:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Scanner;
public class TcpClientSocket {
private Socket socket = null;
public TcpClientSocket(String address,int port) throws IOException {
socket = new Socket(address,port);
}
public void start() {
System.out.println("'客户端启动");
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
Scanner scanner = new Scanner(System.in);
Scanner Rescanner = new Scanner(inputStream);
while (true) {
String request = scanner.next();
request += "/n";
outputStream.write(request.getBytes());
if(!Rescanner.hasNext()) {
break;
}
String response = Rescanner.next();
System.out.println(response);
}
}catch (IOException e) {
e.printStackTrace();
}
}
}
步骤:
1.客户端从控制台得到请求。2.将请求发送到服务器3.服务器接收请求4.服务器处理请求5服务器.将响应发送给客户端6.客户端接收响应7.将响应输出在控制台。
优化服务器
1.关闭服务器创建的socket对象
serverrSocket不需要特别关闭,因为生命周期是伴随整个服务器进程。客户端的socket也是如此,但是服务器用于接收客户端信息的socket就必须关闭,服务器会对应多个客户端,如果使用完毕后不关闭当前资源文件得不到释放,就会引起文件资源泄露。在服务器代码中加上finally语句释放socket对象。
2.引入线程池,为多个客户端提供服务
主线程处理accept,每次接收一个accept就创建一个线程来服务,这里使用可扩容的线程池:
public void start() throws IOException {
System.out.println("服务器启动");
//自动扩容线程池
ExecutorService pool = Executors.newCachedThreadPool();
while (true) {
Socket socket = serverSocket.accept();
pool.submit(new Runnable() {
@Override
public void run() {
try {
possess(socket);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
});
}
}
文章到这里就结束了,感谢观看。