我要学编程(ಥ_ಥ)

我要学编程(ಥ_ಥ)

 找往期文章包括但不限于本期文章中不懂的知识点:

目录

 TCP 与 UDP

Socket套接字

UDP

TCP


 

网络基础知识   在一篇文章中,我们了解了基础的网络知识,网络的出现就是为了不同机器之间进行通信从而实现资源共享。现如今我们使用网络进行的一系列操作,打游戏、网上购物、网上聊天等都是客户端与服务器之间通信,准确的来说是多个客户端之间通过服务器这个平台来实现通信。而今天我们就是要来实现一个最简单的服务器与客户端。在此之前还得了解一些基本概念。

 TCP 与 UDP

上文了解了 TCP/IP 五层协议的基本分层,在以后的日常开发中,写的一些应用程序都是工作在应用层,而应用层是基于传输层的,我们也是需要了解传输层的传输协议的,主要是两个协议:TCP协议 与 UDP 协议。 

了解了TCP 与 UDP 的基本点之后,还需要了解 JVM对于操作系统提供的API封装后的结果,毕竟我们通过Java代码来编写网络编程时,是直接使用Java标准库中提供的类。

Socket套接字

Socket套接字,是由操作系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。 基于Socket套接字的网络程序开发就是网络编程。而经过JVM封装之后,就主要是针对 TCP 和 UDP 的。

UDP

java中使用UDP协议通信,主要基于DatagramSocket 类来创建数据报套接字,并使用
DatagramPacket作为发送或接收的UDP数据报。

因为操作系统为了方面更好的管理系统资源(包括硬件资源),所以操作系统采用了文件管理的方式来管理这些资源,这也就意味着某个应用程序去使用这些资源时,就和使用文件资源没什么区别了,也就是打开文件、使用文件、关闭文件。因此网卡资源的使用也是如此。

下面就来学习相关方法:

DatagramSocket 是UDP Socket,用于发送和接收UDP数据报。

这里的数据报套接字我们可以简单的理解为网卡资源,receive方法就是通过网卡接收数据,send方法就是通过网卡发送数据。 构造方法是在打开网卡资源,close方法就是在关闭网卡资源。

DatagramPacket是UDP Socket发送和接收的数据报。

注意区分上述两个概念:DatagramSocket 是用来传送与接收数据报的,而DatagramPacket 是数据报本身的一层封装,简单理解就是数据报本身。生活中的例子,就是DatagramSocket 是属于快递站,而DatagramPacket 是属于包裹。包裹要通过快递站的分拣传递出去。

 由于UDP是无连接的,因此构造UDP发送的数据报时,需要传入 SocketAddress ,该对象可以使用 InetSocketAddresS 来创建。即 InetSocketAddress 是 SocketAddress 的子类。

先来理解服务器与客户端这个两个名词的含义: 

举个例子:我们去学校食堂吃饭时,可能某个窗口的饭菜比较好吃,那么我们下一次或者以后都有可能会去这个窗口吃饭,而这个窗口肯定是一直在这个食堂的某个固定地点的,而这个窗口所服务的学生不是固定的,每个学生去吃饭时,肯定也是随机选择的座位坐下来吃饭。

针对上面的情况,食堂的窗口就是服务器,吃饭的学生就是客户端,客户端会给服务器提供请求(我们会把吃的菜告诉食堂阿姨),服务器会给客户端提供响应(食堂阿姨就会给我们打对应的菜)。因为服务器(食堂窗口)是需要给多个客户端提供响应,如果这个服务器的端口老是发生变化(窗口老是发生变化),那肯定是不方便客户端去访问的,因此服务器的IP与端口都是在一段时间内固定的,而客户端的端口(学生在吃饭找的座位)肯定是随机的,如果某个学生没在这里,但是他占了一个位置,那么肯定是不合理的,同样某个客户端没有启动进程访问服务器时,一直把端口号给踹在怀里肯定也是会对别的进程造成影响的(端口号是有限的)。

有了以上信息,我们就可以来写一个最简单服务器:回显服务器(接收到的请求就是响应,即接收的请求是什么,服务器返回的响应也就是什么,类似于鹦鹉学舌)。

服务器的处理逻辑:1、接收请求并解析;2、根据请求计算响应;3、将相应发送给响应的客户端;4、打印日志。

