java学习笔记之基础:网络编程、JDBC、函数式编程

网络编程

计算机网络是指两台或更多的计算机组成的网。在同一个网络中,任意两台计算机都可以使用 TCP/IP 协议通信。

在互联网中,IP 地址用于唯一标识网络接口。联入互联网的计算机有一个或者多个 IP 地址。IP 地址分为 IPv4 和 IPv6 两种。IPv4 采用 32 位地址,类似 101.202.99.12,而 IPv6 采用 128 位地址,类似 2001:0DA8:100A:0000:0000:1020:F2F3:1428。IP 地址又分为公网 IP 地址和内网 IP 地址。公网 IP 地址可以直接被访问,内网 IP 地址只能在内网访问。内网 IP 地址类似于:192.168.x.x 、10.x.x.x 。有一个特殊的 IP 地址,称之为本机地址,它总是 127.0.0.1。如果计算机只有一个网卡并且接入了网络,那么它有一个本机地址 127.0.0.1,还有一个 IP 地址,例如 101.202.99.12,可以通过这个 IP 地址接入网络。如果计算机有两块网卡,那么除了本机地址,它可以有两个 IP 地址,可以分别接入两个网络。通常连接两个网络的设备是路由器或者交换机,它至少有两个 IP 地址,分别接入不同的网络,让网络之间连接起来。

位于同一个网络的两台计算机可以直接通信,因为他们的 IP 地址前段是相同的。不在同一个网络的两台计算机不能直接通信,它们之间必须通过路由器或者交换机间接通信,我们把这种设备称为网关。网关的作用就是连接多个网络,负责把来自一个网络的数据包发到另一个网络,这个过程叫路由。计算机的网卡会有 3 个关键配置:IP 地址、子网掩码、网关的 IP 地址。

由于记忆 IP 地址非常困难,我们通常使用域名访问某个特定的服务。域名解析服务器 DNS 负责把域名翻译成对应的 IP 地址,客户端再根据 IP 地址访问服务器。用 nslookup 可以查看域名对应的 IP 地址:nslookup example.com 。有一个特殊的本机域名 localhost,它对应的 IP 地址总是本机地址 127.0.0.1。

常用协议

IP 协议是分组交换协议,不保证可靠传输。TCP 协议是传输控制协议,是面向连接的协议,支持可靠传输和双向通信。TCP 协议是建立在 IP 协议之上的。简单地说,IP 协议只负责发数据包,不保证顺序和正确性。TCP 协议负责控制数据包传输,在传输数据之前需要先建立连接,建立连接后才能传输数据,传输完后还需要断开连接。TCP 协议之所以能保证数据的可靠传输,是通过接收确认、超时重传这些机制实现的。并且 TCP 协议允许双向通信,即通信双方可以同时发送和接收数据。

TCP 协议是应用最广泛的协议,许多高级协议都是建立在 TCP 协议之上的,例如 HTTP、SMTP 等。

UDP 协议是数据报文协议,是无连接协议,不保证可靠传输,传输效率比 TCP 高,而且 UDP 协议比 TCP 协议要简单得多。选择 UDP 协议时,传输的数据通常是能容忍丢失的,一些语音视频通信的应用会选择 UDP 协议。

TCP 编程

Socket 是一个抽象概念,应用程序通过 Socket 来建立远程连接,Socket 内部通过 TCP/IP 协议把数据传输到网络。操作系统抽象出 Socket 接口,每个应用程序需要对应到不同的 Socket,数据包才能根据 Socket 正确地发到对应应用程序。Socket、TCP 和部分 IP 的功能都是由操作系统提供的,不同的编程语言只是提供了对操作系统调用的简单封装。

Socket 由 IP 地址和端口号(范围是 0~65535)组成。端口号总是由操作系统分配,其中小于 1024 的端口属于特权端口,需要管理员权限,大于 1024 的端口可以由任意用户的应用程序打开。使用 Socket 进行网络编程时,本质上就是两个进程之间的网络通信。服务器端进程主动监听某个指定的端口,客户端进程必须主动连接服务器的 IP 地址和指定端口,如果连接成功,服务器端和客户端就成功地建立了一个 TCP 连接,双方后续就可以随时发送和接收数据。因此当 Socket 连接成功地在服务器端和客户端之间建立后,服务器端的 Socket 是指定的 IP 地址和指定的端口号,客户端 Socket 是它所在计算机的 IP 地址和一个由操作系统分配的随机端口号。

Java 标准库提供了 ServerSocket 来实现对指定 IP 和指定端口的监听。

import java.util.*;

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket(6666); // 监听指定端口
        System.out.println("server is running...");
        while(true) {
            Socket sock = ss.accept();
            System.out.println("connected from " + sock.getRemoteSocketAddress());
            Thread t = new HandlerThread(sock);
            t.start();
        }
    }
}

class HandlerThread extends Thread {
    Socket sock;

    public HandlerThread(Socket sock) {
        this.sock = sock;
    }

