company_banner

Практическое руководство по Unicode'изации



    Мы, наконец, это сделали! Долгое время позорное наследие CP1251 раздражало разработчиков, наводило на мысли о том, что, как же так? Эпоха Unicode уже давно наступила, а мы все еще используем однобайтовую кодировку и расставляем в разных местах костыли для совместимости с внешними системами. Но причина тому была достаточно рациональная: перевести на Unicode большой проект, в который развился Мой Мир, очень трудоемко. Мы оценивали это в полгода и не были готовы тратить столько ресурсов на фичу, которая не принесет русскоязычной аудитории существенной пользы.

    Но история вносит свои коррективы, зачастую весьма неожиданные. Не секрет, что в Казахстане весьма популярен проект Мой Мир, который является самой популярной социальной сетью в этой стране. И нам всегда хотелось, чтобы у наших казахских пользователей появилась возможность использовать символы казахского алфавита из расширенного кириллического набора, которым, к сожалению, не нашлось места в CP1251. И дополнительным стимулом для нас, позволившим, наконец, оправдать длительную разработку, стал дальнейший рост популярности проекта за пределами нашей страны. Мы поняли, что пора делать шаг навстречу зарубежным пользователям.

    Разумеется, первое, что было необходимо для интернационализации проекта, это начать принимать, передавать, обрабатывать и хранить данные в UTF-8. Процедура эта для большого проекта непростая и длительная, по пути нам пришлось решить несколько достаточно интересных задач, про которые мы постараемся рассказать.

    Перекодирование баз данных

    Первый выбор, с которым мы столкнулись, был достаточно стандартным — с какого конца начинать: от отображения страницы или от хранилищ данных. Решили начать с хранилищ по причине того, что это самый затяжной и трудоемкий процесс, требующий слаженных действий разработчиков и администраторов.

    Ситуацию осложняло еще и то, что в нашей социальной сети, из соображений быстродействия, используется большое количество разнообразных специализированных хранилищ. Разумеется, не все они содержали текстовые поля, подлежащие интернационализации, но все же их оказалось немало. И первое, что пришлось сделать — провести полную инвентаризацию всех наших хранилищ на предмет содержания в них текстовых строк. Стоит признать, что мы узнали много нового.

    MySQL

    Конвертацию хранилищ в UTF-8 мы начали с MySQL. Причиной этому послужило то, что, в общем-то, изменение кодировки этой базой поддерживается нативно. Но на практике все оказалось не так просто.

    Во-первых, необходимо было осуществить конвертацию базы без даунтайма на время конвертации.

    Во-вторых, выяснилось, что выполнить для всех таблиц alter table `my_table` convert to character set utf8; не рационально и, более того, невозможно. Не рационально потому, что индекс для UTF-8-поля всегда занимает 3 * length_in_characters байт, даже если поле содержит только ASCII-символы. А таких полей у нас оказалось немало, в том числе индексных, особенно таких, которые содержали hex-строки. Невозможно по причине того, что максимальная длина ключа индекса в MySQL 767 байт, и индексы (особенно многоколоночные) перестают помещаться. Помимо этого обнаружилось, что в текстовых полях кое-где по недосмотру хранятся бинарные данные и наоборот, и надо внимательно проверять каждое поле.

    После того, как мы собрали с наших баз данных информацию об имеющихся там таблицах, появилось понимание, что львиная доля их, скорее всего, не используется. Так оно в результате и оказалось, мы удалили из баз примерно половину всех имевшихся в них таблиц. Для того чтобы найти неиспользуемые таблицы мы применили следующую технику: при помощи tcpdump собрали все запросы к нашим базам за сутки, затем пересекли список таблиц из этого дампа с текущей схемой баз и на всякий случай поискали неиспользуемые таблицы по коду (заодно подчистили код). Tcpdump применили потому, что он, в отличие от записи всех запросов в лог средствами MySQL, не требует перезапуска базы и не оказывает влияния на скорость обработки запросов. Разумеется, сразу удалять таблицы было страшно, поэтому сначала просто переименовали таблицы со специальным суффиксом, выждали несколько недель и потом удалили (кстати, не зря перестраховались, парочку лишних малоиспользуемых зацепили по недосмотру, пришлось вернуть).

    Затем приступили собственно к написанию DDL для конвертации баз. Для этого использовалось несколько стандартных паттернов:
    • если в таблице не было текстовых полей, то (на всякий случай, вдруг когда-нибудь добавим) просто выполняли запрос: alter table `my_table` default character set utf8;
    • если в таблице были только varchar текстовые поля требующие интернационализации, то: alter table `my_table` convert to character set utf8;
    • поля, содержащие только ASCII-символы, конвертили в ASCII: alter table `my_table` modify `my_column` varchar(n) character set ascii …;
    • поля, требующие интернационализации, стандартно: alter table `my_table` modify `my_column` varchar(n) character set utf8 …;
    • но, для некоторых полей с уникальным индексом, из-за равенства в collation utf8_general_ci (в отличии от cp1251_general_ci) букв е и ё, пришлось костылить: alter table `my_table` modify `my_column` varchar(n) charater set utf8 collate utf8_bin …;
    • для индексных полей, которые после конвертации перестали влезать в индекс, тоже пришлось костылить: alter table `my_table` drop index `my_index`, modify `my_column` varchar(n) character set utf8 …, add index `my_index`(`my_column`(m)); (где m < n, а индекс, как правило, по нескольким полям);
    • текстовые поля, содержащие двоичные данные, переводили в binary и varbinary;
    • двоичные поля, содержащие текстовые строки в CP1251, конвертировали в два приема: alter table `my_table` modify `my_column` varchar(n) character set cp1251; alter table `my_table` modify `my_column` varchar(n) character set utf8; Это необходимо, чтобы первым запросом MySQL понял, что данные в кодировке cp1251, а вторым сконвертировал в utf8.
    • текстовые блобы пришлось обрабатывать отдельно, так как при convert to character set utf8 MySQL расширяет блоб до минимально необходимого, чтобы уместить текст максимальной длины, все символы которого трехбайтные. То есть text автоматически расширяется до mediumtext. Это не совсем то, чего мы хотели в ряде случаев, поэтому приводили явно: alter table `my_table` alter `my_column` text character set utf8;
    • и, разумеется, на будущее финальный аккорд: alter database `my_database` default character set utf8;

    Задачу конвертации базы в UTF-8 без даунтайма на время конвертации решили привычным для нас способом: через реплику. Но не обошлось без особенностей. Во-первых, для того чтобы строки автоматически конвертировались при догоне реплики из мастера необходимо, чтобы репликация обязательно была в режиме statement, в режиме raw конвертация не осуществляется. Во-вторых, чтобы перейти на statement репликацию также нужно поменять transaction isolation level с дефолтного repeatable read на read commited.

    Собственно конвертировали следующим образом:
    1. Переключаем мастер в режим statement-репликации.
    2. Поднимаем временную копию базы для конвертации, запускаем на ней конвертацию.
    3. По окончанию конвертации переводим копию в режим реплики от основной базы, данные догоняются, строки на лету также конвертируются.
    4. Для каждой реплики базы:
      — переводим нагрузку с реплики на временную реплику в UTF-8;
      — переливаем все реплики с нуля из временной базы, включаем репликацию с нее;
      — возвращаем нагрузку обратно на реплику.
    5. Переводим временную базу в режим мастера, перебрасываем запросы со старого мастера на временный при помощи NAT.
    6. Старый мастер переливаем из временной базы, догоняем репликацией.
    7. Переключаем мастер обратно, убираем NAT, возвращаем репликацию обратно в mixed.
    8. Отключаем временную базу.

    В итоге за три месяца кропотливой работы нам удалось сконвертировать все 98 мастеров (плюс куча реплик) с пятнадцатью разнообразными схемами баз (одна особенно большая база на 750Гб конвертировалась почти две недели машинного времени). Админы плакали, не спали по ночам (иногда не давали спать и разработчикам), но процесс, не так быстро как нам хотелось, все же шел. Изначально хотели как лучше и проводили конвертацию по вышеприведенной схеме, для ускорения процесса использовали машины с SSD-дисками. Но под конец третьего месяца, осознавая, что при таком раскладе понадобится еще месяца два работы, не выдержали, перекинули всю нагрузку с реплик на мастеры и стали конвертировать прямо на старых репликах. К счастью, никаких нештатных ситуаций за это время на мастерах не возникло, и за неделю (в основном, потому что реплики крутились на довольно-таки слабеньких старых тачках) конвертация завершилась.

    Помимо конвертации собственно баз, понадобилось также впилить поддержку UTF-8 в коде, а также обеспечить плавный и незаметный переход. С MySQL тут все, правда, просто. Дело в том, что у него есть отдельно кодировка, в которой он хранит данные, и отдельно кодировка, в которой он данные отдает клиенту. Исторически на серверах у нас было прописано, что character_set_* = cp1251. Для параметров character_set_client, character_set_connection, character_set_results мы ничего менять не стали, чтобы не ломать старые клиенты, и оставили cp1251. Остальные заменили на utf8. В итоге старые клиенты, работающие в cp1251, по-прежнему получают данные в cp1251, независимо от того, отконвертирована база или нет, а новые, работающие в UTF-8, после установления соединения сразу же выполняют команду set names utf8; и начинают пользоваться всеми благами этой кодировки.

    Tarantool

    Что такое тарантул, думаю, уже можно не рассказывать. Это детище Моего Мира уже обрело достаточную известность и выросло в хороший open source проект.

    Мы за годы его использования ухитрились накопить в нем огромное количество информации, и когда обнаружилось, что инстансов тарантула у нас под 400 штук, честно говоря, стало страшно, что конвертация затянется надолго. Но, к счастью для нас, выяснилось, что текстовые поля есть только в 60 из них (в основном это пользовательские профили).

    Стоит признать, что перекодировать тарантулы оказалось действительно интересной задачей. И решение получилось достаточно элегантное. Но, разумеется, не совсем из коробки. Сразу же оговорюсь, что исторически так получилось, что после того, как tarantool стал развиваться как open source проект, выяснилось, что потребности сообщества и наши немного не совпадают. Сообществу нужен понятный продукт, key-value хранилище, которое работает из коробки, нам же нужен продукт с модульной архитектурой (фрейморк для написания хранилищ), дополнительными узкоспециализированными возможностями, оптимизациями производительности. Поэтому где-то мы продолжали использовать tarantool, а где-то стали использовать его форк octopus, который развивается силами автора самого первого тарантула. И это нам сильно упростило процесс конвертации. Дело в том, что в октопусе есть возможность писать репликационные фильтры на lua, то есть передавать не оригинальные команды из снапшота и xlog'а мастера, а прошедшие модификацию при помощи lua-функции. Эту возможность когда-то давно добавляли для того, чтобы была возможность поднимать частичные реплики, содержащие не все данные из мастера, а только определенные поля tuple'а. И у нас возникла мысль, что аналогичным образом мы можем в процессе репликации на лету перекодировать тексты.

    И все же октопус для этой задачи пришлось чуть допилить: хотя feeder (процесс мастера, скармливающий xlog'и реплике) уже долгое время был реализован в виде отдельного модуля октопуса mod_feeder, он все же не мог запускаться отдельно без хранилища (в данном случае key-value, реализуемого модулем mod_box), а это было необходимо, чтобы изменения в механизме репликации не требовали перезапуска мастера. Ну и, разумеется, пришлось написать репликационные фильтры на lua, которые для каждого namespace'а конвертировали нужные поля из CP1251 в UTF-8.

    Помимо, собственно, конвертации данных в тарантулах и октопусах, необходимо было обеспечить прозрачную работу кода с их шардами, которые уже отконвертированы и еще нет, а также обеспечить атомарное переключение с работы в CP1251 на работу в UTF-8. Поэтому было решено поставить перед хранилищами специальную перекодирующую прокси, которая, в зависимости от флажка в запросе клиента, конвертировала данные из кодировки базы в кодировку клиента. Тут нам на помощь опять пришел октопус, а вернее его модуль mod_colander, который позволяет писать быстрые прокси-серверы, в том числе на lua (так как octopus использует luajit и ffi, то получается действительно производительно).

    Итого, схема конвертации tarantool/octopus в UTF-8 получилась следующая:
    1. Настраиваем utf8proxy на мастере и репликах. Поднимаем его на том порту, который до этого слушал тарантул, сам тарантул перевешиваем на другой порт. Начиная с этого момента, клиенты могут выполнять запросы как в CP1251, так и в UTF-8.
    2. На сервере с мастером запускаем переконвертирующий utf8feeder, настраиваем его читать snapshot'ы и xlog'и из тех же директорий, куда их пишет мастер.
    3. На другом сервере в сторонке поднимаем временную реплику мастера, настраиваем ее реплицироваться с переконвертирующего фидера. Во временную реплику данные уже будут приезжать в кодировке UTF-8.
    4. utf8proxy реплики настраиваем реплицироваться из временной реплики, старую реплику переливаем из временной, затем возвращаем нагрузку обратно.
    5. Файерволим порт на utf8proxy мастера (чтобы не было коллизий на апдейтах), utf8proxy перенастраиваем на временную реплику, временную реплику делаем временным мастером, старый мастер тушим, расфайерволиваем порт на utf8proxy.
    6. Переливаем новый мастер из временного, переключаем реплики на репликацию из него.
    7. Делаем новый мастер мастером при помощи utf8proxy, временный мастер выключаем. На этом шаге все инстансы содержат данные в UTF-8, можно начинать писать с клиентов некириллические тексты.
    8. После перехода всех клиентов на UTF-8 вынимаем utf8proxy.




    Весь процесс перекодирования тарантулов/октопусов занял примерно месяц. К сожалению, не обошлось без накладок: так как конвертировали по несколько шардов параллельно, ухитрились при переключении мастеров обратно перепутать два шарда местами. К тому времени, как проблема обнаружилось, уже произошло существенное количество изменений данных. Пришлось анализировать xlog'и с обоих шардов и восстанавливать справедливость.

    Memcached

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

    Поэтому мы пошли по пути помечания каждого ключа флагом о том, в какой кодировке он хранится. Тем более что перловый клиент к мемкешу Cache::Memcached::Fast уже имеет эту возможность: при сохранении строки в мемкеше в одном из флагов ключа (F_UTF8 = 0x4) он записывает внутренний перловый флаг строки SVf_UTF8, который установлен, если строка содержит мультибайтовые символы. Таким образом, если флаг стоит, то строка однозначно в UTF-8, если не стоит, то все чуть сложнее: это строка либо текстовая в CP1251, либо бинарная. Текстовые строки, понятное дело, мы переконвертируем при необходимости, а вот с бинарными возникла сложность: чтобы не ломать их ненужной конвертацией, пришлось разделить методы set/get (и пр.) для текстовых строк и бинарных, найти все сохранения бинарных строк в мемкэшед и их получения, и заменить соответствующими методами без автоматического перекодирования. В сишном коде поступили аналогично и добавили поддержку флага F_UTF8.

    Прочие самописные хранилища

    Помимо вышеупомянутых стандартных хранилищ, у нас используется огромное количество самописных хранилищ, используемых для хранения ленты «что нового», комментариев, очередей сообщений, диалогов, поиска и прочего. Подробнее останавливаться на каждом из них не будем, отметим лишь основные случаи и способы их решения.
    1. Хранилище сложно переконвертировать без даунтайма, либо скоро планируем перелить данные в новое хранилище, либо данные с коротким сроком жизни. В таких случаях данные не конвертировали, а помечали новые записи признаком кодировки одним из двух способов: либо при помощи флага, указывающего в какой кодировке находится вся запись, либо при помощи BOM-маркера в начале каждого строкового поля, если оно в UTF-8.
    2. В хранилище хранятся не сами строки, а хэш-сумма от них. Используется у нас для поиска. Тут просто прошли по всему хранилищу скриптом, который перегенирировал хэш-суммы от оригинальных строк, переконвертированных в UTF-8. На момент переконвертации пришлось на каждый поисковый запрос выполнять два запроса к базе: один в CP1251, другой в UTF-8.
    3. Перед хранилищем уже установлена прокси, и все запросы в хранилище идут через нее. В этом случае реализовывали переконвертацию в прокси, аналогично тому, как это было сделано для тарантула, с той лишь разницей, что если для тарантула это временный функционал, то в данном случае он останется до тех пор, пока будут актуальны данные, хранящиеся в базе.

    Поддержка UTF-8 в коде

    Параллельно с тем, как наши администраторы конвертировали базы данных, разработчики адаптировали код к работе с кодировкой UTF-8. Вся наша кодовая база условно делится на три части: Perl, C и шаблоны.

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

    Perl и UTF-8

    Для адаптации перлового кода для работы с UTF-8 потребовалось решить несколько основных задач:
    • переконвертировать кириллические строки, разбросанные по коду;
    • учитывать кодировку сервера при установке соединения ко всем хранилищам и сервисам;
    • учитывать, что параметры HTTP-запросов могут приходить не в той кодировке, в которой работает сервер;
    • необходимо отдавать контент в кодировке сервера и использовать правильные шаблоны;
    • необходимо однозначно логически разделять байтовые строки и символьные, декодировать UTF-8 (из байтов в символы) на входе и кодировать в него на выходе.

    Задачу конвертации перлового кода из CP1251 в UTF-8 мы решили несколько нетривиальным способом: начали с конвертации модулей на лету при компиляции с применением фильтров (см. perlfilter и Filter::Util::Call, перл позволяет модифицировать исходные коды в промежутке между чтением с диска и компиляцией). Это понадобилось для того, чтобы избежать множественных конфликтов при мердже веток репозитория, которые возникли бы в том случае, если бы мы попытались сконвертировать репо в одной отдельно взятой ветке и держать ее в сторонке во время процесса разработки и тестирования. Весь процесс тестирования и первую неделю после запуска исходные коды продолжали оставаться в CP1251 и конвертировались прямо на боевых серверах при запуске демонов, если сервер был сконфигурирован как UTF-8. Через неделю после запуска мы сконвертировали репозиторий и сразу же вмерджили результат в master. В итоге, конфликты при мердже возникали всего лишь для тех веток, которые были в разработке в этот момент времени.

    Самым рутинным был процесс добавления во все необходимые места автоматической конвертации строк для хранилищ, которые мы не стали конвертировать в UTF-8 целиком. Но даже и в тех случаях, когда конвертация строк в перле была не нужна, все же приходилось учитывать то, что в перле есть разница (и существенная) между байтовыми и символьными строками. Разумеется, нам хотелось, чтобы все текстовые строки автоматически становились символьными после прочтения из базы, что потребовало анализа всего ввода/вывода на предмет того двоичные данные передаются или текстовые, пришлось пройтись по всем вызовам pack/unpack, чтобы после распаковки пометить все нужные строки как символьные (либо перед упаковкой наоборот сделать строку байтовой, чтобы длина считалась в байтах, а не символах).

    Проблему того, что параметры HTTP-запроса могут прийти либо в CP1251, либо в UTF-8 (в зависимости от того, в какой кодировке была загружена referer-страница) сначала хотели решить при помощи передачи дополнительного параметра в запросе. Но потом, проанализировав то, как кодируется CP1251 и UTF-8 пришли к выводу, что всегда можем однозначно отличать кириллицу в CP1251 от кириллицы в UTF-8 путем проверки строки на то, является ли она валидным UTF-8 (только из русских букв в CP1251 практически невозможно составить валидный UTF-8).

    В целом же, то, как организована работа с UTF-8 в перле хотя и достаточно удобно, но все-таки зачастую магично, и следует учитывать, что надо:
    • забыть про то, что у строк есть флаг SVf_UTF8 (он полезен только при отладке), вместо этого лучше относиться к строкам как к байтовым и символьным, забыть про то, что внутреннее представление перловой строки с флагом SVf_UTF8 — UTF-8;
    • забыть про функции Encode::_utf8_on(), Encode::_utf8_off(), utf8::upgrade(), utf8::downgrade(), utf8::is_utf8(), utf8::valid();
    • использовать utf8::encode() при конвертации символьной Unicode-строки в UTF-8;
    • учитывать, что для перла кодировка UTF-8 и utf8 — это слегка разные кодировки: для первой валидны только code point <= 0x10FFFF (как и определено стандартом Unicode), а для второй — любые IV (int32 или int64 в зависимости от архитектуры), закодированные по алгоритму кодирования UTF-8;
    • в соответствии с этим, utf8::decode() можно применять только для декодирования из доверенных источников (свои БД), в которых не может быть невалидного UTF-8, а при декодировании внешнего ввода всегда применять Encode::decode('UTF-8', $_), чтобы защититься от невалидных, с точки зрения Unicode, code point'ов;
    • не забывать, что результат функции utf8::decode() иногда полезно проверять, чтобы понять была ли байтовая строка валидным utf8, для аналогичных целей проверки на валидный UTF-8 можно использовать третий параметр в Encode::decode();
    • учитывать, что верхняя половина таблицы latin1 содержит те же самые символы, что и Unicode code point'ы с теми же номерами, но при этом в UTF-8 они будет кодироваться по-другому. Это сказывается на результате ошибочного двойного вызова utf8::decode(): для строк содержащих только code point из таблицы ASCII или содержащих хотя бы один символ с code point > 0xff все будет в порядке, а вот если строка содержит только символы с code point из верхней половины таблицы latin1 и ascii, то символы из latin1 побьются.
    • использовать перл последних версий. На perl 5.8.8 мы наступили на замечательный баг: сочетание use locale и некоторых регулярных выражений при правильных входных данных приводит к бесконечному зацикливанию регулярки. Пришлось максимально ограничить scope применения use locale только для строго необходимого набора функций: sort, cmp, lt, le, gt, ge, lc, uc, lcfirst, ucfirst.

    C и UTF-8

    В нашем коде на C, к счастью, оказалось не так много строк, как в перле, поэтому пошли по классическому пути: вынесли все кириллические строки в отдельный файл. Это позволило ограничить потенциальные конфликты при мерджах в рамках одного файла, а также упростило последующую локализацию. В процессе конвертации репо в UTF-8 обнаружили забавное — русскоязычные комментарии в коде были во всех 4 кириллических кодировках: cp1251, cp866, koi8-r и iso8859-5. Пришлось при конвертации использовать автоопределение кодировки каждой конкретной строки.

    Помимо конвертации репо, в C также нужна была поддержка базовых строковых функций: определение длины в символах, приведение регистров, обрезание строки по длине, и пр. Для работы с Unicode в C есть замечательная библиотечка libicu, но у нее есть определенное неудобство: она в качестве внутреннего представления использует UTF-16. Разумеется, нам хотелось избежать накладных расходов на перекодирование между UTF-8 и UTF-16, поэтому для наиболее часто используемых несложных функций пришлось реализовать аналоги, работающие непосредственно с UTF-8 без перекодирования.

    Шаблоны, javascript и UTF-8

    С шаблонами, к счастью, все оказалось достаточно просто. На production они раскладываются rpm-пакетами, поэтому логичным решением было впилить перекодирование в процесс сборки rpm. Мы добавили еще один пакет с шаблонами в UTF-8, которые устанавливались в соседнюю директорию, а код (и перловый и сишный) после этого просто выбирал шаблон из соответствующей директории.

    С javascript же из коробки не получилось. Большинство браузеров при загрузке javascript учитывают его Content-Type, но все же есть отдельные старые экземпляры, которые этого не делают, а ориентируются на кодировку страницы. Поэтому поставили костыль: при сборке пакета с javascript мы заменяли все не-ASCII-символы на их escape-последовательности в виде номеров code point'ов. При таком подходе размер js увеличивается, но зато любой браузер загружает его корректно.

    Что в итоге

    В итоге, по прошествии шести месяцев пасьянс сошелся. Админы как раз закончили перекодировать пару сотен баз, разработчики допилили код, процесс тестирования тоже завершился. Мы постепенно переключили ручки на панели управления Миром: сначала в UTF-8 перевели все аккаунты наших коллег, затем одного процента пользователей, после чего стали по 10 серверов переключать backend-сервера и, в конце, frontend'ы. Визуально ничего не менялось, ни страницы проекта, ни графики нагрузки, что не могла не радовать. Единственное внешние изменение, по которому было понятно, что полгода прошли недаром, — это изменение в Content-Type строчки charset=windows-1251 на charset=UTF-8.

    С тех пор прошло еще три месяца, наши русскоязычные пользователи уже оценили возможность вставлять в текст emoji и прочие рюшечки, а казахские начали переписываться на родном языке и, с недавнего времени, у них появилась возможность пользоваться web-интерфейсом и мобильными приложениями на родном языке. Интересных задач в последовавшем за unicode'изацией процессе интернационализации и локализации проекта тоже хватало, мы постараемся посвятить этому отдельную статью.
    Mail.ru Group
    1234.33
    Строим Интернет
    Share post

    Comments 19

      +4
      Не перехожу на критику, поскольку не знаю внутренней кухни, однако всё же спрошу — что мешало сделать на Unicode изначально? Думается, было бы проще все сделать по-человечески с самого начала. Или же для этого были свои причины?
        +6
        Причина, думаю, у большинства проектов, использующих морально устаревшие кодировки, одна: рождение проекта (кодовой базы) в те времена, когда использование многобайтовых кодировок требовало слишком много усилий, а эффект от этого в пределах Рунета бал минимальный. У нас в коде до сих пор встречаются строки, датированные 2000 годом :)
          0
          Поправьте меня, если я ошибаюсь, но мне казалось, что МойМир зародился где-то в районе 2005-2006 годов — по меньшей мере, я не помню, чтобы он существовал сильно раньше этих дат. Или же он просто разрабатывался всё это время, начиная с самого начала нулевых?
            +6
            Не ошибаетесь, проекту Мой Мир и правда 7 лет. Но изначально он вылился из Почты в виде проектов Блоги, Фото и Видео, который использовали кодовую базу почты (и часть БД тоже). Потом из Блогов образовался Мир, а также произошло его слияние с Фото и Видео.
        +3
        Невозможно по причине того, что максимальная длина ключа индекса в MySQL 767 байт, и индексы (особенно многоколоночные) перестают помещаться.

        С версии 5.5.37 (кажется) можно указать в конфигурации:
        innodb_large_prefix = ON
        innodb_file_per_table = ON
        innodb_file_format = Barracuda
        

        Это позволяет хранить в индексе до 3072 байт

        Кодировка utf8 в MySQL не умеет хранить 4-х байтные символы unicode, а, например, стандратные смайлы на стандартной клавиатуре в андроиде, успешно их отправляют пачками. Выход — перейти на кодировку utf8mb4 (поддерживается с версии 5.5.3)

        Подробнее о проблеме можно почитать тут code.djangoproject.com/ticket/18392#comment:16
          +3
          Насчет увеличения длины ключа индекса думали, но решили, что не хотим увеличивать без лишней необходимости: все-таки индекс всегда съедает память по максимальному варианту. Пришлось, конечно, повозиться и убедиться в том, что наши строки уникальны на выбранную длину, и конфликтов на уникальных индексах не возникает.
          Что касается utf8mb4, согласен, это неприятная проблема. Когда выбирали между utf8mb3 и utf8mb4, мы посчитали, что можем пожертвовать редкими языками в пользу экономии памяти в индексах. Но, к сожалению, не подумали про emoji. Хорошо, что в MySQL у нас почти что нет данных, в которых применение emoji было бы особенно критично (диалоги, лента, комментарии хранятся в специализированных хранилищах). Спасибо, что обратили на это внимание, мы проведем исследование на тему того, насколько это может быть критично для нас, и, возможно, придется принять меры.
            0
            что в MySQL у нас почти что нет данных, в которых применение emoji было бы особенно критично

            Да да, а потом оно там внезапно появляется и это в зависимости от архитектуры приложения может привести к чему угодно
              0
              В MySQL есть возможность указывать используемую кодировку для каждой конкретной колонки, поэтому для совсем новых данных это решается очень просто:
              alter table `my_table` add column `my_column` varchar(n) character set utf8mb4;
              

              (и, разумеется, set names utf8mb4 вместо set names utf8)
              Если вдруг символы с четырехбайтовым представлением в UTF-8 появляются в колонке, которая определена как utf8mb3, то такой символ просто заменяется на знак вопроса (при условии set names utf8mb4, если используется utf8mb3, то строка внезапно обрежется по такому символу).
          +1
          Теперь понятно почему в аське от многих контактов не приходят смайлы а рисуются квадратики… конечно, оф.клиент их поддерживает а все остальные в очередной раз выброшены заборт.
            +1
            Это один из вариантов. Второй, вероятный вариант, использование смайликов, которые появились в последних версиях Unicode (7.0.0, например, добавляет приличное их количество) и которых нету в установленных на системе шрифтах. У меня, например, половина таблиц на странице en.wikipedia.org/wiki/Emoji в квадратиках. Скорее всего это решается установкой/обновлением пакетов с шрифтами.
              0
              Но тогда это будут черно-белые смайлы-символы, причем такие какими их нарисовал разработчик шрифта. Куда там анимированные или специфические кастомные смайлы…

              Печально. Перешел по ссылке, только 3 таблицы в смайлах и те не до конца…
                0
                Красочные смайлики технически тоже шрифты. Я так понимаю, что у этих шрифтов больше вес для диапазонов с пиктограммками, как результат они используются вместо черно-белых.
              0
              Квадратики рисуются скорее всего от того, что в шрифте, которым они отображаются, нет нужных символов.
              –12
              Это как раз тот случай, когда несколько первых фраз вызывают сильнейшее желание немедленно прикрыть ладонью лицо.
                +6
                Вопрос про Unicode и UTF-8 в Perl'е не может обойтись без этого ответа. :-)
                  0
                  Не понял я про Perl часть, как всёже всё произошло.

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

                  Было в CP1251:
                  open my $f, ">", "report.file";
                  print $f from_to($data, 'cp1251', 'utf8');
                  close $f;
                  


                  Нужно после перехода на Unicode:

                  open my $f, ">:encoding(UTF-8)", "report.file";
                  print $f $data;
                  close $f;
                  


                  в какокй момент один исходник поменялся на другой?
                  (это просто пример с записью в файл, может быть любая другая операция, md5 от строки с русскими буквами, вызов ord, сериализация в какой-нибудь формат)

                    +3
                    Совсем ничего не делать в таком случае не получилось. Мы завели функцию, которая в зависимости от конфигурации сервера (cp1251 или UTF-8) принимала решение о том, как именно перекодировать. Заменили все такие явные преобразования в коде (к счастью, их было не так много, в пределах 200, — это посильная задача), и только после этого стали переключать.
                    open my $f, ">", "report.file";
                    my::utf8::encode_any($data, 1);
                    print $f $data;
                    close $f;
                    

                    И ниже, собственно как эти функции реализованы (на самом деле их десяток на все случаи жизни). Сразу оговорюсь по стилю: отсутствие копирования входных параметров и оператора return — принесено в жертву производительности — это разумно для однострочных частовызываемых функций; модификация аргумента in-place и возвращаемый значения — для совместимости с utf8::encode/decode, в будущем это позволит удалить костылики почти не задумываясь.
                    sub enabled () { $utf8_enabled }
                    
                    my $UTF8_ENC = Encode::find_encoding('UTF-8'); # Use strict version of UTF-8 to prevent invalid data
                    my $CP1251_ENC = Encode::find_encoding('cp1251');
                    
                    if (enabled) {
                        *encode_any = sub ($$) { $_[0] = Encode::encode($_[1] ? $UTF8_ENC : $CP1251_ENC, $_[0]); () };
                        *decode_any = sub ($$) { $_[0] = Encode::decode($_[1] ? $UTF8_ENC : $CP1251_ENC, $_[0]); 1 };
                    } else {
                        *encode_any = sub ($$) { Encode::from_to($_[0], $CP1251_ENC, $UTF8_ENC) if $_[1]; () };
                        *decode_any = sub ($$) { Encode::from_to($_[0], $UTF8_ENC, $CP1251_ENC) if $_[1]; 1 };
                    }
                    
                    
                      +1
                      Очень интересно. Мы тоже переходим на unicode, и у нас Perl часть, похоже, точно такая же как у вас
                      (начиная с sub enabled () { $utf8_enabled }). Есть моё выступление на YAPC Russia про этот переход youtu.be/43vDtaKl71c ( и это единственное место
                      где описание процесса лежит в паблике, больше дать нечего)

                      У нас как раз таких явных преобразований (которых у вас в перделах 200) много. Каждый исходник отдельно переводится «на юникод»
                      т.е. добавляются эти преобразования (при этом сам код конвертируется в UTF-8, ставится use utf8, и любую строковую константу
                      тоже нужно обработать такими преобразованиями, перед любым не-ASCII регэкспом их тоже нужно выполнить над исходными данными).

                      Получается перед любым вызовом внешнего модуля нужно решать нужно ли такие преобразование или нет.
                      К ним относятся JSON/YAML/итд модули, md5/sha хэши и пр, модули ввода-вывода (т.е. там где текст превращается в бинарные данные):
                      LWP/другие http(s) библиотеки, любые библиотеки по работе с протоколами, либые print, say, syswrite, pack и пр.

                      Хранилища впринципе тоже — memcached,redis.
                      Нам тоже пришлось разделить данные на бинарные и текстовые перед работой с ними, т.к. используем больше Redis, и там нет такой фичи, как в Memcached.

                      Вот меня и удивляет что у вас таки преобразований меньше.
                      Как удалось обойтись только 200 местами, может специфика работы кода не предполагает обилие конвертации форматов, регэкспов и ввода-вывода?

                      Ещё, если не секрет, сколько строчек кода и сколько человек в течение какого срока занимались Perl частью?
                        +1
                        Каждый исходник отдельно переводится «на юникод» т.е. добавляются эти преобразования (при этом сам код конвертируется в UTF-8, ставится use utf8, и любую строковую константу тоже нужно обработать такими преобразованиями, перед любым не-ASCII регэкспом их тоже нужно выполнить над исходными данными).

                        Именно чтобы избежать этого мы и использовали перловые фильтры. Более того, забыл про это написать, мы вместо use utf8 использовали use my::utf8. Особенность нашей прагмы как раз в том, что она применяется условно, в зависимости от той самой константы enabled. Чтобы не быть голословным, приведу еще кусочек кода из этого модуля на эту тему (source_in_utf8 меняется с 0 на 1 при конвертации git-репо, это делается уже после переключения на utf8):
                        package my::utf8;
                        
                        use Filter::Util::Call;
                        
                        sub source_in_utf8 () { 0 }
                        sub enabled () { $utf8_enabled }
                        
                        my %FILTERING;
                        my $UTF8_ENC = Encode::find_encoding('UTF-8'); # Use strict version of UTF-8 to prevent invalid data
                        my $CP1251_ENC = Encode::find_encoding('cp1251');
                        
                        sub import {
                            my ($class, %args) = @_;
                            my $filename = ($args{level} ? caller($args{level}) : caller())[1];
                            if (source_in_utf8 && enabled) {
                                goto &utf8::import;
                            } elsif (source_in_utf8) {
                                unless ($FILTERING{$filename}) {
                                    filter_add(sub {
                                        my $status = filter_read();
                                        Encode::from_to($_, $UTF8_ENC, $CP1251_ENC) if $status > 0;
                                        return $status;
                                    });
                                    $FILTERING{$filename} = 1;
                                }
                            } elsif (enabled) {
                                unless ($FILTERING{$filename}) {
                                    filter_add(sub {
                                        my $status = filter_read();
                                        Encode::from_to($_, $CP1251_ENC, $UTF8_ENC) if $status > 0;
                                        return $status;
                                    });
                                    $FILTERING{$filename} = 1;
                                }
                                goto &utf8::import;
                            }
                            return;
                        }
                        
                        sub unimport {
                            my ($class, %args) = @_;
                            my $filename = ($args{level} ? caller($args{level}) : caller())[1];
                            if (source_in_utf8 && enabled) {
                                goto &utf8::unimport;
                            } elsif (source_in_utf8) {
                                filter_del();
                                delete $FILTERING{$filename};
                            } elsif (enabled) {
                                filter_del();
                                delete $FILTERING{$filename};
                                goto &utf8::unimport;
                            }
                            return;
                        }
                        


                        Вот меня и удивляет что у вас таки преобразований меньше.
                        Как удалось обойтись только 200 местами, может специфика работы кода не предполагает обилие конвертации форматов, регэкспов и ввода-вывода?

                        К счастью, у нас весь ввод/вывод большей частью сосредоточен в нескольких местах: ввод/вывод веб-сервера (разбор get/post параметров, шаблонизатор, аяксы, апишка), клиентики к хранилищам, взаимодействие с внешними системами.

                        сколько человек в течение какого срока занимались Perl частью?

                        Собственно perl-часть заняла не так много времени, если посчитать время всех задействованных разработчиков, то вряд ли больше шести-восьми человеконедель. Точно сложно сказать, так как чисто перловых задач было всего несколько, а большинство правок было связано с необходимостью обеспечить плавное и постепенное переключение хранилищ в другую кодировку.

                  Only users with full accounts can post comments. Log in, please.