От скрытых ловушек со строками до эндпоинтов, корректно обрабатывающих эмодзи — разберёмся, как корректно работать с текстом в современных Java-приложениях.

Большинство разработчиков на Java печатали String name = "Hello"; столько раз, что и не сосчитать. Это работает. Никаких сюрпризов. Но иллюзия простоты рушится в момент, когда в системе появляется «こんにちは», «浩宇» или «😉». Вдруг эта простая строка раскрывает целую вселенную сложностей. Появляются баги. Данные портятся. Пользователи жалуются, что их имена сохраняются неправильно.

В этом руководстве мы разберем непонятности вокруг Unicode и покажем, как строить надёжные, интернациональные Java-приложения. Разберём теорию, укажем на подводные камни, а затем соберём «Глобальный сервис приветствий» на Quarkus, который переживёт весь хаос реального текста.

К концу статьи вы разберётесь с тем,

  • как устроен Unicode и как Java на самом деле хранит текст

  • почему длина строки и перебор символов сложнее, чем кажется

  • как нормализация предотвращает неприятные несоответствия

  • как настроить REST-сервис и базу данных для безопасной работы с Unicode

Поехали.


Основы Unicode: фундамент современного текста

Unicode часто понимают неправильно. Это не кодировка вроде UTF-8 или UTF-16. Это стандарт, своего рода «гигантский словарь», который назначает каждому символу и эмодзи уникальный код (кодовую точку).

  • Буква «A» → U+0041

  • Эмодзи «подмигивающее лицо» 😉 → U+1F609

Строки в Java представлены последовательностями UTF-16-кодовых единиц (с Java 9 действует Compact Strings: LATIN-1 или UTF-16 в зависимости от содержимого), из-за чего возникают тонкие ловушки.

Кодовые точки, кодовые единицы и графемные кластеры

Подумайте о трёх уровнях:

  • Кодовая точка (code point): абстрактный номер из Unicode (U+1F48B = 💋).

  • Кодовая единица (code unit): представлен��е кодовой точки в виде элементов кодировки; в UTF-16 это 16-битные единицы — иногда одна, иногда две.

  • Графемный кластер (grapheme cluster): то, что люди воспринимают как «отдельный символ». Это может быть одна кодовая точка или несколько в комбинации (например, «e» + комбинируемый диакритический знак).

Пример: «a🚀c»

  • Графемные кластеры: 3 (a, 🚀, c)

  • Кодовые точки: 3 (U+0061, U+1F680, U+0063)

  • Кодовые единицы UTF-16: 4 (🚀 представляется суррогатной парой — двумя единицами)

Именно поэтому метод length() в Java нередко «врёт».

Нормализация имеет значение

Один и тот же визуальный символ может иметь несколько представлений:

  • «é» = U+00E9 (предкомбинированный вариант, NFC)

  • «e» + «´» = U+0065 + U+0301 (комбинируемый острый акцент) — разложенный вариант, NFD

Без нормализации два визуально одинаковых «café» могут не считаться равными при сравнении. Нормализация (обычно форма NFC) гарантирует единообразное хранение и сравнение текста.

Создание «Глобального сервиса приветствий» на Quarkus

Перейдём от теории к практике. Мы создадим простой REST API, который будет сохранять и возвращать приветствия.

Настройка проекта

Понадобятся Java (17+), Maven, Podman и терминал.

Создаём проект Quarkus:

mvn io.quarkus.platform:quarkus-maven-plugin:create \
    -DprojectGroupId=org.acme \
    -DprojectArtifactId=unicode-greetings \
    -DclassName="org.acme.GreetingResource" \
    -Dpath="/greetings" \
    -Dextensions="rest-jackson,quarkus-hibernate-orm-panache,quarkus-jdbc-postgresql"

cd unicode-greetings

Удаляем папку src/main/test.

Создаём сущность Greeting:

Переименуйте MyEntity.java в src/main/java/org/acme/Greeting.java и замените содержимое на следующее:

package org.acme;

import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;

@Entity
public class Greeting extends PanacheEntity {
    public String name;
    public String message;
}

Обновляем GreetingResource:

Замените содержимое файла src/main/java/org/acme/GreetingResource.java:

package org.acme;

import java.util.List;

import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

@Path("/greetings")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class GreetingResource {

    @GET
    public List<Greeting> getAll() {
        return Greeting.listAll();
    }

    @POST
    @Transactional
    public Response add(Greeting greeting) {
        greeting.persist();
        return Response.status(Response.Status.CREATED).entity(greeting).build();
    }
}

Настраиваем базу данных

Обновите src/main/resources/application.properties, чтобы подключиться к локальной базе PostgreSQL.

