【Java网络编程】网络编程中的基本概念及实现UDP、TCP客户端服务器程序

目录

一、什么是网络编程?

二、网络编程中的基本概念

[1. 客户端和服务器](#1. 客户端和服务器)

[2. 请求和响应](#2. 请求和响应)

三、Socket套接字

UDP数据报套接字编程

[1. DatagramSocket](#1. DatagramSocket)

[2. DatagramPacket](#2. DatagramPacket)

[3. UDP回显客户端服务器程序](#3. UDP回显客户端服务器程序)

[4. UDP字典客户端服务器程序](#4. UDP字典客户端服务器程序)

TCP流套接字编程

[1. ServerSocket](#1. ServerSocket)

[2. Socket](#2. Socket)

[3. TCP回显客户端服务器程序](#3. TCP回显客户端服务器程序)

[4. 服务器引入多线程](#4. 服务器引入多线程)

[5. 服务器引入线程池](#5. 服务器引入线程池)

[6. TCP字典客户端服务器程序](#6. TCP字典客户端服务器程序)


一、什么是网络编程?

网络编程,指网络上的主机,通过不同的进程 ,以编程的方式实现网络通信(或称网络数据传输)

即便是同一个主机,只要是不同进程,基于网络来传输数据,也属于网络编程。但是,我们的目的是提供网络上的不同主机,基于网络来传输数据资源。

网络编程,本质上就是学习"传输层"给"应用层"提供的API,通过代码的形式,把数据交给传输层,进一步的通过层层封装,就可以把数据通过网卡发送出去了。

二、网络编程中的基本概念

1. 客户端和服务器

**客户端:**主动发起通信的一方,称为客户端.

**服务器:**被动接受的一方,称为服务器,可以提供对外服务.

同一个程序在不同场景中,可能是客户端也可能是服务器(服务器可能还需要主动向别的服务器发起通信,此时的服务器相对于被发起通信的服务器来说,就是客户端).

2. 请求和响应

**请求(request):**客户端给服务器发送数据。

**响应(response):**服务器给客户端返回数据。

一般来说,获取一个网络资源,涉及到两次网络数据传输:

  • 第一次:请求数据的发送.
  • 第二次:响应数据的发送.

就比如在快餐店点一份炒饭:

先要发起请求:点一份炒饭;再有快餐店提供的对于响应:提供一份炒饭。

三、Socket套接字

Socket套接字,是由系统提供的用于网络编程的技术,是基于TCP/IP协议的网络通信的基本操作单元。基于Socket套接字的网络程序开发就是网络编程。

前面说过,要想进行网络编程,需要使用的系统API,本质上是由传输层提供的。

传输层涉及到的主要协议有两个:

  1. 流套接字:TCP(传输控制协议)
  2. 数据报套接字:UDP(用户数据报协议)

TCP的特点:

  • 有连接(类似于打电话,需要接通才能通信)
  • 可靠传输(尽可能完成数据传输,起码可以知道当前这个数据对方是否接收到了)
  • 面向字节流(此处字节流和文件中的字节流完全一致,网络中传输数据的基本单位就是字节)
  • 全双工(一个信道,可以双向通信)

UDP的特点:

  • 无连接(类似于发微信/短信,直接发送过去)
  • 不可靠传输
  • 面向数据报(每次传输的基本单位是一个数据报(由一系列的字节构成的)特定的结构)
  • 全双工(一个信道,可以双向通信)

UDP数据报套接字编程

UDP socket API的使用

1. DatagramSocket

DatagramSocket 是 UDP Socket(套接字),用于发送和接收UDP数据报

构造方法:

重要方法:

2. DatagramPacket

DatagramPacket 是 UDP Socket(套接字)发送和接收的数据报(每次发送接收数据的基本单位)

构造方法:

3. UDP回显客户端服务器程序

通过这个程序,了解 socket api 的使用,和典型的客户端服务器基本工作流程。

对于服务器,需要指定端口号来创建 socket (类似于饭店,需要指定具体位置),主要流程如下:

  1. 接收客户端请求,并解析
  2. 根据请求,计算出响应(回显服务器,则是直接将请求的数据返回)
  3. 将响应写回给客户端

注意事项详见代码注释:

java 复制代码
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;

//Udp回显服务器
public class UdpEchoServer {

    //Udp套接字
    private DatagramSocket socket;

    public UdpEchoServer(int port) throws SocketException {
        socket = new DatagramSocket(port); //服务器:指定端口号创建
    }

    public void start() throws IOException {
        System.out.println("服务器启动");
        while (true) {
            //1.接收客户端的请求,并解析
            DatagramPacket requestServer = new DatagramPacket(new byte[4096], 4096);
            socket.receive(requestServer);

            //2.根据请求,计算出响应
            String request = new String(requestServer.getData(), 0, requestServer.getLength());
            String response = process(request);

            //3.将响应写回给客户端(需要指定发送到的IP地址及端口号)
            DatagramPacket responseServer = new DatagramPacket(
                    response.getBytes(), response.getBytes().length, requestServer.getSocketAddress());
            socket.send(responseServer);

            //打印日志
            System.out.printf("[%s:%d] request:%s response:%s\n",
                    responseServer.getAddress(), responseServer.getPort(), request, response);
        }
    }

    //根据请求计算响应(由于是回显程序,直接返回请求的内容)
    public String process(String request) {
        return request;
    }

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

对于客户端,服务器的端口号可以由系统随机分配,但需要知道服务器的IP地址及端口号(去饭店吃饭,需要知道饭店的地址及具体是哪个门店),主要流程如下:

  1. 客户端读取用户请求
  2. 构造请求的数据报,并发送到服务器(此时就需要指定服务器的IP地址及端口号)
  3. 读取服务器的响应,并解析出响应的内容
  4. 输出服务器的响应
java 复制代码
import java.io.IOException;
import java.net.*;
import java.util.Scanner;

//Udp回显客户端
public class UdpEchoClient {

    private DatagramSocket socket;
    private String address;
    private int port;

    //客户端需要知道服务器的IP地址及端口号
    public UdpEchoClient(String address, int port) throws SocketException {
        this.address = address;
        this.port = port;
        socket = new DatagramSocket(); //服务器:随机端口号创建
    }

    public void start() throws IOException {
        System.out.println("客户端启动");
        Scanner in = new Scanner(System.in);
        while (true) {
            System.out.print("-> ");
            if (!in.hasNext()) {
                break;
            }
            //1.控制台读取请求内容
            String request = in.next();

            //2.构造请求的数据报,并发送到服务器(需要指定目的IP地址和目的端口号发送请求)
            DatagramPacket requestPacket = new DatagramPacket(
                    request.getBytes(), request.getBytes().length, InetAddress.getByName(address), port);
            socket.send(requestPacket);

            //3.读取服务器的响应,并解析出响应的内容
            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 udpEchoClient = new UdpEchoClient("127.0.0.1", 9090);
        udpEchoClient.start();
    }
}

先运行服务器,再运行客户端,看程序的执行效果:

  • 可以看到,服务器端能够正确接收到请求并作出响应,并打印出日志(客户端IP,客户端端口号,请求内容,响应内容)
  • 客户端也能够正确发送请求,并正确解析服务器端返回的响应

这个程序并不能直接做到"跨主机通信",因为这台主机可能不能直接访问到另一台主机(NAT机制)。但是可以通过以下手段实现"跨主机通信":

  1. 将服务器端程序打成 jar 包
  2. 把 jar 包传到云服务器上,并运行

经过这样的操作,其他主机通过运行上述的客户端程序,就能够发起通信了。

4. UDP字典客户端服务器程序

基于上述回显服务器,还可以实现出一些其他带有一点业务逻辑的服务器。

改进成一个"字典服务器",英译汉的效果。请求是一个英文单词,响应返回对应的中文翻译。

主要逻辑其实和回显服务器基本一致,唯一的区别就在于,服务器端将客户端请求的数据,计算成响应的方式不一致。回显服务器是直接返回客户端请求的数据,这里的字典服务器则是英译汉效果。

而上述代码中,这个根据请求数据计算响应数据的操作,是通过process方法实现的。因此只需要让这个字典服务器继承回显服务器,并重写process方法即可。这里英译汉的业务逻辑通过打表的方式实现。

java 复制代码
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;

//Udp字典服务器
public class UdpDictServer extends UdpEchoServer {

    Map<String, String> map;

    public UdpDictServer(int port) throws SocketException {
        super(port);
        map = new HashMap<>();

        map.put("cat", "小猫");
        map.put("dog", "小狗");
        map.put("animal", "动物");
    }

    //通过重写 计算响应的process方法,达成 英->汉 的效果
    @Override
    public String process(String request) {
        return map.getOrDefault(request, "找不到该单词");
    }

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

还是先运行字典服务器,再运行回显客户端(这里客户端是通用的,因为回显客户端只进行发送请求和接收响应并解析的操作),看程序的执行效果:

  • 同样,服务器端能够正确接收到请求、解析请求,并计算出响应、写回给客户端,并打印出日志(客户端IP,客户端端口号,请求内容,响应内容)
  • 客户端也能够正确发送请求,并正确解析服务器端返回的响应

TCP流套接字编程

1. ServerSocket

ServerSocket 类是创建TCP服务器端Socket的API. (只能给服务器端使用)

构造方法:

重要方法:

2. Socket

Socket 类用于创建客户端 Socket,或服务器端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket. (服务器端和客户端都能使用)

构造方法:

重要方法:

3. TCP回显客户端服务器程序

使用TCP协议实现回显客户端服务器程序。与UDP协议实现的最大区别是,TCP是有连接的,和打电话一样,需要一方(客户端)拨号,一方(服务器)接通,因此TCP协议首要操作就是等待客户端连接。

和UDP回显服务器一样,对于这里的服务器,同样需要指定端口号创建TCP服务器端Socket,即ServerSocket。

  1. 服务器启动后,就需要监听当前绑定端口(accept方法),等待客户端连接。
  2. 当成功建立连接后,会返回一个Socket对象。这个对象保存了对端信息,即客户端信息,可以用来接收和发送数据(TCP是面向字节流的,通过这个Socket对象获取对应输入流和输出流,通过输入输出流进行对 socket 的读写,达成接收和发送数据的功能)。

后续流程和UCP回显服务器一致。此处由于每有一个客户端连接,就会有一个clientSocket,这里消耗的Socket会越来越多,因此每当一个客户端连接结束,就需要释放这个clientSocket。

java 复制代码
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;

//TCP回显服务器
public class TcpEchoServer {

    private ServerSocket serverSocket;

    public TcpEchoServer(int port) throws IOException {
        //指定服务器端口号,创建一个serverSocket
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
        System.out.println("服务器启动!");
        while (true) {
            //监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息
            Socket clientSocket = serverSocket.accept();
            processConnection(clientSocket);
        }
    }

    private void processConnection(Socket clientSocket) throws IOException {
        System.out.printf("[%s:%d] 客户端上线\n", clientSocket.getInetAddress(), clientSocket.getPort());
        try (InputStream inputStream = clientSocket.getInputStream();
             OutputStream outputStream = clientSocket.getOutputStream()) {
            while (true) {
                //1.读取客户端请求的数据
                //利用scanner读取客户端输入的信息
                Scanner scanner = new Scanner(inputStream);
                if (!scanner.hasNext()) {
                    System.out.printf("[%s:%d] 客户端下线\n",
                            clientSocket.getInetAddress(), clientSocket.getPort());
                    break;
                }
                //这里的next()需要遇到\n才停止,因此需要对端写入的时候,要同时写入\n换行符
                String request = scanner.next();

                //2.解析请求的数据,并计算出响应
                String response = process(request);

                //3.将响应写回到客户端
                //outputStream.write(response.getBytes(), 0, response.getBytes().length);
                PrintWriter writer = new PrintWriter(outputStream);
                writer.println(response);
                writer.flush();

                //打印日志
                System.out.printf("[%s:%d] request:%s response:%s\n",
                        clientSocket.getInetAddress(), clientSocket.getPort(), request, response);
            }

        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            clientSocket.close();
        }
    }

    //回显服务器,直接返回原数据
    public String process(String request) {
        return request;
    }

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

对于客户端,需要 指定服务器的IP和端口号建立连接,即使用 Socket(String host, int port) 创建Socket的时候,就开始发起与对应服务器建立连接的请求了。

主要流程和UDP回显客户端程序的流程也基本一致,只需要注意请求和响应数据的方式是不同的,是通过操作输入输出流完成的即可。

java 复制代码
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
import java.util.Scanner;

//TCP回显客户端
public class TcpEchoClient {

    private Socket clientSocket;

    //需要指定服务器的IP和端口号
    public TcpEchoClient(String serverAddress, int serverPort) throws IOException {
        //与对应客户端建立连接
        clientSocket = new Socket(InetAddress.getByName(serverAddress), serverPort);
    }

    public void start() {
        System.out.println("客户端启动!");
        try (Scanner scannerConsole = new Scanner(System.in);
             InputStream inputStream = clientSocket.getInputStream();
             OutputStream outputStream = clientSocket.getOutputStream()) {
            while (true) {
                //1.用户从控制台输入数据
                System.out.print("-> ");
                String request = scannerConsole.next();

                //2.将该数据作为请求,发送给服务器
                //outputStream.write(request.getBytes(), 0, request.getBytes().length);
                //outputStream.write('\n');
                PrintWriter writer = new PrintWriter(outputStream);
                writer.println(request);
                writer.flush(); //刷新缓冲区,确保数据发送出去

                //3.读取服务器的响应,并解析响应的内容
                Scanner scannerNetwork = new Scanner(inputStream);
                String response = scannerNetwork.next();

                //4.将响应输出到控制台
                System.out.println(response);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

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

先运行服务器,再运行客户端,看执行效果:

可以看到,服务器和客户端都能满足我们的需求,但这里其实还存在一个问题。

当我们开启多个客户端想要进行连接通信时,只有第一个连接到的客户端才能正确通信,其他的客户端是没有反应的。要想某个客户端能正常通信,只有当其他客户端都下线(结束程序),这个客户端才能接收到响应数据。

可以看到,此处的这个客户端并没有正确通信,当另一个客户端下线之后,该客户端此前发送的数据又正常请求并响应了。

分析过程:

  • 第一个客户端连上服务器之后,服务器就会从accept这里返回(解除阻塞),然后进入到processConnection方法中.
  • 接下来服务器就会在processConnection循环处理客户端的请求,只有当客户端退出之后,连接结束,才会退出循环.
  • 而服务器在循环处理客户端请求的时候,第二个客户端发起连接请求,而服务器这里并不能执行到accept。因此并不能成功连接,只有当客户端退出,才会执行回到accept进行连接.

第二个客户端之前发的请求为什么能被立即处理?

  • 当前TCP在内核中,每个 socket 都是由缓冲区的。客户端发送的数据通过客户端代码,已经写入到该客户端的输出流(缓冲区)了,这里数据确实发送了,只不过数据在服务器的接收缓冲区中。
  • 一旦第一个客户端退出,回到第一层循环,执行accept连接操作,后续processConnection方法里的 next 就能把之前缓冲区的内容给读出来。

解决上述问题的核心思路就是使用多线程:

  • 单个线程,无法既能给客户端提供服务,又能去快速执行到第二次 accept 方法进行连接。
  • 通过引入多线程,让主线程只负责执行 accept。每次有一个客户端连接上来,就分配一个新的线程,由新的线程负责给客户端提供服务。

由于这里不涉及多个线程修改共享变量,因此没有线程安全问题,我们只需要改动 start 方法即可。

4. 服务器引入多线程

java 复制代码
    //多线程
    public void start() throws IOException {
        System.out.println("服务器启动!");
        while (true) {
            //监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息
            Socket clientSocket = serverSocket.accept();

            Thread t = new Thread(() -> {
                try {
                    processConnection(clientSocket);
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
            t.start();
        }
    }

通过引入多线程,这里的服务器就能支持多个客户端同时与其通信了。

上述问题,不是TCP引起的,而是代码两次循环嵌套引起的,UDP服务器,就是只有一层循环,因此不会有这个问题。

而这个多线程版本同样还有一些问题:

  • 每有一个客户端连接,就会创建一个新的线程,每当这个客户端结束,就要销毁这个线程。
  • 如果客户端比较多,并且频繁连接、关闭,就会使服务器频繁创建和销毁线程。

前面讲过,线程池解决的就是线程频繁创建和销毁的问题,因此,这里的优化方案就是使用线程池。

5. 服务器引入线程池

java 复制代码
    public void start() throws IOException {
        System.out.println("服务器启动!");
        ExecutorService threadPool = Executors.newCachedThreadPool();
        while (true) {
            //监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息
            Socket clientSocket = serverSocket.accept();

            threadPool.submit(new Runnable() {
                @Override
                public void run() {
                    try {
                        processConnection(clientSocket);
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                }
            });
        }
    }

线程开销问题解决了。但是,如果当前的场景使线程频繁创建,但是不销毁呢?

  • 这种情况下,如果继续使用多线程/线程池,就会导致当前服务器积累大量的线程,此时,对于服务器的负担是非常重的。

要解决这个新问题,还可以引入其他的方案:

  1. **协程:**轻量级线程,本质上还是一个线程,用户态可以通过手动调度的方式让这一个线程"并发"执行多个任务。
  2. **IO 多路复用:**系统内核级别的机制,本质上是让一个线程同时去负责处理多个socket。

6. TCP字典客户端服务器程序

同UDP字典客户端服务器程序:

java 复制代码
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

//TCP字典服务器
public class TcpDictServer extends TcpEchoServer {

    Map<String, String> map;

    public TcpDictServer(int port) throws IOException {
        super(port);

        map = new HashMap<>();
        map.put("cat", "小猫");
        map.put("dog", "小狗");
        map.put("animal", "动物");
    }

    @Override
    public String process(String request) {
        return map.getOrDefault(request, "未找到该单词");
    }

    public static void main(String[] args) throws IOException {
        TcpDictServer tcpDictServer = new TcpDictServer(9090);
        tcpDictServer.start();
    }
}
相关推荐
界面开发小八哥5 分钟前
更高效的Java 23开发,IntelliJ IDEA助力全面升级
java·开发语言·ide·intellij-idea·开发工具
hzyyyyyyyu17 分钟前
内网安全隧道搭建-ngrok-frp-nps-sapp
服务器·网络·安全
草莓base18 分钟前
【手写一个spring】spring源码的简单实现--容器启动
java·后端·spring
Allen Bright31 分钟前
maven概述
java·maven
编程重生之路33 分钟前
Springboot启动异常 错误: 找不到或无法加载主类 xxx.Application异常
java·spring boot·后端
薯条不要番茄酱34 分钟前
数据结构-8.Java. 七大排序算法(中篇)
java·开发语言·数据结构·后端·算法·排序算法·intellij-idea
努力进修43 分钟前
“探索Java List的无限可能:从基础到高级应用“
java·开发语言·list
politeboy43 分钟前
k8s启动springboot容器的时候,显示找不到application.yml文件
java·spring boot·kubernetes
刽子手发艺44 分钟前
WebSocket详解、WebSocket入门案例
网络·websocket·网络协议