Text blocks và String API mới (Java 15+)

1. Tổng quan

Trước Java 15, viết một đoạn JSON, SQL hay HTML nhiều dòng trong code Java khá khổ. Bạn phải nối chuỗi bằng +, thêm \n và escape từng dấu ". Text block (chính thức từ Java 15, JEP 378) giải quyết chuyện này. Đi kèm là vài method mới trong String.

Đọc xong bài này, bạn sẽ viết được text block đúng thụt lề và dùng được các escape \ và \s. Bạn cũng sẽ biết khi nào nên dùng formatted(), stripIndent(), translateEscapes() cùng một số API bổ sung tới Java 21.

2. Chuẩn bị

Mọi ví dụ chạy trên Java 21. Phần test dùng JUnit 5 và AssertJ:

<properties>
    <maven.compiler.release>21</maven.compiler.release>
</properties>

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Nếu bạn dùng Spring Boot 3.x thì spring-boot-starter-test đã có sẵn cả hai thư viện này.

3. Text block cơ bản

3.1. Cú pháp

Một text block mở bằng ba dấu nháy kép """ rồi xuống dòng ngay. Nó kết thúc bằng """:

public class TextBlockDemo {
    public static void main(String[] args) {
        String json = """
                {
                  "id": 42,
                  "customer": "Lan",
                  "status": "PAID"
                }
                """;
        System.out.print(json);
    }
}

Kết quả:

{
  "id": 42,
  "customer": "Lan",
  "status": "PAID"
}

Đoạn code này có mấy điểm đáng chú ý:

  • Dấu " bên trong không cần escape.
  • Phần thụt lề chung ở đầu mỗi dòng bị bỏ đi. Đây gọi là incidental whitespace. Phần thụt lề tương đối, như 2 dấu cách trước "id", vẫn được giữ.
  • Vì """ đóng nằm trên một dòng riêng, chuỗi kết thúc bằng \n.

Text block là một String bình thường. Nó được xử lý hoàn toàn lúc compile, nên không tốn thêm chi phí lúc chạy.

Lưu ý: Không được viết nội dung ngay sau """ mở. Dòng String s = """Hello"""; sẽ báo lỗi compile: illegal text block open delimiter sequence.

3.2. Vị trí """ đóng quyết định thụt lề

Compiler tìm dòng có ít khoảng trắng đầu dòng nhất, tính cả dòng chứa """ đóng. Sau đó nó cắt đúng số khoảng trắng đó khỏi mọi dòng. Vì vậy bạn có thể chủ động thêm thụt lề bằng cách kéo """ đóng sang trái:

String html = """
            <ul>
              <li>Order #1</li>
            </ul>
        """;
System.out.print(html);
    <ul>
      <li>Order #1</li>
    </ul>

""" đóng thụt ít hơn nội dung 4 cột, nên mỗi dòng giữ lại 4 dấu cách ở đầu.

Còn nếu bạn không muốn ký tự \n ở cuối chuỗi, hãy đặt """ ngay sau nội dung:

String greeting = """
        Xin chào Lan""";
System.out.println("[" + greeting + "]");
[Xin chào Lan]

3.3. Khoảng trắng cuối dòng và ký tự xuống dòng

Có hai quy tắc khác bạn cần biết:

  1. Khoảng trắng ở cuối mỗi dòng luôn bị xoá.
  2. Mọi kiểu xuống dòng (\r\n, \r) đều được chuẩn hoá thành \n. Điều này đúng cả khi file nguồn được lưu với line ending của Windows.

Quy tắc 2 giúp kết quả ổn định giữa các hệ điều hành. Nhưng nếu giao thức của bạn bắt buộc dùng \r\n, ví dụ header HTTP hay file CSV theo RFC 4180, bạn phải tự thay ký tự xuống dòng.

4. Hai escape sequence mới: \<xuống dòng> và \s

4.1. Nối dòng bằng \

Một câu SQL dài thường được viết thành nhiều dòng cho dễ đọc. Nhưng đôi khi bạn lại cần nó nằm trên một dòng, chẳng hạn khi ghi log. Dấu \ ở cuối dòng sẽ chặn ký tự xuống dòng:

String sql = """
        SELECT id, total \
        FROM orders \
        WHERE status = 'PAID'""";
System.out.println(sql);
SELECT id, total FROM orders WHERE status = 'PAID'

Dấu cách đứng trước \ được giữ lại. Lý do là nó không còn nằm ở cuối dòng: ký tự \ đứng sau nó.

4.2. Giữ khoảng trắng bằng \s

\s tương đương một dấu cách (U+0020). Bạn dùng nó để giữ khoảng trắng cuối dòng mà bình thường sẽ bị xoá, ví dụ khi tạo dữ liệu có độ rộng cố định:

String report = """
        Lan    \s
        Minh   \s
        """;
report.lines().forEach(line -> System.out.println("[" + line + "]"));
[Lan     ]
[Minh    ]

Mỗi dòng dài đúng 8 ký tự. Bốn dấu cách trước \s được giữ vì chúng không còn ở cuối dòng.

4.3. Khi trong nội dung có """

