【Java EE】-网络编程(二) Socket(套接字) + Udp版本客户端服务器 +Tcp版本客户端服务器

作者:学Java的冬瓜
博客主页:☀冬瓜的主页
专栏:【JavaEE】
主要内容:传输层协议对应Socket编程,DatagramSocket,DatagramPacket,Udp版本的客户端和服务器,UdpEchoSever,UdpEchoClient,Udp版本的查词典服务器底层原理;Tcp版本的客户端和服务器,TcpEchoServer,TcpEchoClient。Tcp版本的服务器的几个要点。

文章目录

  • 一、UDP和TCP
  • 二、Udp版本客户端服务器
    • 1、DatagramSocket和DatagramPacket(数据报)
    • 2、UdpEchoSever&&UdpEchoClient
      • 2.1、什么是Echo Sever?
      • 2.2、UDP客户端+UDP回显服务器代码
      • 2.3、查词典服务器代码
  • 三、Tcp版本客户端服务器
    • 1、ServerSocket和Socket
    • 2、TcpEchoServer&&TcpEchoClient
      • 2.1、Tcp客户端
      • 2.2、Tcp服务器
  • 三、UDP和TCP总结

一、UDP和TCP

       Socket API 是操作系统给应用程序提供的来进行网络数据的 发送和接收的api(即传输层给应用层使用的api)。
在需要通过操作系统来执行的传输层里,提供了两个最核心的协议:UDP和TCP。因此Socket API也提供了两种风格:UDP、TCP。下面我们来看看UDP和TCP两种方式有什么区别。

TCP:有连接,可靠传输,面向字节流,全双工。
UDP:无连接,不可靠传输,面向数据报,全双工。

直接记上面的功能可能有点懵,那我们来举例子来类比理解:
比如关于连接:打电话时,双方是先拨通后,才进行你一句我一句的通话,是有连接的;而如果是发消息,因为不需要拨通等双方都先接受,是无连接的。
比如关于是否可靠传输:打电话时,可以互相回应,可以知道我的消息对方收到没有,这是可靠传输。而如果是发消息,则我无法确定对方是否收到我的消息,则是不可靠传输。
面向的对象 :TCP是面向字节流,即操作单位是字节;而UDP是面向数据报进行编程,即操作单位是数据报(一个数据报带有一定的格式,可能有多个字节)。
全双工:比如一根水管,它只能实现单向输水,可以叫做半双工;而这里的全双工指的是一个通信管道,可以双向传输(既可以发送也可以接收),怎么实现的? 一根网线里,其实有8根线,4根负责传输,4根负责接收,这样就完美实现全双工。

二、Udp版本客户端服务器

1、DatagramSocket和DatagramPacket(数据报)

基于 UDP 来编写一个客户端服务器的网络通信程序:
DatagramSocket:使用这个类表示一个Socket对象,在操作系统中,把这个Socket对象也当成是一个文件来处理,是在文件描述符表上的一项。
区别是:普通文件对应的硬件是 硬盘;而Socket对象对应的硬件是网卡,或者说操作系统内核中,用"Socket"这样的文件对象来抽象表示网卡。
DatagramPacket:表示UDP传输中的一个数据报。

DatagramSocket类的相关方法:
构造方法:

构造方法 说明
DatagramSocket() 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口一般用于客户端)
DatagramSocket(intport) 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务端)
  • 进程关联了端口号,本质上是进程里的Socket对象关联了 端口号。同时一个进程可以创建多个Socket对象,每个Socket对象都可以连接到不同的网络地址和端口。因此一个进程可以关联多个端口,但一个端口只能关联一个进程。

普通方法:

普通方法 说明
void receive(DatagramPacket p) 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞待)
void send(DatagramPacket p) 从此套接字发送数据报包(不会阻塞等待,直接发送)
void close() 关闭此数据报套接字
  • receive方法中的DatagramPacket是我们创建的传入的一个空的对象,当receive接收到发送方发来的数据报时,才把发送方发来的内容填充进入我们传入的这个空的DatagramPacket对象,得到接收到的数据报,这个参数也叫做"输出型参数"。

DatagramPacket类(数据报)的相关方法:
构造方法:

构造方法 说明
DatagramPacket(byte[] buf, int length) 构造一个DatagramPacket以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收指定长度(第二个参数length)
DatagramPacket(byte[] buf, int offset, int length,SocketAddress address) 构造一个DatagramPacket以用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从0到指定长度(第二个参数length)。address指定目的主机的IP和端口号