# Настройки базы данных
quarkus.datasource.db-kind=postgresql

# Важно для Unicode! Убедитесь, что клиентское соединение использует UTF-8.
quarkus.datasource.jdbc.additional-jdbc-properties.charSet=UTF-8

# Для разработки сбрасываем и пересоздаём схему при запуске
quarkus.hibernate-orm.database.generation=drop-and-create

Запуск приложения:

./mvnw quarkus:dev

Quarkus автоматически запустит контейнер PostgreSQL.

Базовый REST-сервис готов. Посмотрим, как он ведёт себя под натиском Unicode.

Java и её подводные камни

Наш простой сервис отлично работает с ASCII. Теперь добавим имя с эмодзи и посмотрим, что произойдёт.

Метод String.length() вас обманывает

Добавим в наш ресурс валидационную проверку длины, чтобы не разрешать слишком длинные имена.

Измените метод add в GreetingResource.java:

// In GreetingResource.java
@POST
@Transactional
public Response add(Greeting greeting) {
    // Казалось бы, безобидная проверка валидации
    if (greeting.name != null && greeting.name.length() > 6) {
        return Response.status(Response.Status.BAD_REQUEST)
                .entity("{\"error\":\"Имя не может быть длиннее 6 символов\"}")
                .build();
    }
    greeting.persist();
    return Response.status(Response.Status.CREATED).entity(greeting).build();
}

Теперь попробуйте отправить запрос через curl:

curl -X POST http://localhost:8080/greetings \
-H "Content-Type: application/json" \
-d '{ "name": "Team 🚀", "message": "To the moon!" }'

Результат: вы получите 400 Bad Request!

{"error":"Имя не может быть длиннее 6 символов"}

Но ведь «Team 🚀» выглядит как шесть символов. Почему так?

Как мы уже знаем, эмодзи 🚀 кодируется (представляется) в UTF-16 суррогатной парой.

Поэтому greeting.name.length() возвращает 7 (T-e-a-m-пробел-🚀[часть1]-🚀[часть2]), что больше шести. Упс.

Решение: используйте codePointCount() для подсчёта фактического числа символов (кодовых точек).