Hai dấu " liên tiếp thì không sao. Ba dấu liên tiếp sẽ bị hiểu là ký tự kết thúc text block, nên bạn cần escape ít nhất một dấu:

String doc = """
        Python docstring dùng \""" để mở và đóng.
        """;

5. String API mới đi kèm

Java 15 thêm ba method vào String để hỗ trợ text block. Đây cũng là các bước compiler thực hiện khi xử lý text block.

5.1. formatted()

formatted(Object... args) làm việc giống String.format(this, args), nhưng bạn gọi được ngay trên chuỗi. Cách viết này đặc biệt hợp với text block:

record Order(long id, String customer, double total) {}

Order order = new Order(42, "Lan", 199_000);

String message = """
        Đơn hàng #%d
        Khách hàng: %s
        Tổng tiền: %,.0f VND
        """.formatted(order.id(), order.customer(), order.total());

System.out.print(message);
Đơn hàng #42
Khách hàng: Lan
Tổng tiền: 199,000 VND

Dấu phân cách hàng nghìn phụ thuộc vào Locale mặc định của JVM. Nếu cần kết quả cố định, hãy dùng String.format(Locale.US, ...), vì formatted() không nhận tham số Locale.

5.2. stripIndent()

Method này áp dụng đúng thuật toán bỏ incidental whitespace của text block lên một chuỗi bất kỳ. Nó hữu ích khi chuỗi nhiều dòng đến từ file hay cơ sở dữ liệu thay vì nằm trong source code:

String raw = "    SELECT *\n      FROM customers\n    ";
System.out.print(raw.stripIndent());
SELECT *
  FROM customers

Dòng cuối chỉ chứa khoảng trắng, nên nó được tính vào việc xác định thụt lề rồi trở thành chuỗi rỗng. Đây chính là vai trò của """ đóng trong text block.

5.3. translateEscapes()

Method này chuyển các escape sequence đang ở dạng chữ thành ký tự thật. Bạn sẽ cần nó khi đọc template từ file cấu hình có chứa chuỗi \n hoặc \t:

String fromConfig = "Xin chào\\tLan\\nCảm ơn bạn đã đặt hàng";
System.out.println(fromConfig.translateEscapes());
Xin chào	Lan
Cảm ơn bạn đã đặt hàng

Escape sai cú pháp, ví dụ \q, sẽ làm method ném IllegalArgumentException. Đừng gọi nó trên dữ liệu người dùng nhập mà không bắt exception. Lưu ý thêm: method này không xử lý \u vì unicode escape đã được compiler dịch từ trước.

6. Các method hữu ích khác (Java 11 – 21)

Nhiều method nhỏ được thêm qua các phiên bản. Chúng thường dùng chung với text block:

String text = """
        Lan
           \s
        Minh
        """;

// Java 11
System.out.println("  ".isBlank());                      // true
System.out.println(text.lines().filter(l -> !l.isBlank()).count()); // 2
System.out.println("\u2003Lan\u2003".strip());           // "Lan" (strip hiểu Unicode, trim thì không)
System.out.println("-".repeat(10));                      // ----------

// Java 12
System.out.print("Lan\nMinh".indent(2));                 // thêm 2 cách mỗi dòng, kết thúc bằng \n
int length = "Lan".transform(String::length);            // 3

// Java 21
System.out.println("order-42-paid".indexOf('-', 6, 13)); // 8, chỉ tìm trong [6, 13)
System.out.println(Arrays.toString("a,b;c".splitWithDelimiters("[,;]", 0)));
// [a, , , b, ;, c]

Một vài chi tiết cần nói rõ:

  • indent(n) thêm n dấu cách vào đầu mỗi dòng nếu n > 0, hoặc bớt tối đa n khoảng trắng nếu n < 0. Nó luôn chuẩn hoá xuống dòng và thêm \n ở cuối.
  • transform(Function) cho phép bạn nối các bước biến đổi chuỗi một cách tự nhiên, ví dụ template.transform(this::render).transform(String::strip).
  • Từ Java 21, StringBuilder cũng có repeat(...): new StringBuilder().repeat("=", 5) cho ra =====.

7. Dùng text block trong Spring Boot

Trong ứng dụng Spring Boot 3.x, text block rất hợp với @Query của Spring Data JPA. Bạn giữ được câu query nhiều dòng mà không phải nối chuỗi:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
            SELECT o FROM Order o
            WHERE o.customer.email = :email
              AND o.status = 'PAID'
            ORDER BY o.createdAt DESC
            """)
    List<Order> findPaidOrders(@Param("email") String email);
}

