Пишу, по большей части, про историю, свою жизнь и немного про программирование.

PHP и UTF-8

Все уже на UTF-8, а PHP как-то не успел запрыгнуть на поезд. Я сейчас исследую тему перевода одного большого веб-сервиса на UTF-8, а он, так случилось, написан на PHP.

Увы, с UTF-8 в PHP всё очень тяжело. «Голый» PHP c UTF-8 работать, можно сказать, не умеет. Положение несколько скрашивает модуль Multibyte String, особенно тем фактом, что он умеет заменять некоторые функции на их Unicode-аналоги автоматически. Увы, не для всех строковых функций PHP в этом модуле есть аналоги, например, для strspn его не существует. Таких функций, увы, больше, чем хотелось быть.

Немного праздника в этот однобайтный склеп привносит тот замечательный факт, что UTF-8, в общем-то, придумали неглупые люди — по виду байта всегда можно отличить где мы — в начале последовательности символа или нет. Это позволяет таким функциям, как str_replace или strtr (когда вызывается с двумя параметрами) работать и с UTF-8 тоже.

Печальнее всего с двумя вещами.

Во-первых, PHP самой распоследней версии всегда считает, что строки содержат символы размером в один байт, на практике это значит, что $str[$index] вернёт на не $index-й символ, а $index-й байт последовательности символов, а это существенная разница. Найти все такие места в коде и не перепутать их с адресацией к массиву — задача непростая. Я придумал для этой цели использовать специальную функцию, которая, если на вход ей подаётся массив, работает с ним правильно, тихонько логгируя все такие вызовы.

Вторая проблема заковыристее. В PHP испокон веков аж три модуля для работы с регулярными выражениями — PCRE, Regular Expression (POSIX Extended) и уже знакомый нам Multibyte String. Первый работает с Perl-совместимыми регулярными выражениями, оставшиеся — с POSIX-синтаксисом, причём «Multibyte String» содержит UTF-8-аналоги всех функций из «Regular Expression (POSIX Extended)».

PCRE в таких помощниках не нуждается, потому что сам умеет работать с UTF-8, у него даже ключ специальный есть — «u» («Unicode»). Это в теории. На практике код

if (preg_match('/(\w)/iu', 'ПриветN', $m)) {
        var_dump($m);
    }

Выдаст нам… букву «N». Почему? Находить в строке UTF-8-символы куда сложнее, чем символы размером в один байт, поэтому для производительности разработчики решили, что… Впрочем, слово разработчикам:

Matching characters by Unicode property is not fast, because PCRE has to search a structure that contains data for over fifteen thousand characters. That is why the traditional escape sequences such as \d and  \w do not use Unicode properties in PCRE

То есть «\d» (цифры), «\w» (буквы) и, кстати, «\b» (граница слова) и «\s» (пробельные символы) работают только с однобайтовыми кодировками. Ужасно? Не то слово! Всё потеряно? К счастью, нет.

Вообще-то в PCRE есть специальные последовательности для различных классов Unicode-символов, например, «\p{L}» — это буквы, «\p{N}» — цифры и так далее. Можно заменить все не-Unicode последовательности этими аналогами. Но и это достаточно тяжело, особенно на тех объёмах, с которыми я имею дело. Простым поиском с заменой не обойтись — «[\w\s]» должен раскрываться уже иначе, не так же как «(\w|\s)».

Если внимательно читать документацию по PCRE, то найдётся много интересного, в том числе решение и этой проблемы. В одной из версий этой библиотеки появилась экспериментальная конструкция, называемая «глаголом» (VERB), «глаголы» умеют очень многое, но нас интересует специфический «глагол» «UCP», который (ура) занимается тем, что переключает «\w», «\s», «\d» и «\b» в полностью юникодный режим.

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

if (preg_match('/(*UCP)(\w)/iu', 'ПриветN', $m)) {
        var_dump($m);
    }

Победа? Ничего подобного. Этот глагол работает начиная с PCRE 8.10, в самой последней стабильной версии PHP (5.3.3) содержится PCRE 8.02, там этот глагол ещё не поддерживается, я проверял.

Нужная нам версия 8.10 войдёт в PHP 5.3.4. Но, во-первых, её ещё нужно дождаться (пока нет даже кандидата в релизы), во-вторых, 5.3 — это совсем другая ветка, ещё пока недостаточно стабильная, я как-то уже упоминал, что пара попыток перевести кое-какие проекты на PHP 5.3 закончились провалом, причём из-за багов этой версии PHP.

