Bootiful Spring Boot 在 2024 年 (第一部分)

工程 | Josh Long | 2024 年 3 月 11 日 | ...

注:程式碼位於我的 Github 賬戶:github.com/joshlong/bootiful-spring-boot-2024-blog

嗨,各位 Spring 粉絲!我是Josh Long,在 Spring 團隊工作。很高興今年能在微軟的 JDConf 大會上發表主題演講和進行分享。我是 Kotlin GDE 和 Java Champion,並且我認為現在是成為 Java 和 Spring Boot 開發人員的最佳時機。我說這話時充分了解我們今天所處的時代。自 Spring Framework 最早釋出以來已經超過 21 年,自 Spring Boot 最早釋出以來也已超過 11 年。今年是 Spring Framework 誕生 20 週年和 Spring Boot 誕生 10 週年。所以,當我說現在是成為 Java 和 Spring 開發人員的最佳時機時,請記住我已經在其中度過了大半個十年。我熱愛 Java 和 JVM,熱愛 Spring,這一切都非常棒。

但這確實是最好的時代。從未如此接近。所以,像往常一樣,讓我們透過訪問我在網際網路上僅次於生產環境的第二喜歡的地方,start.spring.io,來開發一個新應用,你就會明白我的意思了。點選 Add Dependencies,然後選擇 Web, Spring Data JDBC, OpenAI, GraalVM Native Support, Docker Compose, Postgres, 和 Devtools

給它起一個 artifact 名稱。我把我的服務叫做……“service”。我起名字很厲害。這是遺傳我爸的。我爸起名字也厲害。我小時候,我們有一隻小小的白狗,我爸給它起名叫小白狗。它在我們家養了好幾年。但大概十年後,它不見了。我到現在也不知道它到底怎麼了。也許它找了份工作吧,我不知道。但後來奇蹟般地,另一隻小白狗出現在我們家紗門上敲門。所以我們收養了它,我爸給它起名叫。或者。我不知道。總之,起名字厲害得很。話說回來,我媽總告訴我,我很幸運是她給我起的名……呃,這可能確實是真的。

總之,選擇 Java 21。這部分是關鍵。如果你不使用 Java 21,你就不能使用 Java 21。所以,你需要 Java 21。但我們還將使用 GraalVM 的原生映象(native image)能力。

還沒安裝 Java 21?下載它!使用超棒的 SDKMAN 工具:sdk install java 21-graalce。然後把它設為預設版本:sdk default java 21-graalce。開啟新的 shell。下載 .zip 檔案。

Java 21 太棒了。它比 Java 8 好得多。它在技術上全面領先。它更快、更健壯、語法更豐富。它在道德上也更優越。當你孩子看到你在生產環境中使用 Java 8 時,你可不想看到他們眼中那種羞愧和恥辱的眼神。別這麼做。成為你想在世界上看到的變化。使用 Java 21。

你會得到一個 zip 檔案。解壓它並在你的 IDE 中開啟。

我使用的是 IntelliJ IDEA,它安裝了一個命令列工具叫做 idea

cd service
idea build.gradle 
# idea pom.xml if you're using Apache Maven

如果你使用 Visual Studio Code,請務必在 Visual Studio Code Marketplace 上安裝 Spring Boot Extension Pack

這個新應用將與資料庫互動;它是一個數據中心的應用。在 Spring Initializr 上,我們添加了對 PostgreSQL 的支援,但現在我們需要連線到它。我們最不希望看到的是一個長長的 README.md,裡面有個章節叫做《開發的一百個簡單步驟》。我們想要那種 `git clone` & Run 的生活!

為此,Spring Initializr 生成了一個 Docker Compose compose.yml 檔案,其中包含了對超讚的 SQL 資料庫 Postgres 的定義。

Docker Compose 檔案,compose.yaml

services:
  postgres:
    image: 'postgres:latest'
    environment:
      - 'POSTGRES_DB=mydatabase'
      - 'POSTGRES_PASSWORD=secret'
      - 'POSTGRES_USER=myuser'
    ports:
      - '5432'

更棒的是,Spring Boot 配置為在應用啟動時自動執行 Docker Compose (docker compose up) 配置。無需配置連線詳情,比如 spring.datasource.urlspring.datasource.password 等等。這一切都透過 Spring Boot 令人驚歎的自動配置完成。太讚了!而且,為了不留下爛攤子,Spring Boot 在應用關閉時也會關閉 Docker 容器。

我們希望儘可能快地推進。為此,我們在 Spring Initializr 上選擇了 DevTools。它能讓我們快速行動。這裡的核心理念是重啟 Java 相當慢。然而,重啟 Spring 真的很快。那麼,如果我們有一個程序監控我們的專案資料夾,它可以注意到新編譯的 .class 檔案,將它們載入到類載入器中,然後建立一個新的 Spring ApplicationContext,丟棄舊的,並給我們一種即時過載的錯覺,會怎麼樣?這正是 Spring DevTools 所做的事情。在開發期間執行它,你會看到你的重啟時間大幅縮短!