普通方法:

普通方法 说明
InetAddress getAddress() 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址
int getPort() 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号
byte[] getData() 获取数据报中的数据(字节数组的形式)
int getLength() 获取数据报中数据的长度
SocketAddress getSocketAddress() 获取数据报发送端主机的IP和端口号(DatagramPacket中隐含发送方的IP和端口)

2、UdpEchoSever&&UdpEchoClient

2.1、什么是Echo Sever?

Echo Sever是一种基于客户端/服务器模型的网络应用程序,它的功能是将客户端发送的数据原封不断地返回给客户端,称为回显服务器。
这里我们主要来理解怎么实现客户端服务器,以及Socket API的使用,所以就省略了中间的业务逻辑,而是直接把客户端发来的数据直接返回。

2.2、UDP客户端+UDP回显服务器代码

客户端

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 UdpEchoClient {
    private DatagramSocket socket = null;
    // 客户端的ip是环回ip(127.0.0.1),端口是操作系统随机分配的一个端口
    // 因为在本机模拟通信,所以服务器的ip也是环回ip(127.0.0.1),端口是程序员指定的
    // 服务器的ip和端口都得告诉客户端,我们才能在客户端访问服务器
    private String serverIp = null;
    private int serverPort = 0;

    public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
        this.socket = new DatagramSocket();
        this.serverIp = serverIp;
        this.serverPort = serverPort;
    }
    public void start() throws IOException {
        System.out.println("客户端启动!");
        Scanner scanner = new Scanner(System.in);
        while (true){
            // 1.从控制台读取数据到一个空的DatagramPacket中
            System.out.print("> ");
            String request = scanner.next();
            if(request.equals("exit")){
                System.out.println("客户端关闭!");
                break;
            }
            
            // 注意1:InetAddress.getByName(serverIp)操作把点分十进制的ip(127.0.0.1)转换成32位二进制数
            // 注意2:发送数据报时,使用String的getBytes().length方法获取数据报长度
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length
                                           ,InetAddress.getByName(serverIp), this.serverPort);
            // 2.把DatagramPacket发给服务器
            socket.send(requestPacket);
            
            // 3.使用空的DatagramPacket,接收服务器处理后的响应数据
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
            socket.receive(responsePacket);  // 注意:如果receive没有接收到响应数据,那就会阻塞等待。

            // 4.打印响应结果
            // 注意1:打印返回的响应结果,不能用toString,因为你无法为DatagramPacket类重写toString方法
            // 注意2:接收数据报时使用DatagramPacket的getLength方法获取数据报长度
            String response = new String(responsePacket.getData(),0, responsePacket.getLength());
            System.out.println(response);
        }
    }
    public static void main(String[] args) throws IOException {
        UdpEchoClient client = new UdpEchoClient("127.0.0.1",9090);
        client.start();
    }
}

客户端代码步骤:

  1. 用一个空的DatagramPacket类型的 requestPacket接收用户从控制台输入的数据(接收字符串)
  2. 根据给出的服务器的ip和端口,发送这个DatagramPacket类型的 requestPacket给服务器处理数据
  3. 用DatagramPacket类型的空的 responsePacket接收服务器发来的处理后的数据(接收数据报)
  4. 把数据报类型的响应内容 requestPacket转换成字符串 request,便于打印

服务器

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;

public class UdpEchoServer {
    // 注意:1.这个socket对象在操作系统内核中操作时,是当成文件的方式操作,把这个对象当成网卡的抽象
    private DatagramSocket socket = null;
    // 注意:2.服务器端需要手动指定一个端口,避免客户端找不到服务器
    public UdpEchoServer(int port) throws SocketException {
        this.socket = new DatagramSocket(port);
    }