Но не стоит опускать руки, 4-5 лет назад я уже сталкивался с подобной ситуацией и помню, что при компиляции PHP есть возможность указать внешнюю PCRE-библиотеку (ключ «—with-pcre-regex=DIR»), что полностью решает проблему, я вчера проверил.

Подведём итоги. Для перевода (большого) проекта на UTF-8 необходимо найти все функции, которые обращаются к строкам, заменить их на Unicode-аналоги (если они есть в модуле Multibyte String, то оттуда, если их там нет — на аналоги, написанные на PHP), функции implode, explode, str_replace, strtok (возможно, какие-то ещё) в аналогах не нуждаются, операции $str[$index] заменить на функцию, которая умеет работать и с Unicode-строками и с массивами, вкомпилить в PHP PCRE версии 8.10 и в регулярных выражениях добавить ключ «u» и «глагол» «UCP».

Ну а так же не забыть про внешние источники данных — перевести базу на UTF-8, сконвертировать шаблоны, комментарии в коде и тому подобные места.

29 комментариев
zero_sharp 2010

по виду байта всегда можно отличить где мы — в начале последовательности символа, в середине или конце

середину от конца нельзя отличить, они все из промежутка 0x80-0xBF

Sam (rmcreative.ru) 2010

У 5.3 вроде покрытие тестами значительно лучше 5.2. Лично на баги 5.3 не натыкался, а вот в 5.2 бывало.

SiMM 2010

PHP самой распоследней версии всегда считает, что строки содержат символы размеров в один байт, на практике это значит, что $str[$index] вернёт на не $index-й символ, а $index-й байт последовательности символов, а это существенная разница

долго втыкал, что вместо «размеров» должно быть «размером», начал было уже думать, что речь о чём-то а-ля строк в паскале, предваряемых байтом с длиной строки :)

Евгений Степанищев (bolknote.ru) 2010

Комментарий для zero_sharp:

середину от конца нельзя отличить, они все из промежутка 0x80-0xBF

Неверное выразился, сейчас попробую переформулировать.

Евгений Степанищев (bolknote.ru) 2010

Комментарий для SiMM:

долго втыкал, что вместо «размеров» должно быть «размером», начал было уже думать, что речь о чём-то а-ля строк в паскале, предваряемых байтом с длиной строки :)

Спасибо, поправил :) Перед сном писал, могут быть опечатки неточности.

brainstorm.name 2010

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

Евгений Степанищев (bolknote.ru) 2010

Комментарий для brainstorm.name:

в общем то баянистый баян

Prooflink, пожалуйста.

brainstorm.name 2010

Я про то что такая проблема в общемт ти давно известна. в 5 друпале в автоматическом генерении тизеров используются не юникодные обрезания строк. насколько я это помню :)
я пользовал не API от CMF а свой код писал.
ну и другие заморочки были.
те кто «живет» на латинице — просто этих проблем не замечают. вот и все.

http://api.drupal.org/api/function/node_teaser/5
http://api.drupal.org/api/function/truncate_utf8/5  — обрезает до числа байт. не символов. :-)
я об это уперся. у меня то язык русский и надо было посимвольно кромсать :)

В 6ой версии кажись та же пестня.

brainstorm.name 2010

кстати при вводе форм при валидации там тоже самое. считается длина последовательноыти байтов а не уникода :) гы

Евгений Степанищев (bolknote.ru) 2010

Комментарий для brainstorm.name:

Я про то что такая проблема в общемт ти давно известна.

Конечно проблема известна, я далеко не первый с ней сталкиваюсь (и не в первый раз, конечно же).

Только никто её от начала до конца системно не исследовал. А я этим занимаюсь.

Евгений Степанищев (bolknote.ru) 2010

Комментарий для brainstorm.name:

http://api.drupal.org/api/function/truncate_utf8/5

Странные костыли. В PHP есть функция mb_substr

brainstorm.name 2010

ну так делалось с расчетом на 4ку. и да. буржуи багов не замечают. есть такая тема. ну а русские по привычке на уровне написания темы их решают. гы.(кому надо. большинству пофиг)

zg 2010

Комментарий для brainstorm.name:

но пиплу с языками на латинице проще жить

есть диакритика. которая не вся влезает в 1252.

Евгений Степанищев (bolknote.ru) 2010

Комментарий для brainstorm.name:

ну так делалось с расчетом на 4ку

модуль Multibyte String есть в PHP4.

brainstorm.name 2010