這之所以有效,是因為 Spring 啟動超快……除了,當你在每次重啟時都啟動一個 PostgreSQL 資料庫的時候。我喜歡 PostgreSQL,但呃,是的,它不是用來在你每次調整方法名、修改 HTTP 端點路徑或微調一些 CSS 時不斷重啟的。所以,讓我們配置 Spring Boot,讓它只啟動 Docker Compose 檔案一次,然後讓它一直執行,而不是每次都重啟。

將屬性新增到 application.properties

spring.docker.compose.lifecycle-management=start_only

我們將從一個簡單的 record 開始。

package com.example.service;

import org.springframework.data.annotation.Id;

// look mom, no Lombok!
record Customer(@Id Integer id, String name) {
}

我愛 Java records!你也應該愛!別忽視 records。這個不起眼的小 record 不僅僅是比 Lombok 的 @Data 註解做類似事情更好的方式,它實際上是 Java 21 中達到頂峰並結合在一起的一些特性的一部分,它們共同支援一種叫做 資料導向程式設計 的東西。

Java 語言架構師 Brian Goetz 在他 2022 年發表於 InfoQ 的關於資料導向程式設計的文章中談到了這一點。

Java 統治了單體應用的世界,理由是其強大的訪問控制、良好而快速的編譯器、隱私保護等等。Java 使建立相對模組化、可組合的單體應用變得容易。單體應用通常是龐大、散亂的程式碼庫,Java 支援它。確實,如果你想要模組化並想很好地組織你的大型單體程式碼庫,可以看看 Spring Modulith 專案。

但情況變了。如今,我們表達系統變化的方式不再是抽象型別深層層次結構的專門實現(透過動態分派和多型性),而是透過 HTTP/REST、gRPC、Apache Kafka 和 RabbitMQ 等訊息傳遞層傳送的那些通常是臨時的訊息。笨蛋,是資料!

Java 已經發展到支援這些新模式。讓我們看看四個關鍵特性——records、模式匹配、智慧 switch 表示式和 sealed types——來了解我的意思。假設我們在一個受到嚴格監管的行業工作,比如金融。

想象一下我們有一個叫做 Loan 的介面。顯然,貸款是受到嚴格監管的金融工具。我們不希望有人隨便新增一個 Loan 介面的匿名內部類實現,繞過我們辛辛苦苦構建到系統中的驗證和保護。

所以,我們將使用 sealed types。Sealed types 是一種新的訪問控制或可見性修飾符。

package com.example.service;

sealed interface Loan permits SecuredLoan, UnsecuredLoan {

}

record UnsecuredLoan(float interest) implements Loan {
}

final class SecuredLoan implements Loan {

}

在示例中,我們明確規定系統中有兩個 Loan 的實現:SecuredLoanUnsecuredLoan。類預設是開放給子類繼承的,這違反了 sealed 層次結構所隱含的保證。所以,我們明確地將 SecuredLoan 設為 finalUnsecuredLoan 實現為一個 record,它是隱式 final 的。

Records 是 Java 對元組的回應。它們就是元組。只不過 Java 是一種命名語言:事物有名字。這個元組也有名字:UnsecuredLoan。如果我們同意 records 所蘊含的契約,records 會給我們很多力量。records 的核心理念是,物件的身份等於 record 中欄位(它們被稱為“元件”)的身份。所以在這個例子中,record 的身份等於 interest 變數的身份。如果同意這一點,那麼編譯器可以給我們一個建構函式,它可以為每個元件提供儲存空間,它可以給我們一個 toString 方法、一個 hashCode 方法和一個 equals 方法。它還會給我們建構函式中的元件訪問器。不錯!而且,它支援解構!語言知道如何提取 record 的狀態。

現在,假設我想為每種 Loan 型別顯示一條訊息。我將編寫一個方法。這是最初的簡單實現。

 @Deprecated
    String badDisplayMessageFor(Loan loan) {
        var message = "";
        if (loan instanceof SecuredLoan) {
            message = "good job! ";
        }
        if (loan instanceof UnsecuredLoan) {
            var usl = (UnsecuredLoan) loan;
            message = "ouch! that " + usl.interest() + "% interest rate is going to hurt!";
        }
        return message;
    }

這行得通,某種程度上。但它並未物盡其用。

我們可以清理一下。讓我們利用模式匹配,像這樣

 @Deprecated
    String notGreatDisplayMessageFor(Loan loan) {
        var message = "";
        if (loan instanceof SecuredLoan) {
            message = "good job! ";
        }
        if (loan instanceof UnsecuredLoan usl) {
            message = "ouch! that " + usl.interest() + "% interest rate is going to hurt!";
        }
        return message;
    }

