Java 网络编程基础学习记录

2020-12-21
·
23 min read
a cup of hot coffee, with the text 'java' on the cup

黑历史文章系列

网络编程基础

TCP/IP 协议

通常所说的 TCP (Transmission Control Protocol) / IP (Internet Protocol) 协议并不仅仅指这两种协议, 而是指包括 FTP (文件传输协议), SMTP (邮件传输协议) 以及 HTTP, TCP, UDP, IP 等协议在内的互联网协议簇, 其中 TCP 和 IP 是其中最重要的两个协议.

TCP 负责从应用程序到网络的数据传输控制, 在数据传送之前将它们分割为 IP 包, 然后在它们到达的时候将它们重组.

IP 负责计算机之间的通信, 在因特网上发送和接收数据包. IP 包的特点是按块发送, 途径多个路由, 但不保证能到达, 也不保证顺序到达.

IP 地址

上文提到 IP 负责计算机之间的通信, 而 IP 地址便是一台设备连接至网络后所分配到的用作标识的一串数字.

IPv4 的地址是一个 32 位二进制数数, 一般按照 8 位分隔为一组转换为十进制数后并用小数点隔开, 又被分为前段的网络地址和后段的主机地址两部分.

网络地址 (网络号) 用来识别设备所在的网络, 同一网络上的所有设备, 都有相同的网络地址. 主机地址 (主机号) 用来区别同一网络中的不同设备.

子网掩码

子网掩码是用来配合 IP 地址使用的, 用于区分识别出 IP 地址的网络号, 例如下表的例子

/DECBIN
IP 地址192.168.1.19911000000.10101000.00000001.11000111
子网掩码255.255.255.011111111.11111111.11111111.00000000

对 IP 地址和子网掩码的各个二进制位作与运算便得到了 11000000.10101000.00000001.00000000, 转换为十进制数后便是 192.168.1.0, 将网络地址分离了出来.

网络端口

一台服务器上可能运行着多个网络程序的进程, 网络端口便是用来标识服务器上提供特定网络服务的进程的一个 16 位二进制数. 客户端可以按照服务器 IP 与端口号与相应的服务器进程创建网络连接, 获得相应的网络服务. 通常一些端口具有自己固定的用途, 例如 HTTP 服务使用 80 端口, HTTPS 服务使用 443 端口, SSH 服务使用 22 端口, MySQL 服务默认使用 3306 端口等等.

DNS

IP 地址对于我们人来说难以记忆, 在浏览器中想要访问互联网时, 很难通过记忆 IP 地址来访问对应的网站. DNS (Domain Name System) 即域名系统可以解决这个难题, 其建立了一个域名与 IP 地址的映射, 即通过 DNS 服务, 我们只需要提供域名即可解析至对应的 IP 地址.

TCP

TCP 是一个面向连接的协议, 即客户端与服务端能够开始发送数据之前, 首先要经历三次握手创建一个 TCP 连接, 而在关闭连接的时候也要经历四次握手. TCP 负责在两台计算机之间建立可靠连接, 保证数据包按顺序到达. TCP 协议会通过握手建立连接, 然后对每个 IP 包编号, 确保对方按顺序收到, 如果包丢失了会自动重发. TCP 具有的确认重传等机制使得其建立的连接为可靠连接. (具体流程细节目前阶段暂时略去...)

TCP

UDP

UDP 是面向无连接的协议, 使用时不需要经过握手建立连接, 因此不能保证服务端发送的数据包能否送达, 但因其不需要在通信前连理连接, 其传输效率比 TCP 更高.

Socket

Socket 本义为插座, 一般被译为套接字. 它提供了介于 TCP, UDP 协议的运输层与应用层之间的一层抽象接口, 便于进行网络编程.

前面我们提到一台服务器上可能不止运行有一个网络程序, 仅仅通过 IP 地址是无法具体判断要与哪个网络进程通信, 因此操作系统抽象出 Socket 接口, 每个应用程序需要各自对应到不同的 Socket , 数据包才能根据 Socket 正确地发到对应的应用程序. 一个应用程序通过一个 Socket 来建立远程连接, 而在 Socket 内部通过 TCP/IP 协议把数据传输到网络, 因此一个 Socket 需要 IP 地址, 协议, 端口三部分来进行通信.

