基于Java实现hello/hi简单网络聊天程序

Socket简要阐述

Socket的概念

  • Socket的英文原义是“孔”或“插座”。
    在网络编程中,网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个Socket。

  • Socket套接字是通信的基石,是支持TCP/IP协议的网络通信的基本操作单元。
    它是网络通信过程中端点的抽象表示,包含进行网络通信必须的五种信息:连接使用的协议,本地主机的IP地址,本地进程的协议端口,远地主机的IP地址,远地进程的协议端口。

  • Socket本质是编程接口(API),对TCP/IP的封装,TCP/IP也要提供可供程序员做网络开发所用的接口,这就是Socket编程接口。

HTTP是轿车,提供了封装或者显示数据的具体形式;Socket是发动机,提供了网络通信的能力。

Socket原理

Socket实质上提供了进程通信的端点。进程通信之前,双方首先必须各自创建一个端点,否则是没有办法建立联系并相互通信的。正如打电话之前,双方必须各自拥有一台电话机一样。

套接字之间的连接过程可以分为三个步骤:服务器监听客户端请求连接确认

  • 服务器监听:建立服务器端套接字,并处于等待连接的状态,不定位具体的客户端套接字,而是实时监控网络状态。

  • 客户端请求:是指由客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。
    为此,客户端的套接字必须首先描述它要连接的服务器的套接字,指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。

  • 连接确认:是指当服务器端套接字监听到或者说接收到客户端套接字的连接请求,它就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发给客户端,
    一旦客户端确认了此描述,连接就建立好了。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

下图为基于TCP协议Socket的通信模型。
通信模型

hello/hi的简单网络聊天程序实现

服务器端

实现步骤

1.创建ServerSocket对象,绑定监听端口。
2.通过accept()方法监听客户端请求。
3.连接建立后,在接收进程中通过输入流读取客户端发送的请求信息。
4.在服务器发送进程中通过输出流向客户端发送响应信息。
5.关闭相应的资源和Socket。

package com.socket.MultiThread;

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;

public class Server {

    public static ServerSocket serverSocket;
    public static Socket socket;
    public static Scanner scanner;

    /**
     * 构造方法
     * 新建serverSocket和Socket
     */
    public Server() {
        try {
            serverSocket = new ServerSocket(6666);
            System.out.println("Server is working, waiting for client's link");
            socket = serverSocket.accept();
            System.out.println("Client has linked with Server");
        } catch (IOException i) {
            i.printStackTrace();
        }
    }