Text block là hằng số compile-time, nên dùng được làm giá trị của annotation. Tham số vẫn được bind qua :email, an toàn trước SQL injection.

8. Viết test cho hành vi của text block

Các quy tắc về thụt lề và ký tự xuống dòng dễ gây nhầm lẫn. Vì vậy, bạn nên viết test để chốt lại hành vi mình mong đợi:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class TextBlockTest {

    @Test
    void givenClosingDelimiterOnOwnLine_thenEndsWithNewline() {
        String json = """
                {
                  "id": 42
                }
                """;

        assertThat(json).isEqualTo("{\n  \"id\": 42\n}\n");
    }

    @Test
    void givenBackslashAtLineEnd_thenLinesAreJoined() {
        String sql = """
                SELECT id \
                FROM orders""";

        assertThat(sql).isEqualTo("SELECT id FROM orders");
    }

    @Test
    void givenSpaceEscape_thenTrailingSpacesArePreserved() {
        String padded = """
                Lan  \s""";

        assertThat(padded).hasSize(6).endsWith("   ");
    }

    @Test
    void givenTemplate_whenFormatted_thenPlaceholdersAreReplaced() {
        String message = """
                Xin chào %s, đơn #%d đã thanh toán.""".formatted("Lan", 42);

        assertThat(message).isEqualTo("Xin chào Lan, đơn #42 đã thanh toán.");
    }
}

Cả bốn test đều pass. Test đầu tiên đáng giữ lại nhất: nó nhắc bạn rằng """ đóng nằm trên dòng riêng sẽ tạo ra \n ở cuối chuỗi.

9. Lỗi hay gặp và khi nào không nên dùng

Trộn tab và dấu cách. Khi tính incidental whitespace, compiler coi một tab là một ký tự khoảng trắng, giống một dấu cách. Nếu các dòng thụt lề bằng hai kiểu khác nhau, kết quả sẽ lệch dù trong IDE trông vẫn thẳng hàng. Hãy cấu hình editor chỉ dùng dấu cách.

Quên \n ở cuối. Khi so sánh chuỗi hoặc ghép text block vào một chuỗi khác, ký tự \n thừa rất hay gây lỗi kiểu “nhìn giống mà equals ra false”. Nếu không cần nó, hãy đặt """ ngay sau nội dung, hoặc gọi strip().

Dùng formatted() để ghép SQL với dữ liệu người dùng. Text block chỉ làm code dễ đọc hơn, không làm nó an toàn hơn.

// SAI: lỗ hổng SQL injection
String sql = """
        SELECT * FROM customers WHERE email = '%s'
        """.formatted(email);

Hãy dùng PreparedStatement, JdbcTemplate với tham số ?, hoặc @Query với named parameter.

Lưu ý: Với JSON, text block phù hợp cho dữ liệu test hoặc payload cố định. Nếu payload phụ thuộc dữ liệu động, hãy dùng Jackson để serialize object. Tự ghép chuỗi sẽ không escape ký tự đặc biệt như " hay \ trong tên khách hàng.

Chuỗi ngắn một dòng. "Lan" vẫn tốt hơn một text block ba dòng. Chỉ dùng text block khi nội dung thực sự có nhiều dòng hoặc chứa nhiều dấu ".

Template quá lớn. Một email HTML vài trăm dòng nằm trong class Java khiến code khó bảo trì. Khi đó, bạn nên chuyển nó sang file template, ví dụ dùng Thymeleaf hoặc tài nguyên trong src/main/resources.

10. Kết luận

Text block giúp bạn viết JSON, SQL, HTML trong Java gọn và dễ đọc như ở dạng gốc. Bạn chỉ cần nhớ vài quy tắc:

  • """ mở phải xuống dòng ngay.
  • Vị trí """ đóng quyết định thụt lề và việc chuỗi có \n ở cuối hay không.
  • Khoảng trắng cuối dòng bị xoá. Xuống dòng luôn được chuẩn hoá thành \n.
  • Dùng \ để nối dòng và \s để giữ khoảng trắng.

Về API, formatted() rất hợp để điền giá trị vào text block. stripIndent() và translateEscapes() cho phép bạn áp dụng cùng logic đó lên chuỗi đọc từ bên ngoài. Các method như lines(), strip(), indent(), transform() và splitWithDelimiters() của Java 21 giúp bạn xử lý chuỗi nhiều dòng mà không cần thư viện phụ. Cuối cùng, hãy viết test để chốt hành vi mong muốn, và đừng dùng text block để tự ghép SQL với dữ liệu người dùng.

javatext-blockstringjava-21