// В GreetingResource.java
// ...
if (greeting.name != null && greeting.name.codePointCount(0, greeting.name.length()) > 6) {
// ...

Обновите код и повторите запрос curl — теперь всё сработает: приветствие успешно создано.

Корректная итерация

Ещё одна распространённая ошибка — итерация по массиву символов char[] строки.

Представим, что мы хотим создать «слаг» из имени, отфильтровав допустимые символы.

// Так делать нельзя! Это пример того, как делать НЕ нужно.
public static String createSlug(String input) {
    StringBuilder slug = new StringBuilder();
    for (char c : input.toCharArray()) {
        if (Character.isLetterOrDigit(c)) {
            slug.append(Character.toLowerCase(c));
        }
    }
    return slug.toString();
}

Пример вызова: System.out.println(createSlug("User-👍-Name"));

Ожидаемый результат: "username"

Фактический результат: "username" — кажется, всё ок, но эмодзи просто пропущен. Опасность проявляется в других сценариях работы с char, где легко получить «половинку» эмодзи и испортить данные.

Когда цикл встречает эмодзи 👍, он обрабатывает каждую половину суррогатной пары отдельно.

Character.isLetterOrDigit() возвращает false для обеих частей, поэтому они просто пропускаются. На первый взгляд это безвредно, но в других сценариях можно получить «половинку» эмодзи — испорченные данные.

Решение: используйте codePoints() (поток кодовых точек). Он корректно отдаёт каждый символ как одну кодовую точку; добавляйте в буфер через appendCodePoint(...).

public static String createSlugProperly(String input) {
    StringBuilder slug = new StringBuilder();
    input.codePoints().forEach(codePoint -> {
        if (Character.isLetterOrDigit(codePoint)) {
            slug.append(Character.toLowerCase(Character.toChars(codePoint)));
        }
    });
    return slug.toString();
}

Эта версия безопасна для Unicode: она корректно работает с любыми символами — из любого языка и с любыми эмодзи.

Болевые точки веб-разработки

Теперь разберём проблемы, которые возникают, когда наш сервис начинает взаимодействовать с другими системами — например, с базой данных или клиентами.

Проблема поиска и нормализации

Добавим новое приветствие:

curl -X POST http://localhost:8080/greetings \
-H "Content-Type: application/json" \
-d '{ "name": "José", "message": "Hola!" }'

Имя будет сохранено с предкомбинированным символом é (U+00E9, форма NFC).

Теперь представим пользователя, у которого клавиатура вводит букву e, а затем комбинируемый острый акцент (U+0301) — он ищет «José».

Побайтово это уже другая строка.

Добавим эндпоинт поиска, чтобы увидеть, как это ломается.

В GreetingResource.java:

    @GET
    @Path("/search")
    public Response search(@QueryParam("name") String name) {
        if (name == null) {
            return Response.ok(List.of()).build();
        }
        // Наивное прямое сравнение, которое не сработает
        List<Greeting> results = Greeting.list("name", name);
        return Response.ok(results).build();
    }

Теперь попробуйте выполнить поиск по «Jose»:

curl "http://localhost:8080/greetings/search?name=Jose"

Очевидно, вернётся пустой результат. Но даже если бы мы смогли ввести версию с комбинированным акцентом — она тоже не сработала бы.

Решение: нормализовать все строки в единую форму. Используем форму NFC.

Измените метод add: нормализуйте имя перед сохранением.

// Импорт добавить в верх файла: import java.text.Normalizer;
// Внутри метода:
if (greeting.name != null) {
    greeting.name = Normalizer.normalize(greeting.name, Normalizer.Form.NFC);
}

// ... затем выполняем проверку и сохраняем

Измените метод search: нормализуйте поисковый запрос перед сравнением.

// В методе search() класса GreetingResource.java
@GET
@Path("/search")
public Response search(@QueryParam("name") String name) {
    if (name == null) {
        return Response.ok(List.of()).build();
    }
    String normalizedName = Normalizer.normalize(name, Normalizer.Form.NFC);
    // Наивное прямое сравнение, которое не сработает
    List<Greeting> results = Greeting.list("name", normalizedName);
    return Response.ok(results).build();
}

Теперь, независимо от того, как было введено «José», оно будет преобразовано в одну и ту же каноническую форму.

А поиск? Всё ещё не работает? Почему? Добро пожаловать в мир кодировок символов.

Проблема URL-кодирования

  • Сохранено в БД: José (байты: [74, 111, 115, -61, -87])

  • Получено из запроса: José (байты: [74, 111, 115, -61, -125, -62, -87])

Символ é кодируется дважды при отправке через curl.
É (U+00E9) превращается в %C3%A9, но затем эти байты интерпретируются как é из-за неверной обработки.

URL-кодирование (также называемое percent-encoding) преобразует специальные символы, небезопасные для URL, в формат, который можно безопасно передавать.

Например, буква «é» становится %C3%A9, потому что в UTF-8 она представлена двумя байтами (C3 A9 в шестнадцатеричном виде), и каждый байт предваряется символом %.

Это гарантирует, что такие символы, как пробелы, диакритические знаки и знаки препинания, не нарушат парсинг URL и не вызовут ошибок при передаче через разные системы, обрабатывающие кодировки по-разному.

Вы можете поступить двумя способами: либо исправить команду curl

curl "http://localhost:8080/greetings/search?name=Jos%C3%A9"

либо обновить обработку поиска, сделав её более гибкой:

@GET
@Path("/search")
public Response search(@QueryParam("name") String name) {
    if (name == null) {
        return Response.ok(List.of()).build();
    }

    // Обрабатываем проблемы URL-декодирования, нормализуя и запрос, и сохранённые значения
    String normalizedName = Normalizer.normalize(name, Normalizer.Form.NFC);

    // Используем более гибкий поиск, который у��итывает различия в кодировке
    List<Greeting> results = Greeting.find("LOWER(name) = LOWER(?1)", normalizedName).list();

    // Если ничего не найдено, пробуем более мягкий поиск
    if (results.isEmpty()) {
        results = Greeting.find("name LIKE ?1", "%" + normalizedName + "%").list();
    }

    return Response.ok(results).build();
}

Примечание: по-настоящему удобный для пользователя поиск должен быть регистронезависимым и, возможно, игнорировать диакритику (чтобы, например, «Jose» находил «José»). Это обычно достигается при помощи LIKE-запросов к базе данных и отдельной библиотеки для удаления акцентов. Но нормализация — обязательный первый шаг. Здесь мы используем очень широкое, «прощающее» сравнение как запасной вариант.

Сортировка с Collator

Добавим метод, который будет возвращать список приветствий в отсортированном виде.

В GreetingResource.java:

@GET
@Path("/sorted")
public List<Greeting> getSortedByName() {
    return Greeting.list("order by name");
}

Теперь добавим в сервис три имени: "Zebra", "Ångström", "Aaron".

curl -X POST http://localhost:8080/greetings \
-H "Content-Type: application/json" \
-d '{ "name": "Zebra"}'

curl -X POST http://localhost:8080/greetings \
-H "Content-Type: application/json" \
-d '{ "name": "Ånstöm"}'

curl -X POST http://localhost:8080/greetings \
-H "Content-Type: application/json" \
-d '{ "name": "Aaron"}'

Когда вы вызовете:

curl "http://localhost:8080/greetings/sorted"

скорее всего получите порядок:

  1. Aaron

  2. Ångström

  3. Zebra

Это лексикографическая сортировка по кодовым значениям, а не с учётом языка. Однако в шведском языке буква Å — 27-я в алфавите, поэтому в шведском алфавите слово «Ångström» должно стоять после «Zebra».

Решение: используем сортировку с учётом локали

Для языковой сортировки используем java.text.Collator. Поскольку база сортирует по умолчанию, без учёта локали, сортировку выполняем в коде приложения.

// В GreetingResource.java
import java.text.Collator;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;

@GET
@Path("/sorted")
public List<Greeting> getSortedByName(@QueryParam("locale") @DefaultValue("en-US") String localeTag) {
    List<Greeting> greetings = Greeting.listAll();
    Locale locale = Locale.forLanguageTag(localeTag);
    Collator collator = Collator.getInstance(locale);
    collator.setStrength(Collator.PRIMARY); // Делает сортировку регистронезависимой

    greetings.sort(Comparator.comparing(g -> g.name, Comparator.nullsLast(collator)));
    return greetings;
}

Теперь, если вызвать:

curl "http://localhost:8080/greetings/sorted?locale=sv-SE"

для шведской локали вы получите правильный, ожидаемый порядок:

  1. Aaron

  2. Zebra

  3. Ångström

Заключительный эндпоинт: поцелуй 💋

Добавим напоследок забавный эндпоинт, который послужит практическим тестом нашей Unicode-настройки.

Этот эндпоинт будет добавлять эмодзи поцелуй к сообщению приветствия.

В GreetingResource.java:

@POST
@Path("/{id}/kiss")
@Transactional
public Response addKiss(@PathParam("id") Long id) {
    Greeting greeting = Greeting.findById(id);
    if (greeting == null) {
        return Response.status(Response.Status.NOT_FOUND).build();
    }

    // U+1F48B — кодовая точка эмодзи 💋
    String kissEmoji = new String(Character.toChars(0x1F48B));
    greeting.message = (greeting.message == null || greeting.message.isBlank())
            ? kissEmoji
            : greeting.message + " " + kissEmoji;

    return Response.ok(greeting).build();
}

Сначала создайте приветствие, которому можно «отправить поцелуй»:

curl -X POST http://localhost:8080/greetings \
-H "Content-Type: application/json; charset=utf-8" \
-d '{ "name": "浩宇", "message": "xoxo" }'

Важно явно указывать charset=utf-8 в заголовке — это хорошая практика.

В ответе появится созданное приветствие с id: 1 (или другим номером). Теперь отправим виртуальный поцелуй:

curl -s -X POST "http://localhost:8080/greetings/1/kiss" | jq -r .

Результат — идеальный JSON, где эмодзи отображается корректно:

{
  "id": 1,
  "name": "浩宇",
  "message": "xoxo 💋"
}

Это подтверждает, что весь стек — от клиента до базы данных через REST-слой Quarkus — корректно обрабатывает Unicode, включая многобайтные символы и эмодзи.

Заключение и чек-лист лучших практик

Поздравляем! Вы создали REST-сервис с поддержкой Unicode и разобрались с одними из самых частых и раздражающих ошибок, связанных с обработкой текста в Java.

Помните об этих правилах при работе с любым сервисом, где важна корректная работа с Unicode:

  • Всегда указывайте charset=utf-8 в заголовках Content-Type.

  • Настраивайте базы данных и соединения явно под UTF-8.

  • Используйте codePointCount() и codePoints() вместо length() и toCharArray().

  • Нормализуйте пользовательский ввод перед сохранением или сравнением.

  • Применяйте Collator для сортировки с учётом локали.

  • Всегда исходите из того, что Unicode используется повсюду. ASCII больше не является безопасным стандартом.

Текст глобален — таким же должен быть и код.


В реальных проектах чаще падает приложение в продакшене не из-за «сложных тем», а из-за базовых ошибок: непонимание синтаксиса, неаккуратная работа со строками, хаос в исключениях. Чтобы укрепить фундамент, приходите на открытые уроки, которые бесплатно проведут преподаватели курса «Java-разработчик»:

  • 20 октября, 20:00 — Основы синтаксиса Java: что важно знать при переходе с другого языка? Записаться

  • 10 ноября, 20:00 — Строки в Java: String, StringBuilder. Записаться

  • 18 ноября, 20:00 — Исключения в Java: как писать стабильный код. Записаться