Socket Abstraction Layer

以 TCP 协议通讯的 Socket 运行流程如下图

服务器根据地址类型, Socket 类型, 通信协议来创建一个 Socket, 并绑定 IP 地址和端口号, 此时服务端开始一直监听该端口, 等待客户端请求连接, 此时服务端的 Socket 并未开放.

然后客户端创建 Socket 并根据服务器 IP 地址和端口尝试连接服务器 Socket, 服务端收到客户端的请求开放 Socket, 开始接收客户端请求, 直到客户端返回连接信息. 这时候 Socket 进入阻塞状态, 即一直到客户端返回连接信息后才停止, 再开始接收下一个客户端连接请求.

客户端连接成功后, 向服务器发送连接状态信息, 客户端向 Socket 写入信息, 服务器读取信息, 最后客户端和服务端各自关闭.

Socket Communication

网络编程 Java 实现

IO Stream

Java 的标准库提供了进行 Socket 编程的 API, 同时 TCP 是一种基于流的协议, Java 标准库提供了 InputStreamOutputStream 来封装 Socket 的数据流.

IO 流是一种顺序读写数据的模式, 它的特点是单向流动, 其以字节为最小单位, 因此也被称作字节流. 目前我们用到的 IO 操作都是同步 IO, 即读写 IO 时必须等待数据返回后才能够继续执行后续操作.

Java 标准库中的 InputStreamOutputStream 都是抽象类, 是所有输入输出流的父类, 另外还有 ReaderWriter 实现, 用于读取字符, 下面例子我们将使用 BufferedReaderBufferedWriter, 可以从字符输入流中读取文本并缓冲字符, 以便有效地读取字符.

IO Stream 在使用完毕后都需要手动调用 close() 方法进行关闭, 使用 Java 7 引入的 try (resource) 语法可以自动为我们关闭资源, 编译器会在其后加一个 finally 语句来关闭该 IO Stream. 用法示例如下

try (InputStream input = new FileInputStream("test.txt")) {
    // Do something...
}

TCP 编程

服务端

首先创建一个 ServerSocket 对象, 并指定监听的端口, 接下来进入一个无限循环来处理客户端的连接, server.accept() 会阻塞并一直等待客户端连接, 每当有新的客户端连接进来时, 该方法会返回一个 Socket 对象, 然后新建立一个线程来对该 Socket 对象进行处理. 如果有多个客户端同时连接进来, ServerSocket 会把连接扔进一个队列里, 然后挨个处理.

我们另外还新写了一个继承自 Thread 的类, 覆写其 run() 方法, 用来处理每次通过 accept() 方法获取到的 Socket 对象.

run() 方法中, 我们获取到传入的 Socket 对象的 InputStreamOutputStream, 并用 handle() 方法对其进行处理. 其中我们用 InputStreamReader() 将传入的 InputStream 转换为处理字符的 Reader, 再据其创建 BufferdReader, 对 OutputStream 作同样处理, 然后用 reader.readLine() 读取客户端传过来的数据, 用 writer.write() 再向客户端发送数据.

注意到每次我们调用 writer.write() 方法后, 都又调用了 writer.flush() 方法, 因为以流的形式写入数据的时候, 并不是一写入就立刻发送到网络, 而是先写入一个内存缓冲区, 等到缓冲区满了后, 才会一次性真正发送到网络. 这样设计的目的是为了提高传输效率, 如果缓冲区的数据未达到最大值, 而我们又想强制把这些数据发送到网络出去, 就必须调用 writer.flush() 强制把缓冲区数据发送出去.

另外在服务端处理逻辑里, 如果我们检测到客户端传递过来了 exit 的消息, 那么我们将停止循环, 并关闭该 Socket.

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket server = new ServerSocket(9000);
        System.out.println("Server is running on " + server.getInetAddress().getHostName() + ":" + server.getLocalPort() + "...");
        while (true) {
            Socket socket = server.accept();
            System.out.println("Connected from " + socket.getRemoteSocketAddress());
            Thread thread = new Handler(socket);
            thread.start();
        }
    }
}