    @Override
    public void run() {
        try (InputStream input = this.sock.getInputStream()) {
            try (OutputStream output = this.sock.getOutputStream()) {
                handle(input, output);
            }
        } catch (Exception e) {
            try {
                this.sock.close();
            } catch (IOException ioe) {
            }
            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\n");
        writer.flush();
        while(true) {
            String s = reader.readLine();
            if (s.equals("bye")) {
                writer.write("bye\n");
                writer.flush();
                break;
            }
            writer.write("ok: " + s + "\n");
            writer.flush();
        }
    }
}

服务器端通过代码:ServerSocket ss = new ServerSocket(6666); 在指定端口 6666 监听。这里我们没有指定 IP 地址,表示在计算机的所有网络接口上进行监听。如果 ServerSocket 监听成功,我们就使用一个无限循环来处理客户端的连接。ss.accept() 表示每当有新的客户端连接进来后,就返回一个 Socket 实例,这个 Socket 实例就是用来和刚连接的客户端进行通信的。由于客户端很多,要实现并发处理,我们就必须为每个新的 Socket 创建一个新线程来处理,这样主线程的作用就是接收新的连接,每当收到新连接后,就创建一个新线程进行处理。这里也完全可以利用线程池来处理客户端连接,能大大提高运行效率。

如果没有客户端连接进来,accept() 方法会阻塞并一直等待。如果有多个客户端同时连接进来,ServerSocket 会把连接扔到队列里,然后一个一个处理。对于 Java 程序而言,只需要通过循环不断调用 accept() 就可以获取新的连接。

客户端:

public class Client {
    public static void main(String[] args) throws IOException {
        Socket sock = new Socket("localhost", 6666); // 连接指定服务器和端口
        try (InputStream input = sock.getInputStream()) {
            try (OutputStream output = sock.getOutputStream()) {
                handle(input, output);
            }
        }
        sock.close();
        System.out.println("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("[server] " + reader.readLine());
        while(true) {
            System.out.print(">>> "); // 打印提示
            String s = scanner.nextLine(); // 读取一行输入
            writer.write(s);
            writer.newLine();
            writer.flush();
            String resp = reader.readLine();
            System.out.println("<<< " + resp);
            if (resp.equals("bye")) {
                break;
            }
        }
    }
}

当 Socket 连接创建成功后,无论是服务器端还是客户端,都使用 Socket 实例进行网络通信。由于 TCP 是一种基于流的协议,Java 标准库使用 InputStream 和 OutputStream 来封装 Socket 的数据流,这样我们使用 Socket 的流,和普通 IO 流类似:

// 用于读取网络数据:
InputStream in = sock.getInputStream();
// 用于写入网络数据:
OutputStream out = sock.getOutputStream();

写入网络数据时,要调用 flush()方法。如果不调用 flush(),客户端和服务器可能收不到数据。以流的形式写入数据的时候,并不是一写入就立刻发送到网络,而是先写入内存缓冲区。直到缓冲区满了以后,才会一次性真正发送到网络,这样设计的目的是为了提高传输效率。如果缓冲区的数据很少,而我们又想强制把这些数据发送到网络,就必须调用 flush() 强制把缓冲区数据发送出去。

UDP 编程

和 TCP 编程相比,UDP 编程就简单得多,因为 UDP 没有创建连接,数据包也是一次收发一个,没有流的概念。在 Java 中使用 UDP 编程,仍然需要使用 Socket。UDP 端口和 TCP 端口虽然都使用 0~65535,但他们是两套独立的端口,即一个应用程序用 TCP 占用了端口 1234,不影响另一个应用程序用 UDP 占用端口 1234。

Java 提供了 DatagramSocket 来实现 UDP 连接:

DatagramSocket ds = new DatagramSocket(6666); // 监听指定端口
while(true) { // 无限循环
    // 数据缓冲区:
    byte[] buffer = new byte[1024];
    DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
    ds.receive(packet); // 收取一个 UDP 数据包
    // 收取到的数据存储在 buffer 中,由 packet.getOffset(), packet.getLength() 指定起始位置和长度
    // 将其按 UTF-8 编码转换为 String:
    String s = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
    // 发送数据:
    byte[] data = "ACK".getBytes(StandardCharsets.UTF_8);
    packet.setData(data);
    ds.send(packet);
}

服务器端首先使用如下语句在指定的端口监听 UDP 数据包:DatagramSocket ds = new DatagramSocket(6666);。要接收一个 UDP 数据包,需要准备一个 byte[] 缓冲区,并通过 DatagramPacket 实现接收。假设我们收取到的是一个 String,通过 DatagramPacket 返回的 packet.getOffset() 和 packet.getLength() 确定数据在缓冲区的起止位置:
String s = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);。当服务器收到一个 DatagramPacket 后,通常必须立刻回复一个或多个 UDP 包,因为客户端地址在 DatagramPacket 中,每次收到的 DatagramPacket 可能来自不同的客户端,如果不回复,客户端就收不到任何 UDP 包。

和服务器端相比,客户端使用 UDP 时,只需要直接向服务器端发送 UDP 包,然后接收返回的 UDP 包:

DatagramSocket ds = new DatagramSocket();
ds.setSoTimeout(1000);
ds.connect(InetAddress.getByName("localhost"), 6666); // 连接指定服务器和端口
// 发送:
byte[] data = "Hello".getBytes();
DatagramPacket packet = new DatagramPacket(data, data.length);
ds.send(packet);
// 接收:
byte[] buffer = new byte[1024];
packet = new DatagramPacket(buffer, buffer.length);
ds.receive(packet);
String resp = new String(packet.getData(), packet.getOffset(), packet.getLength());
ds.disconnect();
// 关闭:
ds.close();

客户端创建 DatagramSocket 实例时并不需要指定端口,而是由操作系统自动指定一个当前未使用的端口。调用 setSoTimeout(1000) 设定超时 1 秒,意思是后续接收 UDP 包时,等待时间最多不会超过 1 秒,否则在没有收到 UDP 包时,客户端会无限等待下去。客户端的 DatagramSocket 调用 connect() 方法在客户端的 DatagramSocket 实例中保存服务器端的 IP 和端口号,确保这个 DatagramSocket 实例只能往指定的地址和端口发送 UDP 包,不能往其他地址和端口发送。通常来说,客户端必须先发 UDP 包,因为客户端不发 UDP 包,服务器端就根本不知道客户端的地址和端口号。如果客户端认为通信结束,就可以调用 disconnect() 断开连接:disconnect() 也不是真正地断开连接,它只是清除了客户端 DatagramSocket 实例记录的远程服务器地址和端口号,这样 DatagramSocket 实例就可以连接另一个服务器端。

如果客户端希望向两个不同的服务器发送 UDP 包,有两种方法:

  1. 客户端可以创建两个 DatagramSocket 实例,用 connect() 保存不同服务器端的 IP 和端口号;
  2. 客户端也可以不调用 connect() 方法,而是在创建 DatagramPacket 的时候指定服务器地址。

不调用 connect() 方法:

DatagramSocket ds = new DatagramSocket();
ds.setSoTimeout(1000);
// 发送到 localhost:6666:
byte[] data1 = "Hello".getBytes();
var packet1 = new DatagramPacket(data1, data1.length, InetAddress.getByName("localhost"), 6666);
ds.send(packet1);
// 发送到 localhost:8888:
byte[] data2 = "Hi".getBytes();
var packet2 = new DatagramPacket(data2, data2.length, InetAddress.getByName("localhost"), 8888);
ds.send(packet2);
// 关闭:
ds.close();

发送 Email

SMTP 协议,Simple Mail Transport Protocol ,使用标准端口 25,也可以使用加密端口 465 或 587。SMTP 协议是一个建立在 TCP 之上的协议,任何程序发送邮件都必须遵守 SMTP 协议。使用 Java 程序发送邮件时,无需关心 SMTP 协议的底层原理,只需要使用 JavaMail 这个标准 API 就可以直接发送邮件。

发送邮件前首先要确定 SMTP 服务器的地址和端口号。邮件服务器地址通常是 smtp.example.com,端口号由邮件服务商确定使用 25、465 还是 587。有了 SMTP 服务器的域名和端口号,我们还需要 SMTP 服务器的登录信息,通常是使用邮件地址作为用户名,登录口令是用户口令或者一个独立设置的 SMTP 口令。

import javax.mail.*;
import javax.mail.internet.*;
import java.util.Properties;

public class TextEmailSender {
    public static void main(String[] args) {
        // 发件人邮箱和授权码
        final String username = "your_email@example.com";
        final String password = "your_password";
        
        // 收件人邮箱
        String to = "recipient@example.com";
        
        // SMTP服务器配置
        Properties props = new Properties();
        props.put("mail.smtp.host", "smtp.example.com"); // SMTP主机名
        props.put("mail.smtp.port", "587"); // 主机端口号
        props.put("mail.smtp.auth", "true"); // 是否需要用户认证
        props.put("mail.smtp.starttls.enable", "true"); // 启用TLS加密
        
        // 创建会话
        Session session = Session.getInstance(props,
            new Authenticator() {
                protected PasswordAuthentication getPasswordAuthentication() {
                    return new PasswordAuthentication(username, password);
                }
            });
        // 设置debug模式便于调试:
        session.setDebug(true);

        try {
            // 创建邮件
            Message message = new MimeMessage(session);
            message.setFrom(new InternetAddress(username)); // 设置发送方地址
            message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to)); // 设置接收方地址
            message.setSubject("测试邮件主题"); // 设置邮件主题
            message.setText("这是一封测试邮件的正文内容", "UTF-8"); // 设置邮件正文
            
            // 发送邮件
            Transport.send(message);
            System.out.println("邮件发送成功");
        } catch (MessagingException e) {
            throw new RuntimeException(e);
        }
    }
}

发送 HTML 邮件: message.setText(body, "UTF-8", "html");

发送附件:

// 创建邮件正文部分
MimeBodyPart textPart = new MimeBodyPart();
textPart.setText("请查收附件");

// 创建附件部分
MimeBodyPart attachmentPart = new MimeBodyPart();
String filename = "attachment.txt";
FileDataSource source = new FileDataSource(filename);
attachmentPart.setDataHandler(new DataHandler(source));
attachmentPart.setFileName(filename);

// 组合正文和附件
Multipart multipart = new MimeMultipart();
multipart.addBodyPart(textPart);
multipart.addBodyPart(attachmentPart);

message.setContent(multipart);

发送内嵌图片的 HTML 邮件:

// 创建邮件正文部分
BodyPart textpart = new MimeBodyPart();
textpart.setContent("<h1>Hello</h1><p><img src=\"cid:img01\"></p>", "text/html;charset=utf-8");

// 添加 image
BodyPart imagepart = new MimeBodyPart();
imagepart.setFileName(fileName);
imagepart.setDataHandler(new DataHandler(new ByteArrayDataSource(input, "image/jpeg")));
// 与 HTML 的 <img src="cid:img01"> 关联:
imagepart.setHeader("Content-ID", "<img01>");

Multipart multipart = new MimeMultipart();
multipart.addBodyPart(textpart);
multipart.addBodyPart(imagepart);

HTTP 编程

Java 11 引入了 HttpClient 用于 HTTP 通信。HttpClient 提供了发送 HTTP 请求和接收 HTTP 响应的功能。它使用链式调用的 API,能大大简化 HTTP 的处理。

import java.net.URI;
import java.net.http.*;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;

public class HttpClientDemo {
    private static final HttpClient client = HttpClient.newBuilder()
        .version(HttpClient.Version.HTTP_2)
        .connectTimeout(Duration.ofSeconds(10))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