    public void start() throws IOException {
        System.out.println("服务器启动!");
        while (true){
            // 1.给一个空的DatagramPacket,用于接收客户端发来的数据报
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
            socket.receive(requestPacket);   // 注意:如果receive没有接收到请求数据,那就会阻塞等待。
            
            // 注意1:为了便于处理,把DatagramPacket这个特殊的对象转化成字符串的形式,但是不能用toString,因为你无法为DatagramPacket类重写toString方法
            // 注意2:接收数据报时使用DatagramPacket的getLength方法获取数据报长度
            String request = new String(requestPacket.getData(),0,requestPacket.getLength());
            // 2.对请求内容进行业务处理(这里是回显服务器直接返回)
            String response = process(request);
            
            // 3.构造好响应的DatagramPacket,并把它发回客户端。
            // (注意1:这里也可以直接使用requestPacket.getSocketAddress()同时获取IP和端口,客户端的端口和ip是requestPacket自带的。
            //  注意2:第二个参数必须是字节数组长度response.getBytes().length,而不是字符串的长度
            //        使用String的getBytes().length方法获取数据报长度)
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length
                    ,requestPacket.getAddress(),requestPacket.getPort());
            socket.send(responsePacket);

            // 4.为了观察,打印一下客户端发来的的信息
            System.out.printf("[%s,%d] req:%s; resp:%s\n",requestPacket.getAddress(),requestPacket.getPort()
                                                          ,request, response);
        }
    }

    private String process(String request) {
        return request;
    }
    public static void main(String[] args) throws IOException {
        UdpEchoServer sever = new UdpEchoServer(9090);
        sever.start();
    }
}

服务器代码步骤:

  1. 用一个空的DatagramPacket类型的 requestPacket接收 客户端发来的数据(接收数据报)
  2. 把这个requestPacket转换成字符串 request,然后进行业务处理得到字符串 response
  3. 用一个空的DatagramPacket类型的 requestPacket接收响应字符串 response(接收响应字符串)
    然后根据requestPacket自带的客户端的ip和端口,把响应发给客户端。
  4. 打印中间过程,客户端的ip和端口,服务器的处理请求和响应。

执行顺序:

1.服务器先启动,进行到receive进行阻塞,等待客户端发送请求数据报(服务器)
2.客户端读取用户输入内容到请求数据报(客户端)
3.客户端执行send把请求数据报发给服务器(客户端)
4.客户端发送请求数据报后立即执行到receive,等待服务器发来响应数据报(客户端)
  服务器接收到请求数据报,从服务器的receive阻塞中返回(服务器)
5.服务器根据请求数据报计算响应数据报(服务器)
6.服务器执行send,发送响应数据报给客户端(服务器)
7.客户端从receive阻塞中返回,读到响应数据报(客户端)

2.3、查词典服务器代码

  • 注意:需要复用上面 UDP客户端+服务器 中的代码。
    操作是:使用UdpDictSever继承UdpEchoSever,再重写process方法。
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;

public class UdpDictSever extends UdpEchoServer{
    // 使用一个集合来存放单词集合
    private Map<String,String> dict = new HashMap<>();

    public UdpDictSever(int port) throws SocketException {
        super(port);
        dict.put("cat","猫");
        dict.put("beautiful","美丽的");
        dict.put("perfect","完美的");
    }

    @Override
    public String process(String request){
        return dict.getOrDefault(request,"没有你要查的单词!");
    }

    public static void main(String[] args) throws IOException {
        UdpDictSever sever = new UdpDictSever(9090);
        sever.start();
    }
}

三、Tcp版本客户端服务器

1、ServerSocket和Socket

ServerSocket:专门给服务器使用的Socket对象。
Socket:既可以给客户端使用,也可以给服务器使用的Socket对象。

注意1:
ServerSocket 用于服务器端本身的ServerSocket对象的创建;
Socket 用于客户端本身的Socket对象的创建(指定服务器的ip和端口) 和 服务器端accept与客户端连接后返回的Socket对象

注意2:
服务器端accept后,返回得到一个Socket对象,通过这个Socket对象和客户端 使用字节流进行 发送/接收。

ServerSocket类的相关方法:
构造方法:

构造方法 说明
ServerSocket(int port) 创建一个服务端流套接字Socket,并绑定到指定端口

普通方法:

普通方法 说明
ServerSocket(int port) 创建一个服务端流套接字ServletSocket类的对象,并绑定到指定端口
Socket accept() 开始监听服务器端的绑定的端口,有客户端连接后,返回给服务器端一个Socket对象,并基于该Socket对象建立与客户端的连接,如果没有客户端连接则accept阻塞等待
void close() 关闭此套接字(Socket)

Socket类的相关方法:
构造方法:

构造方法 说明
Socket(String host, int port) 创建一个客户端流的Socket类的对象,和对应IP的主机上的对应端口建立连接

普通方法:

普通方法 说明
InetAddress getInetAddress() 返回调用该方法的Socket对象的对应连接的Ip
int getPort() 返回调用该方法的Socket对象的对应连接的端口
InputStream getInputStream() 返回调用该方法的Socket对象的输入流
OutputStream getOutputStream() 返回调用该方法的Socket对象的输出流