更好。注意,我們使用模式匹配來匹配物件的形狀,然後將確定可轉換的東西提取到一個變數 usl 中。雖然我們甚至不需要 usl 變數,對吧。相反,我們想解引用 interest 變數。所以我們可以改變模式匹配來提取那個變數,像這樣。

 @Deprecated
    String notGreatDisplayMessageFor(Loan loan) {
        var message = "";
        if (loan instanceof SecuredLoan) {
            message = "good job! ";
        }
        if (loan instanceof UnsecuredLoan(var interest) ) {
            message = "ouch! that " + interest + "% interest rate is going to hurt!";
        }
        return message;
    }

如果我註釋掉其中一個分支會發生什麼?什麼也不會!編譯器不在乎。我們沒有處理程式碼可能經過的關鍵路徑之一。

同樣,我將這個值儲存在一個變數 message 中,並將其作為某個條件的副作用進行賦值。如果我能去掉中間值並直接返回某個表示式,豈不是更好?讓我們來看看使用智慧 switch 表示式這種 Java 中另一個巧妙的新特性的更簡潔實現。

 String displayMessageFor(Loan loan) {
        return switch (loan) {
            case SecuredLoan sl -> "good job! ";
            case UnsecuredLoan(var interest) -> "ouch! that " + interest + "% interest rate is going to hurt!";
        };
    }

這個版本使用智慧 switch 表示式來返回值和模式匹配。如果你註釋掉其中一個分支,編譯器會報錯,因為——得益於 sealed types——它知道你還沒有窮盡所有可能的選項。太棒了!編譯器為我們做了很多工作!結果既更簡潔又更具表現力。大部分如此。

好了,回到我們正常的程式設計日程。為 Spring Data JDBC repository 新增一個介面,並新增一個 Spring MVC controller 類。然後啟動應用。注意,這會花費非常長的時間!那是因為在幕後,它正在使用 Docker daemon 啟動 PostgreSQL 例項。

但從現在起,我們將使用 Spring Boot 的 DevTools。你只需要重新編譯。如果應用正在執行,並且你使用的是 Eclipse 或 Visual Studio Code,你只需要儲存檔案:在 macOS 上按 CMD+S。IntelliJ IDEA 沒有 Save 選項;在 macOS 上按 CMD+Shift+F9 強制構建。不錯。

好了,我們有了一個照看著資料庫的 HTTP web 端點,但資料庫裡什麼也沒有,所以肯定會失敗。讓我們用一些 schema 和一些示例資料來初始化我們的資料庫。

新增 schema.sqldata.sql

我們應用的 DDL,schema.sql

create table if not exists customer  (
    id serial primary key ,
    name text not null
) ;

應用的一些示例資料,data.sql

delete from customer;
insert into customer(name) values ('Josh') ;
insert into customer(name) values ('Madhura');
insert into customer(name) values ('Jürgen') ;
insert into customer(name) values ('Olga');
insert into customer(name) values ('Stéphane') ;
insert into customer(name) values ('Dr. Syer');
insert into customer(name) values ('Dr. Pollack');
insert into customer(name) values ('Phil');
insert into customer(name) values ('Yuxin');
insert into customer(name) values ('Violetta');

確保透過將以下屬性新增到 application.properties 來告訴 Spring Boot 在啟動時執行 SQL 檔案

spring.sql.init.mode=always

重新載入應用:在 macOS 上按 CMD+Shift+F9。在我的電腦上,這次重新載入的時間大約是重啟 JVM 和應用本身所需時間的 1/3,或者說減少了 66%。巨大提升。

應用已經啟動並運行了。訪問 https://:8080/customers 檢視結果。成功了!當然,它成功了。這是一個演示。它註定會成功。

這都是相當標準的常規操作。十年前你也可以做類似的事情。請注意,程式碼會囉嗦得多。從那時起,Java 已經取得了飛躍性的進步。當然,速度也無法相比。而且,抽象也更好了。但你確實可以做類似的事情——一個照看著資料庫的 web 應用。

事情在變化。總有新的領域。現在,新的領域是人工智慧,或稱 AI。AI:顯然,尋找老一套的智慧還不夠難。

AI 是一個龐大的產業,但大多數人想到 AI 時,他們想到的是利用 AI。你不需要使用 Python 來使用大型語言模型(LLMs),就像大多數人不需要使用 C 來使用 SQL 資料庫一樣。你只需要與 LLMs 整合,而在這方面,Java 在選擇和能力上首屈一指。

在我們 2023 年的上一場重要開發者活動 SpringOne 上,我們宣佈了 Spring AI,這是一個旨在讓整合和使用 AI 儘可能簡單的全新專案。

你可能想攝取資料,比如來自賬戶、檔案、服務,甚至是 PDF 檔案集。你可能想將它們儲存在向量資料庫中以便於檢索,以支援相似性搜尋。然後你可能想與 LLM 整合,將來自該向量資料庫的資料提供給它。