    /**
     * 服务器端发送消息线程
     * 作用:从键盘读入消息,发送给服务器端
     */
    public class SendThread implements Runnable {
        @Override
        public void run() {
            try {
                OutputStream outputStream = socket.getOutputStream();
                OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
                PrintWriter printWriter = new PrintWriter(outputStreamWriter, true);

                scanner = new Scanner(System.in);

                while (true) {
                    printWriter.println(scanner.nextLine());
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 服务器端接收线程
     * 作用:使用字符流读取缓冲区中客户端所发送的消息
     */
    public class ReceiveThread implements Runnable {
        @Override
        public void run() {
            try {
                InputStream inputStream = socket.getInputStream();
                InputStreamReader inputStreamReader = new InputStreamReader(inputStream, "UTF-8");
                BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

                // 输出从客户端端接收到的消息
                while (true) {
                    System.out.println("Client> " + bufferedReader.readLine());
                }
            } catch (IOException i) {
                i.printStackTrace();
            }
        }
    }

    public void start() {
        Thread send = new Thread(new SendThread()); // 发送进程负责服务器端的消息发送
        Thread receive = new Thread(new ReceiveThread()); // 接收进程负责接收客户端的消息
        send.start();
        receive.start();
    }

    public static void main(String[] args) {
        Server server = new Server();
        server.start();
    }

}

客户端

实现步骤

1.创建Socket对象,指明需要连接的服务器的地址和端口号。
2.连接建立后,通过输出流向服务器发送请求信息。
3.通过输入流获取服务器响应的信息。
4.关闭相应资源。

package com.socket.MultiThread;

import java.io.*;
import java.net.Socket;
import java.util.Scanner;

public class Client {

    public static Socket socket;
    public static Scanner scanner;

    /**
     * 构造方法
     * 新建一个socket,并指定了host和port属性,其port与服务器端保持一致
     */
    public Client() {
        try {
            socket = new Socket("127.0.0.1", 6666);
        } catch (IOException i) {
            i.printStackTrace();
        }
    }

    /**
     * 客户端发送消息线程
     * 作用:从键盘读入消息,发送给服务器端
     */
    public class SendThread implements Runnable {
        @Override
        public void run() {
            try {
                OutputStream outputStream = socket.getOutputStream();
                OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
                PrintWriter printWriter = new PrintWriter(outputStreamWriter, true);

                scanner = new Scanner(System.in);

                while (true) {
                    printWriter.println(scanner.nextLine());
                }
            } catch (IOException i) {
                i.printStackTrace();
            }
        }
    }

    /**
     * 客户端接收线程
     * 作用:使用字符流读取缓冲区中服务器端所发送的消息
     */
    public class ReceiveThread implements Runnable {
        @Override
        public void run() {
            try {
                InputStream inputStream = socket.getInputStream();
                InputStreamReader inputStreamReader = new InputStreamReader(inputStream,"UTF-8");
                BufferedReader bufferedReader = new BufferedReader(inputStreamReader);

                // 输出从服务器端接收到的消息
                while (true) {
                    System.out.println("Server> " + bufferedReader.readLine());
                }
            } catch (IOException i) {
                i.printStackTrace();
            }
        }
    }


    public void start() {
        Thread send = new Thread(new SendThread()); // 发送进程负责客户端的消息发送
        Thread receive = new Thread(new ReceiveThread()); // 接收进程负责接收服务器端的消息
        send.start();
        receive.start();
    }

    public static void main(String[] args) {
        Client client = new Client();
        client.start();
    }

}

程序执行结果

先运行服务器端,后运行客户端,服务器端在监听客户端的连接请求后建立连接。

Connect

服务器端与客户端的交互

客户端显示消息
服务器端显示消息

跟踪分析调用栈 & Linux API对比

创建ServerSocket

在前面的服务器端代码中,我们创建一个ServerSocket是这样做的:
serverSocket = new ServerSocket(6666);

这行代码在平常看来就是创建了一个端口号为6666的ServerSocket。
但是实际上,我们只是调用了大牛们早已经写好并封装在JDK中的方法,这才能够如此简单地完成套接字的创建。

因此下面通过查看JDK源码,追踪其调用栈来看看ServerSocket的创建究竟是如何实现的。

调用栈图示

ServerSocket创建
通过上图中对于jdk代码中socket创建过程的展示,我们了解到:
在java中ServerSocket的创建主要是调用PlainSocketImpl.socketCreate这个native方法来实现的。

源码分析

那么,我们来康一下这个方法:

/**
* The file descriptor object for this socket.
*/
    protected FileDescriptor fd; // 文件描述符

@Override
    void socketCreate(boolean stream) throws IOException {
        if (fd == null) // 空则抛出异常
            throw new SocketException("Socket closed");

        int newfd = socket0(stream); // 调用jvm的socket0方法来创建新的fd

        fdAccess.set(fd, newfd);
    }

可以看到PlainSocketImpl.socketCreate方法中有一个重要的变量是fd,在代码块中我也将这个变量的声明一并列出了。
记得在本科的Linux课上老师曾经也着重强调了文件描述符这个概念,那么此fd是彼fd吗?
在了解了Linux内核中Socket的建立之后,就能够得出答案:是的。

在jvm中,调用linux底层api: socket()函数时,执行的步骤为:

创建socket结构体
创建tcp_sock结构体,刚创建完的tcp_sock的状态为:TCP_CLOSE
创建文件描述符与socket绑定

因此,在PlainSocketImpl.socketCreate方法中所实现的也正是这样的逻辑。

Socket绑定

上述分析中,我们会发现:
PlainSocketImpl.socketCreate中创建socket时,它并没有绑定任何的ip地址与端口,只是实现了与文件描述符的绑定。
这就有点奇怪了,我们在上面的Java代码中创建ServerSocket的时候明明指定了端口号的呀,怎么调用到底层方法它就把端口号丢了呢?

再次分析源码,原来仅仅是new ServerSocket(6666);这一步操作就调用了三次Linux API,其对应关系如下图。
Linux API调用

调用栈图示

Socket Bind

同样的,我们可以得出:java中ServerSocket的绑定是调用PlainSocketImpl.socketBind这个native方法来实现的。

源码分析

查看以下JDK源码中PlainSocketImpl.socketBind方法的内容。

@Override
    void socketBind(InetAddress address, int port) throws IOException {
        int nativefd = checkAndReturnNativeFD();

        if (address == null) // ip地址为空则抛出异常
            throw new NullPointerException("inet address argument is null.");

        if (preferIPv4Stack && !(address instanceof Inet4Address)) // 限定IP地址为IPv4版本
            throw new SocketException("Protocol family not supported"); 

        // 调用jvm的bind0方法实现绑定
        bind0(nativefd, address, port, useExclusiveBind); 
        if (port == 0) { // 没有给出端口号
            localport = localPort0(nativefd);
        } else {
            localport = port;
        }

        this.address = address;
    }

可以看到,在上面的方法中通过调用bind0这个方法来实现实现的端口号以及IP地址的绑定。
并且,源码限制目前所支持的IP地址是IPv4版本的(虽然目前IPv4地址已经分配完毕),相信在后续的JDK更新中这里会修改过来。

Socket监听

从之前的Java调用Linux API图中可以看到,在完成Socket的创建和绑定之后,服务器端进入监听的状态,等待客户端发出连接的请求。

调用栈图示

Socket Listen
从上图可以得出:java中ServerSocket的绑定是调用PlainSocketImpl.socketListen这个native方法来实现的。

源码分析

@Override
    void socketListen(int backlog) throws IOException {
        int nativefd = checkAndReturnNativeFD();

        // 调用jvm的listen0方法实现监听
        listen0(nativefd, backlog);
    }

在JDK中监听的实现较为简单,主要是通过调用JVM中listen0来实现的,这里不做过多的展开。

Socket Accept

服务器端一直被动等待着客户端的连接,终于有一个客户端使用与之匹配的IP地址端口号
并在经历了TCP三次握手之后,客户端建立新的连接Socket对象,服务器就与这个客户端建立了TCP连接

调用栈图示

Socket Accept
从上图可以得出:java中ServerSocket的绑定是调用PlainSocketImpl.socketAccept这个native方法来实现的。

源码分析

@Override
    void socketAccept(SocketImpl s) throws IOException {
        int nativefd = checkAndReturnNativeFD();

        if (s == null)
            throw new NullPointerException("socket is null");

        int newfd = -1;
        InetSocketAddress[] isaa = new InetSocketAddress[1];
        if (timeout <= 0) { // 设定有超时计时器
            // 没有超时则调用accept0方法建立连接
            newfd = accept0(nativefd, isaa);
        } else {
            // 否则将该客户端挂入阻塞队列中
            configureBlocking(nativefd, false);
            try {
                waitForNewConnection(nativefd, timeout);
                newfd = accept0(nativefd, isaa);
                if (newfd != -1) {
                    configureBlocking(newfd, true);
                }
            } finally {
                configureBlocking(nativefd, true);
            }
        }

        // 更新socketImpl的文件描述符值
        fdAccess.set(s.fd, newfd);

        // 更新socketImpl中的端口号、ip地址以及localport值
        InetSocketAddress isa = isaa[0];
        s.port = isa.getPort();
        s.address = isa.getAddress();
        s.localport = localport;
        if (preferIPv4Stack && !(s.address instanceof Inet4Address))
            throw new SocketException("Protocol family not supported");
    }

Java Socekt API与Linux Socket API

在上面的调用栈分析中,无论是ServerSocket的创建、绑定、监听,还是连接都伴随着对glibc的调用。

那么glibc到底何许人也?这里引用百度词条的内容:

glibc是GNU发布的libc库,即c运行库。glibc是linux系统中最底层的api,几乎其它任何运行库都会依赖于glibc。glibc除了封装linux操作系统所提供的系统服务外,它本身也提供了许多其它一些必要功能服务的实现。由于 glibc 囊括了几乎所有的 UNIX 通行的标准,可以想见其内容包罗万象。而就像其他的 UNIX 系统一样,其内含的档案群分散于系统的树状目录结构中,像一个支架一般撑起整个操作系统。在 GNU/Linux 系统中,其C函式库发展史点出了GNU/Linux 演进的几个重要里程碑,用 glibc 作为系统的C函式库,是GNU/Linux演进的一个重要里程碑。

就绑定功能而言,在上述的调用栈追踪中我们知道了所调用的是底层由glibc提供的Bind方法,
但实际上,最终调用内核的SYSCALL_DEFINE3(bind, int, fd, struct sockaddr __user *, umyaddr, int, addrlen)。

因此,可以得出结论:

java的socket实现是通过调用操作系统的socket api实现的

参考链接

简单hello/hi程序、分析及Java Socket API与Linux Socket API对比
Linux/Unix socket 基础API (一)
图解Java服务端Socket建立原理
glibc

posted @ 2019-12-10 21:20  RichardTAO  阅读(378)  评论(1编辑  收藏  举报