    public static void main(String[] args) throws Exception {
        // 同步 GET 请求
        String getResponse = syncGetRequest("https://jsonplaceholder.typicode.com/posts/1");
        System.out.println("GET响应:\n" + getResponse);

        // 异步 GET 请求
        asyncGetRequest("https://jsonplaceholder.typicode.com/posts/2");

        // 同步 POST JSON 请求
        String postData = "{\"title\":\"foo\",\"body\":\"bar\",\"userId\":1}";
        String postResponse = syncPostRequest(
            "https://jsonplaceholder.typicode.com/posts", 
            postData
        );
        System.out.println("POST响应:\n" + postResponse);
    }

    // 同步 GET 请求
    public static String syncGetRequest(String url) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .header("Accept", "application/json")
            .GET()
            .build();

        HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
        return response.body();
    }

    // 异步GET请求
    public static void asyncGetRequest(String url) {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .GET()
            .build();

        CompletableFuture<HttpResponse<String>> future = 
            client.sendAsync(request, BodyHandlers.ofString());

        future.thenApply(HttpResponse::body)
              .thenAccept(body -> System.out.println("异步响应:\n" + body))
              .join();
    }

    // 同步POST请求
    public static String syncPostRequest(String url, String json) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();

        HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
        return response.body();
    }
}

JDBC 编程

JDBC是 Java 程序访问数据库的标准接口。使用 Java 程序访问数据库时,Java 代码并不是直接通过 TCP 连接去访问数据库,而是通过 JDBC 接口来访问,而 JDBC 接口则通过 JDBC 驱动来实现真正对数据库的访问。JDBC 接口是 Java 标准库自带的,可以直接编译,具体的 JDBC 驱动是由数据库厂商提供的。因此访问某个具体的数据库,我们只需要引入该厂商提供的 JDBC 驱动,就可以通过 JDBC 接口来访问,这样保证了 Java 程序编写的是一套数据库访问代码,却可以访问各种不同的数据库,因为他们都提供了标准的 JDBC 驱动。

我们自己编写的代码只需要引用 Java 标准库提供的 java.sql 包下面的相关接口,由此再间接地通过 MySQL 驱动的 jar 包通过网络访问 MySQL 服务器,所有复杂的网络通讯都被封装到 JDBC 驱动中。

JDBC 连接

Connection 代表一个 JDBC 连接,它相当于 Java 程序到数据库的连接,通常是 TCP 连接。打开一个 Connection 时,需要准备 URL、用户名和口令,才能成功连接到数据库。URL 是由数据库厂商指定的格式,例如 MySQL 的 URL 是:jdbc:mysql://<hostname>:<port>/<db>?key1=value1&key2=value2。假设数据库运行在本机 localhost,端口使用标准的 3306,数据库名称是 learnjdbc,那么 URL 如下:jdbc:mysql://localhost:3306/learnjdbc?useSSL=false&characterEncoding=utf8。后面的两个参数表示不使用 SSL 加密,使用 UTF-8 作为字符编码。

获取数据库连接:

String JDBC_URL = "jdbc:mysql://localhost:3306/test";
String JDBC_USER = "root";
String JDBC_PASSWORD = "password";
// 获取连接:
Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD);
// TODO: 访问数据库...
// 关闭连接:
conn.close();

核心代码是 DriverManager 提供的静态方法 getConnection() 。DriverManager 会自动扫描 classpath,找到所有的 JDBC 驱动,然后根据我们传入的 URL 自动挑选一个合适的驱动。JDBC 连接是一种昂贵的资源,使用后要及时释放。使用 try (resource) 来自动释放 JDBC 连接是一个好方法:

try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
    ...
}

JDBC 查询

查询数据库分以下几步:

  1. 通过 Connection 提供的 createStatement() 方法创建一个 Statement 对象,用于执行一个查询;
  2. 执行 Statement 对象提供的 executeQuery("SELECT * FROM students") 传入 SQL 语句,执行查询并获得返回的结果集,使用 ResultSet 来引用这个结果集;
  3. 反复调用 ResultSet 的 next() 方法并读取每一行结果。
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
    try (Statement stmt = conn.createStatement()) {
        try (ResultSet rs = stmt.executeQuery("SELECT id, grade, name, gender FROM students WHERE gender=1")) {
            while (rs.next()) {
                long id = rs.getLong(1); // 注意:索引从 1 开始
                long grade = rs.getLong(2);
                String name = rs.getString(3);
                int gender = rs.getInt(4);
            }
        }
    }
}

Statement 和 ResultSet 都是需要关闭的资源,因此嵌套使用 try (resource) 确保及时关闭;rs.next() 用于判断是否有下一行记录,如果有将自动把当前行移动到下一行;
ResultSet 获取列时,索引从 1 开始而不是 0;必须根据 SELECT 的列的对应位置来调用 getLong(1)getString(2) 这些方法,否则对应位置的数据类型不对将报错。

SQL 注入

使用 Statement 拼字符串非常容易引发 SQL 注入的问题。要避免 SQL 注入攻击,可以使用 PreparedStatement。使用 PreparedStatement 可以完全避免 SQL 注入的问题,因为 PreparedStatement 始终使用 ? 作为占位符,并且把数据连同 SQL 本身传给数据库,这样可以保证每次传给数据库的 SQL 语句是相同的,只是占位符的数据不同,还能高效利用数据库本身对查询的缓存。

String sql = "SELECT * FROM user WHERE login=? AND pass=?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setObject(1, name);
ps.setObject(2, pass);

使用 Java 对数据库进行操作时,必须使用 PreparedStatement,严禁任何通过参数拼字符串的代码。使用 PreparedStatement 和 Statement 稍有不同,必须首先调用 setObject() 设置每个占位符 ? 的值,最后获取的仍然是 ResultSet 对象。从结果集读取列时,使用 String 类型的列名比索引要易读而且不易出错。

