От скрытых ловушек со строками до эндпоинтов, корректно обрабатывающих эмодзи — разберёмся, как корректно работать с текстом в современных 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"
скорее всего получите порядок:
Aaron
Ångström
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"
для шведской локали вы получите правильный, ожидаемый порядок:
Aaron
Zebra
Å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: как писать стабильный код. Записаться