public class UdpEchoServer {
    // 创建网卡资源
    DatagramSocket socket = null;
    public UdpEchoServer(int port) throws SocketException {
        // 指定本机的一个固定端口号为服务器的端口号
        socket = new DatagramSocket(port);
    }
    // 启动服务器方法
    public void start() throws IOException {
        // 由于服务器是7*24小时的工作制,因此得死循环
        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、根据请求计算响应
            String response = process(request);
            // 3、将响应返回给客户端
            // 也是通过数据报的形式
            // 由于UDP是无连接的,因此我们得手动去设置发送的IP与端口号
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), 0, response.getBytes().length,
                    requestPacket.getAddress(), requestPacket.getPort());
            socket.send(responsePacket);
            // 4、打印日志:客户端IP、客户端端口号、请求、响应
            System.out.printf("[%s %d]  request:%s  response:%s\n", requestPacket.getAddress(),
                    requestPacket.getPort(), request, response);
        }
    }

    // 后续如果要修改服务器的功能,就只需要重载process方法即可
    private String process(String request) {
        return request; // 回显服务器的功能
    }

    public static void main(String[] args) throws IOException {
        // 创建一个服务器实例并启动服务器
        UdpEchoServer server = new UdpEchoServer(9090);
        server.start();
    }
}

有了服务器之后,就可以来创建客户端程序了。

public class UdpEchoClient {
    DatagramSocket socket = null;
    // UDP是不连接,因此客户端得保存对应服务器的IP与端口号
    private String serverIP = null;
    private int serverPort = 0;

    // 指定需要访问的服务器IP与端口号
    public UdpEchoClient(String serverIP, int serverPort) {
        this.serverIP = serverIP;
        this.serverPort = serverPort;
    }

    public void start() throws IOException {
        System.out.println("客户端启动成功(exit退出)~");
        // 创建网卡资源
        socket = new DatagramSocket();
        while (true) {
            // 1、开始接收用户的输入
            Scanner scanner = new Scanner(System.in);
            String request = scanner.nextLine();
            if (request.equals("exit")) {
                socket.close(); // 释放网卡资源
                System.out.println("客户端成功退出~")
                break;
            }
            // 2、将输入数据打包成数据报 (指定服务器IP与端口号)
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), 0 ,request.getBytes().length
            , InetAddress.getByName(serverIP), serverPort);
            // 3、然后再给到服务器
            socket.send(requestPacket);
            // 4、接收服务器的响应
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(responsePacket);
            // 5、处理响应:打印响应的结果
            String response = new String(responsePacket.getData(), 0, responsePacket.getLength()); // 有效的长度
            System.out.println(response);
        }
    }

    public static void main(String[] args) throws IOException {
        // 指定对应服务器的IP与端口号
        UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
        client.start();
    }
}

注意:127.0.0.1 这就是代指当前机器的IP。 

运行结果:

客户端:

初始JavaEE篇 —— 网络编程(2):了解套接字,从0到1实现回显服务器-LMLPHP

服务器:

初始JavaEE篇 —— 网络编程(2):了解套接字,从0到1实现回显服务器-LMLPHP

由上图可知,客户端的运行与否和服务器没什么关系,服务器在正常运行的情况下会一直记录客户端的访问信息。 

下面就来使用另外一种协议来实现回显服务器:

TCP

ServerSocket 是创建TCP的服务器Socket的APl。

ServerSocket :

Socket:

Socket是客户端Socket,或服务器中接收到客户端建立连接(accept方法)的请求后,返回的服务器Socket。不管是客户端还是服务器Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。

 这里的构造方法有很多,但是常用的就是通过 String类型的host 来建立连接的。

对端:这个概念是相对的,站在服务器的角度,对端是指客户端;站在客户端的角度,对端指的是服务器。当然,也是可以获取本地程序的地址和端口号的, 使用的是 getLocalPort ,站在服务器的角度,获取的就是服务器自己所在端口。

这里的ServerSocket 可以理解为网卡资源,而Socket 就是保存TCP连接双方的连接。服务器的连接有很多个,因此我们需要为其申请网卡资源来随时获取新的连接。而客户端只需要和服务器连接即可,因此只需要去尝试申请对应的IP地址与端口号进行连接即可。

总体的实现思路还是和上面的UDP差不多,但是具体的实现方式有不同。

服务器:

public class TcpEchoServer {
    private ServerSocket serverSocket;

    public TcpEchoServer(int port) throws IOException {
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        System.out.println("服务器启动成功~");
        while (true) {
            System.out.println("等待客户端连接...");
            Socket socket = serverSocket.accept();
            System.out.println("客户端连接成功:" + socket.getInetAddress() + ":" + socket.getPort());
            
            // 处理客户端连接,进入通信过程
            handleClient(socket);
        }
    }