當然,你可以找到任何你想要的 LLMs 的客戶端繫結——Amazon BedrockAzure OpenAIGoogle Vertex 以及 Google GeminiOllamaHuggingFace,當然還有 OpenAI 本身,但這僅僅是個開始

驅動 LLM 的所有知識都內建在模型中,該模型隨後影響 LLM 對世界的理解。但該模型有一定的“保質期”,過期後其知識就會過時。如果模型是兩週前構建的,它就不會知道昨天發生的事情。所以,如果你想構建一個自動助理,例如,處理使用者關於銀行賬戶的請求,那麼該 LLM 在處理時就需要獲知世界最新的狀態。

你可以在請求中新增資訊,並將其用作上下文來指導響應。如果事情就這麼簡單,那倒也沒什麼。但還有另一個問題。不同的 LLMs 支援不同的令牌視窗大小(token window sizes)。令牌視窗決定了你在給定請求中可以傳送和接收多少資料。視窗越小,你可以傳送的資訊就越少,LLM 在響應中的資訊量也就越少。

這裡你可能要做的一件事是將資料放入向量儲存(vector store)中,例如 pgvectorNeo4jWeaviate 等等,然後將你的 LLM 連線到該向量資料庫。向量儲存使你能夠在給定一個詞或一組詞的情況下,找到與它們相似的其他事物。它將資料儲存為數學表示,並允許你查詢相似的事物。

這個攝取、豐富、分析和消化資料以指導 LLM 響應的整個過程被稱為檢索增強生成(Retrieval Augmented Generation,RAG),Spring AI 支援所有這些。更多資訊,請參閱我關於 Spring AI 的這個 Spring Tips 影片。然而,我們在這裡不會利用所有這些功能。只利用其中一個。

我們在 Spring Initializr 上添加了 OpenAI 支援,所以 Spring AI 已經在 classpath 中。新增一個新的 controller,像這樣

一個由 AI 驅動的 Spring MVC controller

package com.example.service;

import org.springframework.ai.chat.ChatClient;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.util.Map;

@Controller
@ResponseBody
class StoryController {

    private final ChatClient singularity;

    StoryController(ChatClient singularity) {
        this.singularity = singularity;
    }

    @GetMapping("/story")
    Map<String, String> story() {
        var prompt = """
                Dear Singularity,

                Please write a story about the good folks of San Francisco, capital of all things Artificial Intelligence,
                and please do so in the style of famed children's author Dr. Seuss.

                Cordially,
                Josh Long
                """;
        var reply = this.singularity.call(prompt);
        return Map.of("message", reply);
    }

}

相當直接!注入 Spring AI 的 ChatClient,用它向 LLM 傳送請求,獲取響應,並將其作為 JSON 返回給 HTTP 客戶端。

你需要透過一個屬性來配置與 OpenAI API 的連線,例如 spring.ai.openai.api-key=。我在執行程式之前將其匯出為一個環境變數,SPRING_AI_OPENAI_API_KEY。我不會在這裡洩露我的金鑰。請原諒我不會洩露我的 API 憑據。

按 CMD+Shift+F9 重新載入應用,然後訪問端點:https://:8080/story。LLM 可能需要幾秒鐘來生成響應,所以準備好你的咖啡、水或別的什麼,以便快速而滿意地喝一口。

story time ai response
瀏覽器中的 JSON 響應,已啟用 JSON 格式化外掛

看!我們生活在一個奇蹟時代!該死的奇點時代!你現在什麼都能做。

但確實花了幾秒鐘,不是嗎?我不怪電腦花這點時間!它做得太棒了!我可快不了那麼多。看看它生成的故事吧!簡直是藝術品。

但確實花了一段時間。這對我們的應用的可伸縮性有影響。我們在幕後呼叫 LLM 時,正在進行網路呼叫。在程式碼深處某個地方,有一個 java.net.Socket,我們從中獲取了一個 java.io.InputStream,它表示來自服務的 byte 陣列資料。我不知道你是否還記得直接使用 InputStream。這裡有一個例子

    try (var is = new FileInputStream("afile.txt")) {
        var next = -1;
        while ((next = is.read()) != -1) {
            next = is.read();
            // do something with read
        }
    }

看到我們在呼叫 InputStream.readInputStream 中讀取位元組的那部分了嗎?我們稱之為阻塞操作(blocking operation)。如果我們在第四行呼叫 InputStream.read,那麼我們必須等待該呼叫返回後才能執行第五行。

如果我們要連線的服務返回了太多資料怎麼辦?如果服務宕機了怎麼辦?如果它永遠不返回怎麼辦?如果我們永遠被困在等待中怎麼辦?如果……?

