Java学习历程28——网络编程初步 [ 通信系统(1)]

在日常生活中,我们所使用的网页和APP都是在网络的条件下运行的,由客户端向服务端发起请求,再由服务端响应实现客户端想要进行的操作,这就是网络编程的产物,涉及到通信和计算机网络的相关知识。在本篇博客中,我们将以一个本地的通信控制台消息程序,初步了解网络编程的相关知识。

目录

一、计算机网络的基础知识

①IP地址------网络中的门牌号

②端口------程序中的门牌号

[③TCP vs UDP------网络协议](#③TCP vs UDP——网络协议)

二、通信小程序开发

服务端

①初始化ServerSocket并绑定端口

②创建监听线程等待客户端连接

③为每一个客户端创建独立的接收线程

④消息格式的设计⭐⭐⭐

⑤消息的接收与转发

客户端

①连接服务端

②获取输入输出流

③创建独立的接收线程

④主线程处理用户的输入和发送

三、补充知识

①ServerSocket和Socket

核心常用方法

[②InputStream 和 OutputStream](#②InputStream 和 OutputStream)

[核心常用方法(InputStream in,OutputStream out)](#核心常用方法(InputStream in,OutputStream out))

[a. in.read()](#a. in.read())

[b. out.write()](#b. out.write())

[c. out.flush()](#c. out.flush())

③字节流传输

四、完整代码

服务端

客户端

五、效果展示


一、计算机网络的基础知识

在开始写代码之前,得先搞清楚几个最基本的概念。这就好比你要寄快递,得知道收件人的地址(IP)、具体的门牌号(端口),以及通过什么方式寄(协议)。

①IP地址------网络中的门牌号

IP地址用来标识互联网上的每一台设备,就像我们的家庭住址一样。IPv4地址是32位的,通常写成点分十进制的形式,比如 192.168.1.100。

我们平时最常遇到的就是两类IP:

  • 公网IP:在全球互联网上唯一的地址,可以直接被访问

  • 私网IP:只在局域网内使用,比如家里的路由器会给每台设备分配一个 192.168.x.x 的地址

还有一个特殊的IP叫 127.0.0.1,也就是 localhost,表示"本机"。当你用这个地址连接时,数据不经过网卡,直接在操作系统内部就绕回来了,非常适合本地测试。

②端口------程序中的门牌号

IP地址找到了设备,但一台电脑上运行着很多程序,数据该交给谁呢?这就用到了端口。

端口号是一个16位的数字,范围是0到65535。其中:

  • 0-1023:系统保留端口,比如HTTP用80,HTTPS用443

  • 1024-49151:注册端口,用户程序可以使用

  • 49152-65535:动态端口,通常由操作系统临时分配

当我们写聊天程序时,服务端需要绑定一个固定的端口(比如54321),客户端连接时就要指定这个端口。

③TCP vs UDP------网络协议

TCP就像打电话:先拨号建立连接(三次握手),然后才能通话,确保对方能收到每一句话。它可靠、有序,但开销大一些,适用于要求不能丢失数据的应用程序。

UDP就像发短信:不管对方在不在线,直接发出去就行。它速度快,但不保证一定能收到,也不保证顺序,适用于实时性要求较高的应用程序。

| 特性 | TCP | UDP |
| 连接 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠(确认+重传) | 不可靠(尽力而为) |
| 速度 | 较慢 | 较快 |

适用场景 文件传输、HTTP、聊天 音视频、DNS、游戏

二、通信小程序开发

我们计划开发一个支持本地运行服务端,同时连上多个客户端,客户端可以发送信息至服务端并由服务端转发到各个客户端,并且收发可以随时进行而不阻塞,实现群聊的效果。

服务端

①初始化ServerSocket并绑定端口

服务端第一步就是创建ServerSocket并绑定端口。new ServerSocket (xxx) 相当于告诉操作系统:"我要监听xxx端口,所有发往这个端口的数据都交给我处理"。

创建监听线程等待客户端连接

服务端需要持续监听来自客户端的连接请求,这时我们需要调用ServerSocket类中的accept方法。而由于这个方法是一个阻塞方法,它会一直等待,直到有客户端连接进来,然后返回一个代表这个连接的Socket对象。正因为它是阻塞的,我们才需要把它放在单独的线程中执行,否则整个程序都会停在这里干不了别的事。这个监听线程会一直运行,不断接收新的客户端连接。

与此同时,我们需要建一个Socket的动态数组用于存放所有客户端的Socket对象,便于后续的使用,在监听到后调用add方法即可。

③为每一个客户端创建独立的接收线程

当监听到一个新的客户端连接后,我们需要为这个客户端创建一个独立的线程来处理它后续发送的所有消息。这样设计的核心原因是:每个客户端的接收操作都是独立的,一个客户端发了消息,我们处理它的消息,另一个客户端没发消息,它的线程就在那里等着,彼此互不干扰。

具体实现是:在监听线程的循环中,每得到一个Socket对象,就创建一个新的线程,在线程内部通过socket.getInputStream () 获取输入流,然后持续读取该客户端发来的数据。

这里有一个关键点:为什么不能把所有客户端的输入流放在一个线程里统一处理?因为in.read()是阻塞的。如果线程A正在等待客户端1的数据,客户端2此时发来了消息,线程A根本不会知道------它被客户端1的读操作卡住了。每个客户端独立一个接收线程,就解决了这个问题。

④消息格式的设计⭐⭐⭐

在真正开始收发消息之前,我们需要先确定一个重要的细节:消息在网络上怎么传输,接收方怎么知道一条消息从哪里开始、到哪里结束?

我们的方案是使用长度前缀:发送消息时,先发送1个字节表示消息的长度,再发送消息的内容本身。接收方先读取1个字节获得长度,再根据这个长度读取对应数量的字节,组装成完整的消息。

这是网络信息传输的经典做法,这样做的好处是简单直接,而且能有效解决TCP的"粘包"问题------因为TCP是流式传输,没有消息边界,如果没有长度前缀,接收方无法判断一条消息的结束位置和下一条消息的开始位置。

⑤消息的接收与转发

每个客户端的接收线程通过 InputStream 持续读取消息。读取时按照约定的格式:先读1个字节获得消息长度,然后循环读取直到读满指定长度的字节,最后将这些字节转换成字符串,得到完整的消息内容。

收到消息后,服务端需要将它转发给所有在线的客户端,实现群聊的效果。具体做法是:遍历存储所有客户端Socket的动态数组,对数组中的每一个Socket(除了发送者自己),获取其输出流,将消息以相同的格式(长度前缀+内容)发送出去。

客户端

①连接服务端

客户端第一步就是通过 new Socket("127.0.0.1" , 端口号) 连接到服务端。这里的127.0.0.1是本地回环地址,表示连接本机上的服务端,xxx是服务端绑定的端口号。连接成功后,会得到一个Socket对象,通过这个对象就可以和服务端进行通信了。

②获取输入输出流

连接建立后,通过Socket对象获取输入流和输出流。Socket.getInputStream() 用于接收服务端发来的消息,Socket.getOutputStream() 用于向服务端发送消息。这两个流是TCP连接的数据通道,所有收发操作都通过它们完成。

③创建独立的接收线程

和服务端类似,客户端也需要一个独立的线程来持续接收服务端发来的消息。为什么不能把接收放在主线程?因为 in.read() 是阻塞的,如果放在主线程里,程序会卡在等待服务端消息的位置,用户就无法输入新的消息了。

在接收线程中,通过 in.read() 先读取1个字节获得消息长度,然后根据长度循环读取对应数量的字节,最后将字节数组转换成字符串打印出来,则可以将服务端转发的消息显示在客户端的控制台上。

④主线程处理用户的输入和发送

主线程负责读取用户在控制台输入的内容并发送给服务端。通过 ScannernextLine() 方法获取用户输入的一行字符串,然后按照约定的消息格式发送:先获取消息内容的字节数组,将字节数组的长度(1个字节)写入输出流,再写入字节数组本身,最后调用 flush() 确保数据立即发送出去。

这样收发分离的设计,使得用户可以随时输入消息发送,同时也能随时接收并显示服务端转发来的消息,实现了真正的双向通信。

三、补充知识

①ServerSocket和Socket

在整个网络编程中,ServerSocket和Socket是两个最核心的类。ServerSocket负责监听和接受连接请求,可以把它理解成"总机接线员",它只做一件事------等着电话打进来。它的accept方法会一直阻塞,直到有客户端连接进来,然后返回一个Socket对象。这个Socket就是服务端和客户端之间通信的通道。

Socket类代表一个TCP连接的两端。在服务端,它是accept方法的返回值;在客户端,它是new Socket(ip, port)构造出来的。无论哪一端,一旦拿到了Socket对象,就可以通过getInputStream和getOutputStream获取输入输出流,进行数据的收发。

核心常用方法

  • new ServerSocket(int port):构造方法,绑定到指定端口

  • Socket.accept():阻塞等待客户端连接,返回连接上的Socket

  • new Socket(String host, int port):客户端构造方法,连接到指定服务端

  • InputStream getInputStream():获取输入流,用于读取对方发来的数据

  • OutputStream getOutputStream():获取输出流,用于向对方发送数据

②InputStream 和 OutputStream

InputStream 和 OutputStream是所有字节输入输出流的父类。在网络编程中,它们负责在Socket之间传输原始字节数据。输入流指的是该socket接受其他socket消息的通道,而输出流是向其他socket发送信息的通道。

核心常用方法(InputStream in,OutputStream out)

a. in.read()

该方法用于将输入流中的数据读取出来,其返回值是int,代表该字节对应的ASCII。有三种常用的重载方法,一种是无形参的,用于读取输入流中的一个字节;另一种是以字节数组为形参的,用于将输入流中的所有字节存入该字节数组中;还有一种是int read(byte\[\] b, int off, int len),表示读取输入流中len长度的字节,从数组b的off位置开始存放字节数据。

b. out.write()

该方法用于将数据写入输出流中,无返回值,其重载方法和read类似,可以是一个字节,也可以是一个字节数组,也可以是一个字节数组某个位置开始的某长度。

c. out.flush()

该方法用于将缓冲区的数据强制刷新到底层输出流,确保数据立即发送。在网络编程中,数据不会在调用write后立即发送,而是积累在缓冲区中,等缓冲区满了或者手动调用flush才会发送。所以我们发送完消息后,一定要调用flush。如果不调用flush,数据可能会在缓冲区里积压,迟迟到不了对方手中。

③字节流传输

在本项目中,我们采用字节的方式进行消息的传输。网络传输的本质是字节的传输,所谓的字符流不过是在字节流上加了编码的转换,所以在传输时使用字节可以避免编码混乱导致乱码的问题,并且字节流可以传输二进制数据,如文件、数据等。

在我们的代码中,我们用字节数组byte msg先接收传输过来的信息,再使用new String(msg),将字节转换成字符串打印,完全控制了编码的过程,不会出现乱码的问题。

四、完整代码

服务端

java 复制代码
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;

public class Server {
    ServerSocket serverSocket = null;

    // 服务端初始化
    public void init() {
        try {
            serverSocket = new ServerSocket(54321);
            System.out.println("Server Started");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    // 监听连接
    public Socket listen() {
        try {
            return serverSocket.accept();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    // 接收信息
    public String receive(InputStream in) {
        String msgString;
        try {
            int len = in.read();
            byte[] msg = new byte[len];
            // 直接读取后面的所有字节存进字节数组
            in.read(msg);
            msgString = new String(msg);
            System.out.println(msgString);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return msgString;
    }

    // 服务端接受到信息并转发至各个客户端(一个客户端用一个线程)
    public void transpond(Socket socket, ArrayList<Socket> socketList) {
        new Thread(() -> {
            while (true) {
                try {
                    InputStream in = socket.getInputStream();
                    String msg = receive(in);
                    byte[] msgBytes = msg.getBytes();
                    for (Socket s : socketList) {
                        if(!s.equals(socket)) {
                            OutputStream out = s.getOutputStream();
                            out.write(msgBytes.length);
                            out.write(msgBytes);
                            out.flush();
                        }
                    }
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }).start();
    }

    public static void main(String[] args) {
        Server server = new Server();
        ArrayList<Socket> SocketList = new ArrayList<>();
        server.init();

        // 专门监听接入连接的线程
        new Thread(() -> {
            while (true) {
                Socket socket = server.listen();
                SocketList.add(socket);
                // 接受信息同时转发
                server.transpond(socket, SocketList);
            }
        }).start();
    }
}

客户端

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

public class Client {
    // 接受消息
    public void receive(InputStream in) {
        // 单独开一个线程接受来自服务端的消息
        new Thread(() -> {
            while (true) {
                int receiveLen;
                try {
                    receiveLen = in.read();
                    for (int i = 0; i < receiveLen; i++) {
                        System.out.print((char) in.read());
                    }
                    System.out.println();
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }).start();
    }

    public static void main(String[] args) {
        Client client = new Client();
        try {
            // 套接字
            Socket sc = new Socket("127.0.0.1", 54321);
            // 输入输出流
            InputStream in = sc.getInputStream();
            OutputStream out = sc.getOutputStream();
            Scanner scanner = new Scanner(System.in);

            // 接收信息
            client.receive(in);

            // 发送信息
            while (true) {
                String msg = scanner.nextLine();
                int len = msg.length();
                out.write(len);
                byte[] b = msg.getBytes();
                out.write(b);
                // 点击发送
                out.flush();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

五、效果展示

注意,我们需要先启动服务端,在客户端的运行设置中设置为"允许运行多个实例",开启多个客户端的实例连上服务端,然后进行测试。

这样,我们就初步实现了群聊的效果,后续的开发方向是认证系统和界面化等高级功能。

相关推荐
weixin_4462608539 分钟前
AVA-Encoder:面向智能体原生视频表征学习框架
学习·音视频
吃好睡好便好1 小时前
说说口罩的佩戴
学习·生活·口罩
fīɡЙtīиɡ ℡2 小时前
大模型结构化输出
人工智能·学习
Dave1205462 小时前
Day17:Agent Memory高级设计——短期记忆、长期记忆与向量语义记忆
人工智能·学习
小唔w2 小时前
2026年AI培训行业观察:主流学习渠道一览
人工智能·学习
Nan_Shu_6143 小时前
学习:MapVGL 地理信息可视化
学习
皖山文武3 小时前
逻辑代数基础学习笔记--概述
笔记·学习·机器学习
fanged3 小时前
高通学习24--XBL(TODO)
学习
咸甜适中4 小时前
rust语言AI编程学习笔记(五)clap命令行参数解析
学习·rust·ai编程
诗句藏于尽头4 小时前
deepseek harness对接商汤科技免费大模型使用教程
人工智能·科技·学习