2、TcpEchoServer&&TcpEchoClient

2.1、Tcp客户端

Tcp版本的客户端和Udp版本的客户端的区别:

Udp版本的客户端(端口和DatagramSocket建立关联) 使用两个成员变量来表示指定服务器的serverIp和serverPort,且发送数据时需要把点分十进制的目标服务器的ip(serverIp)转换成32位的二进制数据,使用数据报进行发送/接收。
Tcp版本的客户端(端口和Socket建立关联) 要先根据指定服务器的serverIp和serverPort建立连接客户端的new Socket时传入serverIp和serverPort,它可以自动识别点分十进制为32位二进制数,然后使用字节流读写网卡(即接收/发送信息),其实是在协议栈里处理,然后交由网卡发送和接收。

客户端:

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 {
        // 注意1:在客户端new一个Socket对象的时候,就连接服务器。
        // 注意2:Socket对象可以字节把点分十进制的serverIp转换成32位二进制数
        socket = new Socket(serverIp,serverPort);
    }

    public void start(){
        System.out.println("客户端启动!");
        try(InputStream inputStream = socket.getInputStream();
            OutputStream outputStream = socket.getOutputStream()){
            Scanner scanner = new Scanner(System.in);
            while (true){
                // 1.客户端从控制台读取用户输入的内容
                System.out.print(">");
                String request = scanner.next();
                if (request.equals("exit")){
                    System.out.println("客户端关闭!");
                    break;
                }

                // 2.客户端把请求写入网卡,发送给服务器处理
                PrintWriter printWriter = new PrintWriter(outputStream);
                printWriter.println(request);   //注意:要写入"\n"
                printWriter.flush();  // 冲刷,保证数据写入网卡

                // 3.客户端读取服务器响应写回到网卡上的数据
                Scanner respScan = new Scanner(inputStream);
                String response = respScan.next();

                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();
    }
}
客户端代码步骤:
1. 客户端从控制台读取用户输入的内容
2. 客户端把请求写入网卡,发送给服务器处理
3. 客户端读取服务器响应写回到网卡上的数据
4. 打印响应的结果

2.2、Tcp服务器

Tcp版本的服务器和Udp版本的服务器的区别:

Udp版本服务器不需要建立连接,使用数据报传输,如果客户端访问量不是很多,可以不用多线程,可以多个客户端同时访问,直接while就搞定。
Tcp版本需要建立连接,使用字节流传输,使用多线程(或者线程池),如果不用多线程,那么因为每个客户端访问都需要连接,在有客户端连接时,其它客户端则无法连接,导致无法使用,导致效率问题。

Tcp版本的服务器需要注意的点:

  • 1> Tcp版本的服务器需要在发送消息时在数据后面加上\n。因为接收端读取数据时使用Scanner的next方法读取,next方法规则是:读到换行符/空格/tab时结束,读到的数据不包含以上符号。所以发送端可以在数据的结尾加上\n,表示读取数据结束。这个点客户端也是一样。如下图:printWriter.println(outputStream)表示在发送数据outputStream后面加上一个\n。发送outputStream后,一定记得flash,把信息真正的发送。
    在这里插入图片描述
    在这里插入图片描述

  • 2> 在Tcp版本的服务器端中,需要关闭客户端访问时创建的Socket资源。每次有一个客户端访问服务器,就会创建一个Socket对象和客户端的Socket连接。服务器端每创建一个Socket对象,就在服务器的这个进程上的文件描述符表上占用一个空间,而客户端访问量应该是很多的。因此如果连接完成后,不关闭这个Socket,到了文件描述符表位置被占满时,其它客户端就无法再访问服务器了,因此,在每个客户端连接完成后,我们需要关闭服务器端的这个Socket资源,释放这个Socket占用的文件描述符表的位置。
    那么为什么Udp版本的服务器不需要关闭?Udp版本服务器端的的DatagramSocket的生命周期是整个进程。而Tcp版本的clientSocket的生命周期是每个客户端连接时,断开连接,这个Socket就没用了,且因为每创建一个客户端连接,服务器就会创建一个clientSocket,所以数量上也会很多!

  • 3> 短连接和长连接:下列代码的processConnection中的while去掉就是短连接,即传输一次就断开连接,每次访问都得先连接再发送请求;长连接即用while,当一个客户端连接好服务器然后发送请求后,先不断开连接,等待用户再次发送请求,等用户自己退出时才断开连接。
    在这里插入图片描述

  • 4> IO多路复用,如果客户端访问量很大,即使使用多线程服务器压力还是很大,就需要用IO多路复用。比如C10K问题(1w个客户端),C10M问题(1kw个客户端访问)。IO多路复用,可以使用一个线程处理多个客户端的任务。原理:在这个线程中使用一个集合来存放连接对象,这个线程就负责监听这个集合,在集合中哪个连接有数据来了,线程就处理这个连接。在操作系统中提供了select,epoll就可以监听。

服务器:

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 TcpEchoSever {
    private ServerSocket serverSocket = null;
    // 注意:服务器本身使用ServerSocket和端口绑定连接
    public TcpEchoSever(int Port) throws IOException {
        serverSocket = new ServerSocket(Port);
    }


    public void start() throws IOException {
        System.out.println("启动服务器!");
        // 注意:使用while保证每次有客户端连接时都能连接到
        while (true){
             版本一:使用多线程
//            // 注意:每当有一个客户端连接服务器时,创建一个Socket对象和客户端的Socket进行通信
//            Socket clientSocket = serverSocket.accept();
//            // 注意:建立连接使用当前线程,放在我们创建的线程外;使用多线程去处理客户端发来的请求(处理业务)
//            Thread t = new Thread(()->{
//                try {
//                    processConnection(clientSocket);
//                } catch (IOException e) {
//                    e.printStackTrace();
//                }
//            });
//            t.start();

            // 版本二:使用线程池
            Socket clientSocket = serverSocket.accept();
            ExecutorService pool = Executors.newCachedThreadPool();
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    try {
                        processConnection(clientSocket);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            });
        }
    }

    // 注意:一个连接对应一个客户端,
    private void processConnection(Socket clientSocket) throws IOException {
        // 注意:服务器的每一个Socket对应一个客户端
        System.out.printf("[%s:%d] 客户端上线!\n",
                clientSocket.getInetAddress().toString(),
                clientSocket.getPort());

        try (InputStream inputStream = clientSocket.getInputStream();
             OutputStream outputStream = clientSocket.getOutputStream()){
            // 注意:由于一个客户端可能要处理多个请求和响应,所以使用循环进行
            while (true){
                // 1.服务器读取客户端写入网卡的字节流数据
                Scanner reqScan = new Scanner(inputStream);
                if (!reqScan.hasNext()){
                    System.out.printf("[%s:%d] 客户端下线!\n",
                            clientSocket.getInetAddress().toString(),
                            clientSocket.getPort());
                    break;
                }
                String request = reqScan.next();
                // 注意:next读到换行符/空格/tab结束,但是读取的内容不包含换行符/空格等
                //    我们这里是从客户端的请求内容就读取,所以客户端发来的请求中应当有以上结束符

                // 2.对请求进行业务处理
                String response = process(request);

                // 3.服务器把响应内容写回网卡,响应给客户端
                //   操作:用outputStream构造一个PrintWriter字符流对象,便于把"\n"一并写入网卡
                PrintWriter printWriter = new PrintWriter(outputStream);
                printWriter.println(response);
                printWriter.flush();   // 冲刷,保证数据写入网卡

                // 4.打印日志
                System.out.printf("[%s:%d] req:%s; resp:%s\n",
                        clientSocket.getInetAddress(),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 {
        TcpEchoSever sever = new TcpEchoSever(9090);
        sever.start();
    }
}
服务器代码步骤:
1. 服务器读取客户端写入网卡的字节流数据
2. 对请求进行业务处理
3. 服务器把响应内容写回网卡,响应给客户端

执行顺序:

1.服务器先启动,进行到accept进行阻塞,等待客户端new Socket从而建立连接(服务器)
2.客户端从控制台读取用户输入内容(客户端)
3.客户端使用OutputStream把请求发给服务器(客户端)
4.服务器Socket感知到请求并使用InputStream接收请求(服务器)
5.服务器根据请求计算响应(服务器)
6.服务器使用OutputStream把响应发回客户端(服务器)
7.客户端Socket感知到请求并使用InputStream接收请求(客户端)
8.客户端打印响应结果

三、UDP和TCP总结

在这里插入图片描述
在这里插入图片描述

你可能感兴趣的:(JAVAEE,网络,udp,tcp/ip)