ну там кажись в 5ой версии друпала был упор на то что mbstring может быть не поставлен вообще :)
как то так. if(function_exists(....) ) почему было позабыто. увы.

Евгений Степанищев (bolknote.ru) 2010

Комментарий для brainstorm.name:

Потому что он на самом деле может быть не поставлен. Например, его нет в PHP 4.4.2 или 5.1.2, кроме того, его можно запретить использовать.

Правда, я не очень понимаю, почему бы в этом случае просто не сэмулировать их наличие.

Вячеслав Мацнев (vm.moikrug.ru) 2010

А зачем это вообще может понадобиться переводить проект на unicode?
Если раньше все работало с однобайтной кодировкой, что изменилось в последнее время кроме моды?

brainstorm.name 2010

затем что унификация, работа на куче языков сразу в одной СУБД и прочие вкусности. для этого уникод и был придуман. :-)

Евгений Степанищев (bolknote.ru) 2010

Комментарий для vm.moikrug.ru:

Если раньше все работало с однобайтной кодировкой, что изменилось в последнее время кроме моды?

Унификация, как правильно заметили комментарием ниже. У нас в интранете несколько крупных сервисов, все интегрированы друг в друга и только одному приходится устраивать переконвертацию.

Переконвертация отвратительна ещё и тем, что данные другие сервисы, вообще-то, отдают в UTF-8, а наш однобайтный друг ждёт их в CP1251, так вот — часть символов просто потеряется, им нет места в CP1251.

Ну и проще с UTF8, у меня сайт на UTF8, я, особо не изворачиваясь, могу хоть тайский источник процитировать, он будет сохранён в натуральном виде, а не в виде диких html entities, которые суть тот же UTF-8, только намного хуже.

Вячеслав Мацнев (vm.moikrug.ru) 2010

Комментарий для Евгения Степанищева:

Поддержка нескольких языков — это понятно.

А есть ли еще причины для перехода? Скажем мне не нужна поддержка разных языком и спецсимволов.

Интересно, есть ли менее очевидные преимущества, чем универсальность.

Евгений Степанищев (bolknote.ru) 2010

Комментарий для vm.moikrug.ru:

Не рассуждал на эту тему, я меня цель вполне конкретная.

Alex Karpinskiy 2010

Уже более месяца исследую тему перевода проекта на PHP с windows-1251 в UTF-8.
Прочел много литературы, с «глаголами» «UCP» в регулярках еще не знакомился.
Спасибо, разберусь что к чему.

Мой тернистый путь таков:

  1. перекодирование всех файлов в проекте в utf-8 с помощью iconv + удаление BOM-символов, если таковы имеются в теле файла.
  2. Указание заголовков PHP header(’Content-Type: text/html; charset=utf-8’);
  3. выставление локали setLocale(LC_ALL, ’ru_RU.UTF-8’);
  4. определение utf-8 кодировки по-умолчанию для nginx как проксирующего сервера на php fastcgi
  5. настройка сервера БД под utf-8 (сервер, клиент, sqldump)
  6. конвертация БД в utf-8, задание корректных charset’ов и collation для таблиц.
  7. в php.ini не переопределял параметр ХХХ для вызова multibyte-функций аналогов.
    Вместо этого написал отдельный файл с функциями с нижним подчеркиванием, например: _strlen, _substr... Список параметров и их порядковые позиции соответствуют функциям без подчеркивания. Реализация функций зависит от наличия Multibyte Extension для нее или нет, а также от текущей кодировки проекта. Далее по всему коду проекта производим грамотную замену строковых функций на их реализованные выше аналоги с нижним подчеркиванием.
    Таким образом мы можем регулировать поведение этих функций, а также производить их поиск и замену в программном коде, например, с помощью регулярных выражений IDE search, зная, что отличие от native название отличается нижним подчеркиванием.
    Есть смысл оформить этот список функций как статические методы класса, но тогда нужно править также те участки кода, которые используют callback вызов строковых функций. Тут поступайте как удобно Вам.

Примерный список функций, требующих адаптации:
strlen
strtolower
strtoupper
ucfirst
lcfirst
ucwords
substr
str_replace
str_ireplace
str_repeat
substr_replace
substr_count
strstr
strchr
strrchr
strrichr
stristr
strtr
strpos
stripos
strripos
strrpos
str_split
strrev
str_shuffle
parse_str
trim
ltrim
rtrim
wordwrap
htmlspecialchars
htmlspecialchars_decode
htmlentities
html_entity_decode
preg_grep
preg_split
preg_filter
preg_match
preg_match_all
preg_replace_callback
preg_replace
substr_compare