try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
    try (PreparedStatement ps = conn.prepareStatement("SELECT id, grade, name, gender FROM students WHERE gender=? AND grade=?")) {
        ps.setObject(1, "M"); // 注意:索引从1开始
        ps.setObject(2, 3);
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                long id = rs.getLong("id");
                long grade = rs.getLong("grade");
                String name = rs.getString("name");
                String gender = rs.getString("gender");
            }
        }
    }
}

数据类型

使用 JDBC 的时候,我们需要在 Java 数据类型和 SQL 数据类型之间进行转换。

SQL 数据类型 Java 数据类型
BIT, BOOL boolean
INTEGER int
BIGINT long
REAL float
FLOAT, DOUBLE double
CHAR, VARCHAR String
DECIMAL BigDecimal
DATE java.sql.Date, LocalDate
TIME java.sql.Time, LocalTime

只有最新的 JDBC 驱动才支持 LocalDate 和 LocalTime。

JDBC 更新

通过 JDBC 进行插入是用 PreparedStatement 执行一条 INSERT 语句,然后执行 executeUpdate()

try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
    try (PreparedStatement ps = conn.prepareStatement(
            "INSERT INTO students (id, grade, name, gender) VALUES (?,?,?,?)")) {
        ps.setObject(1, 999); // 注意:索引从 1 开始
        ps.setObject(2, 1); // grade
        ps.setObject(3, "Bob"); // name
        ps.setObject(4, "M"); // gender
        int n = ps.executeUpdate(); // 1
    }
}

成功执行 executeUpdate() 的返回值是 int,表示插入的记录数量。此处总是 1,因为只插入了一条记录。

如果数据库的表设置了自增主键,那么在执行 INSERT 语句时,并不需要指定主键,数据库会自动分配主键。对于使用自增主键的程序,要获取自增主键需要在创建 PreparedStatement 的时候,指定一个 RETURN_GENERATED_KEYS 标志位,表示 JDBC 驱动必须返回插入的自增主键。

try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
    try (PreparedStatement ps = conn.prepareStatement(
            "INSERT INTO students (grade, name, gender) VALUES (?,?,?)",
            Statement.RETURN_GENERATED_KEYS)) {
        ps.setObject(1, 1); // grade
        ps.setObject(2, "Bob"); // name
        ps.setObject(3, "M"); // gender
        int n = ps.executeUpdate(); // 1
        try (ResultSet rs = ps.getGeneratedKeys()) {
            if (rs.next()) {
                long id = rs.getLong(1); // 注意:索引从 1 开始
            }
        }
    }
}

如果一次插入多条记录,ResultSet 对象就会有多行返回值。如果插入时有多列自增,ResultSet 对象的每一行都会对应多个自增值。

更新操作是 UPDATE 语句,它可以一次更新若干列的记录。

try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
    try (PreparedStatement ps = conn.prepareStatement("UPDATE students SET name=? WHERE id=?")) {
        ps.setObject(1, "Bob"); 
        ps.setObject(2, 999);
        int n = ps.executeUpdate(); // 返回更新的行数
    }
}

executeUpdate() 返回数据库实际更新的行数。返回结果可能是正数,也可能是 0(表示没有任何记录更新)。

删除操作是 DELETE 语句,它可以一次删除若干行。

try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
    try (PreparedStatement ps = conn.prepareStatement("DELETE FROM students WHERE id=?")) {
        ps.setObject(1, 999);
        int n = ps.executeUpdate(); // 删除的行数
    }
}

JDBC 事务

数据库事务是由若干个 SQL 语句构成的一个操作序列。数据库系统保证在一个事务中的所有 SQL 要么全部执行成功,要么全部不执行,即数据库事务具有 ACID 特性:Atomicity 原子性、Consistency 一致性、Isolation 隔离性、Durability:持久性。

数据库事务可以并发执行,而数据库系统从效率考虑,对事务定义了不同的隔离级别。SQL 标准定义了 4 种隔离级别,分别对应可能出现的数据不一致的情况:

Isolation Level 脏读(Dirty Read) 不可重复读(Non Repeatable Read) 幻读(Phantom Read)
Read Uncommitted Yes Yes Yes
Read Committed - Yes Yes
Repeatable Read - - Yes
Serializable - - -

对应用程序来说,数据库事务非常重要,很多运行着关键任务的应用程序,都必须依赖数据库事务保证程序的结果正常。

要在 JDBC 中执行事务,本质上就是如何把多条 SQL 包裹在一个数据库事务中执行。

Connection conn = openConnection();
try {
    // 关闭自动提交:
    conn.setAutoCommit(false);
    // 执行多条 SQL 语句:
    insert(); update(); delete();
    // 提交事务:
    conn.commit();
} catch (SQLException e) {
    // 回滚事务:
    conn.rollback();
} finally {
    conn.setAutoCommit(true);
    conn.close();
}

获取到 Connection 后,默认情况下总是处于“自动提交”模式,也就是每执行一条 SQL 都是作为事务自动执行的。开启事务的关键代码是 conn.setAutoCommit(false),表示关闭自动提交。执行完指定的若干条 SQL 语句后,调用 conn.commit() 提交事务 。出现 SQL 异常时必须捕获并调用 conn.rollback() 回滚事务。最后在 finally 中通过 conn.setAutoCommit(true) 把 Connection 对象的状态恢复到初始值。

MySQL 的默认隔离级别是 REPEATABLE_READ。修改事务的隔离级别:

// 设定隔离级别为 READ COMMITTED:
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

JDBC 批处理

SQL 数据库对 SQL 语句相同,但只有参数不同的若干语句可以批量执行。批量执行速度远远快于循环执行每个 SQL。