class Handler extends Thread {
    Socket socket;

    public Handler(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try (
            InputStream input = this.socket.getInputStream()
        ) {
            try (OutputStream output = this.socket.getOutputStream()) {
                handle(input, output);
            }
        } catch (Exception e) {
            try {
                this.socket.close();
            } catch (IOException ioe) {
                ioe.printStackTrace();
            }
            System.out.println("Client disconnected.");
        }
    }

    private void handle(InputStream input, OutputStream output) throws IOException {
        var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
        var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
        writer.write("Hello World!\n");
        writer.flush();
        while (true) {
            String str = reader.readLine();
            if (str.equals("exit")) {
                writer.write("exit");
                writer.flush();
                break;
            }
            str = str.replace("吗", "");
            str = str.replace("?", "!");
            str = str.replace("?", "!");
            writer.write(str + "\n");
            writer.flush();
        }
        writer.close();
        reader.close();
    }
}

客户端

用 Socket 来创建客户端 Socket, 需要指定 IP 地址和端口, 因为我们的服务端是运行在本地上的, localhost 便是指向本地的地址. 同样我们获取到该 Socket 的 InputStreamOutputStream, 同样再定义一个 handle() 方法来处理 IO 流.

public class Client {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("localhost", 9000);
        try (InputStream input = socket.getInputStream()) {
            try (OutputStream output = socket.getOutputStream()) {
                handle(input, output);
            }
        }
        System.out.println("Bye~");
        socket.close();
        System.out.println("Client disconnected...");
    }

    private static void handle(InputStream input, OutputStream output) throws IOException {
        var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
        var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
        Scanner scanner = new Scanner(System.in);
        System.out.println("From Server: " + reader.readLine());
        while (true) {
            System.out.print(">>> ");
            String str = scanner.nextLine();
            writer.write(str);
            writer.newLine();
            writer.flush();
            String response = reader.readLine();
            System.out.println("<<< " + response);
            if (response.equals("exit")) {
                break;
            }
        }
        writer.close();
        reader.close();
    }
}

演示效果

Server

Client

UDP 编程

服务端

UDP 协议不需要创建连接, 数据包是每次收发一个, 不存在流的概念, 并且 TCP 协议和 UDP 协议的端口互不冲突.

使用 Java 标准库提供的 DatagramSocket 创建一个 Server Socket, 也需要指定所监听的端口.

接下来进入一个无限循环, 要接收数据包, 首先要创建一个 byte[] buffer 作为缓冲区, 作为参数传递给 DatagramPacket 的构造器来创建一个 Packet 对象, 再调用 server.receive() 方法接收包. 该方法也会一直阻塞直到收取到包为止.

假定接收到的数据为字符串, 通过 String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8); 构造器从包的数据来构建一个字符串, 其中分别通过 offsetlength 来指定数据在缓冲区中的位置.

当服务器收到一个 Packet 后, 通常必须立即回复一个或多个 UDP 包, 因为客户端地址在 Packet 中, 每次收到的 Packet 可能来自不同的客户端, 如果不回复, 客户端就收不到任何 UDP 包.

我们将从客户端收到的数据包转换为字符串后, 将字符串进行处理后再将其转换成 byte[] 后以 Packet 的形式再返回给客户端.

public class UDPServer {
    public static void main(String[] args) throws IOException {
        var server = new DatagramSocket(9000);
        System.out.println("Server running...");
        while (true) {
            byte[] buffer = new byte[1024];
            var packet = new DatagramPacket(buffer, buffer.length);
            server.receive(packet);
            String str = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
            str = str.replace("吗", "");
            str = str.replace("?", "!");
            str = str.replace("?", "!");
            byte[] data = str.getBytes(StandardCharsets.UTF_8);
            packet.setData(data);
            server.send(packet);
        }
    }
}

客户端

UDP 客户端在使用时只需要向服务端发包再从服务端接收包即可.