如果只發生一次,這只是令人厭煩。但如果它可能發生在系統中用於處理 HTTP 請求的每個執行緒上,那將是對我們服務的生存威脅。這種情況經常發生。這就是為什麼可以登入到一個本應無響應的 HTTP 服務,卻發現 CPU 基本處於休眠狀態——空閒!——什麼都沒做或幾乎沒做什麼。執行緒池中的所有執行緒都卡在等待狀態,等待著永遠不會來的東西。

這是對我們花錢購買的寶貴 CPU 容量的巨大浪費。而且即使是最好的情況也不好。即使方法最終會返回,仍然意味著處理該請求的執行緒對系統中的其他任何東西都不可用。該方法獨佔了該執行緒,因此係統中的其他任何人都無法使用它。如果執行緒便宜且充裕,這不是問題。但它們並非如此。在 Java 的大部分生命週期中,每個新執行緒都與一個作業系統執行緒一一對應。這並不便宜。每個執行緒都有一定量的簿記開銷。一到兩兆位元組。所以你無法建立很多執行緒,而且你在浪費你僅有的那點執行緒。太可怕了!反正誰需要睡覺了?

一定有更好的辦法。

你可以使用非阻塞 IO。比如那些會讓人痔瘡發作般複雜的 Java NIO 庫。這是一種選擇,就像和一窩臭鼬一起住是一種選擇一樣:太臭了!反正我們大多數人思考問題時也不是非阻塞 IO 或常規 IO 的角度。我們生活在抽象層次更高的層級。我們可以使用反應式程式設計。我熱愛反應式程式設計。我甚至寫了一本關於它的書——Reactive Spring。但如果你不習慣像函式式程式設計師那樣思考,就不太清楚如何讓它工作。它是一個不同的正規化,意味著需要重寫你的程式碼。

如果我們既能得到非阻塞的好處,又能保留傳統的程式設計模式呢?使用 Java 21,現在可以了!有一個叫做虛擬執行緒(virtual threads)的新特性,讓這一切變得超級簡單!如果你在這些新的虛擬執行緒上做了阻塞的事情,執行時會檢測到你正在進行阻塞操作——比如 java.io.InputStream.readjava.io.OutputStream.writejava.lang.Thread.sleep——並將那個阻塞的、空閒的活動從執行緒上移開,放入 RAM 中。然後,它會為睡眠設定一個定時器,或者監控 IO 的檔案描述符,同時讓執行時將該執行緒用於其他任務。當阻塞操作完成後,執行時會將其移回執行緒,讓它從暫停的地方繼續執行,而你的程式碼幾乎無需修改。這有點難以理解,所以我們來看一個例子。我毫不臉紅地借用了 Oracle 開發者佈道師 José Paumard 的這個例子。

這個例子演示了建立 1,000 個執行緒並在每個執行緒上休眠 400 毫秒,同時記錄這 1,000 個執行緒中第一個執行緒的名稱。

package com.example.service;

import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;

@Configuration
class Threads {