try (PreparedStatement ps = conn.prepareStatement("INSERT INTO students (name, gender, grade, score) VALUES (?, ?, ?, ?)")) {
    // 对同一个 PreparedStatement 反复设置参数并调用 addBatch():
    for (Student s : students) {
        ps.setString(1, s.name);
        ps.setBoolean(2, s.gender);
        ps.setInt(3, s.grade);
        ps.setInt(4, s.score);
        ps.addBatch();
    }
    // 执行执行:
    int[] ns = ps.executeBatch();
    for (int n : ns) {
        System.out.println(n + " inserted."); // 每个 SQL 执行的执行结果数量
    }
}

JDBC 连接池

为了避免频繁地创建和销毁 JDBC 连接,我们可以通过连接池复用连接。JDBC 连接池有一个标准的接口 javax.sql.DataSource。常用的 JDBC 连接池实现有:HikariCP、C3P0、 BoneCP、Druid。目前使用最广泛的是 HikariCP。

创建 DataSource 是一个非常昂贵的操作,所以通常 DataSource 实例总是作为一个全局变量存储,并贯穿整个应用程序的生命周期。

HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/test");
config.setUsername("root");
config.setPassword("password");
config.addDataSourceProperty("connectionTimeout", "1000"); // 连接超时:1秒
config.addDataSourceProperty("idleTimeout", "60000"); // 空闲超时:60秒
config.addDataSourceProperty("maximumPoolSize", "10"); // 最大连接数:10
DataSource ds = new HikariDataSource(config);

获取 Connection :

try (Connection conn = ds.getConnection()) { // 在此获取连接
    ...
} // 在此“关闭”连接

通过连接池获取连接时,并不需要指定 JDBC 的相关 URL、用户名、口令等信息,因为这些信息已经存储在连接池内部。一开始,连接池内部并没有连接,所以第一次调用 ds.getConnection(),接池内部先创建一个 Connection,再返回给客户端使用。当我们调用 conn.close() 方法时(在 try(resource){...} 结束处),不是真正“关闭”连接,而是释放到连接池中,以便下次获取连接时能直接返回。连接池内部维护了若干个 Connection 实例,如果调用 ds.getConnection(),就选择一个空闲连接,并标记它为“正在使用”然后返回,如果对 Connection 调用 close(),那么就把连接再次标记为“空闲”从而等待下次调用。我们通过连接池维护了少量连接,但可以频繁地执行大量的 SQL 语句。

通常连接池提供了大量的参数可以配置,例如维护的最小、最大活动连接数,指定一个连接在空闲一段时间后自动关闭等,需要根据应用程序的负载合理地配置这些参数。此外大多数连接池都提供了详细的实时状态以便进行监控。

函数式编程

Java 从 Java 8 开始,支持函数式编程。函数式编程允许把函数本身作为参数传入另一个函数,还允许返回一个函数。函数式编程是把函数作为基本运算单元,函数可以作为变量,可以接收函数,还可以返回函数。我们把支持函数式编程的编码风格称为 Lambda 表达式。

Lambda 表达式

在 Java 程序中,我们经常遇到单方法接口,即一个接口只定义了一个方法:Comparator、Runnable、Callable。以 Comparator 为例,我们想要调用 Arrays.sort() ,可以传入一个 Comparator 实例,以匿名类方式编写如下:

String[] array = new String[] { "Apple", "Orange", "Banana", "Lemon" };
Arrays.sort(array, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return s1.compareTo(s2);
    }
});
System.out.println(String.join(", ", array));

从 Java 8 开始,我们可以用 Lambda 表达式替换单方法接口。改写上述代码如下:

String[] array = new String[] { "Apple", "Orange", "Banana", "Lemon" };
Arrays.sort(array, (s1, s2) -> {
    return s1.compareTo(s2);
});
System.out.println(String.join(", ", array));

Lambda 表达式的写法,它只需要写出方法定义,参数类型可以省略,因为编译器可以自动推断出类型。返回值的类型也是由编译器自动推断的。

(s1, s2) -> {
    return s1.compareTo(s2);
}

如果只有一行代码,可以用更简单的写法:Arrays.sort(array, (s1, s2) -> s1.compareTo(s2));

Functional Interface

我们把只定义了单方法的接口称之为 Functional Interface,用注解 @FunctionalInterface 标记。例如 Callable 接口:

@FunctionalInterface
public interface Callable<V> {
    V call() throws Exception;
}

方法引用

使用 Lambda 表达式,我们就可以不必编写 FunctionalInterface 接口的实现类,从而简化代码:

Arrays.sort(array, (s1, s2) -> {
    return s1.compareTo(s2);
});

除了 Lambda 表达式,我们还可以直接传入方法引用。所谓方法引用是指如果某个方法签名和接口恰好一致,就可以直接传入方法名。

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        String[] array = new String[] { "Apple", "Orange", "Banana", "Lemon" };
        Arrays.sort(array, Main::cmp);
        System.out.println(String.join(", ", array));
    }

    static int cmp(String s1, String s2) {
        return s1.compareTo(s2);
    }
}

Comparator<String> 接口定义的方法是 int compare(String, String),和静态方法 int cmp(String, String) 相比,除了方法名外的方法参数一致,返回类型相同,因此我们说两者的方法签名一致,可以直接把方法名作为 Lambda 表达式传入。这里的方法签名只看参数类型和返回类型,不看方法名称,也不看类的继承关系。

String[] array = new String[] { "Apple", "Orange", "Banana", "Lemon" };
Arrays.sort(array, String::compareTo);
System.out.println(String.join(", ", array));