客户端创建 DatagramSocket 实例时并不需要指定端口, 而是由操作系统自动指定一个当前未使用的端口. 然后调用 setSoTimeout(1000) 方法设定超时为 1 秒, 即在后续接收 UDP 包时, 等待的时间最多不会超过 1 秒, 否则在没有收到 UDP 包时, 客户端会无限等待下去.

接下来我们还调用了 connect() 方法来连接到我们的服务端. 但前文我们提到过 UDP 协议是无连接的, 因此这里调用的方法其实是为我们的客户端 Socket 指定服务端的 IP 地址和端口号, 确保该 Socket 实例只能向指定的服务端发送数据包, 这并不是 UDP 协议本身的限制, 而是 Java 的 API 做的安全检查. 所以如果客户端希望向两个不同的服务器发送 UDP 包, 那么必须创建两个 DatagramSocket 实例才能实现.

通常来说, 客户端必须先发 UDP 包, 因为客户端不发 UDP 包, 服务器端就不知道客户端的地址和端口号, 就无法将数据包再返回给客户端.

public class UDPClient {
    public static void main(String[] args) throws IOException {
        var server = new DatagramSocket();
        server.setSoTimeout(1000);
        server.connect(InetAddress.getByName("localhost"), 9000);

        var in = new Scanner(System.in);
        while (true) {
            System.out.print(">>> ");
            var str = in.nextLine();

            if (str.equals("exit")) {
                break;
            }

            byte[] data = str.getBytes(StandardCharsets.UTF_8);
            var packet = new DatagramPacket(data, data.length);
            server.send(packet);

            byte[] buffer = new byte[1024];
            packet = new DatagramPacket(buffer, buffer.length);
            server.receive(packet);

            String response = new String(packet.getData(), packet.getOffset(), packet.getLength());
            System.out.println("<<< " + response);
        }

        server.disconnect();
    }
}

演示效果

UDP Server

UDP Client

简单应用

编写一个简单的登陆服务, 客户端输入用户名和密码, 服务端接收后从数据库中查询, 并返回相应的结果.

User Bean

首先给用户创建一个 Java Bean, 并且实现了 Serializable 接口, 使得其对象能够被转换成 byte[] 并通过 ObjectInputStreamObjectOutputStream 这两个 IO 流传输, 并且能够保证对象所包含的数据不发生改变.

public class User implements Serializable {
    private String username;
    private String passwd;

    public User(String username, String passwd) {
        this.username = username;
        this.passwd = passwd;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPasswd() {
        return passwd;
    }

    public void setPasswd(String passwd) {
        this.passwd = passwd;
    }
}

Database Utils

使用 JDBC 接口来实现对数据库的操作, 这里就直接用了之前实现登录注册页面的用户数据库, 详情请看这里.

这里需要通过 Maven 引入 MySQL 相关的库, 在 pom.xml 里加上下面的配置即可.

<dependencies>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>5.1.47</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

创建一个 DBUtils 类, 其中的 openConnection() 方法用来连接数据库, getUsers() 方法用来获取所有的用户列表, 并返回一个 ResultSet 实例. isExist() 方法用来检测用户名是否存在于数据库之中, isInfoMatch() 方法用来判断用户名和密码是否匹配.

public class DBUtils {
    private Connection connection;
    private final String url = "jdbc:mysql://tunkshif.design:3306/test";
    private final String user = "tunkshif";
    private final String passwd = "******";
    private Statement statement;

    public void openConnection() {
        try {
            Class.forName("com.mysql.jdbc.Driver");
            connection = DriverManager.getConnection(url, user, passwd);
            if (connection != null) {
                System.out.println("Connected to the database successfully!");
            }
        } catch (Exception e) {
            System.out.println("ERROR: " + e.getMessage());
            e.printStackTrace();
        }
    }

    public ResultSet getUsers() {
        ResultSet result = null;
        try {
            statement = connection.createStatement();
            result = statement.executeQuery("SELECT * FROM users");
        } catch (SQLException e) {
            System.out.println("ERROR: " + e.getMessage());
            e.printStackTrace();
        }
        return result;
    }

