网络编程套接字(UDP与TCP网络编程)

前言:该篇文章直接从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网络编程

1.1 基础概念

数据报套接字:使用传输层UDP协议:  UDP,即User Datagram Protocol(用户数据报协议),传输层协议。

以下为UDP的特点:

  • 无连接
  • 不可靠传输
  • 面向数据报(发送和接受数据,都需要使用数据报,下方回显服务器会展示)
  • 有接收缓冲区,无发送缓冲区
  • 大小受限:一次最多传输64k.

解析:对于数据报来说,可以简单的理解为,传输数据是一块一块的,发送一块数据假如100个字节,必须一次发送,接收也必须一次接收100个字节,而不能分100次,每次接收1个字节。

1.2 DatagramSocket API 

我们知道我们的数据是从应用层开始封装,一直到物理层封装完成并发送,那数据传输的第一步就是将应用层的数据交给传输层,为了完成这个过程,操作系统提供了一组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() 关闭此数据报套接字

1.3 DatagramPacket API

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 来创
建。

1.4 UDP回显服务器 

为了加强大家对UDP网络编程的理解,这便利用了回显服务器,顾名思义,其作用主要是返回你所输入的请求。

1.4.1 服务器:

步骤:

  1. 创建Socket实例对象(DatagramSocket对象),并指定服务器的端口号。
  2. 启动服务器。
  3. 获取客户端请求(DatagramPacket为载体)。
  4. 处理客户端请求,并获取计算后的响应。
  5. 发送处理响应(DatagramPacket为载体)。
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();
    }
}

1.4.2 服务器客户端

步骤:

  1. 创建Socket实例对象(DatagramSocket对象)。
  2. 用户输入请求。
  3. 读取用户请求。
  4. 打包请求并发送给服务器(DatagramPacket对象打包数据)。
  5. 等待响应。
  6. 接收响应,并反馈。
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();
    }

}

Tip1:

对于服务器来说,“读取请求并分析”,“根据请求计算响应”,“把响应写回客户端”.的三个请求执行速度极快,如果同一时间内,多个客户端发来请求,服务器也是可以响应的,但是在服务器本质上却是串行执行的。

当然了,具体速度还是要取决于实际的业务场景,如果确实快,就天然可以处理比较多的并发。

反之则需要使用多线程(多个CPU),和分布式了(多台主机来处理)。

Tips2: 

上面我们也说到:服务器有时候是需要处理多个客户端的,那么如何在idea中处理多个客户端呢?

网络编程套接字(UDP与TCP网络编程)_第1张图片

网络编程套接字(UDP与TCP网络编程)_第2张图片

 以上面的回显服务器为例,以下两个不同的端口号(这里的端口号是系统随机分配的)就代表两个不同的客户端。

网络编程套接字(UDP与TCP网络编程)_第3张图片 
1.5 UDP翻译服务器 

翻译服务器大致内容与回显相同,只是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网络编程

TCP相较于UDP有很大的不同,TCP是需要建立连接的,而且是通过文件的读与写操作来进行以字节为单位的数据传输。

网络编程套接字(UDP与TCP网络编程)_第4张图片

这里简单介绍两个常用的类:

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来获取流对象,对其进行读和写。

2.1 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 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();
    }
}

2.2 TCP回显客户端

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();
    }
}

2.3 解决无法同时启动多个客户端的问题

但是上述代码其实是有些问题的,当客户端的数量从单个变为多个的时候,这时服务器就无法响应多个客户端的请求了。

原因如下:

因为在服务器代码中procession方法里面还有一层循环,这层循环需要与当前的客户端传输完成后才退出,此时如果有其他的客户端发送请求,就无法跳出循环来与其建立连接,这就像打电话遇到占线的情况一样。简单来说,就是一个线程只能连接一个客户端,所以最简单的方法就是使用多线程,这样就可以做到每连接一个客户端,就创建一个线程与其对接。

网络编程套接字(UDP与TCP网络编程)_第5张图片

 注:这里并不是说客户端的消息并未发送出去,客户端的数据是实实在在的发送出去了,但是服务器由于处于阻塞状态,没有时间进行处理):

网络编程套接字(UDP与TCP网络编程)_第6张图片

于是对上述代码进行改进:
这里我们运用多线程的方法,让服务器即能快速重复的调用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();
                    }
                }
            });
        }
    }

2.4 TCP中的长短连接

TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接;

  1. 短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
  2. 长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。

对比以上长短连接,两者区别如下:

  • 建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要第一次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也是要耗时的,长连接效率更高。
  • 主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求,也可以是服务端主动发。
  • 两者的使用场景有不同:短连接适用于客户端请求频率不高的场景,如浏览网页等。长连接适用于客户端与服务端通信频繁的场景,如聊天室,实时游戏等。

扩展:
基于BIO(同步阻塞IO)的长连接会一直占用系统资源。对于并发要求很高的服务端系统来说,这样的消耗是不能承受的。

由于每个连接都需要不停的阻塞等待接收数据,所以每个连接都会在一个线程中运行。
一次阻塞等待对应着一次请求、响应,不停处理也就是长连接的特性:一直不关闭连接,不停的处理请求。

实际应用时,服务端一般是基于NIO(即同步非阻塞IO)来实现长连接,性能可以极大的提升。
 

你可能感兴趣的:(网络,服务器,linux)