String.compareTo() 的方法定义和 int Comparator<String>.compare(String s1, String s2) 能匹配是因为:实例方法有一个隐含的 this 参数,String 类的 compareTo() 方法在实际调用的时候,第一个隐含参数总是传入 this。当传入 String::compareTo 时,Java 会将其隐式转换为 Comparator<String> 的实现。所以 String.compareTo() 方法也可作为方法引用传入。

public final class String {
    public int compareTo(String o) {
        ...
    }
}

构造方法引用

除了可以引用静态方法和实例方法,我们还可以引用构造方法。

import java.util.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        List<String> names = List.of("Bob", "Alice", "Tim");
        List<Person> persons = names.stream().map(Person::new).collect(Collectors.toList());
        System.out.println(persons);
    }
}

class Person {
    String name;
    public Person(String name) {
        this.name = name;
    }
    public String toString() {
        return "Person:" + this.name;
    }
}

Stream

Java 8 不但引入了 Lambda 表达式,还引入了一个全新的流式 API:Stream API。它位于 java.util.stream 包中,代表任意 Java 对象的序列。Stream 和 List 也不一样,List 存储的每个元素都是已经存储在内存中的某个 Java 对象,而 Stream 输出的元素可能并没有预先存储在内存中,而是实时计算出来的。

如果我们要表示一个全体自然数的集合,用 List 是不可能写出来的,但用 Stream 可以做到。Stream API 的基本用法是:创建一个 Stream,然后做若干次转换,最后调用一个方法获取真正计算结果。创建或者转换 Stream 只存储了规则,并没有任何计算发生,真正的计算通常发生在最后结果的获取,因此 Stream 是惰性计算。

创建 Stream

使用 Stream.of() 创建 Stream

Stream<String> stream = Stream.of("A", "B", "C", "D");
stream.forEach(System.out::println);

基于数组或 Collection 创建 Stream

import java.util.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        Stream<String> stream1 = Arrays.stream(new String[] { "A", "B", "C" });
        Stream<String> stream2 = List.of("X", "Y", "Z").stream();
        stream1.forEach(System.out::println);
        stream2.forEach(System.out::println);
    }
}

把数组变成 Stream 使用 Arrays.stream() 方法。对于 Collection(List、Set、Queue 等),直接调用 stream() 方法就可以获得 Stream。上述创建 Stream 的方法都是把一个现有的序列变为 Stream,它的元素是固定的。

创建 Stream 还可以通过 Stream.generate() 方法,它需要传入一个 Supplier 对象:Stream<String> s = Stream.generate(Supplier<String> sp);。基于 Supplier 创建的 Stream 会不断调用 Supplier.get() 方法来不断产生下一个元素,这种 Stream 保存的不是元素而是算法,它可以用来表示无限序列。

import java.util.function.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        Stream<Integer> natual = Stream.generate(new NatualSupplier());
        // 注意:无限序列必须先变成有限序列再打印:
        natual.limit(20).forEach(System.out::println);
    }
}

class NatualSupplier implements Supplier<Integer> {
    int n = 0;
    public Integer get() {
        n++;
        return n;
    }
}

我们用一个 Supplier<Integer> 模拟了一个无限序列(当然受 int 范围限制不是真的无限大)。如果用 List 表示,即便在 int 范围内,也会占用巨大的内存,而 Stream 几乎不占用空间,因为每个元素都是实时计算出来的,用的时候再算。对于无限序列,如果直接调用 forEach() 或者 count() 这些最终求值操作会进入死循环,因为永远无法计算完这个序列,所以正确的方法是先把无限序列变成有限序列,例如用 limit() 方法可以截取前面若干个元素,这样就变成了一个有限序列,对这个有限序列调用 forEach() 或者 count() 操作就没有问题。

创建 Stream 还通过一些 API 提供的接口,直接获得 Stream。例如 Files 类的 lines() 方法可以把一个文件变成一个 Stream,每个元素代表文件的一行内容。

try (Stream<String> lines = Files.lines(Paths.get("/path/to/file.txt"))) {
...
}

正则表达式的 Pattern 对象有一个 splitAsStream() 方法,可以直接把一个长字符串分割成 Stream 序列而不是数组:

Pattern p = Pattern.compile("\\s+");
Stream<String> s = p.splitAsStream("The quick brown fox jumps over the lazy dog");
s.forEach(System.out::println);
基本类型 Stream

Java 泛型不支持基本类型,所以我们无法用 Stream<int> 这样的类型,会发生编译错误。为了保存 int,只能使用 Stream<Integer>,但这样会产生频繁的装箱、拆箱操作。为了提高效率,Java 标准库提供了 IntStream、LongStream 和 DoubleStream 这三种使用基本类型的 Stream,它们的使用方法和泛型 Stream 没有大的区别,设计这三个 Stream 的目的是提高运行效率:

// 将 int[] 变为 IntStream:
IntStream is = Arrays.stream(new int[] { 1, 2, 3 });
// 将 Stream<String> 转换为 LongStream:
LongStream ls = List.of("1", "2", "3").stream().mapToLong(Long::parseLong);
map

Stream.map() 是 Stream 最常用的一个转换方法,它把一个 Stream 转换为另一个 Stream。所谓 map 操作就是把一种操作运算映射到一个序列的每一个元素上。

Stream<Integer> s = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> s2 = s.map(n -> n * n);

map() 对于字符串操作以及任何 Java 对象都是非常有用的。

import java.util.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        List.of("  Apple ", " pear ", " ORANGE", " BaNaNa ")
                .stream()
                .map(String::trim) // 去空格
                .map(String::toLowerCase) // 变小写
                .forEach(System.out::println); // 打印
    }
}
filter