Отдельная статья с регулярными выражениями. Ниже по тексту есть ссылки на исследование и решение проблем.

Пока еще не ознакомился с авторской заметкой про regexp «UCP», представлю функцию модификации regexp-паттерна перед использования в регулярках:

function cp1251_to_utf8_pattern($pattern) {
if($pattern) $pattern .= ’u’;
$alpha = ’\p{L}’;
$w = ’\p{L}|\p{Nd}|_’;
$d = ’\p{Nd}’;
$W = ’^’.$w;
$D = ’^’.$d;
$map = array(
’\w’ => $w,
’\d’ => $d,
’\W’ => $W,
’\D’ => $D,
’^\W’ => $w,
’^\D’ => $d,
);
$pattern = _strtr($pattern, $map);
return $pattern;
}

Для простых регулярок такого преобразование будет вполне достаточно, проверял на некоторых образцах. Как поведет себя регулярка при комбинации различных заменяемых паттернов — вопрос не из легких.

Полезные ссылки:
http://habrahabr.ru/blogs/php/45910/
http://test.dis.dj/utf/
http://www.regular-expressions.info/unicode.html

Евгений Степанищев (bolknote.ru) 2010

Комментарий для Alex Karpinskiy:

  1. выставление локали setLocale(LC_ALL, ’ru_RU.UTF-8’);

Мои эксперименты показывают, что это ни на что не влияет.

Есть смысл оформить этот список функций как статические методы класса, но тогда нужно править также те участки кода, которые используют callback вызов строковых функций.

Их в любом случае нужно править — функции это или статические методы.

Примерный список функций, требующих адаптации

Часть этих функций не требует адаптации. Например, str_replace, str_repeat, strtr и так далее. Посмотрите мой список.

представлю функцию модификации regexp-паттерна перед использования в регулярках

Так делать нельзя. Ваша модификация разрушит шаблон вида [\w\r\n] и тому подробные.

Для простых регулярок такого преобразование будет вполне достаточно, проверял на некоторых образцах

В ВакоВики, форк которого мы используем, регулярные выражения очень сложный и их много. В любом случае, в проекте, охватить который взглядом я не в силах (и оттестировать полностью — тоже) я бы не рискнул использовать такие замены.

Полезные ссылки

Не пользуйтесь суррогатами, есть документация по PCRE: http://www.pcre.org/pcre.txt

Евгений Степанищев (bolknote.ru) 2010

Комментарий для Alex Karpinskiy:

strtr всё-таки требует адаптации, в случае использования с тремя параметрами.

Евгений Степанищев (bolknote.ru) 2010

Комментарий для Alex Karpinskiy:

Нет, оказывается выставление локали в UTF-8 всё-таки влияет на сортировку с ключём SORT_LOCALE_STRING.

Евгений Степанищев (bolknote.ru) 2010

Правда, для мультиязычной среды не подходит — нужно указывать явным образом язык.

Alex Karpinskiy 2010

Локаль желательно выставлять в части инициализации для правильной работы регулярных выражений. Был такой случай, когда на сервере не была установлена локаль CP1251. В результате в проекте не ловились кириллические символы в данной кодировке. И на порядок сортировки влияет.
По поводу замены символов в регулярках — Вы правы: в сложных комбинациях простой замены будет недостаточно. Выход один? PCRE_UCP?

Alex Karpinskiy 2010

Евгений, в Вашей серии статей про перевод проекта на UTF-8 не хватает перехода на следующую-предыдущую. Это существенно упростит навигацию между описанными этапами для ищущих грамотных решений для своих веб-проектов. В сети на данное время есть много форумов, много примеров и кусков кода, но нету полного руководства с последовательными шагами и примерами. Это реально поможет разработчикам сократить время поиска следующей статьи и больше времени уделить своим разработкам. Спасибо!

Евгений Степанищев (bolknote.ru) 2010

Комментарий для Alex Karpinskiy:

Локаль желательно выставлять в части инициализации для правильной работы регулярных выражений.

Я в свете UTF-8 имел ввиду.

По поводу замены символов в регулярках — Вы правы: в сложных комбинациях простой замены будет недостаточно. Выход один? PCRE_UCP?

Простой выход — такой. Но можно рассмотреть все вариаты и сделать правильные замены.

Евгений, в Вашей серии статей про перевод проекта на UTF-8 не хватает перехода на следующую-предыдущую

Она ещё не закончена. Потом сделаю, видимо.