    private void handleClient(Socket socket) throws IOException {
        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()) {

            while (true) {
                byte[] buffer = new byte[4096];
                int len = 0;

                StringBuilder sb = new StringBuilder();
                // 循环读取客户端请求并响应
                while ((len = inputStream.read(buffer)) != -1) {
                    sb.append(new String(buffer, 0, len));
                    if (sb.toString().contains("\n")) {
                        // 检测到换行符,认为请求结束
                        break;
                    }
                }
                String request = sb.toString();

                // 根据请求计算响应
                String response = process(request);

                // 先判断连接是否终止了
                if (socket.isClosed()) {
                    return;
                }
                // 将响应返回给客户端
                outputStream.write(response.getBytes());
                outputStream.flush(); // 刷新缓冲区
            }
        } finally {
            socket.close();
            System.out.println("客户端已断开连接");
        }
    }

    private String process(String request) {
        return request+"\n";
    }

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

客户端:

public class TcpEchoClient {
    private String serverIp;
    private int serverPort;

    public TcpEchoClient(String serverIp, int serverPort) {
        this.serverIp = serverIp;
        this.serverPort = serverPort;
    }

    public void start() throws IOException {
        try (Socket socket = new Socket(serverIp, serverPort);
             OutputStream outputStream = socket.getOutputStream();
             InputStream inputStream = socket.getInputStream();
             Scanner scanner = new Scanner(System.in)) {

            System.out.println("客户端连接服务器成功~");

            // 循环发送请求并接收响应
            while (true) {
                System.out.print("输入请求数据(exit退出): ");
                // 加上换行符,让服务器在读取数据时,知道这个是结束的标志
                String request = scanner.nextLine()+"\n";

                if (request.equals("exit\n")) { // 因为手动加上了换行符,因此判断也要加上
                    System.out.println("客户端请求断开连接");
                    break;
                }

                // 发送请求数据
                outputStream.write(request.getBytes());
                outputStream.flush(); // 刷新缓冲区,更好地让数据发送

                // 接收服务器响应
                byte[] buffer = new byte[4096];
                StringBuilder responseBuilder = new StringBuilder();
                int len = 0;
                
                // 使用while循环读取直到服务器停止发送
                while ((len = inputStream.read(buffer)) != -1) {
                    responseBuilder.append(new String(buffer, 0, len));
                    if (responseBuilder.toString().contains("\n")) { // 检测到换行符,认为响应完整
                        break;
                    }
                }
                
                // 打印完整的响应
                String response = responseBuilder.toString();
                System.out.print("接收到服务器响应: " + response);
            }
        }
    }

    public static void main(String[] args) throws IOException {
        TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090);
        client.start();
    }
}

注意:

1、 因为TCP是字节流,因此我们使用的是前面文件IO操作的字节流来进行发送与读取数据。但方式略微不同,我们需使用连接获取字节输入流与字节输出流。

2、由于这里的输入输出流是建立在连接之上的,我们不知道什么时候输入与输出结束,因此我们得手动地去设置结束标志或者使用socket的shoudownOutput,后者不推荐使用,后者是直接关闭了输出流,从而导致连接中断,可能会影响后续程序逻辑的执行,而前者是我们手动地去使用标记符来判断,这样的处理更好。

3、对于资源的关闭,也应该即使去做,这里是Socket、InputStream、OutputStream等资源都需要我们手动地去关闭,防止造成资源泄露,特别是Socket资源,可能会有非常多个客户端要建立连接,但是资源有限,因此会阻塞等待后面的,如果不释放的话,就导致后续客户端无法申请到。

上述代码虽然能够达到基本的运行效果,但是还存在部分缺陷(TCP的代码):

1、同一时刻只能有一个客户端去执行服务器的逻辑,因为我们在处理请求时,也是使用的一个循环,因此这里就会导致服务器的逻辑卡在了处理请求的代码中,而不会去尝试建立新的连接。

解决方法:多线程。将处理请求的代码放到一个新的线程中,这样后续的客户端都只会占用别的线程,而不会占用main线程。

2、在引入多线程的基础上,又有一个新的问题来了:如果客户端的请求非常简单(回显这种),且同一时刻有非常多的客户端去申请服务器为其服务的话,这时候就会出现线程频繁地创建与删除,这就会导致服务器的性能比较低,因此我们可以创建一个线程池来解决上述问题。

以上就是使用TCP与UDP实现网络通信的基本过程,后面我们在学习TCP与UDP的通信保障与具体实现等。

好啦!本期 初始JavaEE篇 —— 网络编程(2):了解套接字,从0到1实现回显服务器 的学习之旅就到此结束啦!我们下一期再一起学习吧!

11-10 16:59