Stream.filter()是 Stream 的另一个常用转换方法。所谓 filter() 操作,就是对一个 Stream 的所有元素一一进行测试,不满足条件的就被“滤掉”了,剩下的满足条件的元素就构成了一个新的 Stream。

 IntStream.of(1, 2, 3, 4, 5, 6, 7, 8, 9)
            .filter(n -> n % 2 != 0)
            .forEach(System.out::println);

filter()也可应用于任何 Java 对象。

import java.time.*;
import java.util.function.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        Stream.generate(new LocalDateSupplier())
                .limit(31)
                .filter(ldt -> ldt.getDayOfWeek() == DayOfWeek.SATURDAY || ldt.getDayOfWeek() == DayOfWeek.SUNDAY)
                .forEach(System.out::println);
    }
}

class LocalDateSupplier implements Supplier<LocalDate> {
    LocalDate start = LocalDate.of(2020, 1, 1);
    int n = -1;
    public LocalDate get() {
        n++;
        return start.plusDays(n);
    }
}
reduce

Stream.reduce() 是 Stream 的一个聚合方法,它可以把一个 Stream 的所有元素按照聚合函数聚合成一个结果。

import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        int sum = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9).reduce(0, (acc, n) -> acc + n);
        System.out.println(sum); // 45
    }
}
输出集合

Stream 输出为 List 是一个聚合操作,它会强制 Stream 输出每个元素。

import java.util.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        Stream<String> stream = Stream.of("Apple", "", null, "Pear", "  ", "Orange");
        List<String> list = stream.filter(s -> s != null && !s.isBlank()).collect(Collectors.toList());
        System.out.println(list);
    }
}

类似的,collect(Collectors.toSet()) 可以把 Stream 的每个元素收集到 Set 中。

Stream 输出为数组只需要调用 toArray() 方法,并传入数组的“构造方法”:

List<String> list = List.of("Apple", "Banana", "Orange");
String[] array = list.stream().toArray(String[]::new);

Stream 还有一个强大的分组功能,可以按组输出。分组输出使用 Collectors.groupingBy(),它需要提供两个函数:一个是分组的 key,这里使用 s -> s.substring(0, 1),表示只要首字母相同的 String 分到一组,第二个是分组的 value,这里直接使用 Collectors.toList(),表示输出为 List。

import java.util.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        List<String> list = List.of("Apple", "Banana", "Blackberry", "Coconut", "Avocado", "Cherry", "Apricots");
        Map<String, List<String>> groups = list.stream()
                .collect(Collectors.groupingBy(s -> s.substring(0, 1), Collectors.toList()));
        System.out.println(groups);
    }
}
其它常用方法

sorted() 对 Stream 的元素进行排序。此方法要求 Stream 的每个元素必须实现 Comparable 接口。如果要自定义排序,传入指定的 Comparator 即可。sorted() 是转换操作。

import java.util.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        List<String> list = List.of("Orange", "apple", "Banana")
            .stream()
            .sorted()
            .collect(Collectors.toList());
        System.out.println(list);
    }
}

distinct() 对 Stream 的元素进行去重。distinct() 是转换操作。

List.of("A", "B", "A", "C", "B", "D")
    .stream()
    .distinct()
    .collect(Collectors.toList()); // [A, B, C, D]

截取操作常用于把无限的 Stream 转换成有限的 Stream,skip() 用于跳过前 N 个元素,limit() 用于截取 N 个元素。截取操作是转换操作,将返回新的Stream。

List.of("A", "B", "C", "D", "E", "F")
    .stream()
    .skip(2) // 跳过 A, B
    .limit(3) // 截取 C, D, E
    .collect(Collectors.toList()); // [C, D, E]

将两个 Stream 合并为一个 Stream 可以使用 Stream 的静态方法 concat()

Stream<String> s1 = List.of("A", "B", "C").stream();
Stream<String> s2 = List.of("D", "E").stream();
Stream<String> s = Stream.concat(s1, s2);
System.out.println(s.collect(Collectors.toList())); // [A, B, C, D, E]

flatMap() 指把 Stream 的每个元素映射为 Stream,然后合并成一个新的Stream:

Stream<List<Integer>> s = Stream.of(
        Arrays.asList(1, 2, 3),
        Arrays.asList(4, 5, 6),
        Arrays.asList(7, 8, 9));
Stream<Integer> i = s.flatMap(list -> list.stream());

通常情况下,对 Stream 的元素进行处理是单线程的,即一个一个元素进行处理。但元素数量非常大,我们希望可以并行处理 Stream 的元素,并行处理可以大大加快处理速度。把一个Stream 转换为可以并行处理的 Stream 只需调用 parallel() 进行转换:

Stream<String> s = ...
String[] result = s.parallel() // 变成一个可以并行处理的Stream
                   .sorted() // 可以进行并行排序
                   .toArray(String[]::new);

除了 reduce()collect() 外,Stream 还有一些常用的聚合方法:

  • count():用于返回元素个数
  • max(Comparator<? super T> cp):找出最大元素
  • min(Comparator<? super T> cp):找出最小元素

针对 IntStream、LongStream 和 DoubleStream,还额外提供了以下聚合方法:

  • sum():对所有元素求和;
  • average():对所有元素求平均数。

还有一些,用来测试 Stream 的元素是否满足条件的方法:

  • boolean allMatch(Predicate<? super T>):测试是否所有元素均满足测试条件;
  • boolean anyMatch(Predicate<? super T>):测试是否至少有一个元素满足测试条件。

循环处理 Stream 的每个元素的方法:

Stream<String> s = ...
s.forEach(str -> {
    System.out.println("Hello, " + str);
});
posted @ 2022-11-19 09:36  carol2014  阅读(112)  评论(0)    收藏  举报