    private static void run(boolean first, Set<String> names) {
        if (first)
            names.add(Thread.currentThread().toString());
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    @Bean
    ApplicationRunner demo() {
        return args -> {

            // store all 1,000 threads
            var threads = new ArrayList<Thread>();

            // dedupe elements with Set<T>
            var names = new ConcurrentSkipListSet<String>();

            // merci José Paumard d'Oracle
            for (var i = 0; i < 1000; i++) {
                var first = 0 == i;
                threads.add(Thread.ofPlatform().unstarted(() -> run(first, names)));
            }

            for (var t : threads)
                t.start();

            for (var t : threads)
                t.join();

            System.out.println(names);
        };
    }

}

我們使用 Thread.ofPlatform 工廠方法建立普通的平臺執行緒(platform threads),其性質與 Java 在 20 世紀 90 年代首次亮相以來我們建立的執行緒基本相同。程式建立了 1,000 個執行緒。在每個執行緒中,我們休眠 100 毫秒,重複四次。期間,我們測試是否是這 1000 個執行緒中的第一個,如果是,我們透過將其新增到集合(set)中來記錄當前執行緒的名稱。集合會去除重複元素;如果同一個名字出現多次,集合中仍然只有一個元素。

執行程式(CMD+Shift+F9!)你會看到程式的物理特性沒有改變。Set<String> 中只有一個名字。為什麼會這樣呢?我們只是反覆測試了同一個執行緒。

現在,將建構函式更改為使用虛擬執行緒Thread.ofVirtual。這是超級簡單的更改。現在執行程式。CMD+Shift+F9。

你會看到集合中不止一個元素。你完全沒有改變程式碼的核心邏輯。事實上,你只需要改變一個地方,但現在,在幕後,編譯器和執行時無縫地重寫了你的程式碼,以便當虛擬執行緒上發生阻塞時,執行時會無縫地將你移開,並在阻塞事情完成後將你移回執行緒。這意味著你之前所在的執行緒現在可供系統的其他部分使用。你的可伸縮性將 skyrocketing!

你可能會抗議說,我不想改變所有程式碼。首先,這是個荒謬的論點,改動微不足道。你可能會抗議:我也不想自己去建立執行緒。好觀點。Tony Hoare 在 20 世紀 70 年代寫道,NULL 是價值 10 億美元的錯誤。他錯了。實際上是 PHP。但是,他也詳細談論了使用執行緒構建系統是多麼不可行。你肯定想使用更高階的抽象,比如 sagas、actors,或者至少是一個 ExecutorService

還有一個新的虛擬執行緒執行器:Executors.newVirtualThreadPerTaskExecutor。不錯!如果你使用 Spring Boot,覆蓋該型別的預設 bean 並在系統中使用它非常簡單。Spring Boot 會將其引入並轉而使用它。很容易。但如果你使用 Spring Boot 3.2——你肯定在用 Spring Boot 3.2,對吧?你意識到每個版本大概只支援一年,對吧?務必檢視給定Spring 專案的支援策略。如果你正在使用 3.2,那麼你只需在 application.properties 中新增一個屬性,我們就會為你插入虛擬執行緒支援。

spring.threads.virtual.enabled=true

不錯!無需更改程式碼。現在你應該能看到顯著提升的可伸縮性,如果你的服務是 IO 密集型的,你可能可以減少負載均衡器中的一些例項。我的建議?告訴你老闆你要為公司節省一大筆錢,但堅持要把這筆錢加到你的工資裡。然後部署這個改動。瞧!

好了,我們進展很快。我們有 git clone 即執行的能力。我們有 Docker compose 支援。我們有 DevTools。我們有一種非常好的語言和語法。我們有了該死的奇點。我們進展很快。我們有了可伸縮性。在構建中新增 Spring Boot Actuator,現在你就有了可觀測性。我覺得是時候轉向生產環境了。

我想把這個應用打包,並使其儘可能高效。各位朋友,這裡有幾件事需要考慮。首先,我們如何容器化這個應用?簡單。使用 Buildpacks。很容易。記住,朋友不會讓朋友寫 Dockerfiles。使用 Buildpacks。Spring Boot 也原生支援它們:./gradlew bootBuildImage./mvnw spring-boot:build-image。但這也不是新東西了,所以下一個問題。

我們如何讓這東西儘可能高效和最佳化?朋友們,在深入討論之前,重要的是要記住 Java 已經非常非常非常高效了。我喜歡這篇來自 2018 年、COVID 大流行之前(也就是 BC)的部落格文章

它考察了哪些語言使用的能源最少,或者說能效最高。C 是能效最高的。它使用的電最少。1.0。這是基準。它對機器來說是高效的。對人可不是!絕對不是人。

然後是 Rust 和它的零成本抽象。做得好。

然後是 C++... gross

C++ 太噁心了!繼續……

然後是 Ada 語言,但是……誰在乎?

然後我們有 Java,接近 2.0。我們就四捨五入說 2.0 吧。Java 的效率是 C 的一半,或者說比 C 低兩倍!

目前為止還不錯吧?太棒了。不過它仍在能效最高的前 5 種語言之列!

如果你向下滾動列表,會看到一些驚人的數字。Go 和 C# 大約在 3.0+ 的範圍。再向下滾動,我們有 JavaScript 和 TypeScript,其中一種——令我百思不得其解的是——比另一種效率低四倍!

然後是 PHP 和 Hack,這兩個越少提及越好。繼續!

然後是 JRuby 和 Ruby。朋友們,記住 JRuby 是用 Java 寫的 Ruby。而 Ruby 是用 C 寫的 Ruby。然而,JRuby 的效率幾乎比 Ruby 高三分之一!僅僅因為它是用 Java 寫的,並且執行在 JVM 上。JVM 是一個了不起的工具。絕對非凡。

然後……我們有 Python。這可真是讓我很難過!我熱愛 Python!我從 20 世紀 90 年代就開始使用 Python 了!我第一次學 Python 時,比爾·克林頓還是總統!但這些數字可好。想想看。75.88。我們四捨五入到 76。我數學不太好。但你知道誰數學好嗎?該死的 Python!我們來問它。

python doing math

38!這意味著如果你用 Java 執行一個程式,生成所需能量會產生一些碳排放,這些碳會滯留在大氣中,升高溫度,而這個升高的溫度反過來會殺死一棵樹,那麼用 Python 執行等效的程式將殺死三十八棵樹!那是一片森林!這比比特幣還糟糕!我們必須儘快對此採取行動。我不知道具體該做什麼,但必須做點什麼。

總之,我想說的是,Java 已經非常棒了。我認為這是因為人們習以為常的兩件事:垃圾回收(garbage collection)和即時(Just In Time,JIT)編譯器。

垃圾回收(Garbage collection),嗯,我們都知道它是什麼。說實在的,甚至白宮也在其最近關於保障軟體以保障網路空間基石的報告中讚賞垃圾回收、記憶體安全的語言,比如 Java。

Java 程式語言的垃圾回收器讓我們編寫平庸的軟體,並且某種程度上可以“矇混過關”。太厲害了!話雖如此,我確實不同意它是最初的 Java 垃圾回收器這種說法!那個榮譽屬於別處,比如可能屬於Jesslyn

JIT 是另一個很棒的工具。它分析應用程式中經常訪問的程式碼路徑,並將它們轉換成特定於作業系統和架構的原生程式碼。但它只能對部分程式碼這樣做。它需要知道編譯程式碼時涉及的型別是執行時唯一會涉及的型別。而 Java 中一些東西——一種執行時更類似於 JavaScript、Ruby 和 Python 的高度動態語言——允許 Java 程式做一些會違反這個限制的事情。比如序列化(serialization)、JNI、反射(reflection)、資源載入(resource loading)和 JDK 代理(JDK proxies)。記住,使用 Java,你可以有一個 String,其內容是 Java 原始碼檔案,將這個字串編譯成檔案系統上的一個 .class 檔案,將 .class 檔案載入到 ClassLoader 中,透過反射建立該類的一個例項,然後——如果該類是一個介面——建立一個該類的 JDK 代理。如果該類實現了 java.io.Serializable,則可以透過網路 socket 寫入該類例項,並在另一個 JVM 上載入它。所有這些都可以在不顯式使用 java.lang.Object 之外的任何型別引用的情況下完成!這是一種了不起的語言,這種動態特性使其成為一種非常有生產力的語言。它也阻礙了 JIT 的最佳化嘗試。

儘管如此,JIT 在力所能及的地方做得非常出色。結果不言自明。所以,人們會想:為什麼我們不能預先(ahead of time)主動 JIT 整個程式呢?我們可以的。有一個叫做 GraalVM 的 OpenJDK 分發版,它包含許多好東西,用額外的工具(比如 native-image 編譯器)擴充套件了 OpenJDK 釋出版。原生映象編譯器很棒。但這個原生映象編譯器有同樣的限制。它對非常動態的東西無能為力。這就是一個問題。因為大多數程式碼——你的單元測試庫、你的 web 框架、你的 ORM、你的日誌庫……一切!——都使用了這些動態行為中的一個或全部。

有一個應急方案(escape hatch)。你可以在一個知名目錄下以 .json 檔案的形式向 GraalVM 編譯器提供配置:src/main/resources/META-INF/native-image/$groupId/$artifactId/\*.json。這些 .json 檔案有兩個問題。

首先,“JSON”這個詞聽起來很蠢。我不喜歡說“JAY-SAWN”。作為一個成年人,我不敢相信我們竟然會對彼此說這樣的話。我會法語,在法語裡,你會把它發音成 jeeesã。所以,.gison。好聽多了。閩南語裡有個詞——gingsong(幸福),這個發音也行。所以你可以用 .gingsong。站隊吧!不管怎樣,.json 都不應該再用了。我站隊 .gison,但這其實不重要。

第二個問題是,咳,它要求的東西實在太多了!再說一次,想想你的程式在多少地方做了這些有趣、動態的事情,比如反射、序列化、JDK 代理、資源載入和 JNI!簡直沒完沒了。你的 Web 框架。你的測試庫。你的資料訪問技術。我沒時間為每個程式手工編寫配置。我連寫完這篇部落格的時間都不夠!

所以,我們將轉而使用 Spring Boot 3.0 中引入的預編譯(AOT)引擎。AOT 引擎會分析 Spring 應用中的 Bean,併為你發出必需的配置檔案。太好了!甚至還有一個完整的元件模型,你可以用它來擴充套件 Spring 到編譯時。我在這裡不會深入講解所有這些,但你可以閱讀我的免費電子書或觀看我的免費 YouTube 影片,其中介紹了所有與Spring 和 AOT相關的內容。內容基本相同,只是消費方式不同。

所以讓我們用 Gradle 啟動構建,./gradlew nativeCompile,或者如果你使用 Apache Maven,則使用 ./mvnw -Pnative native:compile。你可能想跳過這次的測試… 這個構建會花一點時間。記住,它正在分析程式碼庫中的所有東西——無論是在 classpath 上的庫、JRE,還是你自己程式碼中的類——來確定哪些型別應該保留,哪些應該丟棄。結果是一個精簡、高效、速度極快的執行時機器,但代價是編譯時間非常、非常慢。

事實上,它花的時間實在太長了,以至於它有點阻礙了我的流暢性。它讓我徹底停下了腳步,等著。我就像這篇部落格前面提到的平臺執行緒一樣:阻塞了!我感到無聊。等著。等著。我現在終於理解了這個著名的 XKCD 漫畫

有時我開始哼歌。或者主題曲。或者電梯音樂。你知道電梯音樂聽起來是什麼樣的,對吧?永不停歇,沒完沒了。所以我想,如果大家都能聽到電梯音樂,那不是很好嗎?於是我問了。得到了一些很棒的回覆。

有人建議,我們的朋友建議我們播放來自任天堂 64 影片遊戲的配樂中的電梯音樂,這款遊戲是皮爾斯·布魯斯南首次出演詹姆斯·邦德的《黃金眼》(Goldeneye)。我喜歡這個主意。

adinn elevator music
黃金眼》有一些很棒的電梯音樂!

另一個回覆建議說,有嗶嗶聲會很有用。再同意不過了。我那愚蠢的微波爐完成時會發出 ding! 的一聲。我的百萬行編譯器為什麼不能呢?

ivan beeps
叮!

然後我們收到了這個回覆,來自我另一位最喜歡的博士,Niephaus 博士,他在 GraalVM 團隊工作。他說,新增電梯音樂只會解決症狀,而不是問題的根源,根源在於如何讓 GraalVM 在時間和記憶體方面更加高效。

doctor
niephaus

好的。但他確實分享了這個有希望的原型!

graalvm
prototype

我相信它隨時都會合並進去...

總之!如果你檢視編譯情況,現在應該已經完成了。它在 ./build/native/nativeCompile/ 資料夾裡,名字叫 service。在我的機器上,編譯花了 52 秒。天哪!

執行它。它會失敗,因為,再說一次,我們過的是 git clone 然後執行的生活方式!我們沒有指定任何連線憑據!所以,透過環境變數指定你的 SQL 資料庫連線詳情來執行它。這是我在我的機器上使用的指令碼。這隻適用於 Unix 類的作業系統,Maven 和 Gradle 都適用。

#!/usr/bin/env bash

export SPRING_DATASOURCE_URL=jdbc:postgresql:///mydatabase
export SPRING_DATASOURCE_PASSWORD=secret
export SPRING_DATASOURCE_USERNAME=myuser

SERVICE=.
MVN=$SERVICE/target/service
GRADLE=$SERVICE/build/native/nativeCompile/service
ls -la $GRADLE && $GRADLE || $MVN

在我的機器上,它在大約 100 毫秒內啟動!像火箭一樣快!而且,顯然,如果我使用 Spring Cloud Function 來構建 AWS Lamba 風格的函式即服務,它會更快得多,因為我例如就不需要打包一個 HTTP 伺服器。事實上,如果我真正想要的只是純粹的啟動速度,那我甚至可能會使用 Spring 對Project CRaC 的出色支援。這也不是這裡要說的重點。我不太關心這個,因為它是一個獨立的、長期執行的服務。我關心的是資源使用情況,以駐留集大小(RSS)表示。記下程序識別符號(PID)——它會在日誌中。如果 PID 是,比如說,55,那麼你可以像這樣使用幾乎所有 Unix 系統上都有的 ps 工具來獲取 RSS

ps -o rss 55

它會輸出一個以千位元組為單位的數字;除以一千,你就會得到以兆位元組為單位的數字。在我的機器上,它執行只需要略多於 100MB 的記憶體。你不能在這麼小的記憶體裡執行 Slack!我敢打賭你的 Chrome 瀏覽器裡隨便一個標籤頁佔用的記憶體都比這個多,甚至更多!

所以,我們有了一個程式,它既簡潔,又易於開發和迭代。它使用虛擬執行緒為我們提供了無與倫比的可伸縮性。它作為一個獨立的、自包含的、特定於作業系統和架構的本機映像執行。哦!而且,它竟然支援奇點(singularity)!

我們生活在一個令人驚歎的時代。現在是成為 Java 和 Spring 開發人員的最佳時機。我希望我也說服了你。

資源

我,以及 Spring 團隊的其他成員,將在微軟的 JDConf 2024 大會上發表演講!

註冊並參加我在 JDConf 的會議:Bootiful Spring Boot 3

在 JDConf 上可以參加的其他 Spring 會議:

如果你喜歡這篇部落格,我希望你會訂閱我們的YouTube 頻道,我在其中每週都有新影片在Spring Tips 播放列表裡。當然,你可以在Biodrop上找到我的 Twitter/X、網站、YouTube 頻道、書籍、播客等等。謝謝!

獲取 Spring 新聞通訊

訂閱 Spring 新聞通訊,保持聯絡

訂閱

先行一步

VMware 提供培訓和認證,為你的進步加速。

瞭解更多

獲取支援

Tanzu Spring 透過一個簡單的訂閱,提供 OpenJDK™、Spring 和 Apache Tomcat® 的支援和二進位制檔案。

瞭解更多

即將舉辦的活動

檢視 Spring 社群所有即將舉辦的活動。

檢視全部