    public boolean isExist(String username) {
        boolean isExist = false;
        try {
            var result = this.getUsers();
            if (result != null) {
                while (result.next()) {
                    if (result.getString("user_name").equals(username)) {
                        isExist = true;
                        break;
                    }
                }
            }
        } catch (SQLException e) {
            System.out.println("ERROR: " + e.getMessage());
            e.printStackTrace();
        }
        return isExist;
    }

    public boolean isInfoMatch(String username, String passwd) {
        boolean isMatch = false;
        if (!isExist(username)) {
            throw new NullPointerException("User doesn't exits!");
        }
        try {
            var result = this.getUsers();
            if (result != null) {
                while (result.next()) {
                    if (result.getString("user_name").equals(username)) {
                        isMatch = result.getString("user_pwd").equals(passwd);
                        break;
                    }
                }
            }
        } catch (SQLException e) {
            System.out.println("ERROR: " + e.getMessage());
            e.printStackTrace();
        }
        return isMatch;
    }
}

Login Server

服务端部分, 核心逻辑主要在 Handler 类中的 handle() 函数中. 客户端要发送过来的是一个序列化后的 User 对象, 因此服务端的 reader 是一个 ObjectInputStream, 而服务端要向客户端发送文本信息, 所以服务端的 writer 是一个 BufferedWriter.

服务端接收到客户端传递的 User 对象后, 与数据库中的数据进行比对, 然后向客户端返回对应的信息.

public class LoginServer {
    public static void main(String[] args) throws IOException {
        var server = new ServerSocket(9000);
        System.out.println("The server is running...");
        while (true) {
            var socket = server.accept();
            System.out.println("Connected from " + socket.getRemoteSocketAddress());
            Thread thread = new Handler(socket);
            thread.start();
        }
    }
}

class Handler extends Thread {
    Socket socket;

    public Handler(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try (InputStream input = this.socket.getInputStream()) {
            try (OutputStream output = this.socket.getOutputStream()) {
                handle(input, output);
            }
        } catch (Exception e) {
            try {
                this.socket.close();
                System.out.println("Client disconnected.");
            } catch (IOException ioe) {
                ioe.printStackTrace();
            }
        }
    }

    private void handle(InputStream input, OutputStream output) throws IOException, ClassNotFoundException {
        var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
        var reader = new ObjectInputStream(input);

        User user = (User) reader.readObject();
        var db = new DBUtils();
        try {
            db.openConnection();
            if (db.isExist(user.getUsername())) {
                if (db.isInfoMatch(user.getUsername(), user.getPasswd())) {
                    writer.write("Logged in successfully!");
                    writer.flush();
                } else {
                    writer.write("Incorrect password!");
                    writer.flush();
                }
            } else {
                writer.write("User name doesn't exist!");
                writer.flush();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        writer.close();
        reader.close();
    }
}

Login Client

客户端的核心逻辑也主要在 handle() 方法当中, 其中的 writer 用于向服务端传递序列化后的 User 对象, reader 用于从服务端读取文本信息.

在用户输入用户名和密码后, 先创建 User 对象, 然后将其发送给服务端, 等待接收服务端返回来的文本信息, 并打印展示.

public class LoginClient {
    public static void main(String[] args) throws IOException {
        var socket = new Socket("localhost", 9000);
        try (var input = socket.getInputStream()) {
            try (var output = socket.getOutputStream()) {
                handle(input, output);
            }
        }

        System.out.println("Bye~");
        socket.close();
    }

    private static void handle(InputStream input, OutputStream output) throws IOException {
        var writer = new ObjectOutputStream(output);
        var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));

        var in = new Scanner(System.in);
        System.out.print("Enter your username: ");
        var username = in.next();
        System.out.print("Enter your password: ");
        var passwd = in.next();

        var user = new User(username, passwd);
        writer.writeObject(user);
        writer.flush();

        var response = reader.readLine();
        System.out.println(">>> " + response);

        writer.close();
        reader.close();
    }
}

演示效果

Server

服务器运行

Wrong Password

密码错误

Login

登录成功

Username

用户名不存在

Database

数据库具体内容

参考资料